Add LanguageTool grammar/spell checking with a settings dialog
Check the current file against a LanguageTool server (typically a local instance) from a background thread. Issues are underlined in the editor (red spelling, blue grammar) and listed in a bottom panel with one-click fixes that re-align the remaining matches. A menu bar (File / View / Settings) exposes a LanguageTool settings dialog with discrete scheme / host / port / token / language fields, an endpoint preview, and a Test connection button. Config stores the parts and assembles the base URL. The token is sent as a bearer header. Uses ureq with a statically-linked rustls backend, so both http and https endpoints work with no new runtime library dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+107
@@ -452,6 +452,12 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -1852,6 +1858,7 @@ dependencies = [
|
|||||||
"rfd",
|
"rfd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"ureq",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2691,6 +2698,20 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2738,6 +2759,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -3007,6 +3063,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -3272,6 +3334,27 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -3565,6 +3648,24 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "22.1.0"
|
version = "22.1.0"
|
||||||
@@ -4393,6 +4494,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
# Native open/save dialogs via the XDG Desktop Portal (no GTK build dependency).
|
# 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"] }
|
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]
|
[profile.release]
|
||||||
opt-level = 2
|
opt-level = 2
|
||||||
|
|||||||
@@ -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
|
words added (or removed) to it since the current session started. The count
|
||||||
updates as you type, including unsaved edits.
|
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
|
## Header stripping on export
|
||||||
|
|
||||||
Manuscript files often carry editorial notes and metadata above the prose that
|
Manuscript files often carry editorial notes and metadata above the prose that
|
||||||
|
|||||||
+576
-2
@@ -6,6 +6,12 @@ use eframe::egui;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
config: Config,
|
config: Config,
|
||||||
/// Ordered markdown file names (relative to the workspace).
|
/// 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
|
/// Each file's word count captured when the workspace was opened, used as the
|
||||||
/// per-file baseline for the "this session" delta.
|
/// per-file baseline for the "this session" delta.
|
||||||
session_start_counts: HashMap<String, usize>,
|
session_start_counts: HashMap<String, usize>,
|
||||||
|
/// Grammar/spelling issues from the last LanguageTool check.
|
||||||
|
lt_matches: Vec<crate::langtool::Match>,
|
||||||
|
/// The exact buffer text the current `lt_matches` were computed against;
|
||||||
|
/// underlines and replacements only apply while the buffer still equals it.
|
||||||
|
lt_checked_text: String,
|
||||||
|
/// One-line status for the grammar checker (issue count, error, or progress).
|
||||||
|
lt_status: String,
|
||||||
|
/// Receiver for an in-flight background check, if any.
|
||||||
|
lt_rx: Option<std::sync::mpsc::Receiver<LtCheckMsg>>,
|
||||||
|
/// Whether the grammar results panel is visible.
|
||||||
|
show_lt_panel: bool,
|
||||||
|
/// Whether the LanguageTool settings window is open.
|
||||||
|
show_settings: bool,
|
||||||
|
/// Receiver for an in-flight "Test connection" from the settings window.
|
||||||
|
settings_test_rx: Option<std::sync::mpsc::Receiver<Result<usize, String>>>,
|
||||||
|
/// Result line for the settings window's "Test connection" button.
|
||||||
|
settings_test_status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -56,6 +79,14 @@ impl App {
|
|||||||
git_log: String::new(),
|
git_log: String::new(),
|
||||||
is_repo: false,
|
is_repo: false,
|
||||||
session_start_counts: HashMap::new(),
|
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.open_workspace();
|
||||||
app
|
app
|
||||||
@@ -82,6 +113,7 @@ impl App {
|
|||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
self.pending_delete = false;
|
self.pending_delete = false;
|
||||||
|
self.clear_lt();
|
||||||
self.session_start_counts = self.snapshot_counts();
|
self.session_start_counts = self.snapshot_counts();
|
||||||
if !self.files.is_empty() {
|
if !self.files.is_empty() {
|
||||||
self.select(0);
|
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) {
|
fn select(&mut self, idx: usize) {
|
||||||
if self.selected == Some(idx) {
|
if self.selected == Some(idx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.save_current();
|
self.save_current();
|
||||||
|
self.clear_lt();
|
||||||
if let Some(name) = self.files.get(idx) {
|
if let Some(name) = self.files.get(idx) {
|
||||||
let path = self.path_for(name);
|
let path = self.path_for(name);
|
||||||
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
|
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) {
|
fn reorder(&mut self, from: usize, mut to: usize) {
|
||||||
if from >= self.files.len() || from == to {
|
if from >= self.files.len() || from == to {
|
||||||
return;
|
return;
|
||||||
@@ -495,6 +664,39 @@ impl App {
|
|||||||
self.config.save();
|
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);
|
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) {
|
fn central(&mut self, ctx: &egui::Context) {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
match self.selected {
|
match self.selected {
|
||||||
@@ -748,16 +1187,31 @@ impl App {
|
|||||||
.unwrap_or(12.0);
|
.unwrap_or(12.0);
|
||||||
let size = (base * (1.0 + self.config.editor_zoom / 100.0)).max(4.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()
|
egui::ScrollArea::vertical()
|
||||||
.id_salt("editor")
|
.id_salt("editor")
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.show(ui, |ui| {
|
.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(
|
let resp = ui.add(
|
||||||
egui::TextEdit::multiline(&mut self.buffer)
|
egui::TextEdit::multiline(&mut self.buffer)
|
||||||
.code_editor()
|
.code_editor()
|
||||||
.font(egui::FontId::monospace(size))
|
|
||||||
.desired_width(f32::INFINITY)
|
.desired_width(f32::INFINITY)
|
||||||
.desired_rows(30),
|
.desired_rows(30)
|
||||||
|
.layouter(&mut layouter),
|
||||||
);
|
);
|
||||||
if resp.changed() {
|
if resp.changed() {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
@@ -774,9 +1228,17 @@ impl eframe::App for App {
|
|||||||
self.save_current();
|
self.save_current();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.poll_lt();
|
||||||
|
self.poll_settings_test();
|
||||||
|
|
||||||
|
self.menu_bar(ctx);
|
||||||
self.top_bar(ctx);
|
self.top_bar(ctx);
|
||||||
self.left_pane(ctx);
|
self.left_pane(ctx);
|
||||||
|
|
||||||
|
if self.show_lt_panel {
|
||||||
|
self.lt_panel(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if self.show_log {
|
if self.show_log {
|
||||||
egui::TopBottomPanel::bottom("gitlog")
|
egui::TopBottomPanel::bottom("gitlog")
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
@@ -801,6 +1263,10 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.central(ctx);
|
self.central(ctx);
|
||||||
|
|
||||||
|
if self.show_settings {
|
||||||
|
self.settings_window(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
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<usize> = 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).
|
/// Very small markdown-ish preview (headings emphasised, everything else plain).
|
||||||
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
fn render_preview(ui: &mut egui::Ui, markdown: &str) {
|
||||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
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))
|
.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<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.
|
/// Count words in a string: runs of non-whitespace separated by whitespace.
|
||||||
fn count_words(s: &str) -> usize {
|
fn count_words(s: &str) -> usize {
|
||||||
s.split_whitespace().count()
|
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]
|
#[test]
|
||||||
fn formats_thousands_separators() {
|
fn formats_thousands_separators() {
|
||||||
assert_eq!(thousands(0), "0");
|
assert_eq!(thousands(0), "0");
|
||||||
|
|||||||
@@ -24,6 +24,42 @@ pub struct Config {
|
|||||||
/// +100 = double, -50 = half).
|
/// +100 = double, -50 = half).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub editor_zoom: f32,
|
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.
|
/// Default header/body separator, matching the manuscript drafting convention.
|
||||||
@@ -42,6 +78,11 @@ impl Default for Config {
|
|||||||
draft_marker: default_marker(),
|
draft_marker: default_marker(),
|
||||||
zero_pad_index: false,
|
zero_pad_index: false,
|
||||||
editor_zoom: 0.0,
|
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 {
|
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 {
|
pub fn load() -> Config {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
match std::fs::read_to_string(&path) {
|
match std::fs::read_to_string(&path) {
|
||||||
|
|||||||
+233
@@ -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<String>,
|
||||||
|
/// 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<WireMatch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WireMatch {
|
||||||
|
message: String,
|
||||||
|
offset: usize,
|
||||||
|
length: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
replacements: Vec<WireReplacement>,
|
||||||
|
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<Vec<Match>, 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<Vec<Match>, 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<usize> {
|
||||||
|
let mut map: Vec<usize> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
mod gitsync;
|
mod gitsync;
|
||||||
|
mod langtool;
|
||||||
mod odt;
|
mod odt;
|
||||||
mod order;
|
mod order;
|
||||||
mod preprocess;
|
mod preprocess;
|
||||||
|
|||||||
Reference in New Issue
Block a user