feat(ui): the Use Smart Quotes preference turns typed quotes typographic (#204) (#2522)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Reynold Hu 2026-10-11 02:55:53 +08:00 committed by GitHub
parent 706c730cd2
commit 5ab78e8ce1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 76 additions and 6 deletions

View File

@ -1000,7 +1000,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
"plugIns.showExtensionPanels",
"plugIns.allowScriptsToConnect",
"plugIns.generatorEnabled",
"type.smartQuotes",
"type.missingGlyphProtection",
"type.showFontNamesInEnglish",
"type.textEngine",

View File

@ -2399,7 +2399,7 @@ mod tests {
assert!(has_visible_fields(&values, "type"));
assert!(!prefs::is_hidden("type.fillNewTypeLayersWithPlaceholder"));
assert!(!prefs::is_hidden("type.useEscToCommit"));
assert!(prefs::is_hidden("type.smartQuotes"));
assert!(!prefs::is_hidden("type.smartQuotes"));
// Rotate View with Trackpad is live; the other Enhanced Controls rows stay hidden.
assert!(has_visible_fields(&values, "enhancedControls"));
assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad"));

View File

@ -334,6 +334,53 @@ fn insert(app: &mut PhotocraftApp, s: &str) {
}
}
/// Typed text (not a paste): with Preferences ▸ Type ▸ Use Smart Quotes, straight quotes become
/// typographic (Photoshop does not rewrite pasted text).
fn insert_typed(app: &mut PhotocraftApp, s: &str) {
if !app.session.prefs().type_.smart_quotes {
return insert(app, s);
}
let prev = app.ui.text_edit.clone().and_then(|ed| {
let at = ed.caret.min(ed.anchor);
let text = current_text(app, LayerId(ed.layer))?;
(at > 0).then(|| text.chars().nth(at - 1)).flatten()
});
insert(app, &smart_quotes(s, prev));
}
/// Straight quotes become “ ” and ‘ ’: opening after whitespace, a bracket or a dash (or at the
/// start of the text), closing next to a letter or another quote. `prev` is the character before
/// the caret.
fn smart_quotes(s: &str, mut prev: Option<char>) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let c = match c {
'"' => {
if opens_quote(prev) {
'\u{201c}'
} else {
'\u{201d}'
}
}
'\'' => {
if opens_quote(prev) {
'\u{2018}'
} else {
'\u{2019}'
}
}
_ => c,
};
out.push(c);
prev = Some(c);
}
out
}
fn opens_quote(prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '-' | '\u{2013}' | '\u{2014}'))
}
/// Alt+←/→: kern the pair before the caret by `by` (1/1000 em). No pair (caret at a text or line
/// edge): nothing happens, like Photoshop. Not coalesced: one history step per press.
fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) {
@ -476,7 +523,8 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
};
handled[k] = true;
match ev {
egui::Event::Text(s) | egui::Event::Paste(s) => insert(app, s),
egui::Event::Text(s) => insert_typed(app, s),
egui::Event::Paste(s) => insert(app, s),
// A bare line break from the IME is the Enter key, which the key arm handles.
egui::Event::Ime(egui::ImeEvent::Preedit { text: s, .. } | egui::ImeEvent::Commit(s)) if s == "\n" || s == "\r" => handled[k] = false,
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => ime_update(app, text, false),
@ -1628,6 +1676,29 @@ mod tests {
assert!(app.ui.text_edit.is_none());
}
/// Pure smart-quote mapping: opening after whitespace, a bracket or a dash, closing next to a
/// letter or another quote.
#[test]
fn smart_quotes_are_typographic() {
assert_eq!(smart_quotes("\"Hello\"", None), "\u{201c}Hello\u{201d}");
assert_eq!(smart_quotes("don't", None), "don\u{2019}t");
assert_eq!(smart_quotes("(\"x\")", None), "(\u{201c}x\u{201d})");
assert_eq!(smart_quotes("--'x'", None), "--\u{2018}x\u{2019}", "a dash stays a dash, the quote opens after it");
assert_eq!(smart_quotes("\"\"", None), "\u{201c}\u{201d}");
}
/// Preferences ▸ Type ▸ Use Smart Quotes switches typed quotes (typed text only, not paste).
#[test]
fn smart_quotes_preference_controls_typed_quotes() {
for (smart, expected) in [(true, "hi \u{201c}you\u{201d}"), (false, "hi \"you\"")] {
let mut app = app();
app.run("prefs.set", json!({"path": "type.smartQuotes", "value": smart})).unwrap();
pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]);
insert_typed(&mut app, "hi \"you\"");
assert_eq!(layer_text(&app), expected, "smartQuotes={smart}");
}
}
#[test]
fn a_name_from_text_after_blank_lines_keeps_following_the_text() {
let mut app = app();

View File

@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 21 | 15 | 6 | |
| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 46 of 149 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 45 of 149 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 628 of 628 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@ -175,9 +175,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 46 of 149** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 45 of 149** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|