diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 91ec951f..b3078662 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -1069,6 +1069,17 @@ struct BoxItem { detail: String, marker: Option, } +fn box_text_clip(rect: Rectangle, scale: f32, viewport: Size) -> Option { + 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 { id: Id, points: Vec, @@ -2134,24 +2145,31 @@ impl canvas::Program for Scene { } } for b in &self.boxes { - f.fill_text(Text { - content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize), - position: Point::new(b.rect.x + 12., b.rect.y + 12.), - size: 13.into(), - color: if b.id == self.selected { - style::TEAL - } else { - style::INK - }, - ..Default::default() - }); - if !b.detail.is_empty() { - f.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() + if let Some(clip) = box_text_clip(b.rect, self.scale, f.size()) { + // Clipped child frames start with an identity transform. Match + // the card's scale explicitly, as for lifecycle Create labels. + f.with_clip(clip, |label| { + label.scale(self.scale); + label.fill_text(Text { + content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize), + position: Point::new(b.rect.x + 12., b.rect.y + 12.), + size: 13.into(), + color: if b.id == self.selected { + style::TEAL + } else { + style::INK + }, + ..Default::default() + }); + if !b.detail.is_empty() { + 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() + }); + } }); } } diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs index 885aaba7..166e09a3 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs @@ -173,6 +173,116 @@ fn intervals(scene: &Scene, lifeline: Id, scope: Id) -> Vec<(f32, f32, Life)> { .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 = ::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] fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() { let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.)); diff --git a/harness/lifecycle-header-overflow-correction-ytr83r-20260911.md b/harness/lifecycle-header-overflow-correction-ytr83r-20260911.md new file mode 100644 index 00000000..7502360d --- /dev/null +++ b/harness/lifecycle-header-overflow-correction-ytr83r-20260911.md @@ -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.