Add a Ctrl+/ whole-line comment toggle to the editor

The app already treats `<!-- ... -->` as editorial notes: preprocess
strips them from the export and the word count. Ctrl+/ makes that a
one-key move, so a paragraph can be shelved without being deleted.

It grows the selection out to line boundaries first, and only toggles a
block back off when every non-blank line in it is already commented.
Blank lines are passed through so paragraph breaks survive the round
trip, and pressing it on an empty line opens `<!--  -->` with the caret
inside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
landon
2026-09-07 11:03:02 -05:00
parent a23cd5abfe
commit 70046b0382
3 changed files with 213 additions and 3 deletions
+9
View File
@@ -97,6 +97,15 @@ Terminal=false
| `Ctrl/Cmd + E` | `inline code` |
| `Ctrl/Cmd + Shift + X` | ~~strikethrough~~ (`~~…~~`) |
| `Ctrl/Cmd + K` | link — `[selection](url)`, with `url` selected to replace |
| `Ctrl/Cmd + /` | comment the selected lines out (`<!-- … -->`), or take it back off |
`Ctrl/Cmd + /` is the odd one out: it works on whole lines, growing the
range out to line boundaries first, and it toggles the block off again
only when every non-blank line in it is already commented. Commented lines
stay in the file but drop out of the export and the word count, so this is
the way to shelve a paragraph without losing it. Blank lines in the range
are left as-is so paragraph breaks survive; pressing it on an empty line
opens `<!-- -->` with the caret inside.
Press **`Ctrl/Cmd + F`** (or **Edit ▸ Find / Replace…**) to open the
find/replace bar above the editor; **`Ctrl/Cmd + H`** opens it with the
+200 -2
View File
@@ -108,7 +108,8 @@ impl App {
}
// 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).
// Ctrl/Cmd+Shift+X for strikethrough). Ctrl/Cmd + / is in here
// too, though it rewrites whole lines rather than the selection.
if output.response.has_focus() {
if let Some(fmt) = ui.input_mut(detect_format_hotkey) {
let sel = output
@@ -433,6 +434,9 @@ pub(super) enum Fmt {
Code,
Strike,
Link,
/// Whole-line `<!-- … -->` comments, toggled over every line the selection
/// touches rather than wrapped around the selection itself.
Comment,
}
impl Fmt {
@@ -444,6 +448,8 @@ impl Fmt {
Fmt::Code => ("`", "`"),
Fmt::Strike => ("~~", "~~"),
Fmt::Link => ("[", "](url)"),
// Unused: `Comment` is line-based and never reaches the wrap path.
Fmt::Comment => (COMMENT_OPEN, COMMENT_CLOSE),
}
}
}
@@ -469,6 +475,9 @@ pub(super) fn detect_format_hotkey(input: &mut egui::InputState) -> Option<Fmt>
if input.consume_key(cmd, Key::K) {
return Some(Fmt::Link);
}
if input.consume_key(cmd, Key::Slash) {
return Some(Fmt::Comment);
}
None
}
@@ -495,12 +504,16 @@ pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
/// 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.
/// `url` placeholder so it can be replaced. [`Fmt::Comment`] is the exception:
/// it works on whole lines, so it hands straight off to [`toggle_line_comment`].
pub(super) fn apply_format(
text: &str,
sel: std::ops::Range<usize>,
fmt: Fmt,
) -> (String, std::ops::Range<usize>) {
if fmt == Fmt::Comment {
return toggle_line_comment(text, sel);
}
let (prefix, suffix) = fmt.markers();
let start = sel.start.min(sel.end);
let end = sel.start.max(sel.end);
@@ -553,6 +566,116 @@ pub(super) fn apply_format(
}
}
/// The markers Ctrl+/ writes. The app already treats `<!-- … -->` as editorial
/// notes — [`crate::preprocess`] strips them from the export and the word count
/// — so commenting a line hides it from the manuscript, not from a compiler.
const COMMENT_OPEN: &str = "<!--";
const COMMENT_CLOSE: &str = "-->";
/// Split a line into its leading whitespace and everything after it.
fn split_indent(line: &str) -> (&str, &str) {
line.split_at(line.len() - line.trim_start().len())
}
/// Is this whole line a comment, i.e. one Ctrl+/ can take back off? The length
/// test keeps the two markers from overlapping in a bare `<!--`.
fn is_commented(line: &str) -> bool {
let t = line.trim();
t.len() >= COMMENT_OPEN.len() + COMMENT_CLOSE.len()
&& t.starts_with(COMMENT_OPEN)
&& t.ends_with(COMMENT_CLOSE)
}
/// Comment a line, keeping its indentation, and report how many characters went
/// in ahead of the original text so a caret on the line can follow it.
fn comment_line(line: &str) -> (String, isize) {
let (indent, body) = split_indent(line);
let out = format!("{indent}{COMMENT_OPEN} {body} {COMMENT_CLOSE}");
(out, COMMENT_OPEN.chars().count() as isize + 1)
}
/// Undo [`comment_line`], dropping the padding space it adds on each side (and
/// any trailing whitespace after the closing marker) but not the indentation.
fn uncomment_line(line: &str) -> (String, isize) {
let (indent, rest) = split_indent(line);
let body = rest.trim_end();
let inner = &body[COMMENT_OPEN.len()..body.len() - COMMENT_CLOSE.len()];
let unpadded = inner.strip_prefix(' ').unwrap_or(inner);
let removed = COMMENT_OPEN.chars().count() + (inner.chars().count() - unpadded.chars().count());
let unpadded = unpadded.strip_suffix(' ').unwrap_or(unpadded);
(format!("{indent}{unpadded}"), -(removed as isize))
}
/// Toggle whole-line comments over every line the selection touches.
///
/// The range first grows out to line boundaries. If every non-blank line in it
/// is already a whole-line comment the markers come off, otherwise they go on;
/// blank lines are passed through untouched so paragraph breaks survive the
/// round trip. A bare caret on a blank line has nothing to toggle, so it opens
/// an empty note and sits inside it. Offsets in and out are *character*
/// indices, matching [`apply_format`].
pub(super) fn toggle_line_comment(
text: &str,
sel: std::ops::Range<usize>,
) -> (String, std::ops::Range<usize>) {
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);
// Grow the range out to the whole lines it touches. With an empty selection
// this is exactly one line, which the caret arithmetic below relies on.
let rs = text[..b_start].rfind('\n').map(|i| i + 1).unwrap_or(0);
let re = text[b_end..].find('\n').map(|i| b_end + i).unwrap_or(text.len());
let lines: Vec<&str> = text[rs..re].split('\n').collect();
let region_start = byte_to_char(text, rs);
if lines.iter().all(|l| l.trim().is_empty()) {
if start != end {
// A selection of nothing but blank lines: leave the file alone.
return (text.to_string(), sel);
}
let indent = split_indent(lines[0]).0;
let opened = format!("{indent}{COMMENT_OPEN} {COMMENT_CLOSE}");
let caret = region_start + indent.chars().count() + COMMENT_OPEN.chars().count() + 1;
return (format!("{}{}{}", &text[..rs], opened, &text[re..]), caret..caret);
}
let uncommenting = lines
.iter()
.filter(|l| !l.trim().is_empty())
.all(|l| is_commented(l));
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
for line in &lines {
let (new, delta) = if line.trim().is_empty() {
// Blank lines keep the paragraph breaks intact, so leave them be.
((*line).to_string(), 0)
} else if uncommenting {
uncomment_line(line)
} else {
comment_line(line)
};
out.push(new);
deltas.push(delta);
}
let new_region = out.join("\n");
let new_text = format!("{}{}{}", &text[..rs], new_region, &text[re..]);
if start != end {
// A real selection keeps covering the lines it grew out to.
return (new_text, region_start..region_start + new_region.chars().count());
}
// One line, so the caret just shifts by whatever went in ahead of it.
let col = start - region_start;
let shifted = (col as isize + deltas[0]).max(0) as usize;
let caret = region_start + shifted.min(out[0].chars().count());
(new_text, caret..caret)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -618,6 +741,81 @@ mod tests {
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url");
}
#[test]
fn comments_the_caret_line_and_keeps_the_caret_on_it() {
// Caret at "wo|rd" on line 2, nothing selected.
let (text, sel) = apply_format("first\nword\nlast", 8..8, Fmt::Comment);
assert_eq!(text, "first\n<!-- word -->\nlast");
// It rides the inserted "<!-- ", staying between "wo" and "rd".
assert_eq!(&text[..char_to_byte(&text, sel.start)], "first\n<!-- wo");
}
#[test]
fn comment_toggles_back_off_and_restores_the_line() {
let once = apply_format("first\nword\nlast", 8..8, Fmt::Comment).0;
let (twice, sel) = apply_format(&once, 13..13, Fmt::Comment);
assert_eq!(twice, "first\nword\nlast");
assert_eq!(&twice[..char_to_byte(&twice, sel.start)], "first\nwo");
}
#[test]
fn comment_grows_a_partial_selection_out_to_whole_lines() {
// The selection starts mid-line-1 and ends mid-line-2; both get commented.
let (text, sel) = apply_format("alpha\nbeta\ngamma", 3..7, Fmt::Comment);
assert_eq!(text, "<!-- alpha -->\n<!-- beta -->\ngamma");
// It ends up covering exactly the two lines it touched.
let picked = &text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)];
assert_eq!(picked, "<!-- alpha -->\n<!-- beta -->");
}
#[test]
fn comment_leaves_blank_lines_alone_between_commented_ones() {
// The blank line has to survive, or the paragraph break is lost.
let (text, _) = apply_format("one\n\ntwo", 0..8, Fmt::Comment);
assert_eq!(text, "<!-- one -->\n\n<!-- two -->");
}
#[test]
fn a_mixed_block_comments_rather_than_uncomments() {
// One line already commented, one not: adding wins, so a second press
// takes the whole block back off again.
let (text, _) = apply_format("<!-- one -->\ntwo", 0..16, Fmt::Comment);
assert_eq!(text, "<!-- <!-- one --> -->\n<!-- two -->");
let (back, _) = apply_format(&text, 0..34, Fmt::Comment);
assert_eq!(back, "<!-- one -->\ntwo");
}
#[test]
fn comment_preserves_indentation() {
let (text, _) = apply_format(" indented", 0..0, Fmt::Comment);
assert_eq!(text, " <!-- indented -->");
assert_eq!(apply_format(&text, 0..0, Fmt::Comment).0, " indented");
}
#[test]
fn comment_on_a_blank_line_opens_an_empty_note() {
let (text, sel) = apply_format("a\n\nb", 2..2, Fmt::Comment);
assert_eq!(text, "a\n<!-- -->\nb");
// The caret lands between the two padding spaces, ready to type.
assert_eq!(&text[..char_to_byte(&text, sel.start)], "a\n<!-- ");
}
#[test]
fn comment_round_trips_multibyte_lines() {
let (text, _) = apply_format("café au lait", 0..0, Fmt::Comment);
assert_eq!(text, "<!-- café au lait -->");
assert_eq!(apply_format(&text, 0..0, Fmt::Comment).0, "café au lait");
}
#[test]
fn commented_prose_is_dropped_from_the_exported_body() {
// The whole point of the hotkey: preprocess must not see the hidden line.
let md = "### Rough Draft:\n\nKeep this.\n\n<!-- Cut this. -->\n";
let body = crate::preprocess::parse(md, "### Rough Draft:").body;
assert!(body.contains("Keep this."));
assert!(!body.contains("Cut this."));
}
#[test]
fn format_respects_multibyte_char_offsets() {
// "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must
+4 -1
View File
@@ -64,6 +64,7 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
("Ctrl/Cmd + E", "Inline code (`…`)."),
("Ctrl/Cmd + Shift + X", "Strikethrough (~~…~~)."),
("Ctrl/Cmd + K", "Wrap as a link and select the url to replace."),
("Ctrl/Cmd + /", "Comment out the lines you have selected, or take it back off."),
("Ctrl/Cmd + F", "Open the find bar."),
("Ctrl/Cmd + H", "Open find/replace (replace field focused)."),
],
@@ -72,7 +73,9 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
ui,
"Wrapping keys apply to the selected text (or the caret, for an empty \
selection); pressing the same key on already-wrapped text removes the \
markers. In the find bar, Enter / Shift+Enter step through matches and \
markers. Ctrl/Cmd + / works on whole lines instead, hiding them from the \
export and the word count without deleting them; blank lines in the range \
are left alone. In the find bar, Enter / Shift+Enter step through matches and \
Esc closes it.",
);