Clip behavior card labels to their bounds
This commit is contained in:
@@ -1069,6 +1069,17 @@ struct BoxItem {
|
|||||||
detail: String,
|
detail: String,
|
||||||
marker: Option<Kind>,
|
marker: Option<Kind>,
|
||||||
}
|
}
|
||||||
|
fn box_text_clip(rect: Rectangle, scale: f32, viewport: Size) -> Option<Rectangle> {
|
||||||
|
if !scale.is_finite() || scale <= 0. || rect.width <= 20. || rect.height <= 16. {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Rectangle::new(
|
||||||
|
Point::new((rect.x + 10.) * scale, (rect.y + 8.) * scale),
|
||||||
|
Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale),
|
||||||
|
)
|
||||||
|
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
|
||||||
|
}
|
||||||
|
|
||||||
struct Line {
|
struct Line {
|
||||||
id: Id,
|
id: Id,
|
||||||
points: Vec<Point>,
|
points: Vec<Point>,
|
||||||
@@ -2134,24 +2145,31 @@ impl canvas::Program<Message> for Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for b in &self.boxes {
|
for b in &self.boxes {
|
||||||
f.fill_text(Text {
|
if let Some(clip) = box_text_clip(b.rect, self.scale, f.size()) {
|
||||||
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
// Clipped child frames start with an identity transform. Match
|
||||||
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
|
// the card's scale explicitly, as for lifecycle Create labels.
|
||||||
size: 13.into(),
|
f.with_clip(clip, |label| {
|
||||||
color: if b.id == self.selected {
|
label.scale(self.scale);
|
||||||
style::TEAL
|
label.fill_text(Text {
|
||||||
} else {
|
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
||||||
style::INK
|
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
|
||||||
},
|
size: 13.into(),
|
||||||
..Default::default()
|
color: if b.id == self.selected {
|
||||||
});
|
style::TEAL
|
||||||
if !b.detail.is_empty() {
|
} else {
|
||||||
f.fill_text(Text {
|
style::INK
|
||||||
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
|
},
|
||||||
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
|
..Default::default()
|
||||||
size: 11.into(),
|
});
|
||||||
color: style::MUTED,
|
if !b.detail.is_empty() {
|
||||||
..Default::default()
|
label.fill_text(Text {
|
||||||
|
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
|
||||||
|
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
|
||||||
|
size: 11.into(),
|
||||||
|
color: style::MUTED,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,116 @@ fn intervals(scene: &Scene, lifeline: Id, scope: Id) -> Vec<(f32, f32, Life)> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
for name in [
|
||||||
|
"W".repeat(18),
|
||||||
|
"界".repeat(24),
|
||||||
|
"e\u{301}".repeat(24),
|
||||||
|
"👩\u{200d}🔧".repeat(12),
|
||||||
|
] {
|
||||||
|
let mut f = Fixture::new(layer);
|
||||||
|
f.project.elements.get_mut(&f.receiver).unwrap().name = name.clone();
|
||||||
|
f.project.elements.get_mut(&f.work).unwrap().name = name.clone();
|
||||||
|
let scene = f.scene();
|
||||||
|
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
|
||||||
|
assert_eq!(header.title, format!("Lane · {name}"));
|
||||||
|
assert_eq!(header.detail, name);
|
||||||
|
assert_eq!(header.rect.size(), Size::new(204., 70.));
|
||||||
|
assert_eq!(scene.hit(header.rect.center()), Some(f.receiver));
|
||||||
|
// Scroll offset is handled by the existing outer canvas/scrollable;
|
||||||
|
// these are the local drawing frame's coordinates, not screen input.
|
||||||
|
for scale in [0.05, 0.5, 0.512, 0.64, 0.8, 1., 1.953125, 2.] {
|
||||||
|
let clip =
|
||||||
|
crate::behavior::box_text_clip(header.rect, scale, Size::new(2000., 2000.))
|
||||||
|
.unwrap();
|
||||||
|
let world = Rectangle::new(
|
||||||
|
Point::new(clip.x / scale, clip.y / scale),
|
||||||
|
Size::new(clip.width / scale, clip.height / scale),
|
||||||
|
);
|
||||||
|
assert!((world.x - header.rect.x - 10.).abs() < 0.001);
|
||||||
|
assert!((world.y - header.rect.y - 8.).abs() < 0.001);
|
||||||
|
assert!((world.width - 184.).abs() < 0.001);
|
||||||
|
assert!((world.height - 54.).abs() < 0.001);
|
||||||
|
assert!(header.rect.contains(world.position()));
|
||||||
|
assert!(
|
||||||
|
header
|
||||||
|
.rect
|
||||||
|
.contains(Point::new(world.x + world.width, world.y + world.height,))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() {
|
||||||
|
use crate::behavior::box_text_clip;
|
||||||
|
let card = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
|
||||||
|
assert_eq!(
|
||||||
|
box_text_clip(card, 2., Size::new(300., 500.)),
|
||||||
|
Some(Rectangle::new(Point::new(220., 416.), Size::new(80., 84.)))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
box_text_clip(
|
||||||
|
Rectangle::new(Point::new(-20., -10.), card.size()),
|
||||||
|
2.,
|
||||||
|
Size::new(300., 500.),
|
||||||
|
),
|
||||||
|
Some(Rectangle::new(Point::ORIGIN, Size::new(300., 104.)))
|
||||||
|
);
|
||||||
|
for viewport in [Size::new(200., 500.), Size::new(300., 400.), Size::ZERO] {
|
||||||
|
assert_eq!(box_text_clip(card, 2., viewport), None);
|
||||||
|
}
|
||||||
|
for scale in [0., -1., f32::NAN, f32::INFINITY] {
|
||||||
|
assert_eq!(box_text_clip(card, scale, Size::new(1000., 1000.)), None);
|
||||||
|
}
|
||||||
|
for size in [Size::new(20., 70.), Size::new(204., 16.), Size::ZERO] {
|
||||||
|
assert_eq!(
|
||||||
|
box_text_clip(Rectangle::new(Point::ORIGIN, size), 1., card.size()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_scene_wide_latin_header_exceeds_old_character_budget() {
|
||||||
|
use iced::advanced::text::{self, Paragraph as _};
|
||||||
|
let mut f = Fixture::new(Layer::Oa);
|
||||||
|
f.project.elements.get_mut(&f.receiver).unwrap().name = "W".repeat(18);
|
||||||
|
f.project.elements.get_mut(&f.work).unwrap().name = "W".repeat(24);
|
||||||
|
let scene = f.scene();
|
||||||
|
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
|
||||||
|
for (content, size, divisor) in [(&header.title, 13., 7.), (&header.detail, 11., 6.)] {
|
||||||
|
let label = iced::widget::canvas::Text {
|
||||||
|
content: crate::views::short(content, ((header.rect.width - 25.) / divisor) as usize),
|
||||||
|
size: size.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Use the same real paragraph/font/shaping defaults as canvas Text.
|
||||||
|
// The native defect survives short(): glyph width, not scalar count,
|
||||||
|
// is what exceeds the card. This is a font witness, not a pixel gate.
|
||||||
|
let paragraph = <iced::Renderer as text::Renderer>::Paragraph::with_text(text::Text {
|
||||||
|
content: label.content.as_str(),
|
||||||
|
bounds: Size::INFINITE,
|
||||||
|
size: label.size,
|
||||||
|
line_height: label.line_height,
|
||||||
|
font: label.font,
|
||||||
|
align_x: label.align_x,
|
||||||
|
align_y: label.align_y,
|
||||||
|
shaping: label.shaping,
|
||||||
|
wrapping: text::Wrapping::default(),
|
||||||
|
});
|
||||||
|
let old_available = header.rect.width - 12.;
|
||||||
|
assert!(
|
||||||
|
paragraph.min_width() > old_available,
|
||||||
|
"wide glyph fixture must overflow the old unbounded card: {} <= {old_available}",
|
||||||
|
paragraph.min_width()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
|
fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
|
||||||
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
|
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Lifecycle header overflow correction — 2026-09-11
|
||||||
|
|
||||||
|
Owner: Codex-ytr83r /root/windows_release_gap_review. Bounded root assignment;
|
||||||
|
source ownership approved before editing: `crates/oa-desktop/src/behavior.rs`
|
||||||
|
shared Scene box text path and existing
|
||||||
|
`crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs` only.
|
||||||
|
|
||||||
|
## Observed defect and diagnosis
|
||||||
|
|
||||||
|
Root exercised admitted typography-wide source on production freeze29084050,
|
||||||
|
immutable Windows GUI SHA prefix47332A6E. At1100×700/80%, receiver header card
|
||||||
|
spanned approximately x515–679 while its eighteen-W name continued to about730;
|
||||||
|
64% and subsequently100/125/156/195% also showed overflow. Reviewer inspected
|
||||||
|
`.local/lifecycle-native-ui/screenshots/wide-latin-oa-header-overflow-min-80.jpg`
|
||||||
|
and confirmed the text visibly crosses the card's right border. The Create capsule
|
||||||
|
below contains its text correctly; selection remains original occurrence110.
|
||||||
|
These are pre-correction native observations, not evidence of a corrected build.
|
||||||
|
No CJK156% observation is inferred from the interrupted later Computer Use action.
|
||||||
|
|
||||||
|
The shared Scene text loop used `short(title, (width-25)/7)` and an analogous
|
||||||
|
detail estimate with divisor6, then unbounded `Frame::fill_text`. Scalar-count
|
||||||
|
truncation cannot bound wide Latin/CJK glyphs, combining clusters or emoji. The
|
||||||
|
card is204×70 world units with title at(+12,+12), detail at(+12,+39). Both text
|
||||||
|
lines can overflow independently. Existing B4 Create text already uses a bounded
|
||||||
|
`with_clip` child frame and explicitly reapplies the scene scale.
|
||||||
|
|
||||||
|
## Applied correction
|
||||||
|
|
||||||
|
Added small `box_text_clip(rect, scale, viewport)` helper: inset10 world units
|
||||||
|
horizontally and8 vertically, transform to local drawing-frame coordinates by the
|
||||||
|
scene scale, then intersect with the actual parent viewport. Invalid scale or an
|
||||||
|
empty interior produces no text. The shared box title/detail loop uses this clip
|
||||||
|
and reapplies `label.scale(self.scale)` inside the child frame, whose WGPU transform
|
||||||
|
starts at identity. Both text draws retain their exact content/shortening rule,
|
||||||
|
positions, sizes, colors and selection behavior. No new layout engine or dependency.
|
||||||
|
|
||||||
|
Cards, outlines, message labels, lifecycle capsules/markers, insertion slots, hit
|
||||||
|
geometry, Project, source, names, history and camera state are untouched. The
|
||||||
|
shared card path also protects other Scene cards using it; it does not change
|
||||||
|
their layouts. Overflow is hard-clipped inside the existing card, so the last
|
||||||
|
visible glyph may be cut and an existing ellipsis may lie beyond the clip. This
|
||||||
|
bounded tradeoff preserves the current single-line design and exposes the full
|
||||||
|
unchanged name in Properties; pixel-measured ellipsizing is not required to prevent
|
||||||
|
the demonstrated leak.
|
||||||
|
|
||||||
|
Current Scene drawing applies scale; its outer canvas/scrollable owns scrolling.
|
||||||
|
Do not subtract `scroll_offset` again inside the clip or child frame. For the
|
||||||
|
future peer shared-canvas pan integration, transform **both** clip rectangles and
|
||||||
|
child text by the same pan+scale convention, just as for B4 Create labels. This
|
||||||
|
change deliberately does not invent a new pan convention.
|
||||||
|
|
||||||
|
## Prepared regression coverage and evidence limits
|
||||||
|
|
||||||
|
Three tests added to the existing admitted real-Scene suite:
|
||||||
|
|
||||||
|
- `real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips`:
|
||||||
|
OA/SA wide W, CJK, combining and joined-emoji names, full title/detail retention,
|
||||||
|
unchanged204×70 header and exact lifeline selection, clip containment at
|
||||||
|
scales0.05/0.5/0.512/0.64/0.8/1/1.953125/2. The existing Fixture::scene gate also
|
||||||
|
checks ordinary core/source admission, Project/SourceBundle/history immutability
|
||||||
|
and stable lifecycle geometry.
|
||||||
|
- `box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors`:
|
||||||
|
partial viewport intersections on right/bottom and negative origins, disjoint
|
||||||
|
and zero viewports, invalid scales and empty padded interiors.
|
||||||
|
- `real_scene_wide_latin_header_exceeds_old_character_budget`:
|
||||||
|
actual renderer Paragraph/font/shaping defaults measure the unchanged shortened
|
||||||
|
title and detail. Both must exceed the old available width, proving this is a
|
||||||
|
real-glyph falsifier rather than another character-count assertion. This is
|
||||||
|
font/layout evidence when run, not a screenshot/pixel-render acceptance gate.
|
||||||
|
|
||||||
|
**Uncompiled and unexecuted by this worker.** No Cargo, rustfmt, compiler, tests,
|
||||||
|
native UI, Git/index or WSL actions. Root's strict workspace pass16:45:59 and
|
||||||
|
affected prior let-chain test pass16:56:53 predate this production correction.
|
||||||
|
They do not transfer acceptance to these changed bytes. Root owns scoped
|
||||||
|
format/compile/tests, strict checks and a matching GUI rebuild/native recheck.
|
||||||
|
Native toolbar factors are51.2%/100%/195.3125% plus minimum Fit; exact0.5/2 are
|
||||||
|
prepared geometry-test cases, not claimed selectable native zoom settings.
|
||||||
|
|
||||||
|
## Source readback and handoff
|
||||||
|
|
||||||
|
The interrupted private preparation completed only an ignored proposed
|
||||||
|
`.local/lifecycle-header-overflow-correction-ytr83r/behavior.rs`. Before actual
|
||||||
|
edits, both production hashes still matched the frozen inputs. Root explicitly
|
||||||
|
released the source freeze before application. No other source or prior report
|
||||||
|
was edited.
|
||||||
|
|
||||||
|
Readback confirms behavior.rs exactly matches the prepared proposal. Removing
|
||||||
|
only the three inserted tests in memory reconstructs the complete original test
|
||||||
|
file SHA256, so unrelated test bytes are preserved.
|
||||||
|
|
||||||
|
| File | Before SHA256 | Corrected SHA256 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| behavior.rs | F2FB8850480FB96F8038A4C1D855EE31E408890B1D943E7B3A9F26B5E4498445 | B47E1DF6F88282D907CD5726A03DB0BF066B60B134962A223F11842482E75946 |
|
||||||
|
| scenario_lifecycle/render_tests.rs | 2942071D768CE021CA0DCE2FE2667BBA14DE0F3FA1D4176B7A6ECDCC156B2AA9 | 13716CBD059C99D7355DB1A777241F38C713050CB554C1643E7294106D1A95D0 |
|
||||||
|
|
||||||
|
Root should inspect the two-file diff, run the three new cases together with the
|
||||||
|
existing lifecycle suite, and verify both header lines stay inside their borders
|
||||||
|
while names/selection/history remain unchanged on a new matching GUI. Include
|
||||||
|
the already admitted Unicode variants and scrolling/zoom/minimum viewport cases.
|
||||||
|
Linux remains user-paused. This is a source correction handoff, not closed native
|
||||||
|
acceptance or first-release completion.
|
||||||
|
|
||||||
|
## Root review and source freeze
|
||||||
|
|
||||||
|
Root reviewed the two-file diff and all shared card dimensions, then ran scoped rustfmt successfully. behavior.rs remains B47E1DF6F88282D907CD5726A03DB0BF066B60B134962A223F11842482E75946; formatting changes render_tests.rs to5F8410FC80C85C9DFF6A47353FA9B82FD589F098E82EB9D24FDA030AFD603652.
|
||||||
|
|
||||||
|
A separate GPT-6 Astra reviewer found no material source issue for the configured WGPU backend after checking child-frame identity/paste transforms, viewport intersection, outer scrolling, compact branch cards and empty-label activation markers. Model strings, hits and existing shortening rules remain unchanged. This is static review: the tests do not exercise the complete draw loop or prove rendered pixels. Root must compile/run the new tests and verify a matching native build before accepting the correction. No corrected native pass is claimed by this freeze.
|
||||||
Reference in New Issue
Block a user