UI: sections in the Slide Sorter

The sorter draws a bar for each section. Add Section splits at the current
slide, double-click renames in place, the chevron collapses that section, and
dragging the bar moves the section and its slides together.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 00:50:34 +03:00
co-authored by Cursor
parent 457406a7a5
commit afd058a8ff
5 changed files with 487 additions and 61 deletions
+2
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-14 | Slide Sorter sections: Add Section, rename in place, collapse a section, and drag the bar to move that section and its slides |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Progress log: Slide Sorter sections |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+3
View File
@@ -227,6 +227,8 @@ pub struct SlideApp {
pub(crate) thumb_drag: Option<(usize, usize)>,
/// Selection pane: folded groups, renaming, drag and drop.
pub selpane: selpane::State,
/// Slide Sorter: folded sections, inline rename, dragging a whole section.
pub sorter: sorter::State,
}
impl SlideApp {
@@ -267,6 +269,7 @@ impl SlideApp {
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
sorter: sorter::State::default(),
}
}
+308 -58
View File
@@ -1,86 +1,332 @@
//! Slide Sorter, Notes Page and the Outline pane.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use std::collections::HashSet;
use deckcraft_model::{Presentation, SlideId};
use egui::{Align2, Color32, CornerRadius, 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};
/// Sorter-only state. Nothing here is part of the document.
#[derive(Debug, Default)]
pub struct State {
/// Sections folded shut, keyed by (document uid, that section's first slide) so moving a
/// section does not fold a different one. An empty section has nothing to hide.
pub collapsed: HashSet<(u64, u32)>,
/// Inline rename: (document uid, section index, text typed so far).
pub renaming: Option<(u64, usize, String)>,
rename_started: bool,
/// Whole-section drag: (section index, insertion slot among the bars).
pub drag: Option<(usize, usize)>,
}
/// Where a dragged section would land. `centers` are the header centre-y values, top to bottom;
/// `slot` is the gap (0 = before the first bar, `len` = after the last).
fn drop_slot(centers: &[f32], y: f32) -> usize {
centers.iter().position(|cy| y < *cy).unwrap_or(centers.len())
}
/// `section.move`'s `to` for a drag from `from` released at `slot`. `None` when the section
/// would stay where it is (the gap before it, or the gap just after it).
fn move_dest(from: usize, slot: usize) -> Option<usize> {
if slot == from || slot == from + 1 { None } else { Some(if slot > from { slot - 1 } else { slot }) }
}
/// One section's slides, in the section's order, then any slide that belongs to none.
fn groups(doc: &Presentation) -> Vec<(Option<usize>, Vec<usize>)> {
if doc.sections.is_empty() {
return vec![(None, (0..doc.slides.len()).collect())];
}
let mut seen = vec![false; doc.slides.len()];
let mut out = Vec::with_capacity(doc.sections.len() + 1);
for (si, sec) in doc.sections.iter().enumerate() {
let mut slides = Vec::with_capacity(sec.slides.len());
for id in &sec.slides {
if let Some(i) = doc.slide_index(*id)
&& let Some(flag) = seen.get_mut(i)
&& !*flag
{
*flag = true;
slides.push(i);
}
}
out.push((Some(si), slides));
}
let rest: Vec<usize> = seen.iter().enumerate().filter(|(_, on)| !**on).map(|(i, _)| i).collect();
if !rest.is_empty() {
out.push((None, rest));
}
out
}
struct Header<'a> {
uid: u64,
index: usize,
name: &'a str,
count: usize,
first: Option<u32>,
}
fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect {
let t = Tokens::get(ui.ctx());
let width = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
let resp = ui.interact(r, ui.id().with(("sorter-section", h.uid, h.index)), Sense::click_and_drag());
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, h.name));
let open = h.first.is_none_or(|id| !app.sorter.collapsed.contains(&(h.uid, id)));
let dragging = app.sorter.drag.is_some_and(|(from, _)| from == h.index);
let bg = if dragging {
t.selected
} else if resp.hovered() {
t.hover
} else {
t.card
};
let painter = ui.painter_at(r);
painter.rect_filled(r, CornerRadius::same(4), bg);
let chev = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(24.0, 24.0));
let cresp = ui.interact(chev, ui.id().with(("sorter-fold", h.uid, h.index)), Sense::click());
let fold = format!("{} {}", if open { "Collapse" } else { "Expand" }, h.name);
cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &fold));
icons::paint(&painter, chev.shrink(4.0), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, h.first.is_none());
let renaming = app.sorter.renaming.clone().filter(|(uid, index, _)| *uid == h.uid && *index == h.index).map(|(_, _, text)| text);
let name_rect = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 4.0), pos2(r.max.x - 56.0, r.max.y - 4.0));
if let Some(mut buf) = renaming {
let edit_id = ui.id().with(("sorter-rename", h.uid, h.index));
let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::bold(13.0)).margin(vec2(4.0, 2.0)));
let already = app.sorter.rename_started;
if !already {
out.request_focus();
if let Some(mut state) = 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()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), edit_id);
}
app.sorter.rename_started = true;
}
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
// The frame that asks for focus has not had it yet; committing then would cancel the rename.
if already && (out.lost_focus() || enter || esc) {
app.sorter.renaming = None;
app.sorter.rename_started = false;
let trimmed = buf.trim();
if !esc && !trimmed.is_empty() && trimmed != h.name {
let _ = app.run("section.rename", json!({"index": h.index, "name": trimmed}));
}
} else if let Some((_, _, text)) = app.sorter.renaming.as_mut() {
*text = buf;
}
} else {
painter.text(pos2(name_rect.min.x, r.center().y), Align2::LEFT_CENTER, h.name, theme::bold(13.0), t.text);
}
painter.text(pos2(r.max.x - 16.0, r.center().y), Align2::RIGHT_CENTER, format!("({})", h.count), theme::font(12.0), t.text_dim);
if cresp.clicked()
&& let Some(id) = h.first
{
let key = (h.uid, id);
if open {
app.sorter.collapsed.insert(key);
} else {
app.sorter.collapsed.remove(&key);
}
} else if resp.double_clicked() && app.sorter.renaming.is_none() {
app.sorter.renaming = Some((h.uid, h.index, h.name.to_string()));
app.sorter.rename_started = false;
}
let on_chevron = resp.interact_pointer_pos().is_some_and(|p| chev.contains(p));
if resp.drag_started() && !on_chevron && app.sorter.renaming.is_none() {
app.sorter.drag = Some((h.index, h.index));
app.thumb_drag = None;
}
let index = h.index;
let name = h.name.to_string();
resp.context_menu(|ui| {
if ui.button("Rename Section…").clicked() {
let mut d = crate::dialogs::Dialog::new("renameSection");
d.params = json!({"index": index, "name": name});
app.dialog = Some(d);
ui.close();
}
if ui.button("Add Section").clicked() {
if let Some(at) = app.session.active().map(|st| st.selection.slide) {
let _ = app.run("section.add", json!({"at": at}));
}
ui.close();
}
if ui.button("Remove Section").clicked() {
let _ = app.run("section.remove", json!({"index": index}));
ui.close();
}
if ui.button("Remove Section & Slides").clicked() {
let _ = app.run("section.remove", json!({"index": index, "slides": true}));
ui.close();
}
if ui.button("Move Section Up").clicked() {
let _ = app.run("section.move", json!({"index": index, "to": index.saturating_sub(1)}));
ui.close();
}
if ui.button("Move Section Down").clicked() {
let _ = app.run("section.move", json!({"index": index, "to": index + 1}));
ui.close();
}
});
r
}
struct Grid<'a> {
doc: &'a Presentation,
indices: &'a [usize],
cur: usize,
multi: &'a [SlideId],
tw: f32,
th: f32,
}
/// One section's thumbnails. Returns whether a thumbnail is still being drawn.
fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, rects: &mut Vec<(usize, Rect)>) -> bool {
let t = Tokens::get(ui.ctx());
let ppp = ui.ctx().pixels_per_point();
let cols = ((ui.available_width() - 32.0) / (g.tw + 36.0)).floor().max(1.0) as usize;
let mut need_more = false;
let rows = g.indices.len().div_ceil(cols);
for row in 0..rows {
ui.horizontal(|ui| {
ui.add_space(24.0);
for c in 0..cols {
let Some(&i) = g.indices.get(row * cols + c) else { break };
let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag());
let label = format!("Slide {}", i + 1);
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th));
rects.push((i, cell));
let p = ui.painter();
match app.textures.thumb(ui.ctx(), g.doc, i, (g.tw * ppp) as u32, budget) {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
need_more = true;
}
}
let id = g.doc.slides.get(i).map(|s| s.id);
let selected = i == g.cur || id.is_some_and(|id| g.multi.contains(&id));
p.rect_stroke(
img.expand(if selected { 2.0 } else { 0.0 }),
CornerRadius::same(3),
Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }),
egui::StrokeKind::Outside,
);
p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim);
if let Some(tr) = g.doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|tr| tr.kind != "none") {
p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint);
}
if resp.clicked() {
let add = ui.input(|inp| inp.modifiers.command);
if add {
if let Some(id) = id {
let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true}));
}
} else {
let _ = app.run("slide.go", json!({"index": i}));
}
}
if resp.double_clicked() {
let _ = app.run("slide.go", json!({"index": i}));
app.ui.view = crate::ViewMode::Normal;
}
if resp.drag_started() && app.sorter.drag.is_none() {
app.thumb_drag = Some((i, i));
}
resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i));
}
});
}
need_more
}
/// Grid of slides; click selects, double-click opens in Normal view, drag reorders.
/// With sections, each one is a bar: add, rename, collapse, and drag the whole section.
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let Some(st) = app.session.active() else {
app.sorter.drag = None;
app.sorter.renaming = None;
app.sorter.rename_started = false;
return;
};
let uid = st.uid;
if app.sorter.renaming.as_ref().is_some_and(|(u, i, _)| *u != uid || *i >= st.doc.sections.len()) {
app.sorter.renaming = None;
app.sorter.rename_started = false;
}
let doc = st.doc.clone();
let cur = st.selection.slide;
let multi = st.selection.slides.clone();
let zoom = app.ui.sorter_zoom.clamp(0.5, 3.0);
let grouped = groups(&doc);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.add_space(16.0);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.add_space(24.0);
if ui.add(egui::Button::new("Add Section").stroke(Stroke::new(1.0, t.border))).clicked() {
let _ = app.run("section.add", json!({"at": cur}));
}
});
ui.add_space(8.0);
let tw = 200.0 * zoom;
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
let ppp = ui.ctx().pixels_per_point();
let cols = ((ui.available_width() - 32.0) / (tw + 36.0)).floor().max(1.0) as usize;
let mut budget = 4u32;
let mut rects = vec![];
let mut headers: Vec<Rect> = Vec::new();
let mut need_more = false;
for row in 0..doc.slides.len().div_ceil(cols) {
ui.horizontal(|ui| {
ui.add_space(24.0);
for c in 0..cols {
let i = row * cols + c;
if i >= doc.slides.len() {
break;
}
let (cell, resp) = ui.allocate_exact_size(vec2(tw + 36.0, th + 36.0), Sense::click_and_drag());
let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th));
rects.push((i, cell));
let p = ui.painter();
match app.textures.thumb(ui.ctx(), &doc, i, (tw * ppp) as u32, &mut budget) {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
need_more = true;
}
}
let id = doc.slides.get(i).map(|s| s.id);
let selected = i == cur || id.is_some_and(|id| multi.contains(&id));
p.rect_stroke(
img.expand(if selected { 2.0 } else { 0.0 }),
CornerRadius::same(3),
Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }),
egui::StrokeKind::Outside,
);
p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim);
if let Some(tr) = doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|t| t.kind != "none") {
p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint);
}
if resp.clicked() {
let add = ui.input(|i| i.modifiers.command);
if add {
if let Some(id) = id {
let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true}));
}
} else {
let _ = app.run("slide.go", json!({"index": i}));
}
}
if resp.double_clicked() {
let _ = app.run("slide.go", json!({"index": i}));
app.ui.view = crate::ViewMode::Normal;
}
if resp.drag_started() {
app.thumb_drag = Some((i, i));
}
resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i));
for (si, slides) in &grouped {
if let Some(si) = *si
&& let Some(sec) = doc.sections.get(si)
{
let first = sec.slides.first().map(|id| id.0);
let rect = section_header(app, ui, &Header { uid, index: si, name: &sec.name, count: sec.slides.len(), first });
headers.push(rect);
let folded = first.is_some_and(|id| app.sorter.collapsed.contains(&(uid, id)));
if folded {
ui.add_space(8.0);
continue;
}
});
}
let grid = Grid { doc: &doc, indices: slides, cur, multi: &multi, tw, th };
if place_slides(app, ui, &grid, &mut budget, &mut rects) {
need_more = true;
}
ui.add_space(8.0);
}
if need_more {
ui.ctx().request_repaint();
}
if let Some((from, _)) = app.thumb_drag {
if let Some((from, _)) = app.sorter.drag {
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
let centers: Vec<f32> = headers.iter().map(|r| r.center().y).collect();
let slot = drop_slot(&centers, pp.y);
app.sorter.drag = Some((from, slot));
let y = headers.get(slot).map(|r| r.min.y).or_else(|| headers.last().map(|r| r.max.y + 2.0));
if let Some(y) = y {
ui.painter().line_segment([pos2(16.0, y), pos2(ui.available_width(), y)], Stroke::new(2.0, t.accent));
}
}
if ui.input(|i| i.pointer.any_released()) {
if let Some((from, slot)) = app.sorter.drag.take()
&& let Some(to) = move_dest(from, slot)
{
let _ = app.run("section.move", json!({"index": from, "to": to}));
}
app.sorter.drag = None;
}
}
if app.sorter.drag.is_none()
&& let Some((from, _)) = app.thumb_drag
{
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
let to =
rects.iter().find(|(_, r)| r.contains(pp)).map(|(i, r)| if pp.x > r.center().x { i + 1 } else { *i }).unwrap_or(doc.slides.len());
@@ -230,3 +476,7 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) {
},
);
}
#[cfg(test)]
#[path = "sorter_tests.rs"]
mod ui_tests;
+171
View File
@@ -0,0 +1,171 @@
//! Slide Sorter sections: add, rename, collapse, and dragging a whole section.
use deckcraft_engine::Session;
use egui::Pos2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use crate::{Services, SlideApp};
fn harness(with_sections: bool) -> Harness<'static, SlideApp> {
let mut s = Session::with_new();
for _ in 0..3 {
s.execute("slide.new", &json!({})).unwrap();
}
if with_sections {
s.execute("section.add", &json!({"name": "Alpha", "at": 0})).unwrap();
s.execute("section.add", &json!({"name": "Beta", "at": 2})).unwrap();
}
let mut app = SlideApp::new(s, Services::default());
app.ui.view = crate::ViewMode::Sorter;
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
}
fn section_names(h: &Harness<'_, SlideApp>) -> Vec<String> {
h.state().session.active().unwrap().doc.sections.iter().map(|s| s.name.clone()).collect()
}
fn section_slides(h: &Harness<'_, SlideApp>) -> Vec<Vec<u32>> {
h.state().session.active().unwrap().doc.sections.iter().map(|s| s.slides.iter().map(|id| id.0).collect()).collect()
}
fn double_click(h: &mut Harness<'_, SlideApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
for _ in 0..2 {
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
}
}
h.run_steps(2);
}
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 drop_slot_and_move_dest() {
assert_eq!(super::drop_slot(&[10.0, 40.0, 70.0], 0.0), 0);
assert_eq!(super::drop_slot(&[10.0, 40.0, 70.0], 20.0), 1);
assert_eq!(super::drop_slot(&[10.0, 40.0, 70.0], 100.0), 3);
assert_eq!(super::move_dest(1, 1), None);
assert_eq!(super::move_dest(1, 2), None);
assert_eq!(super::move_dest(1, 0), Some(0));
assert_eq!(super::move_dest(0, 2), Some(1));
}
#[test]
fn add_section_splits_at_the_current_slide() {
let mut h = harness(false);
assert!(h.query_by_label("Slide 4").is_some());
assert!(section_names(&h).is_empty());
h.get_by_label("Add Section").click();
h.run_steps(3);
assert_eq!(section_names(&h), ["Default Section", "Untitled Section"]);
let slides = section_slides(&h);
assert_eq!(slides[0].len(), 3);
assert_eq!(slides[1].len(), 1);
assert!(h.query_by_label("Default Section").is_some());
assert!(h.query_by_label("Untitled Section").is_some());
assert!(h.query_by_label("Slide 1").is_some() && h.query_by_label("Slide 4").is_some());
}
#[test]
fn collapse_hides_that_sections_slides_and_expand_brings_them_back() {
let mut h = harness(true);
assert_eq!(section_names(&h), ["Alpha", "Beta"]);
assert_eq!(section_slides(&h).iter().map(|s| s.len()).collect::<Vec<_>>(), [2, 2]);
assert!(h.query_by_label("Slide 1").is_some() && h.query_by_label("Slide 4").is_some());
h.get_by_label("Collapse Beta").click();
h.run_steps(2);
assert!(h.query_by_label("Slide 1").is_some() && h.query_by_label("Slide 2").is_some());
assert!(h.query_by_label("Slide 3").is_none() && h.query_by_label("Slide 4").is_none());
assert!(h.query_by_label("Expand Beta").is_some());
h.get_by_label("Expand Beta").click();
h.run_steps(2);
assert!(h.query_by_label("Slide 3").is_some() && h.query_by_label("Slide 4").is_some());
}
#[test]
fn double_click_renames_and_switching_documents_drops_the_draft() {
let mut h = harness(true);
let beta = h.get_by_label("Beta").rect().center();
double_click(&mut h, beta);
assert!(h.state().sorter.renaming.is_some(), "double-click starts renaming");
h.event(egui::Event::Text("Gamma".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(section_names(&h), ["Alpha", "Gamma"]);
assert!(h.state().sorter.renaming.is_none());
// Past the double-click window, so the next two clicks count as a new double-click.
h.run_steps(25);
let gamma = h.get_by_label("Gamma").rect().center();
double_click(&mut h, gamma);
assert!(h.state().sorter.renaming.is_some());
h.event(egui::Event::Text("Nope".into()));
h.run_steps(1);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert_eq!(section_names(&h), ["Alpha", "Gamma"]);
assert!(h.query_by_label("Nope").is_none());
h.run_steps(25);
let gamma = h.get_by_label("Gamma").rect().center();
double_click(&mut h, gamma);
assert!(h.state().sorter.renaming.is_some());
h.event(egui::Event::Text("Draft".into()));
h.run_steps(1);
h.state_mut().run("file.new", json!({})).unwrap();
h.run_steps(2);
assert!(h.state().sorter.renaming.is_none(), "a different presentation drops the draft");
h.state_mut().session.set_active(0);
h.run_steps(2);
assert_eq!(section_names(&h), ["Alpha", "Gamma"]);
}
#[test]
fn dragging_a_section_moves_it_and_keeps_it_collapsed() {
let mut h = harness(true);
let before = section_slides(&h);
h.get_by_label("Collapse Beta").click();
h.run_steps(2);
let (alpha, beta) = (h.get_by_label("Alpha").rect(), h.get_by_label("Beta").rect());
assert!(beta.top() > alpha.bottom());
drag(&mut h, beta.center(), Pos2::new(alpha.center().x, alpha.top() - 4.0));
assert_eq!(section_names(&h), ["Beta", "Alpha"]);
let after = section_slides(&h);
assert_eq!(after[0], before[1]);
assert_eq!(after[1], before[0]);
let doc = &h.state().session.active().unwrap().doc;
let order: Vec<u32> = doc.slides.iter().map(|s| s.id.0).collect();
let expect: Vec<u32> = after.iter().flatten().copied().collect();
assert_eq!(order, expect, "slides follow the section order");
assert!(h.query_by_label("Expand Beta").is_some(), "Beta stays collapsed after the move");
assert!(h.query_by_label("Slide 1").is_none() && h.query_by_label("Slide 2").is_none());
assert!(h.query_by_label("Slide 3").is_some() && h.query_by_label("Slide 4").is_some());
let (beta, alpha) = (h.get_by_label("Beta").rect(), h.get_by_label("Alpha").rect());
assert!(beta.top() < alpha.top());
}
+3 -3
View File
@@ -1,8 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
@@ -85,6 +83,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter |
| Drag and drop of files and between thumbnails | yes | yes |
| Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes |
| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) |
| Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) |
@@ -148,6 +147,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Slide Sorter draws a bar per section: Add Section, rename in place, collapse, and drag the bar to move the section and its slides |
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 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 |