From ddbed09f86124f8d1300d76d22b5d5f04cb92d37 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 21:59:04 -0400 Subject: [PATCH] Front-most first with groups folding; drag a row to restack among its siblings (accent insertion line); Bring Forward / Send Backward arrows; Show All / Hide All; per-row eye and padlock; double-click or F2 renames in place; Ctrl+click toggles, Shift+click selects a run; arrows, Home, End and Esc in the list; right-click menu. Lock/Unlock on the canvas menu, greyed handles on locked objects. egui_kittest tests drive the pane. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 12 + crates/ui-egui/src/icons.rs | 24 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panes.rs | 68 +---- crates/ui-egui/src/selpane.rs | 412 ++++++++++++++++++++++++++++ crates/ui-egui/src/selpane_tests.rs | 255 +++++++++++++++++ docs/ui-parity.md | 4 +- 8 files changed, 706 insertions(+), 75 deletions(-) create mode 100644 crates/ui-egui/src/selpane.rs create mode 100644 crates/ui-egui/src/selpane_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 129157f..0f1e02f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | + +| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 644e2f6..bf138c3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -461,6 +461,13 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } else { poly(painter, corners, sel_stroke); } + if sh.locked && !editing_this { + // Locked: greyed handles that don't respond, no rotate or adjust handles. + for h in box_handles(&x).0 { + painter.circle(xf.to_screen(h), 4.0, t.text_faint, Stroke::new(1.0, t.handle_fill)); + } + continue; + } if st.selection.shapes.len() == 1 || !editing_this { let (hs, rot) = box_handles(&x); let rot_s = xf.to_screen(rot); @@ -640,6 +647,11 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); }); + let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + let _ = app.run("shape.lock", json!({"locked": !locked})); + ui.close(); + } if ui.button("Link…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ff9897c..aa3f2b5 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -284,6 +284,8 @@ icons! { Info => "info", Warning => "warning", Lock => "lock", + /// Open padlock (Selection pane: an object that isn't locked). + Unlock => "unlock", Eye => "eye", EyeOff => "eye-off", Close => "close", @@ -2262,22 +2264,30 @@ fn draw(p: &Pen<'_>, icon: Icon) { p.seg((12.0, 9.0), (12.0, 14.0), D); p.dot((12.0, 17.2), 1.2, D); } + // Selection pane toggles: monochrome outlines in the ink colour. I::Lock => { let mut sh = vec![(7.5, 10.5), (7.5, 8.0)]; sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); sh.push((16.5, 10.5)); p.line(&sh, K); - p.rect(4.5, 10.5, 19.5, 21.5, 1.8, Y, K); - p.dot((12.0, 15.0), 1.5, D); - p.seg((12.0, 15.5), (12.0, 18.3), D); + p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); + p.seg((12.0, 14.5), (12.0, 17.5), K); + } + I::Unlock => { + // The shackle swung open: its left leg out of the body, raised. + let mut sh = vec![(12.0, 10.5), (12.0, 6.0)]; + sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); + sh.push((21.0, 8.5)); + p.line(&sh, K); + p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); + p.seg((9.75, 14.5), (9.75, 17.5), K); } I::Eye | I::EyeOff => { - let mut e = quad_pts((2.0, 12.0), (12.0, 2.5), (22.0, 12.0), 16); - e.extend(quad_pts((22.0, 12.0), (12.0, 21.5), (2.0, 12.0), 16).into_iter().skip(1)); + let mut e = quad_pts((2.0, 12.0), (12.0, 4.0), (22.0, 12.0), 16); + e.extend(quad_pts((22.0, 12.0), (12.0, 20.0), (2.0, 12.0), 16).into_iter().skip(1)); e.pop(); p.shape(&e, N, K); - p.circle((12.0, 12.0), 3.4, B, N); - p.dot((12.0, 12.0), 1.3, if p.dis { N } else { W }); + p.circle((12.0, 12.0), 3.2, N, K); if icon == I::EyeOff { p.seg_w((4.0, 21.0), (20.0, 3.0), K, 1.1); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1d786b2..25a8323 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod media; pub mod menus; pub mod panes; pub mod ribbon; +pub mod selpane; pub mod show; pub mod sorter; pub mod status; @@ -219,6 +220,8 @@ pub struct SlideApp { pub ui_fonts: uifonts::UiFonts, /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, + /// Selection pane: folded groups, renaming, drag and drop. + pub selpane: selpane::State, } impl SlideApp { @@ -258,6 +261,7 @@ impl SlideApp { notes_buf: (0, usize::MAX, String::new()), ui_fonts: uifonts::UiFonts::default(), thumb_drag: None, + selpane: selpane::State::default(), } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fff1da4..2b3ada3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -47,7 +47,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "format" => format_shape(app, ui), "background" => background(app, ui), "animation" => animation(app, ui), - "selection" => selection(app, ui), + "selection" => crate::selpane::show(app, ui), "comments" => comments(app, ui), "designer" => designer(app, ui), _ => {} @@ -549,72 +549,6 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let _ = Stroke::NONE; } -fn selection(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let shapes: Vec<(u32, String, bool, &'static str)> = - st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect(); - let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); - ui.horizontal(|ui| { - if ui.button("Show All").clicked() { - let ids: Vec = shapes.iter().map(|s| s.0).collect(); - run(app, "shape.visible", json!({"ids": ids, "visible": true})); - } - if ui.button("Hide All").clicked() { - let ids: Vec = shapes.iter().map(|s| s.0).collect(); - run(app, "shape.visible", json!({"ids": ids, "visible": false})); - } - }); - ui.separator(); - let n = shapes.len(); - for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); - let sel = selected.contains(&id); - if sel || resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover }); - } - ui.painter().text( - pos2(r.min.x + 8.0, r.center().y), - Align2::LEFT_CENTER, - &name, - theme::font(12.5), - if hidden { t.text_faint } else { t.text }, - ); - ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint); - let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false); - let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); - if eye_resp.clicked() { - run(app, "shape.visible", json!({"id": id, "visible": hidden})); - } else if resp.clicked() { - let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); - run(app, "edit.select", json!({"ids": [id], "toggle": add})); - } - if resp.double_clicked() { - let mut d = crate::dialogs::Dialog::new("renameShape"); - d.params = json!({"id": id, "name": name}); - app.dialog = Some(d); - } - resp.context_menu(|ui| { - if ui.button("Bring Forward").clicked() { - run(app, "arrange.bringForward", json!({"ids": [id]})); - ui.close(); - } - if ui.button("Send Backward").clicked() { - run(app, "arrange.sendBackward", json!({"ids": [id]})); - ui.close(); - } - if ui.button("Rename…").clicked() { - let mut d = crate::dialogs::Dialog::new("renameShape"); - d.params = json!({"id": id, "name": name}); - app.dialog = Some(d); - ui.close(); - } - }); - let _ = (k, n); - } -} - fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if ui.button("+ New Comment").clicked() { diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs new file mode 100644 index 0000000..414a61f --- /dev/null +++ b/crates/ui-egui/src/selpane.rs @@ -0,0 +1,412 @@ +//! Selection pane: every object on the slide, front-most first, groups expanded with their +//! children. Click to select (Ctrl toggles, Shift selects a run), drag to restack among +//! siblings, eye to show or hide, padlock to lock, double-click or F2 to rename, arrows to move +//! through the list. Everything it changes goes through engine commands. + +use std::collections::HashSet; + +use deckcraft_model::Shape; +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; +use serde_json::json; + +use crate::SlideApp; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +pub const ROW_H: f32 = 24.0; +const INDENT: f32 = 14.0; + +/// Pane-only state (nothing here is part of the document). +#[derive(Debug, Default)] +pub struct State { + /// Groups folded shut. + pub collapsed: HashSet, + /// Object being renamed and the text typed so far. + pub renaming: Option<(u32, String)>, + rename_started: bool, + /// Last row clicked without Shift (the start of a Shift+click run). + pub anchor: Option, + /// Row being dragged and the drop it would make: (index among siblings, line y). + pub drag: Option<(u32, Option<(usize, f32)>)>, +} + +/// One line of the list. +#[derive(Clone, Debug, PartialEq)] +pub struct Row { + pub id: u32, + pub name: String, + pub depth: usize, + pub group: bool, + pub hidden: bool, + /// Inside a hidden group: drawn dimmed. + pub dimmed: bool, + pub locked: bool, + pub parent: Option, +} + +/// The rows as listed: front-most first, children under their group unless it's folded. +pub fn rows(shapes: &[Shape], collapsed: &HashSet) -> Vec { + fn rec(v: &[Shape], depth: usize, parent: Option, dim: bool, collapsed: &HashSet, out: &mut Vec) { + if depth > 64 { + return; + } + for sh in v.iter().rev() { + out.push(Row { + id: sh.id.0, + name: sh.name.clone(), + depth, + group: sh.is_group(), + hidden: sh.hidden, + dimmed: dim, + locked: sh.locked, + parent, + }); + if sh.is_group() && !collapsed.contains(&sh.id.0) { + rec(sh.children(), depth + 1, Some(sh.id.0), dim || sh.hidden, collapsed, out); + } + } + } + let mut out = vec![]; + rec(shapes, 0, None, false, collapsed, &mut out); + out +} + +/// Where a dragged row would land: among the rows sharing its parent (`rects`: each sibling's +/// block, the group's children included, top to bottom), the gap nearest `y`. Returns the index +/// among the siblings in the file's order (0 = back) and the y of the insertion line. +pub fn drop_target(dragged: u32, siblings: &[(u32, Rect)], y: f32) -> Option<(usize, f32)> { + let others: Vec<&(u32, Rect)> = siblings.iter().filter(|(id, _)| *id != dragged).collect(); + let first = others.first()?; + let mut gaps: Vec = vec![first.1.top()]; + gaps.extend(others.iter().map(|(_, r)| r.bottom())); + let (g, gy) = gaps.iter().enumerate().min_by(|a, b| (a.1 - y).abs().total_cmp(&(b.1 - y).abs()))?; + // `g` siblings sit above the line; the dragged object goes behind them. + Some((others.len() - g, *gy)) +} + +fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) { + let _ = app.run(id, p); +} + +/// Each row is a widget with an id of its own (keyboard focus sits on the selected row). +fn row_id(id: u32) -> Id { + Id::new(("selection_pane_row", id)) +} + +fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); + if enabled && resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); + } + ui.painter().rect_stroke(rect, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), rect.shrink(4.0), icon, t.text, !enabled); + resp.on_hover_text(label) +} + +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let rows = rows(st.shapes(), &app.selpane.collapsed); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + let has_sel = !selected.is_empty(); + + ui.horizontal(|ui| { + if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { + run(app, "selection.showAll", json!({})); + } + if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() { + run(app, "selection.hideAll", json!({})); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if small_icon_button(ui, Icon::ChevronDown, "Send Backward", has_sel).clicked() { + run(app, "arrange.sendBackward", json!({})); + } + if small_icon_button(ui, Icon::ChevronUp, "Bring Forward", has_sel).clicked() { + run(app, "arrange.bringForward", json!({})); + } + }); + }); + ui.add_space(6.0); + + // The list has the keyboard while one of its rows has focus. + let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id)); + if let Some(f) = focused_row { + ui.memory_mut(|m| { + m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + } + let mut rects: Vec = Vec::with_capacity(rows.len()); + let width = ui.available_width(); + for (k, row) in rows.iter().enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover()); + let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag()); + rects.push(r); + let sel = selected.contains(&row.id); + resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name)); + let painter = ui.painter_at(r); + if sel { + painter.rect_filled(r, CornerRadius::same(3), t.selected); + painter.rect_stroke(r.shrink(0.5), CornerRadius::same(3), Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside); + } else if resp.hovered() { + painter.rect_filled(r, CornerRadius::same(3), t.hover); + } + let x0 = r.min.x + 4.0 + INDENT * row.depth as f32; + // Group chevron. + if row.group { + let open = !app.selpane.collapsed.contains(&row.id); + let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); + let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click()); + let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); + cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); + icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); + if cresp.clicked() { + if open { + app.selpane.collapsed.insert(row.id); + } else { + app.selpane.collapsed.remove(&row.id); + } + } + } + let name_x = x0 + 16.0; + let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0)); + let lock = Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(16.0, 16.0)); + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0)); + let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone()); + if let Some(mut buf) = renaming { + let edit_id = ui.id().with(("rename", row.id)); + let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0))); + if !app.selpane.rename_started { + out.request_focus(); + if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())); + s.cursor.set_char_range(Some(all)); + s.store(ui.ctx(), edit_id); + } + app.selpane.rename_started = true; + } + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if out.lost_focus() || enter || esc { + app.selpane.renaming = None; + app.selpane.rename_started = false; + if !esc && buf.trim() != row.name && !buf.trim().is_empty() { + run(app, "shape.rename", json!({"id": row.id, "name": buf.trim()})); + } + ui.memory_mut(|m| m.request_focus(row_id(row.id))); + } else if let Some((_, s)) = app.selpane.renaming.as_mut() { + *s = buf; + } + } else { + let color = if row.dimmed { t.text_faint } else { t.text }; + painter.text(pos2(name_x, r.center().y), Align2::LEFT_CENTER, &row.name, theme::font(12.5), color); + } + // Eye and padlock. + let ink = if row.dimmed { t.text_faint } else { t.text_dim }; + icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false); + icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false); + let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click()); + let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name); + eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label)); + let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click()); + let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name); + lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label)); + if eye_resp.clicked() { + run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); + } else if lock_resp.clicked() { + run(app, "shape.lock", json!({"id": row.id})); + } else if resp.double_clicked() { + app.selpane.renaming = Some((row.id, row.name.clone())); + app.selpane.rename_started = false; + } else if resp.clicked() { + let m = ui.input(|i| i.modifiers); + click(app, &rows, k, m.command || m.ctrl, m.shift); + ui.memory_mut(|mem| mem.request_focus(row_id(row.id))); + } + if resp.drag_started() { + if !sel { + run(app, "selection.select", json!({"id": row.id})); + } + app.selpane.drag = Some((row.id, None)); + } + context_menu(app, &resp, row); + } + + // Dragging: the insertion line among the dragged row's siblings. + if let Some((dragged, _)) = app.selpane.drag { + let pointer = ui.input(|i| i.pointer.interact_pos()); + let released = ui.input(|i| i.pointer.any_released() || !i.pointer.any_down()); + let target = pointer.and_then(|p| { + let parent = rows.iter().find(|r| r.id == dragged).map(|r| r.parent)?; + let blocks = sibling_blocks(&rows, &rects, parent); + drop_target(dragged, &blocks, p.y) + }); + app.selpane.drag = Some((dragged, target)); + if let Some((_, y)) = target { + let (x0, x1) = (ui.min_rect().left(), ui.min_rect().left() + width); + let p = ui.painter(); + let s = Stroke::new(2.0, t.accent); + p.line_segment([pos2(x0, y), pos2(x1, y)], s); + p.line_segment([pos2(x0, y - 4.0), pos2(x0, y + 4.0)], s); + p.line_segment([pos2(x1, y - 4.0), pos2(x1, y + 4.0)], s); + } + if released { + app.selpane.drag = None; + if let Some((index, _)) = target { + run(app, "arrange.reorder", json!({"id": dragged, "index": index})); + } + } + } + + // Keys while the list has focus. + if let Some(f) = focused_row + && app.selpane.renaming.is_none() + { + keys(app, ui, &rows, &selected, f); + } + // Clicking the empty part of the list doesn't change anything; keep the pane's height. + let rest = ui.available_size(); + if rest.y > 0.0 { + ui.allocate_exact_size(vec2(width, rest.y.min(400.0)), Sense::hover()); + } +} + +/// Each sibling of `parent`'s block (its row plus its shown children), top to bottom. +fn sibling_blocks(rows: &[Row], rects: &[Rect], parent: Option) -> Vec<(u32, Rect)> { + let mut out: Vec<(u32, Rect)> = vec![]; + let depth_of = |id: Option| id.and_then(|p| rows.iter().find(|r| r.id == p)).map(|r| r.depth + 1).unwrap_or(0); + let depth = depth_of(parent); + let mut current: Option<(u32, Rect)> = None; + for (row, rect) in rows.iter().zip(rects) { + if row.parent == parent { + if let Some(c) = current.take() { + out.push(c); + } + current = Some((row.id, *rect)); + } else if row.depth > depth + && let Some((_, r)) = current.as_mut() + { + *r = r.union(*rect); + } else if let Some(c) = current.take() { + out.push(c); + } + } + if let Some(c) = current { + out.push(c); + } + out +} + +fn click(app: &mut SlideApp, rows: &[Row], k: usize, ctrl: bool, shift: bool) { + let Some(row) = rows.get(k) else { return }; + if shift && let Some(a) = app.selpane.anchor.and_then(|a| rows.iter().position(|r| r.id == a)) { + // A run of rows of the same kind (top level, or inside groups) between the anchor and here. + let (lo, hi) = if a <= k { (a, k) } else { (k, a) }; + let top = row.parent.is_none(); + let ids: Vec = rows.get(lo..=hi).unwrap_or(&[]).iter().filter(|r| r.parent.is_none() == top).map(|r| r.id).collect(); + run(app, "selection.select", json!({"ids": ids, "add": ctrl})); + return; + } + run(app, "selection.select", json!({"id": row.id, "toggle": ctrl})); + app.selpane.anchor = Some(row.id); +} + +fn keys(app: &mut SlideApp, ui: &Ui, rows: &[Row], selected: &[u32], focused: u32) { + let (up, down, home, end, f2, esc) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::ArrowDown), + i.key_pressed(egui::Key::Home), + i.key_pressed(egui::Key::End), + i.key_pressed(egui::Key::F2), + i.key_pressed(egui::Key::Escape), + ) + }); + let cur = selected.last().and_then(|id| rows.iter().position(|r| r.id == *id)); + let to = if up { + Some(cur.map(|c| c.saturating_sub(1)).unwrap_or(0)) + } else if down { + Some(cur.map(|c| (c + 1).min(rows.len().saturating_sub(1))).unwrap_or(0)) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some(k) = to + && let Some(r) = rows.get(k) + && Some(k) != cur + { + run(app, "selection.select", json!({"id": r.id})); + app.selpane.anchor = Some(r.id); + ui.memory_mut(|m| m.request_focus(row_id(r.id))); + } + if f2 + && let [one] = selected + && let Some(r) = rows.iter().find(|r| r.id == *one) + { + app.selpane.renaming = Some((r.id, r.name.clone())); + app.selpane.rename_started = false; + } + if esc { + ui.memory_mut(|m| m.surrender_focus(row_id(focused))); + } +} + +fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { + resp.context_menu(|ui| { + for (label, cmd) in [ + ("Bring to Front", "arrange.bringToFront"), + ("Bring Forward", "arrange.bringForward"), + ("Send Backward", "arrange.sendBackward"), + ("Send to Back", "arrange.sendToBack"), + ] { + if ui.button(label).clicked() { + run(app, cmd, json!({"ids": [row.id]})); + ui.close(); + } + } + ui.separator(); + if ui.button("Rename").clicked() { + app.selpane.renaming = Some((row.id, row.name.clone())); + app.selpane.rename_started = false; + ui.close(); + } + if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() { + run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); + ui.close(); + } + if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() { + run(app, "shape.lock", json!({"id": row.id})); + ui.close(); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(id: u32, top: f32) -> (u32, Rect) { + (id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H))) + } + + #[test] + fn drop_target_picks_the_nearest_gap() { + // Rows top to bottom: 4 (front), 3, 2, 1 (back); dragging 1. + let sib = [r(4, 0.0), r(3, 24.0), r(2, 48.0), r(1, 72.0)]; + // Above everything: in front of all three others. + assert_eq!(drop_target(1, &sib, -5.0), Some((3, 0.0))); + // Between 4 and 3: one sibling above → index 2. + assert_eq!(drop_target(1, &sib, 22.0), Some((2, 24.0))); + // Below the last other row: the back. + assert_eq!(drop_target(1, &sib, 500.0), Some((0, 72.0))); + // Nothing else to drop among. + assert_eq!(drop_target(1, &[r(1, 0.0)], 10.0), None); + } +} + +#[cfg(test)] +#[path = "selpane_tests.rs"] +mod ui_tests; diff --git a/crates/ui-egui/src/selpane_tests.rs b/crates/ui-egui/src/selpane_tests.rs new file mode 100644 index 0000000..2c2181d --- /dev/null +++ b/crates/ui-egui/src/selpane_tests.rs @@ -0,0 +1,255 @@ +//! The Selection pane driven like a person would: clicks, Ctrl+click, drag and drop, the eye, +//! the padlock, renaming and the keyboard. + +use deckcraft_engine::Session; +use egui::{Modifiers, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// A blank slide with A, B, C (back to front) and the group G = [D, E] in front; the pane open. +fn harness() -> (Harness<'static, SlideApp>, Vec) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = vec![]; + for (i, n) in ["A", "B", "C", "D", "E"].iter().enumerate() { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [20.0 + 110.0 * i as f64, 50, 100, 80]})).unwrap(); + let id = r["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": id, "name": n})).unwrap(); + ids.push(id); + } + let g = s.execute("arrange.group", &json!({"ids": [ids[3], ids[4]]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.rename", &json!({"id": g, "name": "G"})).unwrap(); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + ids.push(g); + let mut app = SlideApp::new(s, Services::default()); + app.ui.pane = Some("selection".into()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, ids) +} + +fn names(h: &Harness<'_, SlideApp>) -> String { + fn rec(v: &[deckcraft_model::Shape]) -> String { + v.iter().map(|x| if x.is_group() { format!("{}[{}]", x.name, rec(x.children())) } else { x.name.clone() }).collect::>().join(" ") + } + rec(h.state().session.active().unwrap().shapes()) +} + +fn selected(h: &Harness<'_, SlideApp>) -> Vec { + let st = h.state().session.active().unwrap(); + st.selection.shapes.iter().filter_map(|i| st.shape(*i)).map(|s| s.name.clone()).collect() +} + +fn row(h: &Harness<'_, SlideApp>, name: &str) -> egui::Rect { + h.get_by_label(name).rect() +} + +fn shape<'a>(h: &'a Harness<'_, SlideApp>, name: &str) -> &'a deckcraft_model::Shape { + let st = h.state().session.active().unwrap(); + let mut found = None; + deckcraft_model::walk(st.shapes(), &mut |s, _| { + if found.is_none() && s.name == name { + found = Some(s); + } + }); + found.unwrap() +} + +#[test] +fn lists_front_most_first_with_groups_expanded() { + let (h, _) = harness(); + let ys: Vec = ["G", "D", "E", "C", "B", "A"].iter().map(|n| row(&h, n).center().y).collect(); + // G is in front, then its children front-most first (E above D), then C, B, A. + let order = ["G", "E", "D", "C", "B", "A"]; + let mut by_y: Vec<(&str, f32)> = ["G", "D", "E", "C", "B", "A"].iter().copied().zip(ys).collect(); + by_y.sort_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(by_y.iter().map(|x| x.0).collect::>(), order); + // Children are indented under the group (the name's row starts at the same x; the group + // has a fold button). + assert!(h.query_by_label("Collapse G").is_some()); + assert!(h.query_by_label("Hide A").is_some() && h.query_by_label("Lock A").is_some()); +} + +#[test] +fn click_ctrl_click_and_shift_click_select() { + let (mut h, _) = harness(); + h.get_by_label("A").click(); + h.run_steps(2); + assert_eq!(selected(&h), ["A"]); + h.get_by_label("C").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(selected(&h), ["A", "C"]); + h.get_by_label("A").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert_eq!(selected(&h), ["C"]); + // Shift+click: the run from the last click (C) down to A. + h.get_by_label("C").click(); + h.run_steps(2); + h.get_by_label("A").click_modifiers(Modifiers::SHIFT); + h.run_steps(2); + let mut sel = selected(&h); + sel.sort(); + assert_eq!(sel, ["A", "B", "C"]); + // A child of the group. + h.get_by_label("D").click(); + h.run_steps(2); + assert_eq!(selected(&h), ["D"]); +} + +#[test] +fn eye_and_padlock_toggle_and_undo() { + let (mut h, _) = harness(); + h.get_by_label("Hide B").click(); + h.run_steps(2); + assert!(shape(&h, "B").hidden); + assert!(h.query_by_label("Show B").is_some(), "the eye now offers to show it"); + h.get_by_label("Lock G").click(); + h.run_steps(2); + assert!(shape(&h, "G").locked && shape(&h, "D").locked && shape(&h, "E").locked, "the group locks with its children"); + h.get_by_label("Unlock D").click(); + h.run_steps(2); + assert!(!shape(&h, "D").locked && shape(&h, "G").locked); + h.get_by_label("Hide All").click(); + h.run_steps(2); + assert!(["A", "B", "C", "G"].iter().all(|n| shape(&h, n).hidden)); + h.get_by_label("Show All").click(); + h.run_steps(2); + assert!(["A", "B", "C", "G", "D", "E"].iter().all(|n| !shape(&h, n).hidden)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(shape(&h, "A").hidden); +} + +/// Drag a row with the mouse: press, move in steps, release. +fn drag(h: &mut Harness<'_, SlideApp>, from: Pos2, to: Pos2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + let t = i as f32 / 6.0; + h.hover_at(from + (to - from) * t); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); +} + +#[test] +fn drag_and_drop_restacks_among_siblings() { + let (mut h, _) = harness(); + assert_eq!(names(&h), "A B C G[D E]"); + // A (bottom row) dragged above G (top row): A goes to the front. + let (a, g) = (row(&h, "A"), row(&h, "G")); + drag(&mut h, a.center(), Pos2::new(g.center().x, g.top() + 2.0)); + assert_eq!(names(&h), "B C G[D E] A"); + assert_eq!(selected(&h), ["A"], "the dragged object is selected"); + // C dragged to the bottom of the list: the back. + let (c, b) = (row(&h, "C"), row(&h, "B")); + drag(&mut h, c.center(), Pos2::new(b.center().x, b.bottom() + 30.0)); + assert_eq!(names(&h), "C B G[D E] A"); + // D (a child) dragged far above its group stays in the group, at its front. + let (d, a) = (row(&h, "D"), row(&h, "A")); + drag(&mut h, d.center(), Pos2::new(d.center().x, a.top() - 10.0)); + assert_eq!(names(&h), "C B G[E D] A"); + // B dropped over the group lands among the top-level objects (here just in front of the + // group), never inside it. + let (b, g) = (row(&h, "B"), row(&h, "G")); + drag(&mut h, b.center(), Pos2::new(g.center().x, g.center().y + 6.0)); + assert_eq!(names(&h), "C G[E D] B A"); + // Each drop is one undo step. + for _ in 0..4 { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + assert_eq!(names(&h), "A B C G[D E]"); +} + +#[test] +fn bring_forward_and_send_backward_buttons() { + let (mut h, _) = harness(); + h.get_by_label("A").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[D E]"); + h.get_by_label("D").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + h.get_by_label("Bring Forward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[E D]", "a child stays in its group"); + h.get_by_label("Send Backward").click(); + h.run_steps(2); + assert_eq!(names(&h), "B A C G[D E]"); +} + +#[test] +fn double_click_renames_in_place() { + let (mut h, _) = harness(); + let b = row(&h, "B").center(); + h.hover_at(b); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + } + h.run_steps(2); + assert!(h.state().selpane.renaming.is_some(), "double-click starts renaming"); + // The whole name is selected: typing replaces it. + h.event(egui::Event::Text("Bravo".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(shape(&h, "Bravo").id, shape(&h, "Bravo").id); + assert!(h.state().selpane.renaming.is_none()); + // Escape cancels. + h.get_by_label("Bravo").click(); + h.run_steps(2); + h.key_press(egui::Key::F2); + h.run_steps(3); + assert!(h.state().selpane.renaming.is_some(), "F2 renames the selected object"); + h.event(egui::Event::Text("Nope".into())); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Bravo").is_some()); + assert!(h.query_by_label("Nope").is_none()); +} + +#[test] +fn arrow_keys_move_through_the_list_and_collapse_hides_children() { + let (mut h, _) = harness(); + h.get_by_label("G").click(); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(selected(&h), ["E"]); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + assert_eq!(selected(&h), ["C"]); + // The arrows moved through the list: C itself didn't move on the slide. + assert_eq!(shape(&h, "C").xfrm.map(|x| x.x), Some(240.0)); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert_eq!(selected(&h), ["D"]); + h.get_by_label("Collapse G").click(); + h.run_steps(2); + assert!(h.query_by_label("D").is_none() && h.query_by_label("Expand G").is_some()); + h.get_by_label("Expand G").click(); + h.run_steps(2); + assert!(h.query_by_label("D").is_some()); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07ae91c..7ac7318 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa |---|---|---| | Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | | Format Background pane | yes | yes | -| Selection Pane | yes | yes | +| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline | | Comments pane (threads) | yes | yes | | Design Ideas (Designer) | yes (cloud) | no | @@ -89,4 +89,6 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) | + +| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |