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,
}
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(),
}
}
#[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;
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 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 Err(e) = hyprcorrect_ui::autostart::ensure_user_icon() {
eprintln!("hyprcorrect: could not refresh user icon ({e})");
}
if let Ok(exe) = std::env::current_exe()
&& let Err(e) = hyprcorrect_ui::autostart::ensure_apps_catalog_entry(&exe.to_string_lossy())
{
eprintln!("hyprcorrect: could not refresh apps-catalog entry ({e})");
}
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),
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;
}
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();
}
_ => {
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(),
),
"sentence" => fix_last_sentence(
buffer,
&provider,
smart_provider_id,
llm.as_ref(),
languagetool.as_ref(),
pause_per_backspace_ms,
),
_ => fix_last_word(
buffer,
default_provider_id,
llm.as_ref(),
languagetool.as_ref(),
&provider,
pause_per_backspace_ms,
),
}
}
}
}
}
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;
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;
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);
}
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);
}
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() {
use std::process::{Command, Stdio};
let Ok(exe) = std::env::current_exe() else {
eprintln!("hyprcorrect: cannot find own executable to launch prefs");
return;
};
let result = Command::new(&exe)
.arg("prefs")
.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>,
) -> Vec<hyprcorrect_core::Chord> {
let mut out = vec![word.clone()];
if let Some(c) = sentence {
out.push(c.clone());
}
if let Some(c) = review {
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,
) {
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,
) 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")]
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,
) -> 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"
);
}
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) => {
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 {
eprintln!(
"hyprcorrect: word-fix — default provider is LLM but no key configured; falling back to spellbook"
);
notify_warning(
"LLM key not set",
"Open Preferences → Providers → LLM and paste your Anthropic key.",
);
} else if default == ProviderId::LanguageTool
&& let Some(lt) = languagetool
{
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 Some(plan_for(&primary, fix, bracketed, ProviderId::LanguageTool));
}
eprintln!(
"hyprcorrect: word-fix — LT left {:?} unchanged, scanning nearby",
at.word
);
if let Some(plan) =
scan_nearby_lt(buffer, &at.word, lt, max_chars, &mut sentence_cache)
{
return Some(plan);
}
notify_info(
"Nothing to correct",
&format!("LanguageTool thinks {bracketed} (and nearby) are fine."),
);
return None;
}
Err(e) => {
eprintln!("hyprcorrect: word-fix LT failed ({e}) — falling back to spellbook");
notify_warning(
"LanguageTool unavailable",
&format!("Falling back to Spellbook for {bracketed}."),
);
}
}
} else if default == ProviderId::LanguageTool {
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 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")]
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>,
) {
use hyprcorrect_core::runtime::{ReviewRequest, write_review_request};
let Some(at) = buffer.sentence_at_caret() else {
return;
};
let (corrected, _used_provider) =
correct_sentence(&at.sentence, smart, llm, languagetool, provider);
eprintln!(
"hyprcorrect: review-build — original ({} chars): {:?}",
at.sentence.chars().count(),
at.sentence
);
if corrected == at.sentence {
eprintln!("hyprcorrect: review-build — provider returned the same text, nothing to review");
return;
}
eprintln!(
"hyprcorrect: review-build — corrected ({} chars): {:?}",
corrected.chars().count(),
corrected
);
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(),
};
if let Err(e) = write_review_request(&request) {
eprintln!("hyprcorrect: could not write review request: {e}");
return;
}
spawn_review_window();
}
#[cfg(target_os = "linux")]
fn install_window_rules() {
use std::process::Command;
const REVIEW_CLASS: &str = "hyprcorrect-review";
const PREFS_CLASS: &str = "hyprcorrect-prefs";
for rule in [
format!("float on, match:class {REVIEW_CLASS}"),
format!("center on, match:class {REVIEW_CLASS}"),
format!("float off, match:class {PREFS_CLASS}"),
format!("center off, match:class {PREFS_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::{LlmProvider, ProviderId};
if config.providers.smart != ProviderId::Llm && config.providers.default != ProviderId::Llm {
return None;
}
match LlmProvider::from_config(&config.providers.llm) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("hyprcorrect: LLM provider unavailable — {e}");
None
}
}
}
#[cfg(target_os = "linux")]
fn build_languagetool(
config: &hyprcorrect_core::Config,
) -> Option<hyprcorrect_core::LanguageToolProvider> {
use hyprcorrect_core::{LanguageToolProvider, ProviderId};
if config.providers.smart != ProviderId::LanguageTool
&& config.providers.default != ProviderId::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,
) {
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);
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 correct_sentence(
text: &str,
smart: hyprcorrect_core::ProviderId,
llm: Option<&hyprcorrect_core::LlmProvider>,
languagetool: Option<&hyprcorrect_core::LanguageToolProvider>,
spell: &hyprcorrect_core::OfflineProvider,
) -> (String, hyprcorrect_core::ProviderId) {
use hyprcorrect_core::ProviderId;
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 to spellbook");
notify_warning("LLM unavailable", &msg);
(apply_corrections(text, spell), ProviderId::Spellbook)
}
},
None => {
let msg = "Smart provider is set to LLM, but no API key is configured. \
Open Preferences → Providers → LLM and paste your Anthropic key.";
eprintln!("hyprcorrect: {msg} — falling back to spellbook");
notify_warning("LLM key not set", msg);
(apply_corrections(text, spell), ProviderId::Spellbook)
}
},
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 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(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::{first_overlap_suggestion, word_in_sentence_bytes};
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());
}
}