//! 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 = 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)> = 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 = 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 = None; let mut dismiss: Option = 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 = 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, 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 = 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 { 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, fmt: Fmt, ) -> (String, std::ops::Range) { 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 = ""; /// 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 `\nlast"); // It rides the inserted "\n\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, "\n"); } #[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, "\n\n"); } #[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("\ntwo", 0..16, Fmt::Comment); assert_eq!(text, " -->\n"); let (back, _) = apply_format(&text, 0..34, Fmt::Comment); assert_eq!(back, "\ntwo"); } #[test] fn comment_preserves_indentation() { let (text, _) = apply_format(" indented", 0..0, Fmt::Comment); assert_eq!(text, " "); 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"); 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\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"); } }