diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 67552bc3..185780cf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -606,6 +606,9 @@ pub(crate) const GPU_OUTPUT: u32 = u32::MAX; /// The document to render: the committed one, or a clone with the live adjustment preview applied. fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc, u64) { + if let Some(shown) = crate::type_transform::display_doc(app, idx) { + return shown; + } // Puppet / Perspective Warp previews hide the layer they draw on a mesh. if let Some(shown) = crate::distort_ui::display_doc(app, idx) { return shown; @@ -810,6 +813,12 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6 if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 { return last_damage; } + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::type_transform::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1) + { + return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); + } // Between an adjustment dialog's previews (and the document): the target's area. if seen.0 == now.0 && let Some(st) = app.session.documents().get(idx) @@ -2009,7 +2018,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: d.reposition = reposition; drawing = true; } - let temporary = crate::hold_keys::for_frame(app, &ctx, drawing); + let temporary = crate::hold_keys::for_frame(app, &ctx, drawing || crate::type_transform::active(app)); let space_pan = temporary == Some(crate::hold_keys::Temporary::Hand); let middle = ui.input(|i| i.pointer.middle_down()); let tool = match temporary { @@ -2064,6 +2073,26 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // (Preferences › Tools, `paint_mouse`). crate::paint_mouse::sync_tool_smoothing(app); let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool); + // Capture temporary Type transforms at the actual press, before egui's drag threshold. + // Releasing Command before the first recognised move must not turn it into text selection. + let type_press = egui::Id::new("type-pointer-press-modifiers"); + if tool.is_type() + && let Some((p, press_mods)) = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers } if rect.contains(*pos) => { + Some((*pos, *modifiers)) + } + _ => None, + }) + }) + { + let press_mods = crate::workspace_ui::sticky_mods(app, press_mods); + ctx.data_mut(|d| d.insert_temp(type_press, press_mods)); + if press_mods.command && app.ui.text_edit.is_some() { + let d = xf.to_doc(p); + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); + } + } // Right-click while transforming: switch the box's mode (Free Transform, Scale, Rotate, // Skew, Distort, Perspective). let transforming = app.ui.transform.as_ref().is_some_and(|t| t.warp.is_none()); @@ -2115,7 +2144,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: begin_transform_controls_at(app, &ctx, &xf, p); } let d = xf.to_doc(p); - tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, mods); + let press_mods = if tool.is_type() { ctx.data(|d| d.get_temp(type_press)).unwrap_or(mods) } else { mods }; + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } if buttons.dragged || buttons.stopped { // Feed every pointer move the OS delivered this frame, not just the latest position, so @@ -2211,7 +2241,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.ui.transform.is_some() && response.double_clicked() { crate::transform_tool::commit(app); } - if tool.is_type() && response.double_clicked() { + if tool.is_type() && response.double_clicked() && !crate::type_transform::visible(app, crate::workspace_ui::sticky_mods(app, mods)) { crate::type_tool::select_word(app); } if app.ui.extras.grid && app.ui.view.extras { @@ -2247,6 +2277,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt))) { ui.ctx().set_cursor_icon(c); + } else if let Some(c) = response.hover_pos().filter(|_| tool.is_type()).and_then(|p| { + let mods = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)); + crate::type_transform::cursor(app, &ctx, &xf, p, mods) + }) { + ui.ctx().set_cursor_icon(c); } else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| crate::crop_ui::cursor(app, xf.to_doc(p))) { ui.ctx().set_cursor_icon(c); } else if app.drag.as_ref().is_some_and(|d| d.sel_move.is_some()) @@ -2735,6 +2770,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // consume the event, so it never outlives the press it was armed for (#297). let armed = std::mem::take(&mut app.brush_resize_armed); crate::transform_tool::end_if_left(app); + let mods = crate::workspace_ui::sticky_mods(app, mods); + if app.ui.transform.is_none() && crate::type_transform::pointer(app, ev, mods) { + return; + } // View › Snap / Snap To and smart guides (snap_ui.rs). let raw = ev; // A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b036f759..482e4103 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -626,6 +626,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "toolOptions": app.ui.tool_options, "magnetic": app.ui.magnetic, "textEdit": app.ui.text_edit, + "typeTransform": app.ui.type_transform, "layerMenu": app.ui.layer_menu, "canvasToolMenu": app.ui.canvas_tool_menu.as_ref().map(|menu| { json!({ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a015b259..e110a4a9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -133,6 +133,7 @@ pub mod transform_tex; pub mod transform_tool; pub mod type_panels_ui; pub mod type_tool; +mod type_transform; mod variables_ui; pub mod vector_ui; pub mod view_cmds; @@ -439,6 +440,7 @@ pub struct PhotocraftApp { pub(crate) crop: crop_ui::CropState, /// Type tool layout cache: ((doc, revision, layer), layout). pub(crate) type_layout: Option<((u64, u64, u64), std::sync::Arc)>, + pub(crate) type_transform_preview: Option, /// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels. channel_thumbs: Option<(DocId, std::sync::Weak, Vec)>, /// Channels panel overlays / channel views drawn over the canvas, per document id. @@ -527,6 +529,7 @@ impl PhotocraftApp { channel_thumbs: None, channel_views: HashMap::new(), type_layout: None, + type_transform_preview: None, guide_drag: None, crop: Default::default(), hover_doc: None, @@ -695,6 +698,7 @@ impl PhotocraftApp { /// Keep one view per document, in tab order: a view and its windows stay with their document /// when tabs move (`document.move`) or close. pub fn sync_views(&mut self) { + type_transform::cancel_stale(self); crate::lasso_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9555c80f..40f66299 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -666,6 +666,16 @@ pub struct TextEdit { pub preedit: Option<(usize, usize)>, } +/// Temporary Type-tool drag. The document is unchanged until release; this frame is view state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TypeTransform { + pub document: u64, + pub revision: u64, + pub original: photocraft_geom::Affine, + pub frame: TransformSession, + pub(crate) gesture: crate::transform_tool::Gesture, +} + /// View-menu overlays. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -715,6 +725,12 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// Ctrl/Cmd Type-tool gesture, exposed to automation but never restored as a held pointer. + #[serde(default, skip_deserializing)] + pub type_transform: Option, + /// Reference point for the current type editing session (document coordinates). + #[serde(skip)] + pub type_transform_pivot: Option<[f64; 2]>, /// Free Transform session, if any. #[serde(default)] pub transform: Option, @@ -858,6 +874,8 @@ impl Default for UiState { tool: Tool::Brush, recent_files: Vec::new(), text_edit: None, + type_transform: None, + type_transform_pivot: None, transform: None, mask_target: false, vector_mask_target: false, diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 22767d4f..8e0bba1c 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -641,8 +641,8 @@ fn step(app: &mut PhotocraftApp, redo: bool) -> serde_json::Value { } /// Which part of the box a document point hits. -#[derive(Clone, Copy, Debug, PartialEq)] -enum Hit { +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub(crate) enum Hit { Corner(usize), Edge(usize), Pivot, @@ -652,7 +652,7 @@ enum Hit { /// The nearest handle within `tol` (on a small box their grab areas overlap), else inside or /// outside the box. -fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { +pub(crate) fn hit(t: &TransformSession, p: [f64; 2], tol: f64) -> Hit { let d = |a: [f64; 2]| ((a[0] - p[0]).powi(2) + (a[1] - p[1]).powi(2)).sqrt(); let mid = |i: usize| { let (a, b) = (t.quad[i], t.quad[(i + 1) % 4]); @@ -679,12 +679,12 @@ fn inside(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool { } /// Transient drag state (kept in egui memory; not part of the serialised session). -#[derive(Clone, Copy, Debug)] -struct Gesture { - hit: Hit, - start: [f64; 2], - quad0: [[f64; 2]; 4], - pivot0: [f64; 2], +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub(crate) struct Gesture { + pub(crate) hit: Hit, + pub(crate) start: [f64; 2], + pub(crate) quad0: [[f64; 2]; 4], + pub(crate) pivot0: [f64; 2], } /// Pointer input while transforming. Returns false when no transform is active. @@ -787,7 +787,7 @@ fn keep_convex(from: [[f64; 2]; 4], to: [[f64; 2]; 4]) -> [[f64; 2]; 4] { lerp(lo) } -fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Modifiers) { +pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Modifiers) { let (dx, dy) = (p[0] - g.start[0], p[1] - g.start[1]); match g.hit { Hit::Inside => { diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index bfd4f418..d4f90d0e 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -34,15 +34,16 @@ pub fn layout(app: &mut PhotocraftApp, id: LayerId) -> Option<(Arc, let st = app.session.active()?; let (doc, rev) = (st.doc.clone(), st.revision); let t = text_layer(&doc, id)?; + let transform = crate::type_transform::current_transform(app, id).unwrap_or(t.transform); let key = (doc.id.0, rev, id.0); if let Some((k, l)) = &app.type_layout && *k == key { - return Some((l.clone(), t.transform, t.text.clone())); + return Some((l.clone(), transform, t.text.clone())); } let l = Arc::new(photocraft_text::shared().lock().ok()?.layout(t, doc.resolution_dpi)); app.type_layout = Some((key, l.clone())); - Some((l, t.transform, t.text.clone())) + Some((l, transform, t.text.clone())) } fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { @@ -145,7 +146,7 @@ fn handle_sides(i: u8) -> (bool, bool, bool, bool) { } } -fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { +pub(crate) fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape))? { photocraft_doc::text::TextShape::Box { x, y, width, height } => Some((x, y, width, height)), _ => None, @@ -153,7 +154,7 @@ fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { } /// The paragraph-box handle under the document point, if the layer is paragraph text. -fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> Option { +pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> Option { let (bx, by, w, h) = box_shape(app, id)?; let (_, aff, _) = layout(app, id)?; let (r, b) = (bx + w, by + h); @@ -300,6 +301,7 @@ fn insert(app: &mut PhotocraftApp, s: &str) { if a == b && s.is_empty() { return; } + crate::type_transform::finish(app); if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": a, "end": b, "text": s}, "coalesce": ed.session})).is_ok() && let Some(e) = app.ui.text_edit.as_mut() { @@ -317,6 +319,7 @@ fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) { if before.is_none_or(|c| c == '\n') || after.is_none_or(|c| c == '\n') { return; } + crate::type_transform::finish(app); let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}})); } @@ -338,6 +341,7 @@ fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { return; } let len = s.chars().count(); + crate::type_transform::finish(app); if app.run("type.edit", json!({"layer": ed.layer, "replace": {"start": start, "end": end, "text": s}, "coalesce": ed.session})).is_ok() && let Some(e) = app.ui.text_edit.as_mut() { @@ -407,6 +411,10 @@ fn line_edge(app: &mut PhotocraftApp, id: LayerId, caret: usize, end: bool) -> u /// Keyboard input while editing. Returns true when a type edit session is active (single-key /// tool shortcuts must then be skipped). Handled events are removed from the frame's input. pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + crate::type_transform::cancel_stale(app); + if !ctx.input(|i| i.focused) { + crate::type_transform::cancel_drag(app); + } let Some(ed) = app.ui.text_edit.clone() else { return false }; let id = LayerId(ed.layer); let Some(text) = current_text(app, id) else { @@ -511,6 +519,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } Key::Enter if m.command => commit(app), Key::Enter => insert(app, "\n"), + Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), Key::Escape => commit(app), Key::A if m.command => { if let Some(e) = app.ui.text_edit.as_mut() { @@ -524,7 +533,12 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { let _ = crate::menus::invoke(app, ctx, "window.panel.character", json!({})); } // Other command shortcuts (⌘Z, ⌘S, …) pass through to the menus. - _ if m.command => handled[k] = false, + _ if m.command => { + if *key == Key::Z { + crate::type_transform::cancel_drag(app); + } + handled[k] = false; + } _ => {} } } @@ -547,6 +561,10 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { /// End the editing session. A new layer left empty is deleted; a new layer is named after its text. pub fn commit(app: &mut PhotocraftApp) { + if app.ui.tool.is_type() { + crate::type_transform::finish(app); + } + crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; let Some(text) = current_text(app, LayerId(ed.layer)) else { return }; if text.trim().is_empty() && ed.created { @@ -583,6 +601,10 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX }); } // Frame: paragraph text shows its box with handles; point text an underline per line. + let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers)); + if crate::type_transform::draw(app, painter, xf, mods) { + return; + } let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape)); let frame = Stroke::new(1.0, t.accent); match shape { @@ -1346,6 +1368,7 @@ fn type_options(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// Cancel the editing session: undo it back to where it started (removes a new layer). pub fn cancel(app: &mut PhotocraftApp) { + crate::type_transform::reset(app); let Some(ed) = app.ui.text_edit.take() else { return }; let coalesced = app.session.active().is_some_and(|s| s.coalesce.as_deref() == Some(ed.session.as_str())); if coalesced { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 9c8d4c86..65e87ceb 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -15,7 +15,13 @@ use crate::PhotocraftApp; use crate::canvas::ViewXform; fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { - let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state( + make_harness(ppp, app, false) +} + +fn make_harness(ppp: f32, app: PhotocraftApp, render: bool) -> Harness<'static, PhotocraftApp> { + let builder = Harness::builder().with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp); + let builder = if render { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( |ui, app: &mut PhotocraftApp| { let ctx = ui.ctx().clone(); if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -452,3 +458,586 @@ fn command_t_while_typing_toggles_the_character_panel() { assert!(h.state().ui.text_edit.is_some(), "still editing"); assert_ne!(crate::view_cmds::checked(h.state(), "window.panel.character"), before, "the Character panel toggled"); } + +fn type_command() -> Modifiers { + Modifiers { command: true, ctrl: true, ..Modifiers::NONE } +} + +fn hold(h: &mut Harness<'static, PhotocraftApp>, mods: Modifiers) { + h.event(egui::Event::ModifiersChanged(mods)); + h.run_steps(1); +} + +fn transform_press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, mods: Modifiers) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: mods }); + h.run_steps(1); +} + +fn transform_drag(h: &mut Harness<'static, PhotocraftApp>, a: Pos2, b: Pos2, mods: Modifiers, release_command: bool) { + hold(h, mods); + h.hover_at(a); + h.run_steps(1); + transform_press(h, a, true, mods); + for k in 1..=6 { + if release_command && k == 4 { + hold(h, Modifiers { command: false, ctrl: false, mac_cmd: false, ..mods }); + } + h.hover_at(a + (b - a) * (k as f32 / 6.0)); + h.run_steps(1); + } + transform_press(h, b, false, if release_command { Modifiers::NONE } else { mods }); + hold(h, Modifiers::NONE); +} + +fn transform_app(paragraph: bool, vertical: bool, transform: Affine) -> (PhotocraftApp, LayerId) { + let mut app = new_app(); + let mut p = json!({"text": "Hello é世界\nSecond line", "size": 32, "x": 300, "y": 420}); + if paragraph { + p["box"] = json!([300, 300, 240, 180]); + } + if vertical { + p["orientation"] = json!("vertical"); + } + let id = LayerId(app.run("type.create", p).unwrap()["layer"].as_u64().unwrap()); + app.run("type.edit", json!({"layer": id.0, "transform": transform.m})).unwrap(); + super::edit_active(&mut app).unwrap(); + let ed = app.ui.text_edit.as_mut().unwrap(); + ed.caret = 3; + ed.anchor = 3; + (app, id) +} + +fn frame_point(app: &PhotocraftApp, p: [f64; 2]) -> Pos2 { + xf(app).to_screen(p[0] as f32, p[1] as f32) +} + +fn assert_affine(a: Affine, b: Affine) { + for (x, y) in a.m.into_iter().zip(b.m) { + assert!((x - y).abs() < 2e-4, "{a:?} != {b:?}"); + } +} + +/// #1091: modifier changes alone draw/hide the oriented frame without touching editing/history. +#[test] +fn temporary_type_transform_visibility_and_cursors() { + let (app, id) = transform_app(false, false, Affine::translate(500.0, 350.0)); + let mut h = harness(1.0, app); + let before = h.state().session.active().unwrap().history.entries().len(); + let original = text(h.state(), id); + assert!(!crate::type_transform::visible(h.state(), Modifiers::NONE)); + hold(&mut h, type_command()); + assert!(crate::type_transform::visible(h.state(), h.ctx.input(|i| i.modifiers))); + let frame = crate::type_transform::frame(h.state_mut()).unwrap(); + let mut positions: Vec<[f64; 2]> = frame.quad.to_vec(); + positions.extend((0..4).map(|i| [(frame.quad[i][0] + frame.quad[(i + 1) % 4][0]) / 2.0, (frame.quad[i][1] + frame.quad[(i + 1) % 4][1]) / 2.0])); + for (i, q) in positions.into_iter().enumerate() { + let p = frame_point(h.state(), q); + h.hover_at(p); + h.run_steps(2); + let cursor = h.output().platform_output.cursor_icon; + if i < 4 { + assert!(matches!( + cursor, + egui::CursorIcon::ResizeNwSe | egui::CursorIcon::ResizeNeSw | egui::CursorIcon::ResizeHorizontal | egui::CursorIcon::ResizeVertical + )); + } else { + assert_eq!(cursor, egui::CursorIcon::None, "skew arrowhead"); + } + } + let middle = [(frame.quad[0][0] + frame.quad[1][0]) / 2.0, frame.quad[0][1] - 20.0 / f64::from(h.state().current_zoom())]; + h.hover_at(frame_point(h.state(), middle)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "curved rotation cursor"); + hold(&mut h, Modifiers::NONE); + assert!(!crate::type_transform::visible(h.state(), Modifiers::NONE)); + assert_eq!(selection(&h), (3, 3)); + assert_eq!(text(h.state(), id).transform, original.transform); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), before); + assert!(h.state().ui.transform.is_none()); + assert!(h.state().ui.type_transform.is_none()); + // macOS's real Control is not the logical Command modifier. + assert!(!crate::type_transform::visible(h.state(), Modifiers { ctrl: true, command: false, ..Modifiers::NONE })); + h.state_mut().ui.shell.sticky_command = true; + let sticky = crate::workspace_ui::sticky_mods(h.state(), Modifiers::NONE); + assert!(crate::type_transform::visible(h.state(), sticky)); +} + +/// Real canvas input: layer-local scaling stays correct across orientation, zoom, HiDPI and flip. +#[test] +fn temporary_type_transform_scales_without_reflow_or_leaving_type() { + for ppp in [1.0, 2.0] { + for zoom in [0.25, 1.0, 2.0, 8.0] { + for (paragraph, vertical, flip) in [(false, false, false), (true, false, true), (false, true, true)] { + let transform = Affine { m: [1.2, 0.25, 0.18, 0.9, 500.0, 360.0] }; + let (app, id) = transform_app(paragraph, vertical, transform); + let mut h = harness(ppp, app); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let center = f.pivot; + h.state_mut().ui.view.flip_horizontal = flip; + let view = &mut h.state_mut().ui.views[0]; + // At high zoom, show the dragged corner; a release beyond the canvas is valid. + view.center = if zoom >= 2.0 { [f.quad[2][0] as f32, f.quad[2][1] as f32] } else { [center[0] as f32 + 17.0, center[1] as f32 - 9.0] }; + view.zoom = zoom; + view.fit_pending = false; + h.run_steps(2); + let before = text(h.state(), id); + let count = h.state().session.active().unwrap().history.entries().len(); + let (a, b) = (f.quad[2], [f.quad[0][0] + 1.25 * (f.quad[2][0] - f.quad[0][0]), f.quad[0][1] + 1.25 * (f.quad[2][1] - f.quad[0][1])]); + let (a, b) = (frame_point(h.state(), a), frame_point(h.state(), b)); + assert!(xf(h.state()).rect.contains(a)); + transform_drag(&mut h, a, b, type_command() | Modifiers::SHIFT, true); + let after = text(h.state(), id); + assert_affine( + after.transform, + transform.mul(&Affine::translate(f.rect[0], f.rect[1])).mul(&Affine::scale(1.25)).mul(&Affine::translate(-f.rect[0], -f.rect[1])), + ); + assert_eq!( + (after.text.as_str(), &after.runs, &after.paragraphs, after.orientation, after.shape), + (before.text.as_str(), &before.runs, &before.paragraphs, before.orientation, before.shape) + ); + assert_eq!(selection(&h), (3, 3)); + assert_eq!(h.state().ui.text_edit.as_ref().unwrap().layer, id.0); + assert!(h.state().ui.tool.is_type()); + assert!(h.state().ui.transform.is_none()); + assert!(h.state().ui.type_transform.is_none()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), count + 1); + h.event(egui::Event::Text("!".into())); + h.run_steps(2); + assert!(text(h.state(), id).text.starts_with("Hel!lo")); + h.key_press(if vertical { egui::Key::ArrowUp } else { egui::Key::ArrowLeft }); + h.run_steps(1); + assert_eq!(selection(&h), (3, 3)); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), count + 1, "one edit session"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_affine(text(h.state(), id).transform, before.transform); + assert_eq!(text(h.state(), id).text, before.text); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(text(h.state(), id).transform, after.transform); + } + } + } +} + +#[test] +fn temporary_type_transform_free_scale_center_scale_move_skew_and_rotation() { + use crate::canvas::{ToolEvent, tool_event}; + for gesture in ["free", "center", "move", "skew", "rotate", "snap"] { + let initial = Affine::translate(400.0, 350.0).mul(&Affine::rotate(0.2)); + let (mut app, id) = transform_app(true, false, initial); + let frame = crate::type_transform::frame(&mut app).unwrap(); + let before = text(&app, id); + let (a, b, mods) = match gesture { + "move" => (frame.quad[0].map(|v| v + 20.0), frame.quad[0].map(|v| v + 45.0), type_command()), + "skew" => { + let p = [(frame.quad[0][0] + frame.quad[1][0]) / 2.0, (frame.quad[0][1] + frame.quad[1][1]) / 2.0]; + (p, [p[0] + 30.0, p[1] + 12.0], type_command()) + } + "rotate" | "snap" => { + let a = [frame.quad[1][0] + 20.0, frame.quad[1][1] - 10.0]; + let da: f64 = 0.42; + let (x, y) = (a[0] - frame.pivot[0], a[1] - frame.pivot[1]); + let b = [frame.pivot[0] + x * da.cos() - y * da.sin(), frame.pivot[1] + x * da.sin() + y * da.cos()]; + (a, b, if gesture == "snap" { type_command() | Modifiers::SHIFT } else { type_command() }) + } + _ => { + let a = frame.quad[2]; + let local = [frame.rect[2] + 60.0, frame.rect[3] + 18.0]; + let p = initial.apply(Point::new(local[0], local[1])); + (a, [p.x, p.y], if gesture == "center" { type_command() | Modifiers::ALT } else { type_command() }) + } + }; + let count = app.session.active().unwrap().history.entries().len(); + tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, mods); + for k in 1..=5 { + let f = f64::from(k) / 5.0; + tool_event(&mut app, ToolEvent::Move { x: a[0] + (b[0] - a[0]) * f, y: a[1] + (b[1] - a[1]) * f, pressure: 1.0 }, mods); + assert!(crate::type_transform::display_doc(&mut app, 0).is_some()); + assert_eq!(text(&app, id).transform, initial, "preview leaves engine untouched"); + } + assert_eq!(app.session.active().unwrap().history.entries().len(), count); + tool_event(&mut app, ToolEvent::Up { x: b[0], y: b[1] }, mods); + let after = text(&app, id); + assert_ne!(after.transform, initial, "{gesture}"); + assert_eq!(after.shape, before.shape, "no paragraph reflow"); + assert_eq!(selection(&harness(1.0, app)), (3, 3)); + // Assertions below use the matrix itself to check the chosen gesture. + if gesture == "move" { + assert_affine(after.transform, Affine::translate(25.0, 25.0).mul(&initial)); + } else if gesture == "rotate" { + assert_affine( + after.transform, + Affine::translate(frame.pivot[0], frame.pivot[1]) + .mul(&Affine::rotate(0.42)) + .mul(&Affine::translate(-frame.pivot[0], -frame.pivot[1])) + .mul(&initial), + ); + } else if gesture == "snap" { + let angle = after.transform.m[1].atan2(after.transform.m[0]).to_degrees(); + assert!((angle / 15.0 - (angle / 15.0).round()).abs() < 1e-8); + } else if gesture == "center" { + let p = after.transform.apply(Point::new((frame.rect[0] + frame.rect[2]) / 2.0, (frame.rect[1] + frame.rect[3]) / 2.0)); + assert!((p.x - frame.pivot[0]).abs() < 1e-8 && (p.y - frame.pivot[1]).abs() < 1e-8); + } else if gesture == "free" { + let ratio_x = after.transform.m[0].hypot(after.transform.m[1]); + let ratio_y = after.transform.m[2].hypot(after.transform.m[3]); + assert!((ratio_x - ratio_y).abs() > 0.05, "unconstrained corners"); + } else if gesture == "skew" { + assert_ne!(after.transform.m[2], before.transform.m[2]); + } + } +} + +#[test] +fn temporary_type_transform_noop_cancel_and_stale_capture() { + use crate::canvas::{ToolEvent, tool_event}; + for stop in ["noop", "escape", "focus", "tool", "undo", "delete", "document", "close"] { + let original = Affine::translate(400.0, 350.0); + let (app, id) = transform_app(false, false, original); + let mut h = harness(1.0, app); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let a = f.quad[2]; + let count = h.state().session.active().unwrap().history.entries().len(); + tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + tool_event(h.state_mut(), ToolEvent::Move { x: a[0] + 50.0, y: a[1] + 20.0, pressure: 1.0 }, type_command()); + let preview = { + let (shown, _) = crate::type_transform::display_doc(h.state_mut(), 0).unwrap(); + std::sync::Arc::downgrade(&shown) + }; + assert_eq!(text(h.state(), id).transform, original); + match stop { + "noop" => tool_event(h.state_mut(), ToolEvent::Up { x: a[0], y: a[1] }, Modifiers::NONE), + "escape" => { + h.key_press(egui::Key::Escape); + h.run_steps(2); + } + "focus" => { + h.input_mut().focused = false; + h.run_steps(2); + } + "tool" => { + h.state_mut().ui.tool = crate::state::Tool::Brush; + h.state_mut().sync_views(); + } + "undo" => { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + "delete" => { + h.state_mut().run("layer.delete", json!({"layer": id.0})).unwrap(); + } + "close" => { + h.state_mut().run("file.close", json!({})).unwrap(); + } + _ => { + h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap(); + } + } + assert!(h.state().ui.type_transform.is_none(), "{stop}"); + assert!(preview.upgrade().is_none(), "{stop}: release rendered preview without waiting for another frame"); + if ["noop", "escape", "focus", "tool"].contains(&stop) { + assert_eq!(text(h.state(), id).transform, original); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), count); + } + if stop == "escape" { + assert!(h.state().ui.text_edit.is_some()); + } + } +} + +#[test] +fn temporary_type_transform_control_roundtrip_and_input_safety() { + use crate::control::{ControlRequest, Outcome}; + let initial = Affine::translate(400.0, 350.0); + let (mut app, id) = transform_app(true, false, initial); + // A document selection must not restrict a text-layer transformation. + app.run("select.rect", json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap(); + let frame = crate::type_transform::frame(&mut app).unwrap(); + let a = frame.quad[2]; + let ctx = egui::Context::default(); + let call = |app: &mut PhotocraftApp, params| { + let (req, _) = ControlRequest::new("ui.pointer", params); + let Outcome::Done(result) = crate::control::handle(app, &ctx, &req) else { panic!("expected immediate pointer reply") }; + assert_eq!(result["ok"], true, "{result}"); + }; + call(&mut app, json!({"command": true, "events": [{"kind": "down", "x": a[0], "y": a[1]}]})); + let inspected = crate::control::inspect(&app, &ctx); + assert_eq!(inspected["typeTransform"]["frame"]["layer"], id.0); + call(&mut app, json!({"command": true, "events": [{"kind": "move", "x": a[0]+60.0, "y": a[1]+30.0}]})); + call(&mut app, json!({"events": [{"kind": "up", "x": a[0]+60.0, "y": a[1]+30.0}]})); + assert_ne!(text(&app, id).transform, initial); + let doc = &app.session.active().unwrap().doc; + for name in ["type.pcraft", "type.psd"] { + let out = photocraft_io::export(doc, name, &Default::default()).unwrap(); + let back = photocraft_io::import(name, &out.bytes).unwrap().document; + let t = back.walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Text(t) = &l.content { Some(t) } else { None }).unwrap(); + assert_affine(t.transform, text(&app, id).transform); + assert_eq!(t.text, text(&app, id).text); + assert_eq!(t.shape, text(&app, id).shape); + } + for content in ["", " ", "é👩‍💻世界", "\n\n"] { + app.run("type.edit", json!({"layer": id.0, "text": content, "point": [400, 350]})).unwrap(); + assert!(crate::type_transform::frame(&mut app).is_some()); + call( + &mut app, + json!({"command": true, "events": [{"kind": "down", "x": 400, "y": 350}, {"kind": "move", "x": 1e300, "y": -1e300}, {"kind": "up", "x": 400, "y": 350}]}), + ); + assert!(text(&app, id).transform.m.iter().all(|v| v.is_finite())); + assert!(app.ui.type_transform.is_none()); + } + app.run("layer.lockLayers", json!({"position": true})).unwrap(); + assert!(crate::type_transform::frame(&mut app).is_none()); +} + +#[test] +fn temporary_type_transform_preserves_depth_and_color_model() { + use crate::canvas::{ToolEvent, tool_event}; + for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (16, "gray"), (8, "cmyk"), (16, "lab")] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 1200, "height": 900, "depth": depth, "mode": mode})).unwrap(); + let id = LayerId(app.run("type.create", json!({"text": "PhotoCraft é世界", "size": 32, "x": 300, "y": 350})).unwrap()["layer"].as_u64().unwrap()); + super::edit_active(&mut app).unwrap(); + let format = app.session.active().unwrap().doc.pixel_format(); + let a = crate::type_transform::frame(&mut app).unwrap().quad[2]; + tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + tool_event(&mut app, ToolEvent::Move { x: a[0] + 30.0, y: a[1] + 15.0, pressure: 1.0 }, type_command()); + let (shown, _) = crate::type_transform::display_doc(&mut app, 0).unwrap(); + assert_eq!(shown.pixel_format(), format); + assert_eq!(text_layer(&shown, id).unwrap().cache.as_ref().unwrap().format(), format); + tool_event(&mut app, ToolEvent::Up { x: a[0] + 30.0, y: a[1] + 15.0 }, Modifiers::NONE); + assert_eq!(text(&app, id).cache.as_ref().unwrap().format(), format); + assert_eq!(text(&app, id).text, "PhotoCraft é世界"); + } +} + +#[test] +fn temporary_type_transform_preserves_paragraph_resize_pivot_ime_and_shortcuts() { + use crate::canvas::{ToolEvent, tool_event}; + let (app, id) = transform_app(true, false, Affine::translate(350.0, 300.0)); + let mut h = harness(1.0, app); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let before = text(h.state(), id); + let (a, b) = (frame_point(h.state(), f.quad[2]), frame_point(h.state(), [f.quad[2][0] + 40.0, f.quad[2][1] + 20.0])); + h.hover_at(a); + h.run_steps(2); + assert!(matches!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe | egui::CursorIcon::ResizeNeSw)); + drag(&mut h, a, b); + let after = text(h.state(), id); + assert_ne!(after.shape, before.shape); + assert_eq!(after.transform, before.transform, "plain drag resizes the frame"); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let count = h.state().session.active().unwrap().history.entries().len(); + let pivot = [f.pivot[0] + 45.0, f.pivot[1] - 15.0]; + tool_event(h.state_mut(), ToolEvent::Down { x: f.pivot[0], y: f.pivot[1], pressure: 1.0 }, type_command()); + tool_event(h.state_mut(), ToolEvent::Up { x: pivot[0], y: pivot[1] }, type_command()); + assert_eq!(h.state().ui.type_transform_pivot, Some(pivot)); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), count, "moving the pivot is view state"); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.run_steps(2); + assert_eq!(selection(&h), (0, after.text.chars().count())); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "候補".into(), active_range_chars: None })); + h.run_steps(2); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let a = f.quad[2]; + tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + tool_event(h.state_mut(), ToolEvent::Up { x: a[0] + 20.0, y: a[1] + 10.0 }, type_command()); + assert_eq!(h.state().ui.text_edit.as_ref().unwrap().preedit, Some((0, 2))); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let a = f.quad[2]; + tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + tool_event(h.state_mut(), ToolEvent::Move { x: a[0] + 15.0, y: a[1] + 10.0, pressure: 1.0 }, type_command()); + let composed_transform = crate::type_transform::current_transform(h.state(), id).unwrap(); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("確定".into()))); + h.run_steps(2); + assert_eq!(text(h.state(), id).text, "確定"); + assert_affine(text(h.state(), id).transform, composed_transform); + assert!(h.state().ui.type_transform.is_none(), "composition commits a pending drag before editing"); + assert!(h.state().ui.text_edit.as_ref().unwrap().preedit.is_none()); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::Enter); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none()); + super::edit_active(h.state_mut()).unwrap(); + assert!(h.state().ui.text_edit.is_some()); + super::commit(h.state_mut()); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::T); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "outside typing: ordinary Free Transform"); +} + +#[test] +fn temporary_type_transform_captures_press_and_rejects_collapsed_geometry() { + let (app, id) = transform_app(false, false, Affine::translate(450.0, 350.0)); + let mut h = harness(1.0, app); + let frame = crate::type_transform::frame(h.state_mut()).unwrap(); + let a = frame_point(h.state(), frame.quad[2]); + let b = a + vec2(50.0, 20.0); + hold(&mut h, type_command()); + h.hover_at(a); + transform_press(&mut h, a, true, type_command()); + assert!(h.state().ui.type_transform.is_some(), "capture before any movement"); + hold(&mut h, Modifiers::NONE); + h.hover_at(b); + h.run_steps(2); + transform_press(&mut h, b, false, Modifiers::NONE); + assert_ne!(text(h.state(), id).transform, Affine::translate(450.0, 350.0)); + assert_eq!(selection(&h), (3, 3)); + let frame = crate::type_transform::frame(h.state_mut()).unwrap(); + let before = text(h.state(), id).transform; + use crate::canvas::{ToolEvent, tool_event}; + let (a, b) = (frame.quad[2], frame.quad[0]); + tool_event(h.state_mut(), ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + tool_event(h.state_mut(), ToolEvent::Up { x: b[0], y: b[1] }, type_command()); + assert_eq!(text(h.state(), id).transform, before, "collapsed scale is rejected"); + let mut saved = serde_json::to_value(&h.state().ui).unwrap(); + saved["type_transform"] = json!({"invalid": "restored pointer capture"}); + let restored: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(restored.type_transform.is_none()); +} + +/// Render on demand with the same offscreen wgpu infrastructure as the snapshot example. +#[test] +#[ignore = "writes visual evidence to target/issue-1091; run explicitly for visual QA"] +fn temporary_type_transform_visual_evidence() { + let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/issue-1091"); + std::fs::create_dir_all(&out).unwrap(); + for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { + let (app, _) = transform_app(false, false, Affine::translate(450.0, 350.0)); + let mut h = make_harness(if index == 0 { 2.0 } else { 1.0 }, app, true); + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(4); + h.render().unwrap().save(out.join(format!("{index}-normal.png"))).unwrap(); + hold(&mut h, type_command()); + h.render().unwrap().save(out.join(format!("{index}-handles.png"))).unwrap(); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let mut handles = f.quad.to_vec(); + handles.extend((0..4).map(|i| [(f.quad[i][0] + f.quad[(i + 1) % 4][0]) / 2.0, (f.quad[i][1] + f.quad[(i + 1) % 4][1]) / 2.0])); + for (i, q) in handles.into_iter().enumerate() { + h.hover_at(frame_point(h.state(), q)); + h.run_steps(2); + h.render().unwrap().save(out.join(format!("{index}-handle-{i}.png"))).unwrap(); + } + let a = frame_point(h.state(), f.quad[2]); + transform_press(&mut h, a, true, type_command()); + h.hover_at(a + vec2(70.0, 35.0)); + h.run_steps(2); + h.render().unwrap().save(out.join(format!("{index}-scaling.png"))).unwrap(); + crate::type_transform::cancel_drag(h.state_mut()); + transform_press(&mut h, a + vec2(70.0, 35.0), false, type_command()); + h.state_mut().ui.type_transform_pivot = None; + let ed = h.state().ui.text_edit.as_ref().unwrap().layer; + h.state_mut().run("type.edit", json!({"layer": ed, "transform": Affine::translate(450.0, 350.0).mul(&Affine::rotate(-0.3)).m})).unwrap(); + h.run_steps(3); + h.render().unwrap().save(out.join(format!("{index}-rotated.png"))).unwrap(); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let q = [f.quad[1][0] + 20.0, f.quad[1][1] - 15.0]; + let a = frame_point(h.state(), q); + h.hover_at(a); + h.run_steps(2); + transform_press(&mut h, a, true, type_command()); + let b = frame_point(h.state(), [q[0] + 35.0, q[1] + 60.0]); + h.hover_at(b); + h.run_steps(2); + h.render().unwrap().save(out.join(format!("{index}-rotating.png"))).unwrap(); + transform_press(&mut h, b, false, type_command()); + h.state_mut().run("type.edit", json!({"layer": ed, "box": [400, 300, 260, 180]})).unwrap(); + crate::type_transform::reset(h.state_mut()); + hold(&mut h, Modifiers::NONE); + h.run_steps(3); + h.render().unwrap().save(out.join(format!("{index}-paragraph.png"))).unwrap(); + hold(&mut h, type_command()); + h.render().unwrap().save(out.join(format!("{index}-paragraph-transform.png"))).unwrap(); + } +} + +#[test] +fn temporary_type_transform_imported_psd_retains_editability_and_styles() { + let (mut app, original_id) = transform_app(false, false, Affine { m: [1.5, 0.2, 0.1, 0.8, 400.0, 350.0] }); + app.run("type.setStyle", json!({"layer": original_id.0, "range": [0, 5], "fauxBold": true, "color": "#3366cc", "tracking": 25, "align": "center"})) + .unwrap(); + app.run("layer.renameLayer", json!({"layer": original_id.0, "name": "Styled caption"})).unwrap(); + app.run("layer.layerStyle.dropShadow", json!({"layer": original_id.0, "distance": 8, "size": 4})).unwrap(); + let exported = photocraft_io::export(&app.session.active().unwrap().doc, "type.psd", &Default::default()).unwrap(); + let imported = photocraft_io::import("type.psd", &exported.bytes).unwrap().document; + let mut app = new_app(); + app.session.add_document(imported, None); + app.sync_views(); + let id = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).unwrap().2.id; + app.session.select_layer(id).unwrap(); + super::edit_active(&mut app).unwrap(); + let before = text(&app, id); + assert!(before.runs.len() > 1, "mixed character styles were imported"); + let layer_before = app.session.active().unwrap().doc.layer(id).unwrap().clone(); + assert!(!layer_before.effects.items.is_empty()); + let f = crate::type_transform::frame(&mut app).unwrap(); + use crate::canvas::{ToolEvent, tool_event}; + let a = f.quad[2]; + tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + tool_event(&mut app, ToolEvent::Up { x: a[0] + 35.0, y: a[1] + 10.0 }, type_command()); + let after = text(&app, id); + assert_eq!((after.text.as_str(), &after.runs, &after.paragraphs), (before.text.as_str(), &before.runs, &before.paragraphs)); + assert!(after.psd_raw.is_some()); + let layer_after = app.session.active().unwrap().doc.layer(id).unwrap(); + assert_eq!(layer_after.name, layer_before.name); + assert_eq!(layer_after.effects, layer_before.effects); + assert!(app.ui.text_edit.is_some()); + assert!(app.ui.transform.is_none()); + super::cancel(&mut app); + assert_affine(text(&app, id).transform, before.transform); +} + +#[test] +#[ignore = "release benchmark for issue #1091: 24 MP, eight text layers, 60 preview updates"] +fn temporary_type_transform_large_document_preview() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 6000, "height": 4000})).unwrap(); + for i in 0..8 { + app.run("type.create", json!({"text": "PhotoCraft transform preview", "size": 60, "x": 300, "y": 300 + i * 200})).unwrap(); + } + super::edit_active(&mut app).unwrap(); + let frame = crate::type_transform::frame(&mut app).unwrap(); + let a = frame.quad[2]; + let revision = app.session.active().unwrap().revision; + let doc = app.session.active().unwrap().doc.id; + let index = app.session.active_index().unwrap(); + let count = app.session.active().unwrap().history.entries().len(); + let source = (*app.session.active().unwrap().doc).clone(); + let layer = app.ui.text_edit.as_ref().unwrap().layer; + tool_event(&mut app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, type_command()); + let mut times = Vec::new(); + let mut transforms = Vec::new(); + let mut before = 0; + for i in 1..=60 { + let started = std::time::Instant::now(); + tool_event(&mut app, ToolEvent::Move { x: a[0] + f64::from(i) * 2.0, y: a[1] + f64::from(i), pressure: 1.0 }, type_command()); + let (_, after) = crate::type_transform::display_doc(&mut app, index).unwrap(); + times.push(started.elapsed().as_secs_f64() * 1000.0); + transforms.push(crate::type_transform::current_transform(&app, LayerId(layer)).unwrap()); + let damage = crate::type_transform::damage(&app, doc, revision, before, after).unwrap(); + assert!(i64::from(damage.width()) * i64::from(damage.height()) < 2_000_000, "preview only damages the type layer"); + let (_, same) = crate::type_transform::display_doc(&mut app, index).unwrap(); + assert_eq!(same, after, "stationary pointer reuses the rendered preview"); + before = after; + } + assert_eq!(app.session.active().unwrap().revision, revision); + assert_eq!(app.session.active().unwrap().history.entries().len(), count); + times.sort_by(f64::total_cmp); + println!( + "24 MP / 8 type layers / 60 previews: p50 {:.3} ms, p95 {:.3} ms, max {:.3} ms (render + gesture; composite excluded)", + times[29], times[56], times[59] + ); + // Compare the same matrices with the existing live-command path on the same document. + let mut commands = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + commands.session.add_document(source, None); + let mut command_times = Vec::new(); + for transform in transforms { + let started = std::time::Instant::now(); + commands.run("type.edit", json!({"layer": layer, "transform": transform.m, "coalesce": "1091-bench"})).unwrap(); + command_times.push(started.elapsed().as_secs_f64() * 1000.0); + } + command_times.sort_by(f64::total_cmp); + println!( + "existing type.edit per update: p50 {:.3} ms, p95 {:.3} ms, max {:.3} ms (command + render; composite excluded)", + command_times[29], command_times[56], command_times[59] + ); + crate::type_transform::cancel_drag(&mut app); +} diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs new file mode 100644 index 00000000..8e666d2a --- /dev/null +++ b/crates/ui-egui/src/type_transform.rs @@ -0,0 +1,418 @@ +//! Ctrl/Cmd while typing: an oriented frame and a temporary affine drag, without ending typing. +//! The existing Free Transform geometry is reused with Type's modifier rules (Ctrl exposes the +//! frame; it must never distort a corner). Only release dispatches `type.edit`. + +use std::collections::VecDeque; +use std::sync::Arc; + +use egui::{CursorIcon, Modifiers, Pos2, Stroke, vec2}; +use photocraft_doc::{DocId, Document, LayerContent, LayerId, text::TextShape}; +use photocraft_geom::{Affine, Point, Rect}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; +use crate::state::{TransformSession, TypeTransform}; +use crate::transform_tool::{Gesture, Hit}; + +const PREVIEW_BASE: u64 = 1 << 37; +const ROTATE_PX: f64 = 28.0; + +/// Cached rendered preview and bounded damage history. Unchanged pointer positions render nothing. +pub(crate) struct Preview { + document: DocId, + revision: u64, + transform: Affine, + shown: Option>, + key: u64, + original_area: Rect, + areas: VecDeque<(u64, Rect)>, +} + +pub(crate) fn active(app: &PhotocraftApp) -> bool { + app.ui.type_transform.is_some() +} + +pub(crate) fn visible(app: &PhotocraftApp, mods: Modifiers) -> bool { + app.ui.tool.is_type() && app.ui.text_edit.is_some() && (mods.command || active(app)) +} + +/// Drop pointer capture when its source changed externally. Never apply to another document. +pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { + let Some(t) = &app.ui.type_transform else { return }; + let valid = app.ui.tool.is_type() + && app.ui.text_edit.as_ref().is_some_and(|ed| ed.layer == t.frame.layer) + && app.session.active().is_some_and(|st| { + st.doc.id.0 == t.document + && st.revision == t.revision + && st.doc.layer(LayerId(t.frame.layer)).is_some_and(|l| !l.locks.all && !l.locks.position && matches!(l.content, LayerContent::Text(_))) + }); + if !valid { + let lost_layer = app.session.active().is_none_or(|st| st.doc.id.0 != t.document || st.doc.layer(LayerId(t.frame.layer)).is_none()); + cancel_drag(app); + if lost_layer { + app.ui.text_edit = None; + } + } +} + +pub(crate) fn cancel_drag(app: &mut PhotocraftApp) { + app.ui.type_transform = None; + if let Some(p) = app.type_transform_preview.as_mut() { + // A closed last document has no next canvas frame to release its preview's tiles. + p.shown = None; + } + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.dragging = false; + ed.resize = None; + } +} + +pub(crate) fn reset(app: &mut PhotocraftApp) { + cancel_drag(app); + app.ui.type_transform_pivot = None; +} + +/// Logical text-space bounds, mapped by the layer's existing affine (including rotation/skew). +pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { + if let Some(t) = &app.ui.type_transform { + return Some(t.frame.clone()); + } + let id = LayerId(app.ui.text_edit.as_ref()?.layer); + let st = app.session.active()?; + let layer = st.doc.layer(id)?; + if layer.locks.all || layer.locks.position || !layer.visible { + return None; + } + let LayerContent::Text(text) = &layer.content else { return None }; + let shape = text.shape; + let size = text.size_pt; + let (layout, aff, _) = crate::type_tool::layout(app, id)?; + if !aff.m.iter().all(|v| v.is_finite()) || aff.inverse().is_none() { + return None; + } + let rect = match shape { + TextShape::Box { x, y, width, height } => [x, y, x + width, y + height].map(f64::from), + _ => { + let mut r = layout.bounds().unwrap_or([0.0, -size * layout.px_per_pt, 1.0, 0.0]).map(f64::from); + // Empty lines still have a caret and can be moved/scaled, without inventing glyphs. + r[2] = r[2].max(r[0] + 1.0); + r[3] = r[3].max(r[1] + 1.0); + r + } + }; + if !rect.iter().all(|v| v.is_finite()) || rect[2] <= rect[0] || rect[3] <= rect[1] { + return None; + } + let quad = [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]].map(|q| { + let p = aff.apply(Point::new(q[0], q[1])); + [p.x, p.y] + }); + if !quad.iter().flatten().all(|v| v.is_finite()) { + return None; + } + let pivot = app.ui.type_transform_pivot.unwrap_or([(quad[0][0] + quad[2][0]) / 2.0, (quad[0][1] + quad[2][1]) / 2.0]); + Some(TransformSession { + session: 0, + layer: id.0, + rect, + quad, + pivot, + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: None, + made: None, + mode: Default::default(), + }) +} + +fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { + t.quad.iter().zip(t.quad.iter().cycle().skip(1)).take(4).any(|(a, b)| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx * dx + dy * dy; + let f = if len > 0.0 { ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len } else { 0.0 }.clamp(0.0, 1.0); + (p[0] - a[0] - f * dx).hypot(p[1] - a[1] - f * dy) <= tol + }) +} + +fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option { + let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)); + (h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.current_zoom().max(0.01)))).then_some(h) +} + +/// Type uses legacy scaling: Shift constrains; Ctrl/Cmd is the mode key, never corner Distort. +fn gesture_mods(h: Hit, mut mods: Modifiers) -> Modifiers { + mods.command = matches!(h, Hit::Edge(_)); + mods.ctrl = false; + mods.mac_cmd = false; + if matches!(h, Hit::Corner(_)) { + mods.shift = !mods.shift; + } + mods +} + +fn affine(t: &TypeTransform) -> Option { + if t.frame.quad == t.gesture.quad0 { + return Some(t.original); + } + let [x0, y0, x1, y1] = t.frame.rect; + let [a, b, _, d] = t.frame.quad; + let (w, h) = (x1 - x0, y1 - y0); + if w <= 0.0 || h <= 0.0 { + return None; + } + let (ax, ay, bx, by) = ((b[0] - a[0]) / w, (b[1] - a[1]) / w, (d[0] - a[0]) / h, (d[1] - a[1]) / h); + let out = Affine { m: [ax, ay, bx, by, a[0] - ax * x0 - bx * y0, a[1] - ay * x0 - by * y0] }; + let xs = t.frame.quad.map(|p| p[0]); + let ys = t.frame.quad.map(|p| p[1]); + let width = xs.into_iter().fold(f64::MIN, f64::max) - xs.into_iter().fold(f64::MAX, f64::min); + let height = ys.into_iter().fold(f64::MIN, f64::max) - ys.into_iter().fold(f64::MAX, f64::min); + // Reject collapsed/nonfinite geometry and bound input-sized text raster allocations. + (out.m.iter().all(|v| v.is_finite()) + && out.determinant().abs() >= 1e-8 + && t.frame.quad.iter().flatten().all(|v| v.is_finite() && v.abs() <= 1e7) + && width >= 0.05 + && height >= 0.05 + && width * height <= 64.0 * 1024.0 * 1024.0) + .then_some(out) +} + +pub(crate) fn current_transform(app: &PhotocraftApp, id: LayerId) -> Option { + affine(app.ui.type_transform.as_ref().filter(|t| t.frame.layer == id.0)?) +} + +fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { + if !p.iter().all(|v| v.is_finite() && v.abs() <= 1e7) { + return; + } + let Some(t) = app.ui.type_transform.as_mut() else { return }; + let before = t.frame.clone(); + if p == t.gesture.start { + t.frame.quad = t.gesture.quad0; + t.frame.pivot = t.gesture.pivot0; + } else { + // A handle grabbed off-centre follows the pointer delta, without a jump to its hotspot. + let target = match t.gesture.hit { + Hit::Corner(i) => t.gesture.quad0.get(i).copied(), + Hit::Pivot => Some(t.gesture.pivot0), + _ => None, + }; + let p = target.map_or(p, |q| [q[0] + p[0] - t.gesture.start[0], q[1] + p[1] - t.gesture.start[1]]); + crate::transform_tool::apply_drag(&mut t.frame, t.gesture, p, gesture_mods(t.gesture.hit, mods)); + } + if affine(t).is_none() { + t.frame = before; + } +} + +pub(crate) fn finish(app: &mut PhotocraftApp) { + cancel_stale(app); + let Some(t) = app.ui.type_transform.take() else { return }; + cancel_drag(app); + app.ui.type_transform_pivot = Some(t.frame.pivot); + let Some(transform) = affine(&t) else { return }; + // Pivot-only and out-and-back gestures leave no history entry, even in an untouched session. + if transform.m.iter().zip(t.original.m).all(|(a, b)| (a - b).abs() < 1e-9) { + return; + } + let Some(ed) = app.ui.text_edit.as_ref() else { return }; + let params = json!({"layer": ed.layer, "transform": transform.m, "coalesce": ed.session}); + if app.run("type.edit", params).is_ok() + && let Some(preview) = app.type_transform_preview.take().filter(|p| p.transform == transform) + { + crate::canvas::shown_as_document(app, preview.document, |key| key == preview.key); + } +} + +/// First refusal before snapping, caret selection, paragraph resizing or clicking out to commit. +pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + cancel_stale(app); + if !app.ui.tool.is_type() || app.ui.text_edit.is_none() { + return false; + } + match ev { + ToolEvent::Down { .. } if active(app) => true, + ToolEvent::Down { x, y, .. } if mods.command => { + let p = [x, y]; + if !p.iter().all(|v| v.is_finite()) { + return true; + } + let Some(mut frame) = frame(app) else { return true }; + let Some(h) = hit(app, &frame, p) else { return true }; + let Some((_, original, _)) = crate::type_tool::layout(app, LayerId(frame.layer)) else { return true }; + let Some(st) = app.session.active() else { return true }; + let (document, revision) = (st.doc.id.0, st.revision); + frame.session = app.ui.alloc_id(); + let gesture = Gesture { hit: h, start: p, quad0: frame.quad, pivot0: frame.pivot }; + app.ui.type_transform = Some(TypeTransform { document, revision, original, frame, gesture }); + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.dragging = false; + ed.resize = None; + } + true + } + ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } if active(app) => { + update(app, [x, y], mods); + if matches!(ev, ToolEvent::Up { .. }) { + finish(app); + } + true + } + _ => false, + } +} + +/// Preview via the existing text renderer; all glyphs/styles/effects remain native document data. +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + cancel_stale(app); + if !active(app) { + if let Some(p) = app.type_transform_preview.as_mut() { + // Retain damage bookkeeping for the return to the document, not its rendered tiles. + p.shown = None; + } + return None; + } + let t = app.ui.type_transform.as_ref()?; + let st = app.session.documents().get(idx)?; + if st.doc.id.0 != t.document || st.revision != t.revision { + return None; + } + let transform = affine(t)?; + if transform == t.original { + return None; + } + let (id, revision, source, layer) = (st.doc.id, st.revision, st.doc.clone(), LayerId(t.frame.layer)); + if let Some(p) = &app.type_transform_preview + && p.document == id + && p.revision == revision + && p.transform == transform + && let Some(shown) = &p.shown + { + return Some((shown.clone(), p.key)); + } + let original_area = source.layer(layer)?.surface().map_or(Rect::EMPTY, |s| s.tile_bounds()); + let mut shown = (*source).clone(); + let LayerContent::Text(text) = &mut shown.layer_mut(layer)?.content else { return None }; + text.transform = transform; + let started = crate::gpu_canvas::now_ms(); + photocraft_engine::type_cmds::refresh(&source, text); + let area = text.cache.as_ref().map_or(Rect::EMPTY, |s| s.tile_bounds()); + let shown = Arc::new(photocraft_engine::mode_cmds::display_document(&shown).unwrap_or(shown)); + let key = PREVIEW_BASE | (app.ui.alloc_id() & (PREVIEW_BASE - 1)); + let mut areas = app.type_transform_preview.take().filter(|p| p.document == id && p.revision == revision).map(|p| p.areas).unwrap_or_default(); + if areas.len() >= 128 { + areas.pop_front(); + } + areas.push_back((key, area)); + app.type_transform_preview = Some(Preview { document: id, revision, transform, shown: Some(shown.clone()), key, original_area, areas }); + app.perf.span("type transform preview", crate::gpu_canvas::now_ms() - started); + Some((shown, key)) +} + +pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, before: u64, after: u64) -> Option { + let p = app.type_transform_preview.as_ref().filter(|p| p.document == doc && p.revision == revision)?; + let area = |key| if key == 0 { Some(p.original_area) } else { p.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) }; + Some(area(before)?.union(&area(after)?)) +} + +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, mods: Modifiers) -> bool { + if !visible(app, mods) { + return false; + } + let Some(t) = frame(app) else { return false }; + let tokens = crate::theme::Tokens::get(painter.ctx()); + let pts = t.quad.map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); + painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(3.0, tokens.shadow))); + painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(1.0, tokens.accent))); + let mids = [pts[0].lerp(pts[1], 0.5), pts[1].lerp(pts[2], 0.5), pts[2].lerp(pts[3], 0.5), pts[3].lerp(pts[0], 0.5)]; + for p in pts.iter().chain(mids.iter()) { + let r = egui::Rect::from_center_size(*p, vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, tokens.accent_text); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, tokens.accent), egui::StrokeKind::Inside); + } + let c = xf.to_screen(t.pivot[0] as f32, t.pivot[1] as f32); + painter.circle_stroke(c, 4.0, Stroke::new(2.0, tokens.shadow)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, tokens.accent_text)); + for axis in [vec2(6.0, 0.0), vec2(0.0, 6.0)] { + painter.line_segment([c - axis, c + axis], Stroke::new(1.0, tokens.accent)); + } + true +} + +fn resize_cursor(d: [f64; 2], flip: bool) -> CursorIcon { + let angle = d[1].atan2(if flip { -d[0] } else { d[0] }).rem_euclid(std::f64::consts::PI); + match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { + 0 => CursorIcon::ResizeHorizontal, + 1 => CursorIcon::ResizeNwSe, + 2 => CursorIcon::ResizeVertical, + _ => CursorIcon::ResizeNeSw, + } +} + +fn rotation_cursor(ctx: &egui::Context, p: Pos2) { + let tokens = crate::theme::Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-rotate-cursor"))); + let first = -std::f32::consts::FRAC_PI_4; + let last = std::f32::consts::PI + std::f32::consts::FRAC_PI_4; + let points: Vec = (0..=24) + .map(|i| { + let angle = first + (last - first) * i as f32 / 24.0; + p + vec2(angle.cos(), angle.sin()) * 7.0 + }) + .collect(); + painter.add(egui::Shape::line(points.clone(), Stroke::new(3.0, tokens.shadow))); + painter.add(egui::Shape::line(points, Stroke::new(1.25, tokens.accent_text))); + for (angle, sign) in [(first, -1.0), (last, 1.0)] { + let tip = p + vec2(angle.cos(), angle.sin()) * 7.0; + let tangent = vec2(-angle.sin(), angle.cos()) * sign; + let normal = vec2(angle.cos(), angle.sin()); + painter.add(egui::Shape::convex_polygon( + vec![tip + tangent * 3.0, tip - tangent * 3.0 + normal * 3.0, tip - tangent * 3.0 - normal * 3.0], + tokens.accent_text, + Stroke::new(1.0, tokens.shadow), + )); + } +} + +/// The cursor and press share the exact same hit test, including a bounded outside rotation ring. +pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2, mods: Modifiers) -> Option { + if !visible(app, mods) { + let id = LayerId(app.ui.text_edit.as_ref()?.layer); + let q = xf.to_doc(p); + let handle = crate::type_tool::box_handle_at(app, id, q[0], q[1])?; + let t = frame(app)?; + let direction = if handle < 4 { + let a = *t.quad.get(usize::from(handle))?; + let b = *t.quad.get((usize::from(handle) + 2) % 4)?; + [a[0] - b[0], a[1] - b[1]] + } else { + let b = if handle.is_multiple_of(2) { t.quad[3] } else { t.quad[1] }; + [b[0] - t.quad[0][0], b[1] - t.quad[0][1]] + }; + return Some(resize_cursor(direction, xf.flip)); + } + let Some(t) = frame(app) else { return Some(CursorIcon::Default) }; + let h = app.ui.type_transform.as_ref().map(|t| t.gesture.hit).or_else(|| hit(app, &t, xf.to_doc(p))); + Some(match h { + Some(Hit::Corner(i)) => { + let q = *t.quad.get(i)?; + let opposite = *t.quad.get((i + 2) % 4)?; + resize_cursor([q[0] - opposite[0], q[1] - opposite[1]], xf.flip) + } + Some(Hit::Edge(_)) => { + // Original arrowhead indicator, above the canvas, without a proprietary cursor asset. + let tokens = crate::theme::Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("type-skew-cursor"))); + painter.add(egui::Shape::convex_polygon(vec![p, p + vec2(14.0, 6.0), p + vec2(6.0, 14.0)], tokens.accent_text, Stroke::new(1.0, tokens.shadow))); + CursorIcon::None + } + Some(Hit::Outside) => { + rotation_cursor(ctx, p); + CursorIcon::None + } + Some(Hit::Pivot) => CursorIcon::Crosshair, + Some(Hit::Inside) => CursorIcon::Move, + None => CursorIcon::Default, + }) +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d6fc2bc3..30565a2b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,6 +36,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.commands`: list commands with enablement - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) - `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) + - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) - `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. diff --git a/docs/ui-design.md b/docs/ui-design.md index d4136c7e..bf934f3e 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -45,6 +45,22 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Layer masks | `panels.rs` | Clicking the mask thumbnail targets the mask (corner-bracket frame; the tab reads "Layer, Layer Mask/8"). Brush, eraser (paints background colour), gradient and bucket then send `"target": "mask"`. Adjustment and fill layers target their mask automatically. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | +While editing type, hold Ctrl (Windows/Linux) or Command (macOS) for an oriented transform +frame. Drag inside to move, a corner to scale (Shift keeps proportions; Alt/Option scales about +the reference point), a middle handle to skew, or just outside the frame to rotate (Shift snaps +to 15°). Drag the reference point to move the rotation centre. These Type gestures follow +[Adobe's type guide](https://helpx.adobe.com/photoshop/using/creating-type.html), independently +of the Free Transform preference: Ctrl/Cmd never distorts a Type corner. + +Without Ctrl/Cmd, paragraph handles resize the container and reflow its text. Temporary +transforms keep its logical dimensions and all character/paragraph styles. A drag begun with +Ctrl/Cmd owns the pointer until button release, even if the modifier is released first. Its +preview does not change the document; release applies one coalesced `type.edit`, and typing +continues in the same layer. Escape during a drag cancels that preview; focus loss or changes +to its source also discard it. Holding the modifier, moving the reference point, or returning +the pointer to its start creates no history entry. Ctrl/Cmd+T while typing still toggles the +Character panel. + Where the font lacks a symbol (e.g. ∠ ↦ ▔), draw it with the painter or use a Lucide icon; never ship missing-glyph boxes. Check every new panel with the offscreen snapshot tool (`docs/development.md`).