1use 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#[derive(Clone, Debug)]
29pub struct SortableItem {
30 pub id: String,
33 pub title: String,
35 pub subtitle: Option<String>,
37 pub icon: Option<String>,
39 pub status: Option<SortableStatus>,
41}
42
43#[derive(Clone, Debug)]
45pub struct SortableStatus {
46 pub label: String,
48 pub tone: BadgeTone,
50}
51
52impl SortableItem {
53 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 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
66 self.subtitle = Some(subtitle.into());
67 self
68 }
69
70 pub fn icon(mut self, icon: impl Into<String>) -> Self {
76 self.icon = Some(icon.into());
77 self
78 }
79
80 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#[derive(Clone, Debug)]
92struct DragState {
93 origin_idx: usize,
95 target_idx: usize,
98 grab_offset: Vec2,
101 row_size: Vec2,
104}
105
106#[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 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 pub fn show(self, ui: &mut Ui) -> Response {
172 let SortableList { id_salt, items } = self;
173 let theme = Theme::current(ui.ctx());
174
175 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 if drag.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
185 drag = None;
186 }
187
188 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 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 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 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 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 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 if drag.is_none() {
276 drag = new_drag;
277 }
278
279 if let Some(rect) = slot_rect {
281 if ui.is_rect_visible(rect) {
282 paint_slot(ui, rect, &theme);
283 }
284 }
285
286 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 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 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}