feat: build functional Endor start page and balance compact tabs
This commit is contained in:
+2
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user