Skip to main content

elegance/
sortable_list.rs

1//! Drag-and-drop sortable list — reorder rows by dragging their grip.
2//!
3//! See [`SortableList`] for the full interaction model and an example.
4
5use std::hash::Hash;
6
7use egui::{
8    Align2, Color32, CornerRadius, FontId, Id, LayerId, Order, Pos2, Rect, Response, Sense, Stroke,
9    StrokeKind, Ui, Vec2, WidgetInfo, WidgetType,
10};
11
12use crate::badge::BadgeTone;
13use crate::theme::{with_alpha, Palette, Theme};
14
15const ROW_HEIGHT: f32 = 50.0;
16const ROW_GAP: f32 = 6.0;
17const ROW_PAD_X: f32 = 12.0;
18const GRIP_W: f32 = 18.0;
19const ICON_BOX: f32 = 28.0;
20const COLUMN_GAP: f32 = 12.0;
21const PILL_PAD_X: f32 = 9.0;
22const PILL_PAD_Y: f32 = 3.0;
23const PILL_DOT: f32 = 6.0;
24const PILL_GAP: f32 = 6.0;
25const PILL_TEXT: f32 = 11.5;
26
27/// One row in a [`SortableList`].
28#[derive(Clone, Debug)]
29pub struct SortableItem {
30    /// Stable identifier — used as the row id for hit testing and as the
31    /// caller-facing way to look up which item moved.
32    pub id: String,
33    /// Primary label.
34    pub title: String,
35    /// Optional secondary line below the title.
36    pub subtitle: Option<String>,
37    /// Optional leading glyph rendered in a small rounded box.
38    pub icon: Option<String>,
39    /// Optional trailing status pill.
40    pub status: Option<SortableStatus>,
41}
42
43/// A trailing status pill on a [`SortableItem`].
44#[derive(Clone, Debug)]
45pub struct SortableStatus {
46    /// Pill text — kept lower-case to match the design language.
47    pub label: String,
48    /// Tone used for the pill's dot, border and text colours.
49    pub tone: BadgeTone,
50}
51
52impl SortableItem {
53    /// Create an item with a stable id and title.
54    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
55        Self {
56            id: id.into(),
57            title: title.into(),
58            subtitle: None,
59            icon: None,
60            status: None,
61        }
62    }
63
64    /// Set the secondary line below the title.
65    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
66        self.subtitle = Some(subtitle.into());
67        self
68    }
69
70    /// Set the leading icon glyph.
71    ///
72    /// Rendered with the default proportional font; the glyph must be
73    /// available in a registered font (the bundled `Elegance Symbols`
74    /// fallback covers arrows, modifier keys, and a small set of UI icons).
75    pub fn icon(mut self, icon: impl Into<String>) -> Self {
76        self.icon = Some(icon.into());
77        self
78    }
79
80    /// Set the trailing status pill.
81    pub fn status(mut self, label: impl Into<String>, tone: BadgeTone) -> Self {
82        self.status = Some(SortableStatus {
83            label: label.into(),
84            tone,
85        });
86        self
87    }
88}
89
90/// Cross-frame drag state stored in [`egui::Context`] memory.
91#[derive(Clone, Debug)]
92struct DragState {
93    /// Index of the item the drag started on.
94    origin_idx: usize,
95    /// Current insertion index — the slot opens just before items[target_idx]
96    /// (or at the trailing position when `target_idx == items.len()`).
97    target_idx: usize,
98    /// Pointer offset within the source row at drag start, used to keep the
99    /// ghost row anchored under the cursor.
100    grab_offset: Vec2,
101    /// Width and height of the source row at drag start, used to size the
102    /// ghost and the drop slot.
103    row_size: Vec2,
104}
105
106/// A vertical list of rows that can be reordered by dragging their grips.
107///
108/// # Interaction
109///
110/// - **Press on a row's grip** to start dragging. The source row collapses
111///   out of layout and a ghost copy of the row floats under the cursor.
112/// - **Move** the cursor — a sky-tinted slot opens at the predicted drop
113///   position, and the surrounding rows shift to reveal it.
114/// - **Release** to commit the move. If the slot lands at the source's own
115///   position the order is left untouched.
116/// - **Escape** cancels the drag without reordering.
117///
118/// # State
119///
120/// Items are stored in a caller-owned `Vec<SortableItem>` passed by mutable
121/// reference; the widget reorders the vec in place on a successful drop.
122/// Transient drag state (origin, target, grab offset) lives in egui memory
123/// keyed by the widget's `id_salt`.
124///
125/// # Example
126///
127/// ```no_run
128/// # use elegance::{SortableList, SortableItem, BadgeTone};
129/// # egui::__run_test_ui(|ui| {
130/// let mut items = vec![
131///     SortableItem::new("api", "api-east-01")
132///         .subtitle("10.0.1.5 · us-east-1")
133///         .status("live", BadgeTone::Ok),
134///     SortableItem::new("worker", "worker-pool-a")
135///         .subtitle("24 instances · autoscale")
136///         .status("idle", BadgeTone::Neutral),
137/// ];
138/// SortableList::new("deployment-targets", &mut items).show(ui);
139/// # });
140/// ```
141#[must_use = "Call `.show(ui)` to render the sortable list."]
142pub struct SortableList<'a> {
143    id_salt: Id,
144    items: &'a mut Vec<SortableItem>,
145}
146
147impl<'a> std::fmt::Debug for SortableList<'a> {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("SortableList")
150            .field("id_salt", &self.id_salt)
151            .field("items", &self.items.len())
152            .finish()
153    }
154}
155
156impl<'a> SortableList<'a> {
157    /// Create a sortable list.
158    ///
159    /// * `id_salt` — stable salt for this widget's transient drag state.
160    ///   Different `SortableList` widgets in the same window must use
161    ///   distinct salts.
162    /// * `items` — caller-owned list of rows. Reordered in place on drop.
163    pub fn new(id_salt: impl Hash, items: &'a mut Vec<SortableItem>) -> Self {
164        Self {
165            id_salt: Id::new(("elegance_sortable_list", id_salt)),
166            items,
167        }
168    }
169
170    /// Render the list and handle drag interaction.
171    pub fn show(self, ui: &mut Ui) -> Response {
172        let SortableList { id_salt, items } = self;
173        let theme = Theme::current(ui.ctx());
174
175        // Load and validate cross-frame drag state.
176        let mut drag: Option<DragState> = ui.ctx().data(|d| d.get_temp(id_salt));
177        if let Some(s) = &drag {
178            if s.origin_idx >= items.len() {
179                drag = None;
180            }
181        }
182
183        // Cancel on Escape.
184        if drag.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
185            drag = None;
186        }
187
188        // Drop on pointer release.
189        let pointer_down = ui.input(|i| i.pointer.primary_down());
190        let commit_drop = drag.is_some() && !pointer_down;
191
192        let n = items.len();
193
194        // Build the visual sequence: the source row is hidden during a drag,
195        // and a slot is inserted at the current target index.
196        let drag_origin = drag.as_ref().map(|d| d.origin_idx);
197        let drag_target = drag.as_ref().map(|d| d.target_idx);
198        let mut sequence: Vec<DisplayKind> = Vec::with_capacity(n + 1);
199        for i in 0..n {
200            if drag_target == Some(i) {
201                sequence.push(DisplayKind::Slot);
202            }
203            if drag_origin == Some(i) {
204                continue;
205            }
206            sequence.push(DisplayKind::Row(i));
207        }
208        if drag_target == Some(n) {
209            sequence.push(DisplayKind::Slot);
210        }
211
212        // Allocate the list rect.
213        let total_w = ui.available_width();
214        let total_h = if sequence.is_empty() {
215            0.0
216        } else {
217            sequence.len() as f32 * ROW_HEIGHT + (sequence.len() - 1) as f32 * ROW_GAP
218        };
219        let (list_rect, response) =
220            ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
221
222        // Compute per-element rects.
223        let mut row_rects: Vec<(usize, Rect)> = Vec::with_capacity(n);
224        let mut slot_rect: Option<Rect> = None;
225        let mut y = list_rect.top();
226        for kind in &sequence {
227            let r = Rect::from_min_size(
228                Pos2::new(list_rect.left(), y),
229                Vec2::new(total_w, ROW_HEIGHT),
230            );
231            match kind {
232                DisplayKind::Slot => slot_rect = Some(r),
233                DisplayKind::Row(i) => row_rects.push((*i, r)),
234            }
235            y += ROW_HEIGHT + ROW_GAP;
236        }
237
238        // Render rows and detect drag start.
239        let mut new_drag: Option<DragState> = None;
240        for (i, rect) in &row_rects {
241            let item = &items[*i];
242            let row_id = id_salt.with(("row", &item.id));
243
244            // Whole-row hover for the border tint, plus a smaller grip rect
245            // that actually starts the drag — clicking the title or pill
246            // shouldn't grab the row.
247            let row_resp = ui.interact(*rect, row_id, Sense::hover());
248            let grip_rect = grip_rect(*rect);
249            let grip_resp = ui.interact(grip_rect, row_id.with("grip"), Sense::click_and_drag());
250
251            if drag.is_none() && grip_resp.drag_started() {
252                let pointer = ui
253                    .input(|inp| inp.pointer.interact_pos())
254                    .unwrap_or(rect.left_top());
255                new_drag = Some(DragState {
256                    origin_idx: *i,
257                    target_idx: *i,
258                    grab_offset: pointer - rect.left_top(),
259                    row_size: rect.size(),
260                });
261            }
262
263            if grip_resp.hovered() && drag.is_none() {
264                ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
265            }
266
267            if ui.is_rect_visible(*rect) {
268                paint_row(ui, *rect, item, &theme, row_resp.hovered(), false);
269            }
270
271            row_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, &item.title));
272        }
273
274        // Apply drag started this frame so the slot/ghost render immediately.
275        if drag.is_none() {
276            drag = new_drag;
277        }
278
279        // Render the drop slot.
280        if let Some(rect) = slot_rect {
281            if ui.is_rect_visible(rect) {
282                paint_slot(ui, rect, &theme);
283            }
284        }
285
286        // Update target index and render the ghost row.
287        if let Some(s) = drag.as_mut() {
288            let pointer_pos = ui.input(|inp| inp.pointer.interact_pos());
289
290            if let Some(p) = pointer_pos {
291                let mut new_target = n;
292                for (i, rect) in &row_rects {
293                    if p.y < rect.center().y {
294                        new_target = *i;
295                        break;
296                    }
297                }
298                s.target_idx = new_target;
299
300                let ghost_rect = Rect::from_min_size(p - s.grab_offset, s.row_size);
301                paint_ghost(ui, ghost_rect, &items[s.origin_idx], &theme, id_salt);
302            }
303
304            ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
305            ui.ctx().request_repaint();
306        }
307
308        // Commit a drop or clear cancelled state.
309        if commit_drop {
310            if let Some(s) = drag.take() {
311                let mut final_idx = s.target_idx.min(n);
312                if final_idx > s.origin_idx {
313                    final_idx -= 1;
314                }
315                if final_idx != s.origin_idx && final_idx < items.len() {
316                    let moved = items.remove(s.origin_idx);
317                    items.insert(final_idx, moved);
318                }
319            }
320        }
321
322        // Persist drag state (or remove on cancel/drop).
323        ui.ctx().data_mut(|d| match drag {
324            Some(s) => {
325                d.insert_temp(id_salt, s);
326            }
327            None => {
328                d.remove::<DragState>(id_salt);
329            }
330        });
331
332        response.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, "sortable list"));
333        response
334    }
335}
336
337#[derive(Clone, Copy)]
338enum DisplayKind {
339    Slot,
340    Row(usize),
341}
342
343fn grip_rect(row: Rect) -> Rect {
344    Rect::from_min_size(
345        Pos2::new(row.left() + ROW_PAD_X, row.top()),
346        Vec2::new(GRIP_W, row.height()),
347    )
348}
349
350fn paint_row(ui: &Ui, rect: Rect, item: &SortableItem, theme: &Theme, hovered: bool, ghost: bool) {
351    let p = &theme.palette;
352    let t = &theme.typography;
353    let painter = ui.painter();
354    let radius = CornerRadius::same(theme.control_radius as u8);
355
356    let (fill, border, grip_color) = if ghost {
357        (p.card, with_alpha(p.sky, 115), p.sky)
358    } else if hovered {
359        (p.input_bg, p.text_muted, p.text_muted)
360    } else {
361        (p.input_bg, p.border, p.text_faint)
362    };
363    painter.rect(
364        rect,
365        radius,
366        fill,
367        Stroke::new(1.0, border),
368        StrokeKind::Inside,
369    );
370
371    let mid_y = rect.center().y;
372    let mut x = rect.left() + ROW_PAD_X;
373
374    paint_grip(painter, Pos2::new(x + GRIP_W * 0.5, mid_y), grip_color);
375    x += GRIP_W + COLUMN_GAP;
376
377    if let Some(icon) = &item.icon {
378        let icon_rect =
379            Rect::from_center_size(Pos2::new(x + ICON_BOX * 0.5, mid_y), Vec2::splat(ICON_BOX));
380        painter.rect(
381            icon_rect,
382            radius,
383            p.card,
384            Stroke::new(1.0, p.border),
385            StrokeKind::Inside,
386        );
387        painter.text(
388            icon_rect.center(),
389            Align2::CENTER_CENTER,
390            icon,
391            FontId::proportional(13.0),
392            p.text_muted,
393        );
394        x += ICON_BOX + COLUMN_GAP;
395    }
396
397    let pill_size = item
398        .status
399        .as_ref()
400        .map(|s| measure_pill(ui, &s.label))
401        .unwrap_or(Vec2::ZERO);
402    let pill_x = rect.right() - ROW_PAD_X - pill_size.x;
403
404    if let Some(sub) = &item.subtitle {
405        painter.text(
406            Pos2::new(x, rect.top() + 9.0),
407            Align2::LEFT_TOP,
408            &item.title,
409            FontId::proportional(t.body),
410            p.text,
411        );
412        painter.text(
413            Pos2::new(x, rect.top() + 9.0 + t.body + 2.0),
414            Align2::LEFT_TOP,
415            sub,
416            FontId::proportional(t.small),
417            p.text_muted,
418        );
419    } else {
420        painter.text(
421            Pos2::new(x, mid_y),
422            Align2::LEFT_CENTER,
423            &item.title,
424            FontId::proportional(t.body),
425            p.text,
426        );
427    }
428
429    if let Some(s) = &item.status {
430        let pill_rect =
431            Rect::from_min_size(Pos2::new(pill_x, mid_y - pill_size.y * 0.5), pill_size);
432        paint_pill(ui, pill_rect, &s.label, s.tone, p);
433    }
434}
435
436fn paint_grip(painter: &egui::Painter, center: Pos2, color: Color32) {
437    for col in 0..2 {
438        for row in 0..3 {
439            let cx = center.x - 2.0 + col as f32 * 4.0;
440            let cy = center.y - 5.0 + row as f32 * 5.0;
441            painter.circle_filled(Pos2::new(cx, cy), 1.3, color);
442        }
443    }
444}
445
446fn measure_pill(ui: &Ui, label: &str) -> Vec2 {
447    let galley = ui.painter().layout_no_wrap(
448        label.to_string(),
449        FontId::proportional(PILL_TEXT),
450        Color32::WHITE,
451    );
452    Vec2::new(
453        PILL_PAD_X * 2.0 + PILL_DOT + PILL_GAP + galley.size().x,
454        (galley.size().y + PILL_PAD_Y * 2.0).max(PILL_DOT + PILL_PAD_Y * 2.0),
455    )
456}
457
458fn paint_pill(ui: &Ui, rect: Rect, label: &str, tone: BadgeTone, palette: &Palette) {
459    let painter = ui.painter();
460    let (bg, border, fg) = pill_colours(tone, palette);
461    painter.rect(
462        rect,
463        CornerRadius::same(99),
464        bg,
465        Stroke::new(1.0, border),
466        StrokeKind::Inside,
467    );
468    let dot_x = rect.left() + PILL_PAD_X + PILL_DOT * 0.5;
469    painter.circle_filled(Pos2::new(dot_x, rect.center().y), PILL_DOT * 0.5, fg);
470
471    let text_x = rect.left() + PILL_PAD_X + PILL_DOT + PILL_GAP;
472    let galley = painter.layout_no_wrap(label.to_string(), FontId::proportional(PILL_TEXT), fg);
473    let text_y = rect.center().y - galley.size().y * 0.5;
474    painter.galley(Pos2::new(text_x, text_y), galley, fg);
475}
476
477fn pill_colours(tone: BadgeTone, p: &Palette) -> (Color32, Color32, Color32) {
478    match tone {
479        BadgeTone::Ok => (with_alpha(p.green, 26), with_alpha(p.green, 64), p.success),
480        BadgeTone::Warning => (with_alpha(p.amber, 26), with_alpha(p.amber, 64), p.warning),
481        BadgeTone::Danger => (with_alpha(p.red, 26), with_alpha(p.red, 64), p.danger),
482        BadgeTone::Info => (with_alpha(p.sky, 26), with_alpha(p.sky, 64), p.sky),
483        BadgeTone::Neutral => (
484            with_alpha(p.text_muted, 20),
485            with_alpha(p.text_muted, 51),
486            p.text_muted,
487        ),
488    }
489}
490
491fn paint_slot(ui: &Ui, rect: Rect, theme: &Theme) {
492    let painter = ui.painter();
493    let p = &theme.palette;
494    let radius = CornerRadius::same(theme.control_radius as u8);
495    painter.rect(
496        rect,
497        radius,
498        with_alpha(p.sky, 13),
499        Stroke::NONE,
500        StrokeKind::Inside,
501    );
502    let pts = [
503        rect.left_top(),
504        rect.right_top(),
505        rect.right_bottom(),
506        rect.left_bottom(),
507        rect.left_top(),
508    ];
509    let stroke = Stroke::new(1.0, with_alpha(p.sky, 102));
510    painter.extend(egui::Shape::dashed_line(&pts, stroke, 6.0, 4.0));
511}
512
513fn paint_ghost(ui: &Ui, rect: Rect, item: &SortableItem, theme: &Theme, id_salt: Id) {
514    let layer = LayerId::new(Order::Tooltip, id_salt.with("ghost"));
515    let painter = ui.ctx().layer_painter(layer);
516    let p = &theme.palette;
517    let t = &theme.typography;
518    let radius = CornerRadius::same(theme.control_radius as u8);
519
520    let shadow = egui::epaint::Shadow {
521        offset: [0, 14],
522        blur: 28,
523        spread: 0,
524        color: Color32::from_black_alpha(140),
525    };
526    painter.add(shadow.as_shape(rect, radius));
527    painter.rect(
528        rect,
529        radius,
530        p.card,
531        Stroke::new(1.0, with_alpha(p.sky, 115)),
532        StrokeKind::Inside,
533    );
534
535    let mid_y = rect.center().y;
536    let mut x = rect.left() + ROW_PAD_X;
537    paint_grip(&painter, Pos2::new(x + GRIP_W * 0.5, mid_y), p.sky);
538    x += GRIP_W + COLUMN_GAP;
539
540    if let Some(icon) = &item.icon {
541        let icon_rect =
542            Rect::from_center_size(Pos2::new(x + ICON_BOX * 0.5, mid_y), Vec2::splat(ICON_BOX));
543        painter.rect(
544            icon_rect,
545            radius,
546            p.card,
547            Stroke::new(1.0, p.border),
548            StrokeKind::Inside,
549        );
550        painter.text(
551            icon_rect.center(),
552            Align2::CENTER_CENTER,
553            icon,
554            FontId::proportional(13.0),
555            p.text_muted,
556        );
557        x += ICON_BOX + COLUMN_GAP;
558    }
559
560    let pill_size = item
561        .status
562        .as_ref()
563        .map(|s| measure_pill(ui, &s.label))
564        .unwrap_or(Vec2::ZERO);
565    let pill_x = rect.right() - ROW_PAD_X - pill_size.x;
566
567    if let Some(sub) = &item.subtitle {
568        painter.text(
569            Pos2::new(x, rect.top() + 9.0),
570            Align2::LEFT_TOP,
571            &item.title,
572            FontId::proportional(t.body),
573            p.text,
574        );
575        painter.text(
576            Pos2::new(x, rect.top() + 9.0 + t.body + 2.0),
577            Align2::LEFT_TOP,
578            sub,
579            FontId::proportional(t.small),
580            p.text_muted,
581        );
582    } else {
583        painter.text(
584            Pos2::new(x, mid_y),
585            Align2::LEFT_CENTER,
586            &item.title,
587            FontId::proportional(t.body),
588            p.text,
589        );
590    }
591
592    if let Some(s) = &item.status {
593        let pill_rect =
594            Rect::from_min_size(Pos2::new(pill_x, mid_y - pill_size.y * 0.5), pill_size);
595        paint_ghost_pill(&painter, pill_rect, &s.label, s.tone, p);
596    }
597}
598
599fn paint_ghost_pill(
600    painter: &egui::Painter,
601    rect: Rect,
602    label: &str,
603    tone: BadgeTone,
604    palette: &Palette,
605) {
606    let (bg, border, fg) = pill_colours(tone, palette);
607    painter.rect(
608        rect,
609        CornerRadius::same(99),
610        bg,
611        Stroke::new(1.0, border),
612        StrokeKind::Inside,
613    );
614    let dot_x = rect.left() + PILL_PAD_X + PILL_DOT * 0.5;
615    painter.circle_filled(Pos2::new(dot_x, rect.center().y), PILL_DOT * 0.5, fg);
616    let text_x = rect.left() + PILL_PAD_X + PILL_DOT + PILL_GAP;
617    let galley = painter.layout_no_wrap(label.to_string(), FontId::proportional(PILL_TEXT), fg);
618    let text_y = rect.center().y - galley.size().y * 0.5;
619    painter.galley(Pos2::new(text_x, text_y), galley, fg);
620}