mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 20:56:50 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e3fc83c9e |
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user