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"
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"
+4
View File
@@ -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
+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
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
+576 -2
View File
@@ -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<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 {
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<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 {
@@ -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<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).
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<crate::langtool::Match>, s: usize, e: usize, new_len: usize) {
matches.retain(|o| o.end <= s || o.start >= e);
let delta = new_len as i64 - (e - s) as i64;
for o in matches.iter_mut() {
if o.start >= e {
o.start = (o.start as i64 + delta) as usize;
o.end = (o.end as i64 + delta) as usize;
}
}
}
/// Count words in a string: runs of non-whitespace separated by whitespace.
fn count_words(s: &str) -> usize {
s.split_whitespace().count()
@@ -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");
+54
View File
@@ -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) {
+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 config;
mod gitsync;
mod langtool;
mod odt;
mod order;
mod preprocess;