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 {...@@ -71,8 +71,8 @@ enum Appearance {
71 }71 }
72}72}
7373
74/// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened74/// OneNote's Snap To Grid and Default font, and Markdown Shortcuts, kept between launches,
75/// from then on take.75/// which the pages opened from then on take.
76enum Editing {76enum Editing {
77 /// OneNote 2010's Default font sizes, in points.77 /// OneNote 2010's Default font sizes, in points.
78 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]78 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 {...@@ -86,6 +86,14 @@ enum Editing {
86 }86 }
87 }87 }
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
89 static var face: String {97 static var face: String {
90 get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" }98 get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" }
91 set {99 set {
...@@ -105,6 +113,16 @@ enum Editing {...@@ -105,6 +113,16 @@ enum Editing {
105 static func apply() {113 static func apply() {
106 sb_set_snap_to_grid(snapToGrid)114 sb_set_snap_to_grid(snapToGrid)
107 sb_set_default_font(face, size)115 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
108 }126 }
109127
110 /// Options' Default font: the face and size new text takes.128 /// 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...@@ -131,6 +131,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
131 })131 })
132 actions.append(Appearance.menu())132 actions.append(Appearance.menu())
133 actions.append(Editing.fontMenu())133 actions.append(Editing.fontMenu())
134 actions.append(Editing.markdownAction())
134 provide(actions)135 provide(actions)
135 }136 }
136 ])137 ])
crates/canvas/src/editor.rs+21-4
...@@ -32,6 +32,7 @@ mod format;...@@ -32,6 +32,7 @@ mod format;
32mod html;32mod html;
33mod ink;33mod ink;
34mod link;34mod link;
35mod markdown;
35mod ops;36mod ops;
36pub(crate) mod page;37pub(crate) mod page;
37mod recording;38mod recording;
...@@ -179,6 +180,12 @@ pub struct CanvasEditor {...@@ -179,6 +180,12 @@ pub struct CanvasEditor {
179 /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`,180 /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`,
180 /// and Enter a NextStyle the page lacks from here.181 /// and Enter a NextStyle the page lacks from here.
181 pub styles: BTreeMap<String, Definition>,182 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)>,
182}189}
183190
184/// Imported page state the editable content does not carry.191/// Imported page state the editable content does not carry.
...@@ -1211,6 +1218,7 @@ impl CanvasEditor {...@@ -1211,6 +1218,7 @@ impl CanvasEditor {
1211 fresh.stored = self.stored.take();1218 fresh.stored = self.stored.take();
1212 fresh.default_font = std::mem::take(&mut self.default_font);1219 fresh.default_font = std::mem::take(&mut self.default_font);
1213 fresh.styles = std::mem::take(&mut self.styles);1220 fresh.styles = std::mem::take(&mut self.styles);
1221 fresh.markdown = self.markdown.take();
1214 *self = fresh;1222 *self = fresh;
1215 Ok(true)1223 Ok(true)
1216 }1224 }
...@@ -1583,6 +1591,8 @@ impl CanvasEditor {...@@ -1583,6 +1591,8 @@ impl CanvasEditor {
1583 provisional: BTreeMap::new(),1591 provisional: BTreeMap::new(),
1584 default_font: DefaultFont::default(),1592 default_font: DefaultFont::default(),
1585 styles: BTreeMap::new(),1593 styles: BTreeMap::new(),
1594 markdown: None,
1595 formatted: None,
1586 })1596 })
1587 }1597 }
15881598
...@@ -3525,13 +3535,13 @@ impl CanvasEditor {...@@ -3525,13 +3535,13 @@ impl CanvasEditor {
3525 }3535 }
35263536
3527 /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line3537 /// 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.
3529 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {3539 pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> {
3530 if text.is_empty() || text.contains('\n') {3540 if text.is_empty() || text.contains('\n') {
3531 self.insert_text(engine, text)3541 return self.insert_text(engine, text);
3532 } else {
3533 self.typed(|editor| editor.insert_text(engine, text))
3534 }3542 }
3543 self.typed(|editor| editor.insert_text(engine, text))?;
3544 self.markdown(engine, text)
3535 }3545 }
35363546
3537 /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the3547 /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the
...@@ -3867,6 +3877,12 @@ impl CanvasEditor {...@@ -3867,6 +3877,12 @@ impl CanvasEditor {
3867 let [anchor, focus] = selection.positions;3877 let [anchor, focus] = selection.positions;
3868 let mut range = anchor.min(focus)..anchor.max(focus);3878 let mut range = anchor.min(focus)..anchor.max(focus);
3869 let at_caret = range.is_empty();3879 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 }
3870 if at_caret {3886 if at_caret {
3871 let outline = self.active_outline();3887 let outline = self.active_outline();
3872 let paragraph = outline.paragraph_layout(focus.paragraph)?;3888 let paragraph = outline.paragraph_layout(focus.paragraph)?;
...@@ -4036,6 +4052,7 @@ impl CanvasEditor {...@@ -4036,6 +4052,7 @@ impl CanvasEditor {
4036 if let Some((page, _)) = &self.stored {4052 if let Some((page, _)) = &self.stored {
4037 parked.refresh(page.clone(), engine)?;4053 parked.refresh(page.clone(), engine)?;
4038 parked.default_font = std::mem::take(&mut self.default_font);4054 parked.default_font = std::mem::take(&mut self.default_font);
4055 parked.markdown = self.markdown.take();
4039 *self = parked;4056 *self = parked;
4040 }4057 }
4041 Ok(())4058 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...@@ -542,6 +542,32 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T
542542
543/// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing543/// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing
544/// did, the new one gives.544/// 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
545fn followed(format: &Format, old: &Format, new: &Format) -> Format {571fn followed(format: &Format, old: &Format, new: &Format) -> Format {
546 let mut format = format.clone();572 let mut format = format.clone();
547 macro_rules! follow {573 macro_rules! follow {
...@@ -570,7 +596,7 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format {...@@ -570,7 +596,7 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format {
570}596}
571597
572/// Seconds since 1980, as note tags date themselves.598/// Seconds since 1980, as note tags date themselves.
573fn time32() -> Option<u32> {599pub(super) fn time32() -> Option<u32> {
574 let now = web_time::SystemTime::now()600 let now = web_time::SystemTime::now()
575 .duration_since(web_time::UNIX_EPOCH)601 .duration_since(web_time::UNIX_EPOCH)
576 .ok()?602 .ok()?
...@@ -917,27 +943,7 @@ impl CanvasEditor {...@@ -917,27 +943,7 @@ impl CanvasEditor {
917 node.style = Some(style);943 node.style = Some(style);
918 let old = &olds[&node.id];944 let old = &olds[&node.id];
919 let text = &mut node.text_mut().unwrap().text;945 let text = &mut node.text_mut().unwrap().text;
920 *text = restyle(text, 0..text.text().len(), |format| {946 *text = styled(text, old, definition);
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 });
941 }947 }
942 }948 }
943 }949 }
...@@ -1286,7 +1292,7 @@ impl CanvasEditor {...@@ -1286,7 +1292,7 @@ impl CanvasEditor {
1286 /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its1292 /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its
1287 /// shape and when, gives the last list one in place of any of its action type; an element1293 /// shape and when, gives the last list one in place of any of its action type; an element
1288 /// holds one tag of each action type, stored newest first.1294 /// holds one tag of each action type, stored newest first.
1289 fn retag<const N: usize>(1295 pub(super) fn retag<const N: usize>(
1290 &self,1296 &self,
1291 mut lists: [&mut Vec<Tag>; N],1297 mut lists: [&mut Vec<Tag>; N],
1292 kind: &Kind<'static>,1298 kind: &Kind<'static>,
...@@ -1328,7 +1334,7 @@ impl CanvasEditor {...@@ -1328,7 +1334,7 @@ impl CanvasEditor {
13281334
1329 /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps1335 /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps
1330 /// a cleared box's completion time as zero.1336 /// 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>) {
1332 let checked = tags1338 let checked = tags
1333 .iter()1339 .iter()
1334 .filter(|tag| self.check_box(tag).is_some())1340 .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 {...@@ -363,6 +363,7 @@ impl PageView {
363 scene: Option<(PageScene, [f32; 2])>,363 scene: Option<(PageScene, [f32; 2])>,
364 ) -> CanvasEditor {364 ) -> CanvasEditor {
365 editor.default_font = std::mem::take(&mut self.editor.default_font);365 editor.default_font = std::mem::take(&mut self.editor.default_font);
366 editor.markdown = self.editor.markdown.take();
366 let left = std::mem::replace(&mut self.editor, editor);367 let left = std::mem::replace(&mut self.editor, editor);
367 self.scene = scene;368 self.scene = scene;
368 self.drag = None;369 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);...@@ -84,6 +84,7 @@ uint8_t sb_view_target(const View *view, float x, float y);
84void sb_view_pressure(View *view, float pressure);84void sb_view_pressure(View *view, float pressure);
85void sb_set_snap_to_grid(bool on);85void sb_set_snap_to_grid(bool on);
86void sb_set_default_font(const char *face, float size);86void sb_set_default_font(const char *face, float size);
87void sb_set_markdown_shortcuts(bool on);
87bool sb_view_press(View *view, float x, float y);88bool sb_view_press(View *view, float x, float y);
88bool sb_view_drag(View *view, float x, float y);89bool sb_view_drag(View *view, float x, float y);
89bool sb_view_release(View *view);90bool sb_view_release(View *view);
crates/mobile/src/lib.rs+15-6
...@@ -154,7 +154,10 @@ impl Canvas {...@@ -154,7 +154,10 @@ impl Canvas {
154 );154 );
155 page.touch = true;155 page.touch = true;
156 page.host_viewport = true;156 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());
158 Ok(Self {161 Ok(Self {
159 page,162 page,
160 space,163 space,
...@@ -636,12 +639,12 @@ fn pool_engine(engine: TextEngine) {...@@ -636,12 +639,12 @@ fn pool_engine(engine: TextEngine) {
636 }639 }
637}640}
638641
639/// The reader's Snap To Grid and Default font, which every page opened from then on takes,642/// The reader's Snap To Grid, Default font and Markdown Shortcuts, which every page opened
640/// and new pages' titles the font's face.643/// from then on takes, and new pages' titles the font's face.
641static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont)>> =644static OPTIONS: LazyLock<Mutex<(bool, canvas::editor::DefaultFont, bool)>> =
642 LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default())));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)> {
645 OPTIONS.lock().unwrap_or_else(|error| error.into_inner())648 OPTIONS.lock().unwrap_or_else(|error| error.into_inner())
646}649}
647650
...@@ -664,6 +667,12 @@ pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) {...@@ -664,6 +667,12 @@ pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) {
664 };667 };
665}668}
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
667pub struct View {676pub struct View {
668 /// Taken apart on drop, which returns its text engine to `ENGINES`.677 /// Taken apart on drop, which returns its text engine to `ENGINES`.
669 canvas: ManuallyDrop<Canvas>,678 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() {...@@ -742,15 +742,20 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
742}742}
743743
744#[test]744#[test]
745fn pages_open_with_the_snap_and_default_font_options_set() {745fn pages_open_with_the_snap_default_font_and_markdown_options_set() {
746 // OneNote's defaults, so pages other tests open meanwhile see no change.746 // The defaults, so pages other tests open meanwhile see no change.
747 sb_set_snap_to_grid(true);747 sb_set_snap_to_grid(true);
748 unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) };748 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 );
750 let (_directory, section) = features();754 let (_directory, section) = features();
751 let canvas = canvas(&section, "Paragraph controls");755 let canvas = canvas(&section, "Paragraph controls");
752 assert!(canvas.page.snap_to_grid);756 assert!(canvas.page.snap_to_grid);
753 assert_eq!(canvas.page.editor.default_font.face, "Calibri");757 assert_eq!(canvas.page.editor.default_font.face, "Calibri");
758 assert!(canvas.page.editor.markdown.is_some());
754}759}
755760
756#[test]761#[test]
crates/snowbound/src/commands.rs+17
...@@ -69,6 +69,8 @@ pub enum Id {...@@ -69,6 +69,8 @@ pub enum Id {
69 FullPageView,69 FullPageView,
70 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.70 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.
71 HideSpelling,71 HideSpelling,
72 /// Typed Markdown becomes formatting.
73 MarkdownShortcuts,
72 /// The Spelling pane, on the next marked word.74 /// The Spelling pane, on the next marked word.
73 Spelling,75 Spelling,
74 PageColor,76 PageColor,
...@@ -128,6 +130,11 @@ pub fn offered(id: Id) -> bool {...@@ -128,6 +130,11 @@ pub fn offered(id: Id) -> bool {
128 id != Id::ScreenClipping || cfg!(target_os = "macos")130 id != Id::ScreenClipping || cfg!(target_os = "macos")
129}131}
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
131/// What a menu or toolbar offers: a command, or one of a list's entries.138/// What a menu or toolbar offers: a command, or one of a list's entries.
132#[derive(Clone, Debug, PartialEq)]139#[derive(Clone, Debug, PartialEq)]
133pub enum Choice {140pub enum Choice {
...@@ -372,6 +379,7 @@ pub const COMMANDS: &[Command] = &[...@@ -372,6 +379,7 @@ pub const COMMANDS: &[Command] = &[
372 row!(Id::PageList, "Page List", NONE, NONE),379 row!(Id::PageList, "Page List", NONE, NONE),
373 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),380 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),
374 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),381 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
382 row!(Id::MarkdownShortcuts, "Markdown Shortcuts", NONE, NONE),
375 row!(383 row!(
376 Id::Spelling,384 Id::Spelling,
377 "Spelling…",385 "Spelling…",
...@@ -1259,6 +1267,10 @@ impl State {...@@ -1259,6 +1267,10 @@ impl State {
1259 enabled: !modal && self.spelling.is_some(),1267 enabled: !modal && self.spelling.is_some(),
1260 checked: Some(self.hide_spelling),1268 checked: Some(self.hide_spelling),
1261 },1269 },
1270 Id::MarkdownShortcuts => Status {
1271 enabled: !modal,
1272 checked: Some(self.view.editor.markdown.is_some()),
1273 },
1262 Id::Spelling => enabled(text && self.view.spelling.is_some()),1274 Id::Spelling => enabled(text && self.view.spelling.is_some()),
1263 Id::FormatPainter => Status {1275 Id::FormatPainter => Status {
1264 enabled: text,1276 enabled: text,
...@@ -1628,6 +1640,11 @@ impl State {...@@ -1628,6 +1640,11 @@ impl State {
1628 self.save_settings();1640 self.save_settings();
1629 return Ok(());1641 return Ok(());
1630 }1642 }
1643 Id::MarkdownShortcuts => {
1644 self.view.editor.markdown = markdown(self.view.editor.markdown.is_none());
1645 self.save_settings();
1646 return Ok(());
1647 }
1631 Id::PagesMatchTheme => {1648 Id::PagesMatchTheme => {
1632 self.light_pages = !self.light_pages;1649 self.light_pages = !self.light_pages;
1633 self.follow_color_scheme();1650 self.follow_color_scheme();
crates/snowbound/src/main.rs+2
...@@ -1294,6 +1294,7 @@ impl State {...@@ -1294,6 +1294,7 @@ impl State {
1294 };1294 };
1295 state.view.snap_to_grid = !stored.ignore_grid;1295 state.view.snap_to_grid = !stored.ignore_grid;
1296 state.view.editor.default_font = stored.default_font;1296 state.view.editor.default_font = stored.default_font;
1297 state.view.editor.markdown = commands::markdown(!stored.ignore_markdown);
1297 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.1298 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
1298 let unsigned = state.notebooks.iter().find(|library| {1299 let unsigned = state.notebooks.iter().find(|library| {
1299 library.notebook.is_err() && library::server_address(&library.location).is_some()1300 library.notebook.is_err() && library::server_address(&library.location).is_some()
...@@ -2859,6 +2860,7 @@ impl State {...@@ -2859,6 +2860,7 @@ impl State {
2859 Rule,2860 Rule,
2860 Run(Cmd::HideSpelling),2861 Run(Cmd::HideSpelling),
2861 Run(Cmd::Spelling),2862 Run(Cmd::Spelling),
2863 Run(Cmd::MarkdownShortcuts),
2862 ];2864 ];
2863 ui.open(2865 ui.open(
2864 "view",2866 "view",
crates/snowbound/src/options.rs+12
...@@ -117,6 +117,15 @@ const SECTIONS: &[Section] = &[...@@ -117,6 +117,15 @@ const SECTIONS: &[Section] = &[
117 control: Control::Check(|options| &mut options.hide_spelling),117 control: Control::Check(|options| &mut options.hide_spelling),
118 }],118 }],
119 },119 },
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 },
120 Group {129 Group {
121 heading: "Pen",130 heading: "Pen",
122 rows: &[Row {131 rows: &[Row {
...@@ -227,6 +236,7 @@ pub struct Options {...@@ -227,6 +236,7 @@ pub struct Options {
227 color_scheme: ColorScheme,236 color_scheme: ColorScheme,
228 pages_match: bool,237 pages_match: bool,
229 hide_spelling: bool,238 hide_spelling: bool,
239 markdown: bool,
230 automatic_updates: bool,240 automatic_updates: bool,
231 pen_pressure: bool,241 pen_pressure: bool,
232 default_font: DefaultFont,242 default_font: DefaultFont,
...@@ -323,6 +333,7 @@ impl State {...@@ -323,6 +333,7 @@ impl State {
323 color_scheme: self.color_scheme,333 color_scheme: self.color_scheme,
324 pages_match: !self.light_pages,334 pages_match: !self.light_pages,
325 hide_spelling: self.hide_spelling,335 hide_spelling: self.hide_spelling,
336 markdown: self.view.editor.markdown.is_some(),
326 automatic_updates: self.updates.automatic(),337 automatic_updates: self.updates.automatic(),
327 pen_pressure: self.pen_pressure,338 pen_pressure: self.pen_pressure,
328 default_font: self.view.editor.default_font.clone(),339 default_font: self.view.editor.default_font.clone(),
...@@ -629,6 +640,7 @@ impl State {...@@ -629,6 +640,7 @@ impl State {
629 self.color_scheme = options.color_scheme;640 self.color_scheme = options.color_scheme;
630 self.light_pages = !options.pages_match;641 self.light_pages = !options.pages_match;
631 self.hide_spelling = options.hide_spelling;642 self.hide_spelling = options.hide_spelling;
643 self.view.editor.markdown = crate::commands::markdown(options.markdown);
632 self.pen_pressure = options.pen_pressure;644 self.pen_pressure = options.pen_pressure;
633 self.view.editor.default_font = options.default_font;645 self.view.editor.default_font = options.default_font;
634 self.page_tabs_left = options.page_tabs_left;646 self.page_tabs_left = options.page_tabs_left;
crates/snowbound/src/settings.rs+4
...@@ -24,6 +24,8 @@ pub struct Settings {...@@ -24,6 +24,8 @@ pub struct Settings {
24 pub light_pages: bool,24 pub light_pages: bool,
25 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.25 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.
26 pub hide_spelling: bool,26 pub hide_spelling: bool,
27 /// Types Markdown as it is: Markdown Shortcuts off.
28 pub ignore_markdown: bool,
27 /// Where searches look first, as "Set This Scope as Default" chose.29 /// Where searches look first, as "Set This Scope as Default" chose.
28 pub search_scope: crate::search::Scope,30 pub search_scope: crate::search::Scope,
29 /// The tag list Customize Tags edits; none keeps OneNote's.31 /// The tag list Customize Tags edits; none keeps OneNote's.
...@@ -179,6 +181,7 @@ impl crate::State {...@@ -179,6 +181,7 @@ impl crate::State {
179 color_scheme: self.color_scheme,181 color_scheme: self.color_scheme,
180 light_pages: self.light_pages,182 light_pages: self.light_pages,
181 hide_spelling: self.hide_spelling,183 hide_spelling: self.hide_spelling,
184 ignore_markdown: self.view.editor.markdown.is_none(),
182 search_scope: self.search.default,185 search_scope: self.search.default,
183 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),186 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
184 manual_updates: !self.updates.automatic(),187 manual_updates: !self.updates.automatic(),
...@@ -242,6 +245,7 @@ mod tests {...@@ -242,6 +245,7 @@ mod tests {
242 color_scheme: ColorScheme::Dark,245 color_scheme: ColorScheme::Dark,
243 light_pages: true,246 light_pages: true,
244 hide_spelling: true,247 hide_spelling: true,
248 ignore_markdown: true,
245 search_scope: crate::search::Scope::Notebook,249 search_scope: crate::search::Scope::Notebook,
246 tags: Some(vec![canvas::editor::NoteTag {250 tags: Some(vec![canvas::editor::NoteTag {
247 label: "Snow check".into(),251 label: "Snow check".into(),