Wrap native text within occurrence constraints and preserve paint bounds
Verify Tessera GUI / verify (push) Failing after 7m33s
Verify Tessera GUI / verify (push) Failing after 7m33s
This commit is contained in:
@@ -246,12 +246,18 @@ Native creation and commands remain local until the input/candidate succeeds. Fa
|
||||
|
||||
The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame.
|
||||
|
||||
The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; wrapping and general multi-pass constraint negotiation remain future work. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source.
|
||||
The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; TextBlock wraps to its available width; general multi-pass constraint negotiation remains future work. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source.
|
||||
|
||||
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
|
||||
|
||||
The native app sends both press and release positions with the click. Both targets are resolved against the current published frame and heap when the release is processed; only matching Mount identities dispatch a click. These selectors share one read-only VM call and budget, and must return Bool. Callback execution starts after the read-only scope has ended. This is semantic click handling, not a raw pointer-down/up event stream or pointer capture. Keyboard/programmatic activation retains its semantic target and bypasses spatial filtering. API pointer events carry `point: [x, y]` and `press_point: [x, y]` together; GET code exposes X-Tessera-Session and X-Tessera-Layout-Version headers for clients to obtain current metadata without evaluating more source.
|
||||
|
||||
Native TextBlock measurement now uses the occurrence's maximum width, with word wrapping and glyph fallback for long words. Width changes affect measured height and propagate through native Column/Button sibling placement. The shared text cache includes wrap width; the native shape cache also includes the incoming width. Native base measure queries use the supplied constraints both during source layout and when called directly. Shared Blocks retain separate geometry per Mount and can wrap differently in different windows. See [wrapped-text.endor](examples/wrapped-text.endor).
|
||||
|
||||
Native TextBlock painting carries its allocated width/height into the immutable frame and clips glyphs to those bounds as well as any enclosing scroll clip. Measurement and drawing use the same font/shaping implementation; drawing shapes at the device font size. Zero-width or zero-height paint bounds draw nothing. Management UI text keeps its existing single-line/explicit-newline behavior.
|
||||
|
||||
Source `PaintCommand` text may supply both `wrapWidth` and `wrapHeight` as finite nonnegative logical dimensions up to 1000000. Omitting both preserves unwrapped text; supplying only one is rejected. `TextBlock.paint` returns these fields, so a source override can preserve or explicitly change the boundary. Changing paint bounds does not renegotiate layout: source arrange still owns its assigned child Bounds. Row's unbounded main axis and ScrollBlock's unbounded content continue their current policies; flex allocation, automatic vertical-only scroll wrapping and general constraint convergence remain pending. Fractional-DPI wrap boundaries need broader platform validation.
|
||||
|
||||
A source subclass can override `paint(self, mount, geometry): DisplayList`. The renderer invokes that method for each occurrence, after Mount synchronization and hooks, using current logical Bounds. Its commands replace that occurrence's native paint in preorder. Calling `RectBlock.paint(self, mount, geometry)` or another native base method obtains a fresh result that can be modified before returning. `DisplayList(ObjectList(command, ...))` builds multiple commands; ObjectList accepts objects and has a 10000-member bound. PaintCommand fields use kind (`roundedRect`, `strokeRoundedRect` or `text`), x/y/color and the corresponding width/height/radius, strokeWidth, or text/size fields. Commands must pass the same native validation and a total 10000-command frame limit. See [source-paint.endor](examples/source-paint.endor), including a Button source method that updates the same tint field used by two occurrences.
|
||||
|
||||
Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class Paragraph: TextBlock {
|
||||
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return TextBlock.measure(self, mount, constraints)
|
||||
}
|
||||
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
|
||||
return TextBlock.paint(self, mount, geometry)
|
||||
}
|
||||
}
|
||||
class Actions {
|
||||
window: Window
|
||||
paragraph: TextBlock
|
||||
full: String
|
||||
function narrow(self: Actions, event: Event): Unit { self.window.resize(800, 650) }
|
||||
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
|
||||
function short(self: Actions, event: Event): Unit { self.paragraph.setText("短文本:同一个对象、同一个 Mount。") }
|
||||
function restore(self: Actions, event: Event): Unit { self.paragraph.setText(self.full) }
|
||||
}
|
||||
full = "GUI 和代码操控同一套 Aster 内存对象。这个段落同时出现在两个窗口:对象内容相同,每个 Mount 根据自己的可用宽度换行。窗口变窄,段落高度增加,后面的对象向下移动;窗口变宽,段落高度减小,后面的对象向上移动。对象身份和已有 Mount 身份保持稳定。English words wrap at word boundaries; a_long_unbroken_identifier_also_wraps_when_it_cannot_fit_on_one_line. 中文、组合字符 é 与 emoji 👩💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
|
||||
paragraph = Paragraph(full)
|
||||
tail = TextBlock("这一行的位置由前面段落的实际高度决定。")
|
||||
actions = Actions(ui.window(), paragraph, full)
|
||||
narrow = Button("变窄")
|
||||
wide = Button("变宽")
|
||||
short = Button("短文本")
|
||||
restore = Button("恢复段落")
|
||||
narrowConnection = narrow.on("click", actions.narrow)
|
||||
wideConnection = wide.on("click", actions.wide)
|
||||
shortConnection = short.on("click", actions.short)
|
||||
restoreConnection = restore.on("click", actions.restore)
|
||||
page = Page()
|
||||
page.title = "约束下的文字换行"
|
||||
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
|
||||
other = Window()
|
||||
other.resize(720, 500)
|
||||
other.setTitle("同一个段落 · 另一组宽度")
|
||||
other.moveTo(1700, 80)
|
||||
second = Page()
|
||||
second.title = "共享 Paragraph"
|
||||
second.render(Column(24, 16, paragraph))
|
||||
second.open(other)
|
||||
page.activate()
|
||||
ui.window().resize(800, 650)
|
||||
ui.window().setTitle("文字换行与相邻布局")
|
||||
ui.window().moveTo(50, 80)
|
||||
ui.window().focus()
|
||||
+8
-6
@@ -22,11 +22,11 @@
|
||||
| vm_sequence.rs | Shared validated call-sequence builder for event and lifecycle batches in the existing VM frame loop |
|
||||
| source_layout.rs | Read-only source layout, per-Mount measurement caches with subcall dependencies, viewport constraints, typed/slot validation and occurrence geometry |
|
||||
| source_hit.rs | Read-only source/native pointer target selectors, reverse paint order, strict Bool results and matching press/release targets |
|
||||
| source_paint.rs | Read-only source paint dispatch, shared VM budget, checked command conversion and versioned object dependencies |
|
||||
| source_paint.rs | Read-only source paint dispatch, shared VM budget, paired text-wrap bounds and checked command conversion and versioned object dependencies |
|
||||
| native_visual.rs | Managed native visual protocol queries with occurrence validation, value conversion and bounded results |
|
||||
| object_list.rs | Traced indexed results shared by Mount queries and visual protocol results |
|
||||
| layout.rs | Shared native geometry/paint functions, iterative measurement/arrangement, stable occurrence IDs, measurement cache and geometry budgets |
|
||||
| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation, single-line fault banner (full cause retained in Frame) and display commands |
|
||||
| layout.rs | Shared native geometry/paint functions, iterative measurement/arrangement, stable occurrence IDs, measurement cache, geometry budgets and width-sensitive native text measurement |
|
||||
| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation, single-line fault banner (full cause retained in Frame), validated optional text-wrap bounds and display commands |
|
||||
| app.rs | Window events, logical viewport publication, redraw, native multi-window tab dragging 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 |
|
||||
@@ -34,10 +34,10 @@
|
||||
| scene.rs | Page, tab-drop marker and developer-tools display-list composition |
|
||||
| developer_tools.rs | Unified Console prompt/output stream, caret/IME mapping, full Sources, Network inspector, theme, scrolling, overlay splitters and focus |
|
||||
| sources.rs | Disconnected cloud workspace and provider boundary for listing/opening complete source files |
|
||||
| display_list.rs | Rendering primitives and color representation |
|
||||
| display_list.rs | Rendering primitives, bounded wrapped text and color representation |
|
||||
| geometry.rs | Native points and rectangles |
|
||||
| software_renderer.rs | Software rasterization and nested rectangular clipping for independently scrolling panes |
|
||||
| text.rs | Shared system font discovery, shaping, real measurements, antialiased glyphs and bounded caches |
|
||||
| software_renderer.rs | Software rasterization, per-text bounds and nested rectangular clipping for independently scrolling panes |
|
||||
| text.rs | Shared system font discovery, shaping, real measurements, antialiased glyphs and bounded caches keyed by wrap width |
|
||||
| README.md | Module map |
|
||||
|
||||
Aster is an independent source submodule pinned to the published managed type/native-call implementation. Language parsing, AST definitions,
|
||||
@@ -131,3 +131,5 @@ per-window projection refresh; snapshots/caches never own business objects.
|
||||
Tab drops validate source and destination window/session/layout plus the actual VM Page order before callbacks. The bridge invokes managed Page methods, respecting source overrides that decline or redirect a window move. Reordering within a window retains Mounts; moving windows retains Page identity and replaces Mounts. Native drag feedback is host state, with no additional managed roots. Desktop placement is an asynchronous physical outer-origin request, protected by the existing session/commit boundary. Regression sources cover the HTTP event allowlist, stale frame reuse, stable insertion, source overrides and signed positions; these were compiled, not locally executed.
|
||||
|
||||
NativeCallable implementations publish positional metadata, including internal layout/paint/hit/event continuations. The VM validates it before entering Rust. Event delivery uses the same source/native/bound arity query; binding and Task registration reject incompatible or unknown metadata before changing ownership. Native parameter/return types and effects remain checked by existing host boundaries rather than this count-only metadata.
|
||||
|
||||
Native TextBlock measurement and painting now share width-sensitive shaping. Column/Button cross-axis constraints feed both the native fast path and source base calls; direct base measurement also uses its supplied viewport. RenderCommand text has optional wrap_bounds, converted to a clipped WrappedText primitive. Source PaintCommand uses paired wrapWidth/wrapHeight and legacy text remains unwrapped when both are absent. General source-arrange negotiation and axis-specific ScrollBlock wrapping remain future work.
|
||||
|
||||
+3
-1
@@ -1083,7 +1083,9 @@ Window is a host-provided managed resource: ui.window(), page.window(), page.ope
|
||||
|
||||
ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. Dragging uses a 12-logical-pixel threshold, visible insertion marker and Escape cancellation. Overflow auto-scroll, external-app occlusion and mixed-DPI verification remain pending. See examples/tab-placement.endor.
|
||||
|
||||
Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. General constraint negotiation and text wrapping remain pending; ScrollBlock scrolling is described below.
|
||||
Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. Native TextBlock wrapping now uses the occurrence width in measurement/cache/painting; general constraint negotiation remains pending; ScrollBlock scrolling is described below.
|
||||
|
||||
TextBlock now wraps words/glyphs at its available width and measures the resulting height. Native and source-base measurement paths use current constraints; native shape/font caches include wrap width. Painting carries allocated width/height, clips glyphs and keeps ancestor scroll clipping. Source text PaintCommand may supply both wrapWidth/wrapHeight (finite nonnegative logical dimensions up to 1000000); neither preserves legacy unwrapped paint, and a partial pair is rejected. Native TextBlock.paint supplies the pair. These are derived draw bounds, not another business object. Management UI text remains unwrapped. General flex/constraint convergence, vertical-only scrolling policy and broad fractional-DPI validation remain pending. See examples/wrapped-text.endor.
|
||||
|
||||
Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects.
|
||||
|
||||
|
||||
+8
-1
@@ -45,6 +45,12 @@ pub enum DrawCommand {
|
||||
width: f32,
|
||||
color: Color,
|
||||
},
|
||||
WrappedText {
|
||||
rect: Rect,
|
||||
value: String,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
},
|
||||
Text {
|
||||
origin: Point,
|
||||
value: String,
|
||||
@@ -66,7 +72,8 @@ impl DisplayList {
|
||||
pub fn translate(&mut self, delta: Point) {
|
||||
for command in &mut self.commands {
|
||||
match command {
|
||||
DrawCommand::PushClip(rect)
|
||||
DrawCommand::WrappedText { rect, .. }
|
||||
| DrawCommand::PushClip(rect)
|
||||
| DrawCommand::FillRoundedRect { rect, .. }
|
||||
| DrawCommand::StrokeRoundedRect { rect, .. } => {
|
||||
rect.x += delta.x;
|
||||
|
||||
+50
-5
@@ -266,6 +266,7 @@ impl RenderDocument {
|
||||
value: format!("运行已停止:{}", error.lines().next().unwrap_or_default()),
|
||||
size: 1,
|
||||
color: "#A32D25".into(),
|
||||
wrap_bounds: None,
|
||||
}
|
||||
.to_draw_command(scale)?,
|
||||
);
|
||||
@@ -438,6 +439,8 @@ pub enum RenderCommand {
|
||||
#[serde(default = "default_text_size")]
|
||||
size: u32,
|
||||
color: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
wrap_bounds: Option<[f32; 2]>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -476,10 +479,20 @@ impl RenderCommand {
|
||||
value,
|
||||
size,
|
||||
color,
|
||||
wrap_bounds,
|
||||
} => {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
bail!("text coordinates must be finite");
|
||||
}
|
||||
if let Some([width, height]) = wrap_bounds {
|
||||
validate_rect(*x, *y, *width, *height, 0.0)?;
|
||||
if [*x, *y, *width, *height, *x + *width, *y + *height]
|
||||
.iter()
|
||||
.any(|value| value.abs() > crate::ui_bridge::MAX_DIMENSION as f32)
|
||||
{
|
||||
bail!("text wrap bounds exceed limits");
|
||||
}
|
||||
}
|
||||
if value.len() > 16_384 {
|
||||
bail!("text command exceeds 16 KiB");
|
||||
}
|
||||
@@ -526,11 +539,20 @@ impl RenderCommand {
|
||||
value,
|
||||
size,
|
||||
color,
|
||||
} => DrawCommand::Text {
|
||||
origin: Point::new(x * scale, y * scale),
|
||||
value: value.clone(),
|
||||
style: crate::text::TextStyle::ui(*size).scaled(scale),
|
||||
color: parse_color(color)?,
|
||||
wrap_bounds,
|
||||
} => match wrap_bounds {
|
||||
Some([width, height]) => DrawCommand::WrappedText {
|
||||
rect: scaled_rect(*x, *y, *width, *height, scale),
|
||||
value: value.clone(),
|
||||
style: crate::text::TextStyle::ui(*size).scaled(scale),
|
||||
color: parse_color(color)?,
|
||||
},
|
||||
None => DrawCommand::Text {
|
||||
origin: Point::new(x * scale, y * scale),
|
||||
value: value.clone(),
|
||||
style: crate::text::TextStyle::ui(*size).scaled(scale),
|
||||
color: parse_color(color)?,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -576,6 +598,29 @@ pub fn parse_color(value: &str) -> Result<Color> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() {
|
||||
let mut command: RenderCommand = serde_json::from_str(
|
||||
r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
command.to_draw_command(1.0).unwrap(),
|
||||
DrawCommand::Text { .. }
|
||||
));
|
||||
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
|
||||
*wrap_bounds = Some([40.0, 20.0]);
|
||||
}
|
||||
command.validate().unwrap();
|
||||
assert!(
|
||||
matches!(command.to_draw_command(1.25).unwrap(),DrawCommand::WrappedText{rect,..}if rect.width==50.0 && rect.height==25.0)
|
||||
);
|
||||
if let RenderCommand::Text { wrap_bounds, .. } = &mut command {
|
||||
*wrap_bounds = Some([-1.0, 20.0]);
|
||||
}
|
||||
assert!(command.validate().is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -993,6 +993,145 @@ mod tests {
|
||||
const EXAMPLE: &str = include_str!("../examples/hello.endor");
|
||||
const PAGE: &str = include_str!("../examples/page.endor");
|
||||
|
||||
#[test]
|
||||
fn source_text_paint_rejects_incomplete_wrap_bounds() {
|
||||
let source = r##"
|
||||
class Invalid: TextBlock {
|
||||
function paint(self: Invalid,mount: Mount,geometry: Bounds): DisplayList {
|
||||
command=PaintCommand()
|
||||
command.kind="text"
|
||||
command.x=0
|
||||
command.y=0
|
||||
command.text="invalid"
|
||||
command.size=1
|
||||
command.color="#000000"
|
||||
command.wrapWidth=40
|
||||
return DisplayList(ObjectList(command))
|
||||
}
|
||||
}
|
||||
page=Page()
|
||||
page.render(Invalid("invalid"))
|
||||
"##;
|
||||
let error = compile(source, "app.main", 1).unwrap_err();
|
||||
assert!(error.to_string().contains("wrapHeight"), "{error:#}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() {
|
||||
let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8);
|
||||
for custom in [false, true] {
|
||||
let constructor = if custom { "Wrapped" } else { "TextBlock" };
|
||||
let source = format!(
|
||||
r#"
|
||||
class Wrapped: TextBlock {{
|
||||
function measure(self: Wrapped,mount: Mount,constraints: Constraints): MeasureResult {{return TextBlock.measure(self,mount,constraints)}}
|
||||
function paint(self: Wrapped,mount: Mount,geometry: Bounds): DisplayList {{return TextBlock.paint(self,mount,geometry)}}
|
||||
}}
|
||||
paragraph={constructor}("{text}")
|
||||
tail=TextBlock("tail")
|
||||
page=Page()
|
||||
page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph)))
|
||||
"#
|
||||
);
|
||||
let (mut session, initial) = ReplSession::load_with_viewport(
|
||||
&source,
|
||||
"app.main",
|
||||
1,
|
||||
230,
|
||||
Some(crate::layout::Size {
|
||||
width: 240.0,
|
||||
height: 2000.0,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let ids: Vec<_> = initial.frame.mounts.iter().map(|m| m.id).collect();
|
||||
let commands: Vec<_> = initial
|
||||
.frame
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|command| match command {
|
||||
RenderCommand::Text {
|
||||
value,
|
||||
wrap_bounds: Some(bounds),
|
||||
..
|
||||
} if value == &text => Some(*bounds),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[0][0], 240.0);
|
||||
assert_eq!(commands[1][0], 140.0);
|
||||
assert!(commands[1][1] > commands[0][1]);
|
||||
let tail = initial
|
||||
.frame
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|command| match command {
|
||||
RenderCommand::Text { value, y, .. } if value == "tail" => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(tail >= commands[0][1] + 8.0);
|
||||
let resized = session
|
||||
.resize(Some(crate::layout::Size {
|
||||
width: 400.0,
|
||||
height: 2000.0,
|
||||
}))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ids,
|
||||
resized
|
||||
.frame
|
||||
.mounts
|
||||
.iter()
|
||||
.map(|m| m.id)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let heights: Vec<_> = resized
|
||||
.frame
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|command| match command {
|
||||
RenderCommand::Text {
|
||||
value,
|
||||
wrap_bounds: Some(bounds),
|
||||
..
|
||||
} if value == &text => Some(bounds[1]),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(heights[0] < commands[0][1]);
|
||||
let direct = session
|
||||
.execute(
|
||||
"paragraph.measure(paragraph.mounts().at(0),Constraints(0,0,80,2000)).height",
|
||||
"app.main",
|
||||
)
|
||||
.unwrap()
|
||||
.value
|
||||
.parse::<f32>()
|
||||
.unwrap();
|
||||
assert!(direct > heights[0]);
|
||||
session
|
||||
.execute("paragraph.setText(\"short\")", "app.main")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("paragraph.mounts().count", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"2"
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"20"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_signatures_validate_registration_and_deliver_the_event_argument() {
|
||||
let (mut session, _) = ReplSession::load(
|
||||
|
||||
+29
-11
@@ -18,7 +18,7 @@ pub struct Size {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Shape {
|
||||
Text(String, u32),
|
||||
Text(String, u32, f32),
|
||||
Rect(f32, f32),
|
||||
Container(bool, f32, f32, Vec<Size>),
|
||||
}
|
||||
@@ -103,11 +103,13 @@ fn container(node: &UiNode) -> Option<(bool, f32, f32)> {
|
||||
|
||||
fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
let size = match shape {
|
||||
Shape::Text(value, size) => {
|
||||
let result = text::measure(value, TextStyle::ui(*size));
|
||||
Shape::Text(value, size, width) => {
|
||||
let style = TextStyle::ui(*size);
|
||||
let intrinsic = text::measure(value, style);
|
||||
let wrapped = text::measure_wrapped(value, style, *width);
|
||||
Size {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
width: intrinsic.width.min(*width),
|
||||
height: wrapped.height,
|
||||
}
|
||||
}
|
||||
Shape::Rect(width, height) => Size {
|
||||
@@ -144,9 +146,9 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
fn shape(node: &UiNode, children: Vec<Size>) -> Result<Shape> {
|
||||
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
|
||||
Ok(match &node.kind {
|
||||
UiKind::Text { value, size, .. } => Shape::Text(value.clone(), *size),
|
||||
UiKind::Text { value, size, .. } => Shape::Text(value.clone(), *size, constraints[2]),
|
||||
UiKind::Rect { width, height, .. } => Shape::Rect(*width, *height),
|
||||
UiKind::Spacer(size) => Shape::Rect(*size, *size),
|
||||
UiKind::Scroll { width, height, .. } => Shape::Rect(*width, *height),
|
||||
@@ -157,8 +159,8 @@ fn shape(node: &UiNode, children: Vec<Size>) -> Result<Shape> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn measure_native(node: &UiNode, children: Vec<Size>) -> Result<Size> {
|
||||
measure_shape(&shape(node, children)?)
|
||||
pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Size> {
|
||||
measure_shape(&shape(node, children, constraints)?)
|
||||
}
|
||||
|
||||
/// Private derived scratch space for one occurrence during layout. Contains no
|
||||
@@ -171,6 +173,7 @@ pub struct Context {
|
||||
}
|
||||
|
||||
pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderCommand> {
|
||||
let size_for_text = size;
|
||||
match &node.kind {
|
||||
UiKind::Text { value, size, color } => Some(RenderCommand::Text {
|
||||
x,
|
||||
@@ -178,6 +181,7 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
|
||||
value: value.clone(),
|
||||
size: *size,
|
||||
color: color.clone(),
|
||||
wrap_bounds: Some([size_for_text.width, size_for_text.height]),
|
||||
}),
|
||||
UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect {
|
||||
x,
|
||||
@@ -253,8 +257,13 @@ impl LayoutRuntime {
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
pub fn measure_node(&mut self, node: &UiNode, children: Vec<Size>) -> Result<Size> {
|
||||
let shape = shape(node, children)?;
|
||||
pub fn measure_node(
|
||||
&mut self,
|
||||
node: &UiNode,
|
||||
children: Vec<Size>,
|
||||
constraints: [f32; 4],
|
||||
) -> Result<Size> {
|
||||
let shape = shape(node, children, constraints)?;
|
||||
if let Some((_, size)) = self
|
||||
.sizes
|
||||
.get(&node.block_id)
|
||||
@@ -326,6 +335,7 @@ impl LayoutRuntime {
|
||||
self.measure_node(
|
||||
item.node,
|
||||
item.children.iter().map(|i| sizes[*i]).collect(),
|
||||
item.constraints,
|
||||
)?,
|
||||
item.constraints,
|
||||
);
|
||||
@@ -400,3 +410,11 @@ impl LayoutRuntime {
|
||||
pub fn frame(root: &UiNode) -> Result<Frame> {
|
||||
LayoutRuntime::default().frame(root)
|
||||
}
|
||||
|
||||
pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result<Frame> {
|
||||
LayoutRuntime {
|
||||
viewport: Some(viewport),
|
||||
..Default::default()
|
||||
}
|
||||
.frame(root)
|
||||
}
|
||||
|
||||
+33
-14
@@ -56,9 +56,15 @@ pub(crate) fn record(
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
fn size(node: &crate::ui_bridge::UiNode) -> Result<Size, VmError> {
|
||||
let frame =
|
||||
layout::frame(node).map_err(|_| invalid("native measurement exceeds layout limits"))?;
|
||||
fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size, VmError> {
|
||||
let frame = layout::frame_with_viewport(
|
||||
node,
|
||||
Size {
|
||||
width: constraints[2],
|
||||
height: constraints[3],
|
||||
},
|
||||
)
|
||||
.map_err(|_| invalid("native measurement exceeds layout limits"))?;
|
||||
let root = frame
|
||||
.mounts
|
||||
.first()
|
||||
@@ -138,10 +144,10 @@ impl NativeCallable for Method {
|
||||
return Err(invalid("minimum exceeds maximum constraint"));
|
||||
}
|
||||
let size = if let Some((_, children)) = &staged {
|
||||
layout::measure_native(&node, children.clone())
|
||||
layout::measure_native(&node, children.clone(), [min_w, min_h, max_w, max_h])
|
||||
.map_err(|_| invalid("native measurement exceeds layout limits"))?
|
||||
} else {
|
||||
size(&node)?
|
||||
size(&node, [min_w, min_h, max_w, max_h])?
|
||||
};
|
||||
Value::Object(record(
|
||||
cx,
|
||||
@@ -166,7 +172,12 @@ impl NativeCallable for Method {
|
||||
} else {
|
||||
node.children()
|
||||
.iter()
|
||||
.map(|child| size(child))
|
||||
.map(|child| {
|
||||
size(
|
||||
child,
|
||||
layout::child_constraints(&node, [0.0, 0.0, width, height]),
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
let points = layout::arrange_native(&node, x, y, &children)
|
||||
@@ -212,14 +223,22 @@ impl NativeCallable for Method {
|
||||
value: text,
|
||||
size,
|
||||
color,
|
||||
} => vec![
|
||||
("kind", Value::Text("text".into())),
|
||||
("x", value(x)),
|
||||
("y", value(y)),
|
||||
("text", Value::Text(text)),
|
||||
("size", Value::Number((size as i64).into())),
|
||||
("color", Value::Text(color)),
|
||||
],
|
||||
wrap_bounds,
|
||||
} => {
|
||||
let mut fields = vec![
|
||||
("kind", Value::Text("text".into())),
|
||||
("x", value(x)),
|
||||
("y", value(y)),
|
||||
("text", Value::Text(text)),
|
||||
("size", Value::Number((size as i64).into())),
|
||||
("color", Value::Text(color)),
|
||||
];
|
||||
if let Some([width, height]) = wrap_bounds {
|
||||
fields.push(("wrapWidth", value(width)));
|
||||
fields.push(("wrapHeight", value(height)));
|
||||
}
|
||||
fields
|
||||
}
|
||||
RenderCommand::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
|
||||
@@ -52,6 +52,12 @@ impl SoftwareRenderer {
|
||||
width,
|
||||
color,
|
||||
} => canvas.stroke_rounded_rect(*rect, *radius, *width, *color),
|
||||
DrawCommand::WrappedText {
|
||||
rect,
|
||||
value,
|
||||
style,
|
||||
color,
|
||||
} => canvas.draw_wrapped_text(*rect, value, *style, *color),
|
||||
DrawCommand::Text {
|
||||
origin,
|
||||
value,
|
||||
@@ -120,6 +126,27 @@ impl Canvas<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_wrapped_text(&mut self, rect: Rect, value: &str, style: TextStyle, color: Color) {
|
||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let old = self.clip;
|
||||
let x = old.x.max(rect.x);
|
||||
let y = old.y.max(rect.y);
|
||||
self.clip = Rect::new(
|
||||
x,
|
||||
y,
|
||||
((old.x + old.width).min(rect.x + rect.width) - x).max(0.0),
|
||||
((old.y + old.height).min(rect.y + rect.height) - y).max(0.0),
|
||||
);
|
||||
let left = rect.x.round() as i32;
|
||||
let top = rect.y.round() as i32;
|
||||
text::draw_wrapped(value, style, color, rect.width, |dx, dy, pixel| {
|
||||
self.blend_pixel(left + dx, top + dy, pixel)
|
||||
});
|
||||
self.clip = old;
|
||||
}
|
||||
|
||||
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
|
||||
(
|
||||
rect.x.max(self.clip.x).max(0.0).floor() as i32,
|
||||
@@ -191,6 +218,45 @@ fn pack(color: Color) -> u32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrapped_text_uses_multiple_lines_respects_nested_clips_and_restores_the_clip() {
|
||||
let mut list = DisplayList::default();
|
||||
list.push(DrawCommand::Clear(Color::rgb(255, 255, 255)));
|
||||
list.push(DrawCommand::PushClip(Rect::new(12.0, 5.0, 20.0, 40.0)));
|
||||
list.push(DrawCommand::WrappedText {
|
||||
rect: Rect::new(10.0, 5.0, 32.0, 40.0),
|
||||
value: "MMMMMMMMMMMMMMMM".into(),
|
||||
style: TextStyle::ui(1),
|
||||
color: Color::rgb(0, 0, 0),
|
||||
});
|
||||
list.push(DrawCommand::PopClip);
|
||||
list.push(DrawCommand::WrappedText {
|
||||
rect: Rect::new(60.0, 0.0, 0.0, 40.0),
|
||||
value: "invisible".into(),
|
||||
style: TextStyle::ui(1),
|
||||
color: Color::rgb(0, 0, 0),
|
||||
});
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: Rect::new(70.0, 50.0, 2.0, 2.0),
|
||||
radius: 0.0,
|
||||
color: Color::rgb(0, 0, 255),
|
||||
});
|
||||
let mut pixels = vec![0; 80 * 60];
|
||||
SoftwareRenderer::render(&list, &mut pixels, 80, 60);
|
||||
let mut second_line = false;
|
||||
for y in 0..60 {
|
||||
for x in 0..80 {
|
||||
let color = pixels[y * 80 + x];
|
||||
if color != 0xffffff && color != 0x0000ff {
|
||||
assert!((12..32).contains(&x) && (5..45).contains(&y));
|
||||
second_line |= y >= 25;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(second_line);
|
||||
assert_eq!(pixels[50 * 80 + 70], 0x0000ff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_changes_pixels_inside_a_rectangle() {
|
||||
let mut list = DisplayList::default();
|
||||
|
||||
@@ -131,9 +131,10 @@ impl NativeCallable for Step {
|
||||
.collect::<Vec<_>>();
|
||||
let bounds = plan.bounds[self.index];
|
||||
let native_size = if matches!(self.phase, Phase::Measure) && method == native {
|
||||
let constraints = plan.constraints[self.index];
|
||||
Some(
|
||||
plan.runtime
|
||||
.measure_node(&node, children.clone())
|
||||
.measure_node(&node, children.clone(), constraints)
|
||||
.map_err(|_| invalid("native measurement exceeds layout limits"))?,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -104,12 +104,24 @@ fn decode(
|
||||
if size.fract() != 0.0 || !(1.0..=8.0).contains(&size) {
|
||||
return Err(invalid("paint text size must be an integer from 1 to 8"));
|
||||
}
|
||||
let wrap_bounds = match (
|
||||
store.get_attr(command, "wrapWidth"),
|
||||
store.get_attr(command, "wrapHeight"),
|
||||
) {
|
||||
(Err(VmError::AttributeNotFound(_)), Err(VmError::AttributeNotFound(_))) => None,
|
||||
(Ok(_), Ok(_)) => Some([
|
||||
number(store, command, "wrapWidth")?,
|
||||
number(store, command, "wrapHeight")?,
|
||||
]),
|
||||
(Err(error), _) | (_, Err(error)) => return Err(error),
|
||||
};
|
||||
RenderCommand::Text {
|
||||
x,
|
||||
y,
|
||||
value: text(store, command, "text")?,
|
||||
size: size as u32,
|
||||
color,
|
||||
wrap_bounds,
|
||||
}
|
||||
}
|
||||
_ => return Err(invalid("unsupported paint command kind")),
|
||||
|
||||
+68
-5
@@ -59,6 +59,7 @@ struct TextKey {
|
||||
font_size: u32,
|
||||
line_height: u32,
|
||||
family: TextFamily,
|
||||
wrap_width: Option<u32>,
|
||||
}
|
||||
|
||||
struct PreparedText {
|
||||
@@ -114,11 +115,16 @@ impl TextEngine {
|
||||
}
|
||||
|
||||
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
|
||||
self.prepare_width(value, style, None)
|
||||
}
|
||||
|
||||
fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option<f32>) -> TextKey {
|
||||
let key = TextKey {
|
||||
value: value.to_owned(),
|
||||
font_size: style.font_size.to_bits(),
|
||||
line_height: style.line_height.to_bits(),
|
||||
family: style.family,
|
||||
wrap_width: wrap_width.map(f32::to_bits),
|
||||
};
|
||||
if self.buffers.contains_key(&key) {
|
||||
return key;
|
||||
@@ -141,8 +147,12 @@ impl TextEngine {
|
||||
&mut self.fonts,
|
||||
Metrics::new(style.font_size, style.line_height),
|
||||
);
|
||||
buffer.set_wrap(Wrap::None);
|
||||
buffer.set_size(None, None);
|
||||
buffer.set_wrap(if wrap_width.is_some() {
|
||||
Wrap::WordOrGlyph
|
||||
} else {
|
||||
Wrap::None
|
||||
});
|
||||
buffer.set_size(wrap_width, None);
|
||||
buffer.set_text(
|
||||
value,
|
||||
&Attrs::new().family(family),
|
||||
@@ -176,10 +186,44 @@ pub fn measure(value: &str, style: TextStyle) -> TextSize {
|
||||
engine.buffers[&key].size
|
||||
}
|
||||
|
||||
/// Draw at the actual device font size; no scaling of an already rasterized bitmap.
|
||||
pub fn draw(value: &str, style: TextStyle, color: Color, mut pixel: impl FnMut(i32, i32, Color)) {
|
||||
pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
|
||||
if width <= 0.0 {
|
||||
return TextSize {
|
||||
width: 0.0,
|
||||
height: style.line_height,
|
||||
};
|
||||
}
|
||||
let mut engine = engine().lock().expect("text engine lock");
|
||||
let key = engine.prepare(value, style);
|
||||
let key = engine.prepare_width(value, style, Some(width));
|
||||
engine.buffers[&key].size
|
||||
}
|
||||
|
||||
/// Draw at the actual device font size; no scaling of an already rasterized bitmap.
|
||||
pub fn draw(value: &str, style: TextStyle, color: Color, pixel: impl FnMut(i32, i32, Color)) {
|
||||
draw_width(value, style, color, None, pixel)
|
||||
}
|
||||
|
||||
pub fn draw_wrapped(
|
||||
value: &str,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
width: f32,
|
||||
pixel: impl FnMut(i32, i32, Color),
|
||||
) {
|
||||
if width > 0.0 {
|
||||
draw_width(value, style, color, Some(width), pixel)
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_width(
|
||||
value: &str,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
width: Option<f32>,
|
||||
mut pixel: impl FnMut(i32, i32, Color),
|
||||
) {
|
||||
let mut engine = engine().lock().expect("text engine lock");
|
||||
let key = engine.prepare_width(value, style, width);
|
||||
let TextEngine {
|
||||
fonts,
|
||||
swash,
|
||||
@@ -272,6 +316,25 @@ mod tests {
|
||||
use super::*;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[test]
|
||||
fn width_sensitive_shaping_wraps_words_and_clusters_without_changing_intrinsic_metrics() {
|
||||
let style = TextStyle::ui(1);
|
||||
let text = "中文段落 with words and long_unbroken_identifier é 👩💻 中文段落";
|
||||
let intrinsic = measure(text, style);
|
||||
let narrow = measure_wrapped(text, style, 70.0);
|
||||
let wide = measure_wrapped(text, style, 240.0);
|
||||
assert!(narrow.height > wide.height);
|
||||
assert!(wide.height >= style.line_height);
|
||||
assert!(narrow.width <= 70.1);
|
||||
assert_eq!(measure(text, style).width, intrinsic.width);
|
||||
assert_eq!(measure_wrapped(text, style, 0.0).width, 0.0);
|
||||
let mut engine = engine().lock().unwrap();
|
||||
let narrow_key = engine.prepare_width(text, style, Some(70.0));
|
||||
let wide_key = engine.prepare_width(text, style, Some(240.0));
|
||||
assert!(narrow_key != wide_key);
|
||||
assert!(engine.buffers[&narrow_key].size.height > engine.buffers[&wide_key].size.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proportional_metrics_monospace_and_multiline_height_come_from_the_fonts() {
|
||||
let ui = TextStyle::ui(1);
|
||||
|
||||
Reference in New Issue
Block a user