diff --git a/lib/ruin_app/Cargo.toml b/lib/ruin_app/Cargo.toml index 04746de..f6e72b7 100644 --- a/lib/ruin_app/Cargo.toml +++ b/lib/ruin_app/Cargo.toml @@ -25,3 +25,7 @@ path = "example/01_async_data_and_effects.rs" [[example]] name = "02_widget_refs_and_commands" path = "example/02_widget_refs_and_commands.rs" + +[[example]] +name = "03_fine_grained_list" +path = "example/03_fine_grained_list.rs" diff --git a/lib/ruin_app/example/03_fine_grained_list.rs b/lib/ruin_app/example/03_fine_grained_list.rs new file mode 100644 index 0000000..3ff577e --- /dev/null +++ b/lib/ruin_app/example/03_fine_grained_list.rs @@ -0,0 +1,512 @@ +use ruin_app::prelude::*; + +#[ruin_runtime::async_main] +async fn main() -> ruin_app::Result<()> { + App::new() + .window( + Window::new() + .title("RUIN Tasks") + .app_id("dev.ruin.fine-grained-list") + .size(1180.0, 780.0), + ) + .mount(view! { + TaskBoard() {} + }) + .run() + .await +} + +#[component] +fn TaskBoard() -> impl IntoView { + let filter = use_signal(|| TaskFilter::All); + let tasks = use_signal(seed_tasks); + let list_scroll = use_signal(|| 0.0_f32); + + let total_count = use_memo({ + let tasks = tasks.clone(); + move || tasks.with(|tasks| tasks.len()) + }); + let open_count = use_memo({ + let tasks = tasks.clone(); + move || { + tasks.with(|tasks| { + tasks + .iter() + .filter(|task| !matches!(task.status, TaskStatus::Done)) + .count() + }) + } + }); + let done_count = use_memo({ + let tasks = tasks.clone(); + move || { + tasks.with(|tasks| { + tasks + .iter() + .filter(|task| matches!(task.status, TaskStatus::Done)) + .count() + }) + } + }); + let visible_ids = use_memo({ + let filter = filter.clone(); + let tasks = tasks.clone(); + move || { + tasks.with(|tasks| { + tasks + .iter() + .filter(|task| filter.get().matches(task.status)) + .map(|task| task.id) + .collect::>() + }) + } + }); + + use_window_title({ + let visible_ids = visible_ids.clone(); + let total_count = total_count.clone(); + move || { + format!( + "RUIN Tasks ({}/{})", + visible_ids.with(|ids| ids.len()), + total_count.get() + ) + } + }); + + let all_label = if matches!(filter.get(), TaskFilter::All) { + "● All" + } else { + "○ All" + }; + let open_label = if matches!(filter.get(), TaskFilter::OpenOnly) { + "● Open" + } else { + "○ Open" + }; + let done_label = if matches!(filter.get(), TaskFilter::CompletedOnly) { + "● Completed" + } else { + "○ Completed" + }; + let task_rows = visible_ids.with(|ids| { + ids.iter() + .map(|task_id| { + TaskRow::builder() + .tasks(tasks.clone()) + .task_id(*task_id) + .children(()) + }) + .collect::>() + }); + + view! { + column(gap = 16.0, padding = 24.0) { + text(role = TextRole::Heading(1), size = 30.0, weight = FontWeight::Semibold) { + "Fine-grained list updates" + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "This is an honest slice of aspirational example 03: filtering, row mutation, \ + reordering, and wrapped-list reflow all work end-to-end. True keyed diffing and \ + layout-island invalidation are still future work, so this example exercises the \ + real runtime we have rather than pretending those optimizations already exist." + } + + row(gap = 8.0) { + button(on_press = { + let filter = filter.clone(); + move |_| { + let _ = filter.set(TaskFilter::All); + } + }) { all_label } + + button(on_press = { + let filter = filter.clone(); + move |_| { + let _ = filter.set(TaskFilter::OpenOnly); + } + }) { open_label } + + button(on_press = { + let filter = filter.clone(); + move |_| { + let _ = filter.set(TaskFilter::CompletedOnly); + } + }) { done_label } + + button(on_press = { + let tasks = tasks.clone(); + move |_| { + tasks.replace(seed_tasks()); + } + }) { "Reset board" } + } + + row(gap = 16.0) { + block( + flex = 1.0, + padding = 16.0, + gap = 12.0, + background = surfaces::raised(), + border_radius = 12.0, + ) { + text(size = 18.0, weight = FontWeight::Semibold) { "Tasks" } + + text(color = colors::muted()) { + visible_ids.with(|ids| ids.len()); + " visible · "; + total_count.clone(); + " total" + } + + scroll_box( + offset_y = list_scroll.clone(), + height = 520.0, + padding = 12.0, + background = surfaces::canvas(), + border_radius = 10.0, + border = (2.0, colors::muted()), + ) { + column(gap = 10.0) { + task_rows + } + } + } + + block( + width = 300.0, + padding = 16.0, + gap = 12.0, + background = surfaces::raised(), + border_radius = 12.0, + ) { + text(size = 18.0, weight = FontWeight::Semibold) { "Board summary" } + + block( + padding = 12.0, + gap = 8.0, + background = surfaces::canvas(), + border_radius = 10.0, + ) { + text() { "Open tasks: "; open_count.clone() } + text() { "Completed tasks: "; done_count.clone() } + text() { "Current filter: "; filter.get().label() } + } + + text(color = colors::muted(), wrap = TextWrap::Word) { + "Each task row can advance its status, expand notes, and move within the \ + backing list. The scroll box is intentional here: resizing the window \ + changes wrapping pressure, which is useful for validating list reflow and \ + scroll clamping under real content." + } + } + } + } + } +} + +#[component] +fn TaskRow(tasks: Signal>, task_id: u64) -> impl IntoView { + let task = tasks.with(|tasks| { + tasks + .iter() + .find(|task| task.id == task_id) + .cloned() + .expect("task row should only render live tasks") + }); + let task_index = tasks.with(|tasks| { + tasks + .iter() + .position(|task| task.id == task_id) + .expect("task row should know its current index") + }); + let can_move_up = task_index > 0; + let can_move_down = tasks.with(|tasks| task_index + 1 < tasks.len()); + + let mut detail_views = vec![ + text() + .weight(if matches!(task.status, TaskStatus::Done) { + FontWeight::Medium + } else { + FontWeight::Semibold + }) + .children(task.title.clone()), + text().color(colors::muted()).children(( + "Owner: ", + task.owner.clone(), + " · Phase: ", + task.status.description(), + )), + ]; + if task.expanded { + detail_views.push( + text() + .color(colors::muted()) + .wrap(TextWrap::Word) + .children(task.notes.clone()), + ); + } + + let mut action_views = vec![ + button() + .on_press({ + let tasks = tasks.clone(); + move |_| { + update_task(&tasks, task_id, |task| { + task.status = task.status.advance(); + }); + } + }) + .children(task.status.advance_label()), + button() + .on_press({ + let tasks = tasks.clone(); + move |_| { + update_task(&tasks, task_id, |task| { + task.expanded = !task.expanded; + }); + } + }) + .children(if task.expanded { + "Hide notes" + } else { + "Show notes" + }), + ]; + if can_move_up { + action_views.push( + button() + .on_press({ + let tasks = tasks.clone(); + move |_| move_task(&tasks, task_id, MoveDirection::Up) + }) + .children("Move up"), + ); + } + if can_move_down { + action_views.push( + button() + .on_press({ + let tasks = tasks.clone(); + move |_| move_task(&tasks, task_id, MoveDirection::Down) + }) + .children("Move down"), + ); + } + + view! { + block( + padding = 14.0, + gap = 12.0, + background = task.status.background(), + border_radius = 12.0, + border = (1.0, task.status.accent()), + ) { + row(gap = 12.0) { + block( + width = 120.0, + padding = 10.0, + background = surfaces::canvas(), + border_radius = 10.0, + border = (1.0, task.status.accent()), + ) { + text(weight = FontWeight::Semibold, color = task.status.accent()) { + task.status.label() + } + } + + column(flex = 1.0, gap = 8.0) { + detail_views + } + } + + row(gap = 8.0) { + action_views + } + } + } +} + +#[derive(Clone)] +struct Task { + id: u64, + title: String, + owner: String, + notes: String, + status: TaskStatus, + expanded: bool, +} + +#[derive(Clone, Copy)] +enum MoveDirection { + Up, + Down, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TaskStatus { + Backlog, + Doing, + Done, +} + +impl TaskStatus { + const fn label(self) -> &'static str { + match self { + TaskStatus::Backlog => "Backlog", + TaskStatus::Doing => "Doing", + TaskStatus::Done => "Done", + } + } + + const fn description(self) -> &'static str { + match self { + TaskStatus::Backlog => "Waiting for a pass", + TaskStatus::Doing => "Actively being verified", + TaskStatus::Done => "Ready to ship", + } + } + + const fn advance(self) -> Self { + match self { + TaskStatus::Backlog => TaskStatus::Doing, + TaskStatus::Doing => TaskStatus::Done, + TaskStatus::Done => TaskStatus::Backlog, + } + } + + const fn advance_label(self) -> &'static str { + match self { + TaskStatus::Backlog => "Start work", + TaskStatus::Doing => "Complete", + TaskStatus::Done => "Reopen", + } + } + + const fn accent(self) -> Color { + match self { + TaskStatus::Backlog => Color::rgb(0xE3, 0xB5, 0x65), + TaskStatus::Doing => Color::rgb(0x71, 0xA7, 0xF7), + TaskStatus::Done => Color::rgb(0x69, 0xC3, 0x7D), + } + } + + const fn background(self) -> Color { + match self { + TaskStatus::Backlog => Color::rgb(0x38, 0x2B, 0x1C), + TaskStatus::Doing => Color::rgb(0x1F, 0x2E, 0x49), + TaskStatus::Done => Color::rgb(0x1E, 0x36, 0x28), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TaskFilter { + All, + OpenOnly, + CompletedOnly, +} + +impl TaskFilter { + const fn matches(self, status: TaskStatus) -> bool { + match self { + TaskFilter::All => true, + TaskFilter::OpenOnly => !matches!(status, TaskStatus::Done), + TaskFilter::CompletedOnly => matches!(status, TaskStatus::Done), + } + } + + const fn label(self) -> &'static str { + match self { + TaskFilter::All => "All", + TaskFilter::OpenOnly => "Open only", + TaskFilter::CompletedOnly => "Completed only", + } + } +} + +fn update_task(tasks: &Signal>, task_id: u64, mutate: impl FnOnce(&mut Task)) { + tasks.update(|tasks| { + if let Some(task) = tasks.iter_mut().find(|task| task.id == task_id) { + mutate(task); + } + }); +} + +fn move_task(tasks: &Signal>, task_id: u64, direction: MoveDirection) { + tasks.update(|tasks| { + let Some(index) = tasks.iter().position(|task| task.id == task_id) else { + return; + }; + let target = match direction { + MoveDirection::Up if index > 0 => index - 1, + MoveDirection::Down if index + 1 < tasks.len() => index + 1, + _ => index, + }; + if target != index { + tasks.swap(index, target); + } + }); +} + +fn seed_tasks() -> Vec { + vec![ + Task { + id: 1, + title: "Audit nested clip propagation".to_string(), + owner: "Renderer".to_string(), + notes: "Confirm that child rounded clips preserve parent clip intersections, including the empty-intersection case that previously let fully offscreen text reappear.".to_string(), + status: TaskStatus::Done, + expanded: false, + }, + Task { + id: 2, + title: "Clamp stale scroll offsets on reflow".to_string(), + owner: "Layout".to_string(), + notes: "When wrapped content gets shorter after a resize, the scrollbox should clamp the old offset before positioning children so the viewport never opens up an empty gap.".to_string(), + status: TaskStatus::Done, + expanded: false, + }, + Task { + id: 3, + title: "Prototype honest example 03 slice".to_string(), + owner: "ruin_app".to_string(), + notes: "Keep the example faithful to the current runtime: task filtering, list reordering, and row mutation should work today even though keyed diffing and layout islands are still future work.".to_string(), + status: TaskStatus::Doing, + expanded: true, + }, + Task { + id: 4, + title: "Add vector child composition".to_string(), + owner: "Macros".to_string(), + notes: "Allow `Vec`-style composition so examples can build honest dynamic lists without inventing fake `for` syntax before the macro grammar is ready.".to_string(), + status: TaskStatus::Doing, + expanded: false, + }, + Task { + id: 5, + title: "Stress wrapped task notes".to_string(), + owner: "Examples".to_string(), + notes: "Task notes should be long enough to force wrapping and scroll-box growth so resize/reflow behavior is easy to observe while manually testing the example.".to_string(), + status: TaskStatus::Backlog, + expanded: false, + }, + Task { + id: 6, + title: "Decide the keyed-list surface".to_string(), + owner: "Design".to_string(), + notes: "Later passes still need a real keyed list story, stable nested component identity, and layout invalidation boundaries rather than whole-tree rerenders.".to_string(), + status: TaskStatus::Backlog, + expanded: false, + }, + Task { + id: 7, + title: "Polish button variants".to_string(), + owner: "Design".to_string(), + notes: "The current button surface is deliberately minimal. Example 03 would read even better once button kinds and denser inline controls exist.".to_string(), + status: TaskStatus::Backlog, + expanded: false, + }, + ] +} diff --git a/lib/ruin_app/src/lib.rs b/lib/ruin_app/src/lib.rs index a5300e2..6f31aa3 100644 --- a/lib/ruin_app/src/lib.rs +++ b/lib/ruin_app/src/lib.rs @@ -499,6 +499,12 @@ impl Children for T { } } +impl Children for Vec { + fn into_views(self) -> Vec { + self.into_iter().map(IntoView::into_view).collect() + } +} + macro_rules! impl_children_tuple { ($($name:ident),+ $(,)?) => { #[allow(non_camel_case_types)] diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 25bae34..0bb85df 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -2135,7 +2135,11 @@ fn push_clip_state(stack: &mut Vec, active: &mut ActiveClip, region: active.rounded = Some((region.rect, region.radius)); } active.rect = if active.rect_active { - intersect_rects(active.rect, Some(region.rect)) + if active.rect.is_none() { + None + } else { + intersect_rects(active.rect, Some(region.rect)) + } } else { Some(region.rect) }; @@ -2397,4 +2401,47 @@ mod tests { .is_none() ); } + + #[test] + fn deeper_nested_clip_cannot_revive_empty_parent_clip() { + let mut clip_stack = Vec::new(); + let mut active_clip = ActiveClip::default(); + + push_clip_state( + &mut clip_stack, + &mut active_clip, + ClipRegion { + rect: Rect::new(0.0, 0.0, 100.0, 100.0), + radius: 0.0, + }, + ); + push_clip_state( + &mut clip_stack, + &mut active_clip, + ClipRegion { + rect: Rect::new(150.0, 150.0, 50.0, 50.0), + radius: 12.0, + }, + ); + push_clip_state( + &mut clip_stack, + &mut active_clip, + ClipRegion { + rect: Rect::new(160.0, 160.0, 24.0, 12.0), + radius: 8.0, + }, + ); + + assert!(active_clip.rect_active); + assert_eq!(active_clip.rect, None); + assert!( + build_text_vertices( + Point::new(162.0, 162.0), + UiSize::new(16.0, 8.0), + UiSize::new(400.0, 400.0), + active_clip, + ) + .is_none() + ); + } }