mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 10:57:20 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
ccfc878a14
commit
e90dfdcf10
@@ -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 ¶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<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user