From 952fc2eff2e2ef5500516ccd735e904cc61689a7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 24 Oct 2026 20:12:02 +0800 Subject: [PATCH] feat: build functional Endor start page and balance compact tabs --- README.md | 10 +++++- docs/ui-workspace-audit.md | 41 ++++++++++++++++++++-- src/README.md | 3 +- src/scene.rs | 7 +--- src/system_ui/README.md | 2 +- src/system_ui/mod.rs | 2 -- src/system_ui/start_page.rs | 51 --------------------------- src/tabs.rs | 51 ++++++++++++++++++++------- src/ui_bridge.rs | 4 ++- src/ui_bridge/developer_pages.rs | 57 +++++++++++++++++++++++++++--- src/ui_bridge/start_page.endor | 59 ++++++++++++++++++++++++++++++++ 11 files changed, 205 insertions(+), 82 deletions(-) delete mode 100644 src/system_ui/start_page.rs create mode 100644 src/ui_bridge/start_page.endor diff --git a/README.md b/README.md index 9a360c4..f76b10c 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,15 @@ create additional managed Pages; Cmd/Ctrl+W closes the active Page. New tabs exp from left to right while existing tabs interpolate to their new bounds when space becomes limited. Hover and selection use the same capsule geometry, and animated pointer hits use the rendered bounds. Source and page identity remain in the VM; -chrome animation retains only projected scalar IDs and rectangles. +chrome animation retains only projected scalar IDs and rectangles. Tabs are capped at +208 logical pixels wide, with a 40-pixel strip and equal 4-pixel vertical insets. +The leading page symbol and close control have matching slots and edge spacing. + +The default page is implemented in `src/ui_bridge/start_page.endor`, compiled once +per VM into a private namespace. Its buttons create a Page, open a working shared +counter, or show composed Blocks. `ui.newTab()` constructs the same ordinary Page +used by native + and the keyboard shortcut. Layout, callbacks, state and bindings +are Endor objects; the host does not paint an introduction over the guest page. Console, Sources, Network and Settings are independent managed Pages with separate view modules and window-local input/focus/scroll/selection state. They are not diff --git a/docs/ui-workspace-audit.md b/docs/ui-workspace-audit.md index bab9f0b..d1319fe 100644 --- a/docs/ui-workspace-audit.md +++ b/docs/ui-workspace-audit.md @@ -4,9 +4,9 @@ This audit follows the full conversation, including later corrections. Implement | Requirement | Implementation | Evidence / limits | | --- | --- | --- | -| One top row with native macOS controls | Transparent/full-size native title bar; reserved control space; 44 px tab strip | Actual Retina macOS windows viewed; native close/minimize/fullscreen controls retained | +| One top row with native macOS controls | Transparent/full-size native title bar; reserved control space; 40 px tab strip | Actual Retina macOS windows viewed; native close/minimize/fullscreen controls retained | | Continuous left/top chrome | One neutral background; 56 px left rail; inset content panel | Actual main and detached windows viewed; no Tools label or rail launchers | -| Larger symmetric round tabs, softer active state | Common capsule bounds, measured glyph centering, no hard active stroke | Native settled UI viewed; hover and selected use the same bounds | +| Compact symmetric round tabs, softer active state | 208 px maximum width; equal 4 px vertical insets; matching icon/close slots; no hard active stroke | Native settled UI viewed; hover and selected use the same bounds | | + and startup default page | One startup request guarded against duplicate resume; + release-inside; Cmd/Ctrl+T | Immediately after restart: pages().count = 1. Two native Cmd+T actions produced three tabs | | New-tab motion, filled and unfilled | 200 ms cubic ease; new width grows from zero; existing bounds and + interpolate; scalar-only animation data | Real native new/close/fill operations completed; settled compression/overflow viewed. No frame-by-frame recording or Chrome timing comparison | | Overflow and scrolling | Separate + reservation; arrows, edge dwell and hit bounds share tab viewport | Native overflow reached; + in overflow increased VM count 7 → 8. Dragging out of + kept count at 7 | @@ -15,7 +15,7 @@ This audit follows the full conversation, including later corrections. Implement | Four tools not default or permanently placed | No Tools menu, rail buttons or new-page tool directory; existing API/shortcut retained | Final default surfaces viewed without tool entries | | Source reading/selection/copy | Grapheme-boundary selection, no gutter in copy, select-all/copy, Shift navigation, drag edge scrolling | Native single-line drag/copy/paste verified earlier in this change. Final keyboard copy was re-run after the chrome-focus fix: drag-copy matched the complete selected line; four Shift+Right operations copied the expected four characters; select-all copied multiline source without a gutter. Drag edge behavior compiled; exhaustive combinations remain untested | | Clicking Console output must not jump to prompt/end | Input hit region restricted to actual prompt rows | Native reading/copy path verified earlier; no input hit over transcript-only area | -| Default page reflects Endor | Objects, code and UI share identity; persistence/model connections explicitly described as work in progress | Referenced chats read: 01a16819-28c3-7ce0-9423-0ffaa19402ce and 01a1689b-77d8-75a2-ba9e-b2fa3dce654e; actual native start surface viewed | +| Default page reflects Endor | Real Endor Page with creation, shared-object and composition actions; no persistence/model backend claims | Referenced chats read: 01a16819-28c3-7ce0-9423-0ffaa19402ce and 01a1689b-77d8-75a2-ba9e-b2fa3dce654e; actual native start surface viewed | | Sensible source organization | Runtime/API tests separated; four page view modules; shared painter/selection; native tool Page factory; motion module | All-target Clippy compiled production and test sources. Runtime host reduced from ~7000 to ~1380 lines | | Protect existing work | examples/README.md and examples/workspace.endor excluded; vendor/Aster untouched | git status checked before staging | | Verification restrictions | fmt, all-target Clippy -D warnings, release build and manual native/API checks | No cargo test, smoke script, Docker, dependency test install or CI polling | @@ -44,3 +44,38 @@ After the fix, direct system clipboard comparisons (without printing clipboard c ## Local delivery state Implementation commits: 3220544 (runtime test-source separation), 656522e (managed workspace/chrome implementation), d3165b1 (behavior and requirement audit), followed by the chrome-focus fix commit. A push to origin/main was attempted once and rejected by automatic approval review because explicit authorization for this default-branch remote mutation was required. No alternative push route was attempted. The remote remains unchanged pending the user's approval. + + +## Functional start-page revision + +The previous Rust-painted introduction was removed. `ui_bridge/start_page.endor` +now defines a managed Page subclass with native layout alignment, real buttons, +a shared-state counter and a composed-Blocks example. Its private module globals +do not overwrite user REPL bindings. The same factory is available as `ui.newTab()`; +legacy `ui.developerTools("New tab")` delegates to it. No developer-tool entry is +shown on the start page. + +Native startup checks caught unsupported comparison syntax and unimplemented +arithmetic bytecode in initial layout drafts. Those drafts were replaced with +existing Page/Row measurement and native `mainAlign`, without changing Aster. +A subsequent layout check caught unbounded child-height centering; sizing now +belongs to the root Page, which receives the actual content constraints. +These failures were corrected locally, and are not counted as passing checks. + + +Final native checks for this revision: all three start-page buttons opened their +corresponding managed Pages; the blank-page return action activated its original +home Page; one native counter click changed both views from 0 to 1. Overflow +navigation was visible while these Pages were added. A direct `ui.newTab()` call +increased the live Page count from 4 to 5, confirming one Page per factory call. +The final window was also being operated by the user (reported by the UI tool), +so the pre-existing four tabs are not treated as a clean-start count measurement. +No additional restart was done after that interaction. The earlier isolated +startup count of one remains evidence for the guarded startup path; the new +factory's single-call count was checked separately. + +Formatting, all-target offline Clippy with warnings denied, the release macOS +bundle and diff whitespace checks passed. No test suite or smoke script was run. +All earlier requirements were reviewed against the table above; isolated Chrome +motion comparison and the explicitly listed cross-window/DPI verification gaps +remain unverified, rather than being marked complete. diff --git a/src/README.md b/src/README.md index 8e3b70a..bcf6e51 100644 --- a/src/README.md +++ b/src/README.md @@ -257,7 +257,8 @@ Property binding caches now consume synchronous precise ReadSet dependencies, re Window workspace modules: - `tabs.rs`: common tab/close/+ geometry, overflow and shared hover/active capsules. - `tabs/motion.rs`: 200 ms eased geometry shared by painting and pointer hits. -- `system_ui/start_page.rs`: the Endor start page; future capabilities are marked as such. +- `ui_bridge/start_page.endor`: the functional default Page, layout, callbacks and examples in Endor. +- `ui_bridge/developer_pages.rs`: loads the private start-page module and delegates new-tab creation to its managed factory. - `ui_bridge/developer_pages.rs`: managed tool/start Pages, identity-preserving reopen and focus. - `developer_tools/{console,sources,network,settings}.rs`: separate page views. - `developer_tools/painter.rs`: reusable pane painting and response formatting. diff --git a/src/scene.rs b/src/scene.rs index 1a70570..1f96343 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -88,12 +88,7 @@ pub fn build_scene( ); developer_list.translate(crate::geometry::Point::new(left * scale, offset * scale)); developer_layout.translate(left, offset); - if tool == Some("New tab") { - developer_layout = DeveloperToolsLayout::default(); - crate::system_ui::start_page::draw(&mut display_list, content, scale); - } else { - display_list.extend(developer_list); - } + display_list.extend(developer_list); DisplayScene { display_list, diff --git a/src/system_ui/README.md b/src/system_ui/README.md index 5e23cf1..fe02c72 100644 --- a/src/system_ui/README.md +++ b/src/system_ui/README.md @@ -63,4 +63,4 @@ 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. -`start_page.rs` paints the built-in Endor start surface. Native window chrome uses the same neutral palette, while each tool Page has its own interaction state. +The built-in start page lives in `ui_bridge/start_page.endor` and uses the ordinary guest Page/Block renderer. System UI no longer paints a welcome overlay. Native window chrome uses the same neutral palette, while each tool Page has its own interaction state. diff --git a/src/system_ui/mod.rs b/src/system_ui/mod.rs index 7e2eaf2..9f75629 100644 --- a/src/system_ui/mod.rs +++ b/src/system_ui/mod.rs @@ -5,5 +5,3 @@ pub mod theme; mod widgets; pub use interaction::{Axis, Cursor, Interaction, Role, Target, VisualState, WidgetId}; pub use widgets::{Button, Ui}; - -pub mod start_page; diff --git a/src/system_ui/start_page.rs b/src/system_ui/start_page.rs deleted file mode 100644 index 9e36c93..0000000 --- a/src/system_ui/start_page.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! The start page introduces Endor without advertising unimplemented controls. -use super::theme; -use crate::{ - display_list::{DisplayList, DrawCommand}, - geometry::{Point, Rect}, - text::{self, TextStyle}, -}; -pub fn draw(list: &mut DisplayList, content: Rect, scale: f32) { - list.push(DrawCommand::FillRoundedRect { - rect: content, - radius: 12.0 * scale, - color: theme::BG, - }); - let width = content.width / scale; - let left = content.x / scale + ((width - 620.0) / 2.0).max(32.0); - let top = content.y / scale + (content.height / scale * 0.20).clamp(40.0, 150.0); - list.push(DrawCommand::PushClip(content)); - for (dy, value, size, color) in [ - (0.0, "Endor", 3, theme::TEXT), - (82.0, "对象、代码与界面,在同一个世界里。", 1, theme::TEXT), - (118.0, "构建可交互的页面、工具与工作流。", 1, theme::MUTED), - (210.0, "对象拥有身份", 1, theme::TEXT), - ( - 240.0, - "代码与可视界面共享同一份对象,改变对象,也改变它的展示。", - 1, - theme::MUTED, - ), - (310.0, "从这里开始", 1, theme::TEXT), - ( - 340.0, - "这是一个新的页面。你的程序可以在这里组织和呈现对象。", - 1, - theme::MUTED, - ), - ( - 430.0, - "正在构建:持久对象与多模态模型连接。", - 1, - theme::MUTED, - ), - ] { - list.push(DrawCommand::Text { - origin: Point::new(left * scale, (top + dy) * scale), - value: text::fit(value, (width - 64.0).min(620.0), TextStyle::ui(size)), - style: TextStyle::ui(size).scaled(scale), - color, - }); - } - list.push(DrawCommand::PopClip); -} diff --git a/src/tabs.rs b/src/tabs.rs index a6492c0..c9fbe5b 100644 --- a/src/tabs.rs +++ b/src/tabs.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; mod motion; pub use motion::{Motion, Visual}; -pub const HEIGHT: f32 = 44.0; +pub const HEIGHT: f32 = 40.0; pub const INSET: f32 = 8.0; const NEW_TAB_SPACE: f32 = 44.0; pub const RAIL: f32 = 56.0; @@ -33,7 +33,7 @@ pub struct Tab { pub fn height(tabs: &[Tab]) -> f32 { let _ = tabs; - HEIGHT + INSET + HEIGHT } /// A drag viewport is presentation state only: it never activates a Page. @@ -97,7 +97,7 @@ fn items(start: Option, tabs: &[Tab], width: f32) -> Vec> { let overflow = count < tabs.len(); let margin = if overflow { 32.0 } else { 0.0 }; let width = width - NEW_TAB_SPACE; - let tab_width = ((width - LEADING - margin * 2.0) / count as f32).min(240.0); + let tab_width = ((width - LEADING - margin * 2.0) / count as f32).min(208.0); let mut result = Vec::new(); if overflow && start > 0 { result.push(Item { @@ -299,7 +299,7 @@ pub fn draw( origin: Point::new( (plus.x + (plus.width - crate::text::measure("+", TextStyle::ui(1)).width) / 2.0) * scale, - ((HEIGHT - 20.0) / 2.0) * scale, + ((HEIGHT - TextStyle::ui(1).line_height) / 2.0) * scale, ), value: "+".into(), style: TextStyle::ui(1).scaled(scale), @@ -326,24 +326,51 @@ pub fn draw( }, }); } + if item.navigation.is_none() && item.rect.width >= 66.0 { + // Same 20px slot and 8px edge inset as the close button. + let icon = "◇"; + list.push(DrawCommand::Text { + origin: Point::new( + (item.rect.x + + 8.0 + + (20.0 - crate::text::measure(icon, TextStyle::ui(1)).width) / 2.0) + * scale, + ((HEIGHT - TextStyle::ui(1).line_height) / 2.0) * scale, + ), + value: icon.into(), + style: TextStyle::ui(1).scaled(scale), + color: crate::system_ui::theme::MUTED, + }); + } let title = item.navigation.unwrap_or(&item.tab.title); let text_rect = Rect::new( - item.rect.x + 14.0, - 4.0, + item.rect.x + + if item.navigation.is_some() { + 10.0 + } else { + 34.0 + }, + (HEIGHT - TextStyle::ui(1).line_height) / 2.0, (item.rect.width - if item.navigation.is_some() { 12.0 } else { - 46.0 + 66.0 }) .max(0.0), - 28.0, + TextStyle::ui(1).line_height, ); list.push(DrawCommand::PushClip(scaled(text_rect))); list.push(DrawCommand::Text { origin: Point::new( - (item.rect.x + 14.0) * scale, - ((HEIGHT - 20.0) / 2.0) * scale, + (item.rect.x + + if item.navigation.is_some() { + 10.0 + } else { + 34.0 + }) + * scale, + ((HEIGHT - TextStyle::ui(1).line_height) / 2.0) * scale, ), value: title.into(), style: TextStyle::ui(1).scaled(scale), @@ -369,7 +396,7 @@ pub fn draw( - crate::text::measure("×", TextStyle::ui(1)).width) / 2.0) * scale, - ((HEIGHT - 20.0) / 2.0) * scale, + ((HEIGHT - TextStyle::ui(1).line_height) / 2.0) * scale, ), value: "×".into(), style: TextStyle::ui(1).scaled(scale), @@ -425,7 +452,7 @@ mod tests { &tabs, LEADING + NEW_TAB_SPACE + 1000.0, 1, - Point::new(LEADING + 220.0, 18.0) + Point::new(LEADING + 190.0, 18.0) ) .is_none() ); diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index 3641ae2..8b33785 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -3407,7 +3407,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { crate::protocols::install(vm, roots, desktop, ui)?; let tasks = crate::tasks::install(vm, roots, ui, desktop)?; developer_pages::install(vm, ui, &session)?; - Ok(UiBridge { ui, session, tasks }) + let bridge = UiBridge { ui, session, tasks }; + developer_pages::install_start_page(vm, &bridge)?; + Ok(bridge) } fn attach_named_type( diff --git a/src/ui_bridge/developer_pages.rs b/src/ui_bridge/developer_pages.rs index 0b0c686..1ba12c1 100644 --- a/src/ui_bridge/developer_pages.rs +++ b/src/ui_bridge/developer_pages.rs @@ -4,6 +4,7 @@ use super::*; #[derive(Debug)] struct OpenTool { session: Shared, + ui: ObjectRef, } impl NativeCallable for OpenTool { fn arity(&self) -> Option { @@ -16,6 +17,13 @@ impl NativeCallable for OpenTool { if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) { return Err(invalid("unknown developer tool")); } + if name == "New tab" { + let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?; + return Ok(NativeResult::Invoke { + function: factory, + arguments: vec![], + }); + } let nodes = self .session .lock() @@ -23,9 +31,6 @@ impl NativeCallable for OpenTool { .nodes .clone(); for weak in nodes { - if name == "New tab" { - break; - } if let Some(page) = cx.upgrade(weak)? && cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone())) { @@ -63,10 +68,54 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<() "developerTools", OpenTool { session: session.clone(), + ui, }, - vec![], + vec![ui], ))?; vm.set_attr(ui, "developerTools", Value::Object(function))?; vm.release(function)?; Ok(()) } + +/// Compile the bundled Endor page once per VM, in a private module namespace. +/// Its functions/classes and callbacks remain ordinary managed VM objects. +pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> { + let source = frontend::EndorReader::from_text( + "tessera:start-page.endor", + include_str!("start_page.endor"), + ); + let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Module)?; + let compiled = compiler::compile_ast( + &ast, + &source, + &compiler::CompileOptions { + globals: std::iter::once("ui") + .chain(TYPE_NAMES.iter().copied()) + .map(str::to_owned) + .collect(), + }, + ) + .map_err(|error| source.annotate(error))?; + let globals = vm.new_dict()?; + let result = (|| -> Result<()> { + bridge.install_globals(vm, globals)?; + let code = compiled.load(vm)?; + let value = vm.execute_with_limits( + code, + globals, + endor_core::ExecutionLimits { + max_frames: 128, + max_instructions: 100_000, + }, + ); + vm.release(code)?; + vm.release_value(value?)?; + let factory = vm + .dict_get(globals, &Value::Text("newTab".into()))? + .ok_or_else(|| anyhow::anyhow!("start page module requires newTab"))?; + vm.set_attr(bridge.ui, "newTab", factory)?; + Ok(()) + })(); + vm.release(globals)?; + result +} diff --git a/src/ui_bridge/start_page.endor b/src/ui_bridge/start_page.endor new file mode 100644 index 0000000..a4d0212 --- /dev/null +++ b/src/ui_bridge/start_page.endor @@ -0,0 +1,59 @@ +// Tessera's start page is an ordinary Endor Page, including its layout and actions. +// This module has its own globals; creating tabs does not replace user bindings. +class StartPage: Page { + function measure(self: StartPage, mount: Mount, constraints: Constraints): MeasureResult { + return MeasureResult(constraints.maxWidth, constraints.maxHeight) + } +} +class StartRow: Row { + function measure(self: StartRow, mount: Mount, constraints: Constraints): MeasureResult { + measured = Row.measure(self, mount, constraints) + return MeasureResult(constraints.maxWidth, measured.height) + } +} +class StartActions { + home: Page + function blank(self: StartActions, event: Event): Unit { + page = Page() + page.title = "未命名页面" + back = Button("返回开始页") + back.onClick(self.home.activate) + page.render(Column(32, 16, TextBlock("未命名页面", 2), TextBlock("一个新的 Page 对象,已加入当前窗口。"), TextBlock("用 Endor 的 page.render(...) 组织内容。"), back)) + } + function counter(self: StartActions, event: Event): Unit { + count = ui.state(0) + add = Button("增加计数") + add.onClick(count.increment) + page = Page() + page.title = "共享对象" + page.render(Column(32, 16, TextBlock("一个对象,两个视图", 2), TextBlock("点击一次,两处展示同时更新。切换标签后,状态仍然保留。"), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add)) + } + function compose(self: StartActions, event: Event): Unit { + label = TextBlock("对象可以被多处引用") + page = Page() + page.title = "组合页面" + page.render(Column(32, 20, TextBlock("把对象组合成界面", 2), TextBlock("Column 组织纵向内容,Row 组织横向内容。"), Row(0, 16, RectBlock(100, 64, 12, "#DCE8FF"), RectBlock(100, 64, 12, "#E3ECDC"), RectBlock(100, 64, 12, "#F3E4DB")), label, Row(0, 24, label, label))) + } +} +function newTab(): Page { + page = StartPage() + page.title = "新标签页" + actions = StartActions(page) + blank = Button("+ 创建页面") + counter = Button("↗ 试试共享对象") + compose = Button("▦ 探索界面组合") + blank.on("click", actions.blank) + counter.on("click", actions.counter) + compose.on("click", actions.compose) + content = Column(0, 14, + TextBlock("Endor", 3, "#292B2D"), + TextBlock("从一个对象开始。", 2, "#484B50"), + TextBlock("用代码组织对象,让同一份内容成为可交互的界面。", 1, "#7D8085"), + Spacer(16), blank, counter, compose, Spacer(16), + TextBlock("⌘ / Ctrl + T 新建标签页 · ⌘ / Ctrl + W 关闭", 1, "#8B8E93")) + row = StartRow(24, 0, content) + row.mainAlign = "center" + page.mainAlign = "center" + page.render(row) + return page +}