From e90dfdcf101caaafbebc8b1ae938a51eab9268d2 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 02:41:23 +0100 Subject: [PATCH] feat(ui): Photoshop's type formatting keys while editing text (#4239) * feat(engine): relative leading, baseline shift and tracking steps in type.setStyle `leadingBy`, `baselineShiftBy` and `trackingBy` move every character in the range from its own value, so mixed values keep their differences. Auto leading starts from the value layout uses (the paragraph's auto-leading percentage of the character's size), results clamp to Photoshop's ranges, and non-finite or oversized steps are refused as bad params. `leadingBy` follows `metricsAsShown` like `leading`. Co-Authored-By: Claude Opus 5.5 * feat(ui): Photoshop's type formatting keys while editing text While a Type tool edit is active, Shift+Cmd+K/U/B/I and Shift+Cmd+/ toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough on the selection (off when every selected character has it, else on); Alt+Up/Down step leading by 2 pt and Shift+Alt+Up/Down baseline shift by 2 pt (10 with Cmd); Alt+Left/Right over a selection track it by 20 (100 with Cmd), a collapsed caret still kerning; Cmd+Alt+Shift+H toggles Hyphenate for the paragraphs touched. Each press is one type.setStyle history step, and the keys no longer fall through to Color Settings, Desaturate, Auto Color or Inverse while typing; outside an edit those shortcuts work as before. A collapsed caret in non-empty text changes nothing: Photoshop styles the next characters typed, which the document model can't hold apart from the text. In empty text the style becomes the typing style. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/type_cmds.rs | 136 ++++++++++++++++- crates/ui-egui/src/input_tests.rs | 245 +++++++++++++++++++++++++++++- crates/ui-egui/src/type_tool.rs | 158 +++++++++++++++++++ docs/ui-parity.md | 4 +- 4 files changed, 538 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index a53b6baba4..4bf193b679 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -133,6 +133,81 @@ fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option std::result::Result<(), String> { + for (key, max) in STEP_KEYS { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(by) if by.is_finite() && by.abs() <= max => {} + _ => return Err(format!("{key} must be a number between -{max} and {max}")), + } + } + } + Ok(()) +} + +/// Relative character steps (Photoshop's ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking +/// over a selection): every character moves from its own value, so mixed values keep their +/// differences. Auto leading starts from the value layout uses, the paragraph's auto-leading +/// percentage of the character's size. Results are clamped to Photoshop's ranges. Returns true +/// if any step key was present. +fn step_char_metrics(t: &mut TextLayer, a: usize, b: usize, p: &Value) -> bool { + let leading_by = f32p(p, "leadingBy").filter(|v| v.is_finite()); + let shift_by = f32p(p, "baselineShiftBy").filter(|v| v.is_finite()); + let tracking_by = f32p(p, "trackingBy").filter(|v| v.is_finite()); + if leading_by.is_none() && shift_by.is_none() && tracking_by.is_none() { + return false; + } + let paragraphs = t.paragraph_runs(); + let default_auto = ParagraphStyle::default().auto_leading; + // Each paragraph's share of `a..b`, with the auto leading it lays out with. + let mut pieces = Vec::new(); + let mut at = 0usize; + for pr in ¶graphs { + let (ps, pe) = (at, at.saturating_add(pr.len)); + at = pe; + let (pa, pb) = (a.clamp(ps, pe), b.clamp(ps, pe)); + if pa < pb { + pieces.push((pa, pb, pr.style.auto_leading)); + } + } + if at < b { + pieces.push((a.max(at), b, default_auto)); + } + if t.text.is_empty() { + // Empty text: the step sets the style the next typed characters get. + pieces = vec![(0, 0, paragraphs.first().map_or(default_auto, |r| r.style.auto_leading))]; + } + for (pa, pb, auto) in pieces { + style_range(t, pa, pb, &|st| { + if let Some(by) = leading_by { + let auto = if auto.is_finite() && auto > 0.0 { auto } else { default_auto }; + let base = st.leading_pt.unwrap_or(auto * st.size_pt); + let base = if base.is_finite() { base } else { 0.0 }; + st.leading_pt = Some((base + by).clamp(LEADING_MIN_PT, LEADING_MAX_PT)); + } + if let Some(by) = shift_by { + let base = if st.baseline_shift_pt.is_finite() { st.baseline_shift_pt } else { 0.0 }; + st.baseline_shift_pt = (base + by).clamp(-BASELINE_SHIFT_MAX_PT, BASELINE_SHIFT_MAX_PT); + } + if let Some(by) = tracking_by { + let base = if st.tracking.is_finite() { st.tracking } else { 0.0 }; + st.tracking = (base + by).clamp(TRACKING_MIN as f32, TRACKING_MAX as f32); + } + }); + } + true +} + /// Applies character-style keys from JSON. Returns true if any key was present. pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; @@ -582,7 +657,7 @@ fn shown_style_params<'a>(t: &TextLayer, p: &'a Value) -> Result 1e-3 { f64::from(scale) } else { 1.0 }; let mut params = p.clone(); - for key in ["size", "leading"] { + for key in ["size", "leading", "leadingBy"] { if let Some(value) = p.get(key).and_then(Value::as_f64) { let value = value / scale; if !value.is_finite() || value.abs() > f64::from(f32::MAX) { @@ -868,13 +943,14 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: Box::leak( - format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading include each layer's transform scale), {CHAR_PARAMS}, paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) + format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) .into_boxed_str(), ), enabled: has_doc, journal: true, run: |s, p| { check_kerning(p).map_err(|m| bad("type.setStyle", m))?; + check_steps(p).map_err(|m| bad("type.setStyle", m))?; let ids = with_text_targets(s, p, "Set Type Style", |t, (a, b)| { let props = shown_style_params(t, p)?; check_size_tracking(&props).map_err(|m| bad("type.setStyle", m))?; @@ -884,6 +960,7 @@ pub fn specs() -> Vec { apply_char_props(st, &props); }); } + step_char_metrics(t, a, b, &props); let mut pprobe = ParagraphStyle::default(); if apply_para_props(&mut pprobe, p) { style_paragraphs(t, a, b, &|st| { @@ -1616,4 +1693,59 @@ mod tests { ]) ); } + + /// `leadingBy`, `baselineShiftBy` and `trackingBy` step every character from its own value + /// (auto leading from its laid-out value), clamp to Photoshop's ranges, and reject hostile + /// steps without changing anything. + #[test] + fn relative_steps_move_each_character_from_its_own_value() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Hello\nworld", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [8, 11], "leading": 30, "tracking": 50})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [6, 11], "autoLeading": 150})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("type.setStyle", json!({"layer": id, "range": [0, 11], "leadingBy": 2, "baselineShiftBy": -2, "trackingBy": 20})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one history step"); + let t = text_layer(&s, id); + let at = |c: usize| { + let byte = byte_at(&t.text, c); + let mut off = 0; + t.char_runs() + .into_iter() + .find(|r| { + off += r.len; + off > byte + }) + .unwrap() + .style + }; + // Auto leading 120% × 10 pt (first paragraph), 150% × 10 pt (second), and a set 30 pt. + assert_eq!(at(0).leading_pt, Some(14.0)); + assert_eq!(at(6).leading_pt, Some(17.0)); + assert_eq!(at(9).leading_pt, Some(32.0)); + assert_eq!((at(0).baseline_shift_pt, at(9).baseline_shift_pt), (-2.0, -2.0)); + assert_eq!((at(0).tracking, at(9).tracking), (20.0, 70.0)); + // Clamped to the ranges. + s.execute("type.setStyle", json!({"layer": id, "leadingBy": -5000, "baselineShiftBy": 10000, "trackingBy": -11000})).unwrap(); + let t = text_layer(&s, id); + assert!(t.char_runs().iter().all(|r| r.style.leading_pt == Some(LEADING_MIN_PT) + && r.style.baseline_shift_pt == BASELINE_SHIFT_MAX_PT + && r.style.tracking == TRACKING_MIN as f32)); + // A collapsed range in non-empty text changes no character. + let before = text_layer(&s, id).char_runs(); + s.execute("type.setStyle", json!({"layer": id, "range": [3, 3], "trackingBy": 100})).unwrap(); + assert_eq!(text_layer(&s, id).char_runs(), before); + // Hostile steps are refused. + let steps = s.active().unwrap().history.entries().len(); + for bad_step in [json!({"leadingBy": "up"}), json!({"trackingBy": 1e30}), json!({"baselineShiftBy": null}), json!({"leadingBy": [1]})] { + let mut p = bad_step.clone(); + p["layer"] = json!(id); + assert!(matches!(s.execute("type.setStyle", p), Err(EngineError::BadParams { .. })), "{bad_step}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + // Empty text: the step sets the style the next typed characters get. + let empty = s.execute("type.create", json!({"x": 0, "y": 80, "text": "", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": empty, "range": [0, 0], "leadingBy": -2})).unwrap(); + assert_eq!(text_layer(&s, empty).char_runs()[0].style.leading_pt, Some(10.0)); + } } diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 956a9de9ba..4ac71a6634 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -504,10 +504,12 @@ fn type_tool_alt_arrows_kern_the_pair() { h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::ArrowRight); h.run_steps(2); assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((3, 6))); - // With a selection Alt+→ moves by word instead of kerning; at the text end there's no pair. + // With a selection Alt+→ tracks it instead (see `type_tool_format_keys_*`); at the text end + // there's no pair to kern. let before = kern(&h); - h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); + h.key_press(Key::ArrowRight); h.run_steps(2); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((6, 6))); h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); h.run_steps(2); assert_eq!(kern(&h), before); @@ -518,6 +520,245 @@ fn type_tool_alt_arrows_kern_the_pair() { assert_eq!(kern(&h).1, metric + 120.0); } +/// Starts a type editing session on `layer` with `anchor..caret` selected. +fn edit_type(h: &mut Harness<'static, PhotocraftApp>, layer: u64, anchor: usize, caret: usize) { + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer, + caret, + anchor, + session: "format-keys".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); +} + +fn type_layer(h: &Harness<'static, PhotocraftApp>, id: u64) -> photocraft_doc::TextLayer { + match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => t.clone(), + _ => panic!("not text"), + } +} + +/// The character style of character `c` of a type layer. +fn char_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::CharStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.char_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte || t.text.is_empty() + }) + .unwrap() + .style +} + +/// The paragraph style of the paragraph holding character `c`. +fn para_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::ParagraphStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.paragraph_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte + }) + .unwrap() + .style +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries() +} + +/// Photoshop's ⇧⌘K/U/B/I/: toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough +/// on the selected characters while typing, one step per press, and never reach the global +/// ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse). +#[test] +fn type_tool_format_keys_toggle_character_styles() { + use photocraft_doc::text::{Caps, CharStyle}; + let mut h = harness(); + // A selection, so Select ▸ Inverse would be enabled if the key reached it. + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + type Has = fn(&CharStyle) -> bool; + let keys: [(Key, Has); 6] = [ + (Key::K, |s| s.caps == Caps::AllCaps), + (Key::U, |s| s.underline), + (Key::B, |s| s.faux_bold), + (Key::I, |s| s.faux_italic), + (Key::Slash, |s| s.strikethrough), + (Key::Questionmark, |s| s.strikethrough), + ]; + for (key, has) in keys { + let before = history(&h).len(); + let was = has(&char_style(&h, id, 0)); + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + assert_eq!(has(&char_style(&h, id, 0)), !was, "{key:?} toggles the selection"); + assert_eq!(has(&char_style(&h, id, 4)), !was, "{key:?}"); + assert!(!has(&char_style(&h, id, 6)), "{key:?} leaves the unselected characters"); + let steps = history(&h); + assert_eq!(steps.len(), before + 1, "{key:?}: one history step"); + assert_eq!(steps.last().map(String::as_str), Some("Set Type Style"), "{key:?}"); + assert!(h.state().ui.dialogs.is_empty(), "{key:?} must not open Color Settings"); + assert!(h.state().ui.text_edit.is_some()); + } + // The second strikethrough key turned it off again; press the rest again: all off. + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + let s = char_style(&h, id, 2); + assert!(s.caps == Caps::Normal && !s.underline && !s.faux_bold && !s.faux_italic && !s.strikethrough); + // Partly underlined: the key underlines the whole selection rather than flipping each part. + h.state_mut().run("type.setStyle", json!({"layer": id, "range": [0, 2], "underline": true})).unwrap(); + h.key_press_modifiers(cmd_shift, Key::U); + h.run_steps(2); + assert!((0..5).all(|c| char_style(&h, id, c).underline)); + // Undo takes back one press. + assert!(h.state_mut().session.undo()); + h.run_steps(1); + assert!(char_style(&h, id, 0).underline && !char_style(&h, id, 3).underline); + // A collapsed caret in non-empty text: nothing changes, and nothing global runs. + edit_type(&mut h, id, 3, 3); + let before = history(&h); + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + assert_eq!(history(&h), before); + assert!(h.state().ui.dialogs.is_empty()); + // Empty text: the key sets the style the next characters typed get. + let empty = h.state_mut().run("type.create", json!({"x": 20, "y": 160, "text": "", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, empty, 0, 0); + h.key_press_modifiers(cmd_shift, Key::B); + h.run_steps(2); + h.event(egui::Event::Text("ab".into())); + h.run_steps(2); + assert_eq!(type_layer(&h, empty).text, "ab"); + assert!(char_style(&h, empty, 0).faux_bold && char_style(&h, empty, 1).faux_bold); +} + +/// Outside text editing ⇧⌘K and ⇧⌘I keep their global meanings. +#[test] +fn global_cmd_shift_keys_still_work_outside_type_editing() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none()); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + h.key_press_modifiers(cmd_shift, Key::I); + h.run_steps(2); + assert_eq!(history(&h).last().map(String::as_str), Some("Inverse")); + h.key_press_modifiers(cmd_shift, Key::K); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1, "Color Settings opens"); + assert!(!char_style(&h, id, 0).faux_italic && char_style(&h, id, 0).caps == photocraft_doc::text::Caps::Normal); +} + +/// ⌥↑/↓ leading (2 pt, 10 with ⌘), ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking over a selection +/// (20, 100 with ⌘): one step per press, from each character's own value, the selection kept. +#[test] +fn type_tool_format_keys_step_leading_baseline_and_tracking() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let before = history(&h).len(); + let press = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, k: Key| { + h.key_press_modifiers(m, k); + h.run_steps(2); + }; + // Auto leading is 120% of 10 pt: ⌥↓ makes it 14. + press(&mut h, Modifiers::ALT, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(14.0)); + press(&mut h, Modifiers::ALT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 4).leading_pt, Some(12.0)); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(22.0)); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, 2.0); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, -8.0); + press(&mut h, Modifiers::ALT, Key::ArrowRight); + assert_eq!(char_style(&h, id, 0).tracking, 20.0); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowLeft); + assert_eq!(char_style(&h, id, 4).tracking, -80.0); + assert_eq!(history(&h).len(), before + 7, "one history step per press"); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((0, 5)), "the selection stays"); + let rest = char_style(&h, id, 7); + assert_eq!((rest.leading_pt, rest.baseline_shift_pt, rest.tracking), (None, 0.0, 0.0)); + // A collapsed caret: ⌥↑/↓ change nothing and don't move the caret. + edit_type(&mut h, id, 8, 8); + let steps = history(&h).len(); + press(&mut h, Modifiers::ALT, Key::ArrowDown); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(history(&h).len(), steps); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((8, 8))); +} + +/// ⌥⇧⌘H toggles Hyphenate for the paragraphs the selection or caret touches. +#[test] +fn type_tool_format_keys_toggle_hyphenate() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"box": [10, 10, 300, 200], "text": "one\ntwo\nthree", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let keys = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT; + // The caret in the second paragraph. + edit_type(&mut h, id, 5, 5); + let before = history(&h).len(); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, true, false]); + // A selection over the first two: not all hyphenate, so both do; again, neither. + edit_type(&mut h, id, 1, 5); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [true, true, false]); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, false, false]); + assert_eq!(history(&h).len(), before + 3); +} + +/// The formatting keys never panic: an edit of a layer that has gone, or with no document. +#[test] +fn type_tool_format_keys_survive_hostile_state() { + let mut h = harness(); + edit_type(&mut h, 9999, 0, 3); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::U); + h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none(), "an edit of a missing layer ends"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for ed in [None, Some(1)] { + app.ui.text_edit = ed.map(|layer| crate::state::TextEdit { + layer, + caret: 4, + anchor: 0, + session: "x".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + crate::type_tool::toggle_char_style(&mut app, crate::type_tool::CharToggle::AllCaps); + crate::type_tool::step_char_metric(&mut app, "leadingBy", 2.0); + crate::type_tool::step_char_metric(&mut app, "trackingBy", f32::NAN); + crate::type_tool::toggle_hyphenate(&mut app); + } + assert!(app.session.active().is_none()); +} + /// The Character panel's kerning field reads and parses Photoshop's values. #[test] fn kerning_field_values() { diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 291fdde78a..dc99138a32 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -460,6 +460,135 @@ fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) { let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}})); } +/// A character style the type editor's ⇧⌘ keys toggle (Photoshop: ⇧⌘K All Caps, ⇧⌘U +/// Underline, ⇧⌘B Faux Bold, ⇧⌘I Faux Italic, ⇧⌘/ Strikethrough). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CharToggle { + AllCaps, + Underline, + FauxBold, + FauxItalic, + Strikethrough, +} + +impl CharToggle { + fn is_on(self, st: &photocraft_doc::text::CharStyle) -> bool { + match self { + Self::AllCaps => st.caps == photocraft_doc::text::Caps::AllCaps, + Self::Underline => st.underline, + Self::FauxBold => st.faux_bold, + Self::FauxItalic => st.faux_italic, + Self::Strikethrough => st.strikethrough, + } + } + + fn param(self, on: bool) -> (&'static str, serde_json::Value) { + match self { + Self::AllCaps => ("caps", json!(if on { "all" } else { "normal" })), + Self::Underline => ("underline", json!(on)), + Self::FauxBold => ("fauxBold", json!(on)), + Self::FauxItalic => ("fauxItalic", json!(on)), + Self::Strikethrough => ("strikethrough", json!(on)), + } + } +} + +/// The byte range of characters `a..b` of `text`. +fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { + let at = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + (at(a.min(b)), at(a.max(b))) +} + +/// The character styles of the selection, or of the typing style when the text is empty. Empty +/// when nothing is selected in non-empty text. +fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { + let runs = t.char_runs(); + if t.text.is_empty() { + return runs.into_iter().take(1).map(|r| r.style).collect(); + } + let (ba, bb) = byte_range(&t.text, a, b); + let mut at = 0usize; + let mut out = Vec::new(); + for r in runs { + let (rs, re) = (at, at.saturating_add(r.len)); + at = re; + if rs < bb && re > ba { + out.push(r.style); + } + } + out +} + +/// The character range the type editor's formatting keys act on: the selection, or the empty +/// text's typing style. `None` for a collapsed caret in non-empty text: Photoshop would style the +/// next characters typed, which the document model can't hold apart from the text, so the keys +/// do nothing there (rather than restyle a word the user didn't select). +fn format_range(app: &PhotocraftApp, ed: &TextEdit) -> Option<(usize, usize)> { + let t = text_layer(&app.session.active()?.doc, LayerId(ed.layer))?; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + (a < b || n == 0).then_some((a, b)) +} + +/// One `type.setStyle` step on the edited layer's `range`, kept apart from the typing session's +/// coalesced edit so Undo takes back one key press at a time (as with kerning). +fn set_style(app: &mut PhotocraftApp, ed: &TextEdit, range: (usize, usize), mut p: serde_json::Value) { + crate::type_transform::finish(app); + p["layer"] = json!(ed.layer); + p["range"] = json!([range.0, range.1]); + if let Err(e) = app.run("type.setStyle", p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// ⇧⌘K/U/B/I/: flips a character style over the selection: off when every selected character +/// already has it, else on. +pub(crate) fn toggle_char_style(app: &mut PhotocraftApp, which: CharToggle) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + let Some(styles) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))).map(|t| selected_styles(t, range.0, range.1)) else { + return; + }; + let on = !styles.iter().all(|st| which.is_on(st)); + let (key, value) = which.param(on); + set_style(app, &ed, range, json!({ key: value })); +} + +/// ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking: steps every selected character from its +/// own value (`key` is `leadingBy`, `baselineShiftBy` or `trackingBy`). +pub(crate) fn step_char_metric(app: &mut PhotocraftApp, key: &str, by: f32) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + // Leading steps in the points the Character panel shows for a scaled layer. + set_style(app, &ed, range, json!({ key: by, "metricsAsShown": key == "leadingBy" })); +} + +/// ⌥⇧⌘H: flips Hyphenate for the paragraphs the selection (or the caret) is in: off when all of +/// them hyphenate, else on. +pub(crate) fn toggle_hyphenate(app: &mut PhotocraftApp) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(t) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))) else { return }; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + let (ba, bb) = byte_range(&t.text, a, b); + let len = t.text.len(); + let mut at = 0usize; + let mut all = true; + let mut any = false; + for r in t.paragraph_runs() { + let (ps, pe) = (at, at.saturating_add(r.len)); + at = pe; + let touches = if ba == bb { ps <= ba && (ba < pe || pe >= len) } else { ps < bb && pe > ba }; + if touches { + any = true; + all &= r.style.hyphenate; + } + } + let on = !(any && all); + set_style(app, &ed, (a, b), json!({ "hyphenate": on })); +} + /// IME composition. The preedit text is written into the layer (so it lays out and reflows like /// typed text) and replaced by every update; `commit` makes the result final. fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { @@ -677,6 +806,35 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use egui::Key; let key = &flow_key(*key, vertical_flow); match key { + // Photoshop's type formatting keys act on the selected characters and win + // over the global ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse) + // while typing. Each press is one history step. + Key::K | Key::U | Key::B | Key::I | Key::Slash | Key::Questionmark if m.command && m.shift && !m.alt => { + let which = match key { + Key::K => CharToggle::AllCaps, + Key::U => CharToggle::Underline, + Key::B => CharToggle::FauxBold, + Key::I => CharToggle::FauxItalic, + _ => CharToggle::Strikethrough, + }; + toggle_char_style(app, which); + } + Key::H if m.command && m.shift && m.alt => toggle_hyphenate(app), + // ⌥↑/↓ leading -/+2 pt, ⇧⌥↑/↓ baseline shift +/-2 pt (10 with ⌘/Ctrl). + Key::ArrowUp | Key::ArrowDown if m.alt => { + let step = if m.command { 10.0 } else { 2.0 }; + let up = *key == Key::ArrowUp; + if m.shift { + step_char_metric(app, "baselineShiftBy", if up { step } else { -step }); + } else { + step_char_metric(app, "leadingBy", if up { -step } else { step }); + } + } + // ⌥←/→ over a selection tracks it by -/+20 (100 with ⌘/Ctrl). + Key::ArrowLeft | Key::ArrowRight if m.alt && !m.shift && a != b => { + let step = if m.command { 100.0 } else { 20.0 }; + step_char_metric(app, "trackingBy", if *key == Key::ArrowRight { step } else { -step }); + } Key::Backspace | Key::Delete => { if a == b { let fwd = *key == Key::Delete; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a48db144ae..33fb8b319f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -185,6 +185,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | +| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | ## Panels and workspace @@ -220,3 +221,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | +| 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse |