Port example 03
This commit is contained in:
@@ -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"
|
||||
|
||||
512
lib/ruin_app/example/03_fine_grained_list.rs
Normal file
512
lib/ruin_app/example/03_fine_grained_list.rs
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -2135,7 +2135,11 @@ fn push_clip_state(stack: &mut Vec<ActiveClip>, 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user