Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 2e3fc83c9e Dock: visible splitter grips, exact drags, double-click reset (#295)
The right dock already had splitters between panel groups (#88), but
nothing showed where they were and drags lost the movement before
egui's drag threshold, so they felt broken.

- A three-dot grip in every gap; resize cursor and accent line on hover,
  solid while dragging. Grab area is the gap plus 3 pt each side and is
  registered after the groups so it wins over overlapping strips.
- Drags are measured from the press against the heights at the press:
  the edge follows the pointer exactly and clamps at minimum heights
  without drift.
- Double-clicking a splitter gives both neighbouring groups their
  default heights.
- Collapsed groups have no splitter; the one above resizes against the
  next expanded group. Sizes persist as before (DockLayout::heights in
  ui.set, workspaces and prefs.panelLayout).
- Control protocol: ui.press / ui.release (half clicks) so agents can
  hold a drag across calls; ui.move / ui.click now documented.
- Tests: full-app kittest at three window sizes (grab tolerance, exact
  growth, minimum, double-click reset) and dock unit/kittests for
  collapsed groups and reset.

Fixes #295

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 10:53:29 -04:00
5 changed files with 235 additions and 15 deletions

No files matched your search

+6 -1
View File
@@ -319,7 +319,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
app.stylus.feed.set(None);
ok(json!({"status": app.ui.status}))
}
"ui.click" | "ui.move" => {
"ui.click" | "ui.move" | "ui.press" | "ui.release" => {
// Screen coordinates in points (as reported by ui.inspect window size).
let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32;
let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32;
@@ -330,6 +330,11 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
_ => egui::PointerButton::Primary,
};
app.synthetic.push(egui::Event::PointerMoved(pos));
// ui.press / ui.release: half a click, so a drag can be held across calls (and
// screenshotted mid-drag) with ui.move in between.
if matches!(req.method.as_str(), "ui.press" | "ui.release") {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: req.method == "ui.press", modifiers: Default::default() });
}
if req.method == "ui.click" {
let clicks = p.get("count").and_then(Value::as_u64).unwrap_or(1);
for _ in 0..clicks {
+64 -14
View File
@@ -2,7 +2,8 @@
//!
//! A group's height never follows its content: content taller than the group scrolls inside
//! it. The last expanded group (Layers by default) fills what the others leave. Drag the gap
//! between two groups to resize them, double-click a tab (or use the panel menu) to collapse a
//! between two groups (its grip, #295) to resize them down to their minimum heights, and
//! double-click it to give both their default heights. Double-click a tab (or use the panel menu) to collapse a
//! group to its tab strip, and drag a tab strip to move the group up or down the column
//! (unless Window › Workspace › Lock Workspace is on).
//!
@@ -366,7 +367,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
let mut actions: Vec<Action> = Vec::new();
let mut dragging: Option<Group> = None;
let mut strips: Vec<StripRects> = Vec::with_capacity(rects.len());
for (i, (g, rect)) in rects.iter().copied().enumerate() {
for (g, rect) in rects.iter().copied() {
let collapsed = app.ui.dock.is_collapsed(g);
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
@@ -432,19 +433,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
ui.close();
}
});
// Splitter in the gap below this group: resizes it against the next expanded group.
if i + 1 < rects.len() && !collapsed && rects.iter().skip(i + 1).any(|(n, _)| !app.ui.dock.is_collapsed(*n)) {
let gap = Rect::from_min_size(pos2(rect.left(), rect.bottom()), vec2(rect.width(), GAP)).expand2(vec2(0.0, 2.0));
let sresp = ui.interact(gap, ui.id().with(("dock-splitter", g.key())), Sense::drag());
if sresp.hovered() || sresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
ui.painter().line_segment([gap.left_center(), gap.right_center()], Stroke::new(2.0, t.accent.gamma_multiply(0.7)));
}
if sresp.dragged() {
resize(&mut app.ui.dock, &heights, i, sresp.drag_delta().y);
}
}
}
// Splitters in the gaps between groups. Registered after every group so they win the
// pointer over the tab strip and body edges they overlap.
splitters(app, ui, &heights, &rects, &t);
// Drop indicator while a group is dragged by its tab strip.
if let (Some(g), Some(p)) = (dragging, ui.ctx().pointer_interact_pos()) {
let before = drop_before(&order, &rects, g, p.y);
@@ -501,6 +493,64 @@ fn drop_before(order: &[Group], rects: &[(Group, Rect)], dragged: Group, y: f32)
}
}
/// How far a splitter's grab area reaches beyond the gap, above and below (points).
const SPLITTER_SLOP: f32 = 3.0;
/// The splitter below each group that has an expanded group somewhere below it, as
/// `(index of the group above, grab rect)`. A collapsed group has none: its height is its tab strip.
pub fn splitter_rects(layout: &DockLayout, rects: &[(Group, Rect)]) -> Vec<(usize, Rect)> {
rects
.iter()
.enumerate()
.filter(|(i, (g, _))| !layout.is_collapsed(*g) && rects.iter().skip(i + 1).any(|(n, _)| !layout.is_collapsed(*n)))
.map(|(i, (_, r))| (i, Rect::from_min_size(pos2(r.left(), r.bottom()), vec2(r.width(), GAP)).expand2(vec2(0.0, SPLITTER_SLOP))))
.collect()
}
/// Draw and drive the splitters: a short grip in each gap (always visible, so the handle can
/// be found), a full-width accent line while hovered or dragged, drag to resize the groups on
/// either side down to their minimum heights, double-click to give both their default heights.
fn splitters(app: &mut PhotocraftApp, ui: &mut egui::Ui, heights: &[(Group, f32)], rects: &[(Group, Rect)], t: &Tokens) {
for (i, grab) in splitter_rects(&app.ui.dock, rects) {
let Some(&(g, _)) = rects.get(i) else { continue };
let resp = ui.interact(grab, ui.id().with(("dock-splitter", g.key())), Sense::click_and_drag());
let gap = grab.shrink2(vec2(0.0, SPLITTER_SLOP));
let active = resp.hovered() || resp.dragged();
let p = ui.painter();
if active {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
p.line_segment([gap.left_center(), gap.right_center()], Stroke::new(2.0, t.accent.gamma_multiply(if resp.dragged() { 1.0 } else { 0.7 })));
} else {
// Grip: three dots in the middle of the gap.
for k in -1..=1 {
p.circle_filled(gap.center() + vec2(k as f32 * 5.0, 0.0), 1.0, t.text_faint.gamma_multiply(0.8));
}
}
if resp.double_clicked() {
reset_splitter(&mut app.ui.dock, heights, i);
} else if resp.dragged() {
// Measured from the press with the heights at the press, so the movement before
// egui's drag threshold counts and clamping at a minimum doesn't drift.
let id = resp.id.with("start");
let start = ui.data(|d| d.get_temp::<(Vec<(Group, f32)>, f32)>(id)).filter(|_| !resp.drag_started());
let (at_press, y0) = start.unwrap_or_else(|| (heights.to_vec(), ui.input(|inp| inp.pointer.press_origin()).map_or(gap.center().y, |o| o.y)));
if let Some(y) = ui.ctx().pointer_interact_pos().map(|q| q.y) {
resize(&mut app.ui.dock, &at_press, i, y - y0);
}
ui.data_mut(|d| d.insert_temp(id, (at_press, y0)));
}
}
}
/// Splitter `i` double-clicked: the groups on either side go back to their default heights.
fn reset_splitter(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize) {
let Some(&(g, _)) = heights.get(i) else { return };
layout.heights.remove(&g);
if let Some((n, _)) = heights.iter().skip(i + 1).find(|(n, _)| !layout.is_collapsed(*n)) {
layout.heights.remove(n);
}
}
/// Splitter `i` (below group `i`) dragged by `dy`: group `i` grows or shrinks against the next
/// expanded group (or the filler, which absorbs the difference).
fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32) {
+72
View File
@@ -566,3 +566,75 @@ fn dock_strips_fit_and_the_chevron_menu_switches_tabs() {
h.run_steps(3);
assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu");
}
#[test]
fn splitters_skip_collapsed_groups_and_double_click_resets() {
let mut l = DockLayout::default();
let shown = [Group::Color, Group::Properties, Group::History, Group::Layers];
let rects = |l: &DockLayout| rects_after_layout(&l.heights_for(&shown, 900.0, 28.0), Rect::from_min_size(Pos2::ZERO, vec2(280.0, 900.0)));
// One splitter below every group but the last.
let s = splitter_rects(&l, &rects(&l));
assert_eq!(s.iter().map(|(i, _)| *i).collect::<Vec<_>>(), vec![0, 1, 2]);
for (i, r) in &s {
let above = rects(&l)[*i].1;
assert!(r.contains(Pos2::new(above.center().x, above.bottom() + GAP / 2.0)), "the grab area covers the gap");
assert!(r.height() >= GAP + 2.0 * SPLITTER_SLOP - 0.01);
}
// A collapsed group has no splitter; the one above it resizes against the next expanded group.
l.set_collapsed(Group::Properties, true);
let s = splitter_rects(&l, &rects(&l));
assert_eq!(s.iter().map(|(i, _)| *i).collect::<Vec<_>>(), vec![0, 2]);
let hs = l.heights_for(&shown, 900.0, 28.0);
let (color, history) = (hs[0].1, hs[2].1);
resize(&mut l, &hs, 0, 40.0);
assert_eq!(l.height(Group::Color), color + 40.0);
assert_eq!(l.height(Group::History), history - 40.0, "History gave the room, not the collapsed Properties");
assert!(l.is_collapsed(Group::Properties));
// Collapsing the last expanded groups leaves no splitter at all.
l.set_collapsed(Group::History, true);
l.set_collapsed(Group::Layers, true);
let s = splitter_rects(&l, &rects(&l));
assert!(s.is_empty(), "{s:?}");
// Expanding again brings them back; double-click resets both sides to their defaults.
for g in shown {
l.set_collapsed(g, false);
}
let hs = l.heights_for(&shown, 900.0, 28.0);
resize(&mut l, &hs, 1, -30.0);
assert!(l.heights.contains_key(&Group::Properties) && l.heights.contains_key(&Group::History));
let hs = l.heights_for(&shown, 900.0, 28.0);
reset_splitter(&mut l, &hs, 1);
assert!(!l.heights.contains_key(&Group::Properties) && !l.heights.contains_key(&Group::History));
assert!(l.heights.contains_key(&Group::Color), "the other groups keep their sizes");
// Out-of-range indices are ignored.
reset_splitter(&mut l, &hs, 99);
resize(&mut l, &hs, 99, 10.0);
}
#[test]
fn a_splitter_drag_shows_the_handle_and_collapse_still_works() {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium);
let color = rect_of(&h, Group::Color);
let at = Pos2::new(color.center().x, color.bottom() + GAP / 2.0);
drag(&mut h, at, at + vec2(0.0, 25.0));
assert!((rect_of(&h, Group::Color).height() - (color.height() + 25.0)).abs() < 1.0);
// Collapse Color by double-clicking its tab: the group shrinks to its strip and the
// splitter below it goes away; expanding restores the dragged height.
let tab = rect_of(&h, Group::Color).left_top() + vec2(20.0, 13.0);
h.event(egui::Event::PointerMoved(tab));
h.run_steps(1);
for _ in 0..2 {
h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert!(h.state().ui.dock.is_collapsed(Group::Color));
let rects = last_rects(&h.ctx);
assert!(!splitter_rects(&h.state().ui.dock, &rects).iter().any(|(i, _)| rects[*i].0 == Group::Color));
h.state_mut().ui.dock.set_collapsed(Group::Color, false);
h.run_steps(3);
assert!((rect_of(&h, Group::Color).height() - (color.height() + 25.0)).abs() < 1.0);
}
+92
View File
@@ -0,0 +1,92 @@
//! Dock splitters through the whole app (#295): the gaps between the right dock's panel groups
//! resize them, with the Layers panel and the canvas around them, at the window sizes people use.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use photocraft_ui_egui::dock::{GAP, Group, last_rects};
use photocraft_ui_egui::{PhotocraftApp, Services};
use serde_json::json;
type H = Harness<'static, PhotocraftApp>;
fn app(w: f32, h: f32) -> H {
let mut h = Harness::builder().with_size(vec2(w, h)).with_pixels_per_point(1.0).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).expect("new");
for _ in 0..6 {
app.run("layer.new.layer", json!({})).expect("layer");
}
app.sync_views();
app
});
h.run_steps(6);
h
}
fn rect(h: &H, g: Group) -> egui::Rect {
last_rects(&h.ctx).into_iter().find(|(x, _)| *x == g).map(|(_, r)| r).unwrap_or_else(|| panic!("{g:?} not drawn"))
}
fn press(h: &mut H, p: Pos2) {
h.event(Event::PointerMoved(p));
h.step();
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
}
fn release(h: &mut H, p: Pos2) {
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn drag(h: &mut H, from: Pos2, to: Pos2) {
press(h, from);
for i in 1..=6 {
h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.step();
}
release(h, to);
}
#[test]
fn the_splitter_above_layers_gives_layers_room_in_the_real_app() {
for (w, hgt) in [(1440.0, 900.0), (1280.0, 720.0), (1920.0, 1080.0)] {
let mut h = app(w, hgt);
let layers = rect(&h, Group::Layers);
// Grab anywhere along the gap, not only its exact middle.
for dy in [-4.0, 0.0, 4.0] {
let props = rect(&h, Group::Properties);
let at = Pos2::new(props.center().x + 40.0, props.bottom() + GAP / 2.0 + dy);
let before = rect(&h, Group::Layers);
let room = (props.height() - Group::Properties.min_height()).min(15.0);
drag(&mut h, at, at - vec2(0.0, 15.0));
let after = rect(&h, Group::Layers);
assert!((after.height() - (before.height() + room)).abs() < 1.0, "{w}×{hgt} dy {dy}: {before:?} -> {after:?}");
}
assert!(rect(&h, Group::Layers).height() > layers.height(), "{w}×{hgt}");
assert!(h.state().ui.dock.heights.contains_key(&Group::Properties), "the new size is in the layout");
// The Properties group never goes below its minimum.
let props = rect(&h, Group::Properties);
let at = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
drag(&mut h, at, at - vec2(0.0, 900.0));
assert_eq!(rect(&h, Group::Properties).height(), Group::Properties.min_height(), "{w}×{hgt}");
}
}
#[test]
fn double_clicking_a_splitter_resets_both_groups() {
let mut h = app(1440.0, 900.0);
let props0 = rect(&h, Group::Properties);
let at = Pos2::new(props0.center().x, props0.bottom() + GAP / 2.0);
drag(&mut h, at, at - vec2(0.0, 60.0));
assert!(rect(&h, Group::Properties).height() < props0.height() - 50.0);
let at = Pos2::new(props0.center().x, rect(&h, Group::Properties).bottom() + GAP / 2.0);
for _ in 0..2 {
press(&mut h, at);
release(&mut h, at);
}
h.run_steps(3);
assert_eq!(rect(&h, Group::Properties), props0, "back to the default height");
assert!(!h.state().ui.dock.heights.contains_key(&Group::Properties));
}
+1
View File
@@ -42,6 +42,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
- `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen); `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing)
- `ui.move {x, y}` / `ui.click {x, y, button?, count?}` / `ui.press {x, y, button?}` / `ui.release {x, y, button?}`: synthetic pointer input in window coordinates (points). `ui.press` and `ui.release` are the two halves of a click, so a drag can be held across calls with `ui.move` in between (for example to drag a dock splitter and take a `ui.screenshot` mid-drag)
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
- `ui.resize {width, height}`: resize the main window