diff --git a/Cargo.lock b/Cargo.lock index ffa8678..be678a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.6.0" @@ -1852,6 +1858,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "ureq", "zip", ] @@ -2691,6 +2698,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2738,6 +2759,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3007,6 +3063,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -3272,6 +3334,27 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -3565,6 +3648,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wgpu" version = "22.1.0" @@ -4393,6 +4494,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index e7c5164..52b8e1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ zip = { version = "2", default-features = false, features = ["deflate"] } dirs = "5" # Native open/save dialogs via the XDG Desktop Portal (no GTK build dependency). rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "async-std"] } +# Blocking HTTP client for talking to a LanguageTool server. The rustls TLS +# backend links statically (no new runtime .so deps), so both a plain-HTTP local +# server and an HTTPS domain work. +ureq = { version = "2", default-features = false, features = ["tls"] } [profile.release] opt-level = 2 diff --git a/README.md b/README.md index c32b985..1924283 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,48 @@ The status bar shows a live **word count** for the current file, plus the net words added (or removed) to it since the current session started. The count updates as you type, including unsaved edits. +## Grammar & spelling (LanguageTool) + +The editor can check the current file against a +[LanguageTool](https://languagetool.org/) server — typically a **local +instance**, so your manuscript never leaves your machine. + +1. Run a LanguageTool server. The standalone server listens on + `http://localhost:8010` by default (the official Docker image uses `:8081` — + just point the app at whichever you run): + ```sh + # Standalone (needs Java): + java -cp 'languagetool-server.jar' org.languagetool.server.HTTPServer --port 8010 + # or via Docker: + docker run -d -p 8010:8010 --entrypoint java \ + erikvl87/languagetool -cp languagetool-server.jar \ + org.languagetool.server.HTTPServer --port 8010 --allow-origin '*' + ``` +2. Open **Settings ▸ LanguageTool…** (from the menu bar, or the **⚙** button on + the Grammar row) and set the connection: + * **Scheme** — `http` for a local server, `https` for a remote one (TLS is + supported). + * **Host / domain** — an IP address or hostname (default `localhost`). + * **Port** — the server's port (default `8010`). + * **Token** — optional; sent as an `Authorization: Bearer` header for a server + behind an auth proxy, or a premium API key. Leave blank for an + unauthenticated local server. + * **Language** — `auto` to detect, or a code like `en-US`, `en-GB`, `de-DE`. + + Press **Test connection** to confirm the settings reach a working server. All + fields are remembered in the config. +3. Open a file and press **✓ Check**. The check runs in the background (the UI + stays responsive) and issues appear two ways: + * **Underlines in the editor** — red for spelling, blue for grammar/style. + * **A "Grammar & spelling" panel** at the bottom listing each issue with its + explanation and suggested fixes. Click a suggestion to apply it; the + remaining underlines re-align automatically. + +Editing the text after a check hides the underlines (their positions would no +longer be accurate) and disables the fix buttons until you re-check. The checker +sends the raw file text, so occasional markdown tokens (e.g. a `#` heading +marker) may be flagged. + ## Header stripping on export Manuscript files often carry editorial notes and metadata above the prose that diff --git a/src/app.rs b/src/app.rs index 9ea5515..7fd301f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,6 +6,12 @@ use eframe::egui; use std::collections::HashMap; use std::path::{Path, PathBuf}; +/// Result of a background grammar check: the matches, or an error message. +type LtCheckResult = Result, 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); + pub struct App { config: Config, /// Ordered markdown file names (relative to the workspace). @@ -33,6 +39,23 @@ pub struct App { /// 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, + /// Grammar/spelling issues from the last LanguageTool check. + lt_matches: Vec, + /// 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>, + /// 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>>, + /// Result line for the settings window's "Test connection" button. + settings_test_status: String, } impl App { @@ -56,6 +79,14 @@ impl App { git_log: String::new(), is_repo: false, session_start_counts: HashMap::new(), + lt_matches: Vec::new(), + lt_checked_text: String::new(), + lt_status: String::new(), + lt_rx: None, + show_lt_panel: false, + show_settings: false, + settings_test_rx: None, + settings_test_status: String::new(), }; app.open_workspace(); app @@ -82,6 +113,7 @@ impl App { self.buffer.clear(); self.dirty = false; self.pending_delete = false; + self.clear_lt(); self.session_start_counts = self.snapshot_counts(); if !self.files.is_empty() { self.select(0); @@ -147,11 +179,19 @@ impl App { } } + /// 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(); 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(); @@ -395,6 +435,135 @@ impl App { } } + /// Kick off a LanguageTool check of the current buffer on a background + /// thread. Results are delivered through `lt_rx` and picked up in `update`. + fn start_lt_check(&mut self, ctx: &egui::Context) { + if self.selected.is_none() { + self.lt_status = "Open a file to check".to_string(); + return; + } + if self.lt_rx.is_some() { + return; // a check is already running + } + if self.config.languagetool_host.trim().is_empty() { + self.lt_status = "Set a LanguageTool host in Settings first".to_string(); + self.show_lt_panel = true; + self.show_settings = true; + return; + } + let url = self.config.languagetool_base_url(); + let token = self.config.languagetool_token.clone(); + let mut language = self.config.languagetool_language.trim().to_string(); + if language.is_empty() { + language = "auto".to_string(); + } + let text = self.buffer.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + self.lt_rx = Some(rx); + self.lt_status = "Checking…".to_string(); + self.show_lt_panel = true; + let ctx = ctx.clone(); + std::thread::spawn(move || { + let result = crate::langtool::check(&url, &language, &token, &text); + let _ = tx.send((text, result)); + ctx.request_repaint(); + }); + } + + /// Ping the configured server with a sample sentence to confirm the settings + /// work, without disturbing the editor's current results. Used by the + /// Settings dialog's "Test connection" button. + fn test_lt_connection(&mut self, ctx: &egui::Context) { + if self.settings_test_rx.is_some() { + return; + } + let url = self.config.languagetool_base_url(); + let token = self.config.languagetool_token.clone(); + let language = { + let l = self.config.languagetool_language.trim(); + if l.is_empty() { "auto".to_string() } else { l.to_string() } + }; + let (tx, rx) = std::sync::mpsc::channel(); + self.settings_test_rx = Some(rx); + self.settings_test_status = "Testing…".to_string(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + // A sentence with a deliberate error, so a working server returns ≥1 match. + let result = + crate::langtool::check(&url, &language, &token, "The team are ready to began."); + let _ = tx.send(result.map(|m| m.len())); + ctx.request_repaint(); + }); + } + + /// Pick up a finished "Test connection" result for the Settings dialog. + fn poll_settings_test(&mut self) { + let received = self + .settings_test_rx + .as_ref() + .and_then(|rx| rx.try_recv().ok()); + if let Some(result) = received { + self.settings_test_rx = None; + self.settings_test_status = match result { + Ok(_) => format!("✔ Connected to {}", self.config.languagetool_base_url()), + Err(e) => format!("✖ {e}"), + }; + } + } + + /// Poll for a finished background check and store its results. + fn poll_lt(&mut self) { + let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some((text, result)) = received { + self.lt_rx = None; + match result { + Ok(matches) => { + self.lt_status = match matches.len() { + 0 => "No issues found".to_string(), + 1 => "1 issue".to_string(), + n => format!("{n} issues"), + }; + self.lt_matches = matches; + self.lt_checked_text = text; + } + Err(e) => { + self.lt_matches.clear(); + self.lt_checked_text.clear(); + self.lt_status = e; + } + } + } + } + + /// Apply replacement `rep_idx` of match `match_idx` 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; + } + let Some(m) = self.lt_matches.get(match_idx).cloned() else { + return; + }; + let Some(replacement) = m.replacements.get(rep_idx).cloned() else { + return; + }; + if m.end > self.buffer.len() || !self.buffer.is_char_boundary(m.start) { + return; + } + self.buffer.replace_range(m.start..m.end, &replacement); + self.dirty = true; + self.lt_checked_text = self.buffer.clone(); + + // The applied match overlaps its own region, so it is dropped here too. + remap_matches(&mut self.lt_matches, m.start, m.end, replacement.len()); + self.lt_status = match self.lt_matches.len() { + 0 => "No issues remaining".to_string(), + 1 => "1 issue".to_string(), + n => format!("{n} issues"), + }; + } + fn reorder(&mut self, from: usize, mut to: usize) { if from >= self.files.len() || from == to { return; @@ -495,6 +664,39 @@ impl App { 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(4.0); }); @@ -653,6 +855,243 @@ impl App { }); } + /// 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("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; + } + }); + }); + }); + } + + /// 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 grammar/spelling issues with one-click fixes. + fn lt_panel(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::bottom("ltpanel") + .resizable(true) + .default_height(190.0) + .show(ctx, |ui| { + let stale = self.buffer != self.lt_checked_text; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Grammar & spelling").strong()); + if !self.lt_status.is_empty() { + ui.label(egui::RichText::new(&self.lt_status).weak()); + } + if stale && !self.lt_matches.is_empty() { + ui.label( + egui::RichText::new("· edited since check — re-check to apply fixes") + .weak() + .italics(), + ); + } + 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 self.lt_matches.is_empty() { + ui.label( + egui::RichText::new(if self.lt_rx.is_some() { + "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 (i, m) in self.lt_matches.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + let (dot, col) = if m.spelling { + ("●", egui::Color32::from_rgb(0xE0, 0x40, 0x40)) + } else { + ("●", egui::Color32::from_rgb(0x3B, 0x82, 0xF6)) + }; + ui.label(egui::RichText::new(dot).color(col)); + if let Some(snippet) = self.lt_checked_text.get(m.start..m.end) { + ui.label( + egui::RichText::new(format!("“{snippet}”")).strong(), + ); + } + ui.label(&m.message); + }); + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + if m.replacements.is_empty() { + ui.label( + egui::RichText::new("(no suggestion)").weak().italics(), + ); + } else { + for (j, rep) in m.replacements.iter().take(8).enumerate() { + if ui + .add_enabled(!stale, egui::Button::new(rep).small()) + .clicked() + { + apply = Some((i, j)); + } + } + } + }); + ui.separator(); + } + }); + + if let Some((i, j)) = apply { + self.apply_replacement(i, j); + } + }); + } + fn central(&mut self, ctx: &egui::Context) { egui::CentralPanel::default().show(ctx, |ui| { match self.selected { @@ -748,16 +1187,31 @@ impl App { .unwrap_or(12.0); let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.0); + // Underline grammar/spelling matches, but only while the buffer still + // equals the text they were computed against (edits invalidate offsets). + let ranges: Vec<(usize, usize, bool)> = if self.buffer == self.lt_checked_text { + self.lt_matches + .iter() + .map(|m| (m.start, m.end, m.spelling)) + .collect() + } else { + Vec::new() + }; + 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(ui, text, size, &ranges, wrap_width); + ui.fonts(|f| f.layout_job(job)) + }; let resp = ui.add( egui::TextEdit::multiline(&mut self.buffer) .code_editor() - .font(egui::FontId::monospace(size)) .desired_width(f32::INFINITY) - .desired_rows(30), + .desired_rows(30) + .layouter(&mut layouter), ); if resp.changed() { self.dirty = true; @@ -774,9 +1228,17 @@ impl eframe::App for App { self.save_current(); } + self.poll_lt(); + self.poll_settings_test(); + + 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) @@ -801,6 +1263,10 @@ impl eframe::App for App { } self.central(ctx); + + if self.show_settings { + self.settings_window(ctx); + } } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { @@ -811,6 +1277,59 @@ impl eframe::App for App { } } +/// Build the editor's laid-out text, underlining any grammar/spelling matches. +/// +/// `ranges` are `(start_byte, end_byte, is_spelling)` triples into `text`. +/// Spelling issues get a red underline, grammar/style issues a blue one. +fn build_editor_job( + ui: &egui::Ui, + text: &str, + size: f32, + ranges: &[(usize, usize, bool)], + wrap_width: f32, +) -> egui::text::LayoutJob { + use egui::text::{LayoutJob, TextFormat}; + let font_id = egui::FontId::monospace(size); + let color = ui.visuals().text_color(); + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + + if ranges.is_empty() { + job.append(text, 0.0, TextFormat::simple(font_id, color)); + return job; + } + + // Split the text at every match boundary, then format each run. + let mut points: Vec = vec![0, text.len()]; + for &(s, e, _) in ranges { + 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); + 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)) = + ranges.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); + } + job.append(&text[a..b], 0.0, fmt); + } + job +} + /// 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}; @@ -887,6 +1406,21 @@ fn resolve_chapter_title( .unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width)) } +/// 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, 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() @@ -960,6 +1494,46 @@ mod tests { ); } + 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 formats_thousands_separators() { assert_eq!(thousands(0), "0"); diff --git a/src/config.rs b/src/config.rs index 8aaa63b..28a072a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,42 @@ pub struct Config { /// +100 = double, -50 = half). #[serde(default)] pub editor_zoom: f32, + /// URL scheme for the LanguageTool server: `http` (local) or `https`. + #[serde(default = "default_languagetool_scheme")] + pub languagetool_scheme: String, + /// Host of the LanguageTool server — an IP address or a domain name. + #[serde(default = "default_languagetool_host")] + pub languagetool_host: String, + /// TCP port of the LanguageTool server. + #[serde(default = "default_languagetool_port")] + pub languagetool_port: u16, + /// Optional bearer token sent in the `Authorization` header (for a server + /// behind an auth proxy, or the premium API key). Empty = no auth. + #[serde(default)] + pub languagetool_token: String, + /// Language passed to LanguageTool (`auto` to detect, or a code like `en-US`). + #[serde(default = "default_languagetool_language")] + pub languagetool_language: String, +} + +/// Default scheme: plain HTTP, matching a local server. +pub fn default_languagetool_scheme() -> String { + "http".to_string() +} + +/// Default host: the local machine. +pub fn default_languagetool_host() -> String { + "localhost".to_string() +} + +/// Default port: the one the standalone LanguageTool server listens on. +pub fn default_languagetool_port() -> u16 { + 8010 +} + +/// Default checking language: let the server auto-detect. +pub fn default_languagetool_language() -> String { + "auto".to_string() } /// Default header/body separator, matching the manuscript drafting convention. @@ -42,6 +78,11 @@ impl Default for Config { draft_marker: default_marker(), zero_pad_index: false, editor_zoom: 0.0, + languagetool_scheme: default_languagetool_scheme(), + languagetool_host: default_languagetool_host(), + languagetool_port: default_languagetool_port(), + languagetool_token: String::new(), + languagetool_language: default_languagetool_language(), } } } @@ -62,6 +103,19 @@ fn config_path() -> PathBuf { } impl Config { + /// Assemble the LanguageTool base URL (`scheme://host:port`) from its parts. + pub fn languagetool_base_url(&self) -> String { + let scheme = match self.languagetool_scheme.trim() { + "" => "http", + s => s, + }; + format!( + "{scheme}://{}:{}", + self.languagetool_host.trim(), + self.languagetool_port + ) + } + pub fn load() -> Config { let path = config_path(); match std::fs::read_to_string(&path) { diff --git a/src/langtool.rs b/src/langtool.rs new file mode 100644 index 0000000..c50c105 --- /dev/null +++ b/src/langtool.rs @@ -0,0 +1,233 @@ +//! Grammar and spelling checking via a LanguageTool HTTP server. +//! +//! Talks to the `/v2/check` endpoint of a LanguageTool instance (typically a +//! local server, e.g. `http://localhost:8010`). The request is a plain +//! `application/x-www-form-urlencoded` POST; both `http` and `https` endpoints +//! are supported (the `ureq` dependency links a static rustls backend). +//! +//! LanguageTool reports match positions as UTF-16 offsets (it is a Java +//! program). We convert those to byte offsets against the exact text we sent so +//! callers can splice replacements and underline ranges directly in the buffer. + +use serde::Deserialize; +use std::time::Duration; + +/// A single grammar/spelling issue, positioned as a byte range in the checked +/// text. +#[derive(Debug, Clone)] +pub struct Match { + /// Byte offset of the issue in the checked text. + pub start: usize, + /// Byte offset one past the end of the issue. + pub end: usize, + /// Human-readable explanation of the problem. + pub message: String, + /// Suggested replacements, best first (may be empty). + pub replacements: Vec, + /// True for spelling mistakes (LanguageTool category `TYPOS`), as opposed to + /// grammar/style issues. Used to colour the underline differently. + pub spelling: bool, +} + +// ---- Wire format (subset of the LanguageTool JSON response) ---------------- + +#[derive(Deserialize)] +struct Response { + #[serde(default)] + matches: Vec, +} + +#[derive(Deserialize)] +struct WireMatch { + message: String, + offset: usize, + length: usize, + #[serde(default)] + replacements: Vec, + rule: WireRule, +} + +#[derive(Deserialize)] +struct WireReplacement { + value: String, +} + +#[derive(Deserialize)] +struct WireRule { + category: WireCategory, +} + +#[derive(Deserialize)] +struct WireCategory { + #[serde(default)] + id: String, +} + +/// Run a check against the LanguageTool server. +/// +/// `server` is the base URL (e.g. `http://localhost:8010`); `language` is a +/// LanguageTool code such as `en-US`, or `auto` to detect. A non-empty `token` +/// is sent as an `Authorization: Bearer` header (for a server behind an auth +/// proxy). Returned matches use byte offsets into `text`. +pub fn check(server: &str, language: &str, token: &str, text: &str) -> Result, String> { + let base = server.trim().trim_end_matches('/'); + if base.is_empty() || base.ends_with("//") { + return Err("No LanguageTool host set (open Settings ▸ LanguageTool)".to_string()); + } + let url = format!("{base}/v2/check"); + + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(); + + let mut request = agent.post(&url); + let token = token.trim(); + if !token.is_empty() { + request = request.set("Authorization", &format!("Bearer {token}")); + } + + let resp = request + .send_form(&[("language", language), ("text", text)]) + .map_err(|e| friendly_error(base, e))?; + + let body = resp + .into_string() + .map_err(|e| format!("Could not read LanguageTool reply: {e}"))?; + parse_response(text, &body) +} + +/// Parse a LanguageTool `/v2/check` JSON reply, converting each match to a byte +/// range in the `text` that was sent. +fn parse_response(text: &str, body: &str) -> Result, String> { + let parsed: Response = serde_json::from_str(body) + .map_err(|e| format!("Unexpected reply from LanguageTool: {e}"))?; + + let map = utf16_to_byte_map(text); + let byte_at = |u16: usize| -> usize { map.get(u16).copied().unwrap_or(text.len()) }; + + let matches = parsed + .matches + .into_iter() + .map(|m| { + let start = byte_at(m.offset); + let end = byte_at(m.offset + m.length).max(start); + Match { + start, + end, + message: m.message, + replacements: m.replacements.into_iter().map(|r| r.value).collect(), + spelling: m.rule.category.id.eq_ignore_ascii_case("TYPOS"), + } + }) + .collect(); + Ok(matches) +} + +/// Turn a `ureq` error into a message aimed at someone running a local server. +fn friendly_error(base: &str, err: ureq::Error) -> String { + match err { + ureq::Error::Status(code, _) => { + format!("LanguageTool returned HTTP {code} (is {base} a LanguageTool server?)") + } + ureq::Error::Transport(t) => { + format!("Could not reach LanguageTool at {base} — is the server running? ({t})") + } + } +} + +/// Build a lookup from UTF-16 code-unit index to byte offset in `text`. +/// +/// `map[i]` is the byte offset of the character boundary at UTF-16 unit `i`. +/// Indices that land inside a surrogate pair are clamped to the next boundary. +/// The final entry maps the end-of-string. +fn utf16_to_byte_map(text: &str) -> Vec { + let mut map: Vec = Vec::with_capacity(text.len() + 1); + let mut units = 0usize; + for (byte, ch) in text.char_indices() { + while map.len() <= units { + map.push(byte); + } + units += ch.len_utf16(); + } + while map.len() <= units { + map.push(text.len()); + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_ascii_offsets_one_to_one() { + let text = "hello world"; + let map = utf16_to_byte_map(text); + assert_eq!(map[0], 0); + assert_eq!(map[6], 6); // 'w' + assert_eq!(map[11], 11); // end + } + + #[test] + fn maps_multibyte_utf8_within_bmp() { + // "café" — 'é' is 2 bytes in UTF-8 but 1 UTF-16 unit. + let text = "café!"; + let map = utf16_to_byte_map(text); + assert_eq!(map[0], 0); // c + assert_eq!(map[3], 3); // é starts at byte 3 + assert_eq!(map[4], 5); // '!' is after the 2-byte é + assert_eq!(map[5], 6); // end + } + + #[test] + fn maps_astral_char_as_two_units() { + // "a😀b": 😀 is 4 UTF-8 bytes and 2 UTF-16 units. + let text = "a😀b"; + let map = utf16_to_byte_map(text); + assert_eq!(map[0], 0); // a + assert_eq!(map[1], 1); // 😀 starts at byte 1 + assert_eq!(map[3], 5); // b, after the 4-byte emoji + assert_eq!(map[4], 6); // end + } + + #[test] + fn parses_a_realistic_response() { + // Shape mirrors a real LanguageTool /v2/check reply. + let text = "I has a apple."; + let body = r#"{ + "matches": [ + { + "message": "Did you mean 'have'?", + "shortMessage": "Grammar", + "offset": 2, + "length": 3, + "replacements": [{"value": "have"}], + "rule": {"id": "HE_VERB_AGR", "category": {"id": "GRAMMAR", "name": "Grammar"}} + }, + { + "message": "Possible spelling mistake.", + "offset": 8, + "length": 5, + "replacements": [{"value": "apple"}, {"value": "apples"}], + "rule": {"id": "MORFOLOGIK_RULE_EN_US", "category": {"id": "TYPOS", "name": "Possible Typo"}} + } + ] + }"#; + let matches = parse_response(text, body).unwrap(); + assert_eq!(matches.len(), 2); + + assert_eq!(&text[matches[0].start..matches[0].end], "has"); + assert_eq!(matches[0].replacements, vec!["have"]); + assert!(!matches[0].spelling); + + assert_eq!(&text[matches[1].start..matches[1].end], "apple"); + assert_eq!(matches[1].replacements, vec!["apple", "apples"]); + assert!(matches[1].spelling); + } + + #[test] + fn empty_matches_is_ok() { + let matches = parse_response("clean text", r#"{"matches": []}"#).unwrap(); + assert!(matches.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 89b3f1b..d47537f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod app; mod config; mod gitsync; +mod langtool; mod odt; mod order; mod preprocess;