diff --git a/README.md b/README.md index af29e2d..9e4a098 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/README.md b/src/README.md index cd6a0af..d7460b2 100644 --- a/src/README.md +++ b/src/README.md @@ -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. diff --git a/src/api.rs b/src/api.rs index cebdbbc..9bd691a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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; +#[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", ¤t.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>, sender: &SyncSender, - 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>, + notify: &Notify, + ) -> Response>> { + 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>, notify: &Notify, ) -> Response>> { - 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>> { ) } -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 { 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()); diff --git a/src/app.rs b/src/app.rs index 37e279d..4ed3e0c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 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 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 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), diff --git a/src/developer_tools.rs b/src/developer_tools.rs index e0092a7..4252b82 100644 --- a/src/developer_tools.rs +++ b/src/developer_tools.rs @@ -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>, pub cloud_sources: SourceWorkspace, pub tab: Tab, @@ -115,7 +106,6 @@ pub struct DeveloperToolsState { scroll: [Scroll; 6], widths: [Option<(u64, f32)>; 6], drag: Option, - script: bool, cloud: bool, follow: bool, selected_request: Option, @@ -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>, 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, + 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, 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, value: &str, color: Color) { out.extend( @@ -824,6 +909,33 @@ fn multiline(out: &mut Vec, value: &str, color: Color) { } fn console_result(out: &mut Vec, body: &str, color: Color) { if let Ok(value) = serde_json::from_str::(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, r: &Exchange) { diff --git a/src/endor_host.rs b/src/endor_host.rs index f70439c..d64abb4 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -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 { } fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result> { - 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, +) -> Result> { // 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 Result, +} +pub struct ReplResult { + pub value: String, + pub document: Option, + pub bindings: usize, + pub input: usize, +} +impl ReplSession { + pub fn new(id: u64) -> Result { + 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 { + 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 = (|| { + 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 { + 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, @@ -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(); diff --git a/src/endor_script.rs b/src/endor_script.rs index 8bbe1ef..9c01907 100644 --- a/src/endor_script.rs +++ b/src/endor_script.rs @@ -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 { diff --git a/src/main.rs b/src/main.rs index 5b9cd94..bd84ee4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod layout; mod scene; mod software_renderer; mod sources; +mod system_ui; mod text; mod ui_bridge; diff --git a/src/system_ui/README.md b/src/system_ui/README.md new file mode 100644 index 0000000..f1ab46d --- /dev/null +++ b/src/system_ui/README.md @@ -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` 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. diff --git a/src/system_ui/interaction.rs b/src/system_ui/interaction.rs new file mode 100644 index 0000000..4578a07 --- /dev/null +++ b/src/system_ui/interaction.rs @@ -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 { + pub id: WidgetId, + pub rect: Rect, + pub role: Role, + pub enabled: bool, + pub action: Option, +} +#[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, + hovered: Option, + pointer_active: Option, + key_active: Option, + focused: Option, + keyboard_focus: bool, +} +impl Interaction { + pub fn hit<'a, A>(&self, targets: &'a [Target]) -> Option<&'a Target> { + let p = self.pointer?; + targets.iter().rev().find(|t| t.rect.contains(p)) + } + pub fn pointer_moved(&mut self, p: Point, targets: &[Target]) { + 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(&mut self, targets: &[Target]) -> Option> { + 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(&mut self, targets: &[Target]) -> Option { + 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(&mut self, targets: &[Target]) -> 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]) -> Option<&'a Target> { + targets + .iter() + .find(|t| Some(t.id) == self.focused && t.enabled) + } + pub fn focus_next(&mut self, targets: &[Target], back: bool) -> Option> { + self.cancel(); + self.keyboard_focus = true; + let candidates = targets + .iter() + .filter(|t| t.enabled && t.role.focusable()) + .collect::>(); + 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(&mut self, targets: &[Target]) { + self.key_active = self + .focused(targets) + .filter(|t| t.role != Role::TextField) + .map(|t| t.id); + } + pub fn key_release(&mut self, targets: &[Target]) -> Option { + 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(&self, targets: &[Target]) -> 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, + } + } +} diff --git a/src/system_ui/mod.rs b/src/system_ui/mod.rs new file mode 100644 index 0000000..9f75629 --- /dev/null +++ b/src/system_ui/mod.rs @@ -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}; diff --git a/src/system_ui/theme.rs b/src/system_ui/theme.rs new file mode 100644 index 0000000..7c710aa --- /dev/null +++ b/src/system_ui/theme.rs @@ -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 +} diff --git a/src/system_ui/widgets.rs b/src/system_ui/widgets.rs new file mode 100644 index 0000000..8245d2a --- /dev/null +++ b/src/system_ui/widgets.rs @@ -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 { + pub list: DisplayList, + pub targets: Vec>, + pub theme: Theme, + interaction: Interaction, +} +impl Ui { + 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, + 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) { + 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) + }, + ); + } +} diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index 9468d28..62300cb 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -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>> { + 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>> { let session = self .session diff --git a/vendor/aster b/vendor/aster index 6281e58..df1b77f 160000 --- a/vendor/aster +++ b/vendor/aster @@ -1 +1 @@ -Subproject commit 6281e5864783ed23d8959425e7c8b519b61b2105 +Subproject commit df1b77fc1ab5fd4dc37f4a4fce8744fc0e91e7d5