e5eadc08c1
New Tools ▸ "Generate plot beats (Mistral)…" command: pick a novel proposal markdown file (characters + loose plot summary) and have the Mistral chat API lay it out as plot beats in the classic three-act structure. The result opens in a floating window where it can be edited, saved as a new "<proposal> — beats.md" workspace file, or copied. - src/mistral.rs: pure-Rust ureq/rustls POST to /v1/chat/completions (self-contained; no C bindings). System prompt fixes the three-act markdown shape; friendly API-error surfacing. Unit tests for content parsing, empty/blank responses, error-message extraction, and the missing-key guard. - config: mistral_api_key / mistral_model / mistral_base_url with effective-value helpers and defaults (mistral-large-latest, https://api.mistral.ai). - app: background generation + poll (editor stays responsive), a Settings ▸ Mistral… window (key/model/base URL), and the results window with save/copy. - Docs: README "Plot beats (Mistral)" section and help cheatsheet note. Verified: cargo build --release clean, 56 tests pass (+6 Mistral), ldd still only libc/libgcc/libm. Clippy: no new warnings in the added code (the two extra vs. the old 3-warning baseline are toolchain-surfaced in pre-existing app.rs code). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDfaEsTgcn61n3wgP62DFF
3701 lines
146 KiB
Rust
3701 lines
146 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,
|
||
}
|
||
|
||
/// Cached per-file header info for the file list: the hover tooltip fields plus
|
||
/// the word-count target and current prose length (for the per-row progress bar).
|
||
#[derive(Default, Clone)]
|
||
struct FileMeta {
|
||
/// The `# Slug:` synopsis line, if any.
|
||
slug: Option<String>,
|
||
/// The `POV:` line, if any.
|
||
pov: Option<String>,
|
||
/// The `Word Count Target:` goal, if any.
|
||
goal: Option<crate::preprocess::WordGoal>,
|
||
/// Prose (body) word count captured with the rest of this metadata.
|
||
prose_words: usize,
|
||
}
|
||
|
||
impl FileMeta {
|
||
/// Extract the cached fields from a document.
|
||
fn from_markdown(text: &str, marker: &str) -> Self {
|
||
let h = crate::preprocess::parse(text, marker);
|
||
FileMeta {
|
||
slug: h.slug,
|
||
pov: h.pov,
|
||
goal: h.goal,
|
||
prose_words: count_words(&h.body),
|
||
}
|
||
}
|
||
|
||
/// Whether this entry carries anything worth caching/showing.
|
||
fn has_display(&self) -> bool {
|
||
self.slug.is_some() || self.pov.is_some() || self.goal.is_some()
|
||
}
|
||
|
||
/// The tooltip text — a `POV:` line then the slug synopsis — or `None` when
|
||
/// the file carries neither field.
|
||
fn tooltip(&self) -> Option<String> {
|
||
let mut lines = Vec::new();
|
||
if let Some(pov) = &self.pov {
|
||
lines.push(format!("POV: {pov}"));
|
||
}
|
||
if let Some(slug) = &self.slug {
|
||
lines.push(slug.clone());
|
||
}
|
||
(!lines.is_empty()).then(|| lines.join("\n"))
|
||
}
|
||
}
|
||
|
||
/// Stable widget id for the manuscript editor's text field, so its cursor and
|
||
/// focus can be driven when inserting a header-field autocompletion.
|
||
const EDITOR_ID: &str = "manuscript_editor";
|
||
|
||
/// Header field names offered by autocomplete out of the box; merged with any
|
||
/// field names learned from the workspace's own files.
|
||
const DEFAULT_FIELD_NAMES: &[&str] = &[
|
||
"Title",
|
||
"Slug",
|
||
"POV",
|
||
"Characters",
|
||
"Setting",
|
||
"Conflict",
|
||
"Feeling to convey",
|
||
"Word Count Target",
|
||
"The Setup",
|
||
"The Reveal",
|
||
"Things to Note",
|
||
"Summary",
|
||
"Notes",
|
||
"Status",
|
||
"Location",
|
||
"Time",
|
||
"Goal",
|
||
"Motivation",
|
||
"Theme",
|
||
"Mood",
|
||
];
|
||
|
||
/// An active header-field autocomplete popup.
|
||
struct Autocomplete {
|
||
/// Matching field names (display form), best first.
|
||
matches: Vec<String>,
|
||
/// Highlighted match index.
|
||
selected: usize,
|
||
/// The lowercased partial that produced `matches`, so the selection stays
|
||
/// put while the user keeps typing the same prefix.
|
||
partial: String,
|
||
/// Byte offset on the line where the field-name text begins (replaced on
|
||
/// accept), and the caret byte offset (end of the replaced range).
|
||
field_start: usize,
|
||
cursor: usize,
|
||
/// Screen position just below the caret, where the popup is anchored.
|
||
anchor: egui::Pos2,
|
||
}
|
||
|
||
/// 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>,
|
||
/// Cached header info per file (name -> slug/POV/goal/prose) for the file
|
||
/// list's hover tooltip and per-row progress bar. Filled when the workspace
|
||
/// opens and refreshed on save; the selected file is read live from the buffer.
|
||
file_meta: HashMap<String, FileMeta>,
|
||
/// 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,
|
||
/// Active header-field autocomplete popup, if any.
|
||
autocomplete: Option<Autocomplete>,
|
||
/// A partial the user dismissed with Esc; the popup stays closed until the
|
||
/// partial changes, so Esc isn't undone by the next-frame recompute.
|
||
ac_dismissed: Option<String>,
|
||
/// Known header field names (built-ins + those learned from the workspace).
|
||
field_names: Vec<String>,
|
||
/// 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>,
|
||
/// Whether the Mistral settings window is open.
|
||
show_mistral_settings: bool,
|
||
/// In-flight background plot-beat generation, if any.
|
||
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
|
||
/// One-line status for the plot-beat generator.
|
||
beats_status: String,
|
||
/// The generated (and user-editable) plot beats; `Some` opens the results
|
||
/// window. Holds an empty string while generating or on error.
|
||
beats_output: Option<String>,
|
||
/// File stem of the proposal the beats came from, for the default save name.
|
||
beats_source_stem: Option<String>,
|
||
}
|
||
|
||
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(),
|
||
file_meta: 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,
|
||
autocomplete: None,
|
||
ac_dismissed: None,
|
||
field_names: Vec::new(),
|
||
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,
|
||
show_mistral_settings: false,
|
||
beats_rx: None,
|
||
beats_status: String::new(),
|
||
beats_output: None,
|
||
beats_source_stem: 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();
|
||
self.file_meta = self.snapshot_file_meta();
|
||
self.rebuild_field_names();
|
||
self.autocomplete = None;
|
||
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()
|
||
}
|
||
|
||
/// Read every file and return its cached header info (slug/POV/goal/prose),
|
||
/// for files that carry anything worth showing in the list.
|
||
fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> {
|
||
self.files
|
||
.iter()
|
||
.filter_map(|name| {
|
||
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||
let meta = FileMeta::from_markdown(&text, &self.config.draft_marker);
|
||
meta.has_display().then(|| (name.clone(), meta))
|
||
})
|
||
.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).cloned() {
|
||
let path = self.path_for(&name);
|
||
match std::fs::write(&path, &self.buffer) {
|
||
Ok(_) => {
|
||
self.dirty = false;
|
||
self.status = format!("Saved {name}");
|
||
// Keep the file-list cache (slug/POV/goal/prose) in step.
|
||
let meta =
|
||
FileMeta::from_markdown(&self.buffer, &self.config.draft_marker);
|
||
if meta.has_display() {
|
||
self.file_meta.insert(name, meta);
|
||
} else {
|
||
self.file_meta.remove(&name);
|
||
}
|
||
self.merge_field_names_from_buffer();
|
||
}
|
||
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.file_meta.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);
|
||
}
|
||
if let Some(meta) = self.file_meta.remove(&old_name) {
|
||
self.file_meta.insert(new_name.clone(), meta);
|
||
}
|
||
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())
|
||
}
|
||
|
||
/// The current file's word-count target (from its `Word Count Target:`
|
||
/// header) paired with its current prose word count, if a target is set.
|
||
/// Prose = the body below the draft marker, so header metadata isn't counted.
|
||
fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> {
|
||
self.selected?;
|
||
let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker);
|
||
let goal = header.goal?;
|
||
Some((goal, count_words(&header.body)))
|
||
}
|
||
|
||
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}"),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Pick a novel-proposal markdown file and generate three-act plot beats
|
||
/// from it with the Mistral API, in the background. Opens the settings window
|
||
/// instead if no API key is configured yet.
|
||
fn generate_plot_beats(&mut self, ctx: &egui::Context) {
|
||
if self.beats_rx.is_some() {
|
||
return; // a generation is already running
|
||
}
|
||
if self.config.mistral_api_key.trim().is_empty() {
|
||
self.show_mistral_settings = true;
|
||
self.beats_status = "Set your Mistral API key first (Settings ▸ Mistral).".to_string();
|
||
self.beats_output = Some(String::new());
|
||
return;
|
||
}
|
||
|
||
let mut dialog = rfd::FileDialog::new()
|
||
.set_title("Choose a novel proposal (markdown)")
|
||
.add_filter("Markdown / text", &["md", "markdown", "txt"]);
|
||
if let Some(dir) = self.workspace().to_str() {
|
||
dialog = dialog.set_directory(dir);
|
||
}
|
||
let Some(path) = dialog.pick_file() else {
|
||
return; // cancelled
|
||
};
|
||
|
||
let proposal = match std::fs::read_to_string(&path) {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
self.beats_status = format!("Could not read {}: {e}", path.display());
|
||
self.beats_output = Some(String::new());
|
||
return;
|
||
}
|
||
};
|
||
if proposal.trim().is_empty() {
|
||
self.beats_status = "That file is empty — nothing to work from.".to_string();
|
||
self.beats_output = Some(String::new());
|
||
return;
|
||
}
|
||
self.beats_source_stem = path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.map(str::to_string);
|
||
|
||
let api_key = self.config.mistral_api_key.clone();
|
||
let model = self.config.mistral_effective_model();
|
||
let base = self.config.mistral_effective_base_url();
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
self.beats_rx = Some(rx);
|
||
self.beats_status = "Generating plot beats…".to_string();
|
||
self.beats_output = Some(String::new());
|
||
let ctx = ctx.clone();
|
||
std::thread::spawn(move || {
|
||
let result = crate::mistral::generate_beats(&api_key, &model, &base, &proposal);
|
||
let _ = tx.send(result);
|
||
ctx.request_repaint();
|
||
});
|
||
}
|
||
|
||
/// Pick up a finished plot-beat generation and show it (or its error).
|
||
fn poll_beats(&mut self) {
|
||
let received = self.beats_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
||
if let Some(result) = received {
|
||
self.beats_rx = None;
|
||
match result {
|
||
Ok(beats) => {
|
||
self.beats_status = "Done — review, then save or copy.".to_string();
|
||
self.beats_output = Some(beats);
|
||
}
|
||
Err(e) => {
|
||
self.beats_status = format!("✖ {e}");
|
||
// Keep the window open so the error stays visible.
|
||
self.beats_output.get_or_insert_with(String::new);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write the generated beats to a new `<stem> — beats.md` file in the
|
||
/// workspace (dodging name collisions), add it to the list, and open it.
|
||
fn save_beats_as_file(&mut self, beats: &str) {
|
||
let stem = self
|
||
.beats_source_stem
|
||
.clone()
|
||
.unwrap_or_else(|| "plot".to_string());
|
||
let mut name = format!("{stem} — beats.md");
|
||
let mut n = 2;
|
||
while self.path_for(&name).exists() {
|
||
name = format!("{stem} — beats-{n}.md");
|
||
n += 1;
|
||
}
|
||
let path = self.path_for(&name);
|
||
if let Some(parent) = path.parent() {
|
||
let _ = std::fs::create_dir_all(parent);
|
||
}
|
||
let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim());
|
||
match std::fs::write(&path, contents) {
|
||
Ok(_) => {
|
||
if !self.files.contains(&name) {
|
||
self.files.push(name.clone());
|
||
self.persist_order();
|
||
}
|
||
if let Some(idx) = self.files.iter().position(|f| f == &name) {
|
||
self.selected = None; // force the buffer to reload
|
||
self.select(idx);
|
||
}
|
||
self.status = format!("Saved {name}");
|
||
self.beats_output = None; // close the window; the file is now open
|
||
self.beats_status.clear();
|
||
}
|
||
Err(e) => self.beats_status = format!("Save failed: {e}"),
|
||
}
|
||
}
|
||
|
||
/// Floating window presenting the generated plot beats, with save / copy.
|
||
fn beats_window(&mut self, ctx: &egui::Context) {
|
||
let mut open = true;
|
||
let mut close = false;
|
||
let mut save = false;
|
||
let running = self.beats_rx.is_some();
|
||
// Edit a local copy so the button row can borrow `self` mutably; persist
|
||
// any edits back into `beats_output` afterwards.
|
||
let mut text = self.beats_output.clone().unwrap_or_default();
|
||
egui::Window::new("✨ Plot beats (Mistral)")
|
||
.open(&mut open)
|
||
.resizable(true)
|
||
.default_width(560.0)
|
||
.default_height(520.0)
|
||
.show(ctx, |ui| {
|
||
ui.horizontal(|ui| {
|
||
if running {
|
||
ui.spinner();
|
||
}
|
||
if !self.beats_status.is_empty() {
|
||
ui.label(egui::RichText::new(&self.beats_status).weak());
|
||
}
|
||
});
|
||
ui.separator();
|
||
egui::ScrollArea::vertical()
|
||
.auto_shrink([false, false])
|
||
.show(ui, |ui| {
|
||
ui.add(
|
||
egui::TextEdit::multiline(&mut text)
|
||
.desired_width(f32::INFINITY)
|
||
.desired_rows(20)
|
||
.font(egui::TextStyle::Monospace),
|
||
);
|
||
});
|
||
ui.separator();
|
||
ui.horizontal(|ui| {
|
||
let have = !text.trim().is_empty();
|
||
if ui
|
||
.add_enabled(have, egui::Button::new("💾 Save as new file"))
|
||
.clicked()
|
||
{
|
||
save = true;
|
||
}
|
||
if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() {
|
||
ui.output_mut(|o| o.copied_text = text.clone());
|
||
self.beats_status = "Copied to clipboard.".to_string();
|
||
}
|
||
if ui.button("Close").clicked() {
|
||
close = true;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Persist edits made in the text box (unless we're about to close/save).
|
||
if self.beats_output.is_some() {
|
||
self.beats_output = Some(text.clone());
|
||
}
|
||
if save {
|
||
self.save_beats_as_file(&text);
|
||
} else if close || !open {
|
||
self.beats_output = None;
|
||
self.beats_status.clear();
|
||
}
|
||
}
|
||
|
||
/// Floating window for editing the Mistral API connection settings.
|
||
fn mistral_settings_window(&mut self, ctx: &egui::Context) {
|
||
let mut open = self.show_mistral_settings;
|
||
let mut close_clicked = false;
|
||
egui::Window::new("Mistral settings")
|
||
.open(&mut open)
|
||
.resizable(false)
|
||
.collapsible(false)
|
||
.show(ctx, |ui| {
|
||
let mut save_now = false;
|
||
egui::Grid::new("mistral_settings_grid")
|
||
.num_columns(2)
|
||
.spacing([10.0, 8.0])
|
||
.show(ui, |ui| {
|
||
ui.label("API key:");
|
||
let r = ui.add(
|
||
egui::TextEdit::singleline(&mut self.config.mistral_api_key)
|
||
.password(true)
|
||
.hint_text("from console.mistral.ai")
|
||
.desired_width(260.0),
|
||
);
|
||
save_now |= r.lost_focus();
|
||
ui.end_row();
|
||
|
||
ui.label("Model:");
|
||
let r = ui.add(
|
||
egui::TextEdit::singleline(&mut self.config.mistral_model)
|
||
.hint_text(crate::mistral::DEFAULT_MODEL)
|
||
.desired_width(260.0),
|
||
);
|
||
save_now |= r.lost_focus();
|
||
ui.end_row();
|
||
|
||
ui.label("Base URL:");
|
||
let r = ui.add(
|
||
egui::TextEdit::singleline(&mut self.config.mistral_base_url)
|
||
.hint_text(crate::mistral::DEFAULT_BASE_URL)
|
||
.desired_width(260.0),
|
||
);
|
||
save_now |= r.lost_focus();
|
||
ui.end_row();
|
||
});
|
||
|
||
ui.add_space(4.0);
|
||
ui.label(
|
||
egui::RichText::new(format!(
|
||
"Endpoint: {}/v1/chat/completions",
|
||
self.config.mistral_effective_base_url()
|
||
))
|
||
.weak()
|
||
.monospace(),
|
||
);
|
||
ui.label(
|
||
egui::RichText::new(
|
||
"The key is stored in this app's config file in plain text. \
|
||
The proposal you choose is sent to Mistral to generate the beats.",
|
||
)
|
||
.small()
|
||
.weak(),
|
||
);
|
||
ui.separator();
|
||
if ui.button("Close").clicked() {
|
||
close_clicked = true;
|
||
}
|
||
if save_now {
|
||
self.config.save();
|
||
}
|
||
});
|
||
|
||
let now_open = open && !close_clicked;
|
||
if self.show_mistral_settings && !now_open {
|
||
self.config.save();
|
||
}
|
||
self.show_mistral_settings = now_open;
|
||
}
|
||
|
||
/// 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));
|
||
|
||
let goal = self.current_goal();
|
||
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",
|
||
);
|
||
|
||
// Progress toward the file's Word Count Target, if set.
|
||
if let Some((goal, prose)) = goal {
|
||
let (frac, color, text) = goal_progress(goal, prose);
|
||
ui.separator();
|
||
ui.add(
|
||
egui::ProgressBar::new(frac)
|
||
.desired_width(150.0)
|
||
.fill(color)
|
||
.text(egui::RichText::new(text).small()),
|
||
)
|
||
.on_hover_text(
|
||
"Prose words vs the file's “Word Count Target” header \
|
||
(counts the body below the draft marker). Amber = under \
|
||
target, green = in range, blue = over.",
|
||
);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
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);
|
||
// The selected file's fields are read live from the
|
||
// buffer (so unsaved edits show); others come from the
|
||
// cache filled on open/save.
|
||
let meta = if selected {
|
||
FileMeta::from_markdown(&self.buffer, &self.config.draft_marker)
|
||
} else {
|
||
self.file_meta.get(&name).cloned().unwrap_or_default()
|
||
};
|
||
let tooltip = meta.tooltip();
|
||
let row = ui
|
||
.horizontal(|ui| {
|
||
ui.dnd_drag_source(
|
||
egui::Id::new(("dnd", &name)),
|
||
idx,
|
||
|ui| {
|
||
ui.label(
|
||
egui::RichText::new("⠿").monospace().weak(),
|
||
);
|
||
},
|
||
);
|
||
// Reserve room on the right for a per-file
|
||
// word-count-target bar when the file sets one.
|
||
let bar_w = 44.0;
|
||
let reserve = if meta.goal.is_some() { bar_w + 6.0 } else { 0.0 };
|
||
let label_w = (ui.available_width() - reserve).max(24.0);
|
||
let mut label = ui.add_sized(
|
||
[label_w, 20.0],
|
||
egui::SelectableLabel::new(selected, &name),
|
||
);
|
||
if let Some(tooltip) = &tooltip {
|
||
label = label.on_hover_text(tooltip);
|
||
}
|
||
if label.clicked() {
|
||
clicked = Some(idx);
|
||
}
|
||
if let Some(goal) = meta.goal {
|
||
row_goal_bar(ui, bar_w, goal, meta.prose_words);
|
||
}
|
||
})
|
||
.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("Tools", |ui| {
|
||
let busy = self.beats_rx.is_some();
|
||
if ui
|
||
.add_enabled(
|
||
!busy,
|
||
egui::Button::new("✨ Generate plot beats (Mistral)…"),
|
||
)
|
||
.clicked()
|
||
{
|
||
ui.close_menu();
|
||
self.generate_plot_beats(ctx);
|
||
}
|
||
});
|
||
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;
|
||
}
|
||
if ui.button("Mistral…").clicked() {
|
||
ui.close_menu();
|
||
self.show_mistral_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.");
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- Header-field autocomplete -----------------------------------------
|
||
|
||
/// Rebuild the field-name list: the built-ins plus any `#+ Name:` fields
|
||
/// found in the header of every workspace file.
|
||
fn rebuild_field_names(&mut self) {
|
||
let mut names: Vec<String> =
|
||
DEFAULT_FIELD_NAMES.iter().map(|s| s.to_string()).collect();
|
||
for name in &self.files {
|
||
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||
for f in header_field_names(&text, &self.config.draft_marker) {
|
||
if !names.iter().any(|n| n.eq_ignore_ascii_case(&f)) {
|
||
names.push(f);
|
||
}
|
||
}
|
||
}
|
||
names.sort_by_key(|s| s.to_lowercase());
|
||
self.field_names = names;
|
||
}
|
||
|
||
/// Fold any new header field names from the current buffer into the list, so
|
||
/// a field you just invented autocompletes without reopening the workspace.
|
||
fn merge_field_names_from_buffer(&mut self) {
|
||
let found = header_field_names(&self.buffer, &self.config.draft_marker);
|
||
let mut added = false;
|
||
for f in found {
|
||
if !self.field_names.iter().any(|n| n.eq_ignore_ascii_case(&f)) {
|
||
self.field_names.push(f);
|
||
added = true;
|
||
}
|
||
}
|
||
if added {
|
||
self.field_names.sort_by_key(|s| s.to_lowercase());
|
||
}
|
||
}
|
||
|
||
/// Insert the chosen field name at the caret, replacing the partial the user
|
||
/// had typed and leaving the caret after the inserted `": "`.
|
||
fn apply_field_completion(&mut self, ctx: &egui::Context, field: &str) {
|
||
let Some(ac) = self.autocomplete.take() else {
|
||
return;
|
||
};
|
||
// The stored offsets are from the last layout; a nav-key or click didn't
|
||
// edit the buffer, but guard against anything unexpected.
|
||
if ac.cursor > self.buffer.len()
|
||
|| ac.field_start > ac.cursor
|
||
|| !self.buffer.is_char_boundary(ac.field_start)
|
||
|| !self.buffer.is_char_boundary(ac.cursor)
|
||
{
|
||
return;
|
||
}
|
||
let replacement = format!("{field}: ");
|
||
self.buffer.replace_range(ac.field_start..ac.cursor, &replacement);
|
||
self.dirty = true;
|
||
self.find_needs_refresh = true;
|
||
self.spell_dirty = true;
|
||
self.spell_last_edit = Some(Instant::now());
|
||
|
||
// Put the caret just after the inserted "Field: " and refocus the editor.
|
||
let new_cursor_byte = ac.field_start + replacement.len();
|
||
let char_idx = self.buffer[..new_cursor_byte].chars().count();
|
||
let id = egui::Id::new(EDITOR_ID);
|
||
if let Some(mut state) = egui::widgets::text_edit::TextEditState::load(ctx, id) {
|
||
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(
|
||
egui::text::CCursor::new(char_idx),
|
||
)));
|
||
state.store(ctx, id);
|
||
}
|
||
ctx.memory_mut(|m| m.request_focus(id));
|
||
ctx.request_repaint();
|
||
}
|
||
|
||
fn editor(&mut self, ui: &mut egui::Ui) {
|
||
// Base monospace size scaled by the zoom percentage. `desired_width` stays
|
||
// infinite and the scroll area is vertical-only, so text wraps to the pane
|
||
// width at any zoom (no horizontal scrolling).
|
||
let base = ui
|
||
.style()
|
||
.text_styles
|
||
.get(&egui::TextStyle::Monospace)
|
||
.map(|f| f.size)
|
||
.unwrap_or(12.0);
|
||
let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0);
|
||
|
||
// Editor text colour, brightened toward max contrast by the contrast slider.
|
||
let text_color = editor_text_color(ui.visuals(), self.config.editor_text_contrast);
|
||
|
||
// Underline whichever check currently owns the buffer's issues
|
||
// (LanguageTool when fresh, otherwise the live spell checker).
|
||
let ranges: Vec<(usize, usize, bool)> = self
|
||
.current_matches()
|
||
.iter()
|
||
.map(|m| (m.start, m.end, m.spelling))
|
||
.collect();
|
||
|
||
// Search-match highlights (all matches shaded, the active one stronger).
|
||
let find_ranges: Vec<(usize, usize)> = if self.show_find {
|
||
self.find_matches.clone()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let find_active = if self.show_find {
|
||
Some(self.find_active)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
egui::ScrollArea::vertical()
|
||
.id_salt("editor")
|
||
.auto_shrink([false, false])
|
||
.show(ui, |ui| {
|
||
let mut layouter = move |ui: &egui::Ui, text: &str, wrap_width: f32| {
|
||
let job = build_editor_job(
|
||
text,
|
||
size,
|
||
text_color,
|
||
&ranges,
|
||
&find_ranges,
|
||
find_active,
|
||
wrap_width,
|
||
);
|
||
ui.fonts(|f| f.layout_job(job))
|
||
};
|
||
// Autocomplete: intercept navigation keys before the editor
|
||
// consumes them, acting on last frame's popup state.
|
||
let mut accept_field: Option<String> = None;
|
||
let mut dismiss = false;
|
||
if let Some(ac) = self.autocomplete.as_mut() {
|
||
ui.input_mut(|i| {
|
||
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) {
|
||
ac.selected = (ac.selected + 1) % ac.matches.len();
|
||
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) {
|
||
ac.selected = (ac.selected + ac.matches.len() - 1) % ac.matches.len();
|
||
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)
|
||
|| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)
|
||
{
|
||
accept_field = ac.matches.get(ac.selected).cloned();
|
||
} else if i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) {
|
||
dismiss = true;
|
||
}
|
||
});
|
||
}
|
||
if dismiss {
|
||
// Remember the dismissed partial so it doesn't immediately reopen.
|
||
self.ac_dismissed = self.autocomplete.take().map(|a| a.partial);
|
||
}
|
||
if let Some(field) = accept_field {
|
||
self.apply_field_completion(ui.ctx(), &field);
|
||
}
|
||
|
||
let output = egui::TextEdit::multiline(&mut self.buffer)
|
||
.id(egui::Id::new(EDITOR_ID))
|
||
.code_editor()
|
||
.desired_width(f32::INFINITY)
|
||
.desired_rows(30)
|
||
.layouter(&mut layouter)
|
||
.show(ui);
|
||
if output.response.changed() {
|
||
self.dirty = true;
|
||
// The buffer changed, so search matches and the live spell
|
||
// check both need refreshing (the latter debounced).
|
||
self.find_needs_refresh = true;
|
||
self.spell_dirty = true;
|
||
self.spell_last_edit = Some(Instant::now());
|
||
}
|
||
// Markdown formatting hotkeys, applied to the current selection
|
||
// while the editor is focused (Ctrl/Cmd + B / I / E / K, and
|
||
// Ctrl/Cmd+Shift+X for strikethrough).
|
||
if output.response.has_focus() {
|
||
if let Some(fmt) = ui.input_mut(detect_format_hotkey) {
|
||
let sel = output
|
||
.cursor_range
|
||
.map(|cr| cr.as_sorted_char_range())
|
||
.unwrap_or(0..0);
|
||
let (new_text, new_sel) = apply_format(&self.buffer, sel, fmt);
|
||
self.buffer = new_text;
|
||
self.dirty = true;
|
||
// Restore the caret/selection around the change.
|
||
let mut state = output.state;
|
||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
|
||
egui::text::CCursor::new(new_sel.start),
|
||
egui::text::CCursor::new(new_sel.end),
|
||
)));
|
||
state.store(ui.ctx(), output.response.id);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
}
|
||
|
||
// Scroll the active search match into view when requested (on
|
||
// open, Next/Prev, Enter, or after a replace).
|
||
if self.find_scroll {
|
||
if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
|
||
let char_idx = self
|
||
.buffer
|
||
.get(..s)
|
||
.map(|p| p.chars().count())
|
||
.unwrap_or(0);
|
||
let local =
|
||
output.galley.pos_from_ccursor(egui::text::CCursor::new(char_idx));
|
||
let onscreen = local.translate(output.galley_pos.to_vec2());
|
||
ui.scroll_to_rect(onscreen, Some(egui::Align::Center));
|
||
}
|
||
self.find_scroll = false;
|
||
}
|
||
|
||
// Right-click a misspelling → a menu of suggested corrections.
|
||
if output.response.secondary_clicked() {
|
||
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
|
||
let local = pos - output.galley_pos;
|
||
let cursor = output.galley.cursor_from_pos(local);
|
||
let byte = char_to_byte(&self.buffer, cursor.ccursor.index);
|
||
self.match_index_at(byte)
|
||
});
|
||
}
|
||
// Snapshot the target's suggestions (owned) so the menu closure
|
||
// doesn't borrow self; compute them on demand if the background
|
||
// check hadn't produced any for this word.
|
||
let menu: Option<(usize, Vec<String>)> = self.spell_menu.and_then(|i| {
|
||
let m = self.current_matches().get(i)?;
|
||
let mut reps = m.replacements.clone();
|
||
if reps.is_empty() {
|
||
if let (Some(dict), Some(word)) =
|
||
(&self.spell_dict, self.buffer.get(m.start..m.end))
|
||
{
|
||
reps = crate::spell::suggestions(dict, word);
|
||
}
|
||
}
|
||
Some((i, reps))
|
||
});
|
||
let mut chosen: Option<(usize, usize)> = None;
|
||
output.response.context_menu(|ui| {
|
||
match &menu {
|
||
Some((i, reps)) if !reps.is_empty() => {
|
||
ui.label(egui::RichText::new("Suggestions").strong());
|
||
for (j, rep) in reps.iter().enumerate() {
|
||
if ui.button(rep).clicked() {
|
||
chosen = Some((*i, j));
|
||
ui.close_menu();
|
||
}
|
||
}
|
||
}
|
||
Some(_) => {
|
||
ui.label(egui::RichText::new("No suggestions").weak());
|
||
}
|
||
None => {
|
||
ui.label(egui::RichText::new("No spelling issue here").weak());
|
||
}
|
||
}
|
||
});
|
||
if let Some((i, j)) = chosen {
|
||
self.apply_current_fix(i, j);
|
||
self.spell_menu = None;
|
||
}
|
||
|
||
// Recompute the header-field autocomplete from the caret so the
|
||
// popup tracks what the user is typing (only for a collapsed
|
||
// caret in the focused editor).
|
||
if output.response.has_focus() {
|
||
let caret = output.cursor_range.and_then(|cr| {
|
||
let r = cr.as_sorted_char_range();
|
||
(r.start == r.end).then_some(r.end)
|
||
});
|
||
let mut next = None;
|
||
let mut still_dismissed = false;
|
||
if let Some(caret_char) = caret {
|
||
let caret_byte = char_to_byte(&self.buffer, caret_char);
|
||
if let Some((field_start, partial)) = header_completion_context(
|
||
&self.buffer,
|
||
caret_byte,
|
||
&self.config.draft_marker,
|
||
) {
|
||
let partial_l = partial.to_lowercase();
|
||
if self.ac_dismissed.as_deref() == Some(partial_l.as_str()) {
|
||
// Esc-dismissed for exactly this partial; stay closed.
|
||
still_dismissed = true;
|
||
} else {
|
||
let matches = field_matches(&self.field_names, &partial);
|
||
if !matches.is_empty() {
|
||
// Keep the highlight while typing the same prefix.
|
||
let selected = match &self.autocomplete {
|
||
Some(prev)
|
||
if prev.partial == partial_l
|
||
&& prev.selected < matches.len() =>
|
||
{
|
||
prev.selected
|
||
}
|
||
_ => 0,
|
||
};
|
||
let caret_rect = output
|
||
.galley
|
||
.pos_from_ccursor(egui::text::CCursor::new(caret_char));
|
||
let anchor = caret_rect.left_bottom()
|
||
+ output.galley_pos.to_vec2()
|
||
+ egui::vec2(0.0, 2.0);
|
||
next = Some(Autocomplete {
|
||
matches,
|
||
selected,
|
||
partial: partial_l,
|
||
field_start,
|
||
cursor: caret_byte,
|
||
anchor,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.autocomplete = next;
|
||
if !still_dismissed {
|
||
self.ac_dismissed = None;
|
||
}
|
||
} else {
|
||
self.autocomplete = None;
|
||
}
|
||
});
|
||
|
||
// Header-field autocomplete popup, floating just below the caret.
|
||
let mut clicked_field: Option<String> = None;
|
||
if let Some(ac) = &self.autocomplete {
|
||
egui::Area::new(egui::Id::new("field_autocomplete"))
|
||
.order(egui::Order::Foreground)
|
||
.fixed_pos(ac.anchor)
|
||
.constrain(true)
|
||
.show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||
ui.set_max_width(260.0);
|
||
for (i, m) in ac.matches.iter().enumerate() {
|
||
if ui.selectable_label(i == ac.selected, m).clicked() {
|
||
clicked_field = Some(m.clone());
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
if let Some(field) = clicked_field {
|
||
self.apply_field_completion(ui.ctx(), &field);
|
||
}
|
||
}
|
||
|
||
/// 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.poll_beats();
|
||
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_mistral_settings {
|
||
self.mistral_settings_window(ctx);
|
||
}
|
||
|
||
if self.beats_output.is_some() {
|
||
self.beats_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()
|
||
}
|
||
|
||
/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour
|
||
/// (amber under the range, green within it, blue over it), and a label like
|
||
/// "1,234 / 1,500–2,000".
|
||
fn goal_progress(goal: crate::preprocess::WordGoal, prose: usize) -> (f32, egui::Color32, String) {
|
||
let frac = if goal.max == 0 {
|
||
0.0
|
||
} else {
|
||
(prose as f32 / goal.max as f32).clamp(0.0, 1.0)
|
||
};
|
||
let color = if prose < goal.min {
|
||
egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target
|
||
} else if prose <= goal.max {
|
||
egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range
|
||
} else {
|
||
egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target
|
||
};
|
||
let target = if goal.min == goal.max {
|
||
thousands(goal.max)
|
||
} else {
|
||
format!("{}–{}", thousands(goal.min), thousands(goal.max))
|
||
};
|
||
(frac, color, format!("{} / {}", thousands(prose), target))
|
||
}
|
||
|
||
/// Paint a compact word-count-target progress bar for one file-list row, with
|
||
/// the numeric progress (and target) as a hover tooltip. The colour matches the
|
||
/// status-bar bar: amber under target, green in range, blue over.
|
||
fn row_goal_bar(ui: &mut egui::Ui, width: f32, goal: crate::preprocess::WordGoal, prose: usize) {
|
||
let (frac, color, text) = goal_progress(goal, prose);
|
||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover());
|
||
let rounding = egui::Rounding::same(2.0);
|
||
let track = ui.visuals().extreme_bg_color;
|
||
let painter = ui.painter();
|
||
painter.rect_filled(rect, rounding, track);
|
||
if frac > 0.0 {
|
||
let fill = egui::Rect::from_min_size(
|
||
rect.min,
|
||
egui::vec2((rect.width() * frac).max(1.0), rect.height()),
|
||
);
|
||
painter.rect_filled(fill, rounding, color);
|
||
}
|
||
resp.on_hover_text(format!("{text} words"));
|
||
}
|
||
|
||
/// Collect the `#+ Name:` field names from a file's header (the lines above the
|
||
/// draft marker). Returns empty when the marker is disabled or not present, so
|
||
/// prose headings in marker-less files aren't mistaken for fields.
|
||
fn header_field_names(text: &str, marker: &str) -> Vec<String> {
|
||
let marker_l = marker.trim().to_ascii_lowercase();
|
||
if marker_l.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let has_marker = text
|
||
.lines()
|
||
.any(|l| l.trim_start().to_ascii_lowercase().starts_with(&marker_l));
|
||
if !has_marker {
|
||
return Vec::new();
|
||
}
|
||
let mut names = Vec::new();
|
||
for line in text.lines() {
|
||
let t = line.trim_start();
|
||
if t.to_ascii_lowercase().starts_with(&marker_l) {
|
||
break; // reached the marker; the rest is prose
|
||
}
|
||
if let Some(name) = field_name_of_line(t) {
|
||
names.push(name);
|
||
}
|
||
}
|
||
names
|
||
}
|
||
|
||
/// If `trimmed` (a left-trimmed line) is a `#+ Name:` field line, return the
|
||
/// field name. Names are limited to short, plain strings to avoid catching
|
||
/// ordinary prose headings that merely contain a colon.
|
||
fn field_name_of_line(trimmed: &str) -> Option<String> {
|
||
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
|
||
if hashes == 0 {
|
||
return None;
|
||
}
|
||
let after = &trimmed[hashes..];
|
||
let ws = after.len() - after.trim_start().len();
|
||
if ws == 0 {
|
||
return None; // need whitespace after the #'s
|
||
}
|
||
let rest = after.trim_start();
|
||
let colon = rest.find(':')?;
|
||
let name = rest[..colon].trim();
|
||
let plausible = !name.is_empty()
|
||
&& name.chars().count() <= 40
|
||
&& name
|
||
.chars()
|
||
.all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '\'');
|
||
plausible.then(|| name.to_string())
|
||
}
|
||
|
||
/// The byte offset where the draft-marker line begins in `buffer`, if present.
|
||
fn marker_line_start(buffer: &str, marker: &str) -> Option<usize> {
|
||
let marker_l = marker.trim().to_ascii_lowercase();
|
||
if marker_l.is_empty() {
|
||
return None;
|
||
}
|
||
let mut off = 0;
|
||
for line in buffer.split_inclusive('\n') {
|
||
if line.trim().to_ascii_lowercase().starts_with(&marker_l) {
|
||
return Some(off);
|
||
}
|
||
off += line.len();
|
||
}
|
||
None
|
||
}
|
||
|
||
/// If the caret sits in a header field-name position — on a `#+ <partial>` line
|
||
/// above the draft marker, before any colon — return the byte range of the
|
||
/// partial field text and the partial itself, for autocompletion.
|
||
///
|
||
/// Returns `(field_start, partial)`; the caret byte is the caller's `cursor`.
|
||
fn header_completion_context(buffer: &str, cursor: usize, marker: &str) -> Option<(usize, String)> {
|
||
if marker.trim().is_empty() || cursor > buffer.len() || !buffer.is_char_boundary(cursor) {
|
||
return None;
|
||
}
|
||
let line_start = buffer[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0);
|
||
// Suppress once the caret's line is at or past a present marker line.
|
||
if let Some(ms) = marker_line_start(buffer, marker) {
|
||
if line_start >= ms {
|
||
return None;
|
||
}
|
||
}
|
||
let before = &buffer[line_start..cursor];
|
||
let trimmed = before.trim_start();
|
||
let indent = before.len() - trimmed.len();
|
||
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
|
||
if hashes == 0 {
|
||
return None;
|
||
}
|
||
let after_hash = &trimmed[hashes..];
|
||
let ws = after_hash.len() - after_hash.trim_start().len();
|
||
if ws == 0 {
|
||
return None; // need whitespace after the #'s
|
||
}
|
||
let partial = &after_hash[ws..];
|
||
if partial.contains(':') {
|
||
return None; // past the field name, into the value
|
||
}
|
||
let field_start = line_start + indent + hashes + ws;
|
||
Some((field_start, partial.to_string()))
|
||
}
|
||
|
||
/// Field names whose start matches `partial` (case-insensitive), best first.
|
||
/// An empty (or whitespace-only) partial matches nothing.
|
||
fn field_matches(fields: &[String], partial: &str) -> Vec<String> {
|
||
let p = partial.trim().to_lowercase();
|
||
if p.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let mut out: Vec<String> = fields
|
||
.iter()
|
||
.filter(|f| f.to_lowercase().starts_with(&p))
|
||
.cloned()
|
||
.collect();
|
||
// Shorter (closer) matches first, then alphabetical.
|
||
out.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
|
||
out.dedup();
|
||
out.truncate(8);
|
||
out
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
|
||
const DM: &str = "### Rough Draft:";
|
||
|
||
#[test]
|
||
fn field_matches_prefix_case_insensitive() {
|
||
let fields: Vec<String> = ["POV", "Point of View", "Setting", "Slug"]
|
||
.iter()
|
||
.map(|s| s.to_string())
|
||
.collect();
|
||
assert_eq!(field_matches(&fields, "po"), vec!["POV", "Point of View"]);
|
||
assert_eq!(field_matches(&fields, "S"), vec!["Slug", "Setting"]); // shorter first
|
||
assert!(field_matches(&fields, "").is_empty());
|
||
assert!(field_matches(&fields, "zz").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn completion_context_offers_partial_above_marker() {
|
||
let buf = "# Title: X\n## PO\n### Rough Draft:\n\nprose";
|
||
// Caret at the end of "## PO" (byte 16).
|
||
let got = header_completion_context(buf, 16, DM);
|
||
assert_eq!(got, Some((14, "PO".to_string())));
|
||
assert_eq!(&buf[14..16], "PO");
|
||
}
|
||
|
||
#[test]
|
||
fn completion_context_handles_multiword_fields() {
|
||
// A field name may contain spaces; the partial spans them.
|
||
let buf = "## Word Count Ta\n### Rough Draft:\n\nx";
|
||
let got = header_completion_context(buf, 16, DM);
|
||
assert_eq!(got, Some((3, "Word Count Ta".to_string())));
|
||
assert!(field_matches(
|
||
&["Word Count Target".to_string()],
|
||
"Word Count Ta"
|
||
)
|
||
.contains(&"Word Count Target".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn completion_context_suppressed_after_colon_and_below_marker() {
|
||
// Past the colon (into the value) → no completion.
|
||
let after_colon = "## POV: Bi\n### Rough Draft:\n\nx";
|
||
assert_eq!(header_completion_context(after_colon, 10, DM), None);
|
||
// Below the marker (in prose) → no completion, even for a heading.
|
||
let below = "### Rough Draft:\n\n## Scene\n";
|
||
let caret = below.find("## Scene").unwrap() + "## Scene".len();
|
||
assert_eq!(header_completion_context(below, caret, DM), None);
|
||
// No marker configured → disabled.
|
||
assert_eq!(header_completion_context("## PO", 5, ""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn header_field_names_collects_above_marker_only() {
|
||
let buf = "# Title: A\n## POV: Bixby\n## Setting: hall\n\
|
||
### Rough Draft:\n\n## Not a field: prose\n";
|
||
let names = header_field_names(buf, DM);
|
||
assert!(names.contains(&"Title".to_string()));
|
||
assert!(names.contains(&"POV".to_string()));
|
||
assert!(names.contains(&"Setting".to_string()));
|
||
assert!(!names.iter().any(|n| n == "Not a field"));
|
||
// A file with no marker contributes no learned names.
|
||
assert!(header_field_names("## POV: X\n\nprose", DM).is_empty());
|
||
}
|
||
}
|