Port example 03

This commit is contained in:
2026-03-21 23:37:12 -04:00
parent bc287f615d
commit 0d8bc38113
4 changed files with 570 additions and 1 deletions

View File

@@ -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"

View File

@@ -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::<Vec<_>>()
})
}
});
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::<Vec<_>>()
});
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<Vec<Task>>, 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<Vec<Task>>, 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<Vec<Task>>, 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<Task> {
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<View>`-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,
},
]
}

View File

@@ -499,6 +499,12 @@ impl<T: IntoView> Children for T {
}
}
impl<T: IntoView> Children for Vec<T> {
fn into_views(self) -> Vec<View> {
self.into_iter().map(IntoView::into_view).collect()
}
}
macro_rules! impl_children_tuple {
($($name:ident),+ $(,)?) => {
#[allow(non_camel_case_types)]