Files
md-manuscript/src/app.rs
T
landon 19dddf30c7 Add offline live spell checking (Hunspell via spellbook)
Spelling is now checked live as you type, fully offline, using the
pure-Rust `spellbook` crate to read Hunspell .aff/.dic dictionaries (no C
libhunspell — the binary stays self-contained; ldd unchanged).

- Canadian (en-CA, default) and British (en-GB) English dictionaries are
  compiled in; more languages are auto-discovered from system Hunspell
  folders and ~/.config/md-manuscript/dictionaries/. A "Spelling" row in
  the top bar toggles live checking and picks the dictionary.
- Misspellings are underlined in red; right-clicking a word opens a menu
  of suggested corrections. A results-panel and one-click fixes work too.
- Checks run on a background thread, debounced ~400ms after the last edit.
  Prose is extracted with pulldown-cmark so code spans, code blocks and
  link targets are skipped; ALL-CAPS initialisms are ignored.
- LanguageTool still layers on top: when its results are fresh they own
  the underlines (spelling + grammar); once you edit, the offline checker
  resumes. The editor underlines, issues panel and fix-apply logic are
  now shared between the two sources.

Bundled dictionaries are SCOWL-derived under a permissive license (kept
in dictionaries/<lang>/license). Adds spell-check unit tests (tokenizer,
code-block skipping, offset mapping, en-CA/en-GB spelling); 40 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
2026-08-14 06:25:08 -05:00

2816 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::config::Config;
use crate::gitsync;
use crate::odt::{self, Chapter};
use crate::order;
use eframe::egui;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Result of a background grammar check: the matches, or an error message.
type LtCheckResult = Result<Vec<crate::langtool::Match>, String>;
/// Channel payload from a background check: the text that was checked, paired
/// with its result (so the app can confirm the buffer hasn't changed since).
type LtCheckMsg = (String, LtCheckResult);
/// Channel payload from a background spell check: the checked text and the
/// spelling matches found in it.
type SpellCheckMsg = (String, Vec<crate::langtool::Match>);
/// Which check currently owns the editor's underlines and the issues panel.
#[derive(Clone, Copy, PartialEq, Eq)]
enum IssueSource {
/// Fresh LanguageTool results for the current buffer (spelling + grammar).
LanguageTool,
/// Live offline spell-check results (spelling only).
Spell,
/// Nothing current to show.
None,
}
/// An owned snapshot of one issue for the results panel, decoupled from `self`
/// so the panel can render without holding a borrow across its click handling.
struct IssueItem {
/// Index of this item in the source match list (for applying a fix).
idx: usize,
/// The offending text.
snippet: String,
message: String,
spelling: bool,
replacements: Vec<String>,
}
pub struct App {
config: Config,
/// Ordered markdown file names (relative to the workspace).
files: Vec<String>,
/// Per-file chapter title overrides (file name -> title). Missing/empty means
/// the title is derived automatically at export time.
titles: HashMap<String, String>,
/// Editing buffer for the selected file's chapter title override.
title_input: String,
selected: Option<usize>,
/// Editor contents for the selected file.
buffer: String,
dirty: bool,
/// Editable copy of the workspace path shown in the top bar.
workspace_input: String,
/// Editable copy of the export path.
export_input: String,
new_name: String,
rename_input: String,
pending_delete: bool,
status: String,
show_log: bool,
git_log: String,
is_repo: bool,
/// The git work tree currently backing the workspace, if any. Equals the
/// workspace when the folder is itself a repo root; a parent directory when
/// the user has adopted an enclosing repository.
repo_root: Option<PathBuf>,
/// A repository found in a parent directory that is awaiting the user's
/// confirmation before it is adopted for the current workspace.
pending_repo: Option<PathBuf>,
/// Each file's word count captured when the workspace was opened, used as the
/// per-file baseline for the "this session" delta.
session_start_counts: HashMap<String, usize>,
/// Grammar/spelling issues from the last LanguageTool check.
lt_matches: Vec<crate::langtool::Match>,
/// The exact buffer text the current `lt_matches` were computed against;
/// underlines and replacements only apply while the buffer still equals it.
lt_checked_text: String,
/// One-line status for the grammar checker (issue count, error, or progress).
lt_status: String,
/// Receiver for an in-flight background check, if any.
lt_rx: Option<std::sync::mpsc::Receiver<LtCheckMsg>>,
/// Whether the grammar results panel is visible.
show_lt_panel: bool,
/// Whether the LanguageTool settings window is open.
show_settings: bool,
/// Receiver for an in-flight "Test connection" from the settings window.
settings_test_rx: Option<std::sync::mpsc::Receiver<Result<usize, String>>>,
/// Result line for the settings window's "Test connection" button.
settings_test_status: String,
/// Whether the markdown cheatsheet window is open.
show_cheatsheet: bool,
/// Whether the find/replace bar above the editor is visible.
show_find: bool,
/// The search text.
find_query: String,
/// The replacement text.
replace_query: String,
/// Whether the search is case-sensitive.
find_case_sensitive: bool,
/// Byte ranges of the current search matches within `buffer` (in order).
find_matches: Vec<(usize, usize)>,
/// Index into `find_matches` of the "current" match (for Next/Prev/Replace).
find_active: usize,
/// One-line status for the find bar (e.g. "3 of 12" or "No matches").
find_status: String,
/// Set when the matches need recomputing (query/case/buffer changed).
find_needs_refresh: bool,
/// Request keyboard focus for a find-bar field next frame: `Some(true)` for
/// the search field, `Some(false)` for the replace field.
find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame.
find_scroll: bool,
/// The loaded offline spell-check dictionary (shared with the worker thread).
spell_dict: Option<Arc<spellbook::Dictionary>>,
/// Dictionaries available to choose from (bundled + discovered on disk).
spell_dicts: Vec<crate::spell::DictEntry>,
/// Live spelling matches and the buffer text they were computed against.
spell_matches: Vec<crate::langtool::Match>,
spell_checked_text: String,
/// Set when the buffer changed and a re-check is owed.
spell_dirty: bool,
/// When the buffer was last edited, for debouncing the live check. `None`
/// means "check as soon as possible" (e.g. right after opening a file).
spell_last_edit: Option<Instant>,
/// In-flight background spell check, if any.
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
/// One-line status for the spell checker (dictionary name, count, or error).
spell_status: String,
/// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any.
spell_menu: Option<usize>,
}
impl App {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
apply_global_style(&cc.egui_ctx);
let config = Config::load();
let mut app = App {
workspace_input: config.workspace.display().to_string(),
export_input: config.export_path.display().to_string(),
config,
files: Vec::new(),
titles: HashMap::new(),
title_input: String::new(),
selected: None,
buffer: String::new(),
dirty: false,
new_name: String::new(),
rename_input: String::new(),
pending_delete: false,
status: String::new(),
show_log: false,
git_log: String::new(),
is_repo: false,
repo_root: None,
pending_repo: None,
session_start_counts: HashMap::new(),
lt_matches: Vec::new(),
lt_checked_text: String::new(),
lt_status: String::new(),
lt_rx: None,
show_lt_panel: false,
show_settings: false,
settings_test_rx: None,
settings_test_status: String::new(),
show_cheatsheet: false,
show_find: false,
find_query: String::new(),
replace_query: String::new(),
find_case_sensitive: false,
find_matches: Vec::new(),
find_active: 0,
find_status: String::new(),
find_needs_refresh: false,
find_focus: None,
find_scroll: false,
spell_dict: None,
spell_dicts: crate::spell::available(),
spell_matches: Vec::new(),
spell_checked_text: String::new(),
spell_dirty: true,
spell_last_edit: None,
spell_rx: None,
spell_status: String::new(),
spell_menu: None,
};
app.load_spell_dict();
app.open_workspace();
app
}
fn workspace(&self) -> &Path {
&self.config.workspace
}
/// (Re)load the file list for the current workspace, creating the directory
/// if needed, and refresh git status.
fn open_workspace(&mut self) {
let ws = self.config.workspace.clone();
if let Err(e) = std::fs::create_dir_all(&ws) {
self.status = format!("Cannot create workspace: {e}");
return;
}
self.files = order::resolve_order(&ws);
self.titles = order::read_titles(&ws);
// Drop overrides for files that no longer exist.
self.titles.retain(|name, _| self.files.contains(name));
self.detect_repo(&ws);
self.selected = None;
self.buffer.clear();
self.dirty = false;
self.pending_delete = false;
self.clear_lt();
self.session_start_counts = self.snapshot_counts();
if !self.files.is_empty() {
self.select(0);
}
self.persist_order();
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
}
/// Work out the git situation for a freshly opened workspace.
///
/// If the folder is itself a repository root it is adopted silently. If it
/// is not, but an enclosing parent directory is a git work tree, we stash
/// that parent in `pending_repo` and ask the user to confirm before using
/// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is
/// treated as having no repository.
fn detect_repo(&mut self, ws: &Path) {
self.pending_repo = None;
// A `.git` entry directly in the folder (dir, or a file for linked
// worktrees/submodules) means this folder is the repo root.
if ws.join(".git").exists() {
self.is_repo = true;
self.repo_root = Some(ws.to_path_buf());
return;
}
match gitsync::repo_root(ws) {
// A parent directory is a repository — ask before adopting it.
Some(root) if root != ws => {
self.is_repo = false;
self.repo_root = None;
self.pending_repo = Some(root);
}
// `--show-toplevel` reported this very folder (shouldn't happen
// without a `.git` here, but treat it as an ordinary repo root).
Some(root) => {
self.is_repo = true;
self.repo_root = Some(root);
}
None => {
self.is_repo = false;
self.repo_root = None;
}
}
}
/// Adopt the parent repository awaiting confirmation, using it for all git
/// operations on the current workspace.
fn adopt_pending_repo(&mut self) {
if let Some(root) = self.pending_repo.take() {
self.status = format!("Using git repository at {}", root.display());
self.is_repo = true;
self.repo_root = Some(root);
}
}
/// Decline the parent repository; the workspace stays without version
/// control (the user can still `Init git` to create a nested repo).
fn decline_pending_repo(&mut self) {
self.pending_repo = None;
self.is_repo = false;
self.repo_root = None;
self.status = "Not using the enclosing git repository".to_string();
}
/// Read every file and return its current on-disk word count.
fn snapshot_counts(&self) -> HashMap<String, usize> {
self.files
.iter()
.map(|name| {
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
(name.clone(), count_words(&text))
})
.collect()
}
fn persist_order(&self) {
let _ = order::write_order(self.workspace(), &self.files);
}
fn persist_titles(&self) {
let _ = order::write_titles(self.workspace(), &self.titles);
}
/// Store the title override for the selected file from `title_input`.
/// An empty value removes the override (falls back to the auto title).
fn set_title_for_current(&mut self) {
if let Some(idx) = self.selected {
if let Some(name) = self.files.get(idx).cloned() {
let trimmed = self.title_input.trim();
if trimmed.is_empty() {
self.titles.remove(&name);
} else {
self.titles.insert(name, trimmed.to_string());
}
self.persist_titles();
}
}
}
fn path_for(&self, name: &str) -> PathBuf {
self.workspace().join(name)
}
/// Save the in-memory buffer to disk if it has unsaved changes.
fn save_current(&mut self) {
if let Some(idx) = self.selected {
if self.dirty {
if let Some(name) = self.files.get(idx) {
let path = self.path_for(name);
match std::fs::write(&path, &self.buffer) {
Ok(_) => {
self.dirty = false;
self.status = format!("Saved {name}");
}
Err(e) => self.status = format!("Save failed: {e}"),
}
}
}
}
}
/// Discard any grammar-check results (they belong to a specific buffer).
fn clear_lt(&mut self) {
self.lt_matches.clear();
self.lt_checked_text.clear();
self.lt_status.clear();
}
fn select(&mut self, idx: usize) {
if self.selected == Some(idx) {
return;
}
self.save_current();
self.clear_lt();
// Matches from the previous file's buffer are meaningless now.
self.find_matches.clear();
self.find_active = 0;
self.find_needs_refresh = true;
// Re-run the live spell check on the newly loaded buffer immediately.
self.spell_matches.clear();
self.spell_checked_text.clear();
self.spell_dirty = true;
self.spell_last_edit = None;
self.spell_menu = None;
if let Some(name) = self.files.get(idx) {
let path = self.path_for(name);
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
self.selected = Some(idx);
self.dirty = false;
self.pending_delete = false;
self.rename_input = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
self.title_input = self.titles.get(name).cloned().unwrap_or_default();
}
}
fn create_file(&mut self) {
let mut stem = self.new_name.trim().to_string();
if stem.is_empty() {
self.status = "Enter a name for the new file".to_string();
return;
}
if stem.to_lowercase().ends_with(".md") {
stem.truncate(stem.len() - 3);
}
let name = format!("{stem}.md");
let path = self.path_for(&name);
if path.exists() {
self.status = format!("{name} already exists");
return;
}
let seed = format!("# {stem}\n\n");
match std::fs::write(&path, seed) {
Ok(_) => {
self.files.push(name.clone());
self.persist_order();
let idx = self.files.len() - 1;
self.selected = None; // force reload of buffer
self.select(idx);
self.new_name.clear();
self.status = format!("Created {name}");
}
Err(e) => self.status = format!("Create failed: {e}"),
}
}
fn delete_selected(&mut self) {
if let Some(idx) = self.selected {
if let Some(name) = self.files.get(idx).cloned() {
let path = self.path_for(&name);
match std::fs::remove_file(&path) {
Ok(_) => {
self.files.remove(idx);
self.titles.remove(&name);
self.session_start_counts.remove(&name);
self.persist_titles();
self.persist_order();
self.selected = None;
self.buffer.clear();
self.dirty = false;
if !self.files.is_empty() {
self.select(idx.min(self.files.len() - 1));
}
self.status = format!("Deleted {name}");
}
Err(e) => self.status = format!("Delete failed: {e}"),
}
}
}
self.pending_delete = false;
}
fn rename_selected(&mut self) {
let Some(idx) = self.selected else { return };
let mut stem = self.rename_input.trim().to_string();
if stem.to_lowercase().ends_with(".md") {
stem.truncate(stem.len() - 3);
}
if stem.is_empty() {
self.status = "Enter a new name".to_string();
return;
}
let new_name = format!("{stem}.md");
let Some(old_name) = self.files.get(idx).cloned() else {
return;
};
if new_name == old_name {
return;
}
let new_path = self.path_for(&new_name);
if new_path.exists() {
self.status = format!("{new_name} already exists");
return;
}
// Persist any pending edits under the old name first.
self.save_current();
match std::fs::rename(self.path_for(&old_name), &new_path) {
Ok(_) => {
self.files[idx] = new_name.clone();
if let Some(title) = self.titles.remove(&old_name) {
self.titles.insert(new_name.clone(), title);
self.persist_titles();
}
if let Some(words) = self.session_start_counts.remove(&old_name) {
self.session_start_counts.insert(new_name.clone(), words);
}
self.persist_order();
self.status = format!("Renamed to {new_name}");
}
Err(e) => self.status = format!("Rename failed: {e}"),
}
}
fn git_init(&mut self) {
// Initialising creates a repository in the workspace itself, which
// supersedes any enclosing repo we were about to ask about.
self.pending_repo = None;
let ws = self.workspace().to_path_buf();
let outcome = gitsync::init(&ws);
self.git_log = outcome.log;
self.show_log = true;
self.is_repo = gitsync::is_repo(&ws);
self.repo_root = self.is_repo.then_some(ws);
self.status = if self.is_repo {
"Initialised git repository".to_string()
} else {
"git init failed (see log)".to_string()
};
}
fn git_sync(&mut self) {
self.save_current();
self.persist_order();
self.persist_titles();
let msg = format!(
"Sync manuscript {}",
chrono_like_timestamp()
);
let outcome = gitsync::sync(self.workspace(), &msg);
self.git_log = outcome.log;
self.show_log = true;
self.status = if outcome.ok {
"Sync complete".to_string()
} else {
"Sync finished with errors (see log)".to_string()
};
}
/// Digits to zero-pad a defaulted chapter number to: the width of the largest
/// chapter number when padding is enabled, otherwise 1 (no padding).
fn index_pad_width(&self) -> usize {
if self.config.zero_pad_index {
self.files.len().to_string().len().max(1)
} else {
1
}
}
/// The title a chapter at `idx` would get without a manual override: its
/// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint
/// in the chapter-title field.
fn auto_title(&self, idx: usize, markdown: &str) -> String {
let header = crate::preprocess::parse(markdown, &self.config.draft_marker);
resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width())
}
fn export_odt(&mut self) {
self.save_current();
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let mut chapters = Vec::new();
for (i, name) in self.files.iter().enumerate() {
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
// Strip comments + the editorial header, and lift out any
// `# Title:` / `# Slug:` metadata.
let header = crate::preprocess::parse(&raw, &marker);
// Title priority: manual override > `# Title:` metadata > the chapter's
// 1-based position (e.g. "3."). The body from `parse` is used as-is.
let title = resolve_chapter_title(
self.titles.get(name).map(String::as_str),
header.title.as_deref(),
i,
pad_width,
);
chapters.push(Chapter {
title,
slug: header.slug.clone(),
markdown: header.body,
});
}
let out = PathBuf::from(self.export_input.trim());
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
match odt::export(&chapters, &out) {
Ok(_) => {
self.config.export_path = out.clone();
self.config.save();
self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display());
}
Err(e) => self.status = format!("Export failed: {e}"),
}
}
/// Open a native folder picker to choose the workspace directory.
fn browse_workspace(&mut self) {
let start = PathBuf::from(self.workspace_input.trim());
let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder");
if start.is_dir() {
dialog = dialog.set_directory(&start);
}
if let Some(path) = dialog.pick_folder() {
self.save_current();
self.workspace_input = path.display().to_string();
self.config.workspace = path;
self.config.save();
self.open_workspace();
}
}
/// Open a native save dialog to choose the export `.odt` path.
fn browse_export(&mut self) {
let current = PathBuf::from(self.export_input.trim());
let mut dialog = rfd::FileDialog::new()
.set_title("Choose export file")
.add_filter("OpenDocument Text", &["odt"]);
if let Some(parent) = current.parent().filter(|p| p.is_dir()) {
dialog = dialog.set_directory(parent);
}
let name = current
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("manuscript.odt");
if let Some(mut path) = dialog.set_file_name(name).save_file() {
// Ensure the chosen path ends in .odt even if the user omitted it.
let has_odt = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("odt"));
if !has_odt {
path.set_extension("odt");
}
self.export_input = path.display().to_string();
self.config.export_path = path;
self.config.save();
}
}
/// Kick off a LanguageTool check of the current buffer on a background
/// thread. Results are delivered through `lt_rx` and picked up in `update`.
fn start_lt_check(&mut self, ctx: &egui::Context) {
if self.selected.is_none() {
self.lt_status = "Open a file to check".to_string();
return;
}
if self.lt_rx.is_some() {
return; // a check is already running
}
if self.config.languagetool_host.trim().is_empty() {
self.lt_status = "Set a LanguageTool host in Settings first".to_string();
self.show_lt_panel = true;
self.show_settings = true;
return;
}
let url = self.config.languagetool_base_url();
let token = self.config.languagetool_token.clone();
let mut language = self.config.languagetool_language.trim().to_string();
if language.is_empty() {
language = "auto".to_string();
}
let text = self.buffer.clone();
let (tx, rx) = std::sync::mpsc::channel();
self.lt_rx = Some(rx);
self.lt_status = "Checking…".to_string();
self.show_lt_panel = true;
let ctx = ctx.clone();
std::thread::spawn(move || {
let result = crate::langtool::check(&url, &language, &token, &text);
let _ = tx.send((text, result));
ctx.request_repaint();
});
}
/// Ping the configured server with a sample sentence to confirm the settings
/// work, without disturbing the editor's current results. Used by the
/// Settings dialog's "Test connection" button.
fn test_lt_connection(&mut self, ctx: &egui::Context) {
if self.settings_test_rx.is_some() {
return;
}
let url = self.config.languagetool_base_url();
let token = self.config.languagetool_token.clone();
let language = {
let l = self.config.languagetool_language.trim();
if l.is_empty() { "auto".to_string() } else { l.to_string() }
};
let (tx, rx) = std::sync::mpsc::channel();
self.settings_test_rx = Some(rx);
self.settings_test_status = "Testing…".to_string();
let ctx = ctx.clone();
std::thread::spawn(move || {
// A sentence with a deliberate error, so a working server returns ≥1 match.
let result =
crate::langtool::check(&url, &language, &token, "The team are ready to began.");
let _ = tx.send(result.map(|m| m.len()));
ctx.request_repaint();
});
}
/// Pick up a finished "Test connection" result for the Settings dialog.
fn poll_settings_test(&mut self) {
let received = self
.settings_test_rx
.as_ref()
.and_then(|rx| rx.try_recv().ok());
if let Some(result) = received {
self.settings_test_rx = None;
self.settings_test_status = match result {
Ok(_) => format!("✔ Connected to {}", self.config.languagetool_base_url()),
Err(e) => format!("{e}"),
};
}
}
/// Poll for a finished background check and store its results.
fn poll_lt(&mut self) {
let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some((text, result)) = received {
self.lt_rx = None;
match result {
Ok(matches) => {
self.lt_status = match matches.len() {
0 => "No issues found".to_string(),
1 => "1 issue".to_string(),
n => format!("{n} issues"),
};
self.lt_matches = matches;
self.lt_checked_text = text;
}
Err(e) => {
self.lt_matches.clear();
self.lt_checked_text.clear();
self.lt_status = e;
}
}
}
}
/// Apply a LanguageTool suggestion to the buffer, then shift the remaining
/// matches so their highlights stay valid.
fn apply_replacement(&mut self, match_idx: usize, rep_idx: usize) {
// Only safe while the buffer still matches what was checked.
if self.buffer != self.lt_checked_text {
return;
}
if splice_fix(&mut self.buffer, &mut self.lt_matches, match_idx, rep_idx) {
self.dirty = true;
self.find_needs_refresh = true;
self.lt_checked_text = self.buffer.clone();
self.lt_status = match self.lt_matches.len() {
0 => "No issues remaining".to_string(),
1 => "1 issue".to_string(),
n => format!("{n} issues"),
};
}
}
/// Apply a spell-check suggestion to the buffer, keeping the remaining
/// misspelling underlines aligned.
fn apply_spell_fix(&mut self, match_idx: usize, rep_idx: usize) {
if self.buffer != self.spell_checked_text {
return;
}
if splice_fix(&mut self.buffer, &mut self.spell_matches, match_idx, rep_idx) {
self.dirty = true;
self.find_needs_refresh = true;
// Keep these matches valid without forcing a full re-check.
self.spell_checked_text = self.buffer.clone();
self.spell_status = match self.spell_matches.len() {
0 => "No spelling issues".to_string(),
1 => "1 spelling issue".to_string(),
n => format!("{n} spelling issues"),
};
}
}
/// Apply suggestion `rep_idx` of the match at `match_idx` from whichever
/// source currently owns the issues (LanguageTool or the spell checker).
fn apply_current_fix(&mut self, match_idx: usize, rep_idx: usize) {
match self.issue_source() {
IssueSource::LanguageTool => self.apply_replacement(match_idx, rep_idx),
IssueSource::Spell => self.apply_spell_fix(match_idx, rep_idx),
IssueSource::None => {}
}
}
// ---- Spell check (offline) ---------------------------------------------
/// (Re)load the dictionary named by `config.spell_language`, falling back to
/// the default and then to whatever is available. Resets the live results so
/// the buffer is re-checked against the new dictionary.
fn load_spell_dict(&mut self) {
let want = self.config.spell_language.clone();
let entry = self
.spell_dicts
.iter()
.find(|d| d.id == want)
.or_else(|| {
self.spell_dicts
.iter()
.find(|d| d.id == crate::spell::DEFAULT_LANGUAGE)
})
.or_else(|| self.spell_dicts.first())
.cloned();
match entry {
Some(e) => match e.load() {
Ok(dict) => {
self.spell_dict = Some(Arc::new(dict));
self.spell_status = format!("Dictionary: {}", e.id);
}
Err(err) => {
self.spell_dict = None;
self.spell_status = err;
}
},
None => {
self.spell_dict = None;
self.spell_status = "No spelling dictionary available".to_string();
}
}
self.spell_matches.clear();
self.spell_checked_text.clear();
self.spell_dirty = true;
self.spell_last_edit = None;
}
/// Switch the active dictionary and persist the choice.
fn set_spell_language(&mut self, id: &str) {
if self.config.spell_language == id {
return;
}
self.config.spell_language = id.to_string();
self.config.save();
self.load_spell_dict();
}
/// True when LanguageTool results describe the current buffer exactly (so
/// they, rather than the live spell checker, own the underlines).
fn lt_is_current(&self) -> bool {
!self.lt_checked_text.is_empty() && self.buffer == self.lt_checked_text
}
/// Which check currently owns the issues shown in the editor and panel.
fn issue_source(&self) -> IssueSource {
if self.lt_is_current() {
IssueSource::LanguageTool
} else if self.config.spell_check
&& self.spell_dict.is_some()
&& self.buffer == self.spell_checked_text
{
IssueSource::Spell
} else {
IssueSource::None
}
}
/// The matches currently owning the editor's underlines (may be empty).
fn current_matches(&self) -> &[crate::langtool::Match] {
match self.issue_source() {
IssueSource::LanguageTool => &self.lt_matches,
IssueSource::Spell => &self.spell_matches,
IssueSource::None => &[],
}
}
/// Index of the current match covering byte offset `byte`, if any.
fn match_index_at(&self, byte: usize) -> Option<usize> {
self.current_matches()
.iter()
.position(|m| byte >= m.start && byte < m.end)
}
/// An owned snapshot of the current issues for the results panel.
fn issue_items(&self) -> (IssueSource, Vec<IssueItem>) {
let source = self.issue_source();
let (matches, checked) = match source {
IssueSource::LanguageTool => (&self.lt_matches, self.lt_checked_text.as_str()),
IssueSource::Spell => (&self.spell_matches, self.spell_checked_text.as_str()),
IssueSource::None => return (source, Vec::new()),
};
let items = matches
.iter()
.enumerate()
.map(|(idx, m)| IssueItem {
idx,
snippet: checked.get(m.start..m.end).unwrap_or("").to_string(),
message: m.message.clone(),
spelling: m.spelling,
replacements: m.replacements.clone(),
})
.collect();
(source, items)
}
/// The status line for the spelling checker given a match count.
fn spell_count_status(n: usize) -> String {
match n {
0 => "No spelling issues".to_string(),
1 => "1 spelling issue".to_string(),
n => format!("{n} spelling issues"),
}
}
/// Pick up a finished background spell check.
fn poll_spell(&mut self) {
let received = self.spell_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some((text, matches)) = received {
self.spell_rx = None;
if self.spell_dict.is_some() {
self.spell_status = Self::spell_count_status(matches.len());
}
self.spell_matches = matches;
self.spell_checked_text = text;
}
}
/// Start a background spell check of the current buffer if one is due:
/// enabled, a dictionary loaded, a file open, the buffer changed, no check
/// already running, and the debounce interval elapsed since the last edit.
fn maybe_start_spell_check(&mut self, ctx: &egui::Context) {
if !self.config.spell_check || self.selected.is_none() || !self.spell_dirty {
return;
}
// LanguageTool results already cover this exact buffer (spelling too), so
// don't spend effort on a spell pass that wouldn't be shown.
if self.lt_is_current() {
return;
}
let Some(dict) = self.spell_dict.clone() else {
return;
};
if self.spell_rx.is_some() {
return;
}
// Debounce so we don't re-check on every keystroke.
const DEBOUNCE: Duration = Duration::from_millis(400);
if let Some(edited) = self.spell_last_edit {
let elapsed = edited.elapsed();
if elapsed < DEBOUNCE {
ctx.request_repaint_after(DEBOUNCE - elapsed);
return;
}
}
self.spell_dirty = false;
let text = self.buffer.clone();
let (tx, rx) = std::sync::mpsc::channel();
self.spell_rx = Some(rx);
let ctx = ctx.clone();
std::thread::spawn(move || {
let matches = crate::spell::check(&dict, &text);
let _ = tx.send((text, matches));
ctx.request_repaint();
});
}
// ---- Find / replace ----------------------------------------------------
/// Open the find/replace bar, focusing the search field (`focus_search`) or
/// the replace field, and queue a match refresh and a scroll to the first hit.
fn open_find(&mut self, focus_search: bool) {
self.show_find = true;
self.find_focus = Some(focus_search);
self.find_needs_refresh = true;
self.find_scroll = true;
}
/// Recompute the search matches for the current buffer and query, clamping
/// the active index and refreshing the status line.
fn refresh_find(&mut self) {
self.find_matches = find_matches(&self.buffer, &self.find_query, self.find_case_sensitive);
if self.find_active >= self.find_matches.len() {
self.find_active = 0;
}
self.update_find_status();
self.find_needs_refresh = false;
}
fn update_find_status(&mut self) {
self.find_status = if self.find_query.is_empty() {
String::new()
} else if self.find_matches.is_empty() {
"No matches".to_string()
} else {
format!("{} of {}", self.find_active + 1, self.find_matches.len())
};
}
/// Move the active match forward (or backward), wrapping around, and ask the
/// editor to scroll it into view.
fn find_step(&mut self, forward: bool) {
let n = self.find_matches.len();
if n == 0 {
return;
}
self.find_active = if forward {
(self.find_active + 1) % n
} else {
(self.find_active + n - 1) % n
};
self.find_scroll = true;
self.update_find_status();
}
/// Replace the active match with the replacement text, then re-search so the
/// remaining highlights stay accurate.
fn replace_current(&mut self) {
let Some(&(s, e)) = self.find_matches.get(self.find_active) else {
return;
};
if e > self.buffer.len()
|| !self.buffer.is_char_boundary(s)
|| !self.buffer.is_char_boundary(e)
{
return;
}
self.buffer.replace_range(s..e, &self.replace_query);
self.dirty = true;
// Editing invalidates any grammar-check offsets.
self.clear_lt();
// Keep the same index so the "current" match becomes the next occurrence
// in document order; refresh recomputes and clamps it.
self.refresh_find();
self.find_scroll = true;
self.status = "Replaced 1 occurrence".to_string();
}
/// Replace every match in the buffer in one pass.
fn replace_all_matches(&mut self) {
if self.find_query.is_empty() {
return;
}
let (new, count) = replace_all(
&self.buffer,
&self.find_query,
&self.replace_query,
self.find_case_sensitive,
);
if count > 0 {
self.buffer = new;
self.dirty = true;
self.clear_lt();
self.find_active = 0;
self.refresh_find();
}
self.status = format!("Replaced {count} occurrence(s)");
}
fn reorder(&mut self, from: usize, mut to: usize) {
if from >= self.files.len() || from == to {
return;
}
// Remember the selected file by name so selection follows the move.
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let item = self.files.remove(from);
if from < to {
to -= 1;
}
to = to.min(self.files.len());
self.files.insert(to, item);
self.persist_order();
if let Some(name) = selected_name {
self.selected = self.files.iter().position(|n| *n == name);
}
self.status = "Reordered".to_string();
}
// ---- UI ----------------------------------------------------------------
fn top_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("top").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Workspace:");
let resp = ui.add(
egui::TextEdit::singleline(&mut self.workspace_input)
.desired_width(300.0),
);
if ui.button("📂").on_hover_text("Browse for workspace folder").clicked() {
self.browse_workspace();
}
if ui.button("Open").clicked()
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
self.save_current();
self.config.workspace = PathBuf::from(self.workspace_input.trim());
self.config.save();
self.open_workspace();
}
ui.separator();
if self.is_repo {
let hover = match &self.repo_root {
Some(root) if root.as_path() != self.workspace() => {
format!("Commit & push to the repository at {}", root.display())
}
_ => "Commit & push this workspace's repository".to_string(),
};
if ui.button("⟳ Sync (git)").on_hover_text(hover).clicked() {
self.git_sync();
}
} else if ui.button("Init git").clicked() {
self.git_init();
}
if ui.button("Log").clicked() {
self.show_log = !self.show_log;
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Export:");
ui.add(
egui::TextEdit::singleline(&mut self.export_input).desired_width(300.0),
);
if ui.button("📂").on_hover_text("Browse for export .odt file").clicked() {
self.browse_export();
}
if ui.button("Export ODT").clicked() {
self.export_odt();
}
ui.separator();
if ui
.checkbox(&mut self.config.show_preview, "Preview")
.changed()
{
self.config.save();
}
ui.separator();
ui.label("Draft marker:")
.on_hover_text(
"Text above this line in each file (its header) is dropped on export; \
# Title: / # Slug: lines become the chapter heading and caption. \
Leave blank to export files whole.",
);
let marker_resp = ui.add(
egui::TextEdit::singleline(&mut self.config.draft_marker)
.desired_width(140.0),
);
if marker_resp.lost_focus() {
self.config.save();
}
ui.separator();
if ui
.checkbox(&mut self.config.zero_pad_index, "Zero-pad #")
.on_hover_text(
"When a chapter title defaults to its number, pad it with \
leading zeros (e.g. 03. of 12).",
)
.changed()
{
self.config.save();
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Grammar:").on_hover_text(
"Grammar & spelling via a LanguageTool server. \
Configure it in Settings ▸ LanguageTool.",
);
let checking = self.lt_rx.is_some();
if ui
.add_enabled(
!checking && self.selected.is_some(),
egui::Button::new("✓ Check"),
)
.on_hover_text("Check the current file's grammar and spelling")
.clicked()
{
self.start_lt_check(ctx);
}
if !self.lt_matches.is_empty()
&& ui.button("Clear").on_hover_text("Clear check results").clicked()
{
self.clear_lt();
}
if ui
.button("")
.on_hover_text("LanguageTool settings")
.clicked()
{
self.show_settings = true;
}
if !self.lt_status.is_empty() {
ui.label(egui::RichText::new(&self.lt_status).weak());
}
});
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("Spelling:").on_hover_text(
"Offline spell check (Hunspell dictionaries). Underlines misspellings \
as you type when LanguageTool results aren't current; right-click a \
word for suggestions.",
);
if ui
.checkbox(&mut self.config.spell_check, "Check as I type")
.on_hover_text("Underline misspellings live using the dictionary below")
.changed()
{
self.config.save();
if self.config.spell_check {
// Re-check the current buffer straight away.
self.spell_dirty = true;
self.spell_last_edit = None;
}
}
ui.label("Dictionary:");
let current = self.config.spell_language.clone();
let mut pick: Option<String> = None;
egui::ComboBox::from_id_salt("spell_dict")
.selected_text(current.clone())
.show_ui(ui, |ui| {
for d in &self.spell_dicts {
if ui
.selectable_label(current == d.id, &d.label)
.clicked()
{
pick = Some(d.id.clone());
}
}
});
if let Some(id) = pick {
self.set_spell_language(&id);
}
if !self.spell_status.is_empty() {
ui.label(egui::RichText::new(&self.spell_status).weak());
}
});
ui.add_space(4.0);
});
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.add_space(2.0);
ui.horizontal(|ui| {
let dirty = if self.dirty { " • unsaved" } else { "" };
ui.label(format!("{}{dirty}", self.status));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(idx) = self.selected {
let name = &self.files[idx];
let total = count_words(&self.buffer);
let baseline =
self.session_start_counts.get(name).copied().unwrap_or(0);
let delta = total as i64 - baseline as i64;
let sign = if delta < 0 { "-" } else { "+" };
ui.label(
egui::RichText::new(format!(
"{} words · {sign}{} this session",
thousands(total),
thousands(delta.unsigned_abs() as usize),
))
.weak(),
)
.on_hover_text(
"Words in the current file · net change since this session opened",
);
}
});
});
ui.add_space(2.0);
});
}
fn left_pane(&mut self, ctx: &egui::Context) {
egui::SidePanel::left("files")
.resizable(true)
.default_width(260.0)
.show(ctx, |ui| {
// The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in
// dark mode) without affecting the rest of the app.
boost_list_contrast(ui.visuals_mut());
ui.add_space(4.0);
ui.heading("Files");
ui.label(
egui::RichText::new("drag ⠿ to reorder")
.small()
.weak(),
);
ui.separator();
let mut clicked: Option<usize> = None;
let mut from_to: Option<(usize, usize)> = None;
let pointer = ui.input(|i| i.pointer.interact_pos());
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(ui.available_height() - 120.0)
.show(ui, |ui| {
for idx in 0..self.files.len() {
let name = self.files[idx].clone();
let selected = self.selected == Some(idx);
let row = ui
.horizontal(|ui| {
ui.dnd_drag_source(
egui::Id::new(("dnd", &name)),
idx,
|ui| {
ui.label(
egui::RichText::new("").monospace().weak(),
);
},
);
if ui
.add_sized(
[ui.available_width(), 20.0],
egui::SelectableLabel::new(selected, &name),
)
.clicked()
{
clicked = Some(idx);
}
})
.response;
// Drop handling: is a dragged item hovering this row?
if let Some(_payload) = row.dnd_hover_payload::<usize>() {
let rect = row.rect;
let before = pointer
.map(|p| p.y < rect.center().y)
.unwrap_or(true);
let y = if before { rect.top() } else { rect.bottom() };
ui.painter().hline(
rect.x_range(),
y,
egui::Stroke::new(
2.0,
ui.visuals().selection.stroke.color,
),
);
if let Some(payload) = row.dnd_release_payload::<usize>() {
let target = if before { idx } else { idx + 1 };
from_to = Some((*payload, target));
}
}
}
});
if let Some(idx) = clicked {
self.select(idx);
}
if let Some((from, to)) = from_to {
self.reorder(from, to);
}
ui.separator();
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.new_name)
.hint_text("new file name")
.desired_width(150.0),
);
if ui.button(" New").clicked() {
self.create_file();
}
});
if self.selected.is_some() {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.rename_input)
.hint_text("rename")
.desired_width(150.0),
);
if ui.button("Rename").clicked() {
self.rename_selected();
}
});
ui.horizontal(|ui| {
if !self.pending_delete {
if ui.button("🗑 Delete").clicked() {
self.pending_delete = true;
}
} else {
ui.label("Delete file?");
if ui
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
.clicked()
{
self.delete_selected();
}
if ui.button("No").clicked() {
self.pending_delete = false;
}
}
});
}
});
}
/// The application menu bar (File / View / Settings).
fn menu_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("menubar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("📂 Open workspace…").clicked() {
ui.close_menu();
self.browse_workspace();
}
if ui.button("Export ODT").clicked() {
ui.close_menu();
self.export_odt();
}
ui.separator();
if ui.button("Quit").clicked() {
ui.close_menu();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.menu_button("Edit", |ui| {
if ui.button("🔍 Find / Replace…").clicked() {
ui.close_menu();
self.open_find(true);
}
});
ui.menu_button("View", |ui| {
if ui
.checkbox(&mut self.config.show_preview, "Preview pane")
.clicked()
{
self.config.save();
}
ui.checkbox(&mut self.show_lt_panel, "Grammar panel");
ui.checkbox(&mut self.show_log, "Git log");
});
ui.menu_button("Settings", |ui| {
if ui.button("LanguageTool…").clicked() {
ui.close_menu();
self.show_settings = true;
}
});
ui.menu_button("Help", |ui| {
if ui.button("📝 Markdown cheatsheet").clicked() {
ui.close_menu();
self.show_cheatsheet = true;
}
});
});
});
}
/// Floating window for editing the LanguageTool connection settings.
fn settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_settings;
let mut close_clicked = false;
egui::Window::new("LanguageTool settings")
.open(&mut open)
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
let mut save_now = false;
egui::Grid::new("lt_settings_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Scheme:");
egui::ComboBox::from_id_salt("lt_scheme")
.selected_text(self.config.languagetool_scheme.clone())
.show_ui(ui, |ui| {
for s in ["http", "https"] {
if ui
.selectable_value(
&mut self.config.languagetool_scheme,
s.to_string(),
s,
)
.clicked()
{
save_now = true;
}
}
});
ui.end_row();
ui.label("Host / domain:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.languagetool_host)
.hint_text("localhost or lt.example.com")
.desired_width(230.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Port:");
let r = ui.add(
egui::DragValue::new(&mut self.config.languagetool_port)
.speed(1.0)
.range(1..=65535),
);
save_now |= r.drag_stopped() || r.lost_focus();
ui.end_row();
ui.label("Token:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.languagetool_token)
.password(true)
.hint_text("optional — auth proxy / API key")
.desired_width(230.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Language:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.languagetool_language)
.hint_text("auto, en-US, de-DE…")
.desired_width(120.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!(
"Endpoint: {}/v2/check",
self.config.languagetool_base_url()
))
.weak()
.monospace(),
);
ui.separator();
ui.horizontal(|ui| {
let testing = self.settings_test_rx.is_some();
if ui
.add_enabled(!testing, egui::Button::new("Test connection"))
.clicked()
{
self.config.save();
self.test_lt_connection(ctx);
}
if ui.button("Close").clicked() {
close_clicked = true;
}
});
if !self.settings_test_status.is_empty() {
ui.label(&self.settings_test_status);
}
if save_now {
self.config.save();
}
});
let now_open = open && !close_clicked;
if self.show_settings && !now_open {
// Window is closing — persist and reset its transient state.
self.config.save();
self.settings_test_status.clear();
}
self.show_settings = now_open;
}
/// Bottom panel listing the current grammar/spelling issues with one-click
/// fixes. Shows LanguageTool results when they're fresh, otherwise the live
/// spell-check results.
fn lt_panel(&mut self, ctx: &egui::Context) {
let (source, items) = self.issue_items();
egui::TopBottomPanel::bottom("ltpanel")
.resizable(true)
.default_height(190.0)
.show(ctx, |ui| {
let (title, status) = match source {
IssueSource::LanguageTool => ("Grammar & spelling", self.lt_status.clone()),
_ => ("Spelling", self.spell_status.clone()),
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new(title).strong());
if !status.is_empty() {
ui.label(egui::RichText::new(status).weak());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Hide").clicked() {
self.show_lt_panel = false;
}
});
});
ui.separator();
if items.is_empty() {
let busy = self.lt_rx.is_some() || self.spell_rx.is_some();
ui.label(
egui::RichText::new(if busy { "Checking…" } else { "No issues to show." })
.weak(),
);
return;
}
let mut apply: Option<(usize, usize)> = None;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for item in &items {
ui.horizontal_wrapped(|ui| {
let col = if item.spelling {
egui::Color32::from_rgb(0xE0, 0x40, 0x40)
} else {
egui::Color32::from_rgb(0x3B, 0x82, 0xF6)
};
ui.label(egui::RichText::new("").color(col));
if !item.snippet.is_empty() {
ui.label(
egui::RichText::new(format!("{}", item.snippet)).strong(),
);
}
ui.label(&item.message);
});
ui.horizontal_wrapped(|ui| {
ui.add_space(16.0);
if item.replacements.is_empty() {
ui.label(
egui::RichText::new("(no suggestion)").weak().italics(),
);
} else {
for (j, rep) in item.replacements.iter().enumerate() {
if ui.button(egui::RichText::new(rep).small()).clicked() {
apply = Some((item.idx, j));
}
}
}
});
ui.separator();
}
});
if let Some((i, j)) = apply {
self.apply_current_fix(i, j);
}
});
}
fn central(&mut self, ctx: &egui::Context) {
egui::CentralPanel::default().show(ctx, |ui| {
match self.selected {
Some(idx) => {
let name = self.files[idx].clone();
ui.horizontal(|ui| {
ui.heading(&name);
if ui.button("💾 Save").clicked() {
self.dirty = true; // ensure save runs
self.save_current();
}
ui.separator();
ui.label("Zoom:");
if ui
.add(
egui::Slider::new(&mut self.config.editor_zoom, -50.0..=200.0)
.suffix("%")
.step_by(5.0),
)
.on_hover_text("Editor text size (0% = default)")
.changed()
{
self.config.save();
}
if ui
.button("Reset")
.on_hover_text("Reset zoom to 0%")
.clicked()
{
self.config.editor_zoom = 0.0;
self.config.save();
}
ui.separator();
ui.label("Contrast:");
if ui
.add(
egui::Slider::new(
&mut self.config.editor_text_contrast,
0.0..=100.0,
)
.suffix("%")
.step_by(5.0),
)
.on_hover_text(
"Editor text contrast (0% = theme default; higher \
brightens the text in dark mode)",
)
.changed()
{
self.config.save();
}
});
ui.horizontal(|ui| {
ui.label("Chapter title:");
let auto = self.auto_title(idx, &self.buffer);
let resp = ui.add(
egui::TextEdit::singleline(&mut self.title_input)
.hint_text(format!("auto: {auto}"))
.desired_width(320.0),
);
if resp.changed() {
self.set_title_for_current();
}
ui.label(
egui::RichText::new("used as the ODT chapter heading")
.small()
.weak(),
);
});
ui.separator();
if self.show_find {
self.find_bar(ui);
}
if self.config.show_preview {
// Editor + read-only source-ish preview side by side.
let full = ui.available_size();
ui.horizontal_top(|ui| {
let col_w = full.x / 2.0 - 6.0;
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
self.editor(ui);
});
ui.separator();
ui.allocate_ui(egui::vec2(col_w, full.y), |ui| {
egui::ScrollArea::vertical()
.id_salt("preview")
.auto_shrink([false, false])
.show(ui, |ui| {
render_preview(ui, &self.buffer);
});
});
});
} else {
self.editor(ui);
}
}
None => {
ui.centered_and_justified(|ui| {
ui.label("Select a file on the left, or create a new one.");
});
}
}
});
}
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))
};
let output = egui::TextEdit::multiline(&mut self.buffer)
.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;
}
});
}
/// The find/replace bar shown above the editor when `show_find` is set.
fn find_bar(&mut self, ui: &mut egui::Ui) {
egui::Frame::group(ui.style()).show(ui, |ui| {
// Find row.
ui.horizontal(|ui| {
ui.label("Find: ");
let resp = ui.add(
egui::TextEdit::singleline(&mut self.find_query)
.desired_width(220.0)
.hint_text("search text"),
);
if self.find_focus == Some(true) {
resp.request_focus();
self.find_focus = None;
}
if resp.changed() {
self.find_active = 0;
self.refresh_find();
self.find_scroll = true;
}
// Enter jumps to the next match, Shift+Enter to the previous one;
// keep the field focused so the key can be repeated.
if resp.lost_focus() {
let (enter, shift) =
ui.input(|i| (i.key_pressed(egui::Key::Enter), i.modifiers.shift));
if enter {
self.find_step(!shift);
self.find_focus = Some(true);
}
}
let have = !self.find_matches.is_empty();
if ui
.add_enabled(have, egui::Button::new(""))
.on_hover_text("Previous match (Shift+Enter)")
.clicked()
{
self.find_step(false);
}
if ui
.add_enabled(have, egui::Button::new(""))
.on_hover_text("Next match (Enter)")
.clicked()
{
self.find_step(true);
}
if ui
.checkbox(&mut self.find_case_sensitive, "Aa")
.on_hover_text("Match case")
.changed()
{
self.find_active = 0;
self.refresh_find();
}
if !self.find_status.is_empty() {
ui.label(egui::RichText::new(&self.find_status).weak());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("").on_hover_text("Close (Esc)").clicked() {
self.show_find = false;
}
});
});
// Replace row.
ui.horizontal(|ui| {
ui.label("Replace:");
let resp = ui.add(
egui::TextEdit::singleline(&mut self.replace_query)
.desired_width(220.0)
.hint_text("replacement text"),
);
if self.find_focus == Some(false) {
resp.request_focus();
self.find_focus = None;
}
let have = !self.find_matches.is_empty();
if ui
.add_enabled(have, egui::Button::new("Replace"))
.on_hover_text("Replace the current match, then move to the next")
.clicked()
{
self.replace_current();
}
if ui
.add_enabled(have, egui::Button::new("Replace all"))
.on_hover_text("Replace every match in this file")
.clicked()
{
self.replace_all_matches();
}
});
});
ui.add_space(4.0);
}
/// Ask the user whether to adopt a git repository found in a parent
/// directory of the freshly opened workspace.
fn repo_prompt(&mut self, ctx: &egui::Context) {
let Some(root) = self.pending_repo.clone() else {
return;
};
let mut adopt = false;
let mut decline = false;
egui::Window::new("Use enclosing git repository?")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(
"This folder isn't a git repository, but one was found in a \
parent directory:",
);
ui.add_space(4.0);
ui.label(egui::RichText::new(root.display().to_string()).strong());
ui.add_space(4.0);
ui.label(
"Use it for version control (Sync commits and pushes to this \
repo)? Otherwise the workspace is left without git; you can \
still Init a separate repository here.",
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Use this repository").clicked() {
adopt = true;
}
if ui.button("No, keep separate").clicked() {
decline = true;
}
});
});
if adopt {
self.adopt_pending_repo();
} else if decline {
self.decline_pending_repo();
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Ctrl+S saves.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::S)) {
self.dirty = true;
self.save_current();
}
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
self.open_find(true);
}
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) {
self.open_find(false);
}
// Esc closes the find bar.
if self.show_find && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.show_find = false;
}
// Recompute search matches if the query, case option, or buffer changed.
if self.show_find && self.find_needs_refresh {
self.refresh_find();
}
self.poll_lt();
self.poll_settings_test();
self.poll_spell();
self.maybe_start_spell_check(ctx);
self.menu_bar(ctx);
self.top_bar(ctx);
self.left_pane(ctx);
if self.show_lt_panel {
self.lt_panel(ctx);
}
if self.show_log {
egui::TopBottomPanel::bottom("gitlog")
.resizable(true)
.default_height(160.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("git output").strong());
if ui.button("Hide").clicked() {
self.show_log = false;
}
});
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.git_log.as_str())
.code_editor()
.desired_width(f32::INFINITY),
);
});
});
}
self.central(ctx);
if self.show_settings {
self.settings_window(ctx);
}
if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
}
if self.pending_repo.is_some() {
self.repo_prompt(ctx);
}
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.save_current();
self.persist_order();
self.persist_titles();
self.config.save();
}
}
/// 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.
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
}
/// Fraction each base font size is enlarged, applied once at startup so every
/// panel's text is slightly bigger without changing layout maths elsewhere.
const FONT_SCALE: f32 = 1.12;
/// Configure the shared context style once at startup: raise the contrast of
/// *all* UI text (menus, top bar, buttons, panels, headings — everything, not
/// just the file list and editor) and enlarge every base font size slightly.
///
/// The default dark theme uses fairly dim greys (~140) for body text, the main
/// legibility complaint; we pull each widget state's foreground toward white in
/// dark mode / black in light mode. Explicitly coloured spans (grammar
/// underlines, the cheatsheet's syntax colour) set their own colours and are
/// unaffected. The per-pane boosts (`boost_list_contrast`, the editor contrast
/// slider) still layer on top for their specific widgets.
fn apply_global_style(ctx: &egui::Context) {
let mut style = (*ctx.style()).clone();
// Enlarge every text style (Body, Button, Heading, Monospace, Small).
for font in style.text_styles.values_mut() {
font.size = (font.size * FONT_SCALE).round();
}
let v = &mut style.visuals;
let target = if v.dark_mode {
egui::Color32::WHITE
} else {
egui::Color32::BLACK
};
let w = &mut v.widgets;
w.noninteractive.fg_stroke.color =
lerp_color(w.noninteractive.fg_stroke.color, target, 0.55);
w.inactive.fg_stroke.color = lerp_color(w.inactive.fg_stroke.color, target, 0.45);
w.hovered.fg_stroke.color = lerp_color(w.hovered.fg_stroke.color, target, 0.35);
w.active.fg_stroke.color = lerp_color(w.active.fg_stroke.color, target, 0.35);
w.open.fg_stroke.color = lerp_color(w.open.fg_stroke.color, target, 0.45);
ctx.set_style(style);
}
/// Raise the text contrast of list-style widgets in the current `ui`, so file
/// names in the left pane stay legible against the panel background. Only the
/// widget foreground colours are touched; layout and the rest of the app are
/// unaffected (the change is scoped to the panel that calls this).
fn boost_list_contrast(visuals: &mut egui::Visuals) {
if visuals.dark_mode {
// Unselected rows, hover, and the pressed state → toward white.
visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(235);
visuals.widgets.hovered.fg_stroke.color = egui::Color32::WHITE;
visuals.widgets.active.fg_stroke.color = egui::Color32::WHITE;
// Selected-row text (SelectableLabel uses selection.stroke when selected).
visuals.selection.stroke.color = egui::Color32::WHITE;
} else {
visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_gray(20);
visuals.widgets.hovered.fg_stroke.color = egui::Color32::BLACK;
visuals.widgets.active.fg_stroke.color = egui::Color32::BLACK;
visuals.selection.stroke.color = egui::Color32::WHITE;
}
}
/// The editor's text colour, interpolated from the theme's default toward the
/// maximum-contrast colour (white in dark mode, black in light mode) by
/// `contrast` percent (0 = theme default, 100 = full contrast).
fn editor_text_color(visuals: &egui::Visuals, contrast: f32) -> egui::Color32 {
let base = visuals.text_color();
let target = if visuals.dark_mode {
egui::Color32::WHITE
} else {
egui::Color32::BLACK
};
lerp_color(base, target, contrast / 100.0)
}
/// Linearly interpolate between two colours in gamma (sRGB byte) space.
/// `t` is clamped to `[0, 1]`; `t = 0` yields `a`, `t = 1` yields `b`.
fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 {
let t = t.clamp(0.0, 1.0);
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
egui::Color32::from_rgb(
mix(a.r(), b.r()),
mix(a.g(), b.g()),
mix(a.b(), b.b()),
)
}
/// An inline markdown formatting action triggered by an editor hotkey.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
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.
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).
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.
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))
}
}
/// Find every non-overlapping occurrence of `needle` in `haystack`, returning
/// their `(start_byte, end_byte)` ranges in document order. An empty needle
/// matches nothing. When `case_sensitive` is false, matching is done on the
/// Unicode-lowercased text while the returned offsets index the original.
fn find_matches(haystack: &str, needle: &str, case_sensitive: bool) -> Vec<(usize, usize)> {
if needle.is_empty() {
return Vec::new();
}
if case_sensitive {
let mut out = Vec::new();
let mut from = 0;
while let Some(pos) = haystack[from..].find(needle) {
let s = from + pos;
let e = s + needle.len();
out.push((s, e));
// Advance past this match (at least one byte to guarantee progress).
from = e.max(s + 1);
}
return out;
}
// Case-insensitive: search the lowercased haystack, mapping match offsets
// back to the original string's byte positions.
let (lower, map) = lowercased_with_map(haystack);
let low_needle = needle.to_lowercase();
let mut out = Vec::new();
let mut from = 0;
while let Some(pos) = lower[from..].find(&low_needle) {
let ls = from + pos;
let le = ls + low_needle.len();
out.push((map[ls], map[le]));
from = le.max(ls + 1);
}
out
}
/// Lowercase `s`, returning the lowercased string plus a map from each byte
/// index in it (including the terminal `len`) back to the byte index in `s`
/// where the originating character began. Used to translate case-insensitive
/// match offsets back onto the original text.
fn lowercased_with_map(s: &str) -> (String, Vec<usize>) {
let mut lower = String::with_capacity(s.len());
let mut map = Vec::with_capacity(s.len() + 1);
for (byte, ch) in s.char_indices() {
for lc in ch.to_lowercase() {
let mut buf = [0u8; 4];
let encoded = lc.encode_utf8(&mut buf);
for _ in 0..encoded.len() {
map.push(byte);
}
lower.push_str(encoded);
}
}
map.push(s.len());
(lower, map)
}
/// Replace every non-overlapping match of `needle` in `text` with `replacement`,
/// returning the new text and the number of replacements made.
fn replace_all(
text: &str,
needle: &str,
replacement: &str,
case_sensitive: bool,
) -> (String, usize) {
let matches = find_matches(text, needle, case_sensitive);
if matches.is_empty() {
return (text.to_string(), 0);
}
let mut out = String::with_capacity(text.len());
let mut last = 0;
for &(s, e) in &matches {
out.push_str(&text[last..s]);
out.push_str(replacement);
last = e;
}
out.push_str(&text[last..]);
(out, matches.len())
}
/// Very small markdown-ish preview (headings emphasised, everything else plain).
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
let parser = Parser::new_ext(markdown, Options::ENABLE_STRIKETHROUGH);
let mut heading: Option<HeadingLevel> = None;
let mut line = String::new();
let mut bold = false;
let mut italic = false;
let flush = |ui: &mut egui::Ui, line: &mut String, heading: &mut Option<HeadingLevel>| {
if line.trim().is_empty() {
line.clear();
*heading = None;
return;
}
let text = line.clone();
match heading {
Some(HeadingLevel::H1) => {
ui.label(egui::RichText::new(text).size(22.0).strong());
}
Some(HeadingLevel::H2) => {
ui.label(egui::RichText::new(text).size(18.0).strong());
}
Some(_) => {
ui.label(egui::RichText::new(text).size(15.0).strong());
}
None => {
ui.label(text);
}
}
line.clear();
*heading = None;
};
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => heading = Some(level),
Event::End(TagEnd::Heading(_)) => flush(ui, &mut line, &mut heading),
Event::End(TagEnd::Paragraph) => flush(ui, &mut line, &mut heading),
Event::Start(Tag::Item) => line.push_str(""),
Event::End(TagEnd::Item) => flush(ui, &mut line, &mut heading),
Event::Start(Tag::Strong) => bold = true,
Event::End(TagEnd::Strong) => bold = false,
Event::Start(Tag::Emphasis) => italic = true,
Event::End(TagEnd::Emphasis) => italic = false,
Event::Text(t) | Event::Code(t) => {
let _ = (bold, italic);
line.push_str(&t);
}
Event::SoftBreak | Event::HardBreak => line.push(' '),
Event::Rule => {
ui.separator();
}
_ => {}
}
}
flush(ui, &mut line, &mut heading);
}
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// `# Title:` header value, otherwise the chapter's 1-based position followed by
/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding).
fn resolve_chapter_title(
override_title: Option<&str>,
header_title: Option<&str>,
index: usize,
pad_width: usize,
) -> String {
override_title
.map(str::trim)
.filter(|t| !t.is_empty())
.or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
.map(|t| t.to_string())
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
}
/// Apply suggestion `rep_idx` of `matches[match_idx]` to `buffer` in place and
/// remap the remaining matches. Returns whether a replacement was made (false if
/// the indices are out of range or the match's bounds aren't valid boundaries).
fn splice_fix(
buffer: &mut String,
matches: &mut Vec<crate::langtool::Match>,
match_idx: usize,
rep_idx: usize,
) -> bool {
let Some(m) = matches.get(match_idx).cloned() else {
return false;
};
let Some(replacement) = m.replacements.get(rep_idx).cloned() else {
return false;
};
if m.end > buffer.len()
|| !buffer.is_char_boundary(m.start)
|| !buffer.is_char_boundary(m.end)
{
return false;
}
buffer.replace_range(m.start..m.end, &replacement);
// The applied match overlaps its own region, so it is dropped here too.
remap_matches(matches, m.start, m.end, replacement.len());
true
}
/// Re-position matches after the byte range `[s, e)` was replaced with `new_len`
/// bytes. Matches that overlapped the edited region (including the one that was
/// just applied) are dropped; matches entirely after it are shifted by the
/// length delta so their highlights and offsets stay valid.
fn remap_matches(matches: &mut Vec<crate::langtool::Match>, s: usize, e: usize, new_len: usize) {
matches.retain(|o| o.end <= s || o.start >= e);
let delta = new_len as i64 - (e - s) as i64;
for o in matches.iter_mut() {
if o.start >= e {
o.start = (o.start as i64 + delta) as usize;
o.end = (o.end as i64 + delta) as usize;
}
}
}
/// Count words in a string: runs of non-whitespace separated by whitespace.
fn count_words(s: &str) -> usize {
s.split_whitespace().count()
}
/// Format a non-negative integer with comma thousands separators (e.g. 12345 -> "12,345").
fn thousands(n: usize) -> String {
let digits = n.to_string();
let len = digits.len();
let mut out = String::with_capacity(len + len / 3);
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out
}
/// A dependency-free timestamp for commit messages (UTC seconds since epoch).
fn chrono_like_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("@{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_words_across_whitespace() {
assert_eq!(count_words(""), 0);
assert_eq!(count_words(" \n\t "), 0);
assert_eq!(count_words("one"), 1);
assert_eq!(count_words("one two three"), 3);
assert_eq!(count_words(" spread \n over\tlines "), 3);
}
#[test]
fn chapter_title_falls_back_to_position() {
// No override, no header title -> 1-based index with a period (width 1).
assert_eq!(resolve_chapter_title(None, None, 0, 1), "1.");
assert_eq!(resolve_chapter_title(None, None, 2, 1), "3.");
// Header title is used when present.
assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate");
// Override wins over everything, even a header title.
assert_eq!(
resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1),
"My Override"
);
// Blank/whitespace override or header title are ignored.
assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2.");
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7.");
}
#[test]
fn chapter_number_zero_pads_to_width() {
// Width 2 pads single digits; wider numbers are unaffected.
assert_eq!(resolve_chapter_title(None, None, 0, 2), "01.");
assert_eq!(resolve_chapter_title(None, None, 8, 2), "09.");
assert_eq!(resolve_chapter_title(None, None, 11, 2), "12.");
assert_eq!(resolve_chapter_title(None, None, 4, 3), "005.");
// Padding never applies to a real title.
assert_eq!(
resolve_chapter_title(None, Some("The Gate"), 0, 3),
"The Gate"
);
}
fn m(start: usize, end: usize) -> crate::langtool::Match {
crate::langtool::Match {
start,
end,
message: String::new(),
replacements: Vec::new(),
spelling: false,
}
}
#[test]
fn remap_shifts_later_and_drops_overlapping() {
// Text "aaaa BBB cccc dddd"; replace "BBB" (5..8, len 3) with "XX" (len 2).
let mut matches = vec![m(0, 4), m(5, 8), m(9, 13), m(14, 18)];
remap_matches(&mut matches, 5, 8, 2);
// The applied match (5..8) is dropped; the one before is untouched;
// the two after shift left by 1.
assert_eq!(matches.len(), 3);
assert_eq!((matches[0].start, matches[0].end), (0, 4));
assert_eq!((matches[1].start, matches[1].end), (8, 12));
assert_eq!((matches[2].start, matches[2].end), (13, 17));
}
#[test]
fn remap_grows_when_replacement_is_longer() {
let mut matches = vec![m(0, 3), m(10, 14)];
// Replace [0,3) (len 3) with 5 bytes: delta +2 shifts the later match.
remap_matches(&mut matches, 0, 3, 5);
assert_eq!(matches.len(), 1);
assert_eq!((matches[0].start, matches[0].end), (12, 16));
}
#[test]
fn remap_drops_matches_overlapping_the_edit() {
// A match straddling the edit boundary is discarded, not mis-shifted.
let mut matches = vec![m(2, 7)];
remap_matches(&mut matches, 5, 8, 1);
assert!(matches.is_empty());
}
#[test]
fn lerp_color_hits_endpoints_and_midpoint() {
let a = egui::Color32::from_rgb(0, 0, 0);
let b = egui::Color32::from_rgb(200, 100, 50);
assert_eq!(lerp_color(a, b, 0.0), a);
assert_eq!(lerp_color(a, b, 1.0), b);
assert_eq!(lerp_color(a, b, 0.5), egui::Color32::from_rgb(100, 50, 25));
// t is clamped to [0, 1].
assert_eq!(lerp_color(a, b, -1.0), a);
assert_eq!(lerp_color(a, b, 2.0), b);
}
#[test]
fn editor_contrast_moves_toward_white_in_dark_mode() {
let dark = egui::Visuals::dark();
// 0% keeps the theme's text colour; 100% is fully white.
assert_eq!(editor_text_color(&dark, 0.0), dark.text_color());
assert_eq!(editor_text_color(&dark, 100.0), egui::Color32::WHITE);
// In light mode, full contrast is black instead.
let light = egui::Visuals::light();
assert_eq!(editor_text_color(&light, 100.0), egui::Color32::BLACK);
}
#[test]
fn formats_thousands_separators() {
assert_eq!(thousands(0), "0");
assert_eq!(thousands(42), "42");
assert_eq!(thousands(999), "999");
assert_eq!(thousands(1_000), "1,000");
assert_eq!(thousands(12_345), "12,345");
assert_eq!(thousands(1_234_567), "1,234,567");
}
#[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 find_matches_are_non_overlapping_and_ordered() {
// "aaaa" with needle "aa" yields two non-overlapping matches, not three.
assert_eq!(find_matches("aaaa", "aa", true), vec![(0, 2), (2, 4)]);
assert_eq!(
find_matches("the cat sat on the mat", "at", true),
vec![(5, 7), (9, 11), (20, 22)]
);
// An empty needle matches nothing.
assert!(find_matches("anything", "", true).is_empty());
// No occurrence.
assert!(find_matches("hello", "zz", true).is_empty());
}
#[test]
fn find_matches_respects_case_sensitivity() {
// Case-sensitive skips the capitalised occurrence.
assert_eq!(find_matches("Cat cat CAT", "cat", true), vec![(4, 7)]);
// Case-insensitive finds all three, with offsets into the original text.
assert_eq!(
find_matches("Cat cat CAT", "cat", false),
vec![(0, 3), (4, 7), (8, 11)]
);
}
#[test]
fn find_matches_maps_multibyte_offsets_case_insensitively() {
// "café Café" — the accented text keeps its byte offsets when matching
// case-insensitively (é is two bytes).
let hits = find_matches("café Café", "café", false);
assert_eq!(hits.len(), 2);
let (s0, e0) = hits[0];
let (s1, e1) = hits[1];
assert_eq!(&"café Café"[s0..e0], "café");
assert_eq!(&"café Café"[s1..e1], "Café");
}
#[test]
fn replace_all_replaces_every_match() {
assert_eq!(
replace_all("the cat sat", "at", "AT", true),
("the cAT sAT".to_string(), 2)
);
// Case-insensitive replacement across mixed case.
assert_eq!(
replace_all("Cat cat", "cat", "dog", false),
("dog dog".to_string(), 2)
);
// No matches leaves the text untouched.
assert_eq!(
replace_all("hello", "zz", "!", true),
("hello".to_string(), 0)
);
// Replacing with a longer/shorter string doesn't corrupt boundaries.
assert_eq!(
replace_all("a.b.c", ".", " - ", true),
("a - b - c".to_string(), 2)
);
}
#[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");
}
}