use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "hyprcorrect", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
FixWord,
FixSentence,
Review,
Prefs,
InstallDesktop,
}
fn main() {
env_logger::init();
match Cli::parse().command {
None => run_daemon(),
Some(Command::FixWord) => {
eprintln!(
"hyprcorrect: run `hyprcorrect` with no subcommand — the daemon \
corrects the last word when you press the trigger chord"
);
}
Some(Command::FixSentence) => not_yet("fix-sentence as a CLI subcommand", "M5"),
Some(Command::Review) => hyprcorrect_ui::run_review(),
Some(Command::Prefs) => hyprcorrect_ui::run_preferences(),
Some(Command::InstallDesktop) => run_install_desktop(),
}
}
#[cfg(target_os = "linux")]
fn run_install_desktop() {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("hyprcorrect: could not locate the running binary: {e}");
std::process::exit(1);
}
};
let icon = match hyprcorrect_ui::autostart::ensure_user_icon() {
Ok(p) => p,
Err(e) => {
eprintln!("hyprcorrect: could not install app icon: {e}");
std::process::exit(1);
}
};
let entry = match hyprcorrect_ui::autostart::ensure_apps_catalog_entry(&exe.to_string_lossy()) {
Ok(p) => p,
Err(e) => {
eprintln!("hyprcorrect: could not install desktop entry: {e}");
std::process::exit(1);
}
};
hyprcorrect_ui::autostart::mark_install_done();
println!("Installed hyprcorrect desktop integration:");
if let Some(icon) = icon {
println!(" icon {}", icon.display());
}
if let Some(entry) = entry {
println!(" desktop entry {}", entry.display());
}
println!();
println!("hyprcorrect should now appear in your application launcher.");
}
#[cfg(not(target_os = "linux"))]
fn run_install_desktop() {
eprintln!(
"hyprcorrect: install-desktop writes XDG `.desktop` + icon files \
and is Linux-only so far — macOS support is milestone M2."
);
}
#[cfg(target_os = "linux")]
fn run_daemon() {
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use hyprcorrect_core::{Buffer, Chord, Config, OfflineProvider};
use hyprcorrect_platform::linux::{capture, chord_capture, focus, hotkey, tray};
if std::os::unix::net::UnixStream::connect(hyprcorrect_core::runtime::chord_socket_path())
.is_ok()
{
eprintln!(
"hyprcorrect: another daemon instance is already running — exiting. \
Open Preferences with `hyprcorrect prefs`."
);
return;
}
let initial_config = Config::load().unwrap_or_else(|e| {
eprintln!("hyprcorrect: could not load config ({e}) — using defaults");
Config::default()
});
let mut llm = build_llm(&initial_config);
let mut languagetool = build_languagetool(&initial_config);
let mut default_provider_id = initial_config.providers.default;
let mut smart_provider_id = initial_config.providers.smart;
let mut pause_per_backspace_ms = initial_config.behavior.pause_per_backspace_ms;
let mut lt_fallback = initial_config.behavior.fallback_to_languagetool;
capture::set_reset_keys(reset_key_config(&initial_config.behavior.reset_keys));
let mut chord = match effective_chord(&initial_config) {
Ok(c) => c,
Err(e) => {
eprintln!("hyprcorrect: invalid chord in config ({e}) — falling back to default");
Chord::parse("SUPER+CTRL+SHIFT+ALT+F").expect("default chord parses")
}
};
let mut sentence_chord = parse_optional_chord(&initial_config.hotkeys.fix_sentence);
let mut review_chord = parse_optional_chord(&initial_config.hotkeys.review);
let mut review_llm_chord = parse_optional_chord(&initial_config.hotkeys.review_llm);
let mut blocklist = build_blocklist(&initial_config);
let paused = Arc::new(AtomicBool::new(false));
if let Err(e) = hyprcorrect_core::runtime::write_self_pid() {
eprintln!("hyprcorrect: could not write PID file ({e}) — prefs reload won't work");
}
if let Ok(exe) = std::env::current_exe() {
hyprcorrect_ui::autostart::ensure_first_launch(&exe.to_string_lossy());
}
install_window_rules();
let provider = match OfflineProvider::en_us() {
Ok(provider) => provider,
Err(e) => {
eprintln!("hyprcorrect: {e}");
return;
}
};
let chord_slot = chord_capture::ChordCaptureSlot::new();
if let Err(e) = chord_capture::start_listener(chord_slot.clone()) {
eprintln!(
"hyprcorrect: could not start chord-capture listener ({e}) — prefs won't be able to record SUPER chords"
);
}
let key_rx = match capture::start(
&active_chords(&chord, &sentence_chord, &review_chord, &review_llm_chord),
chord_slot.clone(),
) {
Ok(rx) => rx,
Err(e) => {
eprintln!("hyprcorrect: {e}");
return;
}
};
let signal_rx = match hotkey::signal_channel() {
Ok(rx) => rx,
Err(e) => {
eprintln!("hyprcorrect: {e}");
return;
}
};
if let Err(e) = hotkey::install_bind(&chord, "word") {
eprintln!("hyprcorrect: {e}");
return;
}
if let Some(ref sc) = sentence_chord
&& let Err(e) = hotkey::install_bind(sc, "sentence")
{
eprintln!("hyprcorrect: sentence bind failed: {e}");
sentence_chord = None;
}
if let Some(ref rc) = review_chord
&& let Err(e) = hotkey::install_bind(rc, "review")
{
eprintln!("hyprcorrect: review bind failed: {e}");
review_chord = None;
}
if let Some(ref lc) = review_llm_chord
&& let Err(e) = hotkey::install_bind(lc, "review-llm")
{
eprintln!("hyprcorrect: review-llm bind failed: {e}");
review_llm_chord = None;
}
let (initial_window, focus_rx) = match focus::start() {
Ok(pair) => pair,
Err(e) => {
eprintln!("hyprcorrect: {e}");
let _ = hotkey::uninstall_bind(&chord);
if let Some(ref sc) = sentence_chord {
let _ = hotkey::uninstall_bind(sc);
}
return;
}
};
let (tray_handle, tray_rx) = match tray::start(
paused.clone(),
build_tray_pixmaps(false),
build_tray_pixmaps(true),
) {
Ok(pair) => pair,
Err(e) => {
eprintln!("hyprcorrect: {e}");
let _ = hotkey::uninstall_bind(&chord);
if let Some(ref sc) = sentence_chord {
let _ = hotkey::uninstall_bind(sc);
}
return;
}
};
println!(
"hyprcorrect {} — running. Press {chord} to correct the last word; \
quit from the tray menu.",
hyprcorrect_core::version(),
);
enum DaemonEvent {
Key(hyprcorrect_core::Key),
Signal(hotkey::HotkeyEvent),
Focus(focus::FocusEvent),
Tray(tray::TrayEvent),
}
let (tx, rx) = mpsc::channel::<DaemonEvent>();
{
let tx = tx.clone();
thread::spawn(move || {
while let Ok(key) = key_rx.recv() {
if tx.send(DaemonEvent::Key(key)).is_err() {
break;
}
}
});
}
{
let tx = tx.clone();
thread::spawn(move || {
while let Ok(event) = signal_rx.recv() {
if tx.send(DaemonEvent::Signal(event)).is_err() {
break;
}
}
});
}
{
let tx = tx.clone();
thread::spawn(move || {
while let Ok(event) = focus_rx.recv() {
if tx.send(DaemonEvent::Focus(event)).is_err() {
break;
}
}
});
}
{
let tx = tx.clone();
thread::spawn(move || {
while let Ok(event) = tray_rx.recv() {
if tx.send(DaemonEvent::Tray(event)).is_err() {
break;
}
}
});
}
drop(tx);
let mut buffers: HashMap<String, Buffer> = HashMap::new();
let mut current_address: Option<String> = initial_window.as_ref().map(|f| f.address.clone());
let mut current_blocked = initial_window
.as_ref()
.is_some_and(|f| blocklist.contains(&f.class.to_ascii_lowercase()));
for event in rx {
match event {
DaemonEvent::Key(key) => {
if !paused.load(Ordering::Relaxed)
&& !current_blocked
&& let Some(addr) = current_address.as_deref()
{
buffers.entry(addr.to_string()).or_default().push(key);
}
if matches!(key, hyprcorrect_core::Key::Reset) {
capture::caret_suspect_flag().store(false, Ordering::Relaxed);
}
}
DaemonEvent::Signal(hotkey::HotkeyEvent::Trigger) => {
let action = hyprcorrect_core::runtime::read_action();
match action.as_str() {
"review-apply" => apply_review(&mut buffers, pause_per_backspace_ms),
"review-cancel" => {
hyprcorrect_core::runtime::clear_review();
}
"review-llm" => reprocess_review_with_llm(
llm.as_ref(),
languagetool.as_ref(),
&provider,
lt_fallback,
),
_ => {
if !paused.load(Ordering::Relaxed)
&& !current_blocked
&& let Some(addr) = current_address.as_deref()
&& let Some(buffer) = buffers.get_mut(addr)
{
match action.as_str() {
"review" => start_review(
addr,
buffer,
&provider,
smart_provider_id,
llm.as_ref(),
languagetool.as_ref(),
lt_fallback,
),
"sentence" => fix_last_sentence(
buffer,
&provider,
smart_provider_id,
llm.as_ref(),
languagetool.as_ref(),
pause_per_backspace_ms,
lt_fallback,
),
_ => fix_last_word(
buffer,
default_provider_id,
llm.as_ref(),
languagetool.as_ref(),
&provider,
pause_per_backspace_ms,
lt_fallback,
),
}
}
}
}
}
DaemonEvent::Signal(hotkey::HotkeyEvent::Reload) => {
match Config::load() {
Ok(new_config) => match effective_chord(&new_config) {
Ok(new_chord) => {
if new_chord != chord {
let _ = hotkey::uninstall_bind(&chord);
eprintln!(
"hyprcorrect: trigger chord changed: {chord} → {new_chord}"
);
chord = new_chord;
}
if let Err(e) = hotkey::install_bind(&chord, "word") {
eprintln!("hyprcorrect: rebind failed: {e}");
}
let new_sentence_chord =
parse_optional_chord(&new_config.hotkeys.fix_sentence);
if let Some(ref old) = sentence_chord
&& new_sentence_chord.as_ref() != Some(old)
{
let _ = hotkey::uninstall_bind(old);
}
if let Some(ref sc) = new_sentence_chord
&& let Err(e) = hotkey::install_bind(sc, "sentence")
{
eprintln!("hyprcorrect: sentence rebind failed: {e}");
}
sentence_chord = new_sentence_chord;
let new_review_chord = parse_optional_chord(&new_config.hotkeys.review);
if let Some(ref old) = review_chord
&& new_review_chord.as_ref() != Some(old)
{
let _ = hotkey::uninstall_bind(old);
}
if let Some(ref rc) = new_review_chord
&& let Err(e) = hotkey::install_bind(rc, "review")
{
eprintln!("hyprcorrect: review rebind failed: {e}");
}
review_chord = new_review_chord;
let new_review_llm_chord =
parse_optional_chord(&new_config.hotkeys.review_llm);
if let Some(ref old) = review_llm_chord
&& new_review_llm_chord.as_ref() != Some(old)
{
let _ = hotkey::uninstall_bind(old);
}
if let Some(ref lc) = new_review_llm_chord
&& let Err(e) = hotkey::install_bind(lc, "review-llm")
{
eprintln!("hyprcorrect: review-llm rebind failed: {e}");
}
review_llm_chord = new_review_llm_chord;
blocklist = build_blocklist(&new_config);
llm = build_llm(&new_config);
languagetool = build_languagetool(&new_config);
default_provider_id = new_config.providers.default;
smart_provider_id = new_config.providers.smart;
pause_per_backspace_ms = new_config.behavior.pause_per_backspace_ms;
lt_fallback = new_config.behavior.fallback_to_languagetool;
capture::set_reset_keys(reset_key_config(
&new_config.behavior.reset_keys,
));
eprintln!("hyprcorrect: config reloaded");
}
Err(e) => {
eprintln!("hyprcorrect: bad chord in new config ({e}) — kept old");
}
},
Err(e) => eprintln!("hyprcorrect: reload failed: {e}"),
}
}
DaemonEvent::Signal(hotkey::HotkeyEvent::Release) => {
let _ = hotkey::uninstall_bind(&chord);
if let Some(ref sc) = sentence_chord {
let _ = hotkey::uninstall_bind(sc);
}
if let Some(ref rc) = review_chord {
let _ = hotkey::uninstall_bind(rc);
}
if let Some(ref lc) = review_llm_chord {
let _ = hotkey::uninstall_bind(lc);
}
eprintln!("hyprcorrect: trigger released for capture");
}
DaemonEvent::Signal(hotkey::HotkeyEvent::Shutdown) => break,
DaemonEvent::Focus(focus::FocusEvent::Focused { address, class }) => {
current_blocked = blocklist.contains(&class.to_ascii_lowercase());
current_address = Some(address);
}
DaemonEvent::Focus(focus::FocusEvent::Closed { address }) => {
buffers.remove(&address);
if current_address.as_deref() == Some(address.as_str()) {
current_address = None;
current_blocked = false;
}
}
DaemonEvent::Tray(tray::TrayEvent::TogglePause) => {
let was_paused = paused.fetch_xor(true, Ordering::Relaxed);
tray_handle.refresh();
eprintln!(
"hyprcorrect: {}",
if was_paused { "resumed" } else { "paused" }
);
}
DaemonEvent::Tray(tray::TrayEvent::OpenPrefs) => {
spawn_prefs_window();
}
DaemonEvent::Tray(tray::TrayEvent::Quit) => break,
}
}
drop(tray_handle);
let _ = hotkey::uninstall_bind(&chord);
if let Some(ref sc) = sentence_chord {
let _ = hotkey::uninstall_bind(sc);
}
if let Some(ref rc) = review_chord {
let _ = hotkey::uninstall_bind(rc);
}
if let Some(ref lc) = review_llm_chord {
let _ = hotkey::uninstall_bind(lc);
}
hyprcorrect_core::runtime::clear_pid();
hyprcorrect_core::runtime::clear_review();
}
#[cfg(target_os = "linux")]
fn effective_chord(
config: &hyprcorrect_core::Config,
) -> Result<hyprcorrect_core::Chord, hyprcorrect_core::ChordError> {
let raw =
std::env::var("HYPRCORRECT_CHORD").unwrap_or_else(|_| config.hotkeys.fix_word.clone());
hyprcorrect_core::Chord::parse(&raw)
}
#[cfg(target_os = "linux")]
fn build_blocklist(config: &hyprcorrect_core::Config) -> std::collections::HashSet<String> {
config
.privacy
.app_blocklist
.iter()
.map(|c| c.to_ascii_lowercase())
.collect()
}
#[cfg(target_os = "linux")]
fn reset_key_config(
rk: &hyprcorrect_core::ResetKeys,
) -> hyprcorrect_platform::linux::capture::ResetKeyConfig {
hyprcorrect_platform::linux::capture::ResetKeyConfig {
enter: rk.enter,
tab: rk.tab,
escape: rk.escape,
up: rk.up,
down: rk.down,
page_up: rk.page_up,
page_down: rk.page_down,
delete: rk.delete,
insert: rk.insert,
}
}
#[cfg(target_os = "linux")]
fn spawn_prefs_window() {
spawn_prefs_window_section(None);
}
fn spawn_prefs_window_section(section: Option<&str>) {
use std::process::{Command, Stdio};
let Ok(exe) = std::env::current_exe() else {
eprintln!("hyprcorrect: cannot find own executable to launch prefs");
return;
};
let mut cmd = Command::new(&exe);
cmd.arg("prefs");
if let Some(section) = section {
cmd.env("HYPRCORRECT_PREFS_SECTION", section);
}
let result = cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
if let Err(e) = result {
eprintln!("hyprcorrect: could not launch prefs window: {e}");
}
}
#[cfg(target_os = "linux")]
fn active_chords(
word: &hyprcorrect_core::Chord,
sentence: &Option<hyprcorrect_core::Chord>,
review: &Option<hyprcorrect_core::Chord>,
review_llm: &Option<hyprcorrect_core::Chord>,
) -> Vec<hyprcorrect_core::Chord> {
let mut out = vec![word.clone()];
for c in [sentence, review, review_llm].into_iter().flatten() {
out.push(c.clone());
}
out
}
#[cfg(target_os = "linux")]
fn parse_optional_chord(raw: &str) -> Option<hyprcorrect_core::Chord> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
match hyprcorrect_core::Chord::parse(trimmed) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("hyprcorrect: ignoring invalid chord '{trimmed}': {e}");
None
}
}
}
#[cfg(target_os = "linux")]
fn fix_last_word(
buffer: &mut hyprcorrect_core::Buffer,
default: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
provider: &hyprcorrect_core::OfflineProvider,
pause_per_backspace_ms: u32,
lt_fallback: bool,
) {
use std::sync::atomic::Ordering;
use hyprcorrect_platform::linux::emit;
let Some(at) = buffer.word_at_caret() else {
eprintln!("hyprcorrect: word-fix — buffer has no word at caret, trying clipboard fallback");
fix_via_clipboard(provider);
return;
};
eprintln!(
"hyprcorrect: word-fix on {:?} (before={}, after={}; default={default:?})",
at.word, at.chars_before_caret, at.chars_after_caret,
);
let bracketed = format_word_with_caret(&at.word, at.chars_before_caret, at.chars_after_caret);
let Some(plan) = pick_word_fix(
buffer,
&at,
&bracketed,
default,
llm,
languagetool,
provider,
lt_fallback,
) else {
return;
};
let word_chars = plan.original.chars().count();
let chars_from_end = buffer.text()[plan.byte_end..].chars().count();
eprintln!(
"hyprcorrect: word-fix emit — chars_from_end={chars_from_end}, backspace {word_chars} chars, insert {:?}",
plan.fix
);
match emit::anchored_replace_with_delay(
chars_from_end,
word_chars,
&plan.fix,
pause_per_backspace_ms,
) {
Ok(()) => {
buffer.apply_at_word(plan.byte_start, plan.byte_end, &plan.fix);
hyprcorrect_platform::linux::capture::caret_suspect_flag()
.store(false, Ordering::Relaxed);
notify_info(
&format!("Corrected ({})", provider_label(plan.provider)),
&format!("{} → \"{}\"", plan.label, plan.fix),
);
}
Err(e) => eprintln!("hyprcorrect: {e}"),
}
}
#[cfg(target_os = "linux")]
struct WordFixPlan {
original: String,
fix: String,
byte_start: usize,
byte_end: usize,
label: String,
provider: hyprcorrect_core::ProviderId,
}
#[cfg(target_os = "linux")]
const NEARBY_WORD_MAX_CHARS: i32 = 30;
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)] fn pick_word_fix(
buffer: &hyprcorrect_core::Buffer,
at: &hyprcorrect_core::WordAtCaret,
bracketed: &str,
default: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
spell: &hyprcorrect_core::OfflineProvider,
lt_fallback: bool,
) -> Option<WordFixPlan> {
use std::sync::atomic::Ordering;
use hyprcorrect_core::ProviderId;
use hyprcorrect_platform::linux::capture;
let sentence = buffer
.sentence_at_caret()
.map(|s| s.sentence)
.unwrap_or_else(|| at.word.clone());
let use_llm = default == ProviderId::Llm && llm.is_some();
let primary = primary_target(buffer, at);
let caret_suspect = capture::caret_suspect_flag().load(Ordering::Relaxed);
let max_chars = if caret_suspect {
i32::MAX
} else {
NEARBY_WORD_MAX_CHARS
};
if caret_suspect {
eprintln!(
"hyprcorrect: word-fix — caret suspect (recent click); scan widened to whole buffer"
);
}
let mut run_lt = false;
if use_llm {
let llm = llm.expect("checked above");
match llm.fix_word_in_context(&sentence, &at.word) {
Ok(corrected) => {
let corrected = corrected.trim().to_string();
if !corrected.is_empty() && corrected != at.word {
eprintln!(
"hyprcorrect: word-fix — LLM picked {:?} for {:?}",
corrected, at.word
);
return Some(plan_for(&primary, corrected, bracketed, ProviderId::Llm));
}
eprintln!(
"hyprcorrect: word-fix — LLM left {:?} unchanged, scanning nearby",
at.word
);
if let Some(plan) = scan_nearby_llm(buffer, &sentence, &at.word, llm, max_chars) {
return Some(plan);
}
notify_info(
"Nothing to correct",
&format!("LLM thinks {bracketed} (and nearby) are fine in context."),
);
return None;
}
Err(e) => {
if lt_fallback && languagetool.is_some() {
eprintln!(
"hyprcorrect: word-fix LLM failed ({e}) — trying LanguageTool fallback"
);
run_lt = true;
} else {
eprintln!("hyprcorrect: word-fix LLM failed ({e}) — falling back to spellbook");
notify_warning(
"LLM unavailable",
&format!("Falling back to Spellbook for {bracketed}."),
);
}
}
}
} else if default == ProviderId::Llm {
if lt_fallback && languagetool.is_some() {
eprintln!(
"hyprcorrect: word-fix — default provider is LLM but no key configured; trying LanguageTool fallback"
);
run_lt = true;
} else {
eprintln!(
"hyprcorrect: word-fix — default provider is LLM but no key configured; falling back to spellbook"
);
notify_warning(
"LLM API key not set",
"Open Preferences → Providers → LLM and add your API key.",
);
}
} else if default == ProviderId::LanguageTool {
if languagetool.is_some() {
run_lt = true;
} else {
eprintln!(
"hyprcorrect: word-fix — default provider is LanguageTool but it isn't configured; falling back to spellbook"
);
notify_warning(
"LanguageTool not configured",
"Open Preferences → Providers → LanguageTool, enable it, and set the URL.",
);
}
}
if run_lt && let Some(lt) = languagetool {
match try_languagetool_word_fix(buffer, at, &sentence, &primary, bracketed, lt, max_chars) {
LtWordOutcome::Fixed(plan) => return Some(plan),
LtWordOutcome::NothingFound => {
notify_info(
"Nothing to correct",
&format!("LanguageTool thinks {bracketed} (and nearby) are fine."),
);
return None;
}
LtWordOutcome::Failed => {
notify_warning(
"LanguageTool unavailable",
&format!("Falling back to Spellbook for {bracketed}."),
);
}
}
}
if let Some(fix) = spellbook_pick(spell, &at.word) {
return Some(plan_for(&primary, fix, bracketed, ProviderId::Spellbook));
}
eprintln!(
"hyprcorrect: word-fix — spellbook found no error in {:?}, scanning nearby",
at.word
);
if let Some(plan) = scan_nearby_spellbook(buffer, &at.word, spell, max_chars) {
return Some(plan);
}
notify_warning(
"Nothing to correct",
&format!("Spellbook didn't find an error in {bracketed} or nearby."),
);
None
}
#[cfg(target_os = "linux")]
enum LtWordOutcome {
Fixed(WordFixPlan),
NothingFound,
Failed,
}
#[cfg(target_os = "linux")]
fn try_languagetool_word_fix(
buffer: &hyprcorrect_core::Buffer,
at: &hyprcorrect_core::WordAtCaret,
sentence: &str,
primary: &PrimaryTarget,
bracketed: &str,
lt: &hyprcorrect_core::LanguageToolProvider,
max_chars: i32,
) -> LtWordOutcome {
use hyprcorrect_core::ProviderId;
let primary_sentence = buffer.sentence_containing(buffer.caret());
let target_in_sentence = primary_sentence
.as_ref()
.and_then(|s| word_in_sentence_bytes(s, primary.byte_start, primary.byte_end));
let mut sentence_cache = SentenceCache::new();
match lt.check_text(sentence) {
Ok(corrections) => {
if let Some(s) = primary_sentence.as_ref() {
sentence_cache.seed(s.buffer_byte_start, corrections.clone());
}
let pick = match &target_in_sentence {
Some(range) => first_overlap_suggestion(&corrections, range),
None => corrections
.iter()
.find_map(|c| c.suggestions.first().cloned()),
};
if let Some(fix) = pick
&& fix != at.word
{
eprintln!(
"hyprcorrect: word-fix — LT picked {:?} for {:?} (with sentence context)",
fix, at.word
);
return LtWordOutcome::Fixed(plan_for(
primary,
fix,
bracketed,
ProviderId::LanguageTool,
));
}
eprintln!(
"hyprcorrect: word-fix — LT left {:?} unchanged, scanning nearby",
at.word
);
match scan_nearby_lt(buffer, &at.word, lt, max_chars, &mut sentence_cache) {
Some(plan) => LtWordOutcome::Fixed(plan),
None => LtWordOutcome::NothingFound,
}
}
Err(e) => {
eprintln!("hyprcorrect: word-fix LT failed ({e})");
LtWordOutcome::Failed
}
}
}
#[cfg(target_os = "linux")]
fn spellbook_pick(spell: &hyprcorrect_core::OfflineProvider, word: &str) -> Option<String> {
let correction = spell.check_text(word).into_iter().next()?;
correction.suggestions.into_iter().next()
}
#[cfg(target_os = "linux")]
fn scan_nearby_spellbook(
buffer: &hyprcorrect_core::Buffer,
primary_word: &str,
spell: &hyprcorrect_core::OfflineProvider,
max_chars: i32,
) -> Option<WordFixPlan> {
for nw in buffer.words_near_caret() {
if nw.word == primary_word && nw.caret_offset_chars.abs() <= 1 {
continue;
}
if nw.caret_offset_chars.abs() > max_chars {
break; }
if let Some(fix) = spellbook_pick(spell, &nw.word) {
eprintln!(
"hyprcorrect: word-fix — nearby spellbook hit {:?} → {:?} (offset {})",
nw.word, fix, nw.caret_offset_chars
);
return Some(WordFixPlan {
original: nw.word.clone(),
fix,
byte_start: nw.byte_start,
byte_end: nw.byte_end,
label: nw.word,
provider: hyprcorrect_core::ProviderId::Spellbook,
});
}
}
None
}
#[cfg(target_os = "linux")]
fn scan_nearby_llm(
buffer: &hyprcorrect_core::Buffer,
sentence: &str,
primary_word: &str,
llm: &hyprcorrect_core::LlmProvider,
max_chars: i32,
) -> Option<WordFixPlan> {
const MAX_NEARBY_LLM_CALLS: usize = 4;
let mut calls = 0;
for nw in buffer.words_near_caret() {
if nw.word == primary_word && nw.caret_offset_chars.abs() <= 1 {
continue;
}
if nw.caret_offset_chars.abs() > max_chars {
break;
}
if calls >= MAX_NEARBY_LLM_CALLS {
break;
}
calls += 1;
match llm.fix_word_in_context(sentence, &nw.word) {
Ok(corrected) => {
let corrected = corrected.trim().to_string();
if corrected.is_empty() || corrected == nw.word {
continue;
}
eprintln!(
"hyprcorrect: word-fix — nearby LLM hit {:?} → {:?} (offset {})",
nw.word, corrected, nw.caret_offset_chars
);
return Some(WordFixPlan {
original: nw.word.clone(),
fix: corrected,
byte_start: nw.byte_start,
byte_end: nw.byte_end,
label: nw.word,
provider: hyprcorrect_core::ProviderId::Llm,
});
}
Err(e) => {
eprintln!("hyprcorrect: word-fix nearby LLM call failed ({e}) — stopping scan");
return None;
}
}
}
None
}
#[cfg(target_os = "linux")]
fn scan_nearby_lt(
buffer: &hyprcorrect_core::Buffer,
primary_word: &str,
lt: &hyprcorrect_core::LanguageToolProvider,
max_chars: i32,
cache: &mut SentenceCache,
) -> Option<WordFixPlan> {
const MAX_NEARBY_LT_SENTENCES: usize = 2;
for nw in buffer.words_near_caret() {
if nw.word == primary_word && nw.caret_offset_chars.abs() <= 1 {
continue;
}
if nw.caret_offset_chars.abs() > max_chars {
break;
}
let Some(sentence) = buffer.sentence_containing(nw.byte_start) else {
continue;
};
if !cache.has(sentence.buffer_byte_start)
&& cache.sentences_fetched() >= MAX_NEARBY_LT_SENTENCES
{
break;
}
let corrections = match cache.get_or_fetch(&sentence, lt) {
Ok(cs) => cs,
Err(e) => {
eprintln!("hyprcorrect: word-fix nearby LT call failed ({e}) — stopping scan");
return None;
}
};
let Some(target) = word_in_sentence_bytes(&sentence, nw.byte_start, nw.byte_end) else {
continue;
};
if let Some(fix) = first_overlap_suggestion(corrections, &target)
&& fix != nw.word
{
eprintln!(
"hyprcorrect: word-fix — nearby LT hit {:?} → {:?} (offset {}, with context)",
nw.word, fix, nw.caret_offset_chars
);
return Some(WordFixPlan {
original: nw.word.clone(),
fix,
byte_start: nw.byte_start,
byte_end: nw.byte_end,
label: nw.word,
provider: hyprcorrect_core::ProviderId::LanguageTool,
});
}
}
None
}
#[cfg(target_os = "linux")]
struct SentenceCache {
entries: std::collections::HashMap<usize, Vec<hyprcorrect_core::Correction>>,
fetched: usize,
}
#[cfg(target_os = "linux")]
impl SentenceCache {
fn new() -> Self {
Self {
entries: std::collections::HashMap::new(),
fetched: 0,
}
}
fn seed(&mut self, buffer_byte_start: usize, corrections: Vec<hyprcorrect_core::Correction>) {
if self
.entries
.insert(buffer_byte_start, corrections)
.is_none()
{
self.fetched += 1;
}
}
fn has(&self, buffer_byte_start: usize) -> bool {
self.entries.contains_key(&buffer_byte_start)
}
fn sentences_fetched(&self) -> usize {
self.fetched
}
fn get_or_fetch(
&mut self,
sentence: &hyprcorrect_core::Sentence,
lt: &hyprcorrect_core::LanguageToolProvider,
) -> Result<&Vec<hyprcorrect_core::Correction>, hyprcorrect_core::LanguageToolError> {
use std::collections::hash_map::Entry;
let key = sentence.buffer_byte_start;
match self.entries.entry(key) {
Entry::Occupied(e) => Ok(e.into_mut()),
Entry::Vacant(e) => {
let cs = lt.check_text(&sentence.sentence)?;
self.fetched += 1;
Ok(e.insert(cs))
}
}
}
}
#[cfg(target_os = "linux")]
fn word_in_sentence_bytes(
sentence: &hyprcorrect_core::Sentence,
buffer_start: usize,
buffer_end: usize,
) -> Option<std::ops::Range<usize>> {
if buffer_start < sentence.buffer_byte_start || buffer_end > sentence.buffer_byte_end {
return None;
}
Some((buffer_start - sentence.buffer_byte_start)..(buffer_end - sentence.buffer_byte_start))
}
#[cfg(target_os = "linux")]
struct PrimaryTarget {
original: String,
byte_start: usize,
byte_end: usize,
}
#[cfg(target_os = "linux")]
fn primary_target(
buffer: &hyprcorrect_core::Buffer,
at: &hyprcorrect_core::WordAtCaret,
) -> PrimaryTarget {
let caret = buffer.caret();
let text = buffer.text();
let trailing_chars = at.trailing.chars().count();
let byte_start = char_step_left(text, caret, at.chars_before_caret + trailing_chars);
let byte_end = byte_start + at.word.len();
PrimaryTarget {
original: at.word.clone(),
byte_start,
byte_end,
}
}
#[cfg(target_os = "linux")]
fn first_overlap_suggestion(
corrections: &[hyprcorrect_core::Correction],
target: &std::ops::Range<usize>,
) -> Option<String> {
corrections.iter().find_map(|c| {
let overlaps = c.span.start < target.end && target.start < c.span.end;
if overlaps {
c.suggestions.first().cloned()
} else {
None
}
})
}
#[cfg(target_os = "linux")]
fn plan_for(
primary: &PrimaryTarget,
fix: String,
bracketed: &str,
provider: hyprcorrect_core::ProviderId,
) -> WordFixPlan {
WordFixPlan {
original: primary.original.clone(),
fix,
byte_start: primary.byte_start,
byte_end: primary.byte_end,
label: bracketed.to_string(),
provider,
}
}
#[cfg(target_os = "linux")]
fn char_step_left(text: &str, from: usize, steps: usize) -> usize {
let mut pos = from;
for _ in 0..steps {
if pos == 0 {
break;
}
pos = text[..pos].char_indices().next_back().map_or(0, |(i, _)| i);
}
pos
}
#[cfg(target_os = "linux")]
fn format_word_with_caret(word: &str, chars_before: usize, chars_after: usize) -> String {
let chars: Vec<char> = word.chars().collect();
if chars_after == 0 {
return format!("{word}|");
}
if chars_before >= chars.len() {
return format!("{word}|");
}
let before: String = chars[..chars_before].iter().collect();
let on: char = chars[chars_before];
let after: String = chars[chars_before + 1..].iter().collect();
if chars_before == 0 {
format!("|[{on}]{after}")
} else {
format!("{before}[{on}]{after}")
}
}
#[cfg(target_os = "linux")]
fn fix_via_clipboard(provider: &hyprcorrect_core::OfflineProvider) {
use hyprcorrect_platform::linux::clipboard;
let word = match clipboard::copy_previous_word() {
Ok(w) => w,
Err(e) => {
eprintln!("hyprcorrect: clipboard fallback skipped — {e}");
return;
}
};
let trimmed = word.trim();
if trimmed.is_empty() {
return;
}
let Some(correction) = provider.check_text(trimmed).into_iter().next() else {
return;
};
let Some(fix) = correction.suggestions.into_iter().next() else {
return;
};
let leading_ws_len = word.len() - word.trim_start().len();
let trailing_ws_len = word.len() - word.trim_end().len();
let mut replacement = String::with_capacity(word.len());
replacement.push_str(&word[..leading_ws_len]);
replacement.push_str(&fix);
replacement.push_str(&word[word.len() - trailing_ws_len..]);
if let Err(e) = clipboard::type_replacement(&replacement) {
eprintln!("hyprcorrect: clipboard fallback type-back failed: {e}");
}
}
#[cfg(target_os = "linux")]
fn start_review(
address: &str,
buffer: &hyprcorrect_core::Buffer,
provider: &hyprcorrect_core::OfflineProvider,
smart: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
lt_fallback: bool,
) {
use hyprcorrect_core::runtime::{ReviewRequest, write_review_request};
let Some(at) = buffer.sentence_at_caret() else {
return;
};
eprintln!(
"hyprcorrect: review-build — original ({} chars): {:?}",
at.sentence.chars().count(),
at.sentence
);
let screen_width = focused_monitor_width();
let llm_available = llm.is_some();
let pending = ReviewRequest {
original: at.sentence.clone(),
corrected: at.sentence.clone(),
trailing: at.trailing.clone(),
chars_before_caret: at.chars_before_caret,
chars_after_caret: at.chars_after_caret,
window_address: address.to_string(),
suggestions: Vec::new(),
pending: true,
screen_width,
llm_available,
from_llm: false,
};
if let Err(e) = write_review_request(&pending) {
eprintln!("hyprcorrect: could not write pending review request: {e}");
return;
}
spawn_review_window();
let (corrected, used_provider, suggestions) = correct_sentence_with_suggestions(
&at.sentence,
smart,
llm,
languagetool,
provider,
lt_fallback,
);
if corrected == at.sentence {
eprintln!("hyprcorrect: review-build — no changes; popup will close");
} else {
eprintln!(
"hyprcorrect: review-build — corrected ({} chars): {:?}, {} suggestion set(s)",
corrected.chars().count(),
corrected,
suggestions.len(),
);
}
let request = ReviewRequest {
original: at.sentence,
corrected,
trailing: at.trailing,
chars_before_caret: at.chars_before_caret,
chars_after_caret: at.chars_after_caret,
window_address: address.to_string(),
suggestions,
pending: false,
screen_width,
llm_available,
from_llm: used_provider == hyprcorrect_core::ProviderId::Llm,
};
if let Err(e) = write_review_request(&request) {
eprintln!("hyprcorrect: could not write finished review request: {e}");
}
}
fn reprocess_review_with_llm(
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
provider: &hyprcorrect_core::OfflineProvider,
lt_fallback: bool,
) {
use hyprcorrect_core::ProviderId;
use hyprcorrect_core::runtime::{read_review_request, write_review_request};
let Ok(Some(mut req)) = read_review_request() else {
return;
};
if llm.is_none() {
eprintln!("hyprcorrect: review-llm — no LLM configured; opening Preferences");
notify_warning(
"LLM not configured",
"Add an LLM API key in Preferences → Providers to escalate corrections.",
);
spawn_prefs_window_section(Some("providers"));
return;
}
req.pending = true;
req.llm_available = true;
if let Err(e) = write_review_request(&req) {
eprintln!("hyprcorrect: review-llm — could not write pending request: {e}");
return;
}
let (corrected, used, suggestions) = correct_sentence_with_suggestions(
&req.original,
ProviderId::Llm,
llm,
languagetool,
provider,
lt_fallback,
);
eprintln!(
"hyprcorrect: review-llm — LLM corrected ({} chars): {:?}, {} suggestion set(s)",
corrected.chars().count(),
corrected,
suggestions.len(),
);
req.corrected = corrected;
req.suggestions = suggestions;
req.pending = false;
req.from_llm = used == ProviderId::Llm;
if let Err(e) = write_review_request(&req) {
eprintln!("hyprcorrect: review-llm — could not write finished request: {e}");
}
}
#[cfg(target_os = "linux")]
fn focused_monitor_width() -> f32 {
use std::process::Command;
let Ok(out) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
return 0.0;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return 0.0;
};
let Some(monitors) = json.as_array() else {
return 0.0;
};
let monitor = monitors
.iter()
.find(|m| m["focused"].as_bool() == Some(true))
.or_else(|| monitors.first());
let Some(monitor) = monitor else {
return 0.0;
};
let width = monitor["width"].as_f64().unwrap_or(0.0) as f32;
let scale = monitor["scale"].as_f64().unwrap_or(1.0) as f32;
if scale > 0.0 { width / scale } else { width }
}
#[cfg(not(target_os = "linux"))]
fn focused_monitor_width() -> f32 {
0.0
}
#[cfg(target_os = "linux")]
fn install_window_rules() {
use std::process::Command;
const REVIEW_CLASS: &str = "hyprcorrect-review";
const PORTAL_CLASS: &str = "xdg-desktop-portal-gtk";
for rule in [
format!("float on, match:class {REVIEW_CLASS}"),
format!("center on, match:class {REVIEW_CLASS}"),
format!("float on, match:class {PORTAL_CLASS}"),
format!("center on, match:class {PORTAL_CLASS}"),
] {
let result = Command::new("hyprctl")
.args(["keyword", "windowrule", &rule])
.output();
match result {
Ok(output) if !output.status.success() => {
eprintln!(
"hyprcorrect: windowrule install failed for {rule:?}: {}",
String::from_utf8_lossy(&output.stderr).trim(),
);
}
Err(e) => eprintln!("hyprcorrect: hyprctl not available for windowrules: {e}"),
_ => {}
}
}
}
#[cfg(target_os = "linux")]
fn spawn_review_window() {
use std::path::PathBuf;
use std::process::{Command, Stdio};
let exe_proc = PathBuf::from("/proc/self/exe");
let exe = if exe_proc.exists() {
exe_proc
} else if let Ok(p) = std::env::current_exe() {
p
} else {
eprintln!("hyprcorrect: cannot find own executable to launch review");
return;
};
let result = Command::new(&exe)
.arg("review")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
if let Err(e) = result {
eprintln!("hyprcorrect: could not launch review window: {e}");
}
}
#[cfg(target_os = "linux")]
fn apply_review(
buffers: &mut std::collections::HashMap<String, hyprcorrect_core::Buffer>,
pause_per_backspace_ms: u32,
) {
use hyprcorrect_core::runtime::{clear_review, read_review_request};
use hyprcorrect_platform::linux::emit;
let Ok(Some(req)) = read_review_request() else {
return;
};
std::thread::sleep(std::time::Duration::from_millis(100));
let backspaces = req.chars_before_caret + req.trailing.chars().count();
let deletes = req.chars_after_caret;
let insert = format!("{}{}", req.corrected, req.trailing);
eprintln!(
"hyprcorrect: review-apply — {backspaces} backspaces + {deletes} deletes + {:?}",
insert
);
match emit::replace_around_caret_with_delay(
backspaces,
deletes,
&insert,
pause_per_backspace_ms,
) {
Ok(()) => {
if let Some(buf) = buffers.get_mut(&req.window_address) {
buf.apply_around_caret(backspaces, deletes, &insert);
}
}
Err(e) => eprintln!("hyprcorrect: review emit failed: {e}"),
}
clear_review();
}
#[cfg(target_os = "linux")]
fn build_llm(config: &hyprcorrect_core::Config) -> Option<hyprcorrect_core::LlmProvider> {
use hyprcorrect_core::{LlmError, LlmProvider};
let active = config.providers.llms.first()?;
match LlmProvider::from_config(active) {
Ok(p) => Some(p),
Err(LlmError::NoApiKey) | Err(LlmError::UnsupportedBackend(_)) => None,
Err(e) => {
eprintln!(
"hyprcorrect: active LLM provider '{}' unavailable — {e}",
active.backend
);
None
}
}
}
#[cfg(target_os = "linux")]
fn build_languagetool(
config: &hyprcorrect_core::Config,
) -> Option<hyprcorrect_core::LanguageToolProvider> {
use hyprcorrect_core::{LanguageToolProvider, ProviderId};
if !config.providers.languagetool.enabled {
return None;
}
let selected = config.providers.smart == ProviderId::LanguageTool
|| config.providers.default == ProviderId::LanguageTool;
if !selected && !config.behavior.fallback_to_languagetool {
return None;
}
match LanguageToolProvider::from_config(&config.providers.languagetool) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("hyprcorrect: LanguageTool provider unavailable — {e}");
None
}
}
}
#[cfg(target_os = "linux")]
fn build_tray_pixmaps(paused: bool) -> Vec<hyprcorrect_platform::linux::tray::IconPixmap> {
use hyprcorrect_platform::linux::tray::IconPixmap;
const SIZES: &[u32] = &[22, 44];
hyprcorrect_ui::icon::tray_pixmaps(SIZES, paused)
.into_iter()
.map(|p| IconPixmap {
width: p.size as i32,
height: p.size as i32,
argb: p.argb,
})
.collect()
}
#[cfg(target_os = "linux")]
fn fix_last_sentence(
buffer: &mut hyprcorrect_core::Buffer,
provider: &hyprcorrect_core::OfflineProvider,
smart: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
pause_per_backspace_ms: u32,
lt_fallback: bool,
) {
use hyprcorrect_platform::linux::emit;
let Some(at) = buffer.sentence_at_caret() else {
eprintln!(
"hyprcorrect: sentence-fix skipped — focused window's keystroke buffer holds no sentence (try typing the sentence inside this window first)"
);
return;
};
eprintln!(
"hyprcorrect: sentence-fix on {:?} ({} chars; smart={smart:?}; llm={}; lt={})",
truncate(&at.sentence, 60),
at.sentence.chars().count(),
llm.is_some(),
languagetool.is_some(),
);
let (corrected, used_provider) = correct_sentence(
&at.sentence,
smart,
llm,
languagetool,
provider,
lt_fallback,
);
if corrected == at.sentence {
eprintln!("hyprcorrect: sentence-fix — provider returned the same text, nothing to emit");
return;
}
eprintln!(
"hyprcorrect: sentence-fix emitting → {:?}",
truncate(&corrected, 60)
);
let backspaces = at.chars_before_caret + at.trailing.chars().count();
let deletes = at.chars_after_caret;
let insert = format!("{corrected}{}", at.trailing);
match emit::replace_around_caret_with_delay(
backspaces,
deletes,
&insert,
pause_per_backspace_ms,
) {
Ok(()) => {
buffer.apply_around_caret(backspaces, deletes, &insert);
notify_info(
&format!("Corrected ({})", provider_label(used_provider)),
&format!(
"{} → {}",
truncate(&at.sentence, 40),
truncate(&corrected, 40)
),
);
}
Err(e) => eprintln!("hyprcorrect: {e}"),
}
}
#[cfg(target_os = "linux")]
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
format!("{}…", s.chars().take(n).collect::<String>())
}
}
#[cfg(target_os = "linux")]
fn llm_unavailable_fallback(
text: &str,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
spell: &hyprcorrect_core::OfflineProvider,
) -> (String, hyprcorrect_core::ProviderId) {
use hyprcorrect_core::ProviderId;
if let Some(lt) = languagetool {
match lt.check_text(text) {
Ok(corrections) => {
return (
apply_correction_list(text, corrections),
ProviderId::LanguageTool,
);
}
Err(e) => {
eprintln!("hyprcorrect: LanguageTool fallback also failed: {e} — using spellbook");
}
}
}
(apply_corrections(text, spell), ProviderId::Spellbook)
}
#[cfg(target_os = "linux")]
fn llm_unavailable_fallback_with_suggestions(
text: &str,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
spell: &hyprcorrect_core::OfflineProvider,
) -> (
String,
hyprcorrect_core::ProviderId,
Vec<hyprcorrect_core::runtime::WordSuggestions>,
) {
use hyprcorrect_core::ProviderId;
if let Some(lt) = languagetool {
match lt.check_text(text) {
Ok(corrections) => {
let (corrected, suggestions) = apply_with_suggestions(text, corrections);
return (corrected, ProviderId::LanguageTool, suggestions);
}
Err(e) => {
eprintln!("hyprcorrect: LanguageTool fallback also failed: {e} — using spellbook");
}
}
}
let (corrected, suggestions) = apply_with_suggestions(text, spell.check_text(text));
(corrected, ProviderId::Spellbook, suggestions)
}
#[cfg(target_os = "linux")]
fn correct_sentence(
text: &str,
smart: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
spell: &hyprcorrect_core::OfflineProvider,
lt_fallback: bool,
) -> (String, hyprcorrect_core::ProviderId) {
use hyprcorrect_core::ProviderId;
let lt_after_llm = languagetool.filter(|_| lt_fallback);
match smart {
ProviderId::Llm => match llm {
Some(llm) => match llm.rewrite(text) {
Ok(corrected) => (corrected, ProviderId::Llm),
Err(e) => {
let msg = format!("LLM call failed: {e}");
eprintln!("hyprcorrect: {msg} — falling back");
notify_warning("LLM unavailable", &msg);
llm_unavailable_fallback(text, lt_after_llm, spell)
}
},
None => {
let msg = "Smart provider is set to LLM, but the active provider has no API \
key (or its backend isn't supported yet). Open Preferences → \
Providers → LLM.";
eprintln!("hyprcorrect: {msg} — falling back");
notify_warning("LLM API key not set", msg);
llm_unavailable_fallback(text, lt_after_llm, spell)
}
},
ProviderId::LanguageTool => match languagetool {
Some(lt) => match lt.check_text(text) {
Ok(corrections) => (
apply_correction_list(text, corrections),
ProviderId::LanguageTool,
),
Err(e) => {
let msg = format!("LanguageTool call failed: {e}");
eprintln!("hyprcorrect: {msg} — falling back to spellbook");
notify_warning("LanguageTool unavailable", &msg);
(apply_corrections(text, spell), ProviderId::Spellbook)
}
},
None => {
let msg = "Smart provider is set to LanguageTool, but it is disabled or has \
no URL configured. Open Preferences → Providers → LanguageTool.";
eprintln!("hyprcorrect: {msg} — falling back to spellbook");
notify_warning("LanguageTool not configured", msg);
(apply_corrections(text, spell), ProviderId::Spellbook)
}
},
ProviderId::Spellbook => (apply_corrections(text, spell), ProviderId::Spellbook),
}
}
#[cfg(target_os = "linux")]
fn correct_sentence_with_suggestions(
text: &str,
smart: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
spell: &hyprcorrect_core::OfflineProvider,
lt_fallback: bool,
) -> (
String,
hyprcorrect_core::ProviderId,
Vec<hyprcorrect_core::runtime::WordSuggestions>,
) {
use hyprcorrect_core::ProviderId;
let spellbook_fallback = || {
let (corrected, suggestions) = apply_with_suggestions(text, spell.check_text(text));
(corrected, ProviderId::Spellbook, suggestions)
};
let lt_after_llm = languagetool.filter(|_| lt_fallback);
match smart {
ProviderId::Llm => match llm {
Some(llm) => match llm.rewrite_with_alternatives(text) {
Ok((corrected, alts)) => {
let suggestions = order_alternatives_by_position(&corrected, alts);
(corrected, ProviderId::Llm, suggestions)
}
Err(e) => {
let msg = format!("LLM call failed: {e}");
eprintln!("hyprcorrect: {msg} — falling back");
notify_warning("LLM unavailable", &msg);
llm_unavailable_fallback_with_suggestions(text, lt_after_llm, spell)
}
},
None => {
let msg = "Smart provider is set to LLM, but the active provider has no API \
key (or its backend isn't supported yet). Open Preferences → \
Providers → LLM.";
eprintln!("hyprcorrect: {msg} — falling back");
notify_warning("LLM API key not set", msg);
llm_unavailable_fallback_with_suggestions(text, lt_after_llm, spell)
}
},
ProviderId::LanguageTool => match languagetool {
Some(lt) => match lt.check_text(text) {
Ok(corrections) => {
let (corrected, suggestions) = apply_with_suggestions(text, corrections);
(corrected, ProviderId::LanguageTool, suggestions)
}
Err(e) => {
let msg = format!("LanguageTool call failed: {e}");
eprintln!("hyprcorrect: {msg} — falling back to spellbook");
notify_warning("LanguageTool unavailable", &msg);
spellbook_fallback()
}
},
None => {
let msg = "Smart provider is set to LanguageTool, but it is disabled or has \
no URL configured. Open Preferences → Providers → LanguageTool.";
eprintln!("hyprcorrect: {msg} — falling back to spellbook");
notify_warning("LanguageTool not configured", msg);
spellbook_fallback()
}
},
ProviderId::Spellbook => spellbook_fallback(),
}
}
#[cfg(target_os = "linux")]
fn notify_warning(title: &str, body: &str) {
notify_send("normal", title, body);
}
#[cfg(target_os = "linux")]
fn notify_info(title: &str, body: &str) {
notify_send("low", title, body);
}
#[cfg(target_os = "linux")]
fn provider_label(provider: hyprcorrect_core::ProviderId) -> &'static str {
use hyprcorrect_core::ProviderId;
match provider {
ProviderId::Spellbook => "Spellbook",
ProviderId::Llm => "LLM",
ProviderId::LanguageTool => "LanguageTool",
}
}
#[cfg(target_os = "linux")]
fn notify_send(urgency: &str, title: &str, body: &str) {
use std::process::{Command, Stdio};
let _ = Command::new("notify-send")
.args([
"-a",
"hyprcorrect",
"-c",
"im",
"-u",
urgency,
"-i",
"tools-check-spelling",
&format!("hyprcorrect — {title}"),
body,
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
#[cfg(target_os = "linux")]
fn apply_correction_list(text: &str, mut corrections: Vec<hyprcorrect_core::Correction>) -> String {
if corrections.is_empty() {
return text.to_string();
}
corrections.sort_by_key(|c| std::cmp::Reverse(c.span.start));
let mut out = text.to_string();
for c in corrections {
if let Some(fix) = c.suggestions.first() {
out.replace_range(c.span.clone(), fix);
}
}
out
}
#[cfg(target_os = "linux")]
fn apply_corrections(text: &str, provider: &hyprcorrect_core::OfflineProvider) -> String {
let mut corrections = provider.check_text(text);
if corrections.is_empty() {
return text.to_string();
}
corrections.sort_by_key(|c| std::cmp::Reverse(c.span.start));
let mut out = text.to_string();
for c in corrections {
if let Some(fix) = c.suggestions.first() {
out.replace_range(c.span.clone(), fix);
}
}
out
}
#[cfg(target_os = "linux")]
fn apply_with_suggestions(
text: &str,
mut corrections: Vec<hyprcorrect_core::Correction>,
) -> (String, Vec<hyprcorrect_core::runtime::WordSuggestions>) {
use hyprcorrect_core::runtime::WordSuggestions;
if corrections.is_empty() {
return (text.to_string(), Vec::new());
}
corrections.sort_by_key(|c| c.span.start);
let suggestions: Vec<WordSuggestions> = corrections
.iter()
.filter_map(|c| {
c.suggestions.first().map(|applied| WordSuggestions {
word: applied.clone(),
options: c.suggestions.iter().take(6).cloned().collect(),
})
})
.collect();
let mut out = text.to_string();
corrections.sort_by_key(|c| std::cmp::Reverse(c.span.start));
for c in &corrections {
if let Some(fix) = c.suggestions.first() {
out.replace_range(c.span.clone(), fix);
}
}
(out, suggestions)
}
#[cfg(target_os = "linux")]
fn order_alternatives_by_position(
corrected: &str,
mut alts: Vec<hyprcorrect_core::runtime::WordSuggestions>,
) -> Vec<hyprcorrect_core::runtime::WordSuggestions> {
alts.sort_by_key(|a| corrected.find(&a.word).unwrap_or(usize::MAX));
alts
}
#[cfg(not(target_os = "linux"))]
fn run_daemon() {
println!(
"hyprcorrect {}: the background daemon is Linux-only so far — \
macOS support is milestone M2.",
hyprcorrect_core::version(),
);
}
fn not_yet(what: &str, milestone: &str) {
eprintln!("hyprcorrect: {what} is not implemented yet ({milestone}) — see DESIGN.md");
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use hyprcorrect_core::{Correction, Sentence};
use super::{apply_with_suggestions, first_overlap_suggestion, word_in_sentence_bytes};
#[test]
fn apply_with_suggestions_orders_left_to_right_and_keeps_backups() {
let corrections = vec![
Correction {
span: 10..16,
original: "browne".into(),
suggestions: vec!["brown".into(), "crown".into(), "browse".into()],
},
Correction {
span: 0..3,
original: "teh".into(),
suggestions: vec!["the".into(), "then".into()],
},
];
let (corrected, sugg) = apply_with_suggestions("teh quick browne fox", corrections);
assert_eq!(corrected, "the quick brown fox");
assert_eq!(sugg.len(), 2);
assert_eq!(sugg[0].word, "the"); assert_eq!(sugg[0].options, vec!["the", "then"]);
assert_eq!(sugg[1].word, "brown");
assert_eq!(sugg[1].options, vec!["brown", "crown", "browse"]);
}
fn sentence(s: &str, start: usize) -> Sentence {
Sentence {
sentence: s.into(),
buffer_byte_start: start,
buffer_byte_end: start + s.len(),
}
}
#[test]
fn word_in_sentence_bytes_subtracts_buffer_offset() {
let buffer_offset = 13;
let s = sentence("The cat ran their way.", buffer_offset);
let in_sentence = s.sentence.find("their").unwrap();
let buf_start = buffer_offset + in_sentence;
let buf_end = buf_start + "their".len();
let r = word_in_sentence_bytes(&s, buf_start, buf_end).unwrap();
assert_eq!(&s.sentence[r], "their");
}
#[test]
fn word_in_sentence_bytes_rejects_out_of_range() {
let s = sentence("Hello.", 0);
assert!(word_in_sentence_bytes(&s, 0, 10).is_none());
let s2 = sentence("Hello.", 5);
assert!(word_in_sentence_bytes(&s2, 0, 4).is_none());
}
#[test]
fn first_overlap_picks_match_on_target_word() {
let target = 9..14; let corrections = vec![
Correction {
span: 0..4,
original: "They".into(),
suggestions: vec!["Them".into()],
},
Correction {
span: 9..14,
original: "their".into(),
suggestions: vec!["there".into()],
},
];
assert_eq!(
first_overlap_suggestion(&corrections, &target),
Some("there".into())
);
}
#[test]
fn first_overlap_returns_none_when_nothing_touches_target() {
let target = 9..14;
let corrections = vec![Correction {
span: 0..4,
original: "They".into(),
suggestions: vec!["Them".into()],
}];
assert!(first_overlap_suggestion(&corrections, &target).is_none());
}
}