Compose compact Text workspace with guarded root navigation

This commit is contained in:
2026-09-13 04:10:27 -07:00
parent feab2020d8
commit 74542ae2c1
20 changed files with 5482 additions and 738 deletions
@@ -0,0 +1,626 @@
use super::*;
use iced::advanced::widget::operation::{self, Focusable, Scrollable};
use iced::widget::{Space, button, column, container, mouse_area, scrollable, text_input};
use iced::{Point, Theme, event, keyboard};
type View = Element<'static, Message, Theme, ()>;
const BOUNDS: Rectangle = Rectangle {
x: 0.,
y: 0.,
width: 400.,
height: 300.,
};
#[derive(Clone, Debug, PartialEq, Eq)]
enum Message {
Primary(u8),
Related(u8),
BackgroundButton,
BackgroundRelease,
Submit(u8),
Edit,
}
fn rows(version: u8) -> View {
column![
button(Space::new())
.width(200.)
.height(40.)
.padding(0)
.on_press(Message::Primary(version)),
button(Space::new())
.width(200.)
.height(40.)
.padding(0)
.on_press(Message::Related(version)),
]
.into()
}
fn guarded(generation: Option<Id>, version: u8) -> View {
let child: View = if generation.is_some() {
rows(version)
} else {
Space::new().into()
};
Guard::new(
generation.map(|publication| (publication, Id::nil())),
child,
)
.into()
}
fn press() -> Event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
}
fn release() -> Event {
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
}
fn enter() -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Enter),
modified_key: keyboard::Key::Named(keyboard::key::Named::Enter),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Enter),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
fn touch_down(id: u64, position: Point) -> Event {
Event::Touch(touch::Event::FingerPressed {
id: touch::Finger(id),
position,
})
}
fn touch_up(id: u64, position: Point) -> Event {
Event::Touch(touch::Event::FingerLifted {
id: touch::Finger(id),
position,
})
}
fn touch_lost(id: u64, position: Point) -> Event {
Event::Touch(touch::Event::FingerLost {
id: touch::Finger(id),
position,
})
}
struct Mounted {
view: View,
tree: widget::Tree,
node: layout::Node,
}
impl Mounted {
fn new(mut view: View) -> Self {
let mut tree = widget::Tree::new(view.as_widget());
let node = view.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
);
Self { view, tree, node }
}
fn replace(&mut self, mut view: View) {
self.tree.diff(view.as_widget());
self.node = view.as_widget_mut().layout(
&mut self.tree,
&(),
&layout::Limits::new(Size::ZERO, BOUNDS.size()),
);
self.view = view;
}
fn event(&mut self, event: Event, point: Point) -> (Vec<Message>, event::Status) {
self.event_with_capture(event, point, false)
}
fn event_with_capture(
&mut self,
event: Event,
point: Point,
captured: bool,
) -> (Vec<Message>, event::Status) {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
if captured {
shell.capture_event();
}
self.view.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Available(point),
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&BOUNDS,
);
let status = shell.event_status();
(messages, status)
}
fn operate(&mut self, operation: &mut dyn widget::Operation) {
self.view
.as_widget_mut()
.operate(&mut self.tree, Layout::new(&self.node), &(), operation);
}
fn focus(&mut self) {
self.operate(&mut operation::focusable::focus::<()>("row".into()));
}
fn focused(&mut self) -> bool {
#[derive(Default)]
struct Focus(bool);
impl widget::Operation for Focus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn Focusable,
) {
self.0 |= state.is_focused();
}
}
let mut focus = Focus::default();
self.operate(&mut focus);
focus.0
}
fn scroll(&mut self) -> Vector {
#[derive(Default)]
struct Offset(Option<Vector>);
impl widget::Operation for Offset {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn Scrollable,
) {
self.0 = Some(translation);
}
}
let mut offset = Offset::default();
self.operate(&mut offset);
offset.0.unwrap()
}
}
#[test]
fn current_primary_and_related_mouse_clicks_survive_same_publication_diff() {
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
let generation = Id::new_v4();
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(generation), 1));
assert_eq!(ui.event(press(), point), (vec![], event::Status::Captured));
ui.replace(guarded(Some(generation), 1));
assert_eq!(
ui.event(release(), point),
(vec![expected], event::Status::Captured)
);
}
}
#[test]
fn same_publication_new_visit_cancels_armed_rows_without_a_hidden_tree_frame() {
for touch in [false, true] {
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
let publication = Id::new_v4();
let old_visit = Id::new_v4();
let new_visit = Id::new_v4();
let point = Point::new(20., y);
let mut ui = Mounted::new(Guard::new(Some((publication, old_visit)), rows(1)).into());
let down = || if touch { touch_down(7, point) } else { press() };
let up = || if touch { touch_up(7, point) } else { release() };
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
// Hide→Show has already happened in application state. No None
// publication or empty child is ever diffed into this retained Tree.
ui.replace(Guard::new(Some((publication, new_visit)), rows(2)).into());
assert_eq!(ui.event(up(), point), (vec![], event::Status::Captured));
assert_eq!(ui.event(down(), point), (vec![], event::Status::Captured));
assert_eq!(
ui.event(up(), point),
(vec![expected], event::Status::Captured)
);
}
}
}
#[test]
fn negative_control_iced_button_retargets_a_press_after_plain_tree_diff() {
for (y, expected) in [(20., Message::Primary(2)), (60., Message::Related(2))] {
let point = Point::new(20., y);
let mut ui = Mounted::new(rows(1));
assert!(ui.event(press(), point).0.is_empty());
ui.replace(rows(2));
assert_eq!(ui.event(release(), point).0, vec![expected]);
}
}
#[test]
fn replaced_primary_and_related_rows_consume_stale_release_then_accept_new_click() {
for y in [20., 60.] {
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(press(), point);
ui.replace(guarded(Some(Id::new_v4()), 2));
ui.replace(guarded(Some(Id::new_v4()), 3));
assert_eq!(
ui.event(release(), point),
(vec![], event::Status::Captured)
);
ui.event(press(), point);
let expected = if y < 40. {
Message::Primary(3)
} else {
Message::Related(3)
};
assert_eq!(
ui.event(release(), point),
(vec![expected], event::Status::Captured)
);
}
}
#[test]
fn hidden_zero_size_list_consumes_release_outside_old_bounds_once() {
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(press(), Point::new(20., 20.));
ui.replace(guarded(None, 0));
ui.replace(guarded(None, 0));
assert_eq!(ui.node.size(), Size::ZERO);
let outside = Point::new(350., 250.);
assert_eq!(
ui.event(release(), outside),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(release(), outside),
(vec![], event::Status::Ignored)
);
ui.replace(guarded(Some(Id::new_v4()), 2));
assert_eq!(ui.event(release(), Point::new(20., 20.)).0, vec![]);
}
fn with_background(content: View) -> View {
mouse_area(
button(container(content).width(250.).height(100.))
.padding(0)
.on_press(Message::BackgroundButton),
)
.on_release(Message::BackgroundRelease)
.into()
}
#[test]
fn stale_release_does_not_bubble_to_real_background_controls() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(with_background(guarded(Some(Id::new_v4()), 1)));
ui.event(press(), point);
ui.replace(with_background(guarded(None, 0)));
assert_eq!(
ui.event(release(), point),
(vec![], event::Status::Captured)
);
// The enclosing real button still supports a fresh ordinary click.
ui.event(press(), point);
assert_eq!(
ui.event(release(), point).0,
vec![Message::BackgroundButton]
);
}
#[test]
fn negative_control_removing_unguarded_rows_exposes_background_release() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(with_background(rows(1)));
ui.event(press(), point);
ui.replace(with_background(Space::new().into()));
assert_eq!(
ui.event(release(), point).0,
vec![Message::BackgroundRelease]
);
}
#[test]
fn current_touch_activates_primary_and_related_and_cancel_does_not_activate() {
for (y, expected) in [(20., Message::Primary(1)), (60., Message::Related(1))] {
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
assert_eq!(
ui.event(touch_down(1, point), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(touch_up(1, point), point),
(vec![expected], event::Status::Captured)
);
ui.event(touch_down(2, point), point);
assert_eq!(
ui.event(touch_lost(2, point), point),
(vec![], event::Status::Captured)
);
assert!(ui.event(touch_up(2, point), point).0.is_empty());
}
}
#[test]
fn replaced_touch_consumes_lift_or_cancel_even_when_hidden() {
for hide in [false, true] {
for lost in [false, true] {
for y in [20., 60.] {
let point = Point::new(20., y);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(touch_down(1, point), point);
ui.replace(guarded(if hide { None } else { Some(Id::new_v4()) }, 2));
let end = if lost {
touch_lost(1, point)
} else {
touch_up(1, point)
};
assert_eq!(ui.event(end, point), (vec![], event::Status::Captured));
}
}
}
}
#[test]
fn unrelated_finger_or_mouse_release_cannot_complete_another_fingers_press() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(touch_down(1, point), point);
ui.event(touch_down(2, point), point);
assert_eq!(
ui.event(touch_up(2, point), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(release(), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(touch_up(1, point), point).0,
vec![Message::Primary(1)]
);
}
fn keyboard_row(version: u8) -> View {
// Iced 0.14.2 Button has no focus/keyboard activation. A real focusable
// submit control tests the wrapper's focus lifecycle without inventing it.
text_input("Diagnostic action", "row")
.id("row")
.on_input(|_| Message::Edit)
.on_submit(Message::Submit(version))
.into()
}
#[test]
fn same_publication_preserves_focus_but_replacement_never_inherits_activation() {
let generation = Id::new_v4();
let mut ui = Mounted::new(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
ui.focus();
assert!(ui.focused());
ui.replace(Guard::new(Some((generation, Id::nil())), keyboard_row(1)).into());
assert!(ui.focused());
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(2)).into());
assert!(!ui.focused());
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
ui.focus();
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
ui.replace(Guard::new(None, Space::new()).into());
ui.replace(Guard::new(Some((Id::new_v4(), Id::nil())), keyboard_row(3)).into());
assert!(!ui.focused());
assert!(ui.event(enter(), Point::ORIGIN).0.is_empty());
}
#[test]
fn negative_control_plain_focusable_row_migrates_activation_to_replacement() {
let mut ui = Mounted::new(keyboard_row(1));
ui.focus();
ui.replace(keyboard_row(2));
assert!(ui.focused());
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(2)]);
}
#[test]
fn outer_scroll_offset_survives_same_and_changed_publications() {
fn pane(generation: Id) -> View {
scrollable(Guard::new(
Some((generation, Id::nil())),
column![rows(1), Space::new().height(600.)],
))
.width(240.)
.height(100.)
.into()
}
let generation = Id::new_v4();
let mut ui = Mounted::new(pane(generation));
ui.event(
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -120. },
}),
Point::new(20., 20.),
);
let offset = ui.scroll();
assert_eq!(offset, Vector::new(0., 120.));
ui.replace(pane(generation));
assert_eq!(ui.scroll(), offset);
ui.replace(pane(Id::new_v4()));
assert_eq!(ui.scroll(), offset);
}
#[test]
fn stale_touch_release_does_not_disarm_a_new_current_gesture() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(touch_down(1, point), point);
ui.replace(guarded(Some(Id::new_v4()), 2));
ui.event(touch_down(2, point), point);
assert_eq!(
ui.event(touch_up(1, point), point),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(touch_up(2, point), point).0,
vec![Message::Primary(2)]
);
}
#[test]
fn fresh_mouse_press_after_missing_stale_release_can_activate_current_row() {
let point = Point::new(20., 20.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
ui.event(press(), point);
ui.replace(guarded(Some(Id::new_v4()), 2));
// The platform delivers another down without the previous up.
ui.event(press(), point);
assert_eq!(ui.event(release(), point).0, vec![Message::Primary(2)]);
}
#[test]
fn missed_press_is_ignored_and_dragging_release_outside_does_not_activate() {
let inside = Point::new(20., 20.);
let outside = Point::new(350., 250.);
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
assert_eq!(ui.event(press(), outside), (vec![], event::Status::Ignored));
assert_eq!(
ui.event(release(), inside),
(vec![], event::Status::Ignored)
);
ui.event(press(), inside);
assert_eq!(
ui.event(release(), outside),
(vec![], event::Status::Captured)
);
assert_eq!(
ui.event(release(), inside),
(vec![], event::Status::Ignored)
);
}
#[test]
fn same_publication_missing_terminal_recovers_on_fresh_mouse_or_touch_down() {
let primary = Point::new(20., 20.);
let related = Point::new(20., 60.);
for is_touch in [false, true] {
let generation = Id::new_v4();
let mut ui = Mounted::new(guarded(Some(generation), 1));
let down = |point| {
if is_touch {
touch_down(1, point)
} else {
press()
}
};
let up = |point| {
if is_touch {
touch_up(1, point)
} else {
release()
}
};
ui.event(down(primary), primary);
ui.replace(guarded(Some(generation), 1));
// The first gesture never receives its terminal event; same Id means
// no publication reset can accidentally make this regression pass.
assert_eq!(
ui.event(down(related), related),
(vec![], event::Status::Captured)
);
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
assert!(ui.event(up(primary), primary).0.is_empty());
}
}
#[test]
fn already_captured_terminal_cancels_child_without_activation_or_lingering_press() {
let primary = Point::new(20., 20.);
let related = Point::new(20., 60.);
for is_touch in [false, true] {
let mut ui = Mounted::new(guarded(Some(Id::new_v4()), 1));
let down = |point| {
if is_touch {
touch_down(1, point)
} else {
press()
}
};
let up = |point| {
if is_touch {
touch_up(1, point)
} else {
release()
}
};
ui.event(down(primary), primary);
assert_eq!(
ui.event_with_capture(up(primary), primary, true),
(vec![], event::Status::Captured)
);
// An unpaired second release must not expose a lingering button press.
assert_eq!(
ui.event(up(primary), primary),
(vec![], event::Status::Ignored)
);
ui.event(down(related), related);
assert_eq!(ui.event(up(related), related).0, vec![Message::Related(1)]);
}
}
#[test]
fn captured_terminal_cancellation_preserves_same_publication_focusable_child_state() {
let generation = Id::new_v4();
let mut ui = Mounted::new(
Guard::new(
Some((generation, Id::nil())),
column![rows(1), keyboard_row(1)],
)
.into(),
);
ui.event(press(), Point::new(20., 20.));
// Focus through an operation avoids making the old pointer gesture whole.
ui.focus();
assert!(ui.focused());
assert!(
ui.event_with_capture(release(), Point::new(20., 20.), true)
.0
.is_empty()
);
assert!(
ui.focused(),
"gesture cancellation must not remount the row tree"
);
assert_eq!(ui.event(enter(), Point::ORIGIN).0, vec![Message::Submit(1)]);
}
#[test]
fn hidden_zero_height_scroller_delivers_cancelled_mouse_and_touch_terminals_to_guard() {
fn pane(generation: Option<Id>) -> View {
scrollable(guarded(generation, 1))
.width(240.)
.height(if generation.is_some() { 100. } else { 0. })
.into()
}
let point = Point::new(20., 20.);
let outside = Point::new(350., 250.);
for is_touch in [false, true] {
let mut ui = Mounted::new(pane(Some(Id::new_v4())));
let down = if is_touch {
touch_down(1, point)
} else {
press()
};
assert_eq!(ui.event(down, point), (vec![], event::Status::Captured));
ui.replace(pane(None));
ui.replace(pane(None));
assert_eq!(ui.node.size().height, 0.);
let up = if is_touch {
touch_up(1, outside)
} else {
release()
};
assert_eq!(ui.event(up, outside), (vec![], event::Status::Captured));
}
}