diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index 55abfc2e39be7e6339f7bff1a13422bd18d7d168..5c977ae541b25bcc344c99c390685ba38dd7774f 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -71,8 +71,8 @@ enum Appearance { } } -/// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened -/// from then on take. +/// OneNote's Snap To Grid and Default font, and Markdown Shortcuts, kept between launches, +/// which the pages opened from then on take. enum Editing { /// OneNote 2010's Default font sizes, in points. 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 { } } + static var markdownShortcuts: Bool { + get { !UserDefaults.standard.bool(forKey: "ignoreMarkdown") } + set { + UserDefaults.standard.set(!newValue, forKey: "ignoreMarkdown") + apply() + } + } + static var face: String { get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" } set { @@ -105,6 +113,16 @@ enum Editing { static func apply() { sb_set_snap_to_grid(snapToGrid) sb_set_default_font(face, size) + sb_set_markdown_shortcuts(markdownShortcuts) + } + + /// Markdown Shortcuts: typed Markdown becomes formatting. + static func markdownAction() -> UIAction { + let action = UIAction(title: "Markdown Shortcuts", image: UIImage(systemName: "number")) { _ in + markdownShortcuts.toggle() + } + action.state = markdownShortcuts ? .on : .off + return action } /// Options' Default font: the face and size new text takes. diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index c59281f0994379bed71c292b5a499054e07e88ac..bc6039260532660a1dc4a3e2fc4e450b381130c0 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -131,6 +131,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke }) actions.append(Appearance.menu()) actions.append(Editing.fontMenu()) + actions.append(Editing.markdownAction()) provide(actions) } ]) diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index cbc0f90af6ac1593fc8c2f02cf917b19fc4fd7ab..e960efa461ca364cef3ec9527e6d6de527e3cfed 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -32,6 +32,7 @@ mod format; mod html; mod ink; mod link; +mod markdown; mod ops; pub(crate) mod page; mod recording; @@ -179,6 +180,12 @@ pub struct CanvasEditor { /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`, /// and Enter a NextStyle the page lacks from here. pub styles: BTreeMap, + /// OneNote 2010's gallery styles, which Markdown shortcuts apply where the page's theme + /// gives none; none turns the shortcuts off. + pub markdown: Option>, + /// The outline, caret and undo depth a paragraph's Markdown marker left, where Backspace + /// takes it back. + formatted: Option<(ExGuid, TextPosition, usize)>, } /// Imported page state the editable content does not carry. @@ -1211,6 +1218,7 @@ impl CanvasEditor { fresh.stored = self.stored.take(); fresh.default_font = std::mem::take(&mut self.default_font); fresh.styles = std::mem::take(&mut self.styles); + fresh.markdown = self.markdown.take(); *self = fresh; Ok(true) } @@ -1583,6 +1591,8 @@ impl CanvasEditor { provisional: BTreeMap::new(), default_font: DefaultFont::default(), styles: BTreeMap::new(), + markdown: None, + formatted: None, }) } @@ -3525,13 +3535,13 @@ impl CanvasEditor { } /// Types `text` at the selection; a space ends a typed URL, which becomes a link. A line - /// of text joins the typing run before it. + /// of text joins the typing run before it, and may complete a Markdown shortcut. pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { if text.is_empty() || text.contains('\n') { - self.insert_text(engine, text) - } else { - self.typed(|editor| editor.insert_text(engine, text)) + return self.insert_text(engine, text); } + self.typed(|editor| editor.insert_text(engine, text))?; + self.markdown(engine, text) } /// Insert Symbol: `symbol` at the selection, as an undo step that neither joins the @@ -3867,6 +3877,12 @@ impl CanvasEditor { let [anchor, focus] = selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); let at_caret = range.is_empty(); + if backward + && at_caret + && self.formatted.take() == Some((self.active_outline().id, focus, self.undo.len())) + { + return self.undo(engine); + } if at_caret { let outline = self.active_outline(); let paragraph = outline.paragraph_layout(focus.paragraph)?; @@ -4036,6 +4052,7 @@ impl CanvasEditor { if let Some((page, _)) = &self.stored { parked.refresh(page.clone(), engine)?; parked.default_font = std::mem::take(&mut self.default_font); + parked.markdown = self.markdown.take(); *self = parked; } Ok(()) diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 9cea5359ea813ce5ffe010e986d9b2075a244ea6..2b3d917895cadb7914a8aab82dc8c834084decb7 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -542,6 +542,32 @@ fn common(mut values: impl Iterator>) -> Option Paragraph { + restyle(text, 0..text.text().len(), |format| { + // An equation keeps its own formatting; what it took from the old style it takes + // from the new. + if [format.math, format.embedded_object].contains(&Some(true)) { + *format = followed(format, old, &definition.format); + return; + } + *format = Format { + hidden: format.hidden, + hyperlink: format.hyperlink, + hyperlink_label: format.hyperlink_label, + math: format.math, + embedded_object: format.embedded_object, + language: format.language, + alignment: format.alignment, + rtl: format.rtl, + list_spacing: format.list_spacing, + math_object: format.math_object.clone(), + ..definition.format.clone() + }; + }) +} + fn followed(format: &Format, old: &Format, new: &Format) -> Format { let mut format = format.clone(); macro_rules! follow { @@ -570,7 +596,7 @@ fn followed(format: &Format, old: &Format, new: &Format) -> Format { } /// Seconds since 1980, as note tags date themselves. -fn time32() -> Option { +pub(super) fn time32() -> Option { let now = web_time::SystemTime::now() .duration_since(web_time::UNIX_EPOCH) .ok()? @@ -917,27 +943,7 @@ impl CanvasEditor { node.style = Some(style); let old = &olds[&node.id]; let text = &mut node.text_mut().unwrap().text; - *text = restyle(text, 0..text.text().len(), |format| { - // An equation keeps its own formatting; what it took from the - // old style it takes from the new. - if [format.math, format.embedded_object].contains(&Some(true)) { - *format = followed(format, old, &definition.format); - return; - } - *format = Format { - hidden: format.hidden, - hyperlink: format.hyperlink, - hyperlink_label: format.hyperlink_label, - math: format.math, - embedded_object: format.embedded_object, - language: format.language, - alignment: format.alignment, - rtl: format.rtl, - list_spacing: format.list_spacing, - math_object: format.math_object.clone(), - ..definition.format.clone() - }; - }); + *text = styled(text, old, definition); } } } @@ -1286,7 +1292,7 @@ impl CanvasEditor { /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its /// shape and when, gives the last list one in place of any of its action type; an element /// holds one tag of each action type, stored newest first. - fn retag( + pub(super) fn retag( &self, mut lists: [&mut Vec; N], kind: &Kind<'static>, @@ -1328,7 +1334,7 @@ impl CanvasEditor { /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps /// a cleared box's completion time as zero. - fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { + pub(super) fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { let checked = tags .iter() .filter(|tag| self.check_box(tag).is_some()) diff --git a/crates/canvas/src/editor/markdown.rs b/crates/canvas/src/editor/markdown.rs new file mode 100644 index 0000000000000000000000000000000000000000..27c0485e1e460360d50521632a628aa1b6d20c00 --- /dev/null +++ b/crates/canvas/src/editor/markdown.rs @@ -0,0 +1,668 @@ +//! Markdown shortcuts: Markdown typed at a paragraph's start or around text becomes the +//! OneNote formatting it stands for, as one undo step after the typing, which Undo takes back +//! to the characters typed, as Word's AutoFormat As You Type does. + +use super::format::{ListStyle, NoteTag, list_definition, styled, time32}; +use super::*; + +/// What a marker typed at a paragraph's start makes of it. +#[derive(Debug, PartialEq)] +enum Block { + /// A style of the Styles gallery, by stored name. + Style(String), + /// A list, its count restarting at the number given. + List(ListStyle, Option), + /// A To Do check box, checked or not. + ToDo(bool), +} + +impl Block { + /// The lists are those OneNote 2010's AutoFormat makes of the same markers, but for `>`, + /// which it makes an arrow bullet. + fn of(marker: &str) -> Option { + Some(match marker { + "*" => Self::List(ListStyle::BULLET, None), + "-" => Self::List(ListStyle::Bullet(25), None), + "a." => Self::List(ListStyle::Number(2), None), + ">" => Self::Style("blockquote".into()), + "[ ]" => Self::ToDo(false), + "[x]" | "[X]" => Self::ToDo(true), + _ if marker.len() <= 6 && !marker.is_empty() && marker.bytes().all(|b| b == b'#') => { + Self::Style(format!("h{}", marker.len())) + } + _ => { + let (digits, style) = match marker.as_bytes().last()? { + b'.' => (&marker[..marker.len() - 1], ListStyle::NUMBER), + b')' => (&marker[..marker.len() - 1], ListStyle::Number(6)), + _ => return None, + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let number = digits.parse::().ok()?; + Self::List(style, (number != 1).then_some(number)) + } + }) + } +} + +#[derive(Clone, Copy)] +enum Inline { + Bold, + Italic, + Strike, + Code, +} + +/// Markers typed around text, a pair before its half. +const INLINE: [(&str, Inline); 5] = [ + ("**", Inline::Bold), + ("~~", Inline::Strike), + ("*", Inline::Italic), + ("_", Inline::Italic), + ("`", Inline::Code), +]; + +/// Whether no character of bytes `range` belongs to a link, field or equation, or is set in +/// the Code style's font. +fn plain(text: &Paragraph, range: Range, code_font: Option<&str>) -> bool { + let mut start = 0; + text.spans().iter().all(|span| { + let overlaps = start < range.end && span.end > range.start; + start = span.end; + let format = &span.format; + !overlaps + || ![ + format.hidden, + format.hyperlink, + format.math, + format.embedded_object, + ] + .contains(&Some(true)) + && (code_font.is_none() || format.font.as_deref() != code_font) + }) +} + +/// `text` without bytes `markers`, what lies between them changed by `change`; a paragraph +/// left empty keeps the format of the first marker. +fn unmarked( + text: &Paragraph, + markers: [Range; 2], + change: impl Fn(&mut Format), +) -> Paragraph { + let [open, close] = markers; + let kept = [ + (0..open.start, false), + (open.end..close.start, true), + (close.end..text.text().len(), false), + ]; + let mut runs = Vec::new(); + let mut start = 0; + for span in text.spans() { + for (range, inside) in &kept { + let [from, to] = [range.start, range.end].map(|at| at.clamp(start, span.end)); + if from < to { + let mut format = span.format.clone(); + if *inside { + change(&mut format); + } + runs.push((text.text()[from..to].to_owned(), format)); + } + } + start = span.end; + } + if runs.is_empty() { + let first = text.spans().partition_point(|span| span.end <= open.start); + let format = text.spans()[first.min(text.spans().len() - 1)] + .format + .clone(); + return Paragraph::new(String::new(), format); + } + Paragraph::from_runs(runs) +} + +impl CanvasEditor { + /// The Styles gallery's style `name`: the page's theme's, else OneNote 2010's. + fn gallery(&self, name: &str) -> Option { + self.styles + .get(name) + .or_else(|| self.markdown.as_ref()?.get(name)) + .cloned() + } + + /// Applies the Markdown shortcut that `typed`, just typed at the caret, completes, as its + /// own undo step; a marker at the paragraph's start can then be taken back by Backspace. + pub(super) fn markdown( + &mut self, + engine: &mut TextEngine, + typed: &str, + ) -> Result<(), EditorError> { + let outline = self.active_outline(); + let [anchor, focus] = outline.selection.positions; + if self.markdown.is_none() + || !matches!(typed, " " | "*" | "_" | "~" | "`") + || outline.title + || anchor != focus + || self.page_selected() + { + return Ok(()); + } + let (container, index, node) = outline + .document + .leaf(focus.paragraph) + .ok_or(EditError::InvalidRange)?; + let id = outline.id; + let mut node = node.clone(); + if node + .style + .and_then(|style| self.definitions.get(&style)) + .is_some_and(|style| matches!(&style.kind, Kind::Style { name: Some(name), .. } if name == "code")) + { + return Ok(()); + } + let text = node.text().ok_or(EditError::InvalidRange)?.text.clone(); + let caret = text.byte_offset(focus.offset)?; + let code_font = self.gallery("code").and_then(|code| code.format.font); + let code_font = code_font.as_deref(); + let (replacement, at, block) = if typed == " " { + let Some(block) = text.text()[..caret].strip_suffix(' ').and_then(Block::of) else { + return Ok(()); + }; + if !plain(&text, 0..caret, code_font) { + return Ok(()); + } + let rest = unmarked(&text, [0..caret, caret..caret], |_| {}); + (rest, 0, Some(block)) + } else { + let Some((rest, at)) = inline(&text, caret, code_font) else { + return Ok(()); + }; + (rest, at, None) + }; + let typing = text.format_at(focus.offset)?.clone(); + let text = &mut node.text_mut().unwrap().text; + *text = replacement; + let offset = text.utf16_offset(at)?; + let line = block.is_some(); + match block { + Some(Block::Style(name)) => { + let Some(definition) = self.gallery(&name) else { + return Ok(()); + }; + let old = self.style_format(node.style)?; + node.style = Some(self.define_style(&definition)?); + let text = &mut node.text_mut().unwrap().text; + *text = styled(text, &old, &definition); + } + Some(Block::List(style, restart)) => { + let format = &node.text().unwrap().text.spans()[0].format; + let mut definition = list_definition(style, format); + if let Kind::List { restart: count, .. } = &mut definition.kind { + *count = restart; + } + let list = onestore::page::text::new_id()?; + self.definitions.insert(list, definition); + node.lists = vec![list]; + } + // A check box takes a list's place, as To Do List does. + Some(Block::ToDo(checked)) => { + node.lists.clear(); + let tag = &NoteTag::defaults()[0]; + let definition = tag.definition(0); + let tag_id = self.define_tag(&definition)?; + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + self.retag( + [&mut node.tags, &mut text.tags], + &definition.kind, + Some((tag_id, tag.shape, time32())), + ); + if checked { + self.toggle_checks(node.tags.iter_mut().chain(&mut text.tags).collect()); + } + } + None => {} + } + let caret = TextPosition { + paragraph: focus.paragraph, + offset, + }; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index + 1, + replacement: vec![node], + }, + Selection { + positions: [caret; 2], + affinities: [Affinity::Upstream; 2], + }, + )?; + self.typing = None; + if line { + self.formatted = Some((id, caret, self.undo.len())); + } else { + // Typing goes on in the format the closing marker was typed in. + self.pending = Some((id, caret, typing)); + } + Ok(()) + } +} + +/// `text` with the span whose closing marker ends at byte `caret` formatted and its markers +/// gone, and where the caret goes then; none where no span closes there, or it would +/// close inside a word, a URL, a link, an equation or code. +fn inline(text: &Paragraph, caret: usize, code_font: Option<&str>) -> Option<(Paragraph, usize)> { + let source = text.text(); + let before = &source[..caret]; + let (marker, inline) = INLINE + .into_iter() + .find(|(marker, _)| before.ends_with(marker))?; + let edge = marker.chars().next()?; + let close = caret - marker.len(); + let open = before[..close].rfind(marker)?; + let content = &before[open + marker.len()..close]; + let word = |c: Option| c.is_some_and(|c| c.is_alphanumeric() || c == edge); + if content.is_empty() + || content.starts_with(char::is_whitespace) + || content.ends_with(char::is_whitespace) + || content.starts_with(edge) + || content.ends_with(edge) + || word(before[..open].chars().next_back()) + || source[caret..].chars().next().is_some_and(char::is_alphanumeric) + || !plain(text, open..caret, code_font) + // An odd backtick before the span opens code around it. + || !matches!(inline, Inline::Code) && before[..open].matches('`').count() % 2 == 1 + { + return None; + } + let token = before[..open] + .rfind(char::is_whitespace) + .map_or(0, |space| space + 1); + let url = &before[token..]; + if url.contains("://") || url.starts_with("www.") { + return None; + } + let font = code_font.unwrap_or("Consolas").to_owned(); + let rest = unmarked( + text, + [open..open + marker.len(), close..caret], + |format| match inline { + Inline::Bold => format.bold = Some(true), + Inline::Italic => format.italic = Some(true), + Inline::Strike => format.strike = Some(true), + Inline::Code => format.font = Some(font.clone()), + }, + ); + Some((rest, caret - 2 * marker.len())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn markers_name_their_blocks() { + assert_eq!(Block::of("###"), Some(Block::Style("h3".into()))); + assert_eq!(Block::of("#######"), None); + assert_eq!( + Block::of("2."), + Some(Block::List(ListStyle::NUMBER, Some(2))) + ); + assert_eq!( + Block::of("1)"), + Some(Block::List(ListStyle::Number(6), None)) + ); + assert_eq!(Block::of("."), None); + assert_eq!(Block::of("1a."), None); + assert_eq!(Block::of("[x]"), Some(Block::ToDo(true))); + } + + fn calibri() -> Format { + Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + ..Format::default() + } + } + + fn style(name: &str, font: &str) -> Definition { + Definition { + kind: Kind::Style { + name: Some(name.into()), + next: name.starts_with('h').then(|| "p".into()), + }, + format: Format { + bold: Some(name.starts_with('h')), + font: Some(font.into()), + font_size: Some(14.0), + ..Format::default() + }, + } + } + + /// An editor with Markdown shortcuts on, its one paragraph holding `text`, the caret + /// at its end. + fn opened(engine: &mut TextEngine, text: &str) -> CanvasEditor { + let paragraph = Paragraph::new(text.into(), calibri()); + let mut editor = + CanvasEditor::new(engine, TextDocument::new(vec![paragraph]).unwrap(), 300.0).unwrap(); + let gallery = ["h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p"] + .map(|name| (name.to_owned(), style(name, "Calibri"))); + editor.markdown = Some( + gallery + .into_iter() + .chain([("code".to_owned(), style("code", "Consolas"))]) + .collect(), + ); + let end = text.encode_utf16().count() as u32; + editor + .select( + [TextPosition { + paragraph: 0, + offset: end, + }; 2] + .into(), + ) + .unwrap(); + editor + } + + fn typed(engine: &mut TextEngine, editor: &mut CanvasEditor, text: &str) { + for character in text.chars() { + editor + .insert(engine, character.encode_utf8(&mut [0; 4])) + .unwrap(); + } + } + + fn text(editor: &CanvasEditor) -> String { + editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap() + .text() + .to_owned() + } + + /// The paragraph's runs, with whether each is bold, italic, struck and in Consolas. + fn runs(editor: &CanvasEditor) -> Vec<(String, [bool; 4])> { + let paragraph = editor + .active_outline() + .document + .paragraphs() + .next() + .unwrap(); + let mut start = 0; + paragraph + .spans() + .iter() + .map(|span| { + let text = paragraph.text()[start..span.end].to_owned(); + start = span.end; + let format = &span.format; + let on = |value: Option| value == Some(true); + let code = format.font.as_deref() == Some("Consolas"); + ( + text, + [on(format.bold), on(format.italic), on(format.strike), code], + ) + }) + .collect() + } + + #[test] + fn hashes_make_headings_that_undo_and_backspace_take_back_to_the_typed_text() { + let mut engine = TextEngine::default(); + for level in 1..=6 { + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, &format!("{} ", "#".repeat(level))); + assert_eq!(text(&editor), ""); + let state = editor.format_state().unwrap(); + assert_eq!(state.style, Some(format!("h{level}"))); + typed(&mut engine, &mut editor, "Title"); + assert_eq!(text(&editor), "Title"); + assert_eq!( + runs(&editor), + [("Title".into(), [true, false, false, false])] + ); + // Undo takes the typing, then the heading back to the marker. + editor.undo(&mut engine).unwrap(); + assert_eq!(text(&editor), ""); + editor.undo(&mut engine).unwrap(); + assert_eq!(text(&editor), format!("{} ", "#".repeat(level))); + assert_eq!(editor.format_state().unwrap().style, None); + } + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, "## "); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(text(&editor), "## "); + assert_eq!(editor.format_state().unwrap().style, None); + // A second Backspace deletes as ever. + editor.delete(&mut engine, true).unwrap(); + assert_eq!(text(&editor), "##"); + } + + #[test] + fn markers_start_lists_to_dos_and_quotes_as_onenote_s_autoformat_does() { + let mut engine = TextEngine::default(); + for (marker, list) in [ + ("* ", ListStyle::BULLET), + ("- ", ListStyle::Bullet(25)), + ("1. ", ListStyle::NUMBER), + ("1) ", ListStyle::Number(6)), + ("a. ", ListStyle::Number(2)), + ] { + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, marker); + assert_eq!(text(&editor), "", "{marker}"); + assert_eq!(editor.format_state().unwrap().list, Some(list), "{marker}"); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(text(&editor), marker); + assert_eq!(editor.format_state().unwrap().list, None); + } + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, "3. "); + let node = &editor.active_outline().document.nodes()[0]; + assert!(matches!( + editor.definitions[&node.lists[0]].kind, + Kind::List { + restart: Some(3), + .. + } + )); + for (marker, checked) in [("[ ] ", false), ("[x] ", true)] { + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, marker); + assert_eq!(text(&editor), ""); + let state = editor.format_state().unwrap(); + assert_eq!(state.tags[0].0.label, "To Do"); + let tag = &editor.active_outline().document.nodes()[0] + .text() + .unwrap() + .tags[0]; + assert_eq!(tag.status & 1 != 0, checked); + } + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, "> "); + assert_eq!( + editor.format_state().unwrap().style.as_deref(), + Some("blockquote") + ); + // A marker before text already there formats it too. + let mut editor = opened(&mut engine, "words"); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + typed(&mut engine, &mut editor, "# "); + assert_eq!(text(&editor), "words"); + assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h1")); + } + + #[test] + fn closing_markers_format_the_text_between_and_undo_returns_the_markers() { + let mut engine = TextEngine::default(); + for (input, format) in [ + ("**bold**", [true, false, false, false]), + ("*it*", [false, true, false, false]), + ("_it_", [false, true, false, false]), + ("~~gone~~", [false, false, true, false]), + ("`a*b*c`", [false, false, false, true]), + ] { + let mut editor = opened(&mut engine, "say "); + typed(&mut engine, &mut editor, input); + let inner = input.trim_matches(['*', '_', '~', '`']); + assert_eq!( + runs(&editor), + [("say ".into(), [false; 4]), (inner.into(), format),], + "{input}" + ); + // What is typed next is plain. + typed(&mut engine, &mut editor, " on"); + assert_eq!(runs(&editor).last().unwrap(), &(" on".into(), [false; 4])); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!(text(&editor), format!("say {input}"), "{input}"); + } + } + + #[test] + fn markers_inside_words_urls_code_and_titles_or_with_shortcuts_off_stay_typed() { + let mut engine = TextEngine::default(); + for input in [ + "snake_case_name", + "2*3*4", + "a * b *", + "http://example.com/_a_", + "www.example.com/*a*", + "`a *b*", + "**", + "#tag ", + "x # ", + "####### ", + ] { + let mut editor = opened(&mut engine, ""); + typed(&mut engine, &mut editor, input); + assert_eq!(text(&editor), input); + assert_eq!(runs(&editor), [(input.into(), [false; 4])], "{input}"); + } + // A closing marker typed before a word's letters leaves them unformatted. + let mut editor = opened(&mut engine, "*a b"); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 3, + }; 2] + .into(), + ) + .unwrap(); + typed(&mut engine, &mut editor, "*"); + assert_eq!(text(&editor), "*a *b"); + let mut editor = opened(&mut engine, ""); + editor.markdown = None; + typed(&mut engine, &mut editor, "# **a** "); + assert_eq!(text(&editor), "# **a** "); + // Code keeps what it holds. + let mut editor = opened(&mut engine, ""); + let code = editor.gallery("code").unwrap(); + editor.format(&mut engine, Formatting::Style(code)).unwrap(); + typed(&mut engine, &mut editor, "# *a* "); + assert_eq!(text(&editor), "# *a* "); + } + + /// Every shortcut stores ordinary OneNote formatting that reads back as itself. + /// `SNOWBOUND_MARKDOWN_EXPORT` names a new directory receiving the notebook, for a cold + /// open in OneNote. + #[test] + fn shortcuts_write_as_onenote_formatting() { + use onestore::{RevisionIndex, Store, document::Document}; + const NOTEBOOK: &str = "../../corpus/paragraph-edit/before/notebook"; + let section = std::fs::read(format!("{NOTEBOOK}/synthetic.one")).unwrap(); + let page = |bytes: &[u8]| { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .find(|(_, page)| page.title == "Split middle") + .unwrap() + }; + let (space, source) = page(§ion); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(source, &mut engine).unwrap(); + editor.markdown = opened(&mut engine, "").markdown; + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + editor + .move_selection(&mut engine, Movement::DocumentEnd, false) + .unwrap(); + for line in [ + "Plain **bold** *italic* _also_ ~~struck~~ `code` end", + "# Heading one", + "## Heading two", + "### Heading three", + "#### Heading four", + "##### Heading five", + "###### Heading six", + "* Star", + "- Dash", + "1. One", + "2) Two", + "a. Alpha", + "3. Three", + "[ ] To do", + "[x] Done", + "[X] Done too", + "> Quoted", + ] { + editor.enter(&mut engine, false).unwrap(); + typed(&mut engine, &mut editor, line); + } + let edited = editor.page().unwrap(); + let written = super::super::ops::saved(§ion, space, &mut editor); + let (_, reread) = page(&written); + let settled = |mut page: Page| { + for object in &mut page.objects { + if let PageObject::Outline(outline) = object { + for text in outline.paragraphs.iter_mut().filter_map(|p| p.text_mut()) { + for tag in &mut text.tags { + tag.extra_set = 0; + } + } + } + } + page + }; + assert_eq!(settled(reread).objects, settled(edited).objects); + if let Some(directory) = std::env::var_os("SNOWBOUND_MARKDOWN_EXPORT") { + let directory = std::path::Path::new(&directory); + std::fs::create_dir(directory).unwrap(); + std::fs::write(directory.join("synthetic.one"), &written).unwrap(); + std::fs::copy( + format!("{NOTEBOOK}/Open Notebook.onetoc2"), + directory.join("Open Notebook.onetoc2"), + ) + .unwrap(); + } + } +} diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 54f96f6e81b71da4b80374e011968eef3132389e..5afddd36b596314f300da9a485b8565120a49b9a 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -363,6 +363,7 @@ impl PageView { scene: Option<(PageScene, [f32; 2])>, ) -> CanvasEditor { editor.default_font = std::mem::take(&mut self.editor.default_font); + editor.markdown = self.editor.markdown.take(); let left = std::mem::replace(&mut self.editor, editor); self.scene = scene; self.drag = None; diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 50e3a07c7c1c72554b887cc0188fd10881a7b9fd..917c0ab3297e184d2860a6b9488f861d2bed2865 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -84,6 +84,7 @@ uint8_t sb_view_target(const View *view, float x, float y); void sb_view_pressure(View *view, float pressure); void sb_set_snap_to_grid(bool on); void sb_set_default_font(const char *face, float size); +void sb_set_markdown_shortcuts(bool on); bool sb_view_press(View *view, float x, float y); bool sb_view_drag(View *view, float x, float y); bool sb_view_release(View *view); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index f33585dde9b3421be681cc4d05b1394c91dbd3f5..86b011ad6118f64547a62277af5eb9e12f6e3ede 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -154,7 +154,10 @@ impl Canvas { ); page.touch = true; page.host_viewport = true; - (page.snap_to_grid, page.editor.default_font) = options().clone(); + let markdown; + (page.snap_to_grid, page.editor.default_font, markdown) = options().clone(); + page.editor.markdown = + markdown.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet()); Ok(Self { page, space, @@ -636,12 +639,12 @@ fn pool_engine(engine: TextEngine) { } } -/// The reader's Snap To Grid and Default font, which every page opened from then on takes, -/// and new pages' titles the font's face. -static OPTIONS: LazyLock> = - LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default()))); +/// The reader's Snap To Grid, Default font and Markdown Shortcuts, which every page opened +/// from then on takes, and new pages' titles the font's face. +static OPTIONS: LazyLock> = + LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default(), true))); -fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> { +fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont, bool)> { OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) } @@ -664,6 +667,12 @@ pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { }; } +/// Markdown Shortcuts: typed Markdown becomes formatting. +#[unsafe(no_mangle)] +pub extern "C" fn sb_set_markdown_shortcuts(on: bool) { + options().2 = on; +} + pub struct View { /// Taken apart on drop, which returns its text engine to `ENGINES`. canvas: ManuallyDrop, diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 35691504ef6061826a7a746a0f2753b3a5f38558..b84331cb5d08c06eba79cefa53c39812cf384353 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -742,15 +742,20 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { } #[test] -fn pages_open_with_the_snap_and_default_font_options_set() { - // OneNote's defaults, so pages other tests open meanwhile see no change. +fn pages_open_with_the_snap_default_font_and_markdown_options_set() { + // The defaults, so pages other tests open meanwhile see no change. sb_set_snap_to_grid(true); unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) }; - assert_eq!(*options(), (true, canvas::editor::DefaultFont::default())); + sb_set_markdown_shortcuts(true); + assert_eq!( + *options(), + (true, canvas::editor::DefaultFont::default(), true) + ); let (_directory, section) = features(); let canvas = canvas(§ion, "Paragraph controls"); assert!(canvas.page.snap_to_grid); assert_eq!(canvas.page.editor.default_font.face, "Calibri"); + assert!(canvas.page.editor.markdown.is_some()); } #[test] diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 5ed94f56d7fed3749c413c7bb93deba8d0656f10..b50e8ed72ceab8e7763aa8c3f0f42028e076ef32 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -69,6 +69,8 @@ pub enum Id { FullPageView, /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked. HideSpelling, + /// Typed Markdown becomes formatting. + MarkdownShortcuts, /// The Spelling pane, on the next marked word. Spelling, PageColor, @@ -128,6 +130,11 @@ pub fn offered(id: Id) -> bool { id != Id::ScreenClipping || cfg!(target_os = "macos") } +/// What the editor's Markdown shortcuts take: OneNote 2010's gallery styles when `on`. +pub fn markdown(on: bool) -> Option> { + on.then(|| notebook::sidecar::themes::built_in().swap_remove(0).sheet()) +} + /// What a menu or toolbar offers: a command, or one of a list's entries. #[derive(Clone, Debug, PartialEq)] pub enum Choice { @@ -372,6 +379,7 @@ pub const COMMANDS: &[Command] = &[ row!(Id::PageList, "Page List", NONE, NONE), row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), + row!(Id::MarkdownShortcuts, "Markdown Shortcuts", NONE, NONE), row!( Id::Spelling, "Spelling…", @@ -1259,6 +1267,10 @@ impl State { enabled: !modal && self.spelling.is_some(), checked: Some(self.hide_spelling), }, + Id::MarkdownShortcuts => Status { + enabled: !modal, + checked: Some(self.view.editor.markdown.is_some()), + }, Id::Spelling => enabled(text && self.view.spelling.is_some()), Id::FormatPainter => Status { enabled: text, @@ -1628,6 +1640,11 @@ impl State { self.save_settings(); return Ok(()); } + Id::MarkdownShortcuts => { + self.view.editor.markdown = markdown(self.view.editor.markdown.is_none()); + self.save_settings(); + return Ok(()); + } Id::PagesMatchTheme => { self.light_pages = !self.light_pages; self.follow_color_scheme(); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 9aebf0d717d3552bb12c4f7dbafb5852e298beaa..c3e4f80c7d4829c4e10c2a597220bee71c69d720 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1294,6 +1294,7 @@ impl State { }; state.view.snap_to_grid = !stored.ignore_grid; state.view.editor.default_font = stored.default_font; + state.view.editor.markdown = commands::markdown(!stored.ignore_markdown); // A notebook opened from its server that couldn't sign in asks to, as the Finder does. let unsigned = state.notebooks.iter().find(|library| { library.notebook.is_err() && library::server_address(&library.location).is_some() @@ -2859,6 +2860,7 @@ impl State { Rule, Run(Cmd::HideSpelling), Run(Cmd::Spelling), + Run(Cmd::MarkdownShortcuts), ]; ui.open( "view", diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index d229936d54e0e99714b2f861eeb1c87e82ee135f..6ffa3caac77074aa6f3a6e7fa1d791ae1dcc094c 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -117,6 +117,15 @@ const SECTIONS: &[Section] = &[ control: Control::Check(|options| &mut options.hide_spelling), }], }, + Group { + heading: "AutoFormat", + rows: &[Row { + label: "Markdown shortcuts", + keywords: "markdown autoformat heading bullet numbering list to do quote \ + bold italic strikethrough code typing", + control: Control::Check(|options| &mut options.markdown), + }], + }, Group { heading: "Pen", rows: &[Row { @@ -227,6 +236,7 @@ pub struct Options { color_scheme: ColorScheme, pages_match: bool, hide_spelling: bool, + markdown: bool, automatic_updates: bool, pen_pressure: bool, default_font: DefaultFont, @@ -323,6 +333,7 @@ impl State { color_scheme: self.color_scheme, pages_match: !self.light_pages, hide_spelling: self.hide_spelling, + markdown: self.view.editor.markdown.is_some(), automatic_updates: self.updates.automatic(), pen_pressure: self.pen_pressure, default_font: self.view.editor.default_font.clone(), @@ -629,6 +640,7 @@ impl State { self.color_scheme = options.color_scheme; self.light_pages = !options.pages_match; self.hide_spelling = options.hide_spelling; + self.view.editor.markdown = crate::commands::markdown(options.markdown); self.pen_pressure = options.pen_pressure; self.view.editor.default_font = options.default_font; self.page_tabs_left = options.page_tabs_left; diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 55518123a37062e05177d016933d8c6587090826..4985435b0d4b2347b445a5160aa7c087fe37cb79 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -24,6 +24,8 @@ pub struct Settings { pub light_pages: bool, /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. pub hide_spelling: bool, + /// Types Markdown as it is: Markdown Shortcuts off. + pub ignore_markdown: bool, /// Where searches look first, as "Set This Scope as Default" chose. pub search_scope: crate::search::Scope, /// The tag list Customize Tags edits; none keeps OneNote's. @@ -179,6 +181,7 @@ impl crate::State { color_scheme: self.color_scheme, light_pages: self.light_pages, hide_spelling: self.hide_spelling, + ignore_markdown: self.view.editor.markdown.is_none(), search_scope: self.search.default, tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), manual_updates: !self.updates.automatic(), @@ -242,6 +245,7 @@ mod tests { color_scheme: ColorScheme::Dark, light_pages: true, hide_spelling: true, + ignore_markdown: true, search_scope: crate::search::Scope::Notebook, tags: Some(vec![canvas::editor::NoteTag { label: "Snow check".into(),