feat: build functional Endor start page and balance compact tabs

This commit is contained in:
2026-10-24 20:12:02 +08:00
parent 3cb44b96eb
commit 952fc2eff2
11 changed files with 205 additions and 82 deletions
+9 -1
View File
@@ -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
+38 -3
View File
@@ -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.
+2 -1
View File
@@ -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.
+1 -6
View File
@@ -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,
+1 -1
View File
@@ -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.
-2
View File
@@ -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;
-51
View File
@@ -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);
}
+39 -12
View File
@@ -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<usize>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
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()
);
+3 -1
View File
@@ -3407,7 +3407,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
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(
+53 -4
View File
@@ -4,6 +4,7 @@ use super::*;
#[derive(Debug)]
struct OpenTool {
session: Shared,
ui: ObjectRef,
}
impl NativeCallable for OpenTool {
fn arity(&self) -> Option<endor_core::CallArity> {
@@ -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
}
+59
View File
@@ -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
}