Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/app/settings_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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>) {
self.settings.set_smart_quotes(on);
self.editor.update(cx, |ed, cx| ed.set_smart_quotes(on, cx));
self.save_settings(window, cx);
}
}
67 changes: 62 additions & 5 deletions src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ pub struct LiveEditor {
find_current: Option<usize>,
/// 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<usize>, &'static str)>,
}

impl EventEmitter<Changed> for LiveEditor {}
Expand Down Expand Up @@ -250,6 +255,8 @@ impl LiveEditor {
finds: Vec::new(),
find_current: None,
raw_tables: false,
smart_quotes: true,
last_subst: None,
}
}

Expand Down Expand Up @@ -314,6 +321,13 @@ impl LiveEditor {
}
}

pub fn set_smart_quotes(&mut self, on: bool, cx: &mut Context<Self>) {
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<usize>, cx: &mut Context<Self>) {
self.buf.set_sel(r);
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<Self>) {
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>) {
self.buf.insert(s);
self.changed(cx);
Expand Down Expand Up @@ -885,6 +915,7 @@ impl LiveEditor {
fn after_move(&mut self, cx: &mut Context<Self>) {
self.autoscroll = true;
self.goal_x = None;
self.last_subst = None;
cx.notify();
}

Expand Down Expand Up @@ -1098,11 +1129,40 @@ impl EntityInputHandler for LiveEditor {
_: &mut Window,
cx: &mut Context<Self>,
) {
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);
let before = &text[line.start..r.start];
if let Some(s) = crate::smart::substitute(
&text[line.clone()],
before,
new.chars().next().unwrap_or_default(),
true,
crate::smart::literal_at(&self.analysis, r.start, before),
) {
// `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(
Expand Down Expand Up @@ -1177,10 +1237,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);
Expand Down
9 changes: 9 additions & 0 deletions src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ pub enum Key {
TableInsertColumnRight,
TableDeleteRow,
TableDeleteColumn,
// Typographic substitution
SmartQuotes,
SmartQuotesHint,
}

impl Key {
Expand Down Expand Up @@ -446,6 +449,8 @@ impl Key {
Key::TableInsertColumnRight,
Key::TableDeleteRow,
Key::TableDeleteColumn,
Key::SmartQuotes,
Key::SmartQuotesHint,
];
}

Expand Down Expand Up @@ -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.",
}
}

Expand Down Expand Up @@ -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.",
}
}

Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod md;
#[cfg(test)]
mod perf;
mod search;
mod smart;
mod spaces;
mod store;
mod table;
Expand Down
14 changes: 9 additions & 5 deletions src/md.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MarkSpan>,
/// YAML (`---`) / TOML (`+++`) frontmatter ranges, in buffer order.
pub metadata: Vec<Range<usize>>,
}

pub struct Analyzer {
Expand Down Expand Up @@ -236,6 +238,7 @@ impl Analyzer {
callouts: Vec::new(),
tables: Vec::new(),
marks: Vec::new(),
metadata: Vec::new(),
};
if len == 0 {
return out;
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading