This commit is contained in:
Brandon Thomas
2026-10-15 00:35:27 -04:00
2 changed files with 279 additions and 7 deletions
+276 -5
View File
@@ -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)]
@@ -307,10 +311,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);
@@ -319,6 +327,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);
@@ -408,11 +419,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())
@@ -1602,6 +1617,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::<bool>(grab_id)).unwrap_or(false);
if !resp.dragged() {
ui.data_mut(|d| d.remove::<bool>(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);
@@ -1639,6 +1756,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;
@@ -2535,3 +2667,142 @@ mod tests {
assert_eq!(baseline_grid_lines(0.0, 12.0, f64::INFINITY).count(), MAX_BASELINES);
}
}
#[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);
}
}
+3 -2
View File
@@ -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.