Files
md-manuscript/src/app/editor.rs
T
landon 043cc692ac Add project-mode tooling: characters, outline, diff and revision status
Rounds out project mode, where the workspace is the project root and one
subfolder holds the manuscript proper:

- Characters and Outline windows, backed by new `characters` and `outline`
  modules that read the cast from character sheets and measure how much of
  the snowflake outline is actually written.
- Edit ▸ Changes… diffs the open file against its last committed version.
- Revision status, per-file and project word counts, an archive action and
  hidden folders in the file panel.
- Chapter-file export alongside the ODT master, richer header parsing, and
  a project word list for names and invented terms.
- The export path now follows the workspace: opening a project points it at
  that project root, keeping a file name you chose yourself and re-deriving
  one that merely echoed the folder it sat in.
- Clicking an issue in the grammar/spelling panel takes the editor to it,
  selecting the words and centring them; applying a suggestion jumps to the
  rewritten text as well.

README covers the new windows and workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
2026-08-24 19:56:36 -05:00

604 lines
26 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);
}
let output = egui::TextEdit::multiline(&mut self.buffer)
.id(egui::Id::new(EDITOR_ID))
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(30)
.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).
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;
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());
}
}
// 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, 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,
}
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)"),
}
}
}
/// 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);
}
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.
pub(super) fn apply_format(
text: &str,
sel: std::ops::Range<usize>,
fmt: Fmt,
) -> (String, std::ops::Range<usize>) {
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))
}
}
#[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 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");
}
}