Add markdown formatting hotkeys to the editor
Ctrl/Cmd+B/I/E wrap the selection in bold / italic / inline-code markers, Ctrl/Cmd+Shift+X in strikethrough, and Ctrl/Cmd+K as a link (with the url placeholder selected). Pressing the same key on already-wrapped text removes the markers. Applied to the live selection via the TextEdit cursor range, with the caret/selection restored around the edit. Documented in the built-in cheatsheet and the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,16 @@ Terminal=false
|
|||||||
it), and the **Contrast** slider to brighten dim editor text (0% = theme
|
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
|
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
|
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
|
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
|
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
|
blank falls back to a `# Title:` line in the header, and if there is none, to
|
||||||
|
|||||||
+197
-8
@@ -1317,16 +1317,37 @@ impl App {
|
|||||||
let job = build_editor_job(text, size, text_color, &ranges, wrap_width);
|
let job = build_editor_job(text, size, text_color, &ranges, wrap_width);
|
||||||
ui.fonts(|f| f.layout_job(job))
|
ui.fonts(|f| f.layout_job(job))
|
||||||
};
|
};
|
||||||
let resp = ui.add(
|
let output = egui::TextEdit::multiline(&mut self.buffer)
|
||||||
egui::TextEdit::multiline(&mut self.buffer)
|
.code_editor()
|
||||||
.code_editor()
|
.desired_width(f32::INFINITY)
|
||||||
.desired_width(f32::INFINITY)
|
.desired_rows(30)
|
||||||
.desired_rows(30)
|
.layouter(&mut layouter)
|
||||||
.layouter(&mut layouter),
|
.show(ui);
|
||||||
);
|
if output.response.changed() {
|
||||||
if resp.changed() {
|
|
||||||
self.dirty = true;
|
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<Fmt> {
|
||||||
|
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<usize>,
|
||||||
|
fmt: Fmt,
|
||||||
|
) -> (String, std::ops::Range<usize>) {
|
||||||
|
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).
|
/// Very small markdown-ish preview (headings emphasised, everything else plain).
|
||||||
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
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(12_345), "12,345");
|
||||||
assert_eq!(thousands(1_234_567), "1,234,567");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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");
|
section(ui, "Paragraphs & breaks");
|
||||||
syntax(
|
syntax(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
Reference in New Issue
Block a user