diff --git a/README.md b/README.md index 9191ac3..15b9b2e 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,16 @@ Terminal=false it), and the **Contrast** slider to brighten dim editor text (0% = theme default; higher pushes the text toward white in dark mode / black in light mode). Text always wraps to the pane width, so there is no horizontal - scrolling. + scrolling. Inline markdown has keyboard shortcuts, applied to the current + selection (press again on wrapped text to remove the markers): + + | Shortcut | Effect | + | --- | --- | + | `Ctrl/Cmd + B` | **bold** (`**…**`) | + | `Ctrl/Cmd + I` | *italic* (`*…*`) | + | `Ctrl/Cmd + E` | `inline code` | + | `Ctrl/Cmd + Shift + X` | ~~strikethrough~~ (`~~…~~`) | + | `Ctrl/Cmd + K` | link — `[selection](url)`, with `url` selected to replace | 4. Set the **Chapter title** for the open file if you want to override the auto-derived one (the field shows the automatic title as a hint). Leaving it blank falls back to a `# Title:` line in the header, and if there is none, to diff --git a/src/app.rs b/src/app.rs index 1d0a9c4..8cd0bf4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1317,16 +1317,37 @@ impl App { let job = build_editor_job(text, size, text_color, &ranges, wrap_width); ui.fonts(|f| f.layout_job(job)) }; - let resp = ui.add( - egui::TextEdit::multiline(&mut self.buffer) - .code_editor() - .desired_width(f32::INFINITY) - .desired_rows(30) - .layouter(&mut layouter), - ); - if resp.changed() { + let output = egui::TextEdit::multiline(&mut self.buffer) + .code_editor() + .desired_width(f32::INFINITY) + .desired_rows(30) + .layouter(&mut layouter) + .show(ui); + if output.response.changed() { self.dirty = true; } + // Markdown formatting hotkeys, applied to the current selection + // while the editor is focused (Ctrl/Cmd + B / I / E / K, and + // Ctrl/Cmd+Shift+X for strikethrough). + if output.response.has_focus() { + if let Some(fmt) = ui.input_mut(detect_format_hotkey) { + let sel = output + .cursor_range + .map(|cr| cr.as_sorted_char_range()) + .unwrap_or(0..0); + let (new_text, new_sel) = apply_format(&self.buffer, sel, fmt); + self.buffer = new_text; + self.dirty = true; + // Restore the caret/selection around the change. + let mut state = output.state; + state.cursor.set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(new_sel.start), + egui::text::CCursor::new(new_sel.end), + ))); + state.store(ui.ctx(), output.response.id); + ui.ctx().request_repaint(); + } + } }); } @@ -1574,6 +1595,126 @@ fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 { ) } +/// An inline markdown formatting action triggered by an editor hotkey. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Fmt { + Bold, + Italic, + Code, + Strike, + Link, +} + +impl Fmt { + /// The `(prefix, suffix)` markers wrapped around the selection. + fn markers(self) -> (&'static str, &'static str) { + match self { + Fmt::Bold => ("**", "**"), + Fmt::Italic => ("*", "*"), + Fmt::Code => ("`", "`"), + Fmt::Strike => ("~~", "~~"), + Fmt::Link => ("[", "](url)"), + } + } +} + +/// Map a formatting hotkey (if pressed this frame) to its action, consuming the +/// key event so nothing else reacts to it. Cmd+Shift+X is checked first because +/// `consume_key` ignores an *extra* Shift, so a bare Cmd+X test would swallow it. +fn detect_format_hotkey(input: &mut egui::InputState) -> Option { + use egui::{Key, Modifiers}; + let cmd = Modifiers::COMMAND; + if input.consume_key(cmd | Modifiers::SHIFT, Key::X) { + return Some(Fmt::Strike); + } + if input.consume_key(cmd, Key::B) { + return Some(Fmt::Bold); + } + if input.consume_key(cmd, Key::I) { + return Some(Fmt::Italic); + } + if input.consume_key(cmd, Key::E) { + return Some(Fmt::Code); + } + if input.consume_key(cmd, Key::K) { + return Some(Fmt::Link); + } + None +} + +/// Byte offset of the `char_idx`-th character in `s` (or `s.len()` if past the end). +fn char_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices() + .nth(char_idx) + .map(|(b, _)| b) + .unwrap_or(s.len()) +} + +/// Apply an inline-format action to `text` given a selection in *character* +/// indices, returning the new text and the new selection (also character indices). +/// +/// Symmetric markers toggle: if the selection is already wrapped — markers just +/// inside it, or immediately surrounding it — the markers are removed; otherwise +/// they are added (an empty selection just drops the markers with the caret +/// between them). A link wraps the selection as the link text and selects the +/// `url` placeholder so it can be replaced. +fn apply_format( + text: &str, + sel: std::ops::Range, + fmt: Fmt, +) -> (String, std::ops::Range) { + let (prefix, suffix) = fmt.markers(); + let start = sel.start.min(sel.end); + let end = sel.start.max(sel.end); + let b_start = char_to_byte(text, start); + let b_end = char_to_byte(text, end); + let selected = &text[b_start..b_end]; + let plen = prefix.chars().count(); + let slen = suffix.chars().count(); + + if fmt == Fmt::Link { + let new = format!( + "{}{}{}{}{}", + &text[..b_start], prefix, selected, suffix, &text[b_end..] + ); + // The url placeholder sits after "](" (2 chars) inside the suffix. + let url_start = start + plen + selected.chars().count() + 2; + return (new, url_start..url_start + 3); + } + + // Toggle: markers immediately inside the selection. + if selected.starts_with(prefix) + && selected.ends_with(suffix) + && selected.len() >= prefix.len() + suffix.len() + { + let inner = &selected[prefix.len()..selected.len() - suffix.len()]; + let new = format!("{}{}{}", &text[..b_start], inner, &text[b_end..]); + return (new, start..(end - plen - slen)); + } + + // Toggle: markers immediately surrounding the selection. + if text[..b_start].ends_with(prefix) && text[b_end..].starts_with(suffix) { + let new = format!( + "{}{}{}", + &text[..b_start - prefix.len()], + selected, + &text[b_end + suffix.len()..] + ); + return (new, (start - plen)..(end - plen)); + } + + // Wrap. + let new = format!( + "{}{}{}{}{}", + &text[..b_start], prefix, selected, suffix, &text[b_end..] + ); + if start == end { + (new, (start + plen)..(start + plen)) + } else { + (new, (start + plen)..(end + plen)) + } +} + /// Very small markdown-ish preview (headings emphasised, everything else plain). fn render_preview(ui: &mut egui::Ui, markdown: &str) { use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; @@ -1810,4 +1951,52 @@ mod tests { assert_eq!(thousands(12_345), "12,345"); assert_eq!(thousands(1_234_567), "1,234,567"); } + + #[test] + fn wraps_a_selection_in_bold_and_keeps_it_selected() { + let (text, sel) = apply_format("a word here", 2..6, Fmt::Bold); + assert_eq!(text, "a **word** here"); + // The original "word" stays selected, shifted past the "**". + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } + + #[test] + fn empty_selection_inserts_markers_with_caret_between() { + let (text, sel) = apply_format("ab", 1..1, Fmt::Italic); + assert_eq!(text, "a**b"); + // Caret lands between the two "*", i.e. char index 2. + assert_eq!(sel, 2..2); + } + + #[test] + fn re_applying_bold_inside_selection_unwraps_it() { + // Selecting "**word**" and hitting bold again removes the markers. + let (text, sel) = apply_format("a **word** here", 2..10, Fmt::Bold); + assert_eq!(text, "a word here"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } + + #[test] + fn bold_unwraps_when_markers_surround_the_selection() { + // Selecting just "word" inside **word** and hitting bold strips them. + let (text, sel) = apply_format("a **word** here", 4..8, Fmt::Bold); + assert_eq!(text, "a word here"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } + + #[test] + fn link_wraps_selection_and_selects_the_url_placeholder() { + let (text, sel) = apply_format("see docs now", 4..8, Fmt::Link); + assert_eq!(text, "see [docs](url) now"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url"); + } + + #[test] + fn format_respects_multibyte_char_offsets() { + // "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must + // still land on the right bytes. + let (text, sel) = apply_format("café word", 5..9, Fmt::Code); + assert_eq!(text, "café `word`"); + assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "word"); + } } diff --git a/src/help.rs b/src/help.rs index 49eca28..01a6204 100644 --- a/src/help.rs +++ b/src/help.rs @@ -54,6 +54,24 @@ fn cheatsheet_body(ui: &mut egui::Ui) { ], ); + section(ui, "Formatting hotkeys"); + syntax( + ui, + "cs-keys", + &[ + ("Ctrl/Cmd + B", "Bold the selection (**…**)."), + ("Ctrl/Cmd + I", "Italicise the selection (*…*)."), + ("Ctrl/Cmd + E", "Inline code (`…`)."), + ("Ctrl/Cmd + Shift + X", "Strikethrough (~~…~~)."), + ("Ctrl/Cmd + K", "Wrap as a link and select the url to replace."), + ], + ); + note( + ui, + "Applied to the selected text (or the caret, for an empty selection). \ + Pressing the same key on already-wrapped text removes the markers.", + ); + section(ui, "Paragraphs & breaks"); syntax( ui,