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:
landon
2026-08-04 07:59:19 -05:00
parent a50fc5d521
commit ae8de32f04
3 changed files with 225 additions and 9 deletions
+10 -1
View File
@@ -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
+194 -5
View File
@@ -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 resp.changed() { if output.response.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
View File
@@ -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,