Files
md-manuscript/src/app/editor.rs
T
landon 3d6e134a33 Lock the scene template against accidental edits
_template.md is a reference writers copy from by hand, not something
the app reads programmatically, so an accidental edit to it is easy
to make and easy to miss. The editor now shows it read-only with a
lock banner; an "Unlock" button allows editing for the rest of the
session, resetting back to locked the next time a file is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 10:46:52 -05:00

1104 lines
48 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) {
// The bundled scene template is a reference writers copy from by
// hand, not something the app reads — so an edit to it just sits
// there, invisible, until someone notices the scene they copied from
// it looks wrong. Lock it against typing until explicitly unlocked.
let is_template = self
.selected
.and_then(|i| self.files.get(i))
.is_some_and(|f| workspace::is_template_file(f));
let locked = is_template && !self.template_unlocked;
if is_template {
ui.horizontal(|ui| {
if locked {
ui.label("🔒 This is the scene template — locked to prevent accidental edits.");
if ui.button("Unlock").clicked() {
self.template_unlocked = true;
}
} else {
ui.label("🔓 Unlocked for editing.");
if ui.button("Lock").clicked() {
self.template_unlocked = false;
}
}
});
ui.add_space(4.0);
}
// 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
};
// Typewriter mode: only while Distraction-free is active (both panels
// hidden — the same condition `toggle_distraction_free` uses), and
// only if the writer hasn't opted out.
let distraction_free = !self.config.show_toolbar && !self.config.show_file_panel;
let typewriter = self.config.typewriter_mode && distraction_free;
let dim_range = typewriter.then(|| current_paragraph_range(&self.buffer, self.cursor_pos));
egui::ScrollArea::vertical()
.id_salt("editor")
.auto_shrink([false, false])
.show(ui, |ui| {
// Prepare Story Bible reference ranges for highlighting
let bible_ranges: Vec<(usize, usize)> = self
.current_references
.iter()
.map(|r| (r.start, r.end))
.collect();
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,
&bible_ranges,
dim_range,
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)
.interactive(!locked)
.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());
// Update Story Bible references for the current file
self.update_current_references();
}
// 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();
}
}
// Whether some explicit jump (an issue pick, a find-match step)
// already scrolled the view this frame — typewriter centering
// stands down when one did, rather than fighting it.
let mut jumped = false;
// 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() {
jumped = true;
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) {
jumped = true;
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;
}
// Typewriter mode: keep the caret's line centered, the way a
// typewriter's platen does. Only re-centers when the cursor
// actually moved since last frame, so scrolling the view by
// hand between keystrokes isn't immediately undone.
let new_cursor_pos = output
.cursor_range
.map(|cr| char_to_byte(&self.buffer, cr.primary.ccursor.index))
.unwrap_or(self.cursor_pos);
if typewriter && !jumped && new_cursor_pos != self.cursor_pos {
if let Some(cr) = output.cursor_range {
let rect = output
.galley
.pos_from_ccursor(cr.primary.ccursor)
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
self.cursor_pos = new_cursor_pos;
// 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;
}
// Which Story Bible mention (if any) is the pointer over right
// now, and where would its hover card anchor?
let hovered_ref = output.response.hover_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.current_references
.iter()
.find(|r| byte >= r.start && byte < r.end)
});
match hovered_ref {
Some(r) if self.bible_hover_item.as_deref() != Some(r.item_id.as_str()) => {
let (cs, ce) = (
byte_to_char(&self.buffer, r.start),
byte_to_char(&self.buffer, r.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());
self.bible_hover_item = Some(r.item_id.clone());
self.bible_hover_since = Some(Instant::now());
self.bible_hover_anchor = rect;
}
Some(_) => {} // the same word is still hovered — keep its timer/anchor
None => {
// Not over a mention — only drop the card if the
// pointer isn't over the card itself either (the user
// moving toward its "Open" link).
let over_card = self
.bible_hover_card_rect
.zip(ui.ctx().pointer_hover_pos())
.is_some_and(|(rect, pos)| rect.contains(pos));
if !over_card {
self.bible_hover_item = None;
self.bible_hover_since = None;
self.bible_hover_card_rect = 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);
}
// Story Bible hover card: name, type and summary for the mentioned
// item, with a link to open its full entry. Shown after a short
// delay so it doesn't flash across every name the pointer passes
// over on its way somewhere else, and it stays open while the
// pointer crosses onto the card to reach that link (tracked via
// `bible_hover_card_rect`, set below and checked above).
const HOVER_DELAY: std::time::Duration = std::time::Duration::from_millis(350);
let mut open_bible_item: Option<String> = None;
let hover = self.bible_hover_item.clone().zip(self.bible_hover_since);
self.bible_hover_card_rect = None;
if let Some((item_id, since)) = hover {
let elapsed = since.elapsed();
if elapsed < HOVER_DELAY {
ui.ctx().request_repaint_after(HOVER_DELAY - elapsed);
} else if let Some(item) = self.story_bible_index.find_by_id(&item_id).cloned() {
let resp = egui::Area::new(egui::Id::new("bible_hover_card"))
.order(egui::Order::Foreground)
.fixed_pos(self.bible_hover_anchor.left_bottom() + egui::vec2(0.0, 4.0))
.constrain(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_max_width(280.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&item.name).strong());
ui.label(
egui::RichText::new(crate::story_bible::type_label(
&item.item_type,
))
.weak()
.small(),
);
});
match &item.summary {
Some(s) => {
ui.label(s);
}
None => {
ui.label(
egui::RichText::new("No summary yet.").weak().italics(),
);
}
}
ui.separator();
if ui.link("Open in Story Bible \u{2192}").clicked() {
open_bible_item = Some(item.path.clone());
}
});
});
self.bible_hover_card_rect = Some(resp.response.rect);
} else {
// The item vanished (sheet deleted, index gone stale) — stop trying.
self.bible_hover_item = None;
self.bible_hover_since = None;
}
}
if let Some(path) = open_bible_item {
self.show_story_bible_editor = true;
self.load_story_bible_item(&path);
self.bible_hover_item = None;
self.bible_hover_since = None;
self.bible_hover_card_rect = None;
}
}
}
/// 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.
/// `bible` are Story Bible reference ranges, highlighted with a distinctive color.
pub(super) fn build_editor_job(
text: &str,
size: f32,
color: egui::Color32,
grammar: &[(usize, usize, bool)],
find: &[(usize, usize)],
find_active: Option<usize>,
bible: &[(usize, usize)],
dim: Option<(usize, 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() && bible.is_empty() && dim.is_none() {
job.append(text, 0.0, TextFormat::simple(font_id, color));
return job;
}
// Split the text at every match boundary (grammar, find, bible, and the
// typewriter-focus paragraph), 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);
}
for &(s, e) in bible {
points.push(s);
points.push(e);
}
if let Some((s, e)) = dim {
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);
// Story Bible reference highlight - purple/blue color
let bible_bg = egui::Color32::from_rgba_unmultiplied(138, 43, 226, 70);
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
};
}
// Check for Story Bible references and apply background highlight
// Only apply if not already highlighted by find
let is_find_highlight = find.iter().any(|&(s, e)| s < e && a >= s && b <= e);
if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) {
fmt.background = bible_bg;
}
// Typewriter-focus mode: fade every run outside the current paragraph.
if let Some((ds, de)) = dim {
if !(a >= ds && b <= de) {
fmt.color = fmt.color.gamma_multiply(0.35);
}
}
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()
}
/// The byte range of the paragraph containing `pos`: from just after the
/// nearest blank line (`"\n\n"`) before it to just before the nearest one
/// after it, or the buffer's edges when there is none. Used by typewriter
/// mode to know which paragraph to keep in focus.
pub(super) fn current_paragraph_range(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind("\n\n").map_or(0, |i| i + 2);
let end = text[pos..].find("\n\n").map_or(text.len(), |i| pos + i);
(start, end)
}
/// 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 a_bible_only_reference_is_not_skipped_by_the_grammar_find_fast_path() {
// build_editor_job short-circuits to one plain run when there's
// nothing to highlight — a regression check that it doesn't take
// that shortcut when the only thing to highlight is a Story Bible
// mention (no grammar issues, no active search), which used to
// mean the purple background — and the hover card that depends on
// it being visible to find — never appeared for a name with no
// spelling issue of its own.
let text = "Protagonist stood at the door.";
let job = build_editor_job(
text,
14.0,
egui::Color32::WHITE,
&[],
&[],
None,
&[(0, 11)], // "Protagonist"
None,
f32::INFINITY,
);
let highlighted = job
.sections
.iter()
.any(|s| s.format.background != egui::Color32::default());
assert!(highlighted, "a bible-only reference must still be formatted");
}
#[test]
fn current_paragraph_range_finds_the_blank_line_boundaries() {
let text = "First paragraph.\n\nSecond paragraph,\nwrapped.\n\nThird.";
// A position in the second paragraph should not reach into the first
// or third.
let pos = text.find("Second").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Second paragraph,\nwrapped.");
// The first paragraph has no blank line before it; the buffer start
// is the boundary.
let (s, e) = current_paragraph_range(text, 0);
assert_eq!(&text[s..e], "First paragraph.");
// The last paragraph has no blank line after it; the buffer end is
// the boundary.
let pos = text.find("Third").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Third.");
}
#[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 = "---\ntitle: Test\n---\n\nKeep this.\n\n<!-- Cut this. -->\n";
let body = crate::preprocess::parse(md, "").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");
}
}
impl App {
/// Update the current_references list with Story Bible references found in the buffer.
pub(super) fn update_current_references(&mut self) {
if let Some(idx) = self.selected {
let file_path = self.files[idx].clone();
let mut refs = crate::story_bible::find_references(&self.buffer, &self.story_bible_index);
for r in refs.iter_mut() {
r.file_path = file_path.clone();
}
self.current_references = refs;
} else {
self.current_references.clear();
}
}
}