feat: add system widgets and a persistent native Console with bare assignments
Verify Tessera GUI / verify (push) Failing after 7m17s
Verify Tessera GUI / verify (push) Failing after 7m17s
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
¤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<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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ mod layout;
|
||||
mod scene;
|
||||
mod software_renderer;
|
||||
mod sources;
|
||||
mod system_ui;
|
||||
mod text;
|
||||
mod ui_bridge;
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/aster updated: 6281e58647...df1b77fc1a
Reference in New Issue
Block a user