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:
@@ -97,6 +97,15 @@ Terminal=false
|
|||||||
| `Ctrl/Cmd + E` | `inline code` |
|
| `Ctrl/Cmd + E` | `inline code` |
|
||||||
| `Ctrl/Cmd + Shift + X` | ~~strikethrough~~ (`~~…~~`) |
|
| `Ctrl/Cmd + Shift + X` | ~~strikethrough~~ (`~~…~~`) |
|
||||||
| `Ctrl/Cmd + K` | link — `[selection](url)`, with `url` selected to replace |
|
| `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
|
Press **`Ctrl/Cmd + F`** (or **Edit ▸ Find / Replace…**) to open the
|
||||||
find/replace bar above the editor; **`Ctrl/Cmd + H`** opens it with the
|
find/replace bar above the editor; **`Ctrl/Cmd + H`** opens it with the
|
||||||
|
|||||||
+200
-2
@@ -108,7 +108,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
// Markdown formatting hotkeys, applied to the current selection
|
// Markdown formatting hotkeys, applied to the current selection
|
||||||
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
|
// 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 output.response.has_focus() {
|
||||||
if let Some(fmt) = ui.input_mut(detect_format_hotkey) {
|
if let Some(fmt) = ui.input_mut(detect_format_hotkey) {
|
||||||
let sel = output
|
let sel = output
|
||||||
@@ -433,6 +434,9 @@ pub(super) enum Fmt {
|
|||||||
Code,
|
Code,
|
||||||
Strike,
|
Strike,
|
||||||
Link,
|
Link,
|
||||||
|
/// Whole-line `<!-- … -->` comments, toggled over every line the selection
|
||||||
|
/// touches rather than wrapped around the selection itself.
|
||||||
|
Comment,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Fmt {
|
impl Fmt {
|
||||||
@@ -444,6 +448,8 @@ impl Fmt {
|
|||||||
Fmt::Code => ("`", "`"),
|
Fmt::Code => ("`", "`"),
|
||||||
Fmt::Strike => ("~~", "~~"),
|
Fmt::Strike => ("~~", "~~"),
|
||||||
Fmt::Link => ("[", "](url)"),
|
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) {
|
if input.consume_key(cmd, Key::K) {
|
||||||
return Some(Fmt::Link);
|
return Some(Fmt::Link);
|
||||||
}
|
}
|
||||||
|
if input.consume_key(cmd, Key::Slash) {
|
||||||
|
return Some(Fmt::Comment);
|
||||||
|
}
|
||||||
None
|
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
|
/// inside it, or immediately surrounding it — the markers are removed; otherwise
|
||||||
/// they are added (an empty selection just drops the markers with the caret
|
/// 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
|
/// 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(
|
pub(super) fn apply_format(
|
||||||
text: &str,
|
text: &str,
|
||||||
sel: std::ops::Range<usize>,
|
sel: std::ops::Range<usize>,
|
||||||
fmt: Fmt,
|
fmt: Fmt,
|
||||||
) -> (String, std::ops::Range<usize>) {
|
) -> (String, std::ops::Range<usize>) {
|
||||||
|
if fmt == Fmt::Comment {
|
||||||
|
return toggle_line_comment(text, sel);
|
||||||
|
}
|
||||||
let (prefix, suffix) = fmt.markers();
|
let (prefix, suffix) = fmt.markers();
|
||||||
let start = sel.start.min(sel.end);
|
let start = sel.start.min(sel.end);
|
||||||
let end = sel.start.max(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -618,6 +741,81 @@ mod tests {
|
|||||||
assert_eq!(&text[char_to_byte(&text, sel.start)..char_to_byte(&text, sel.end)], "url");
|
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]
|
#[test]
|
||||||
fn format_respects_multibyte_char_offsets() {
|
fn format_respects_multibyte_char_offsets() {
|
||||||
// "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must
|
// "café " is 5 chars but 6 bytes; selecting "word" (chars 5..9) must
|
||||||
|
|||||||
+4
-1
@@ -64,6 +64,7 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
|
|||||||
("Ctrl/Cmd + E", "Inline code (`…`)."),
|
("Ctrl/Cmd + E", "Inline code (`…`)."),
|
||||||
("Ctrl/Cmd + Shift + X", "Strikethrough (~~…~~)."),
|
("Ctrl/Cmd + Shift + X", "Strikethrough (~~…~~)."),
|
||||||
("Ctrl/Cmd + K", "Wrap as a link and select the url to replace."),
|
("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 + F", "Open the find bar."),
|
||||||
("Ctrl/Cmd + H", "Open find/replace (replace field focused)."),
|
("Ctrl/Cmd + H", "Open find/replace (replace field focused)."),
|
||||||
],
|
],
|
||||||
@@ -72,7 +73,9 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
|
|||||||
ui,
|
ui,
|
||||||
"Wrapping keys apply to the selected text (or the caret, for an empty \
|
"Wrapping keys apply to the selected text (or the caret, for an empty \
|
||||||
selection); pressing the same key on already-wrapped text removes the \
|
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.",
|
Esc closes it.",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user