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