From eab0ac7f4bb1221db4d1099585e645d21e30f1d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 02:40:00 +0000 Subject: [PATCH] UI: document views have scrollbars, and Alt pans while editing text Each document view (and each split pane) has thin, theme-coloured scrollbars on its right and bottom edges, outside the rulers and the view. They span the pasteboard: the thumb is the visible fraction, dragging it pans, clicking the track pages by one view, and they follow panning and zooming by any other means. Rotated views scroll in screen axes. With the Type tool editing text, Alt held at the press gives the Hand tool for that drag: the cursor turns into the hand, the drag pans, and the caret stays. Alt pressed after a text drag starts still copies the dropped text. --- crates/ui-egui/src/canvas.rs | 281 ++++++++++++++++++++++++++++++++++- docs/ui-parity.md | 5 +- 2 files changed, 279 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd61442..56c482195 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,10 @@ use serde_json::{Value, json}; use crate::{DesignApp, ScreenMode, View, theme::Tokens}; pub const RULER: f32 = 15.0; +/// Width of the view's scrollbars. +pub const SCROLLBAR: f32 = 10.0; +/// Shortest scrollbar thumb. +const MIN_THUMB: f32 = 18.0; /// Canvas ↔ screen transform. #[derive(Clone, Copy, Debug)] @@ -232,10 +236,14 @@ fn mods(i: &egui::InputState, space: bool) -> Mods { pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let full = ui.available_rect_before_wrap(); + let outer = ui.available_rect_before_wrap(); let rot = app.view().map_or(0, |v| v.rotation % 4); + let presenting = app.ui.screen_mode == ScreenMode::Presentation; + // Scrollbars sit outside the view (right and bottom); rulers and the view share the rest. + let bars = !presenting && app.session.active().is_some(); + let full = if bars { Rect::from_min_max(outer.min, outer.max - vec2(SCROLLBAR, SCROLLBAR)) } else { outer }; // Rulers measure the unrotated layout, so they hide while the view is rotated. - let rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation && rot == 0; + let rulers = app.ui.rulers && !presenting && rot == 0; let screen = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full }; let rect = view_rect(screen, rot); app.canvas_rect = Some(rect); @@ -244,6 +252,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { fit(app, rect, "spread"); } handle_input(app, ui, &resp, rect); + if bars { + scrollbars(app, ui, outer, screen, rect, &t); + } let Some(st) = app.session.active() else { return }; let Some(&v) = app.view() else { return }; let xf = Xf::new(rect, &v); @@ -328,11 +339,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Cursor. if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) { - let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text(); + let wants_text = app.session.wants_text(); + let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text; let middle = ui.input(|i| i.pointer.middle_down()); - let c = if middle { + let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_alt_hand", app.pane)))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); + let c = if middle || alt_hand { Cursor::HandGrab - } else if space { + } else if space || alt_text { Cursor::Hand } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) @@ -1341,6 +1356,108 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } +/// Move the view's content by a screen-space distance `d` (rotated views move in screen axes). +fn pan(app: &mut DesignApp, xf: &Xf, d: egui::Vec2) { + let d = xf.unrotate_delta(d); + if let Some(v) = app.view_mut() { + v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom); + } +} + +/// A scrollbar thumb on a track `track` pixels long, for `visible` (the view) within `content` +/// (the pasteboard), both spans along the bar's axis in screen pixels: the thumb's start and +/// length on the track, and the content pixels one thumb pixel moves (0 when nothing scrolls). +fn thumb_span(track: f32, content: (f32, f32), visible: (f32, f32)) -> (f32, f32, f32) { + let track = track.max(0.0); + let (lo, hi) = (content.0.min(visible.0), content.1.max(visible.1)); + let (total, shown) = (hi - lo, visible.1 - visible.0); + let scrollable = total - shown; + if !total.is_finite() || !shown.is_finite() || total <= 0.0 || scrollable <= 0.0 { + return (0.0, track, 0.0); + } + let len = (track * shown / total).max(MIN_THUMB.min(track)); + let free = track - len; + if free <= 0.0 { + return (0.0, track, 0.0); + } + ((visible.0 - lo) / scrollable * free, len, scrollable / free) +} + +/// The track and thumb of the vertical (or horizontal) scrollbar of the view `screen` inside +/// `outer`, and the content pixels per thumb pixel. +fn scrollbar_rects(outer: Rect, screen: Rect, content: Rect, vertical: bool) -> (Rect, Rect, f32) { + if vertical { + let track = Rect::from_min_max(pos2(outer.max.x - SCROLLBAR, screen.min.y), pos2(outer.max.x, screen.max.y)); + let (start, len, k) = thumb_span(track.height(), (content.min.y, content.max.y), (screen.min.y, screen.max.y)); + (track, Rect::from_min_size(pos2(track.min.x, track.min.y + start), vec2(SCROLLBAR, len)), k) + } else { + let track = Rect::from_min_max(pos2(screen.min.x, outer.max.y - SCROLLBAR), pos2(screen.max.x, outer.max.y)); + let (start, len, k) = thumb_span(track.width(), (content.min.x, content.max.x), (screen.min.x, screen.max.x)); + (track, Rect::from_min_size(pos2(track.min.x + start, track.min.y), vec2(len, SCROLLBAR)), k) + } +} + +/// The view's scrollbars, on the right and bottom edges of `outer`: they span the pasteboard; +/// dragging a thumb pans, clicking the track pages by one view. +fn scrollbars(app: &mut DesignApp, ui: &mut egui::Ui, outer: Rect, screen: Rect, rect: Rect, t: &Tokens) { + let content = |app: &DesignApp| { + let st = app.session.active()?; + let v = app.view()?; + let xf = Xf::new(rect, v); + Some((xf, xf.rect(CanvasLayout::new(&st.doc, st.editing_parents).pasteboard))) + }; + let Some((xf, cb)) = content(app) else { return }; + let press = ui.input(|i| i.pointer.press_origin()); + let mut hot = [false; 2]; + for (n, vertical) in [true, false].into_iter().enumerate() { + let (track, thumb, k) = scrollbar_rects(outer, screen, cb, vertical); + let id = egui::Id::new(("canvas_scrollbar", app.pane, vertical)); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let grab_id = id.with("grab"); + if resp.drag_started() { + let on_thumb = press.is_some_and(|p| thumb.contains(p)); + ui.data_mut(|d| d.insert_temp(grab_id, on_thumb)); + } + let grabbed = resp.dragged() && ui.data(|d| d.get_temp::(grab_id)).unwrap_or(false); + if !resp.dragged() { + ui.data_mut(|d| d.remove::(grab_id)); + } + let along = |p: egui::Vec2| if vertical { p.y } else { p.x }; + let page = along(screen.size()); + let mut moved = 0.0; + if grabbed { + moved = -along(resp.drag_delta()) * k; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let (p, a, b) = if vertical { (p.y, thumb.min.y, thumb.max.y) } else { (p.x, thumb.min.x, thumb.max.x) }; + if p < a { + moved = page; + } else if p > b { + moved = -page; + } + } + if moved != 0.0 && moved.is_finite() { + pan(app, &xf, if vertical { vec2(0.0, moved) } else { vec2(moved, 0.0) }); + } + if let Some(slot) = hot.get_mut(n) { + *slot = grabbed || resp.hovered(); + } + } + // Painted from the view as it is after this frame's input. + let Some((_, cb)) = content(app) else { return }; + let painter = ui.painter_at(outer); + let right = Rect::from_min_max(pos2(outer.max.x - SCROLLBAR, outer.min.y), outer.max); + let bottom = Rect::from_min_max(pos2(outer.min.x, outer.max.y - SCROLLBAR), outer.max); + painter.rect_filled(right, 0.0, t.panel_darker); + painter.rect_filled(bottom, 0.0, t.panel_darker); + for (n, vertical) in [true, false].into_iter().enumerate() { + let (_, thumb, _) = scrollbar_rects(outer, screen, cb, vertical); + let alpha = if hot.get(n).copied().unwrap_or(false) { 0.6 } else { 0.35 }; + painter.rect_filled(thumb.shrink(2.0), SCROLLBAR / 2.0 - 2.0, t.text.gamma_multiply(alpha)); + } +} + fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1373,6 +1490,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } return; } + // Editing text (Space types a space): Alt held at the press gives the Hand tool until the + // button is released, and the caret stays. Alt pressed later in a text drag copies the text. + let alt_hand_id = egui::Id::new(("canvas_alt_hand", app.pane)); + let mut alt_hand: bool = ui.data(|d| d.get_temp(alt_hand_id)).unwrap_or(false); + let (press, alt, primary_down) = ui.input(|i| (i.pointer.primary_pressed(), i.modifiers.alt, i.pointer.primary_down())); + if press && alt && wants_text && !tool_drag && resp.hovered() { + alt_hand = true; + } + if alt_hand { + if resp.dragged() { + pan(app, &xf, resp.drag_delta()); + } + ui.data_mut(|d| d.insert_temp(alt_hand_id, primary_down)); + return; + } // Ruler guides need the rulers (hidden while the view is rotated). if !space && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; @@ -1806,3 +1938,142 @@ mod tests { assert!(h.query_by_label(" Points").is_none()); } } + +#[cfg(test)] +mod scrollbar_tests { + use egui::{Event, Modifiers, PointerButton}; + use egui_kittest::Harness; + + use super::*; + use crate::test_window::{Window, open, wheel}; + + fn window() -> Harness<'static, Window> { + let mut h = open(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()), vec2(1440.0, 900.0)); + // Zoomed in so both axes scroll. + set_zoom(&mut h.state_mut().app, 2.0); + h.run_steps(3); + h + } + + fn origin(h: &Harness<'static, Window>) -> Point { + h.state().app.view().map(|v| v.origin).unwrap_or_default() + } + + /// The scrollbar's track and thumb as the canvas lays them out for the current view, and + /// the content pixels per thumb pixel. + fn bar(h: &Harness<'static, Window>, vertical: bool) -> (Rect, Rect, f32) { + let app = &h.state().app; + let rect = app.canvas_rect.unwrap(); + let st = app.session.active().unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let cb = xf.rect(CanvasLayout::new(&st.doc, st.editing_parents).pasteboard); + let outer = Rect::from_min_max(rect.min, rect.max + vec2(SCROLLBAR, SCROLLBAR)); + scrollbar_rects(outer, rect, cb, vertical) + } + + /// A primary-button drag from `a` to `b` in a few moves, with `mods` held throughout. + fn drag(h: &mut Harness<'static, Window>, a: Pos2, b: Pos2, mods: Modifiers) { + h.event(Event::ModifiersChanged(mods)); + h.hover_at(a); + h.step(); + h.event(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: mods }); + h.step(); + for n in 1..=4 { + h.hover_at(a + (b - a) * n as f32 / 4.0); + h.step(); + } + h.event(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: mods }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + } + + #[test] + fn document_views_have_scrollbars_beside_the_view() { + let h = window(); + let rect = h.state().app.canvas_rect.unwrap(); + for vertical in [true, false] { + let r = h.ctx.read_response(egui::Id::new(("canvas_scrollbar", 0u8, vertical))).expect("scrollbar").rect; + let (track, thumb, k) = bar(&h, vertical); + assert_eq!(r, track); + assert!(k > 0.0, "the zoomed-in view scrolls"); + if vertical { + assert_eq!((r.min.x, r.width(), r.min.y, r.max.y), (rect.max.x, SCROLLBAR, rect.min.y, rect.max.y)); + assert!(thumb.height() < track.height()); + } else { + assert_eq!((r.min.y, r.height(), r.min.x, r.max.x), (rect.max.y, SCROLLBAR, rect.min.x, rect.max.x)); + assert!(thumb.width() < track.width()); + } + } + } + + #[test] + fn dragging_a_thumb_pans_and_clicking_the_track_pages() { + let mut h = window(); + let zoom = h.state().app.view().unwrap().zoom; + // Vertical thumb: down 40 px moves the view down by 40 thumb pixels' worth of content. + let (_, thumb, k) = bar(&h, true); + let before = origin(&h); + drag(&mut h, thumb.center(), thumb.center() + vec2(0.0, 40.0), Modifiers::NONE); + let after = origin(&h); + let want = 40.0 * k as f64 / zoom; + assert!((after.y - before.y - want).abs() < want * 0.2, "{before:?} → {after:?}, want +{want}"); + assert_eq!(after.x, before.x); + // Horizontal thumb: left 30 px. + let (_, thumb, k) = bar(&h, false); + drag(&mut h, thumb.center(), thumb.center() - vec2(30.0, 0.0), Modifiers::NONE); + let next = origin(&h); + let want = 30.0 * k as f64 / zoom; + assert!((after.x - next.x - want).abs() < want * 0.2, "{after:?} → {next:?}, want -{want}"); + assert_eq!(next.y, after.y); + // A click on the vertical track above the thumb pages up by one view. + let (track, thumb, _) = bar(&h, true); + assert!(thumb.min.y - track.min.y > 4.0); + crate::test_window::click_at(&mut h, pos2(track.center().x, track.min.y + 2.0)); + let paged = origin(&h); + let view = h.state().app.canvas_rect.unwrap().height() as f64 / zoom; + assert!((next.y - paged.y - view).abs() < 0.01, "{next:?} → {paged:?}, want -{view}"); + } + + #[test] + fn scrolling_the_view_moves_the_thumbs() { + let mut h = window(); + let (_, v0, _) = bar(&h, true); + let (_, h0, _) = bar(&h, false); + let c = h.state().app.canvas_rect.unwrap().center(); + wheel(&mut h, c, vec2(-50.0, -80.0)); + let (_, v1, _) = bar(&h, true); + let (_, h1, _) = bar(&h, false); + assert!(v1.min.y > v0.min.y && h1.min.x > h0.min.x, "{v0:?} → {v1:?}, {h0:?} → {h1:?}"); + let r = h.ctx.read_response(egui::Id::new(("canvas_scrollbar", 0u8, true))).expect("scrollbar"); + assert_eq!(r.rect, bar(&h, true).0); + } + + #[test] + fn alt_drag_while_editing_text_pans_and_keeps_the_caret() { + let mut h = window(); + let app = &mut h.state_mut().app; + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + // A text frame filling the visible part of the page, with the caret in it. + let (a, b) = (xf.to_canvas(rect.min + vec2(20.0, 20.0)), xf.to_canvas(rect.max - vec2(20.0, 20.0))); + let r = app.run("frame.create", json!({"rect": [a.x, a.y, b.x, b.y], "content": "text", "text": "Hello brave world"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.select", json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + assert!(app.session.wants_text()); + h.run_steps(2); + let caret = h.state().app.session.doc().unwrap().selection.text; + let before = origin(&h); + let zoom = h.state().app.view().unwrap().zoom; + let from = rect.center(); + drag(&mut h, from, from + vec2(60.0, 40.0), Modifiers::ALT); + let after = origin(&h); + assert!(after.x < before.x && after.y < before.y, "{before:?} → {after:?}"); + assert!((before.x - after.x - 60.0 / zoom).abs() < 15.0 / zoom, "{before:?} → {after:?}"); + assert_eq!(h.state().app.session.doc().unwrap().selection.text, caret); + assert!(h.state_mut().app.session.wants_text(), "still editing text"); + // Without Alt the same drag selects text and leaves the view alone. + drag(&mut h, from, from + vec2(60.0, 40.0), Modifiers::NONE); + assert_eq!(origin(&h), after); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee6db4232..32e53a996 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document views row: scrollbars, Alt pans while editing text) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, against InDesign 2026. The look was measured from the running app @@ -34,6 +34,7 @@ basics of editing fail for too many users on Windows and Linux. | Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | | | Smart guides, snapping to guides/grid/baseline | ✓ | ✓ (2,049-line `snap.rs`) | ~ | vertical smart spacing between two rectangles (#188) | | Nudging with arrows, Shift ×10 | ✓ | ✓ | ✓ | | +| Document views: scrollbars (each split pane), Hand tool by Space, middle drag, or Alt while editing text | ✓ | ✓ | ✓ | `canvas.rs` `scrollbar_tests` | | Gridify while drawing, Gap tool, Live Distribute | ✓ | ✓ | ✓ | | | Type tool: caret, selection, double/triple click, drag-and-drop text | ✓ | ✓ | ~ | formatting at a caret discarded (#289); text editing complaints (#23, #174) | | Threading: click out port, load text cursor, click frame | ✓ | ✗ ports drawn only; no thread/unthread command; Primary Text Frame and autoflow on Place | ✗ | **alpha blocker** (#352, #194, #165, #140) | @@ -54,7 +55,7 @@ basics of editing fail for too many users on Windows and Linux. | UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | | | Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) | -Counted 2026-10-10: 27 rows, 11 ✓, 14 ~, 2 ✗ → **67% presence**, scoring ✓ = 1, ~ = 0.5. Ready +Counted 2026-10-11: 28 rows, 12 ✓, 14 ~, 2 ✗ → **68% presence**, scoring ✓ = 1, ~ = 0.5. Ready for real work is lower, ~45%, because threading is missing and three of the ~ rows (copy/paste, context menus, typing at a caret) are things a layout artist does every minute, and their failures stop work.