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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
FinnNk
2026-10-12 21:41:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent ccfc878a14
commit e90dfdcf10
4 changed files with 538 additions and 5 deletions
+134 -2
View File
@@ -133,6 +133,81 @@ fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option<Strin
face.postscript_name.clone()
}
/// Photoshop's leading range in points.
pub const LEADING_MIN_PT: f32 = 0.01;
pub const LEADING_MAX_PT: f32 = 5000.0;
/// Photoshop's baseline shift range in points (either direction).
pub const BASELINE_SHIFT_MAX_PT: f32 = 5000.0;
/// The relative character steps `type.setStyle` accepts, with the largest step allowed.
const STEP_KEYS: [(&str, f64); 3] = [("leadingBy", 5000.0), ("baselineShiftBy", 10_000.0), ("trackingBy", 11_000.0)];
/// Validates the relative steps (`leadingBy`, `baselineShiftBy`, `trackingBy`): finite numbers
/// no larger than the whole range they move through.
pub fn check_steps(p: &Value) -> 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 &paragraphs {
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<std::borrow::Co
let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt() as f32;
let scale = if scale.is_finite() && scale > 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<CommandSpec> {
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<CommandSpec> {
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));
}
}
+243 -2
View File
@@ -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<String> {
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() {
+158
View File
@@ -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<photocraft_doc::text::CharStyle> {
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;
+3 -1
View File
@@ -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 |