70046b0382
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
828 lines
36 KiB
Rust
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");
|
|
}
|
|
}
|