mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Merge #1258: Text: AutoFormat starts bulleted and numbered lists as you type
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user