Split app.rs into an app/ module tree
src/app.rs had grown to 3,700 lines, ~2,400 of them a single impl App block. Move it to src/app/mod.rs and spread the behaviour across eleven child modules grouped by feature: workspace, grammar, spelling, beats, find, editor, autocomplete, file_list, ui, style and util. The new modules are children of app rather than siblings, so they still reach App's private fields without widening its interface; methods and free helpers that are now used across module boundaries are marked pub(super). mod.rs keeps the state types, App::new and the eframe::App update loop. This is pure code motion - every non-blank line of the original file reappears exactly once, and the only edits are the pub(super) markers, the module scaffolding, and rewrapping five signatures that the added prefix pushed past 100 columns. Largest file is now editor.rs at 520 lines. Tests still 56/56, and cargo clippy --release reports the same five warnings as before the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
//! 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;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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());
|
||||
}
|
||||
}
|
||||
});
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user