//! 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); } 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)> = 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; 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 = 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, } 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 { 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, fmt: Fmt, ) -> (String, std::ops::Range) { 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"); } }