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:
landon
2026-07-27 08:33:00 -05:00
parent 6bd44c2ac9
commit 5ecbf3ffbb
7 changed files with 1017 additions and 2 deletions
Generated
+107
View File
@@ -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"
+4
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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");
+54
View File
@@ -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
View File
@@ -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());
}
}
+1
View File
@@ -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;