authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 18:26:13-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:55:46-07:00
loge9e724551a1f4aec871dbe9a3ce15eb97c1fe1a8
tree0e2cd539ce9218876196a6f7c85453ef05a45c3b
parent1de0431d8bcf7c9e558be7762f0996e76eb899b2
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Markdown shortcuts turn typed Markdown into OneNote formatting

`# ` to `###### ` give Heading 1 to 6, `> ` Quote, `[ ] `, `[x] ` and `[X] ` a To Do check box, and `* `, `- `, `1. `, `1) ` and `a. ` the lists OneNote 2010's own AutoFormat makes of them: bullets 2 and 25, numbers `##.` and `##)`, the lettered sequence, and a count restarting at a typed number. Its AutoFormat makes `> ` an arrow bullet; here it is the Quote style. `**bold**`, `*italic*`, `_italic_`, `~~strike~~` and `` `code` `` format the text between, but not inside words, URLs, links, equations or code. Each is one edit after the typing, which Undo takes back to the characters typed; Backspace does too after a paragraph marker. A page written with every trigger and the built-in theme cold-opens in OneNote 2010 without complaint, its h1 to h6 and blockquote quick styles, lists, To Do states and inline formatting reading back as written; its list XML matches what OneNote's AutoFormat wrote for the same markers. "Markdown Shortcuts", on by default, is in the toolbar's More View Options, the command palette, Options > Editing and the iOS More menu, and saved in the settings. Assisted-by: claude-opus-5.5

13 files changed, 800 insertions(+), 39 deletions(-)

apps/ios/Snowbound/App.swift+20-2
......@@ -71,8 +71,8 @@ enum Appearance {
7171 }
7272}
7373
74/// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened
75/// from then on take.
74/// OneNote's Snap To Grid and Default font, and Markdown Shortcuts, kept between launches,
75/// which the pages opened from then on take.
7676enum Editing {
7777 /// OneNote 2010's Default font sizes, in points.
7878 static let sizes: [Float] = [8, 9, 9.5, 10, 10.5, 11, 11.5, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
......@@ -86,6 +86,14 @@ enum Editing {
8686 }
8787 }
8888
89 static var markdownShortcuts: Bool {
90 get { !UserDefaults.standard.bool(forKey: "ignoreMarkdown") }
91 set {
92 UserDefaults.standard.set(!newValue, forKey: "ignoreMarkdown")
93 apply()
94 }
95 }
96
8997 static var face: String {
9098 get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" }
9199 set {
......@@ -105,6 +113,16 @@ enum Editing {
105113 static func apply() {
106114 sb_set_snap_to_grid(snapToGrid)
107115 sb_set_default_font(face, size)
116 sb_set_markdown_shortcuts(markdownShortcuts)
117 }
118
119 /// Markdown Shortcuts: typed Markdown becomes formatting.
120 static func markdownAction() -> UIAction {
121 let action = UIAction(title: "Markdown Shortcuts", image: UIImage(systemName: "number")) { _ in
122 markdownShortcuts.toggle()
123 }
124 action.state = markdownShortcuts ? .on : .off
125 return action
108126 }
109127
110128 /// Options' Default font: the face and size new text takes.
apps/ios/Snowbound/Lists.swift+1
......@@ -131,6 +131,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
131131 })
132132 actions.append(Appearance.menu())
133133 actions.append(Editing.fontMenu())
134 actions.append(Editing.markdownAction())
134135 provide(actions)
135136 }
136137 ])
crates/canvas/src/editor.rs+21-4
......@@ -32,6 +32,7 @@ mod format;
3232mod html;
3333mod ink;
3434mod link;
35mod markdown;
3536mod ops;
3637pub(crate) mod page;
3738mod recording;
......@@ -179,6 +180,12 @@ pub struct CanvasEditor {
179180 /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`,
180181 /// and Enter a NextStyle the page lacks from here.
181182 pub styles: BTreeMap<String, Definition>,
183 /// OneNote 2010's gallery styles, which Markdown shortcuts apply where the page's theme
184 /// gives none; none turns the shortcuts off.
185 pub markdown: Option<BTreeMap<String, Definition>>,
186 /// The outline, caret and undo depth a paragraph's Markdown marker left, where Backspace
187 /// takes it back.
188 formatted: Option<(ExGuid, TextPosition, usize)>,
182189}
183190
184191/// Imported page state the editable content does not carry.
......@@ -1211,6 +1218,7 @@ impl CanvasEditor {
12111218 fresh.stored = self.stored.take();
12121219 fresh.default_font = std::mem::take(&mut self.default_font);
12131220 fresh.styles = std::mem::take(&mut self.styles);
1221 fresh.markdown = self.markdown.take();
12141222 *self = fresh;
12151223 Ok(true)
12161224 }
......@@ -1583,6 +1591,8 @@ impl CanvasEditor {
15831591 provisional: BTreeMap::new(),
15841592 default_font: DefaultFont::default(),
15851593 styles: BTreeMap::new(),
1594 markdown: None,
1595 formatted: None,
15861596 })
15871597 }
15881598
......@@ -3525,13 +3535,13 @@ impl CanvasEditor {
35253535 }
35263536
35273537 /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line
3528 /// of text joins the typing run before it.
3538 /// of text joins the typing run before it, and may complete a Markdown shortcut.
35293539 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
35303540 if text.is_empty() || text.contains('\n') {
3531 self.insert_text(engine, text)
3532 } else {
3533 self.typed(|editor| editor.insert_text(engine, text))
3541 return self.insert_text(engine, text);
35343542 }
3543 self.typed(|editor| editor.insert_text(engine, text))?;
3544 self.markdown(engine, text)
35353545 }
35363546
35373547 /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the
......@@ -3867,6 +3877,12 @@ impl CanvasEditor {
38673877 let [anchor, focus] = selection.positions;
38683878 let mut range = anchor.min(focus)..anchor.max(focus);
38693879 let at_caret = range.is_empty();
3880 if backward
3881 && at_caret
3882 && self.formatted.take() == Some((self.active_outline().id, focus, self.undo.len()))
3883 {
3884 return self.undo(engine);
3885 }
38703886 if at_caret {
38713887 let outline = self.active_outline();
38723888 let paragraph = outline.paragraph_layout(focus.paragraph)?;
......@@ -4036,6 +4052,7 @@ impl CanvasEditor {
40364052 if let Some((page, _)) = &self.stored {
40374053 parked.refresh(page.clone(), engine)?;
40384054 parked.default_font = std::mem::take(&mut self.default_font);
4055 parked.markdown = self.markdown.take();
40394056 *self = parked;
40404057 }
40414058 Ok(())
crates/canvas/src/editor/format.rs+30-24
......@@ -542,6 +542,32 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T
542542
543543/// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing
544544/// did, the new one gives.
545/// `text` in paragraph style `definition` from style format `old`: it loses its character
546/// formatting but links, fields and language.
547pub(super) fn styled(text: &Paragraph, old: &Format, definition: &Definition) -> Paragraph {
548 restyle(text, 0..text.text().len(), |format| {
549 // An equation keeps its own formatting; what it took from the old style it takes
550 // from the new.
551 if [format.math, format.embedded_object].contains(&Some(true)) {
552 *format = followed(format, old, &definition.format);
553 return;
554 }
555 *format = Format {
556 hidden: format.hidden,
557 hyperlink: format.hyperlink,
558 hyperlink_label: format.hyperlink_label,
559 math: format.math,
560 embedded_object: format.embedded_object,
561 language: format.language,
562 alignment: format.alignment,
563 rtl: format.rtl,
564 list_spacing: format.list_spacing,
565 math_object: format.math_object.clone(),
566 ..definition.format.clone()
567 };
568 })
569}
570
545571fn followed(format: &Format, old: &Format, new: &Format) -> Format {
546572 let mut format = format.clone();
547573 macro_rules! follow {
......@@ -570,7 +596,7 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format {
570596}
571597
572598/// Seconds since 1980, as note tags date themselves.
573fn time32() -> Option<u32> {
599pub(super) fn time32() -> Option<u32> {
574600 let now = web_time::SystemTime::now()
575601 .duration_since(web_time::UNIX_EPOCH)
576602 .ok()?
......@@ -917,27 +943,7 @@ impl CanvasEditor {
917943 node.style = Some(style);
918944 let old = &olds[&node.id];
919945 let text = &mut node.text_mut().unwrap().text;
920 *text = restyle(text, 0..text.text().len(), |format| {
921 // An equation keeps its own formatting; what it took from the
922 // old style it takes from the new.
923 if [format.math, format.embedded_object].contains(&Some(true)) {
924 *format = followed(format, old, &definition.format);
925 return;
926 }
927 *format = Format {
928 hidden: format.hidden,
929 hyperlink: format.hyperlink,
930 hyperlink_label: format.hyperlink_label,
931 math: format.math,
932 embedded_object: format.embedded_object,
933 language: format.language,
934 alignment: format.alignment,
935 rtl: format.rtl,
936 list_spacing: format.list_spacing,
937 math_object: format.math_object.clone(),
938 ..definition.format.clone()
939 };
940 });
946 *text = styled(text, old, definition);
941947 }
942948 }
943949 }
......@@ -1286,7 +1292,7 @@ impl CanvasEditor {
12861292 /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its
12871293 /// shape and when, gives the last list one in place of any of its action type; an element
12881294 /// holds one tag of each action type, stored newest first.
1289 fn retag<const N: usize>(
1295 pub(super) fn retag<const N: usize>(
12901296 &self,
12911297 mut lists: [&mut Vec<Tag>; N],
12921298 kind: &Kind<'static>,
......@@ -1328,7 +1334,7 @@ impl CanvasEditor {
13281334
13291335 /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps
13301336 /// a cleared box's completion time as zero.
1331 fn toggle_checks(&self, mut tags: Vec<&mut Tag>) {
1337 pub(super) fn toggle_checks(&self, mut tags: Vec<&mut Tag>) {
13321338 let checked = tags
13331339 .iter()
13341340 .filter(|tag| self.check_box(tag).is_some())
crates/canvas/src/editor/markdown.rs created+668
......@@ -0,0 +1,668 @@
1//! Markdown shortcuts: Markdown typed at a paragraph's start or around text becomes the
2//! OneNote formatting it stands for, as one undo step after the typing, which Undo takes back
3//! to the characters typed, as Word's AutoFormat As You Type does.
4
5use super::format::{ListStyle, NoteTag, list_definition, styled, time32};
6use super::*;
7
8/// What a marker typed at a paragraph's start makes of it.
9#[derive(Debug, PartialEq)]
10enum Block {
11 /// A style of the Styles gallery, by stored name.
12 Style(String),
13 /// A list, its count restarting at the number given.
14 List(ListStyle, Option<u32>),
15 /// A To Do check box, checked or not.
16 ToDo(bool),
17}
18
19impl Block {
20 /// The lists are those OneNote 2010's AutoFormat makes of the same markers, but for `>`,
21 /// which it makes an arrow bullet.
22 fn of(marker: &str) -> Option<Self> {
23 Some(match marker {
24 "*" => Self::List(ListStyle::BULLET, None),
25 "-" => Self::List(ListStyle::Bullet(25), None),
26 "a." => Self::List(ListStyle::Number(2), None),
27 ">" => Self::Style("blockquote".into()),
28 "[ ]" => Self::ToDo(false),
29 "[x]" | "[X]" => Self::ToDo(true),
30 _ if marker.len() <= 6 && !marker.is_empty() && marker.bytes().all(|b| b == b'#') => {
31 Self::Style(format!("h{}", marker.len()))
32 }
33 _ => {
34 let (digits, style) = match marker.as_bytes().last()? {
35 b'.' => (&marker[..marker.len() - 1], ListStyle::NUMBER),
36 b')' => (&marker[..marker.len() - 1], ListStyle::Number(6)),
37 _ => return None,
38 };
39 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
40 return None;
41 }
42 let number = digits.parse::<u32>().ok()?;
43 Self::List(style, (number != 1).then_some(number))
44 }
45 })
46 }
47}
48
49#[derive(Clone, Copy)]
50enum Inline {
51 Bold,
52 Italic,
53 Strike,
54 Code,
55}
56
57/// Markers typed around text, a pair before its half.
58const INLINE: [(&str, Inline); 5] = [
59 ("**", Inline::Bold),
60 ("~~", Inline::Strike),
61 ("*", Inline::Italic),
62 ("_", Inline::Italic),
63 ("`", Inline::Code),
64];
65
66/// Whether no character of bytes `range` belongs to a link, field or equation, or is set in
67/// the Code style's font.
68fn plain(text: &Paragraph, range: Range<usize>, code_font: Option<&str>) -> bool {
69 let mut start = 0;
70 text.spans().iter().all(|span| {
71 let overlaps = start < range.end && span.end > range.start;
72 start = span.end;
73 let format = &span.format;
74 !overlaps
75 || ![
76 format.hidden,
77 format.hyperlink,
78 format.math,
79 format.embedded_object,
80 ]
81 .contains(&Some(true))
82 && (code_font.is_none() || format.font.as_deref() != code_font)
83 })
84}
85
86/// `text` without bytes `markers`, what lies between them changed by `change`; a paragraph
87/// left empty keeps the format of the first marker.
88fn unmarked(
89 text: &Paragraph,
90 markers: [Range<usize>; 2],
91 change: impl Fn(&mut Format),
92) -> Paragraph {
93 let [open, close] = markers;
94 let kept = [
95 (0..open.start, false),
96 (open.end..close.start, true),
97 (close.end..text.text().len(), false),
98 ];
99 let mut runs = Vec::new();
100 let mut start = 0;
101 for span in text.spans() {
102 for (range, inside) in &kept {
103 let [from, to] = [range.start, range.end].map(|at| at.clamp(start, span.end));
104 if from < to {
105 let mut format = span.format.clone();
106 if *inside {
107 change(&mut format);
108 }
109 runs.push((text.text()[from..to].to_owned(), format));
110 }
111 }
112 start = span.end;
113 }
114 if runs.is_empty() {
115 let first = text.spans().partition_point(|span| span.end <= open.start);
116 let format = text.spans()[first.min(text.spans().len() - 1)]
117 .format
118 .clone();
119 return Paragraph::new(String::new(), format);
120 }
121 Paragraph::from_runs(runs)
122}
123
124impl CanvasEditor {
125 /// The Styles gallery's style `name`: the page's theme's, else OneNote 2010's.
126 fn gallery(&self, name: &str) -> Option<Definition> {
127 self.styles
128 .get(name)
129 .or_else(|| self.markdown.as_ref()?.get(name))
130 .cloned()
131 }
132
133 /// Applies the Markdown shortcut that `typed`, just typed at the caret, completes, as its
134 /// own undo step; a marker at the paragraph's start can then be taken back by Backspace.
135 pub(super) fn markdown(
136 &mut self,
137 engine: &mut TextEngine,
138 typed: &str,
139 ) -> Result<(), EditorError> {
140 let outline = self.active_outline();
141 let [anchor, focus] = outline.selection.positions;
142 if self.markdown.is_none()
143 || !matches!(typed, " " | "*" | "_" | "~" | "`")
144 || outline.title
145 || anchor != focus
146 || self.page_selected()
147 {
148 return Ok(());
149 }
150 let (container, index, node) = outline
151 .document
152 .leaf(focus.paragraph)
153 .ok_or(EditError::InvalidRange)?;
154 let id = outline.id;
155 let mut node = node.clone();
156 if node
157 .style
158 .and_then(|style| self.definitions.get(&style))
159 .is_some_and(|style| matches!(&style.kind, Kind::Style { name: Some(name), .. } if name == "code"))
160 {
161 return Ok(());
162 }
163 let text = node.text().ok_or(EditError::InvalidRange)?.text.clone();
164 let caret = text.byte_offset(focus.offset)?;
165 let code_font = self.gallery("code").and_then(|code| code.format.font);
166 let code_font = code_font.as_deref();
167 let (replacement, at, block) = if typed == " " {
168 let Some(block) = text.text()[..caret].strip_suffix(' ').and_then(Block::of) else {
169 return Ok(());
170 };
171 if !plain(&text, 0..caret, code_font) {
172 return Ok(());
173 }
174 let rest = unmarked(&text, [0..caret, caret..caret], |_| {});
175 (rest, 0, Some(block))
176 } else {
177 let Some((rest, at)) = inline(&text, caret, code_font) else {
178 return Ok(());
179 };
180 (rest, at, None)
181 };
182 let typing = text.format_at(focus.offset)?.clone();
183 let text = &mut node.text_mut().unwrap().text;
184 *text = replacement;
185 let offset = text.utf16_offset(at)?;
186 let line = block.is_some();
187 match block {
188 Some(Block::Style(name)) => {
189 let Some(definition) = self.gallery(&name) else {
190 return Ok(());
191 };
192 let old = self.style_format(node.style)?;
193 node.style = Some(self.define_style(&definition)?);
194 let text = &mut node.text_mut().unwrap().text;
195 *text = styled(text, &old, &definition);
196 }
197 Some(Block::List(style, restart)) => {
198 let format = &node.text().unwrap().text.spans()[0].format;
199 let mut definition = list_definition(style, format);
200 if let Kind::List { restart: count, .. } = &mut definition.kind {
201 *count = restart;
202 }
203 let list = onestore::page::text::new_id()?;
204 self.definitions.insert(list, definition);
205 node.lists = vec![list];
206 }
207 // A check box takes a list's place, as To Do List does.
208 Some(Block::ToDo(checked)) => {
209 node.lists.clear();
210 let tag = &NoteTag::defaults()[0];
211 let definition = tag.definition(0);
212 let tag_id = self.define_tag(&definition)?;
213 let ParagraphContent::Text(text) = &mut node.content else {
214 unreachable!()
215 };
216 self.retag(
217 [&mut node.tags, &mut text.tags],
218 &definition.kind,
219 Some((tag_id, tag.shape, time32())),
220 );
221 if checked {
222 self.toggle_checks(node.tags.iter_mut().chain(&mut text.tags).collect());
223 }
224 }
225 None => {}
226 }
227 let caret = TextPosition {
228 paragraph: focus.paragraph,
229 offset,
230 };
231 self.commit(
232 engine,
233 DocumentEdit {
234 columns: BTreeMap::new(),
235 container,
236 range: index..index + 1,
237 replacement: vec![node],
238 },
239 Selection {
240 positions: [caret; 2],
241 affinities: [Affinity::Upstream; 2],
242 },
243 )?;
244 self.typing = None;
245 if line {
246 self.formatted = Some((id, caret, self.undo.len()));
247 } else {
248 // Typing goes on in the format the closing marker was typed in.
249 self.pending = Some((id, caret, typing));
250 }
251 Ok(())
252 }
253}
254
255/// `text` with the span whose closing marker ends at byte `caret` formatted and its markers
256/// gone, and where the caret goes then; none where no span closes there, or it would
257/// close inside a word, a URL, a link, an equation or code.
258fn inline(text: &Paragraph, caret: usize, code_font: Option<&str>) -> Option<(Paragraph, usize)> {
259 let source = text.text();
260 let before = &source[..caret];
261 let (marker, inline) = INLINE
262 .into_iter()
263 .find(|(marker, _)| before.ends_with(marker))?;
264 let edge = marker.chars().next()?;
265 let close = caret - marker.len();
266 let open = before[..close].rfind(marker)?;
267 let content = &before[open + marker.len()..close];
268 let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == edge);
269 if content.is_empty()
270 || content.starts_with(char::is_whitespace)
271 || content.ends_with(char::is_whitespace)
272 || content.starts_with(edge)
273 || content.ends_with(edge)
274 || word(before[..open].chars().next_back())
275 || source[caret..].chars().next().is_some_and(char::is_alphanumeric)
276 || !plain(text, open..caret, code_font)
277 // An odd backtick before the span opens code around it.
278 || !matches!(inline, Inline::Code) && before[..open].matches('`').count() % 2 == 1
279 {
280 return None;
281 }
282 let token = before[..open]
283 .rfind(char::is_whitespace)
284 .map_or(0, |space| space + 1);
285 let url = &before[token..];
286 if url.contains("://") || url.starts_with("www.") {
287 return None;
288 }
289 let font = code_font.unwrap_or("Consolas").to_owned();
290 let rest = unmarked(
291 text,
292 [open..open + marker.len(), close..caret],
293 |format| match inline {
294 Inline::Bold => format.bold = Some(true),
295 Inline::Italic => format.italic = Some(true),
296 Inline::Strike => format.strike = Some(true),
297 Inline::Code => format.font = Some(font.clone()),
298 },
299 );
300 Some((rest, caret - 2 * marker.len()))
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn markers_name_their_blocks() {
309 assert_eq!(Block::of("###"), Some(Block::Style("h3".into())));
310 assert_eq!(Block::of("#######"), None);
311 assert_eq!(
312 Block::of("2."),
313 Some(Block::List(ListStyle::NUMBER, Some(2)))
314 );
315 assert_eq!(
316 Block::of("1)"),
317 Some(Block::List(ListStyle::Number(6), None))
318 );
319 assert_eq!(Block::of("."), None);
320 assert_eq!(Block::of("1a."), None);
321 assert_eq!(Block::of("[x]"), Some(Block::ToDo(true)));
322 }
323
324 fn calibri() -> Format {
325 Format {
326 font: Some("Calibri".into()),
327 font_size: Some(11.0),
328 ..Format::default()
329 }
330 }
331
332 fn style(name: &str, font: &str) -> Definition {
333 Definition {
334 kind: Kind::Style {
335 name: Some(name.into()),
336 next: name.starts_with('h').then(|| "p".into()),
337 },
338 format: Format {
339 bold: Some(name.starts_with('h')),
340 font: Some(font.into()),
341 font_size: Some(14.0),
342 ..Format::default()
343 },
344 }
345 }
346
347 /// An editor with Markdown shortcuts on, its one paragraph holding `text`, the caret
348 /// at its end.
349 fn opened(engine: &mut TextEngine, text: &str) -> CanvasEditor {
350 let paragraph = Paragraph::new(text.into(), calibri());
351 let mut editor =
352 CanvasEditor::new(engine, TextDocument::new(vec![paragraph]).unwrap(), 300.0).unwrap();
353 let gallery = ["h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p"]
354 .map(|name| (name.to_owned(), style(name, "Calibri")));
355 editor.markdown = Some(
356 gallery
357 .into_iter()
358 .chain([("code".to_owned(), style("code", "Consolas"))])
359 .collect(),
360 );
361 let end = text.encode_utf16().count() as u32;
362 editor
363 .select(
364 [TextPosition {
365 paragraph: 0,
366 offset: end,
367 }; 2]
368 .into(),
369 )
370 .unwrap();
371 editor
372 }
373
374 fn typed(engine: &mut TextEngine, editor: &mut CanvasEditor, text: &str) {
375 for character in text.chars() {
376 editor
377 .insert(engine, character.encode_utf8(&mut [0; 4]))
378 .unwrap();
379 }
380 }
381
382 fn text(editor: &CanvasEditor) -> String {
383 editor
384 .active_outline()
385 .document
386 .paragraphs()
387 .next()
388 .unwrap()
389 .text()
390 .to_owned()
391 }
392
393 /// The paragraph's runs, with whether each is bold, italic, struck and in Consolas.
394 fn runs(editor: &CanvasEditor) -> Vec<(String, [bool; 4])> {
395 let paragraph = editor
396 .active_outline()
397 .document
398 .paragraphs()
399 .next()
400 .unwrap();
401 let mut start = 0;
402 paragraph
403 .spans()
404 .iter()
405 .map(|span| {
406 let text = paragraph.text()[start..span.end].to_owned();
407 start = span.end;
408 let format = &span.format;
409 let on = |value: Option<bool>| value == Some(true);
410 let code = format.font.as_deref() == Some("Consolas");
411 (
412 text,
413 [on(format.bold), on(format.italic), on(format.strike), code],
414 )
415 })
416 .collect()
417 }
418
419 #[test]
420 fn hashes_make_headings_that_undo_and_backspace_take_back_to_the_typed_text() {
421 let mut engine = TextEngine::default();
422 for level in 1..=6 {
423 let mut editor = opened(&mut engine, "");
424 typed(&mut engine, &mut editor, &format!("{} ", "#".repeat(level)));
425 assert_eq!(text(&editor), "");
426 let state = editor.format_state().unwrap();
427 assert_eq!(state.style, Some(format!("h{level}")));
428 typed(&mut engine, &mut editor, "Title");
429 assert_eq!(text(&editor), "Title");
430 assert_eq!(
431 runs(&editor),
432 [("Title".into(), [true, false, false, false])]
433 );
434 // Undo takes the typing, then the heading back to the marker.
435 editor.undo(&mut engine).unwrap();
436 assert_eq!(text(&editor), "");
437 editor.undo(&mut engine).unwrap();
438 assert_eq!(text(&editor), format!("{} ", "#".repeat(level)));
439 assert_eq!(editor.format_state().unwrap().style, None);
440 }
441 let mut editor = opened(&mut engine, "");
442 typed(&mut engine, &mut editor, "## ");
443 editor.delete(&mut engine, true).unwrap();
444 assert_eq!(text(&editor), "## ");
445 assert_eq!(editor.format_state().unwrap().style, None);
446 // A second Backspace deletes as ever.
447 editor.delete(&mut engine, true).unwrap();
448 assert_eq!(text(&editor), "##");
449 }
450
451 #[test]
452 fn markers_start_lists_to_dos_and_quotes_as_onenote_s_autoformat_does() {
453 let mut engine = TextEngine::default();
454 for (marker, list) in [
455 ("* ", ListStyle::BULLET),
456 ("- ", ListStyle::Bullet(25)),
457 ("1. ", ListStyle::NUMBER),
458 ("1) ", ListStyle::Number(6)),
459 ("a. ", ListStyle::Number(2)),
460 ] {
461 let mut editor = opened(&mut engine, "");
462 typed(&mut engine, &mut editor, marker);
463 assert_eq!(text(&editor), "", "{marker}");
464 assert_eq!(editor.format_state().unwrap().list, Some(list), "{marker}");
465 editor.delete(&mut engine, true).unwrap();
466 assert_eq!(text(&editor), marker);
467 assert_eq!(editor.format_state().unwrap().list, None);
468 }
469 let mut editor = opened(&mut engine, "");
470 typed(&mut engine, &mut editor, "3. ");
471 let node = &editor.active_outline().document.nodes()[0];
472 assert!(matches!(
473 editor.definitions[&node.lists[0]].kind,
474 Kind::List {
475 restart: Some(3),
476 ..
477 }
478 ));
479 for (marker, checked) in [("[ ] ", false), ("[x] ", true)] {
480 let mut editor = opened(&mut engine, "");
481 typed(&mut engine, &mut editor, marker);
482 assert_eq!(text(&editor), "");
483 let state = editor.format_state().unwrap();
484 assert_eq!(state.tags[0].0.label, "To Do");
485 let tag = &editor.active_outline().document.nodes()[0]
486 .text()
487 .unwrap()
488 .tags[0];
489 assert_eq!(tag.status & 1 != 0, checked);
490 }
491 let mut editor = opened(&mut engine, "");
492 typed(&mut engine, &mut editor, "> ");
493 assert_eq!(
494 editor.format_state().unwrap().style.as_deref(),
495 Some("blockquote")
496 );
497 // A marker before text already there formats it too.
498 let mut editor = opened(&mut engine, "words");
499 editor
500 .select(
501 [TextPosition {
502 paragraph: 0,
503 offset: 0,
504 }; 2]
505 .into(),
506 )
507 .unwrap();
508 typed(&mut engine, &mut editor, "# ");
509 assert_eq!(text(&editor), "words");
510 assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h1"));
511 }
512
513 #[test]
514 fn closing_markers_format_the_text_between_and_undo_returns_the_markers() {
515 let mut engine = TextEngine::default();
516 for (input, format) in [
517 ("**bold**", [true, false, false, false]),
518 ("*it*", [false, true, false, false]),
519 ("_it_", [false, true, false, false]),
520 ("~~gone~~", [false, false, true, false]),
521 ("`a*b*c`", [false, false, false, true]),
522 ] {
523 let mut editor = opened(&mut engine, "say ");
524 typed(&mut engine, &mut editor, input);
525 let inner = input.trim_matches(['*', '_', '~', '`']);
526 assert_eq!(
527 runs(&editor),
528 [("say ".into(), [false; 4]), (inner.into(), format),],
529 "{input}"
530 );
531 // What is typed next is plain.
532 typed(&mut engine, &mut editor, " on");
533 assert_eq!(runs(&editor).last().unwrap(), &(" on".into(), [false; 4]));
534 editor.undo(&mut engine).unwrap();
535 editor.undo(&mut engine).unwrap();
536 assert_eq!(text(&editor), format!("say {input}"), "{input}");
537 }
538 }
539
540 #[test]
541 fn markers_inside_words_urls_code_and_titles_or_with_shortcuts_off_stay_typed() {
542 let mut engine = TextEngine::default();
543 for input in [
544 "snake_case_name",
545 "2*3*4",
546 "a * b *",
547 "http://example.com/_a_",
548 "www.example.com/*a*",
549 "`a *b*",
550 "**",
551 "#tag ",
552 "x # ",
553 "####### ",
554 ] {
555 let mut editor = opened(&mut engine, "");
556 typed(&mut engine, &mut editor, input);
557 assert_eq!(text(&editor), input);
558 assert_eq!(runs(&editor), [(input.into(), [false; 4])], "{input}");
559 }
560 // A closing marker typed before a word's letters leaves them unformatted.
561 let mut editor = opened(&mut engine, "*a b");
562 editor
563 .select(
564 [TextPosition {
565 paragraph: 0,
566 offset: 3,
567 }; 2]
568 .into(),
569 )
570 .unwrap();
571 typed(&mut engine, &mut editor, "*");
572 assert_eq!(text(&editor), "*a *b");
573 let mut editor = opened(&mut engine, "");
574 editor.markdown = None;
575 typed(&mut engine, &mut editor, "# **a** ");
576 assert_eq!(text(&editor), "# **a** ");
577 // Code keeps what it holds.
578 let mut editor = opened(&mut engine, "");
579 let code = editor.gallery("code").unwrap();
580 editor.format(&mut engine, Formatting::Style(code)).unwrap();
581 typed(&mut engine, &mut editor, "# *a* ");
582 assert_eq!(text(&editor), "# *a* ");
583 }
584
585 /// Every shortcut stores ordinary OneNote formatting that reads back as itself.
586 /// `SNOWBOUND_MARKDOWN_EXPORT` names a new directory receiving the notebook, for a cold
587 /// open in OneNote.
588 #[test]
589 fn shortcuts_write_as_onenote_formatting() {
590 use onestore::{RevisionIndex, Store, document::Document};
591 const NOTEBOOK: &str = "../../corpus/paragraph-edit/before/notebook";
592 let section = std::fs::read(format!("{NOTEBOOK}/synthetic.one")).unwrap();
593 let page = |bytes: &[u8]| {
594 let store = Store::parse(bytes).unwrap();
595 let index = RevisionIndex::parse(&store).unwrap();
596 let document = Document::parse(&index).unwrap();
597 document
598 .pages()
599 .unwrap()
600 .into_iter()
601 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
602 .find(|(_, page)| page.title == "Split middle")
603 .unwrap()
604 };
605 let (space, source) = page(&section);
606 let mut engine = TextEngine::default();
607 let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap();
608 editor.markdown = opened(&mut engine, "").markdown;
609 let body = editor
610 .outlines()
611 .iter()
612 .find(|outline| !outline.title)
613 .unwrap()
614 .id;
615 editor.focus_outline(body).unwrap();
616 editor
617 .move_selection(&mut engine, Movement::DocumentEnd, false)
618 .unwrap();
619 for line in [
620 "Plain **bold** *italic* _also_ ~~struck~~ `code` end",
621 "# Heading one",
622 "## Heading two",
623 "### Heading three",
624 "#### Heading four",
625 "##### Heading five",
626 "###### Heading six",
627 "* Star",
628 "- Dash",
629 "1. One",
630 "2) Two",
631 "a. Alpha",
632 "3. Three",
633 "[ ] To do",
634 "[x] Done",
635 "[X] Done too",
636 "> Quoted",
637 ] {
638 editor.enter(&mut engine, false).unwrap();
639 typed(&mut engine, &mut editor, line);
640 }
641 let edited = editor.page().unwrap();
642 let written = super::super::ops::saved(&section, space, &mut editor);
643 let (_, reread) = page(&written);
644 let settled = |mut page: Page| {
645 for object in &mut page.objects {
646 if let PageObject::Outline(outline) = object {
647 for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) {
648 for tag in &mut text.tags {
649 tag.extra_set = 0;
650 }
651 }
652 }
653 }
654 page
655 };
656 assert_eq!(settled(reread).objects, settled(edited).objects);
657 if let Some(directory) = std::env::var_os("SNOWBOUND_MARKDOWN_EXPORT") {
658 let directory = std::path::Path::new(&directory);
659 std::fs::create_dir(directory).unwrap();
660 std::fs::write(directory.join("synthetic.one"), &written).unwrap();
661 std::fs::copy(
662 format!("{NOTEBOOK}/Open Notebook.onetoc2"),
663 directory.join("Open Notebook.onetoc2"),
664 )
665 .unwrap();
666 }
667 }
668}
crates/canvas/src/interaction/mod.rs+1
......@@ -363,6 +363,7 @@ impl PageView {
363363 scene: Option<(PageScene, [f32; 2])>,
364364 ) -> CanvasEditor {
365365 editor.default_font = std::mem::take(&mut self.editor.default_font);
366 editor.markdown = self.editor.markdown.take();
366367 let left = std::mem::replace(&mut self.editor, editor);
367368 self.scene = scene;
368369 self.drag = None;
crates/mobile/include/snowbound.h+1
......@@ -84,6 +84,7 @@ uint8_t sb_view_target(const View *view, float x, float y);
8484void sb_view_pressure(View *view, float pressure);
8585void sb_set_snap_to_grid(bool on);
8686void sb_set_default_font(const char *face, float size);
87void sb_set_markdown_shortcuts(bool on);
8788bool sb_view_press(View *view, float x, float y);
8889bool sb_view_drag(View *view, float x, float y);
8990bool sb_view_release(View *view);
crates/mobile/src/lib.rs+15-6
......@@ -154,7 +154,10 @@ impl Canvas {
154154 );
155155 page.touch = true;
156156 page.host_viewport = true;
157 (page.snap_to_grid, page.editor.default_font) = options().clone();
157 let markdown;
158 (page.snap_to_grid, page.editor.default_font, markdown) = options().clone();
159 page.editor.markdown =
160 markdown.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet());
158161 Ok(Self {
159162 page,
160163 space,
......@@ -636,12 +639,12 @@ fn pool_engine(engine: TextEngine) {
636639 }
637640}
638641
639/// The reader's Snap To Grid and Default font, which every page opened from then on takes,
640/// and new pages' titles the font's face.
641static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont)>> =
642 LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default())));
642/// The reader's Snap To Grid, Default font and Markdown Shortcuts, which every page opened
643/// from then on takes, and new pages' titles the font's face.
644static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont, bool)>> =
645 LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default(), true)));
643646
644fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> {
647fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont, bool)> {
645648 OPTIONS.lock().unwrap_or_else(|error| error.into_inner())
646649}
647650
......@@ -664,6 +667,12 @@ pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) {
664667 };
665668}
666669
670/// Markdown Shortcuts: typed Markdown becomes formatting.
671#[unsafe(no_mangle)]
672pub extern "C" fn sb_set_markdown_shortcuts(on: bool) {
673 options().2 = on;
674}
675
667676pub struct View {
668677 /// Taken apart on drop, which returns its text engine to `ENGINES`.
669678 canvas: ManuallyDrop<Canvas>,
crates/mobile/src/tests.rs+8-3
......@@ -742,15 +742,20 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
742742}
743743
744744#[test]
745fn pages_open_with_the_snap_and_default_font_options_set() {
746 // OneNote's defaults, so pages other tests open meanwhile see no change.
745fn pages_open_with_the_snap_default_font_and_markdown_options_set() {
746 // The defaults, so pages other tests open meanwhile see no change.
747747 sb_set_snap_to_grid(true);
748748 unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) };
749 assert_eq!(*options(), (true, canvas::editor::DefaultFont::default()));
749 sb_set_markdown_shortcuts(true);
750 assert_eq!(
751 *options(),
752 (true, canvas::editor::DefaultFont::default(), true)
753 );
750754 let (_directory, section) = features();
751755 let canvas = canvas(&section, "Paragraph controls");
752756 assert!(canvas.page.snap_to_grid);
753757 assert_eq!(canvas.page.editor.default_font.face, "Calibri");
758 assert!(canvas.page.editor.markdown.is_some());
754759}
755760
756761#[test]
crates/snowbound/src/commands.rs+17
......@@ -69,6 +69,8 @@ pub enum Id {
6969 FullPageView,
7070 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.
7171 HideSpelling,
72 /// Typed Markdown becomes formatting.
73 MarkdownShortcuts,
7274 /// The Spelling pane, on the next marked word.
7375 Spelling,
7476 PageColor,
......@@ -128,6 +130,11 @@ pub fn offered(id: Id) -> bool {
128130 id != Id::ScreenClipping || cfg!(target_os = "macos")
129131}
130132
133/// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`.
134pub fn markdown(on: bool) -> Option<BTreeMap<String, onestore::page::Definition>> {
135 on.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet())
136}
137
131138/// What a menu or toolbar offers: a command, or one of a list's entries.
132139#[derive(Clone, Debug, PartialEq)]
133140pub enum Choice {
......@@ -372,6 +379,7 @@ pub const COMMANDS: &[Command] = &[
372379 row!(Id::PageList, "Page List", NONE, NONE),
373380 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),
374381 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
382 row!(Id::MarkdownShortcuts, "Markdown Shortcuts", NONE, NONE),
375383 row!(
376384 Id::Spelling,
377385 "Spelling…",
......@@ -1259,6 +1267,10 @@ impl State {
12591267 enabled: !modal && self.spelling.is_some(),
12601268 checked: Some(self.hide_spelling),
12611269 },
1270 Id::MarkdownShortcuts => Status {
1271 enabled: !modal,
1272 checked: Some(self.view.editor.markdown.is_some()),
1273 },
12621274 Id::Spelling => enabled(text && self.view.spelling.is_some()),
12631275 Id::FormatPainter => Status {
12641276 enabled: text,
......@@ -1628,6 +1640,11 @@ impl State {
16281640 self.save_settings();
16291641 return Ok(());
16301642 }
1643 Id::MarkdownShortcuts => {
1644 self.view.editor.markdown = markdown(self.view.editor.markdown.is_none());
1645 self.save_settings();
1646 return Ok(());
1647 }
16311648 Id::PagesMatchTheme => {
16321649 self.light_pages = !self.light_pages;
16331650 self.follow_color_scheme();
crates/snowbound/src/main.rs+2
......@@ -1294,6 +1294,7 @@ impl State {
12941294 };
12951295 state.view.snap_to_grid = !stored.ignore_grid;
12961296 state.view.editor.default_font = stored.default_font;
1297 state.view.editor.markdown = commands::markdown(!stored.ignore_markdown);
12971298 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
12981299 let unsigned = state.notebooks.iter().find(|library| {
12991300 library.notebook.is_err() && library::server_address(&library.location).is_some()
......@@ -2859,6 +2860,7 @@ impl State {
28592860 Rule,
28602861 Run(Cmd::HideSpelling),
28612862 Run(Cmd::Spelling),
2863 Run(Cmd::MarkdownShortcuts),
28622864 ];
28632865 ui.open(
28642866 "view",
crates/snowbound/src/options.rs+12
......@@ -117,6 +117,15 @@ const SECTIONS: &[Section] = &[
117117 control: Control::Check(|options| &mut options.hide_spelling),
118118 }],
119119 },
120 Group {
121 heading: "AutoFormat",
122 rows: &[Row {
123 label: "Markdown shortcuts",
124 keywords: "markdown autoformat heading bullet numbering list to do quote \
125 bold italic strikethrough code typing",
126 control: Control::Check(|options| &mut options.markdown),
127 }],
128 },
120129 Group {
121130 heading: "Pen",
122131 rows: &[Row {
......@@ -227,6 +236,7 @@ pub struct Options {
227236 color_scheme: ColorScheme,
228237 pages_match: bool,
229238 hide_spelling: bool,
239 markdown: bool,
230240 automatic_updates: bool,
231241 pen_pressure: bool,
232242 default_font: DefaultFont,
......@@ -323,6 +333,7 @@ impl State {
323333 color_scheme: self.color_scheme,
324334 pages_match: !self.light_pages,
325335 hide_spelling: self.hide_spelling,
336 markdown: self.view.editor.markdown.is_some(),
326337 automatic_updates: self.updates.automatic(),
327338 pen_pressure: self.pen_pressure,
328339 default_font: self.view.editor.default_font.clone(),
......@@ -629,6 +640,7 @@ impl State {
629640 self.color_scheme = options.color_scheme;
630641 self.light_pages = !options.pages_match;
631642 self.hide_spelling = options.hide_spelling;
643 self.view.editor.markdown = crate::commands::markdown(options.markdown);
632644 self.pen_pressure = options.pen_pressure;
633645 self.view.editor.default_font = options.default_font;
634646 self.page_tabs_left = options.page_tabs_left;
crates/snowbound/src/settings.rs+4
......@@ -24,6 +24,8 @@ pub struct Settings {
2424 pub light_pages: bool,
2525 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.
2626 pub hide_spelling: bool,
27 /// Types Markdown as it is: Markdown Shortcuts off.
28 pub ignore_markdown: bool,
2729 /// Where searches look first, as "Set This Scope as Default" chose.
2830 pub search_scope: crate::search::Scope,
2931 /// The tag list Customize Tags edits; none keeps OneNote's.
......@@ -179,6 +181,7 @@ impl crate::State {
179181 color_scheme: self.color_scheme,
180182 light_pages: self.light_pages,
181183 hide_spelling: self.hide_spelling,
184 ignore_markdown: self.view.editor.markdown.is_none(),
182185 search_scope: self.search.default,
183186 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
184187 manual_updates: !self.updates.automatic(),
......@@ -242,6 +245,7 @@ mod tests {
242245 color_scheme: ColorScheme::Dark,
243246 light_pages: true,
244247 hide_spelling: true,
248 ignore_markdown: true,
245249 search_scope: crate::search::Scope::Notebook,
246250 tags: Some(vec![canvas::editor::NoteTag {
247251 label: "Snow check".into(),