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:
+197
-8
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+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");
|
||||
syntax(
|
||||
ui,
|
||||
|
||||
Reference in New Issue
Block a user