From 76412dc60172553f5649185cc388eb808645f238 Mon Sep 17 00:00:00 2001 From: Gabriel Rodrigues Date: Tue, 6 Oct 2026 09:07:46 -0700 Subject: [PATCH 1/2] Typographic substitution while typing (smart quotes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing "/' becomes curly by context (open after line start, space or ([{—–; always ’ after a letter/digit), and a second - folds -- into —. Backspace right after a substitution restores the straight characters, which also serves as the escape. Lines that are only hyphens/spaces (markdown rules) or contain | (tables) stay literal, as do inline code, fenced blocks and frontmatter via a single literal_at() check that new contexts (e.g. math) can extend. Paste is untouched. New smart_quotes setting (on by default) persisted in store.rs, with a toggle at the end of Settings → Editor in en and pt-BR. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 ++ src/app.rs | 3 + src/app/settings_ui.rs | 20 +++++ src/editor.rs | 66 ++++++++++++++-- src/i18n.rs | 9 +++ src/main.rs | 1 + src/md.rs | 14 ++-- src/smart.rs | 174 +++++++++++++++++++++++++++++++++++++++++ src/store.rs | 9 +++ 9 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 src/smart.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c8a3a..ae52aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to abstract are documented here. The format follows ## [Unreleased] +### Added +- Typographic substitution while typing (smart quotes): `"`/`'` become + curly by context, `--` folds into an em dash, and Backspace right after + a substitution restores the straight characters. Code and frontmatter + stay literal; toggle in Settings → Editor (on by default). + ## [0.1.4] - 2026-10-02 ### Added diff --git a/src/app.rs b/src/app.rs index 5949827..eac3d86 100644 --- a/src/app.rs +++ b/src/app.rs @@ -239,6 +239,9 @@ impl AbstractApp { ) -> Self { let editor = cx.new(LiveEditor::new); editor.update(cx, |ed, cx| ed.set_raw_tables(settings.raw_tables(), cx)); + editor.update(cx, |ed, cx| { + ed.set_smart_quotes(settings.smart_quotes(), cx) + }); let on_change = cx.subscribe(&editor, |this: &mut Self, editor, _: &Changed, cx| { let text = editor.read(cx).text(); this.words = text.split_whitespace().count(); diff --git a/src/app/settings_ui.rs b/src/app/settings_ui.rs index 89787cc..3aedb7b 100644 --- a/src/app/settings_ui.rs +++ b/src/app/settings_ui.rs @@ -352,6 +352,7 @@ impl AbstractApp { let raw = self.settings.raw_tables(); let updates = self.settings.updates(); + let smart_quotes = self.settings.smart_quotes(); let font_names = cx.text_system().all_font_names(); let current_font = fonts::resolve(self.settings.font(), &font_names); let font_rows = @@ -430,6 +431,19 @@ impl AbstractApp { cx.listener(move |this, _, window, cx| this.set_raw_tables(!raw, window, cx)), ), ) + .child( + toggle( + "smart-quotes", + t(Key::SmartQuotes), + Some(t(Key::SmartQuotesHint)), + smart_quotes, + window, + cx, + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.set_smart_quotes(!smart_quotes, window, cx) + })), + ) .child(section(t(Key::General))) .child(hover_bg( div() @@ -647,4 +661,10 @@ impl AbstractApp { fonts::apply(&self.settings, cx); self.save_settings(window, cx); } + + fn set_smart_quotes(&mut self, on: bool, window: &mut Window, cx: &mut Context) { + self.settings.set_smart_quotes(on); + self.editor.update(cx, |ed, cx| ed.set_smart_quotes(on, cx)); + self.save_settings(window, cx); + } } diff --git a/src/editor.rs b/src/editor.rs index e90d9d9..2775bbd 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -216,6 +216,11 @@ pub struct LiveEditor { find_current: Option, /// Tables stay Markdown source instead of rendering as a grid. raw_tables: bool, + /// Typographic `"`/`'`/`--` substitution while typing (the setting). + smart_quotes: bool, + /// `(inserted range, original)` of the last substitution: a Backspace + /// right at its end restores the straight characters. + last_subst: Option<(Range, &'static str)>, } impl EventEmitter for LiveEditor {} @@ -250,6 +255,8 @@ impl LiveEditor { finds: Vec::new(), find_current: None, raw_tables: false, + smart_quotes: true, + last_subst: None, } } @@ -314,6 +321,13 @@ impl LiveEditor { } } + pub fn set_smart_quotes(&mut self, on: bool, cx: &mut Context) { + if self.smart_quotes != on { + self.smart_quotes = on; + cx.notify(); + } + } + /// Selects `r` and scrolls it into view. pub fn select_range(&mut self, r: Range, cx: &mut Context) { self.buf.set_sel(r); @@ -749,6 +763,7 @@ impl LiveEditor { self.find_current = None; self.scroll_y = 0.; self.caret = None; + self.last_subst = None; self.analysis = self.analyzer.analyze(self.buf.text()); cx.notify(); } @@ -822,10 +837,25 @@ impl LiveEditor { self.analysis = self.analyzer.analyze(self.buf.text()); self.autoscroll = true; self.goal_x = None; + self.last_subst = None; cx.emit(Changed); cx.notify(); } + /// Backspace right after a substitution restores the straight + /// characters (`”` → `"`, `—` → `--`) instead of deleting them. + fn backspace(&mut self, cx: &mut Context) { + if self.buf.sel().is_empty() + && let Some((r, orig)) = self.last_subst.clone() + && r.end == self.buf.cursor() + { + self.edit(r, orig, None, cx); + return; + } + self.buf.backspace(); + self.changed(cx); + } + fn insert(&mut self, s: &str, cx: &mut Context) { self.buf.insert(s); self.changed(cx); @@ -885,6 +915,7 @@ impl LiveEditor { fn after_move(&mut self, cx: &mut Context) { self.autoscroll = true; self.goal_x = None; + self.last_subst = None; cx.notify(); } @@ -1098,11 +1129,39 @@ impl EntityInputHandler for LiveEditor { _: &mut Window, cx: &mut Context, ) { - let r = range + let mut r = range .map(|r| self.buf.range_from_utf16(&r)) .or(self.buf.marked()) .unwrap_or(self.buf.sel()); + // Typographic substitution on typed text only (paste goes through + // `insert`, untouched): a lone `"`, `'` or `-` may become a curly + // quote or fold into `—`. + let mut sub = None; + let mut new = new; + if self.smart_quotes && new.chars().count() == 1 { + let text = self.buf.text(); + let line = self.line_range(r.start); + if let Some(s) = crate::smart::substitute( + &text[line.clone()], + &text[line.start..r.start], + new.chars().next().unwrap_or_default(), + true, + crate::smart::literal_at(&self.analysis, r.start), + ) { + // `back` chars before the caret fold into the substitution + // (`--`: the first `-` goes into the `—`). + for _ in 0..s.back { + r.start = self.buf.prev_boundary(r.start); + } + new = s.text; + sub = Some(s); + } + } + let at = r.start; self.edit(r, new, None, cx); + if let Some(s) = sub { + self.last_subst = Some((at..at + s.text.len(), s.orig)); + } } fn replace_and_mark_text_in_range( @@ -1177,10 +1236,7 @@ impl Render for LiveEditor { .aria_label(crate::i18n::t(crate::i18n::Key::EditorAria)) .size_full() .cursor_text() - .on_action(cx.listener(|this, _: &Backspace, _, cx| { - this.buf.backspace(); - this.changed(cx); - })) + .on_action(cx.listener(|this, _: &Backspace, _, cx| this.backspace(cx))) .on_action(cx.listener(|this, _: &Delete, _, cx| { this.buf.delete(); this.changed(cx); diff --git a/src/i18n.rs b/src/i18n.rs index 9dd4cbc..04e1d44 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -264,6 +264,9 @@ pub enum Key { TableInsertColumnRight, TableDeleteRow, TableDeleteColumn, + // Typographic substitution + SmartQuotes, + SmartQuotesHint, } impl Key { @@ -446,6 +449,8 @@ impl Key { Key::TableInsertColumnRight, Key::TableDeleteRow, Key::TableDeleteColumn, + Key::SmartQuotes, + Key::SmartQuotesHint, ]; } @@ -644,6 +649,8 @@ fn en(k: Key) -> &'static str { Key::TableInsertColumnRight => "Insert column to the right", Key::TableDeleteRow => "Delete row", Key::TableDeleteColumn => "Delete column", + Key::SmartQuotes => "Smart quotes", + Key::SmartQuotesHint => "Typographic “quotes” and — dashes while you type.", } } @@ -846,6 +853,8 @@ fn pt(k: Key) -> &'static str { Key::TableInsertColumnRight => "Inserir coluna à direita", Key::TableDeleteRow => "Apagar linha", Key::TableDeleteColumn => "Apagar coluna", + Key::SmartQuotes => "Aspas tipográficas", + Key::SmartQuotesHint => "Aspas “curvas” e travessões — ao digitar.", } } diff --git a/src/main.rs b/src/main.rs index fb2687a..26fea76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod md; #[cfg(test)] mod perf; mod search; +mod smart; mod spaces; mod store; mod table; diff --git a/src/md.rs b/src/md.rs index 90e747f..4cf8100 100644 --- a/src/md.rs +++ b/src/md.rs @@ -192,6 +192,8 @@ pub struct Analysis { /// `==…==` marks, in buffer order; `tint` comes from an optional `{color}` /// prefix right after the opener. pub marks: Vec, + /// YAML (`---`) / TOML (`+++`) frontmatter ranges, in buffer order. + pub metadata: Vec>, } pub struct Analyzer { @@ -236,6 +238,7 @@ impl Analyzer { callouts: Vec::new(), tables: Vec::new(), marks: Vec::new(), + metadata: Vec::new(), }; if len == 0 { return out; @@ -442,11 +445,12 @@ impl Analyzer { out.tables.push(first..last + 1); true } - "html_block" - | "minus_metadata" - | "plus_metadata" - | "link_reference_definition" - | "pipe_table_delimiter_row" => { + "minus_metadata" | "plus_metadata" => { + out.metadata.push(range.clone()); + out.mark(range, MUTED); + false + } + "html_block" | "link_reference_definition" | "pipe_table_delimiter_row" => { out.mark(range, MUTED); false } diff --git a/src/smart.rs b/src/smart.rs new file mode 100644 index 0000000..312579f --- /dev/null +++ b/src/smart.rs @@ -0,0 +1,174 @@ +//! Typographic substitution while typing: curly quotes and the `--` → `—` +//! fold, Word-style. Pure decisions — `LiveEditor` calls `substitute` on +//! the typed-text path only (paste never converts) and `literal_at` is the +//! single funnel for forbidden contexts (code, frontmatter; math plugs in +//! here when `$…$` lands). + +use crate::md::{Analysis, CODE, Kind}; + +/// One substitution: `text` replaces the typed char plus `back` chars +/// before the caret; `orig` is what a Backspace right after restores. +#[derive(Debug, PartialEq, Eq)] +pub struct Subst { + pub text: &'static str, + pub orig: &'static str, + pub back: usize, +} + +/// What `typed` becomes at the caret. `line` is the whole current line and +/// `before` its prefix up to the caret. `on` is the setting, `literal` the +/// forbidden-context flag from `literal_at`. +pub fn substitute(line: &str, before: &str, typed: char, on: bool, literal: bool) -> Option { + if !on || literal { + return None; + } + // Opens at line start or after space, `(`, `[`, `{`, `—`, `–`; + // everything else closes. For `'` the "everything else" arm covers the + // apostrophe rule (after a letter or digit → ’). + let opens = |c: Option| { + c.is_none_or(|c| c == ' ' || c == '\t' || matches!(c, '(' | '[' | '{' | '—' | '–')) + }; + let prev = before.chars().last(); + match typed { + '"' => Some(Subst { + text: if opens(prev) { "“" } else { "”" }, + orig: "\"", + back: 0, + }), + '\'' => Some(Subst { + text: if opens(prev) { "‘" } else { "’" }, + orig: "'", + back: 0, + }), + '-' => { + // `---` (rule, frontmatter) and `|---|` delimiters stay literal: + // no fold when the line so far is only hyphens/spaces or the + // line is a table row. + if prev != Some('-') + || line.contains('|') + || before.chars().all(|c| c == '-' || c == ' ') + { + return None; + } + Some(Subst { + text: "—", + orig: "--", + back: 1, + }) + } + _ => None, + } +} + +/// Whether `off` sits in a context that must stay literal: fenced or +/// indented code block, `code` span, frontmatter. Extend here when math +/// (`$…$`) lands — callers stay unchanged. +pub fn literal_at(a: &Analysis, off: usize) -> bool { + if matches!(a.lines[a.line_of(off)].1, Kind::Code) { + return true; + } + if a.metadata.iter().any(|r| r.start < off && off <= r.end) { + return true; + } + // Inside a code span is strictly between two CODE-flagged bytes; a + // caret right at either edge is still prose. + off > 0 + && a.flags.get(off - 1).is_some_and(|f| f & CODE != 0) + && a.flags.get(off).is_some_and(|f| f & CODE != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::md::Analyzer; + + fn sub(line: &str, before: &str, typed: char) -> Option { + substitute(line, before, typed, true, false).map(|s| s.text.to_string()) + } + + fn analysis(text: &str) -> Analysis { + Analyzer::new().analyze(text) + } + + #[test] + fn double_quote_opens_and_closes() { + assert_eq!(sub("", "", '"').unwrap(), "“"); + assert_eq!(sub("diga ", "diga ", '"').unwrap(), "“"); + for open in ['(', '[', '{', '—', '–'] { + let b = format!("a{open}"); + assert_eq!(sub(&b, &b, '"').unwrap(), "“", "after {open}"); + } + assert_eq!(sub("diga", "diga", '"').unwrap(), "”"); + assert_eq!(sub("n = 2", "n = 2", '"').unwrap(), "”"); + assert_eq!(sub("a)b", "a)", '"').unwrap(), "”"); + } + + #[test] + fn single_quote_and_apostrophe() { + assert_eq!(sub("", "", '\'').unwrap(), "‘"); + assert_eq!(sub("a ", "a ", '\'').unwrap(), "‘"); + // After a letter or digit it's always the apostrophe ’. + assert_eq!(sub("don't stop", "dont", '\'').unwrap(), "’"); + assert_eq!(sub("d'água", "d", '\'').unwrap(), "’"); + assert_eq!(sub("x'80", "x'8", '\'').unwrap(), "’"); + assert_eq!(sub("say 'hi'", "say 'hi", '\'').unwrap(), "’"); + } + + #[test] + fn double_hyphen_folds_to_dash() { + let s = substitute("era uma vez --", "era uma vez -", '-', true, false).unwrap(); + assert_eq!(s.text, "—"); + assert_eq!(s.orig, "--"); + assert_eq!(s.back, 1); + // A lone `-` does nothing. + assert!(substitute("a -", "a ", '-', true, false).is_none()); + // `a--` folds too. + assert_eq!(sub("a--", "a-", '-').unwrap(), "—"); + } + + #[test] + fn hyphen_rules_and_tables_stay_literal() { + // A `---` in the making (rule / frontmatter) never folds. + assert!(substitute("-", "-", '-', true, false).is_none()); + assert!(substitute("--", "--", '-', true, false).is_none()); + assert!(substitute(" - - ", " - -", '-', true, false).is_none()); + // Any `|` in the line keeps `|` delimiters intact. + assert!(substitute("|---|", "|-", '-', true, false).is_none()); + assert!(substitute("a | b --", "a | b -", '-', true, false).is_none()); + } + + #[test] + fn off_or_literal_never_substitutes() { + // Toggle off. + assert!(substitute("a ", "a ", '"', false, false).is_none()); + assert!(substitute("a --", "a -", '-', false, false).is_none()); + // Forbidden context. + assert!(substitute("a ", "a ", '"', true, true).is_none()); + assert!(substitute("a ", "a ", '\'', true, true).is_none()); + assert!(substitute("a --", "a -", '-', true, true).is_none()); + // Other chars never substitute. + assert!(substitute("a ", "a ", 'x', true, false).is_none()); + } + + #[test] + fn literal_at_code_and_frontmatter() { + // Fenced code block. + let a = analysis("```\nfoo\n```"); + assert!(literal_at(&a, 5)); + let a = analysis("a `b c` d"); + // Inside the code span (between `b` and ` `). + assert!(literal_at(&a, 4)); + // At the span's left edge and right after it: prose. + assert!(!literal_at(&a, 2)); + assert!(!literal_at(&a, 7)); + assert!(!literal_at(&a, 8)); + // Frontmatter. + let a = analysis("---\ntitle: x\n---\nbody"); + assert!(literal_at(&a, 8)); + assert!(literal_at(&a, 16)); + assert!(!literal_at(&a, 18)); + // Plain prose. + let a = analysis("hello \"x\""); + assert!(!literal_at(&a, 6)); + } +} diff --git a/src/store.rs b/src/store.rs index fe4a358..183e255 100644 --- a/src/store.rs +++ b/src/store.rs @@ -232,6 +232,15 @@ impl Settings { self.kv.set("zoom", &format!("{factor:.2}")); } + /// `smart_quotes = off` keeps `"`/`'`/`--` exactly as typed. + pub fn smart_quotes(&self) -> bool { + self.kv.get("smart_quotes") != Some("off") + } + + pub fn set_smart_quotes(&mut self, on: bool) { + self.kv.set("smart_quotes", if on { "on" } else { "off" }); + } + /// Blocking. pub fn save(&self) { let file = settings_file(); From 1993033afd954e78ac25954a967ff2fbb95c7846 Mon Sep 17 00:00:00 2001 From: Gabriel Rodrigues Date: Tue, 6 Oct 2026 09:43:49 -0700 Subject: [PATCH 2/2] Treat unclosed inline code spans as literal too A line typed linearly like `tail -- "z"` used to bake curls into what becomes a code span, because the tree only flags closed spans. A run of n backticks opens a span closed by a run of exactly n, so scan the line prefix in literal_at for an unmatched run (\\-escaped runs skipped). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/editor.rs | 5 ++-- src/smart.rs | 73 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 2775bbd..a72591c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1141,12 +1141,13 @@ impl EntityInputHandler for LiveEditor { if self.smart_quotes && new.chars().count() == 1 { let text = self.buf.text(); let line = self.line_range(r.start); + let before = &text[line.start..r.start]; if let Some(s) = crate::smart::substitute( &text[line.clone()], - &text[line.start..r.start], + before, new.chars().next().unwrap_or_default(), true, - crate::smart::literal_at(&self.analysis, r.start), + crate::smart::literal_at(&self.analysis, r.start, before), ) { // `back` chars before the caret fold into the substitution // (`--`: the first `-` goes into the `—`). diff --git a/src/smart.rs b/src/smart.rs index 312579f..4e87d6a 100644 --- a/src/smart.rs +++ b/src/smart.rs @@ -61,20 +61,49 @@ pub fn substitute(line: &str, before: &str, typed: char, on: bool, literal: bool } /// Whether `off` sits in a context that must stay literal: fenced or -/// indented code block, `code` span, frontmatter. Extend here when math -/// (`$…$`) lands — callers stay unchanged. -pub fn literal_at(a: &Analysis, off: usize) -> bool { +/// indented code block, `code` span, frontmatter. `before` is the line +/// prefix up to `off`, so an as-yet-unclosed `code` span counts too — +/// the tree only flags closed spans, but someone typing `` `x --` `` +/// linearly is already inside one. Extend here when math (`$…$`) +/// lands — callers stay unchanged. +pub fn literal_at(a: &Analysis, off: usize, before: &str) -> bool { if matches!(a.lines[a.line_of(off)].1, Kind::Code) { return true; } if a.metadata.iter().any(|r| r.start < off && off <= r.end) { return true; } - // Inside a code span is strictly between two CODE-flagged bytes; a - // caret right at either edge is still prose. - off > 0 + // Inside a closed code span is strictly between two CODE-flagged + // bytes; a caret right at either edge is still prose. + (off > 0 && a.flags.get(off - 1).is_some_and(|f| f & CODE != 0) - && a.flags.get(off).is_some_and(|f| f & CODE != 0) + && a.flags.get(off).is_some_and(|f| f & CODE != 0)) + || unclosed_span(before) +} + +/// Is `before` inside an unclosed backtick run? A run of `n` opens a +/// span that a run of exactly `n` closes; `\`-escaped runs don't count. +fn unclosed_span(before: &str) -> bool { + let mut open: Vec = Vec::new(); + let bytes = before.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'`' && (i == 0 || bytes[i - 1] != b'\\') { + let mut n = 1; + while i + n < bytes.len() && bytes[i + n] == b'`' { + n += 1; + } + if open.last() == Some(&n) { + open.pop(); + } else { + open.push(n); + } + i += n; + } else { + i += 1; + } + } + !open.is_empty() } #[cfg(test)] @@ -154,21 +183,33 @@ mod tests { fn literal_at_code_and_frontmatter() { // Fenced code block. let a = analysis("```\nfoo\n```"); - assert!(literal_at(&a, 5)); + assert!(literal_at(&a, 5, "f")); let a = analysis("a `b c` d"); // Inside the code span (between `b` and ` `). - assert!(literal_at(&a, 4)); + assert!(literal_at(&a, 4, "a `b")); // At the span's left edge and right after it: prose. - assert!(!literal_at(&a, 2)); - assert!(!literal_at(&a, 7)); - assert!(!literal_at(&a, 8)); + assert!(!literal_at(&a, 2, "a ")); + assert!(!literal_at(&a, 7, "a `b c`")); + assert!(!literal_at(&a, 8, "a `b c` ")); + // An unclosed span counts: typing `` `x --` `` linearly is code + // even though the tree only flags the closed span. + let a = analysis("`tail --"); + assert!(literal_at(&a, 5, "`tail")); + assert!(literal_at(&a, 6, "`tail -")); + // …and a closed one leaves prose outside. + assert!(!literal_at(&a, 0, "")); + let a = analysis(""); + assert!(literal_at(&a, 0, "`x")); + assert!(!literal_at(&a, 0, "`x` ")); + assert!(literal_at(&a, 0, "``x `")); + assert!(!literal_at(&a, 0, "\\`x ")); // Frontmatter. let a = analysis("---\ntitle: x\n---\nbody"); - assert!(literal_at(&a, 8)); - assert!(literal_at(&a, 16)); - assert!(!literal_at(&a, 18)); + assert!(literal_at(&a, 8, "itle")); + assert!(literal_at(&a, 16, "-")); + assert!(!literal_at(&a, 18, "")); // Plain prose. let a = analysis("hello \"x\""); - assert!(!literal_at(&a, 6)); + assert!(!literal_at(&a, 6, "hello ")); } }