Merge #1258: Text: AutoFormat starts bulleted and numbered lists as you type

This commit is contained in:
aiongg
2026-10-25 02:21:58 +00:00
6 changed files with 179 additions and 6 deletions
+1
View File
@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
+92 -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
),
@@ -426,6 +426,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
c
})?;
autocorrect(s, text.chars().count() == 1)?;
// AutoCorrect can move the caret: a replaced word, or a list lead it removed.
let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret);
Ok(json!({"caret": [pos.0, pos.1]}))
}
@@ -632,6 +634,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()) {
@@ -680,6 +689,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 {
@@ -727,6 +813,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;
@@ -773,7 +860,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,
}))
}
@@ -1028,6 +1115,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(),
+72
View File
@@ -3580,6 +3580,10 @@ fn autocorrect_options_as_you_type() {
};
assert_eq!(typed(&mut s, "hello world. see you monday, i think. "), "Hello world. See you Monday, I think. ");
assert_eq!(typed(&mut s, "THe tHIS 1/2 recieved... "), "The This ½ received… ");
// `text.insert` reports the caret after the correction.
assert_eq!(typed(&mut s, "(c)"), "(c)");
assert_eq!(s.execute("text.insert", &json!({"text": " "})).unwrap()["caret"], json!([0, 2]));
assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "© ");
// Options turn rules off.
s.execute("text.autocorrect", &json!({"sentenceCaps": false, "days": false})).unwrap();
assert_eq!(typed(&mut s, "hello monday "), "hello monday ");
@@ -3633,6 +3637,74 @@ 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()));
// The space that starts the list reports the caret at the start of the emptied paragraph.
assert_eq!(typed(&mut s, "*"), ("*".to_string(), none.clone()));
assert_eq!(s.execute("text.insert", &json!({"text": " "})).unwrap()["caret"], json!([0, 0]));
assert_eq!(para(&s), (String::new(), ch("•")));
// Backspace right after puts the lead and the space back, with the paragraph as it was.
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();
+1
View File
@@ -2211,6 +2211,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key");
ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)");
ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript");
ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists");
ui.checkbox(&mut pr.autocorrect, "Replace text as you type");
ui.add_space(4.0);
let mut from = d.get("from", "");
+5 -3
View File
@@ -260,9 +260,10 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
installed (always on Windows and the web) only a short list of common misspellings is found.
`review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has
PowerPoint's options (two initial capitals, sentence and day capitals, caps lock, fractions, an
editable replace list), and AutoFormat superscripts ordinals (1st) as you type; no automatic
bulleted lists, no undo of a single correction.
- **Estimate:** 1–2 h (bundle openly licensed dictionaries); 2–4 h (AutoFormat as you type).
editable replace list), and AutoFormat superscripts ordinals (1st) and starts bulleted and
numbered lists (`* `, `1. `, Backspace puts the typed text back) as you type; no undo of a
single correction.
- **Estimate:** 1–2 h (bundle openly licensed dictionaries).
### Equations
- **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open.
@@ -357,6 +358,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | trivial | AutoFormat starts bulleted and numbered lists as you type (`* `, `-- `, `> `, `1. `, `a) ` and the rest), with an AutoCorrect option to turn it off |
| 2026-10-22 | minor | Animation Pane ▸ Play From starts the preview at the selected effect; gap 10 keeps the effect gallery (7–13 h) |
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |