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
+197 -8
View File
@@ -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<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).
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");
}
}