feat: add system widgets and a persistent native Console with bare assignments
Verify Tessera GUI / verify (push) Failing after 7m17s

This commit is contained in:
2026-10-20 18:36:48 +08:00
parent 1d4b1d3c86
commit 53e8d7b287
15 changed files with 1549 additions and 245 deletions
+62 -22
View File
@@ -139,7 +139,8 @@ preallocates 4096 real UI node objects plus a separate 64-page pool with bound
render callbacks per candidate. Each constructed node is initialized once.
Constructors return those borrowed handles; Aster retains call results under its
existing rules. Native child data owns its own lifetime and contains no managed
VM edges. This bounded pool is for snapshot construction, not a persistent dynamic UI.
VM edges. Each complete PUT gets a new pool. Console retains its bounded pool until reset;
it still publishes static native snapshots when render is called.
Limits are 1 MiB source, 10000 visited AST nodes with a 64-level visitor depth,
4096 constructed UI nodes, 64 pages, 64 UI levels, 10000 expanded layout visits
@@ -163,8 +164,8 @@ rejected queue entries do not invalidate accepted work. Failed builds do not
increment the committed revision. A worker-caught panic discards the entire
candidate and returns 500, retaining the previous document.
This stage supports static snapshot UI. Event callbacks, persistent running VMs,
window-size language inputs and process isolation are future work. Worker threads,
This stage supports static snapshot UI and one live Console VM on the existing worker.
Event callbacks, window-size language inputs and process isolation are future work. Worker threads,
source budgets and VM instruction limits are not a complete untrusted-code sandbox.
## Developer tools
@@ -177,27 +178,57 @@ area has independent vertical and horizontal scrolling, draggable scrollbar thum
and trackpad support. Shift-wheel scrolls horizontally. Layout uses logical pixels
and the actual display scale; resize clamps pane sizes to keep controls reachable.
Console is the default execution transcript. Type an expression, press Enter to
run, or Shift-Enter for a newline. Switch to Script mode to PUT a **complete source
replacement** that renders a page. Arrow keys, Home/End, grapheme-safe deletion,
selection of all input, macOS copy/cut/paste and IME composition are supported.
Up/Down recall single-line history; Command/Control-Up/Down also recall history in
multiline input. Clicking the editor positions the caret. HTTP requests from other
clients appear in the same transcript with input, result, error and diagnostics.
Clear hides the displayed transcript; history stays in memory for this window.
Transport failures are shown beside the editor and restore an otherwise empty draft.
Console is one live execution context: statements, function definitions and
expressions use the same VM and globals dictionary across submissions. There is no
Expression/Script selector. Enter runs the input; Shift-Enter inserts a newline.
Create objects with bare assignment or `let`, then render them when ready:
Expression mode sends `POST /v1/namespaces/app.main/evaluate` as `text/plain`.
It accepts exactly one Aster expression and returns JSON with `value`,
`execution: "isolated-expression"` and `persistent: false`. It uses a disposable VM,
without `ui` or previous bindings, and never changes the page or committed revision.
For example, `"Hello Endor"` and `42` return values. Language operations unsupported
by the pinned Aster compiler (currently including numeric arithmetic bytecode)
produce actual diagnostics. Evaluation shares the eight-entry execution queue.
**Persistent REPL sessions are not connected.** Console history is a transcript,
not VM state; Tessera never replays old input to simulate persistence.
```endor
title = ui.text("你好,Tessera", 2, "#243047")
```
Sources shows the full last successful source snapshot with line numbers, Aster's
At this point the object exists in Console memory and the page stays unchanged.
Later inputs can refer to it:
```endor
note = ui.text("这是 Endor 渲染的页面", 1, "#637082")
card = ui.rect(320, 100, 16, "#DCE8FF")
page = ui.page()
page.render(ui.column(24, 16, title, note, card))
```
Bare assignment creates/updates a name; `let` remains a declaration. Aster owns this
syntax, AST and bytecode; Tessera never inserts `let` or replays history. The actual
last value is also bound to `_`. Text editing supports cursor positioning, arrow
keys, Home/End, grapheme-safe deletion, select-all, macOS copy/cut/paste and IME.
Up/Down recall single-line history; Command/Control-Up/Down recall multiline history.
Console sends `POST /v1/namespaces/app.main/repl` as UTF-8 text/plain. Responses
include value, session/input IDs, bindings, `persistent: true`, rendered flag and
revision. Object creation alone never publishes a page or advances revision.
`page.render` publishes the last render only after the input succeeds. Syntax and
compilation errors run no statements; runtime errors can retain earlier successful
statements from that input, while preserving the previously displayed page.
Reset session (`DELETE .../repl`) drops the VM and all bindings/objects, preserving
the displayed native snapshot. Clear only hides the transcript. Limits per session
are 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes, 4096 UI nodes
and 64 pages; reset reclaims the whole pool. Code and objects are never stored on disk.
Existing `PUT .../code` remains a complete program replacement with a fresh VM;
function-only source still invokes main. Accepted PUT/code DELETE invalidate the
Console context and older queued Console inputs. The compatibility `POST .../evaluate`
endpoint remains isolated for existing clients, but is not a Console mode. All use
the bounded eight-entry worker queue. GET /code returns the displayed source: exact
PUT source, or annotated Console transcript after a REPL render, distinguished by
`X-Tessera-Source-Kind`. The transcript shows actual executed input and can contain
failed statements; it is not guaranteed to replay as a standalone program.
External HTTP activity appears in the same transcript. Unsupported language
operations (currently numeric arithmetic bytecode) show actual diagnostics. Transport
failures appear beside the editor and restore an otherwise empty draft.
Sources shows the full source snapshot or Console transcript with line numbers, Aster's
AST and selected source ranges. Selecting an AST node scrolls to its location while
keeping the entire file available. Cloud files has a separate file navigator and
source viewer, initially disconnected. The `CloudSourceProvider` boundary in
@@ -219,6 +250,15 @@ Very bright custom colors are darkened to keep accents visible on white. Executi
separators, selected tabs and focus indicators use the accent, while text keeps its
readable neutral/error/success colors. Settings and source remain in memory only.
Management controls use the internal [Tessera System UI](src/system_ui/README.md)
widget library, independent of Endor-controlled page widgets. Buttons, tabs,
toggles, rows, input fields, color swatches and drag handles share hover, pressed,
selected, focus and disabled presentation. Buttons activate on release inside;
dragging outside cancels. Tab/Shift-Tab navigate enabled controls and Enter/Space
activate focused buttons. Plain Tab still indents in the Console editor. Empty
Run/Clear actions are disabled. macOS clipboard reads and writes explicitly use
UTF-8 even when launched from Finder, and decoding errors are surfaced.
## Validation
Local native builds and window runs are allowed; do not run local automated tests
+23 -6
View File
@@ -3,14 +3,16 @@
| Module | Responsibility |
| --- | --- |
| main.rs | Native window event loop, namespace state and API startup |
| api.rs | Code CRUD, isolated expression endpoint, eight-entry worker queue, request observation and diagnostics |
| api.rs | Code CRUD, live Console/reset and compatibility evaluation endpoints, worker-owned session, request observation and diagnostics |
| activity.rs | Bounded in-memory HTTP exchanges with marked previews and allowlisted request headers |
| endor_host.rs | Aster compilation, fresh candidate VM, bounded script/module execution, root cleanup and AST developer tree |
| endor_host.rs | Aster compilation, fresh PUT candidates, worker-owned live REPL VM, bounded execution, root cleanup and AST developer tree |
| endor_script.rs | Batch complete inputs for Aster's Interactive parser/compiler, retain Module entry, and rebase Aster spans to the original snapshot |
| ui_bridge.rs | Tessera UI/page payloads and bound render methods, preallocated node/page pools and native snapshot extraction |
| layout.rs | Native UI tree to Frame; expanded-tree and coordinate budgets |
| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation and display commands |
| app.rs | Window events, redraw and developer-tools input |
| app.rs | Window events, redraw and system widget pointer/keyboard/IME routing |
| clipboard.rs | UTF-8 macOS clipboard adapter independent of Finder locale, with strict decoding and surfaced errors |
| system_ui/ | Host-only management widget library: stable IDs, interaction, theme and reusable control drawing |
| scene.rs | Page and developer-tools display-list composition |
| developer_tools.rs | Console input/transcript, full Sources, Network inspector, theme, pane/scroll geometry and focus |
| sources.rs | Disconnected cloud workspace and provider boundary for listing/opening complete source files |
@@ -25,7 +27,7 @@ compilation, bytecode and VM execution come from its libraries. Tessera implemen
only host UI behavior and layout. No CLI, HTTP server or Docker process is needed
to execute Endor in the application.
An execution candidate owns all external VM roots, including the 4096-node pool
A complete-PUT execution candidate owns all external VM roots, including the 4096-node pool
and 64 pages with bound render callbacks. Script units share one globals dictionary
inside the candidate; no variables or VM handles persist across successful PUTs.
Callbacks return borrowed, preallocated real VM objects. Their mutable slots hold
@@ -36,10 +38,11 @@ extraction finishes, release temporary results, clear
globals, release roots, collect cycles and require an empty VM. The window keeps
only source, developer metadata, frame commands and native UI data.
Tests in api.rs execute real HTTP CRUD and a controlled queued-PUT/DELETE race
Tests in api.rs execute real HTTP CRUD, persistent Console/reset/PUT isolation and a controlled queued-PUT/DELETE race
without opening a window. Tests in endor_host.rs cover lifecycle, direct script and
module entry, page methods, multiline inputs, original source ranges, malformed input,
entry checks, budgets, failure preservation and stale-result invalidation. They
entry checks, budgets, failure preservation, live object identity, explicit rendering,
compile-before-execute behavior and stale-result invalidation. They
run only in remote CI. Font tests in text.rs and software_renderer.rs cover real
advances, multiline height, fractional scaling, CJK fallback, grapheme-safe fitting,
antialiasing and transparency. A process-wide mutex keeps font discovery, shaping
@@ -55,3 +58,17 @@ loopback HTTP requests on a separate client thread and reports transport failure
through the native event loop. CloudSourceProvider is an optional host-side adapter,
not a cloud service. A future network adapter should deliver cached/asynchronous
results to the workspace rather than block the native event loop during I/O.
The [System UI library](system_ui/README.md) owns management control appearance and
interaction. It imports no Endor runtime or guest UI bridge; developer tools supply
typed actions and keep all execution/source behavior outside the widget layer.
Console sessions use `endor_host::ReplSession` on the same worker thread. Real VM
globals, UI pools and `_` survive between inputs. Parse/compile precede execution;
only a successful input drains a render request and commits a native snapshot.
Runtime-failed input can retain earlier completed bindings, but cannot publish its
pending render. Session budgets cover cumulative source/AST and fixed UI pools.
Explicit reset drops the VM; accepted PUT/code DELETE change the publication epoch
and invalidate the old context. Source snapshots carry annotated AST ranges for the
actual Console transcript; source history is never rerun to reconstruct memory.
The Aster pin includes independent Assignment AST/name-analysis/bytecode support.
+218 -27
View File
@@ -25,9 +25,17 @@ pub const API_ADDRESS: &str = "127.0.0.1:4737";
const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64;
type Notify = Arc<dyn Fn() + Send + Sync>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RequestKind {
Code,
Expression,
Repl,
ResetRepl,
}
struct BuildRequest {
request: ObservedRequest,
evaluate: bool,
kind: RequestKind,
source: String,
namespace: String,
submission: u64,
@@ -82,8 +90,9 @@ fn start_server_observed(
thread::Builder::new()
.name("tessera-endor-worker".into())
.spawn(move || {
let mut worker = Worker::default();
for job in receiver {
let response = build(&job, &worker_namespace, &worker_notify);
let response = worker.run(&job, &worker_namespace, &worker_notify);
let _ = job.request.respond(response);
}
})
@@ -198,16 +207,20 @@ fn handle(
.unwrap_or(request.url())
.to_owned();
let evaluation = path
.strip_suffix("/evaluate")
.and_then(|prefix| prefix.strip_prefix("/v1/namespaces/"));
if (method == Method::Put || method == Method::Post)
&& let Some(addressed) = namespace_from_path(&path)
.filter(|_| method == Method::Put)
.or(evaluation
.filter(|name| method == Method::Post && !name.is_empty() && !name.contains('/')))
{
submit(request, addressed, namespace, sender, evaluation.is_some());
let evaluation = namespace_from_route(&path, "/evaluate");
let repl = namespace_from_route(&path, "/repl");
let route = if method == Method::Put {
namespace_from_path(&path).map(|name| (name, RequestKind::Code))
} else if method == Method::Post {
repl.map(|name| (name, RequestKind::Repl))
.or(evaluation.map(|name| (name, RequestKind::Expression)))
} else if method == Method::Delete {
repl.map(|name| (name, RequestKind::ResetRepl))
} else {
None
};
if let Some((addressed, kind)) = route {
submit(request, addressed, namespace, sender, kind);
return;
}
let response = if method == Method::Get && path == "/agent" {
@@ -256,6 +269,20 @@ fn code_request(
"X-Tessera-Revision",
&current.revision.to_string(),
);
add_header(
&mut response,
"X-Tessera-Source-Kind",
if current
.document
.root
.as_ref()
.is_some_and(|root| root.kind == "repl-session")
{
"repl-transcript"
} else {
"source-snapshot"
},
);
return response;
}
drop(current);
@@ -290,7 +317,7 @@ fn submit(
addressed: &str,
namespace: &Arc<RwLock<NamespaceState>>,
sender: &SyncSender<BuildRequest>,
evaluate: bool,
kind: RequestKind,
) {
let source = match read_code(&mut request) {
Ok(source) => source,
@@ -311,7 +338,11 @@ fn submit(
let _ = request.respond(error_response(StatusCode(404), "namespace is not active"));
return;
}
let submission = match current.next_submission() {
let submission = match if kind == RequestKind::Code {
current.next_submission()
} else {
Ok(current.submission)
} {
Ok(id) => id,
Err(error) => {
let _ = request.respond(error_response(StatusCode(500), error.to_string()));
@@ -320,7 +351,7 @@ fn submit(
};
let job = BuildRequest {
request,
evaluate,
kind,
source,
namespace: addressed.into(),
submission,
@@ -329,7 +360,7 @@ fn submit(
// A rejected queue entry never invalidates accepted work. The worker
// uses this same lock when committing, so publication is atomic.
Ok(()) => {
if !evaluate {
if kind == RequestKind::Code {
current.submission = submission;
}
}
@@ -347,12 +378,101 @@ fn submit(
}
}
#[derive(Default)]
struct Worker {
repl: Option<(u64, endor_host::ReplSession)>,
next_session: u64,
}
impl Worker {
fn run(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if matches!(job.kind, RequestKind::Code | RequestKind::Expression) {
if job.kind == RequestKind::Code {
self.repl = None;
}
return build(job, namespace, notify);
}
let epoch = match namespace.read() {
Ok(current) => current.submission,
Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"),
};
if epoch != job.submission {
return superseded(job.submission);
}
if job.kind == RequestKind::ResetRepl {
self.repl = None;
return json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}),
);
}
if self
.repl
.as_ref()
.is_none_or(|(current, _)| *current != epoch)
{
self.next_session += 1;
match endor_host::ReplSession::new(self.next_session) {
Ok(session) => self.repl = Some((epoch, session)),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
let session = &mut self.repl.as_mut().expect("initialized REPL").1;
let id = session.id;
let result = catch_unwind(AssertUnwindSafe(|| {
session.execute(&job.source, &job.namespace)
}));
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if current.submission != epoch {
drop(current);
self.repl = None;
return superseded(job.submission);
}
match result {
Ok(Ok(result)) => {
let rendered = result.document.is_some();
if let Some(document) = result.document {
match current.commit(epoch, document) {
Ok(Some(_)) => {}
Ok(None) => return superseded(job.submission),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
let revision = current.revision;
drop(current);
if rendered {
notify();
}
json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":"repl","persistent":true,"session":id,"input":result.input,"value":result.value,"bindings":result.bindings,"rendered":rendered,"revision":revision}),
)
}
Ok(Err(error)) => compile_error(error),
Err(_) => {
drop(current);
self.repl = None;
json(
StatusCode(500),
serde_json::json!({"ok":false,"session_reset":true,"error":"Console execution failed internally; session discarded, displayed page preserved"}),
)
}
}
}
}
fn build(
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if job.evaluate {
if job.kind == RequestKind::Expression {
return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) {
Ok(Ok(value)) => json(
StatusCode(200),
@@ -408,11 +528,14 @@ fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
)
}
fn namespace_from_path(path: &str) -> Option<&str> {
fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> {
path.strip_prefix("/v1/namespaces/")?
.strip_suffix("/code")
.strip_suffix(suffix)
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
}
fn namespace_from_path(path: &str) -> Option<&str> {
namespace_from_route(path, "/code")
}
fn read_code(request: &mut Request) -> Result<String> {
if request
@@ -456,13 +579,34 @@ Send PUT bodies as `text/plain; charset=utf-8`.
```endor
{example}```
## Isolated expression evaluation
## Live Console session
`POST http://{address}/v1/namespaces/{name}/evaluate` accepts one Endor expression
as text/plain and returns its value. It uses a fresh VM without ui or prior bindings,
does not publish a page or change the revision, and shares the bounded worker queue.
Persistent REPL sessions are not available. The Console is an activity transcript,
not a persistent VM; history is never replayed. PUT remains a complete source replacement.
`POST http://{address}/v1/namespaces/{name}/repl` accepts statements, function
definitions and expressions as text/plain. Consecutive requests use one real VM
and globals dictionary on the execution worker. `title = ui.text("Hello")` creates
an object without displaying it. A later `page.render(root)` publishes its page.
`let title = ...` is also supported. `_` retains the actual last value. No input
history is replayed. Responses include session/input IDs, value, binding count,
rendered flag and revision. Without render the displayed page/revision stay unchanged.
`DELETE http://{address}/v1/namespaces/{name}/repl` resets Console objects and bindings,
leaving the displayed native snapshot intact. Parsing/compilation happen before
execution. On runtime error earlier statements in that input may remain live, but
its pending render is discarded; previous displayed page is preserved.
Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes,
4096 UI nodes and 64 pages. Reset to reclaim them. No VM handles cross to the window.
A newer accepted complete-source PUT or code DELETE invalidates queued Console
inputs and resets the live context. They retain their existing snapshot semantics.
GET /code returns the source of the displayed snapshot: exact submitted source for
PUT, an annotated Console transcript after a REPL render. X-Tessera-Source-Kind
identifies source-snapshot versus repl-transcript; a transcript is not a replay log.
The compatibility `POST http://{address}/v1/namespaces/{name}/evaluate` endpoint
still accepts one isolated expression in a fresh VM without ui or previous bindings.
It does not publish a page or change revision. Console does not use this endpoint.
All execution endpoints share the eight-entry queue. These are in-process bounded
host executions, not a complete untrusted-code sandbox or a process-isolation design.
## UI host object
@@ -491,8 +635,8 @@ document. GET remains available while Aster executes. The window redraws from an
owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing
and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels
with 20-pixel line height; larger sizes multiply both. This first stage builds static UI;
events, persistent VM state and process isolation remain
future work. It is not an untrusted-code sandbox.
event callbacks and process isolation remain future work. Console retains objects
only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox.
"##,
name = namespace.name,
revision = namespace.revision,
@@ -608,6 +752,53 @@ mod tests {
}
}
#[test]
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let repl = "/v1/namespaces/app.main/repl";
let code = "/v1/namespaces/app.main/code";
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
assert_eq!(created.0, 200, "{}", created.2);
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
assert_eq!(created["rendered"], false);
assert_eq!(created["revision"], 0);
let read: serde_json::Value =
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
assert_eq!(read["value"], created["value"]);
assert_eq!(
client.request("POST", repl, "ui.page().render(title)").0,
200
);
let snapshot = client.request("GET", code, "");
assert!(
snapshot
.1
.to_lowercase()
.contains("x-tessera-source-kind: repl-transcript")
);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("DELETE", repl, "").0, 200);
assert_eq!(client.request("GET", code, "").2, snapshot.2);
assert_eq!(client.request("POST", repl, "title").0, 422);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("POST", repl, "retained = 42").0, 200);
assert_eq!(
client
.request("PUT", code, include_str!("../examples/page.endor"))
.0,
200
);
assert_eq!(client.request("POST", repl, "retained").0, 422);
assert_eq!(namespace.read().unwrap().revision, 2);
}
#[test]
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
+30 -5
View File
@@ -84,6 +84,15 @@ impl TesseraApp {
}
fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) {
if self.developer_tools.keyboard_control(
event,
self.modifiers.shift_key(),
&self.layout.developer_tools,
&self.activity,
) {
self.request_redraw();
return;
}
if event.state != ElementState::Pressed {
return;
}
@@ -165,6 +174,14 @@ impl TesseraApp {
);
drop(namespace);
self.layout = scene.layout;
window.set_cursor(
self.developer_tools
.cursor_kind(&self.layout.developer_tools),
);
if self.developer_tools.repaint {
self.developer_tools.repaint = false;
window.request_redraw();
}
let ime = self
.developer_tools
.ime_position(&self.layout.developer_tools);
@@ -256,10 +273,15 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
);
window.set_cursor(
self.developer_tools
.cursor_kind(&self.layout.developer_tools, next),
.cursor_kind(&self.layout.developer_tools),
);
window.request_redraw();
}
WindowEvent::CursorLeft { .. } => {
self.developer_tools.pointer_left();
window.set_cursor(winit::window::CursorIcon::Default);
window.request_redraw();
}
WindowEvent::MouseInput {
state,
button: MouseButton::Left,
@@ -272,7 +294,11 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
&self.activity,
);
} else {
self.developer_tools.release();
self.developer_tools.release(
&self.layout.developer_tools,
self.pointer,
&self.activity,
);
}
window.request_redraw();
}
@@ -304,10 +330,9 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
window.request_redraw();
}
WindowEvent::Focused(false) => {
self.developer_tools.release();
self.developer_tools.focus = None;
self.developer_tools.preedit.clear();
self.developer_tools.blur();
self.modifiers = ModifiersState::default();
window.request_redraw();
}
WindowEvent::ModifiersChanged(modifiers) => self.modifiers = modifiers.state(),
WindowEvent::KeyboardInput { event, .. } => self.handle_keyboard(&event),
+310 -182
View File
@@ -6,6 +6,10 @@ use crate::{
document::{CodeNode, RenderDocument},
geometry::{Point, Rect},
sources::SourceWorkspace,
system_ui::{
Axis, Button, Cursor, Interaction, Role, Target, Ui, WidgetId,
theme::{BAR, BG, BORDER, ERROR, MUTED, OK, PALETTE, TEXT, Theme, readable_accent},
},
text::{self, TextStyle},
};
use std::{
@@ -16,24 +20,9 @@ use std::{
};
use unicode_segmentation::UnicodeSegmentation;
const BG: Color = Color::rgb(255, 255, 255);
const BAR: Color = Color::rgb(243, 245, 248);
const BORDER: Color = Color::rgb(214, 220, 229);
const TEXT: Color = Color::rgb(35, 42, 53);
const MUTED: Color = Color::rgb(103, 114, 130);
const ERROR: Color = Color::rgb(175, 42, 49);
const OK: Color = Color::rgb(31, 121, 80);
const ROW: f32 = 19.0;
const PALETTE: [Color; 6] = [
Color::rgb(22, 113, 213),
Color::rgb(0, 132, 143),
Color::rgb(115, 76, 189),
Color::rgb(180, 68, 115),
Color::rgb(168, 94, 24),
Color::rgb(52, 123, 78),
];
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Tab {
#[default]
Console,
@@ -41,7 +30,7 @@ pub enum Tab {
Network,
Settings,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Region {
Transcript = 0,
Input,
@@ -55,20 +44,20 @@ struct Scroll {
x: f32,
y: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Focus {
Input,
Filter,
Custom,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Action {
Tab(Tab),
Close,
ClearConsole,
ClearNetwork,
Follow,
Mode,
ResetSession,
Submit,
Focus(Focus),
Snapshot,
@@ -79,7 +68,7 @@ pub enum Action {
Request(u64),
Theme(usize),
}
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Split {
Dock,
Editor,
@@ -95,6 +84,8 @@ enum Drag {
#[derive(Debug)]
pub struct DeveloperToolsState {
pub open: bool,
pub interaction: Interaction,
pub repaint: bool,
pub proxy: Option<winit::event_loop::EventLoopProxy<AppEvent>>,
pub cloud_sources: SourceWorkspace,
pub tab: Tab,
@@ -115,7 +106,6 @@ pub struct DeveloperToolsState {
scroll: [Scroll; 6],
widths: [Option<(u64, f32)>; 6],
drag: Option<Drag>,
script: bool,
cloud: bool,
follow: bool,
selected_request: Option<u64>,
@@ -129,6 +119,8 @@ impl Default for DeveloperToolsState {
fn default() -> Self {
Self {
open: false,
interaction: Interaction::default(),
repaint: false,
proxy: None,
cloud_sources: SourceWorkspace::default(),
tab: Tab::Console,
@@ -149,7 +141,6 @@ impl Default for DeveloperToolsState {
scroll: [Scroll::default(); 6],
widths: [None; 6],
drag: None,
script: false,
cloud: false,
follow: true,
selected_request: None,
@@ -164,7 +155,7 @@ impl Default for DeveloperToolsState {
#[derive(Clone, Debug, Default)]
pub struct DeveloperToolsLayout {
pub panel: Rect,
pub actions: Vec<(Rect, Action)>,
pub widgets: Vec<Target<Action>>,
splits: Vec<(Rect, Split)>,
regions: Vec<(Rect, Region, f32, f32)>,
thumbs: Vec<(Rect, Region, bool, f32)>,
@@ -172,6 +163,7 @@ pub struct DeveloperToolsLayout {
impl DeveloperToolsState {
pub fn toggle(&mut self, doc: &RenderDocument) {
self.open = !self.open;
self.interaction.blur();
self.focus = None;
self.drag = None;
self.document_updated(doc);
@@ -185,50 +177,64 @@ impl DeveloperToolsState {
self.selected_node_id = doc.root.as_ref().map(|n| n.id.clone());
}
}
pub fn cursor_kind(
&self,
layout: &DeveloperToolsLayout,
point: Point,
) -> winit::window::CursorIcon {
pub fn cursor_kind(&self, layout: &DeveloperToolsLayout) -> winit::window::CursorIcon {
use winit::window::CursorIcon;
if let Some((_, split)) = layout.splits.iter().find(|(r, _)| r.contains(point)) {
return match split {
Split::Dock | Split::Tree => CursorIcon::ColResize,
_ => CursorIcon::RowResize,
};
if !self.open {
return CursorIcon::Default;
}
if layout
.actions
.iter()
.any(|(r, a)| r.contains(point) && matches!(a, Action::Focus(_)))
{
CursorIcon::Text
} else {
CursorIcon::Default
match self.interaction.cursor(&layout.widgets) {
Cursor::Default => CursorIcon::Default,
Cursor::Pointer => CursorIcon::Pointer,
Cursor::Text => CursorIcon::Text,
Cursor::ResizeHorizontal => CursorIcon::ColResize,
Cursor::ResizeVertical => CursorIcon::RowResize,
Cursor::Grab => CursorIcon::Grab,
Cursor::Disabled => CursorIcon::NotAllowed,
}
}
pub fn pointer_left(&mut self) {
self.interaction.pointer_left();
}
pub fn blur(&mut self) {
self.interaction.blur();
self.drag = None;
self.focus = None;
self.preedit.clear();
}
pub fn press(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) {
if !self.open {
return;
}
self.preedit.clear();
if let Some((_, split)) = layout.splits.iter().find(|(r, _)| r.contains(p)) {
self.drag = Some(Drag::Split(*split));
self.focus = None;
return;
self.interaction.pointer_moved(p, &layout.widgets);
let target = self.interaction.press(&layout.widgets);
self.focus = None;
if let Some(target) = target {
match target.role {
Role::Splitter(_) => {
if let Some((_, split)) = layout.splits.iter().find(|(r, _)| r.contains(p)) {
self.drag = Some(Drag::Split(*split));
}
}
Role::Scrollbar(_) => {
if let Some((_, region, h, ratio)) =
layout.thumbs.iter().find(|(r, _, _, _)| r.contains(p))
{
self.drag = Some(Drag::Scroll(*region, *h, *ratio));
}
}
Role::TextField => self.activate(target.action, layout, p, activity),
_ => {}
}
}
if let Some((_, region, horizontal, ratio)) =
layout.thumbs.iter().find(|(r, _, _, _)| r.contains(p))
{
self.drag = Some(Drag::Scroll(*region, *horizontal, *ratio));
return;
}
let action = layout
.actions
.iter()
.rev()
.find(|(r, _)| r.contains(p))
.map(|(_, a)| a.clone());
}
fn activate(
&mut self,
action: Option<Action>,
layout: &DeveloperToolsLayout,
p: Point,
activity: &Activity,
) {
self.focus = None;
match action {
Some(Action::Tab(t)) => {
@@ -285,9 +291,7 @@ impl DeveloperToolsState {
Some(Action::Follow) => {
self.follow = !self.follow;
}
Some(Action::Mode) => {
self.script = !self.script;
}
Some(Action::ResetSession) => self.submit_console(true),
Some(Action::Submit) => self.submit(),
Some(Action::Snapshot) => self.cloud = false,
Some(Action::Cloud) => self.cloud = true,
@@ -320,8 +324,70 @@ impl DeveloperToolsState {
None => {}
}
}
pub fn release(&mut self) {
pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) {
self.interaction.pointer_moved(p, &layout.widgets);
let action = self.interaction.release(&layout.widgets);
self.drag = None;
if action.is_some() {
self.activate(action, layout, p, activity);
}
}
pub fn keyboard_control(
&mut self,
event: &winit::event::KeyEvent,
shift: bool,
layout: &DeveloperToolsLayout,
activity: &Activity,
) -> bool {
use winit::{
event::ElementState,
keyboard::{Key, NamedKey},
};
if !self.open || !self.preedit.is_empty() {
return false;
}
if event.logical_key == Key::Named(NamedKey::Escape) && self.focus.is_none() {
if event.state == ElementState::Pressed {
self.interaction.blur();
}
return true;
}
if event.logical_key == Key::Named(NamedKey::Tab)
&& (self.focus != Some(Focus::Input) || shift)
{
if event.state == ElementState::Pressed && !event.repeat {
let target = self.interaction.focus_next(&layout.widgets, shift);
self.focus = target.and_then(|t| match t.action {
Some(Action::Focus(f)) => Some(f),
_ => None,
});
if self.focus == Some(Focus::Input) {
self.cursor = self.input.len();
}
}
return true;
}
let control = self
.interaction
.focused(&layout.widgets)
.is_some_and(|t| t.role != Role::TextField);
if control
&& matches!(
event.logical_key,
Key::Named(NamedKey::Enter | NamedKey::Space)
)
{
if event.state == ElementState::Pressed {
if !event.repeat {
self.interaction.key_press(&layout.widgets);
}
} else {
let action = self.interaction.key_release(&layout.widgets);
self.activate(action, layout, Point::default(), activity);
}
return true;
}
false
}
pub fn move_pointer(
&mut self,
@@ -330,6 +396,7 @@ impl DeveloperToolsState {
layout: &DeveloperToolsLayout,
window_width: f32,
) {
self.interaction.pointer_moved(p, &layout.widgets);
match self.drag {
Some(Drag::Split(Split::Dock)) => {
self.panel_width =
@@ -468,6 +535,12 @@ impl DeveloperToolsState {
}
pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) {
use winit::keyboard::NamedKey::*;
if *key == Escape {
self.focus = None;
self.preedit.clear();
self.interaction.blur();
return;
}
if self.focus == Some(Focus::Custom) && *key == Enter {
let hex = self.custom.trim_start_matches('#');
if hex.len() == 6
@@ -623,66 +696,83 @@ impl DeveloperToolsState {
self.reveal_cursor();
}
fn submit(&mut self) {
if self.input.trim().is_empty() {
self.submit_console(false);
}
fn submit_console(&mut self, reset: bool) {
if !reset && self.input.trim().is_empty() {
return;
}
let source = self.input.clone();
let script = self.script;
let source = if reset {
String::new()
} else {
self.input.clone()
};
let proxy = self.proxy.clone();
// A real loopback request keeps Console and external integrations on the same API path.
match thread::Builder::new().name("tessera-console-client".into()).spawn(move || {
let send=|| -> std::io::Result<()> {
let spawned=thread::Builder::new().name("tessera-console-client".into()).spawn(move||{
let send=||->std::io::Result<()> {
let mut stream=TcpStream::connect_timeout(&"127.0.0.1:4737".parse().unwrap(),Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(120)))?;stream.set_write_timeout(Some(Duration::from_secs(10)))?;
let (method,path)=if script {("PUT","code")}else{("POST","evaluate")};
write!(stream,"{method} /v1/namespaces/app.main/{path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nX-Tessera-Origin: console\r\nContent-Length: {}\r\n\r\n{}",source.len(),source)?;
let method=if reset{"DELETE"}else{"POST"};
write!(stream,"{method} /v1/namespaces/app.main/repl HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nX-Tessera-Origin: console\r\nContent-Length: {}\r\n\r\n{}",source.len(),source)?;
let mut response=String::new();stream.read_to_string(&mut response)?;Ok(())
};
if let Err(error)=send() && let Some(proxy)=proxy {let _=proxy.send_event(AppEvent::ConsoleFailed {message:error.to_string(),source});}
}) { Ok(_)=> {self.history.push(self.input.clone()); if self.history.len()>80{self.history.remove(0);}self.input.clear();self.cursor=0;self.history_index=None;self.scroll[Region::Input as usize]=Scroll::default();self.follow=true;self.input_error.clear();},Err(e)=>self.input_error=e.to_string() }
if let Err(error)=send()&&let Some(proxy)=proxy{let _=proxy.send_event(AppEvent::ConsoleFailed{message:error.to_string(),source});}
});
match spawned {
Ok(_) => {
if !reset {
self.history.push(self.input.clone());
if self.history.len() > 80 {
self.history.remove(0);
}
self.input.clear();
self.cursor = 0;
self.history_index = None;
self.scroll[Region::Input as usize] = Scroll::default();
}
self.follow = true;
self.input_error.clear();
}
Err(error) => self.input_error = error.to_string(),
}
}
}
struct Painter {
list: DisplayList,
ui: Ui<Action>,
layout: DeveloperToolsLayout,
accent: Color,
}
impl Painter {
fn fill(&mut self, r: Rect, c: Color) {
self.list.push(DrawCommand::FillRoundedRect {
rect: r,
radius: 0.0,
color: c,
});
self.ui.fill(r, c);
}
fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) {
self.list.push(DrawCommand::Text {
origin: Point::new(x, y),
value: value.into(),
style: if code {
TextStyle::code()
} else {
TextStyle {
font_size: 12.0,
line_height: 18.0,
..TextStyle::ui(1)
}
},
color: c,
});
self.ui.label(x, y, value, c, code);
}
fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) {
if active {
self.fill(r, tint(self.accent));
}
self.label(r.x + 10.0, r.y + 6.0, label, TEXT, false);
self.layout.actions.push((r, action));
let role = match action {
Action::Tab(_) => Role::Tab,
Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle,
_ => Role::Button,
};
self.ui
.button(r, action, Button::new(label).selected(active).role(role));
}
fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) {
self.ui
.button(r, action, Button::new(label).enabled(enabled));
}
fn split(&mut self, r: Rect, kind: Split) {
self.fill(r, BORDER);
self.ui.splitter(
r,
WidgetId::new(("split", kind)),
match kind {
Split::Dock | Split::Tree => Axis::Horizontal,
_ => Axis::Vertical,
},
);
self.layout.splits.push((
Rect::new(r.x - 2.0, r.y - 2.0, r.width + 4.0, r.height + 4.0),
Rect::new(r.x - 3.0, r.y - 3.0, r.width + 6.0, r.height + 6.0),
kind,
));
}
@@ -720,7 +810,7 @@ impl Painter {
s.y.clamp(0.0, (content_h - area.height + 12.0).max(0.0))
};
s.x = s.x.clamp(0.0, (content_w - area.width + 12.0).max(0.0));
self.list.push(DrawCommand::PushClip(Rect::new(
self.ui.list.push(DrawCommand::PushClip(Rect::new(
area.x,
area.y,
(area.width - 9.0).max(0.0),
@@ -731,24 +821,26 @@ impl Painter {
for (i, line) in lines.iter().enumerate().skip(first).take(count) {
let y = area.y + 5.0 + i as f32 * ROW - s.y;
if line.divider {
self.fill(Rect::new(area.x, y, area.width, 1.0), self.accent);
self.fill(Rect::new(area.x, y, area.width, 1.0), self.ui.theme.accent);
}
if line.selected {
self.fill(Rect::new(area.x, y, area.width, ROW), tint(self.accent));
}
self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true);
let paint = Rect::new(area.x, y, area.width, ROW);
if let Some(action) = &line.action {
let top = y.max(area.y);
let bottom = (y + ROW).min(area.y + area.height - 9.0);
if bottom > top {
self.layout.actions.push((
self.ui.row(
paint,
Rect::new(area.x, top, area.width - 9.0, bottom - top),
action.clone(),
));
line.selected,
);
}
} else if line.selected {
self.fill(paint, self.ui.theme.soft());
}
self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true);
}
self.list.push(DrawCommand::PopClip);
self.ui.list.push(DrawCommand::PopClip);
self.layout
.regions
.push((area, region, content_w, content_h));
@@ -775,7 +867,11 @@ impl Painter {
} else {
Rect::new(track.x + 1.0, track.y + position, 6.0, length)
};
self.fill(thumb, Color::rgb(161, 173, 190));
self.ui.scrollbar(
thumb,
WidgetId::new(("scroll", region, h)),
if h { Axis::Horizontal } else { Axis::Vertical },
);
self.layout
.thumbs
.push((thumb, region, h, maximum / travel));
@@ -800,19 +896,8 @@ impl Line {
}
}
}
fn readable_accent(mut c: Color) -> Color {
// Keep an accent visible on white while retaining the chosen hue.
while (c.red as u32 * 299 + c.green as u32 * 587 + c.blue as u32 * 114) / 1000 > 155 {
c = Color::rgb(
(c.red as f32 * 0.94) as u8,
(c.green as f32 * 0.94) as u8,
(c.blue as f32 * 0.94) as u8,
);
}
c
}
fn tint(c: Color) -> Color {
Color::rgb(232 + c.red / 12, 232 + c.green / 12, 232 + c.blue / 12)
Theme::new(c).soft()
}
fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
out.extend(
@@ -824,6 +909,33 @@ fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
}
fn console_result(out: &mut Vec<Line>, body: &str, color: Color) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if value["execution"] == "repl" {
out.push(Line::new(
if value["rendered"] == true {
format!(
"Rendered revision {} · session {}",
value["revision"], value["session"]
)
} else {
format!(
"Session {} · retained in memory; page unchanged",
value["session"]
)
},
MUTED,
));
}
if value["session_reset"] == true {
multiline(
out,
value["message"]
.as_str()
.or(value["error"].as_str())
.unwrap_or("Session reset"),
color,
);
return;
}
if let Some(result) = value["value"].as_str() {
multiline(out, result, color);
return;
@@ -884,15 +996,14 @@ pub fn build(
let x = width - w;
let panel = Rect::new(x, 0.0, w, height);
let mut p = Painter {
list: DisplayList::default(),
ui: Ui::new(Theme::new(state.accent), state.interaction.clone()),
layout: DeveloperToolsLayout {
panel,
..Default::default()
},
accent: state.accent,
};
p.fill(panel, BG);
p.list.push(DrawCommand::PushClip(panel));
p.ui.list.push(DrawCommand::PushClip(panel));
p.fill(Rect::new(x, 0.0, w, 31.0), BAR);
p.split(Rect::new(x, 0.0, 1.0, height), Split::Dock);
for (i, (label, tab)) in [
@@ -906,9 +1017,6 @@ pub fn build(
{
let r = Rect::new(x + 5.0 + i as f32 * 75.0, 0.0, 75.0, 30.0);
p.button(r, label, Action::Tab(*tab), state.tab == *tab);
if state.tab == *tab {
p.fill(Rect::new(r.x, 29.0, r.width, 2.0), state.accent);
}
}
p.button(
Rect::new(width - 32.0, 0.0, 31.0, 30.0),
@@ -927,11 +1035,14 @@ pub fn build(
match state.tab {
Tab::Console => {
p.fill(Rect::new(x, 32.0, w, 30.0), BAR);
p.button(
p.enabled_button(
Rect::new(x + 3.0, 32.0, 62.0, 29.0),
"Clear",
Action::ClearConsole,
false,
records.iter().any(|r| {
r.id > state.console_after
&& matches!(r.method.as_str(), "PUT" | "POST" | "DELETE")
}),
);
p.button(
Rect::new(x + 68.0, 32.0, 106.0, 29.0),
@@ -943,13 +1054,15 @@ pub fn build(
Action::Follow,
state.follow,
);
p.label(
x + 184.0,
39.0,
"app.main · execution transcript",
MUTED,
p.button(
Rect::new(x + 181.0, 32.0, 119.0, 29.0),
"Reset session",
Action::ResetSession,
false,
);
if w > 520.0 {
p.label(x + 309.0, 39.0, "app.main · live objects", MUTED, false);
}
let editor = state
.editor_height
.clamp(100.0, (height - 170.0).max(100.0));
@@ -959,10 +1072,16 @@ pub fn build(
r.id > state.console_after
&& (r.method == "PUT" || r.method == "POST" || r.method == "DELETE")
}) {
let kind = if r.path.ends_with("/evaluate") {
"EXPRESSION · fresh VM"
let kind = if r.path.ends_with("/repl") {
if r.method == "DELETE" {
"RESET SESSION"
} else {
"REPL · live objects"
}
} else if r.path.ends_with("/evaluate") {
"ISOLATED API EVALUATION"
} else if r.method == "PUT" {
"SCRIPT · replace snapshot"
"FULL SOURCE · new run"
} else {
"CLEAR DOCUMENT"
};
@@ -993,7 +1112,7 @@ pub fn build(
if lines.is_empty() {
multiline(
&mut lines,
"Console\n\nEvaluate an expression, or submit a complete UI script.\nExternal HTTP executions appear here as they happen.\n\nEach request starts a fresh VM. Bindings do not persist.\nPersistent REPL sessions are not connected.\nNo history is replayed.",
"Console\n\nStatements, definitions and expressions share one live session.\nCreate objects now; call page.render(...) when ready to display.\nUse title = ui.text(\"你好,Tessera\") or let title = ...\nThe last value is also available as _.\n\nReset session clears objects and bindings, preserving the page.\nRuntime errors may retain earlier successful statements.\nExternal HTTP activity appears here too. History is never replayed.",
MUTED,
);
}
@@ -1007,26 +1126,25 @@ pub fn build(
);
p.split(Rect::new(x + 1.0, split_y, w - 2.0, 1.0), Split::Editor);
p.fill(Rect::new(x + 1.0, split_y + 1.0, w - 2.0, 32.0), BAR);
p.button(
Rect::new(x + 5.0, split_y + 2.0, 190.0, 30.0),
if state.script {
"Script · replace page ⇄"
} else {
"Expression · fresh VM ⇄"
},
Action::Mode,
true,
p.label(
x + 12.0,
split_y + 8.0,
"Console · live session · render to display",
TEXT,
false,
);
p.button(
p.enabled_button(
Rect::new(width - 95.0, split_y + 2.0, 89.0, 30.0),
"Run ↵",
Action::Submit,
false,
!state.input.trim().is_empty(),
);
let input_area = Rect::new(x + 3.0, split_y + 35.0, w - 6.0, (editor - 62.0).max(24.0));
p.layout
.actions
.push((input_area, Action::Focus(Focus::Input)));
p.ui.text_field(
input_area,
Action::Focus(Focus::Input),
state.focus == Some(Focus::Input),
);
if state.selection && state.focus == Some(Focus::Input) {
p.fill(input_area, tint(state.accent));
}
@@ -1041,12 +1159,7 @@ pub fn build(
input.insert_str(state.cursor, &format!("{}│", state.preedit));
}
if input.is_empty() {
input = if state.script {
"// Complete script; must render a page"
} else {
"// Try \"Hello Endor\""
}
.into();
input = "// title = ui.text(\"你好,Tessera\")".into();
}
let mut input_lines = Vec::new();
multiline(&mut input_lines, &input, TEXT);
@@ -1140,7 +1253,16 @@ pub fn build(
code_x + 10.0,
69.0,
&format!(
"app.main.endor · {} lines · read only",
"{} · {} lines · read only",
if doc
.root
.as_ref()
.is_some_and(|root| root.kind == "repl-session")
{
"Console transcript"
} else {
"app.main.endor"
},
doc.source.split('\n').count()
),
MUTED,
@@ -1196,20 +1318,17 @@ pub fn build(
}
Tab::Network => {
p.fill(Rect::new(x, 32.0, w, 30.0), BAR);
p.button(
p.enabled_button(
Rect::new(x + 3.0, 32.0, 62.0, 29.0),
"Clear",
Action::ClearNetwork,
false,
records.iter().any(|r| r.id > state.network_after),
);
let filter_area = Rect::new(x + 72.0, 35.0, (w - 85.0).max(100.0), 24.0);
p.fill(
p.ui.text_field(
filter_area,
if state.focus == Some(Focus::Filter) {
tint(state.accent)
} else {
BG
},
Action::Focus(Focus::Filter),
state.focus == Some(Focus::Filter),
);
p.label(
filter_area.x + 7.0,
@@ -1226,9 +1345,7 @@ pub fn build(
MUTED,
false,
);
p.layout
.actions
.push((filter_area, Action::Focus(Focus::Filter)));
p.label(
x + 10.0,
69.0,
@@ -1318,11 +1435,7 @@ pub fn build(
);
for (i, color) in PALETTE.iter().enumerate() {
let r = Rect::new(x + 24.0 + i as f32 * 44.0, 122.0, 32.0, 32.0);
p.fill(r, *color);
if *color == state.accent {
p.label(r.x + 10.0, r.y + 6.0, "✓", BG, false);
}
p.layout.actions.push((r, Action::Theme(i)));
p.ui.swatch(r, Action::Theme(i), *color, *color == state.accent);
}
p.label(
x + 24.0,
@@ -1332,7 +1445,11 @@ pub fn build(
false,
);
let custom = Rect::new(x + 24.0, 204.0, 200.0, 32.0);
p.fill(custom, BAR);
p.ui.text_field(
custom,
Action::Focus(Focus::Custom),
state.focus == Some(Focus::Custom),
);
p.label(
custom.x + 10.0,
custom.y + 6.0,
@@ -1348,9 +1465,7 @@ pub fn build(
TEXT,
true,
);
p.layout
.actions
.push((custom, Action::Focus(Focus::Custom)));
if !state.input_error.is_empty() {
p.label(x + 24.0, 245.0, &state.input_error, ERROR, false);
}
@@ -1386,9 +1501,9 @@ pub fn build(
MUTED,
false,
);
p.list.push(DrawCommand::PopClip);
p.ui.list.push(DrawCommand::PopClip);
let mut scaled = DisplayList::default();
for cmd in p.list.commands() {
for cmd in p.ui.list.commands() {
let rect =
|r: &Rect| Rect::new(r.x * scale, r.y * scale, r.width * scale, r.height * scale);
scaled.push(match cmd {
@@ -1413,9 +1528,22 @@ pub fn build(
color: *color,
},
DrawCommand::PushClip(r) => DrawCommand::PushClip(rect(r)),
DrawCommand::StrokeRoundedRect {
rect: r,
radius,
width,
color,
} => DrawCommand::StrokeRoundedRect {
rect: rect(r),
radius: radius * scale,
width: width * scale,
color: *color,
},
other => other.clone(),
});
}
p.layout.widgets = p.ui.targets;
state.repaint = state.interaction.reconcile(&p.layout.widgets);
(scaled, p.layout)
}
fn request_details(out: &mut Vec<Line>, r: &Exchange) {
+286 -2
View File
@@ -1,4 +1,5 @@
//! One source submission uses a fresh Aster VM and publishes an owned snapshot.
//! Compile complete source in a fresh VM or retain a worker-owned Console VM.
//! Console publishes owned native snapshots only after a successful explicit render.
use anyhow::{Result, bail};
use endor_core::{ExecutionLimits, ObjectRef, Value, Vm, VmError};
use frontend::{
@@ -140,7 +141,14 @@ pub fn evaluate(source: &str) -> Result<String> {
}
fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::CompiledCode>> {
let mut globals = BTreeSet::from(["ui".to_owned()]);
compile_plan_with_globals(source, plan, BTreeSet::from(["ui".to_owned()]))
}
fn compile_plan_with_globals(
source: &SourceUnit,
plan: &SourcePlan,
mut globals: BTreeSet<String>,
) -> Result<Vec<compiler::CompiledCode>> {
// Match Aster module behavior for function forward references. Bindings are
// added in source order; every executable statement still uses Aster's own
// single-input Interactive compiler and the candidate's shared globals.
@@ -167,6 +175,8 @@ fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::
for statement in &input.statements {
if let StmtKind::Binding(binding) = &statement.kind {
globals.insert(binding.name.text.clone());
} else if let StmtKind::Assignment(assignment) = &statement.kind {
globals.insert(assignment.name.text.clone());
}
}
}
@@ -174,6 +184,193 @@ fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::
Ok(compiled)
}
/// One real VM, owned only by the existing execution worker. No history replay.
/// The UI bridge's fixed pools are session-wide and reclaimed by explicit reset.
pub struct ReplSession {
candidate: Candidate,
bridge: ui_bridge::UiBridge,
pub id: u64,
inputs: usize,
ast_nodes: usize,
source: String,
nodes: Vec<CodeNode>,
}
pub struct ReplResult {
pub value: String,
pub document: Option<RenderDocument>,
pub bindings: usize,
pub input: usize,
}
impl ReplSession {
pub fn new(id: u64) -> Result<Self> {
let mut candidate = Candidate::new()?;
let globals = candidate.vm.new_dict()?;
candidate.globals = Some(globals);
candidate.roots.push(globals);
let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?;
candidate
.vm
.dict_set(globals, Value::Text("ui".into()), Value::Object(bridge.ui))?;
Ok(Self {
candidate,
bridge,
id,
inputs: 0,
ast_nodes: 0,
source: String::new(),
nodes: Vec::new(),
})
}
pub fn execute(&mut self, source: &str, namespace: &str) -> Result<ReplResult> {
if source.len() > MAX_SOURCE_BYTES {
bail!("input exceeds 1 MiB");
}
if self.inputs >= 256 || self.source.len() + source.len() + 80 > MAX_SOURCE_BYTES {
bail!("console session exceeds 256 inputs or 1 MiB; reset the session to continue");
}
let source = EndorReader::from_text(
format!("console session {} input {}", self.id, self.inputs + 1),
source,
);
let plan = endor_script::parse(&source)?;
let mut budget = AstBudget::default();
for ast in plan.roots() {
budget.visit_ast_root(ast)?;
}
if self.ast_nodes + budget.nodes > MAX_AST_NODES {
bail!("console session exceeds 10000 AST nodes; reset the session to continue");
}
let globals = self.candidate.globals.expect("live console globals");
let names = self
.candidate
.vm
.dict_items(globals)?
.iter()
.filter_map(|(key, _)| {
if let Value::Text(name) = key {
Some(name.clone())
} else {
None
}
})
.collect();
let compiled = compile_plan_with_globals(&source, &plan, names)?;
// Parsing/compilation finish before any new statement runs. Successful earlier
// statements in a runtime-failed input remain live, as in an ordinary REPL.
self.bridge.take_rendered()?;
self.inputs += 1;
self.ast_nodes += budget.nodes;
let offset = line_starts(&self.source).len();
self.source
.push_str(&format!("// Console input {}\n", self.inputs));
self.source.push_str(source.text());
if !self.source.ends_with('\n') {
self.source.push('\n');
}
let mut node = architecture(&source, &plan, namespace, self.inputs as u64);
node.name = format!("Input {}", self.inputs);
shift_node_lines(&mut node, offset);
self.nodes.push(node);
let result: Result<ReplResult> = (|| {
let limits = ExecutionLimits {
max_instructions: LIMITS.max_instructions / compiled.len().max(1),
..LIMITS
};
let mut rendered_value = "nil".to_owned();
for (ast, template) in plan.roots().iter().zip(&compiled) {
let code = template.load(&mut self.candidate.vm)?;
let executed = self
.candidate
.vm
.execute_with_limits(code, globals, limits)
.map_err(|error| runtime_error(&self.candidate.vm, error));
self.candidate.vm.release(code)?;
let value = executed?;
// Keep the actual last result accessible as `_`, including unnamed UI objects.
let display = if let AstRoot::Interactive(input) = ast {
match &input.statements[0].kind {
StmtKind::Binding(binding) => self
.candidate
.vm
.dict_get(globals, &Value::Text(binding.name.text.clone()))?
.unwrap_or_else(|| value.clone()),
StmtKind::Assignment(assignment) => self
.candidate
.vm
.dict_get(globals, &Value::Text(assignment.name.text.clone()))?
.unwrap_or_else(|| value.clone()),
_ => value.clone(),
}
} else {
value.clone()
};
let repr = value_repr(&self.candidate.vm, &display);
let retained =
self.candidate
.vm
.dict_set(globals, Value::Text("_".into()), display);
self.candidate.vm.release_value(value)?;
retained?;
rendered_value = repr?;
}
let document = if let Some(root) = self.bridge.take_rendered()? {
let document = RenderDocument {
source: self.source.clone(),
root: Some(CodeNode {
id: format!("console-session-{}", self.id),
name: format!("Console session {}", self.id),
kind: "repl-session".into(),
start_line: 1,
end_line: self.source.lines().count(),
children: self.nodes.clone(),
}),
frame: layout::frame(&root)?,
ui_root: Some(root),
};
document.validate()?;
Some(document)
} else {
None
};
Ok(ReplResult {
value: rendered_value,
document,
bindings: self
.candidate
.vm
.dict_items(globals)?
.len()
.saturating_sub(1),
input: self.inputs,
})
})();
if result.is_err() {
self.bridge.take_rendered()?;
if let Some(node) = self.nodes.last_mut() {
node.name
.push_str(" (error; earlier statements may remain live)");
}
}
result
}
}
fn shift_node_lines(node: &mut CodeNode, offset: usize) {
node.start_line += offset;
node.end_line += offset;
for child in &mut node.children {
shift_node_lines(child, offset);
}
}
fn value_repr(vm: &Vm, value: &Value) -> Result<String> {
Ok(match value {
Value::Nil => "nil".into(),
Value::Bool(v) => v.to_string(),
Value::Text(v) => format!("{v:?}"),
Value::Number(v) => v.to_string(),
Value::Object(v) => vm.repr(*v)?,
})
}
struct Candidate {
vm: Vm,
roots: Vec<ObjectRef>,
@@ -350,6 +547,15 @@ impl Architecture {
vec![child],
)
}
StmtKind::Assignment(assignment) => {
let child = self.expression(&assignment.value);
self.node(
assignment.name.text.clone(),
"assign",
statement.span,
vec![child],
)
}
StmtKind::Return(returned) => {
let children = returned
.value
@@ -398,6 +604,84 @@ mod tests {
const EXAMPLE: &str = include_str!("../examples/hello.endor");
const PAGE: &str = include_str!("../examples/page.endor");
#[test]
fn live_session_retains_real_objects_and_renders_only_on_request() {
let mut session = ReplSession::new(7).unwrap();
let created = session
.execute(
r##"title = ui.text("你好,Tessera", 2, "#243047")"##,
"app.main",
)
.unwrap();
assert!(created.document.is_none());
assert_eq!(
session.execute("title", "app.main").unwrap().value,
created.value
);
assert_eq!(
session.execute("_", "app.main").unwrap().value,
created.value
);
let rendered = session
.execute("page = ui.page()\npage.render(title)", "app.main")
.unwrap()
.document
.unwrap();
assert!(
matches!(&rendered.frame.commands[0], RenderCommand::Text { value, .. } if value == "你好,Tessera")
);
assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session");
assert!(
session
.execute(r#"title = ui.text("changed")"#, "app.main")
.unwrap()
.document
.is_none()
);
let updated = session
.execute("page.render(title)", "app.main")
.unwrap()
.document
.unwrap();
assert!(
matches!(&updated.frame.commands[0], RenderCommand::Text { value, .. } if value == "changed")
);
assert!(updated.source.contains("// Console input 6"));
assert!(
session
.execute("title", "app.main")
.unwrap()
.document
.is_none()
);
}
#[test]
fn live_session_compiles_before_execution_and_discards_failed_render() {
let mut session = ReplSession::new(8).unwrap();
session
.execute("value = 1\npage = ui.page()", "app.main")
.unwrap();
assert!(session.execute("value = 2\nmissing", "app.main").is_err());
assert_eq!(session.execute("value", "app.main").unwrap().value, "1");
assert!(
session
.execute(
"value = 3\npage.render(ui.text(\"discarded\"))\nui.rect(-1, 1, 0)",
"app.main"
)
.is_err()
);
let retained = session.execute("value", "app.main").unwrap();
assert_eq!(retained.value, "3");
assert!(retained.document.is_none());
session.execute("function local(value: Number): Number { result = value result = 42 return result }", "app.main").unwrap();
assert_eq!(
session.execute("local(40)", "app.main").unwrap().value,
"42"
);
}
#[test]
fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() {
let document = compile(PAGE, "app.main", 9).unwrap();
+5
View File
@@ -98,6 +98,11 @@ fn shift_statement(statement: &mut Stmt, offset: usize) {
shift_span(&mut binding.name.span, offset);
shift_expression(&mut binding.value, offset);
}
StmtKind::Assignment(assignment) => {
shift_span(&mut assignment.span, offset);
shift_span(&mut assignment.name.span, offset);
shift_expression(&mut assignment.value, offset);
}
StmtKind::Return(returned) => {
shift_span(&mut returned.span, offset);
if let Some(expression) = &mut returned.value {
+1
View File
@@ -12,6 +12,7 @@ mod layout;
mod scene;
mod software_renderer;
mod sources;
mod system_ui;
mod text;
mod ui_bridge;
+60
View File
@@ -0,0 +1,60 @@
# Tessera System UI
An internal Rust widget library for Tessera's own management windows and developer
tools. It is independent of Endor's `ui` global, `UiNode`, AST and VM. Guest scripts
cannot construct or control these management widgets. Only geometry, font shaping
and display-list rendering are shared with the page renderer.
## Modules
- `interaction.rs`: stable semantic widget IDs, hit testing, pointer capture,
release-inside activation, drag-out cancellation, keyboard focus traversal,
Enter/Space activation, disabled controls and cursor roles.
- `theme.rs`: neutral/error/success colors, accent presets, readable custom accents
and shared idle/hover/pressed/selected colors.
- `widgets.rs`: button, tab, toggle, text field chrome, selectable row, color swatch,
splitter and scrollbar thumb. Text is clipped to its widget bounds. Hover, pressed,
selection, keyboard focus and disabled states compose consistently.
- `mod.rs`: public interface used by management surfaces.
## Frame contract
All widget coordinates are logical pixels. Build a `Ui<Action>` from the current
`Theme` and a clone of retained `Interaction`. Actions are host-defined, `Hash`able
values; IDs come from semantic action keys, never screen coordinates or changing
labels. Explicit decoration IDs are scoped by widget kind and region.
```rust,ignore
let mut ui = Ui::new(Theme::new(accent), interaction.clone());
ui.button(bounds, Action::Refresh,
Button::new("Refresh").enabled(can_refresh));
ui.button(tab_bounds, Action::SelectTab(Tab::Sources),
Button::new("Sources").role(Role::Tab).selected(is_sources));
ui.text_field(input_bounds, Action::FocusEditor, editor_focused);
```
Store `ui.targets` with the newly produced layout and render `ui.list` through the
native display-list backend. Reconcile retained interaction against the new targets;
request one more redraw when a target moves, disappears or changes enabled state.
This settles hover under a stationary pointer after a layout change without a timer
or continuous rendering loop. Preserve clipping and scale every primitive, including
focus outlines, when converting logical coordinates to physical pixels.
Dispatch pointer movement, press and release to `Interaction`. Press establishes
capture; a button action is returned only when released inside the same enabled
widget. Text fields focus on press, while drag handles perform host-side resizing
or scrolling during capture. Pointer exit clears hover, focus loss cancels all
capture, and disabled targets block click-through to controls behind them.
Tab / Shift-Tab traverse enabled controls; Enter / Space activate a focused button
on key release. The host's editor keeps plain Tab for indentation and Shift-Tab for
leaving the editor. Rows and drag handles are excluded from Tab traversal. Text
editing, clipboard and IME remain host adapters; the widget layer owns their chrome
and focus presentation. `src/clipboard.rs` guarantees UTF-8 for macOS clipboard
subprocesses, independent of Finder's locale.
Tessera's developer tools are the first consumer. Domain actions, HTTP calls,
source navigation and Console execution remain in `developer_tools.rs`; none of
those concepts enter this library. The library currently renders synchronously
with immediate state feedback; it is not an Endor component implementation, an
OS accessibility bridge or a separately published Cargo crate.
+204
View File
@@ -0,0 +1,204 @@
use crate::geometry::{Point, Rect};
use std::hash::{Hash, Hasher};
/// Stable across frames, resizing and text changes. Use semantic keys, never row indices.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct WidgetId(u64);
impl WidgetId {
pub fn new(key: impl Hash) -> Self {
let mut hash = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hash);
Self(hash.finish())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
Horizontal,
Vertical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Button,
Tab,
Toggle,
TextField,
Row,
Swatch,
Splitter(Axis),
Scrollbar(Axis),
}
impl Role {
fn focusable(self) -> bool {
!matches!(self, Self::Splitter(_) | Self::Scrollbar(_) | Self::Row)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cursor {
Default,
Pointer,
Text,
ResizeHorizontal,
ResizeVertical,
Grab,
Disabled,
}
#[derive(Clone, Debug)]
pub struct Target<A> {
pub id: WidgetId,
pub rect: Rect,
pub role: Role,
pub enabled: bool,
pub action: Option<A>,
}
#[derive(Clone, Copy, Debug)]
pub struct VisualState {
pub hovered: bool,
pub pressed: bool,
pub focused: bool,
pub selected: bool,
pub enabled: bool,
}
/// Retained input state for an immediate-mode frame. Actions fire on release inside
/// the same enabled control, so dragging away cancels a click.
#[derive(Clone, Debug, Default)]
pub struct Interaction {
pointer: Option<Point>,
hovered: Option<WidgetId>,
pointer_active: Option<WidgetId>,
key_active: Option<WidgetId>,
focused: Option<WidgetId>,
keyboard_focus: bool,
}
impl Interaction {
pub fn hit<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
let p = self.pointer?;
targets.iter().rev().find(|t| t.rect.contains(p))
}
pub fn pointer_moved<A>(&mut self, p: Point, targets: &[Target<A>]) {
self.pointer = Some(p);
self.hovered = self.hit(targets).map(|t| t.id);
}
pub fn pointer_left(&mut self) {
self.pointer = None;
self.hovered = None;
}
pub fn press<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<Target<A>> {
self.key_active = None;
self.keyboard_focus = false;
let target = self.hit(targets).filter(|t| t.enabled).cloned();
self.pointer_active = target.as_ref().map(|t| t.id);
self.focused = target.as_ref().filter(|t| t.role.focusable()).map(|t| t.id);
target
}
pub fn release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
let active = self.pointer_active.take()?;
self.hit(targets)
.filter(|t| t.enabled && t.id == active && t.role != Role::TextField)
.and_then(|t| t.action.clone())
}
pub fn cancel(&mut self) {
self.pointer_active = None;
self.key_active = None;
}
pub fn blur(&mut self) {
self.cancel();
self.focused = None;
self.keyboard_focus = false;
self.pointer_left();
}
pub fn reconcile<A>(&mut self, targets: &[Target<A>]) -> bool {
let before = (
self.hovered,
self.pointer_active,
self.focused,
self.key_active,
);
let exists = |id| targets.iter().any(|t| t.id == id && t.enabled);
self.focused = self.focused.filter(|id| exists(*id));
self.pointer_active = self.pointer_active.filter(|id| exists(*id));
self.key_active = self.key_active.filter(|id| exists(*id));
self.hovered = self.hit(targets).map(|t| t.id);
before
!= (
self.hovered,
self.pointer_active,
self.focused,
self.key_active,
)
}
pub fn focused<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
targets
.iter()
.find(|t| Some(t.id) == self.focused && t.enabled)
}
pub fn focus_next<A: Clone>(&mut self, targets: &[Target<A>], back: bool) -> Option<Target<A>> {
self.cancel();
self.keyboard_focus = true;
let candidates = targets
.iter()
.filter(|t| t.enabled && t.role.focusable())
.collect::<Vec<_>>();
if candidates.is_empty() {
self.focused = None;
return None;
}
let current = candidates.iter().position(|t| Some(t.id) == self.focused);
let index = match current {
Some(i) if back => (i + candidates.len() - 1) % candidates.len(),
Some(i) => (i + 1) % candidates.len(),
None if back => candidates.len() - 1,
None => 0,
};
let target = candidates[index];
self.focused = Some(target.id);
Some(target.clone())
}
pub fn key_press<A>(&mut self, targets: &[Target<A>]) {
self.key_active = self
.focused(targets)
.filter(|t| t.role != Role::TextField)
.map(|t| t.id);
}
pub fn key_release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
let active = self.key_active.take()?;
self.focused(targets)
.filter(|t| t.id == active)
.and_then(|t| t.action.clone())
}
pub fn visual(&self, id: WidgetId, selected: bool, enabled: bool) -> VisualState {
let hovered = enabled && self.hovered == Some(id);
VisualState {
hovered,
pressed: enabled
&& (self.key_active == Some(id) || (hovered && self.pointer_active == Some(id))),
focused: enabled && self.keyboard_focus && self.focused == Some(id),
selected,
enabled,
}
}
pub fn captured(&self, id: WidgetId) -> bool {
self.pointer_active == Some(id)
}
pub fn cursor<A>(&self, targets: &[Target<A>]) -> Cursor {
let target = self
.pointer_active
.and_then(|id| {
targets.iter().find(|t| {
t.id == id && matches!(t.role, Role::Splitter(_) | Role::Scrollbar(_))
})
})
.or_else(|| self.hit(targets));
match target {
Some(t) if !t.enabled => Cursor::Disabled,
Some(t) => match t.role {
Role::TextField => Cursor::Text,
Role::Splitter(Axis::Horizontal) => Cursor::ResizeHorizontal,
Role::Splitter(Axis::Vertical) => Cursor::ResizeVertical,
Role::Scrollbar(_) => Cursor::Grab,
_ => Cursor::Pointer,
},
None => Cursor::Default,
}
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Tessera's host management widgets. No Endor AST, VM, UiNode or ui_bridge dependency.
//! Shares only low-level drawing, font shaping and geometry with guest content.
mod interaction;
pub mod theme;
mod widgets;
pub use interaction::{Axis, Cursor, Interaction, Role, Target, VisualState, WidgetId};
pub use widgets::{Button, Ui};
+55
View File
@@ -0,0 +1,55 @@
use crate::display_list::Color;
pub const BG: Color = Color::rgb(255, 255, 255);
pub const BAR: Color = Color::rgb(243, 245, 248);
pub const BORDER: Color = Color::rgb(214, 220, 229);
pub const TEXT: Color = Color::rgb(35, 42, 53);
pub const MUTED: Color = Color::rgb(103, 114, 130);
pub const ERROR: Color = Color::rgb(175, 42, 49);
pub const OK: Color = Color::rgb(31, 121, 80);
pub const PALETTE: [Color; 6] = [
Color::rgb(22, 113, 213),
Color::rgb(0, 132, 143),
Color::rgb(115, 76, 189),
Color::rgb(180, 68, 115),
Color::rgb(168, 94, 24),
Color::rgb(52, 123, 78),
];
#[derive(Clone, Copy, Debug)]
pub struct Theme {
pub accent: Color,
}
impl Theme {
pub fn new(accent: Color) -> Self {
Self {
accent: readable_accent(accent),
}
}
pub fn soft(self) -> Color {
mix(BG, self.accent, 0.10)
}
pub fn hover(self) -> Color {
mix(BG, self.accent, 0.17)
}
pub fn pressed(self) -> Color {
mix(BG, self.accent, 0.29)
}
}
pub fn mix(a: Color, b: Color, amount: f32) -> Color {
let channel =
|a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * amount.clamp(0.0, 1.0)).round() as u8;
Color::rgb(
channel(a.red, b.red),
channel(a.green, b.green),
channel(a.blue, b.blue),
)
}
pub fn readable_accent(mut c: Color) -> Color {
while (c.red as u32 * 299 + c.green as u32 * 587 + c.blue as u32 * 114) / 1000 > 155 {
c = Color::rgb(
(c.red as f32 * 0.94) as u8,
(c.green as f32 * 0.94) as u8,
(c.blue as f32 * 0.94) as u8,
);
}
c
}
+279
View File
@@ -0,0 +1,279 @@
use super::{
Axis, Interaction, Role, Target, VisualState, WidgetId,
theme::{self, Theme},
};
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::{self, TextStyle},
};
use std::hash::Hash;
pub struct Button<'a> {
pub label: &'a str,
pub selected: bool,
pub enabled: bool,
pub role: Role,
}
impl<'a> Button<'a> {
pub fn new(label: &'a str) -> Self {
Self {
label,
selected: false,
enabled: true,
role: Role::Button,
}
}
pub fn selected(mut self, value: bool) -> Self {
self.selected = value;
self
}
pub fn enabled(mut self, value: bool) -> Self {
self.enabled = value;
self
}
pub fn role(mut self, value: Role) -> Self {
self.role = value;
self
}
}
pub struct Ui<A> {
pub list: DisplayList,
pub targets: Vec<Target<A>>,
pub theme: Theme,
interaction: Interaction,
}
impl<A: Hash> Ui<A> {
pub fn new(theme: Theme, interaction: Interaction) -> Self {
Self {
list: DisplayList::default(),
targets: Vec::new(),
theme,
interaction,
}
}
pub fn fill(&mut self, r: Rect, color: Color) {
self.list.push(DrawCommand::FillRoundedRect {
rect: r,
radius: 0.0,
color,
});
}
fn outline(&mut self, r: Rect, color: Color, width: f32) {
self.list.push(DrawCommand::StrokeRoundedRect {
rect: Rect::new(
r.x + width / 2.0,
r.y + width / 2.0,
(r.width - width).max(0.0),
(r.height - width).max(0.0),
),
radius: 3.0,
width,
color,
});
}
pub fn label(&mut self, x: f32, y: f32, value: &str, color: Color, code: bool) {
self.list.push(DrawCommand::Text {
origin: Point::new(x, y),
value: value.into(),
style: if code {
TextStyle::code()
} else {
TextStyle {
font_size: 12.0,
line_height: 18.0,
..TextStyle::ui(1)
}
},
color,
});
}
fn register(
&mut self,
id: WidgetId,
rect: Rect,
role: Role,
action: Option<A>,
selected: bool,
enabled: bool,
) -> VisualState {
let response = self.interaction.visual(id, selected, enabled);
self.targets.push(Target {
id,
rect,
role,
enabled,
action,
});
response
}
fn surface(&mut self, rect: Rect, s: VisualState, base: Option<Color>) {
let color = if !s.enabled {
base
} else if s.pressed {
Some(self.theme.pressed())
} else if s.hovered {
Some(self.theme.hover())
} else if s.selected {
Some(self.theme.soft())
} else {
base
};
if let Some(color) = color {
self.fill(rect, color);
}
if s.focused {
self.outline(rect, self.theme.accent, 1.5);
}
}
pub fn button(&mut self, rect: Rect, action: A, button: Button<'_>) -> VisualState {
let id = WidgetId::new(&action);
let s = self.register(
id,
rect,
button.role,
Some(action),
button.selected,
button.enabled,
);
self.surface(rect, s, None);
self.list.push(DrawCommand::PushClip(rect));
let label = text::fit(
button.label,
(rect.width - 16.0).max(0.0),
TextStyle {
font_size: 12.0,
line_height: 18.0,
..TextStyle::ui(1)
},
);
self.label(
rect.x + 8.0,
rect.y + (rect.height - 18.0) / 2.0,
&label,
if s.enabled {
theme::TEXT
} else {
Color::rgb(151, 160, 172)
},
false,
);
if s.selected && button.role == Role::Tab {
self.fill(
Rect::new(rect.x, rect.y + rect.height - 2.0, rect.width, 2.0),
self.theme.accent,
);
}
self.list.push(DrawCommand::PopClip);
s
}
pub fn text_field(&mut self, rect: Rect, action: A, focused: bool) {
let s = self.register(
WidgetId::new(&action),
rect,
Role::TextField,
Some(action),
false,
true,
);
self.surface(rect, s, Some(theme::BG));
self.outline(
rect,
if focused || s.focused {
self.theme.accent
} else if s.hovered {
theme::MUTED
} else {
theme::BORDER
},
if focused || s.focused { 1.5 } else { 1.0 },
);
}
pub fn row(&mut self, paint: Rect, hit: Rect, action: A, selected: bool) {
let s = self.register(
WidgetId::new(&action),
hit,
Role::Row,
Some(action),
selected,
true,
);
self.surface(paint, s, None);
}
pub fn swatch(&mut self, rect: Rect, action: A, color: Color, selected: bool) {
let s = self.register(
WidgetId::new(&action),
rect,
Role::Swatch,
Some(action),
selected,
true,
);
self.fill(
rect,
if s.pressed {
theme::mix(color, theme::TEXT, 0.2)
} else {
color
},
);
if s.hovered || s.focused || selected {
self.outline(
Rect::new(
rect.x - 3.0,
rect.y - 3.0,
rect.width + 6.0,
rect.height + 6.0,
),
if s.focused {
self.theme.accent
} else {
theme::TEXT
},
if s.pressed { 3.0 } else { 1.5 },
);
}
if selected {
self.label(rect.x + 10.0, rect.y + 6.0, "✓", theme::BG, false);
}
}
pub fn splitter(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
let hit = Rect::new(
rect.x - 3.0,
rect.y - 3.0,
rect.width + 6.0,
rect.height + 6.0,
);
let mut s = self.register(id, hit, Role::Splitter(axis), None, false, true);
s.pressed |= self.interaction.captured(id);
let color = if s.pressed || s.hovered {
self.theme.accent
} else {
theme::BORDER
};
let line = if s.hovered || s.pressed {
match axis {
Axis::Horizontal => Rect::new(rect.x - 1.0, rect.y, 3.0, rect.height),
Axis::Vertical => Rect::new(rect.x, rect.y - 1.0, rect.width, 3.0),
}
} else {
rect
};
self.fill(line, color);
}
pub fn scrollbar(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
let mut s = self.register(id, rect, Role::Scrollbar(axis), None, false, true);
s.pressed |= self.interaction.captured(id);
self.fill(
rect,
if s.pressed {
self.theme.accent
} else if s.hovered {
theme::mix(theme::MUTED, self.theme.accent, 0.55)
} else {
Color::rgb(161, 173, 190)
},
);
}
}
+8
View File
@@ -298,6 +298,14 @@ pub struct UiBridge {
}
impl UiBridge {
/// Drain this input's render request; retained UI objects remain in the session pool.
pub fn take_rendered(&self) -> Result<Option<Arc<UiNode>>> {
let mut session = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI build session is poisoned"))?;
Ok(session.rendered.take().map(|(_, root)| root))
}
pub fn rendered(&self) -> Result<Option<Arc<UiNode>>> {
let session = self
.session
+1 -1