Merge PR #1044: View: Presentation shows one spread fitted to the screen; arrow keys and clicks go through the spreads

Conflicts:
- crates/ui-egui/src/menus.rs: kept #503's Esc branch first, then #1044's spread-stepping keys.
This commit is contained in:
aiongg
2026-10-16 04:04:17 +00:00
3 changed files with 150 additions and 4 deletions
+1
View File
@@ -460,6 +460,7 @@ Newest first.
- 2026-10-15: Tab, Shift+Tab and Presentation hide the icon column's panel flyout and the classic floating panels too, as InDesign hides floating panels.
- 2026-10-15: Layers panel: an object's row is renamed with a slow double-click (a second click on the selected row), as in InDesign; a quick double-click only selects it.
- **2026-10-15** · New Workspace is InDesign's: Name, and Capture › Panel Locations and Menu Customization (the items hidden with Edit › Menus), both on by default; a workspace saved without panel locations leaves the panels where they are. No readiness percentage change.
- 2026-10-15: Presentation shows one spread at a time, as large as the screen allows; the arrow keys, Page Up/Down, Home, End, a click and Shift-click go through the spreads.
- 2026-10-15: Layout › First Page, Previous Page, Next Page, Last Page, Next Spread and Previous Spread, with InDesign's shortcuts (Shift+Page Up/Down, Shift+Cmd+Page Up/Down, Alt+Page Up/Down).
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
+41 -4
View File
@@ -249,6 +249,22 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) {
}
}
/// Presentation: the spread in view, centred and as large as `rect` allows.
fn present_fit(app: &mut DesignApp, rect: Rect) {
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let Some(b) = current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.bounds) else { return };
if b.width() <= 0.0 || b.height() <= 0.0 {
return;
}
let zoom = (rect.width() as f64 / b.width()).min(rect.height() as f64 / b.height()).clamp(0.05, 40.0);
let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom);
if let Some(v) = app.view_mut() {
v.zoom = zoom;
v.origin = origin;
}
}
/// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the
/// first and last spread. Parent spreads while they are being edited.
pub fn step_spread(app: &mut DesignApp, delta: isize) {
@@ -430,10 +446,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let rect = view_rect(screen, rot);
app.canvas_rect = Some(rect);
let resp = ui.allocate_rect(full, Sense::click_and_drag());
if !app.view().is_some_and(|v| v.fitted) {
fit(app, rect, "spread");
if presenting {
// One spread at a time, as large as the screen allows; a click shows the next one,
// Shift-click the previous one. Tools and selection don't see the input.
present_fit(app, rect);
if resp.clicked() {
// The release that made the click says whether Shift was down.
let shift = ui.input(|i| {
i.modifiers.shift
|| i.events.iter().any(|e| matches!(e, egui::Event::PointerButton { pressed: false, modifiers, .. } if modifiers.shift))
});
step_spread(app, if shift { -1 } else { 1 });
present_fit(app, rect);
}
} else {
if !app.view().is_some_and(|v| v.fitted) {
fit(app, rect, "spread");
}
handle_input(app, ui, &resp, rect);
}
handle_input(app, ui, &resp, rect);
if bars {
scrollbars(app, ui, outer, screen, rect, &t);
}
@@ -441,7 +472,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let Some(&v) = app.view() else { return };
let xf = Xf::new(rect, &v);
let doc = st.doc.clone();
let layout = CanvasLayout::new(&doc, st.editing_parents);
let mut layout = CanvasLayout::new(&doc, st.editing_parents);
if presenting && let Some(cur) = current_slot(app, &layout) {
layout.slots = layout.slots.get(cur).copied().into_iter().collect();
}
let painter = ui.painter_at(screen);
let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation);
let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard };
@@ -521,6 +555,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
draw_dynamic_spelling(app, ui.ctx(), &painter, &xf, &doc, &layout);
}
}
if presenting {
return;
}
let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout);
if !preview {
// Content grabbers: the selected frame's and the hovered one's (Selection tool).
+108
View File
@@ -2995,6 +2995,21 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
let _ = app.run("view.togglePresentation", json!({}));
continue;
}
// Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End.
if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt {
let step = match key {
egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1),
egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1),
egui::Key::Home => Some(isize::MIN),
egui::Key::End => Some(isize::MAX),
_ => None,
};
if let Some(step) = step {
ctx.input_mut(|i| i.consume_key(modifiers, key));
crate::canvas::step_spread(app, step);
continue;
}
}
// Command shortcuts.
let mut fired: Option<(String, bool)> = None;
let all = effective_shortcuts(app);
@@ -4181,6 +4196,99 @@ mod tests {
assert_eq!(v.zoom, zoom);
}
#[test]
fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
app.session.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap();
let id = app.session.active().unwrap().selection.items[0];
let ctx = egui::Context::default();
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
app.logic(&ui.ctx().clone());
app.ui(ui);
});
out.textures_delta.clear();
};
let key = |k: egui::Key| {
vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() },
]
};
let click = |pos: egui::Pos2, modifiers: egui::Modifiers| {
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers },
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers },
]
};
let spread = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
};
// The spread fills the screen: as wide or as tall as it, centred.
let fills = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
let b = designcraft_tools::layout::CanvasLayout::new(&st.doc, false).slots[spread(app)].bounds;
let (v, r) = (*app.view().unwrap(), app.canvas_rect.unwrap());
let (w, h) = (b.width() * v.zoom, b.height() * v.zoom);
let x0 = (b.x0 - v.origin.x) * v.zoom;
let y0 = (b.y0 - v.origin.y) * v.zoom;
((w - r.width() as f64).abs() < 0.01 || (h - r.height() as f64).abs() < 0.01)
&& w <= r.width() as f64 + 0.01
&& h <= r.height() as f64 + 0.01
&& (x0 * 2.0 + w - r.width() as f64).abs() < 0.01
&& (y0 * 2.0 + h - r.height() as f64).abs() < 0.01
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
app.run("view.screenMode", json!({"mode": "presentation"})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, vec![]);
assert_eq!(app.canvas_rect.unwrap().size(), egui::vec2(1400.0, 900.0), "the whole window");
assert_eq!(spread(&app), 0);
assert!(fills(&app));
let bounds = app.session.active().unwrap().doc.item(id).unwrap().bounds();
frame(&mut app, key(egui::Key::ArrowRight));
frame(&mut app, vec![]);
assert_eq!(spread(&app), 1, "Right Arrow shows the next spread");
assert!(fills(&app));
assert_eq!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds, "the arrow doesn't nudge the selection");
frame(&mut app, key(egui::Key::PageDown));
assert_eq!(spread(&app), 2, "Page Down");
frame(&mut app, key(egui::Key::ArrowLeft));
assert_eq!(spread(&app), 1, "Left Arrow");
frame(&mut app, key(egui::Key::End));
assert_eq!(spread(&app), 3, "End");
frame(&mut app, key(egui::Key::ArrowRight));
assert_eq!(spread(&app), 3, "the last spread stays");
frame(&mut app, key(egui::Key::Home));
assert_eq!(spread(&app), 0, "Home");
frame(&mut app, key(egui::Key::PageUp));
assert_eq!(spread(&app), 0, "the first spread stays");
let c = app.canvas_rect.unwrap().center();
frame(&mut app, click(c, egui::Modifiers::NONE));
frame(&mut app, vec![]);
assert_eq!(spread(&app), 1, "a click shows the next spread");
assert!(fills(&app));
frame(&mut app, click(c, egui::Modifiers::SHIFT));
frame(&mut app, vec![]);
assert_eq!(spread(&app), 0, "Shift-click the previous one");
assert_eq!(app.session.active().unwrap().selection.items, vec![id], "clicks don't select");
assert_eq!(app.session.tool_id(), "selection");
// Out of Presentation, the arrow keys nudge again.
app.run("view.screenMode", json!({"mode": "normal"})).unwrap();
frame(&mut app, vec![]);
frame(&mut app, key(egui::Key::ArrowRight));
assert_ne!(app.session.active().unwrap().doc.item(id).unwrap().bounds(), bounds);
}
#[test]
fn tool_shortcuts_work_after_drawing_on_the_canvas() {
// Clicking the canvas gives it keyboard focus; single-key tool shortcuts must still switch