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
+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
}