Text: AutoFormat starts bulleted and numbered lists as you type

A paragraph that starts with *, -, --, >, <>, 1., 1), I., i., A., a.
or a) followed by a space or tab now becomes a bulleted or numbered
list in that style through format.bullets or format.numbering, and the
typed lead and space are removed. The keystroke is one undo step.
Backspace right after the change puts the typed text and the
paragraph's formatting back.

Tools ▸ AutoCorrect has "Automatic bulleted and numbered lists" to turn
it off, and text.autocorrect takes and reports it as lists.
This commit is contained in:
dotCooCoo
2026-10-23 22:06:56 -07:00
parent 5fadda5ecf
commit e04ef2b4a8
6 changed files with 170 additions and 6 deletions
+90 -2
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AutoCorrect Options",
["Tools"],
None,
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?, lists?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
always,
autocorrect_options
),
@@ -631,6 +631,13 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) {
return Ok(());
}
if typing && s.prefs.ac_lists {
// A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list.
let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? {
return Ok(());
}
}
let mut word_end = end - 1;
let mut start = word_end;
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
@@ -679,6 +686,83 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
Ok(())
}
/// A list AutoFormat started: the text being edited right after it, the typed lead with its
/// space, the paragraph's own formatting before, and the revision the change left.
#[derive(Clone, Debug)]
pub struct AutoList {
sel: TextSel,
typed: String,
props: deckcraft_model::text::ParaProps,
revision: u64,
}
/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and
/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering
/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`.
fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> {
let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c})));
let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme})));
match lead {
"*" => bullet("•"),
"-" | "–" => bullet("–"),
"--" | "—" => bullet("■"),
">" => bullet("➢"),
"<>" => bullet("◆"),
"1." => number("arabicPeriod"),
"1)" => number("arabicParenR"),
"I." => number("romanUcPeriod"),
"i." => number("romanLcPeriod"),
"A." => number("alphaUcPeriod"),
"a." => number("alphaLcPeriod"),
"a)" => number("alphaLcParenR"),
_ => None,
}
}
/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a
/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed
/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed.
fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result<bool> {
if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') {
return Ok(false);
}
let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default();
let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) };
let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
let para = t.caret.0;
let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default();
edit_text(s, |body, tt| {
ed::delete(body, (para, 0), (para, end));
tt.anchor = (para, 0);
tt.caret = (para, 0);
})?;
s.execute(id, &params)?;
let st = s.doc_mut()?;
if let Some(sel) = st.selection.text.clone() {
st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision });
}
Ok(true)
}
/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and
/// space and the formatting it had. `false` when the last change was anything else.
fn undo_auto_list(s: &mut Session) -> Result<bool> {
let st = s.doc_mut()?;
let Some(a) = st.auto_list.take() else { return Ok(false) };
if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) {
return Ok(false);
}
edit_text(s, |body, t| {
if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) {
p.props = a.props.clone();
}
let c = ed::insert(body, a.sel.caret, &a.typed, None);
t.anchor = c;
t.caret = c;
})?;
Ok(true)
}
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
/// superscripts it.
fn is_ordinal(word: &str) -> bool {
@@ -726,6 +810,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
("capsLock", &mut pr.ac_caps_lock),
("fractions", &mut pr.ac_fractions),
("ordinals", &mut pr.ac_ordinals),
("lists", &mut pr.ac_lists),
] {
if let Some(v) = p.get(key).and_then(Value::as_bool) {
*slot = v;
@@ -772,7 +857,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
"ordinals": pr.ac_ordinals, "entries": list,
"ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list,
}))
}
@@ -1026,6 +1111,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
if dir == "backward" && undo_auto_list(s)? {
return ok();
}
let dir = dir.to_string();
edit_text(s, move |body, t| {
let (a, b) = t.ordered();
+8 -1
View File
@@ -225,6 +225,9 @@ pub struct DocState {
/// The last Duplicate: where it happened and each original with its copy, so duplicating the
/// copies again repeats how far they were moved, as in PowerPoint (#966).
pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>,
/// The list AutoFormat just started from a typed `* ` or `1. `, so Backspace right after
/// can put the typed text back.
pub auto_list: Option<cmd::text::AutoList>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -253,6 +256,7 @@ impl DocState {
composition: None,
compare: None,
last_duplicate: None,
auto_list: None,
}
}
pub fn is_dirty(&self) -> bool {
@@ -328,13 +332,15 @@ pub struct Prefs {
pub smart_quotes: bool,
/// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first
/// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) and starts
/// bulleted and numbered lists (`* `, `1. `) as you type.
pub ac_two_initials: bool,
pub ac_sentence_caps: bool,
pub ac_days: bool,
pub ac_caps_lock: bool,
pub ac_fractions: bool,
pub ac_ordinals: bool,
pub ac_lists: bool,
/// Your own "replace … with …" entries; they win over the built-in list.
pub ac_entries: Vec<(String, String)>,
/// Built-in entries you deleted.
@@ -360,6 +366,7 @@ impl Default for Prefs {
ac_caps_lock: true,
ac_fractions: true,
ac_ordinals: true,
ac_lists: true,
ac_entries: Vec::new(),
ac_removed: Vec::new(),
author: "Presenter".into(),
+65
View File
@@ -3388,6 +3388,71 @@ fn autocorrect_superscripts_ordinals_as_you_type() {
assert_eq!(typed(&mut s, "2nd "), "2nd ");
}
#[test]
fn autoformat_starts_bulleted_and_numbered_lists_as_you_type() {
use deckcraft_model::text::Bullet;
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
let para = |s: &Session| {
let st = s.doc().unwrap();
let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap();
let p = sh.text.as_ref().unwrap().paragraphs[0].clone();
(p.text(), p.props.bullet)
};
// Clear the title (and its paragraph formatting) and type `text` one character at a time.
let typed = |s: &mut Session, text: &str| {
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.selectAll", &json!({})).ok();
s.execute("text.delete", &json!({"dir": "backward"})).ok();
s.execute("format.bullets", &json!({"on": false})).unwrap();
for c in text.chars() {
s.execute("text.insert", &json!({"text": c.to_string()})).unwrap();
}
para(s)
};
let ch = |c: &str| Some(Bullet::Char { char: c.into() });
let num = |scheme: &str| Some(Bullet::AutoNum { scheme: scheme.into(), start_at: 1 });
for (text, bullet) in [
("* x", ch("•")),
("- x", ch("–")),
("– x", ch("–")),
("-- x", ch("■")),
("— x", ch("■")),
("> x", ch("➢")),
("<> x", ch("◆")),
("*\tx", ch("•")),
("1. x", num("arabicPeriod")),
("1) x", num("arabicParenR")),
("I. x", num("romanUcPeriod")),
("i. x", num("romanLcPeriod")),
("A. x", num("alphaUcPeriod")),
("a. x", num("alphaLcPeriod")),
("a) x", num("alphaLcParenR")),
] {
assert_eq!(typed(&mut s, text), ("x".to_string(), bullet), "{text:?}");
}
// Only a lead at the start of the paragraph, and only before a space or tab.
let none = Some(Bullet::None);
assert_eq!(typed(&mut s, "9 * x"), ("9 * x".to_string(), none.clone()));
assert_eq!(typed(&mut s, "*, x"), ("*, x".to_string(), none.clone()));
assert_eq!(typed(&mut s, "2. x"), ("2. x".to_string(), none.clone()));
assert_eq!(typed(&mut s, "b) x"), ("b) x".to_string(), none.clone()));
// Backspace right after puts the lead and the space back, with the paragraph as it was.
assert_eq!(typed(&mut s, "* "), (String::new(), ch("•")));
s.execute("text.delete", &json!({"dir": "backward"})).unwrap();
assert_eq!(para(&s), ("* ".to_string(), none.clone()));
s.execute("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(para(&s), ("* x".to_string(), none.clone()));
// After anything else Backspace deletes as usual.
assert_eq!(typed(&mut s, "1. xy"), ("xy".to_string(), num("arabicPeriod")));
s.execute("text.delete", &json!({"dir": "backward"})).unwrap();
assert_eq!(para(&s), ("x".to_string(), num("arabicPeriod")));
// The AutoCorrect option turns it off.
assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["lists"], true);
s.execute("text.autocorrect", &json!({"lists": false})).unwrap();
assert_eq!(typed(&mut s, "* x"), ("* x".to_string(), none));
}
#[test]
fn theme_variants_apply_and_report_the_current_one() {
let mut s = session();