Type Tool: Ctrl/Cmd temporary transform handles while editing (#1091) (#1202)

* type: transform editable text with temporary Ctrl/Cmd handles

* type: cover temporary transforms, editable files and preview costs

* docs: describe temporary Type transforms and automation

* type: release temporary previews immediately when gestures end

* test: benchmark the active 24 MP Type document
This commit is contained in:
Remidesbois 2026-10-09 01:10:13 +02:00 committed by GitHub
parent ef9d075f23
commit 46fb94f813
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1129 additions and 19 deletions

View File

@ -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<Document>, 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.

View File

@ -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!({

View File

@ -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<photocraft_text::TextLayout>)>,
pub(crate) type_transform_preview: Option<type_transform::Preview>,
/// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels.
channel_thumbs: Option<(DocId, std::sync::Weak<Document>, Vec<egui::TextureHandle>)>,
/// 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<DocId> = 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.

View File

@ -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<TextEdit>,
/// Ctrl/Cmd Type-tool gesture, exposed to automation but never restored as a held pointer.
#[serde(default, skip_deserializing)]
pub type_transform: Option<TypeTransform>,
/// 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<TransformSession>,
@ -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,

View File

@ -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 => {

View File

@ -34,15 +34,16 @@ pub fn layout(app: &mut PhotocraftApp, id: LayerId) -> Option<(Arc<TextLayout>,
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<u8> {
pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> Option<u8> {
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 {

View File

@ -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);
}

View File

@ -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<Arc<Document>>,
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<TransformSession> {
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<Hit> {
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<Affine> {
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> {
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<Document>, 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<Rect> {
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<Pos2> = (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<CursorIcon> {
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,
})
}

View File

@ -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.

View File

@ -45,6 +45,22 @@ curl -sfL -o assets/icons/<name>.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`).