1use std::cell::RefCell;
20use std::collections::HashMap;
21use std::ops::Range;
22use std::rc::Rc;
23
24use gpui::{
25 AnyElement, App, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement,
26 RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, Window, div,
27 point, prelude::FluentBuilder, px, uniform_list,
28};
29use gpui_kit_semantics::{NodeSpec, Role, Semantic};
30use gpui_kit_theme::{ActiveTheme, ControlSize, Space, Theme};
31
32use crate::data::viewport::scroll_handle;
33pub use crate::data::viewport::scroll_to_row;
34use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt};
35use crate::interaction::dnd::{
36 self, DragItem, DropAxis, DropIntent, DropPosition, MakingWay, RowTarget, SurfaceDrag,
37};
38
39type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
40type RenderRow = Rc<dyn Fn(usize, &mut Window, &mut App) -> ListItem>;
41type ReorderHandler = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
42type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
43
44pub struct ListItem {
46 id: SharedString,
47 text: Option<SharedString>,
48 disabled: bool,
49 content: AnyElement,
50}
51
52impl std::fmt::Debug for ListItem {
53 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 formatter
55 .debug_struct("ListItem")
56 .field("id", &self.id)
57 .field("disabled", &self.disabled)
58 .finish()
59 }
60}
61
62impl ListItem {
63 pub fn new(id: impl Into<SharedString>, content: impl IntoElement) -> Self {
65 Self {
66 id: id.into(),
67 text: None,
68 disabled: false,
69 content: content.into_any_element(),
70 }
71 }
72
73 pub fn text(mut self, text: impl Into<SharedString>) -> Self {
76 self.text = Some(text.into());
77 self
78 }
79
80 pub fn disabled(mut self, disabled: bool) -> Self {
81 self.disabled = disabled;
82 self
83 }
84}
85
86#[derive(IntoElement)]
88pub struct List {
89 ident: Ident,
90 count: usize,
91 render_row: RenderRow,
92 selected: Option<SharedString>,
93 row_height: Option<f32>,
94 visible_rows: Option<usize>,
95 size: ControlSize,
96 disabled: bool,
97 on_select: Option<SelectHandler>,
98 reorderable: bool,
99 accepts: Option<Accepts>,
100 on_reorder: Option<ReorderHandler>,
101}
102
103impl std::fmt::Debug for List {
104 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 formatter
106 .debug_struct("List")
107 .field("ident", &self.ident)
108 .field("count", &self.count)
109 .field("selected", &self.selected)
110 .field("visible_rows", &self.visible_rows)
111 .field("disabled", &self.disabled)
112 .field("has_handler", &self.on_select.is_some())
113 .finish()
114 }
115}
116
117impl List {
118 pub fn new(
119 ident: impl Into<Ident>,
120 count: usize,
121 render_row: impl Fn(usize, &mut Window, &mut App) -> ListItem + 'static,
122 ) -> Self {
123 Self {
124 ident: ident.into(),
125 count,
126 render_row: Rc::new(render_row),
127 selected: None,
128 row_height: None,
129 visible_rows: None,
130 size: ControlSize::Md,
131 disabled: false,
132 on_select: None,
133 reorderable: false,
134 accepts: None,
135 on_reorder: None,
136 }
137 }
138
139 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
140 self.selected = Some(id.into());
141 self
142 }
143
144 pub fn row_height(mut self, height: f32) -> Self {
147 self.row_height = Some(height);
148 self
149 }
150
151 pub fn visible_rows(mut self, rows: usize) -> Self {
154 self.visible_rows = Some(rows);
155 self
156 }
157
158 pub fn on_select(
159 mut self,
160 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
161 ) -> Self {
162 self.on_select = Some(Rc::new(handler));
163 self
164 }
165
166 pub fn reorderable(mut self, reorderable: bool) -> Self {
173 self.reorderable = reorderable;
174 self
175 }
176
177 pub fn accepts(
182 mut self,
183 predicate: impl Fn(&DragItem, &DropPosition) -> bool + 'static,
184 ) -> Self {
185 self.accepts = Some(Rc::new(predicate));
186 self
187 }
188
189 pub fn on_reorder(
191 mut self,
192 handler: impl Fn(&DropIntent, &mut Window, &mut App) + 'static,
193 ) -> Self {
194 self.on_reorder = Some(Rc::new(handler));
195 self
196 }
197}
198
199impl Disableable for List {
200 fn disabled(mut self, disabled: bool) -> Self {
201 self.disabled = disabled;
202 self
203 }
204}
205
206impl Sizable for List {
207 fn control_size(mut self, size: ControlSize) -> Self {
208 self.size = size;
209 self
210 }
211}
212
213impl RenderOnce for List {
214 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
215 let theme = cx.theme().clone();
216 let metrics = theme.control.get(self.size);
217 let row_height = self.row_height.unwrap_or(metrics.height);
218 let ident = self.ident.clone();
219 let count = self.count;
220 let handler = self
221 .on_select
222 .clone()
223 .filter(|_| !self.disabled)
224 .filter(|_| count > 0);
225 let render_row = Rc::clone(&self.render_row);
226 let scroll = scroll_handle(&ident, cx);
227 let reorder = self.reorder(window, cx);
228
229 let rendered: Rendered = Rc::new(RefCell::new(HashMap::new()));
233
234 let rows = {
235 let ident = ident.clone();
236 let theme = theme.clone();
237 let selected = self.selected.clone();
238 let handler = handler.clone();
239 let render_row = Rc::clone(&render_row);
240 let rendered = Rc::clone(&rendered);
241 let reorder = reorder.clone();
242 uniform_list(
243 ident.child("rows").element_id(),
244 count,
245 move |range: Range<usize>, window, cx| {
246 rendered
247 .borrow_mut()
248 .retain(|index, _| range.contains(index));
249 range
250 .map(|index| {
251 let item = render_row(index, window, cx);
252 rendered.borrow_mut().insert(index, item.id.clone());
253 row_element(
254 &ident,
255 &theme,
256 row_height,
257 item,
258 index,
259 selected.as_ref(),
260 handler.as_ref(),
261 reorder.as_ref(),
262 window,
263 cx,
264 )
265 })
266 .collect::<Vec<_>>()
267 },
268 )
269 }
270 .track_scroll(&scroll)
271 .w_full()
272 .with_sizing_behavior(if self.visible_rows.is_some() {
273 ListSizingBehavior::Auto
274 } else {
275 ListSizingBehavior::Infer
276 })
277 .when_some(self.visible_rows, |element, rows| {
278 element.h(px(row_height * rows as f32))
279 });
280
281 let mut container = div().id(ident.element_id()).column().w_full().child(rows);
282
283 if reorder.is_some() {
287 let rendered = Rc::clone(&rendered);
288 let scroll = scroll.clone();
289 container = container.on_drag_move::<DragItem>(move |event, window, _| {
290 let pointer = event.event.position;
291 if !event.bounds.contains(&pointer) {
292 return;
293 }
294 let band = px(row_height);
295 let rendered = rendered.borrow();
296 let next = if pointer.y < event.bounds.top() + band {
297 rendered.keys().min().and_then(|first| first.checked_sub(1))
298 } else if pointer.y > event.bounds.bottom() - band {
299 rendered
300 .keys()
301 .max()
302 .map(|last| last + 1)
303 .filter(|next| *next < count)
304 } else {
305 None
306 };
307 let Some(next) = next else {
308 return;
309 };
310 scroll.scroll_to_item(next, ScrollStrategy::Nearest);
311 window.refresh();
312 });
313 }
314
315 if let Some(handler) = handler {
316 let selected = self.selected.clone();
317 let rendered = Rc::clone(&rendered);
318 container = container.on_key_down(move |event, window, cx| {
319 let from = current_index(&rendered, selected.as_ref());
320 let Some(target) = target_index(event.keystroke.key.as_str(), from, count) else {
321 return;
322 };
323 let delta = if from.is_some_and(|from| target < from) {
324 -1
325 } else {
326 1
327 };
328 let Some((index, id)) = selectable(&render_row, target, delta, count, window, cx)
329 else {
330 return;
331 };
332 scroll.scroll_to_item(index, ScrollStrategy::Nearest);
337 window.refresh();
338 if Some(&id) == selected.as_ref() {
339 return;
340 }
341 handler(id, window, cx);
342 cx.stop_propagation();
343 });
344 }
345
346 container.semantic_in(
347 cx,
348 NodeSpec::new(ident.semantic_id(), Role::List).value(count.to_string()),
349 )
350 }
351}
352
353type Rendered = Rc<RefCell<HashMap<usize, SharedString>>>;
354
355#[derive(Clone)]
357struct Reorder {
358 surface: SharedString,
359 drag: Option<SurfaceDrag>,
360 accepts: Accepts,
361 on_drop: ReorderHandler,
362}
363
364impl List {
365 fn reorder(&self, window: &mut Window, cx: &mut App) -> Option<Reorder> {
366 if self.disabled || !self.reorderable {
367 return None;
368 }
369 let on_drop = self.on_reorder.clone()?;
370 let surface = self.ident.semantic_id();
371 let accepts = self.accepts.clone().unwrap_or_else(|| {
372 let own = surface.clone();
373 Rc::new(move |item: &DragItem, _: &DropPosition| item.source == own)
374 });
375 Some(Reorder {
376 drag: dnd::surface_drag(&surface, window, cx),
377 surface,
378 accepts,
379 on_drop,
380 })
381 }
382}
383
384#[allow(clippy::too_many_arguments)]
385fn row_element(
386 list: &Ident,
387 theme: &Theme,
388 height: f32,
389 item: ListItem,
390 index: usize,
391 selected: Option<&SharedString>,
392 handler: Option<&SelectHandler>,
393 reorder: Option<&Reorder>,
394 window: &mut Window,
395 cx: &mut App,
396) -> AnyElement {
397 let ident = list.child(item.id.as_ref());
398 let selected = selected == Some(&item.id);
399 let actionable = !item.disabled && handler.is_some();
400 let draggable = reorder.filter(|_| !item.disabled);
401 let drag = draggable.and_then(|reorder| reorder.drag.as_ref());
402 let carried = drag.is_some_and(|drag| drag.carries(&item.id));
403 let landing = drag.and_then(|drag| drag.indicator_for(&item.id));
404 let label = item.text.clone().unwrap_or_else(|| item.id.clone());
405
406 let mut row = div()
407 .id(ident.element_id())
408 .row()
409 .w_full()
410 .h(px(height))
411 .px(px(theme.space(Space::Sm)))
412 .gap(px(theme.space(Space::Sm)))
413 .when(selected, |element| element.bg(theme.colors.selected))
414 .when(item.disabled, |element| {
415 element.opacity(theme.opacity.disabled)
416 })
417 .when(carried, |element| element.opacity(theme.opacity.muted))
420 .when(actionable, |element| {
421 element
422 .cursor_pointer()
423 .tab_index(0)
424 .pressable(cx)
425 .when(!selected, |element| {
426 element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
427 })
428 .focus_ring(theme)
429 })
430 .child(div().flex_1().overflow_hidden().child(item.content))
431 .children(landing.map(|(position, accepted)| {
432 dnd::indicator(&position, accepted, DropAxis::Vertical, cx)
433 }));
434
435 if let (true, Some(handler)) = (actionable, handler) {
436 let id = item.id.clone();
437 let handler = Rc::clone(handler);
438 row = row.on_click(move |_, window, cx| handler(id.clone(), window, cx));
439 }
440
441 if let Some(reorder) = draggable {
442 row = dnd::draggable(
443 row,
444 DragItem::new(reorder.surface.clone(), item.id.clone(), label),
445 );
446 row = dnd::drop_target(
447 row,
448 RowTarget {
449 surface: reorder.surface.clone(),
450 id: item.id.clone(),
451 index,
452 allow_into: false,
453 axis: DropAxis::Vertical,
454 accepts: Rc::clone(&reorder.accepts),
455 on_drop: Rc::clone(&reorder.on_drop),
456 },
457 );
458 }
459
460 let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row)
461 .parent(list.semantic_id())
462 .selected(selected)
463 .disabled(item.disabled);
464 if let Some(text) = item.text {
465 spec = spec.text(text);
466 }
467 let row = row.semantic_in(cx, spec);
468
469 match draggable {
470 Some(reorder) => {
471 let shift = reorder
472 .drag
473 .as_ref()
474 .filter(|drag| drag.makes_way(index))
475 .map_or(px(0.0), |_| dnd::make_way_gap(cx, DropAxis::Vertical));
476 row.make_way(ident.semantic_id(), point(px(0.0), shift), window, cx)
477 .into_any_element()
478 }
479 None => row.into_any_element(),
480 }
481}
482
483fn current_index(rendered: &Rendered, selected: Option<&SharedString>) -> Option<usize> {
488 let rendered = rendered.borrow();
489 selected
490 .and_then(|id| {
491 rendered
492 .iter()
493 .find(|(_, row)| *row == id)
494 .map(|(index, _)| *index)
495 })
496 .or_else(|| rendered.keys().min().copied())
497}
498
499fn target_index(key: &str, from: Option<usize>, count: usize) -> Option<usize> {
500 match key {
501 "up" => from?.checked_sub(1),
502 "down" => match from {
503 Some(from) => Some(from + 1).filter(|next| *next < count),
504 None => Some(0),
505 },
506 "home" => Some(0),
507 "end" => count.checked_sub(1),
508 _ => None,
509 }
510}
511
512fn selectable(
518 render_row: &RenderRow,
519 target: usize,
520 delta: isize,
521 count: usize,
522 window: &mut Window,
523 cx: &mut App,
524) -> Option<(usize, SharedString)> {
525 let mut index = target as isize;
526 while index >= 0 && (index as usize) < count {
527 let item = render_row(index as usize, window, cx);
528 if !item.disabled {
529 return Some((index as usize, item.id));
530 }
531 index += delta;
532 }
533 None
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn a_move_stops_at_the_ends_instead_of_wrapping() {
542 assert_eq!(target_index("up", Some(0), 10), None);
543 assert_eq!(target_index("down", Some(9), 10), None);
544 assert_eq!(target_index("down", Some(3), 10), Some(4));
545 assert_eq!(target_index("home", Some(3), 10), Some(0));
546 assert_eq!(target_index("end", Some(3), 10), Some(9));
547 }
548
549 #[test]
550 fn a_move_without_a_selection_enters_at_the_top() {
551 assert_eq!(target_index("down", None, 10), Some(0));
552 assert_eq!(target_index("end", None, 0), None);
553 }
554}