1use crate::control::QuietSurfaceExt as _;
4use crate::control::composed_button;
5use crate::decoration::{DecoratedExt as _, Decoration};
6use crate::handlers::SharedHandler;
7use crate::motion::{ArrivalRoster, MotionTokens, acknowledged_state};
8use crate::surface::CardFrameExt as _;
9use crate::theme::SemanticStyledExt as _;
10use gpui::{
11 App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
12 ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
13 StyleRefinement, Styled, Window, accesskit, div, prelude::FluentBuilder as _,
14};
15use gpui_base::motion::{Transition, transition};
16use gpui_component::{
17 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, spinner::Spinner,
18 v_flex,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum TodoStatus {
24 #[default]
26 Pending,
27 Active,
29 Done,
31}
32
33#[derive(Debug, Clone)]
35pub struct TodoItem {
36 id: SharedString,
37 label: SharedString,
38 status: TodoStatus,
39}
40
41impl TodoItem {
42 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
44 Self {
45 id: id.into(),
46 label: label.into(),
47 status: TodoStatus::default(),
48 }
49 }
50
51 pub fn status(mut self, status: TodoStatus) -> Self {
53 self.status = status;
54 self
55 }
56
57 pub fn done(self) -> Self {
59 self.status(TodoStatus::Done)
60 }
61
62 fn toggled_event(&self) -> TodoListEvent {
63 TodoListEvent::Toggled {
64 id: self.id.clone(),
65 }
66 }
67
68 fn accessibility_toggled(&self) -> accesskit::Toggled {
69 match self.status {
70 TodoStatus::Done => accesskit::Toggled::True,
71 TodoStatus::Pending | TodoStatus::Active => accesskit::Toggled::False,
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum TodoListEvent {
79 Toggled {
81 id: SharedString,
83 },
84}
85
86#[derive(IntoElement)]
105pub struct TodoList {
106 id: ElementId,
107 style: StyleRefinement,
108 decoration: Decoration,
109 title: Option<SharedString>,
110 items: Vec<TodoItem>,
111 on_event: Option<SharedHandler<TodoListEvent>>,
112}
113
114impl TodoList {
115 pub fn decoration(mut self, decoration: Decoration) -> Self {
121 self.decoration = decoration;
122 self
123 }
124
125 pub fn new(id: impl Into<ElementId>) -> Self {
127 Self {
128 id: id.into(),
129 style: StyleRefinement::default(),
130 decoration: Decoration::default(),
131 title: None,
132 items: Vec::new(),
133 on_event: None,
134 }
135 }
136
137 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
139 self.title = Some(title.into());
140 self
141 }
142
143 pub fn items(mut self, items: impl IntoIterator<Item = TodoItem>) -> Self {
145 self.items = items.into_iter().collect();
146 self
147 }
148
149 pub fn on_event(
151 mut self,
152 handler: impl Fn(&TodoListEvent, &mut Window, &mut App) + 'static,
153 ) -> Self {
154 self.on_event = Some(std::rc::Rc::new(handler));
155 self
156 }
157}
158
159impl Styled for TodoList {
160 fn style(&mut self) -> &mut StyleRefinement {
161 &mut self.style
162 }
163}
164
165impl RenderOnce for TodoList {
166 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
167 let tokens = cx.theme().semantic_tokens();
168 let mut decoration = std::mem::take(&mut self.decoration);
171 let motion = MotionTokens::read(cx).clone();
172 let done = self
173 .items
174 .iter()
175 .filter(|item| item.status == TodoStatus::Done)
176 .count();
177 let total = self.items.len();
178 let handler = self.on_event;
179
180 let fraction = if total == 0 {
185 0.0
186 } else {
187 done as f32 / total as f32
188 };
189 let fill = transition(
190 (self.id.clone(), "todo-fill"),
191 fraction,
192 Transition::new(crate::motion::retarget_duration(cx, motion.standard()))
193 .ease(crate::motion::ease_in_out_quart),
194 window,
195 cx,
196 );
197
198 let roster = window.use_keyed_state((self.id.clone(), "arrivals"), cx, |_, _| {
202 ArrivalRoster::new()
203 });
204 roster.update(cx, |roster, cx| {
205 roster.note(
206 self.items
207 .iter()
208 .map(|item| ElementId::Name(SharedString::from(format!("todo-{}", item.id)))),
209 true,
210 &motion,
211 cx.background_executor().now(),
212 );
213 });
214 let list_name = self.title.clone().unwrap_or_else(|| "To-do list".into());
215 let root_id = self.id.clone();
216 let accessibility_label: SharedString =
217 format!("{list_name}, {done} of {total} complete").into();
218
219 v_flex()
220 .id(self.id)
221 .role(Role::List)
222 .aria_label(accessibility_label)
223 .p(tokens.spacing.md)
224 .gap(tokens.spacing.xs)
225 .card_frame(cx)
226 .decoration_under(&mut decoration)
227 .when_some(self.title, |this, title| {
228 this.child(
229 h_flex()
230 .items_center()
231 .justify_between()
232 .pb(tokens.spacing.xs)
233 .child(
236 div()
237 .min_w_0()
238 .truncate()
239 .text_token(tokens.typography.xs)
240 .font_weight(FontWeight::SEMIBOLD)
241 .text_color(cx.theme().muted_foreground)
242 .child(title),
243 )
244 .child(
245 div()
246 .flex_none()
247 .debug_selector(|| "todo-list-count".to_owned())
248 .text_token(tokens.typography.xs)
249 .font_family(cx.theme().mono_font_family.clone())
250 .text_color(cx.theme().muted_foreground)
251 .child(format!("{done}/{total}")),
252 ),
253 )
254 })
255 .child(
256 div()
259 .w_full()
260 .h(tokens.spacing.xxs)
261 .rounded(tokens.radius.full)
262 .bg(cx.theme().muted)
263 .child(
264 div()
265 .h_full()
266 .w(gpui::relative(fill))
267 .rounded(tokens.radius.full)
268 .bg(cx.theme().primary),
269 ),
270 )
271 .children(self.items.into_iter().map(|item| {
272 let item_id = item.id.clone();
273 let accessibility_label = item.label.clone();
274 let accessibility_toggled = item.accessibility_toggled();
275 let accessibility_description =
276 (item.status == TodoStatus::Active).then_some("In progress");
277 let acknowledged = acknowledged_state(
278 ElementId::from((ElementId::from((root_id.clone(), item.id.clone())), "glyph")),
279 item.status as u64,
280 window,
281 cx,
282 );
283 let indicator = match item.status {
284 TodoStatus::Pending => div()
285 .size_3()
286 .rounded(tokens.radius.full)
287 .border_1()
288 .border_color(cx.theme().muted_foreground)
289 .into_any_element(),
290 TodoStatus::Active => Spinner::new()
291 .xsmall()
292 .color(cx.theme().info)
293 .into_any_element(),
294 TodoStatus::Done => Icon::new(IconName::CircleCheck)
298 .small()
299 .text_color(cx.theme().success)
300 .opacity(acknowledged)
301 .into_any_element(),
302 };
303 let arrival = roster.update(cx, |roster, cx| {
304 roster.progress(
305 &ElementId::Name(SharedString::from(format!("todo-{}", item.id))),
306 window,
307 cx,
308 )
309 });
310
311 let event = item.toggled_event();
312 let row = h_flex()
313 .w_full()
314 .items_start()
315 .gap(tokens.spacing.sm)
316 .child(
317 crate::surface::leading_glyph_slot(crate::sizing::slot_md(cx), indicator),
322 )
323 .child(
324 div()
325 .flex_1()
326 .min_w_0()
327 .text_token(tokens.typography.sm)
328 .map(|this| match item.status {
329 TodoStatus::Done => {
330 this.line_through().text_color(cx.theme().muted_foreground)
331 }
332 TodoStatus::Active => this.text_color(cx.theme().foreground),
333 TodoStatus::Pending => this.text_color(cx.theme().muted_foreground),
334 })
335 .child(item.label),
336 );
337
338 let row = match arrival {
339 Some(progress) => row
340 .opacity(progress)
341 .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx)),
342 None => row,
343 };
344
345 match handler.clone() {
346 Some(handler) => composed_button(item.id.clone(), accessibility_label)
347 .w_full()
348 .role(Role::CheckBox)
349 .aria_toggled(accessibility_toggled)
350 .px(tokens.spacing.xs)
351 .py(tokens.spacing.xxs)
352 .quiet_press_surface(
353 ElementId::from((ElementId::from(item.id.clone()), "press")),
354 tokens.radius.sm,
355 window,
356 cx,
357 )
358 .when_some(accessibility_description, |this, description| {
359 this.aria_description(description)
360 })
361 .child(row)
362 .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
363 .into_any_element(),
364 None => div()
365 .id(item_id)
366 .role(Role::ListItem)
367 .aria_label(accessibility_label)
368 .when_some(accessibility_description, |this, description| {
369 this.aria_description(description)
370 })
371 .child(row)
372 .into_any_element(),
373 }
374 }))
375 .decoration_over(&mut decoration)
376 .refine_style(&self.style)
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
384
385 struct ListProbe {
386 count: usize,
387 }
388
389 impl Render for ListProbe {
390 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
391 div().w(px(320.)).h(px(320.)).child(
392 TodoList::new("probe-plan")
393 .title("Plan")
394 .items((0..self.count).map(|ix| TodoItem::new(format!("step-{ix}"), "Step"))),
395 )
396 }
397 }
398
399 fn draw(cx: &mut VisualTestContext) {
400 cx.update(|window, cx| window.draw(cx).clear(cx));
401 }
402
403 #[gpui::test]
404 fn a_loaded_list_rests_and_an_appended_item_settles_in(cx: &mut TestAppContext) {
405 cx.update(crate::init);
406 let (probe, cx) = cx.add_window_view(|_, _| ListProbe { count: 3 });
407 let cx: &mut VisualTestContext = cx;
408 draw(cx);
409 crate::motion::take_reveal_frame_requests();
410 draw(cx);
411 assert_eq!(
412 crate::motion::take_reveal_frame_requests(),
413 0,
414 "the initial load joins at rest"
415 );
416
417 probe.update(cx, |probe, cx| {
418 probe.count = 4;
419 cx.notify();
420 });
421 draw(cx);
422 assert!(
423 crate::motion::take_reveal_frame_requests() > 0,
424 "an item appended to a mounted list must settle in"
425 );
426
427 cx.executor()
428 .advance_clock(std::time::Duration::from_secs(2));
429 draw(cx);
430 crate::motion::take_reveal_frame_requests();
431 probe.update(cx, |_, cx| cx.notify());
432 draw(cx);
433 assert_eq!(
434 crate::motion::take_reveal_frame_requests(),
435 0,
436 "a re-render must not replay an acknowledged arrival"
437 );
438 }
439
440 #[test]
441 fn duplicate_labels_emit_distinct_stable_ids() {
442 let first = TodoItem::new("first", "Same step");
443 let second = TodoItem::new("second", "Same step");
444 assert_eq!(
445 first.toggled_event(),
446 TodoListEvent::Toggled { id: "first".into() }
447 );
448 assert_eq!(
449 second.toggled_event(),
450 TodoListEvent::Toggled {
451 id: "second".into()
452 }
453 );
454 }
455
456 #[test]
457 fn accessibility_checked_state_tracks_completion() {
458 assert_eq!(
459 TodoItem::new("pending", "Pending").accessibility_toggled(),
460 accesskit::Toggled::False
461 );
462 assert_eq!(
463 TodoItem::new("active", "Active")
464 .status(TodoStatus::Active)
465 .accessibility_toggled(),
466 accesskit::Toggled::False
467 );
468 assert_eq!(
469 TodoItem::new("done", "Done").done().accessibility_toggled(),
470 accesskit::Toggled::True
471 );
472 }
473}