19dddf30c7
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
2816 lines
110 KiB
Rust
2816 lines
110 KiB
Rust
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");
|
||
}
|
||
}
|