Files
md-manuscript/src/app/editor.rs
T
landon 70046b0382 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
2026-09-07 11:03:02 -05:00

828 lines
36 KiB
Rust

//! The manuscript text editor: the widget itself, syntax/issue highlighting
//! for its layouter, and the markdown formatting hotkeys.
use super::*;
impl App {
pub(super) fn editor(&mut self, ui: &mut egui::Ui) {
// Base monospace size scaled by the zoom percentage. `desired_width` stays
// infinite and the scroll area is vertical-only, so text wraps to the pane
// width at any zoom (no horizontal scrolling).
let base = ui
.style()
.text_styles
.get(&egui::TextStyle::Monospace)
.map(|f| f.size)
.unwrap_or(12.0);
let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0);
// Editor text colour, brightened toward max contrast by the contrast slider.
let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast);
// Underline whichever check currently owns the buffer's issues
// (LanguageTool when fresh, otherwise the live spell checker).
let ranges: Vec<(usize, usize, bool)> = self
.current_matches()
.iter()
.map(|m| (m.start, m.end, m.spelling))
.collect();
// Search-match highlights (all matches shaded, the active one stronger).
let find_ranges: Vec<(usize, usize)> = if self.show_find {
self.find_matches.clone()
} else {
Vec::new()
};
let find_active = if self.show_find {
Some(self.find_active)
} else {
None
};
egui::ScrollArea::vertical()
.id_salt("editor")
.auto_shrink([false, false])
.show(ui, |ui| {
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
let job = build_editor_job(
text,
size,
text_color,
&ranges,
&find_ranges,
find_active,
wrap_width,
);
ui.fonts(|f| f.layout_job(job))
};
// Autocomplete: intercept navigation keys before the editor
// consumes them, acting on last frame's popup state.
let mut accept_field: Option<String> = None;
let mut dismiss = false;
if let Some(ac) = self.autocomplete.as_mut() {
ui.input_mut(|i| {
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) {
ac.selected = (ac.selected + 1) % ac.matches.len();
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) {
ac.selected = (ac.selected + ac.matches.len() - 1) % ac.matches.len();
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)
|| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)
{
accept_field = ac.matches.get(ac.selected).cloned();
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) {
dismiss = true;
}
});
}
if dismiss {
// Remember the dismissed partial so it doesn't immediately reopen.
self.ac_dismissed = self.autocomplete.take().map(|a| a.partial);
}
if let Some(field) = accept_field {
self.apply_field_completion(ui.ctx(), &field);
}
// Fill the viewport rather than a fixed number of rows, so the
// height handed back by a folded toolbar or file list reaches
// the page instead of leaving grey space under the editor.
// Measured against the *unzoomed* row height because that is
// what `desired_rows` is multiplied by, whatever the layouter
// then draws at.
let row_h = ui.text_style_height(&egui::TextStyle::Monospace).max(1.0);
let rows = (ui.available_height() / row_h).floor().max(10.0) as usize;
let output = egui::TextEdit::multiline(&mut self.buffer)
.id(egui::Id::new(EDITOR_ID))
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(rows)
.layouter(&mut layouter)
.show(ui);
if output.response.changed() {
self.dirty = true;
// The buffer changed, so search matches and the live spell
// check both need refreshing (the latter debounced).
self.find_needs_refresh = true;
self.spell_dirty = true;
self.spell_last_edit = Some(Instant::now());
}
// 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 + / 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
.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.clone();
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();
}
}
// Take the editor to an issue picked in the results panel:
// select the words, centre them, and hand focus back so the
// user can type the correction straight away.
if let Some((start, end)) = self.issue_jump.take() {
let (cs, ce) = (
byte_to_char(&self.buffer, start),
byte_to_char(&self.buffer, end),
);
let rect = output
.galley
.pos_from_ccursor(egui::text::CCursor::new(cs))
.union(output.galley.pos_from_ccursor(egui::text::CCursor::new(ce)))
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, Some(egui::Align::Center));
let mut state = output.state.clone();
state
.cursor
.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(cs),
egui::text::CCursor::new(ce),
)));
state.store(ui.ctx(), output.response.id);
ui.ctx().memory_mut(|m| m.request_focus(output.response.id));
ui.ctx().request_repaint();
}
// Scroll the active search match into view when requested (on
// open, Next/Prev, Enter, or after a replace).
if self.find_scroll {
if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
let char_idx = self
.buffer
.get(..s)
.map(|p| p.chars().count())
.unwrap_or(0);
let local =
output.galley.pos_from_ccursor(egui::text::CCursor::new(char_idx));
let onscreen = local.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(onscreen, Some(egui::Align::Center));
}
self.find_scroll = false;
}
// Right-click a misspelling → a menu of suggested corrections.
if output.response.secondary_clicked() {
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
let local = pos - output.galley_pos;
let cursor = output.galley.cursor_from_pos(local);
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
self.match_index_at(byte)
});
}
// Snapshot the target's suggestions (owned) so the menu closure
// doesn't borrow self; compute them on demand if the background
// check hadn't produced any for this word.
let menu: Option<(usize, Vec<String>)> = self.spell_menu.and_then(|i| {
let m = self.current_matches().get(i)?;
let mut reps = m.replacements.clone();
if reps.is_empty() {
if let (Some(dict), Some(word)) =
(&self.spell_dict, self.buffer.get(m.start..m.end))
{
reps = crate::spell::suggestions(dict, word);
}
}
Some((i, reps))
});
let mut chosen: Option<(usize, usize)> = None;
// The word under the menu, so it can be added to the word list.
let target_word: Option<String> = self.spell_menu.and_then(|i| {
let m = self.current_matches().get(i)?;
m.spelling
.then(|| self.buffer.get(m.start..m.end))
.flatten()
.map(str::to_string)
});
let mut accept: Option<String> = None;
let mut dismiss: Option<usize> = None;
output.response.context_menu(|ui| {
match &menu {
Some((i, reps)) if !reps.is_empty() => {
ui.label(egui::RichText::new("Suggestions").strong());
for (j, rep) in reps.iter().enumerate() {
if ui.button(rep).clicked() {
chosen = Some((*i, j));
ui.close_menu();
}
}
}
Some(_) => {
ui.label(egui::RichText::new("No suggestions").weak());
}
None => {
ui.label(egui::RichText::new("No spelling issue here").weak());
}
}
// Not every complaint is worth acting on — let a deliberate
// one be waved away instead of underlined for good.
if let Some((i, _)) = &menu {
ui.separator();
if ui
.button("✖ Dismiss this issue")
.on_hover_text("Stop flagging this in this file")
.clicked()
{
dismiss = Some(*i);
ui.close_menu();
}
}
// A character or place name is not a misspelling; let it be
// accepted for good rather than corrected every time.
if let Some(word) = &target_word {
ui.separator();
if ui
.button(format!("📗 Add “{word}” to the word list"))
.on_hover_text(
"Accept this word from now on, for this workspace",
)
.clicked()
{
accept = Some(word.clone());
ui.close_menu();
}
}
});
if let Some(word) = accept {
self.add_to_dictionary(&word);
}
if let Some(i) = dismiss {
self.dismiss_issue(i);
}
if let Some((i, j)) = chosen {
self.apply_current_fix(i, j);
self.spell_menu = None;
}
// Recompute the header-field autocomplete from the caret so the
// popup tracks what the user is typing (only for a collapsed
// caret in the focused editor).
if output.response.has_focus() {
let caret = output.cursor_range.and_then(|cr| {
let r = cr.as_sorted_char_range();
(r.start == r.end).then_some(r.end)
});
let mut next = None;
let mut still_dismissed = false;
if let Some(caret_char) = caret {
let caret_byte = char_to_byte(&self.buffer, caret_char);
if let Some((field_start, partial)) = header_completion_context(
&self.buffer,
caret_byte,
&self.config.draft_marker,
) {
let partial_l = partial.to_lowercase();
if self.ac_dismissed.as_deref() == Some(partial_l.as_str()) {
// Esc-dismissed for exactly this partial; stay closed.
still_dismissed = true;
} else {
let matches = field_matches(&self.field_names, &partial);
if !matches.is_empty() {
// Keep the highlight while typing the same prefix.
let selected = match &self.autocomplete {
Some(prev)
if prev.partial == partial_l
&& prev.selected < matches.len() =>
{
prev.selected
}
_ => 0,
};
let caret_rect = output
.galley
.pos_from_ccursor(egui::text::CCursor::new(caret_char));
let anchor = caret_rect.left_bottom()
+ output.galley_pos.to_vec2()
+ egui::vec2(0.0, 2.0);
next = Some(Autocomplete {
matches,
selected,
partial: partial_l,
field_start,
cursor: caret_byte,
anchor,
});
}
}
}
}
self.autocomplete = next;
if !still_dismissed {
self.ac_dismissed = None;
}
} else {
self.autocomplete = None;
}
});
// Header-field autocomplete popup, floating just below the caret.
let mut clicked_field: Option<String> = None;
if let Some(ac) = &self.autocomplete {
egui::Area::new(egui::Id::new("field_autocomplete"))
.order(egui::Order::Foreground)
.fixed_pos(ac.anchor)
.constrain(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_max_width(260.0);
for (i, m) in ac.matches.iter().enumerate() {
if ui.selectable_label(i == ac.selected, m).clicked() {
clicked_field = Some(m.clone());
}
}
});
});
}
if let Some(field) = clicked_field {
self.apply_field_completion(ui.ctx(), &field);
}
}
}
/// Build the editor's laid-out text, underlining grammar/spelling matches and
/// shading search matches.
///
/// `grammar` are `(start_byte, end_byte, is_spelling)` triples: spelling issues
/// get a red underline, grammar/style issues a blue one. `find` are
/// `(start_byte, end_byte)` search-match ranges, shaded with a translucent
/// highlight; the one at index `find_active` (if any) is shaded more strongly.
pub(super) fn build_editor_job(
text: &str,
size: f32,
color: egui::Color32,
grammar: &[(usize, usize, bool)],
find: &[(usize, usize)],
find_active: Option<usize>,
wrap_width: f32,
) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat};
let font_id = egui::FontId::monospace(size);
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
if grammar.is_empty() && find.is_empty() {
job.append(text, 0.0, TextFormat::simple(font_id, color));
return job;
}
// Split the text at every match boundary (grammar and find), then format
// each run according to which ranges cover it.
let mut points: Vec<usize> = vec![0, text.len()];
for &(s, e, _) in grammar {
points.push(s);
points.push(e);
}
for &(s, e) in find {
points.push(s);
points.push(e);
}
points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
points.sort_unstable();
points.dedup();
let spell_color = egui::Color32::from_rgb(0xE0, 0x40, 0x40);
let grammar_color = egui::Color32::from_rgb(0x3B, 0x82, 0xF6);
// Translucent so the highlight reads in both light and dark themes.
let find_bg = egui::Color32::from_rgba_unmultiplied(255, 213, 0, 70);
let find_active_bg = egui::Color32::from_rgba_unmultiplied(255, 145, 0, 150);
let width = (size * 0.08).max(1.5);
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
if a >= b {
continue;
}
let mut fmt = TextFormat::simple(font_id.clone(), color);
if let Some(&(_, _, spelling)) =
grammar.iter().find(|&&(s, e, _)| s < e && a >= s && b <= e)
{
let c = if spelling { spell_color } else { grammar_color };
fmt.underline = egui::Stroke::new(width, c);
}
if let Some(i) = find
.iter()
.position(|&(s, e)| s < e && a >= s && b <= e)
{
fmt.background = if Some(i) == find_active {
find_active_bg
} else {
find_bg
};
}
job.append(&text[a..b], 0.0, fmt);
}
job
}
/// An inline markdown formatting action triggered by an editor hotkey.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Fmt {
Bold,
Italic,
Code,
Strike,
Link,
/// Whole-line `<!-- … -->` comments, toggled over every line the selection
/// touches rather than wrapped around the selection itself.
Comment,
}
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)"),
// Unused: `Comment` is line-based and never reaches the wrap path.
Fmt::Comment => (COMMENT_OPEN, COMMENT_CLOSE),
}
}
}
/// 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.
pub(super) 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);
}
if input.consume_key(cmd, Key::Slash) {
return Some(Fmt::Comment);
}
None
}
/// Byte offset of the `char_idx`-th character in `s` (or `s.len()` if past the end).
pub(super) fn char_to_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
/// Character index of byte offset `byte` in `s`, clamped to the end. An offset
/// landing inside a character — a stale match against an edited buffer — is
/// clamped to the next character boundary, matching how [`crate::langtool`]
/// resolves its UTF-16 offsets, so a jump never lands mid-character.
pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
s.char_indices().take_while(|(b, _)| *b < byte).count()
}
/// 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. [`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);
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))
}
}
/// 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::*;
#[test]
fn byte_to_char_is_the_inverse_of_char_to_byte() {
// "café!" — é is 2 bytes, so byte and char indices diverge after it.
let text = "café!";
for (i, _) in text.char_indices() {
assert_eq!(char_to_byte(text, byte_to_char(text, i)), i);
}
assert_eq!(byte_to_char(text, 0), 0);
assert_eq!(byte_to_char(text, 3), 3); // é
assert_eq!(byte_to_char(text, 5), 4); // '!' sits after the 2-byte é
}
#[test]
fn byte_to_char_clamps_past_the_end_and_inside_a_character() {
let text = "a😀b";
assert_eq!(byte_to_char(text, text.len()), 3);
assert_eq!(byte_to_char(text, 99), 3);
// Byte 3 is inside the 4-byte emoji, so it clamps forward to the
// boundary after it rather than splitting the character.
assert_eq!(byte_to_char(text, 3), 2);
}
#[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 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
// 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");
}
}