use std::collections::BTreeMap;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant, SystemTime};
use eframe::egui;
use hyprcorrect_core::{Config, LlmConfig, ProviderId, runtime, secrets};
#[cfg(target_os = "linux")]
use hyprcorrect_platform::linux::chord_capture::{self, ChordRecording, ClientError};
use crate::apps::AppRegistry;
#[cfg(target_os = "linux")]
use crate::autostart;
use crate::docker::{self, DockerState, LanguageToolStatus, OpHandle, OpKind, StatusHandle};
use crate::icon;
#[cfg(target_os = "linux")]
type ChordRecorder = ChordRecording;
const APP_ID: &str = "hyprcorrect-prefs";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HotkeyTarget {
FixWord,
FixSentence,
Review,
ReviewLlm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum LlmTab {
Provider(String),
Add,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Hotkeys,
Providers,
Behavior,
Privacy,
About,
}
impl Section {
fn label(self) -> &'static str {
match self {
Self::Hotkeys => "Hotkeys",
Self::Providers => "Providers",
Self::Behavior => "Behavior",
Self::Privacy => "Privacy",
Self::About => "About",
}
}
fn all() -> &'static [Self] {
&[
Self::Hotkeys,
Self::Providers,
Self::Behavior,
Self::Privacy,
Self::About,
]
}
fn from_name(name: &str) -> Option<Self> {
Self::all()
.iter()
.copied()
.find(|s| s.label().eq_ignore_ascii_case(name.trim()))
}
}
#[derive(Debug, Clone, Default)]
struct Status {
text: String,
is_error: bool,
}
struct PrefsApp {
config: Config,
saved: Config,
llm_keys: BTreeMap<String, String>,
saved_llm_keys: BTreeMap<String, String>,
llm_tab: LlmTab,
llm_draft: LlmConfig,
llm_draft_key: String,
#[cfg(target_os = "linux")]
autostart_enabled: bool,
#[cfg(target_os = "linux")]
saved_autostart_enabled: bool,
section: Section,
status: Status,
blocklist_entry: String,
shutdown_tx: Option<Sender<()>>,
logo: Option<egui::TextureHandle>,
last_stale_check: Instant,
daemon_stale: bool,
capturing_chord: Option<HotkeyTarget>,
#[cfg(target_os = "linux")]
chord_recorder: Option<ChordRecorder>,
running_apps: Vec<String>,
last_apps_refresh: Instant,
selected_app: Option<String>,
app_filter: String,
app_registry: AppRegistry,
lt_status: Option<LanguageToolStatus>,
lt_ngrams: Option<bool>,
ngram_download: Option<crate::ngrams::DownloadHandle>,
folder_pick: Option<Receiver<Option<String>>>,
folder_picker_available: bool,
last_status_check: Instant,
status_probe: Option<StatusHandle>,
docker_op: Option<OpHandle>,
}
impl PrefsApp {
fn new(
saved: Config,
saved_llm_keys: BTreeMap<String, String>,
shutdown_tx: Sender<()>,
section: Section,
) -> Self {
#[cfg(target_os = "linux")]
let autostart_enabled = autostart::is_enabled();
let llm_tab = saved
.providers
.llms
.first()
.map(|c| LlmTab::Provider(c.backend.clone()))
.unwrap_or(LlmTab::Add);
Self {
config: saved.clone(),
saved,
llm_keys: saved_llm_keys.clone(),
saved_llm_keys,
llm_tab,
llm_draft: LlmConfig {
backend: String::new(),
model: String::new(),
base_url: None,
},
llm_draft_key: String::new(),
#[cfg(target_os = "linux")]
autostart_enabled,
#[cfg(target_os = "linux")]
saved_autostart_enabled: autostart_enabled,
section,
status: Status::default(),
blocklist_entry: String::new(),
shutdown_tx: Some(shutdown_tx),
logo: None,
last_stale_check: Instant::now() - Duration::from_secs(60),
daemon_stale: false,
capturing_chord: None,
#[cfg(target_os = "linux")]
chord_recorder: None,
running_apps: Vec::new(),
last_apps_refresh: Instant::now() - Duration::from_secs(60),
selected_app: None,
app_filter: String::new(),
app_registry: AppRegistry::discover(),
lt_status: None,
lt_ngrams: None,
ngram_download: None,
folder_pick: None,
folder_picker_available: tool_in_path("zenity") || tool_in_path("kdialog"),
last_status_check: Instant::now() - Duration::from_secs(60),
status_probe: None,
docker_op: None,
}
}
fn refresh_lt_status(&mut self, ctx: &egui::Context) {
if let Some(handle) = &self.status_probe
&& let Some(result) = handle.poll()
{
self.lt_status = Some(result.status);
self.lt_ngrams = result.ngrams;
let unrecorded = self
.config
.providers
.languagetool
.ngram_dir
.as_deref()
.unwrap_or("")
.trim()
.is_empty();
if result.ngrams == Some(true)
&& unrecorded
&& let Some(mount) = result.ngram_mount.filter(|m| !m.trim().is_empty())
{
self.config.providers.languagetool.ngram_dir = Some(mount);
let _ = self.persist_languagetool();
}
self.status_probe = None;
ctx.request_repaint();
}
if self.status_probe.is_some() {
ctx.request_repaint_after(Duration::from_millis(200));
return;
}
if self.docker_op.is_some() {
return;
}
if self.last_status_check.elapsed() < Duration::from_secs(5) {
return;
}
self.last_status_check = Instant::now();
let url = self.config.providers.languagetool.url.clone();
self.status_probe = Some(docker::spawn_status_probe(url));
ctx.request_repaint_after(Duration::from_millis(200));
}
fn poll_ngram_download(&mut self, ctx: &egui::Context) {
use crate::ngrams::DownloadPhase;
let phase = match &self.ngram_download {
Some(h) => h.phase(),
None => return,
};
match phase {
DownloadPhase::Downloading { .. } | DownloadPhase::Extracting => {
ctx.request_repaint_after(Duration::from_millis(200));
}
DownloadPhase::Done(dir) => {
self.ngram_download = None;
let dir_str = dir.to_string_lossy().to_string();
self.config.providers.languagetool.ngram_dir = Some(dir_str.clone());
let url = self.config.providers.languagetool.url.clone();
if let Some(port) = docker::host_port_from_url(&url) {
self.docker_op = Some(docker::enable_ngrams(port, &dir_str));
self.ok("n-grams downloaded — enabling (recreating container)…");
} else {
self.ok("n-grams downloaded. Set a URL with a port, then Enable n-grams.");
}
self.last_status_check = Instant::now() - Duration::from_secs(60);
}
DownloadPhase::Failed(msg) => {
self.ngram_download = None;
self.err(format!("n-gram download failed: {msg}"));
}
DownloadPhase::Cancelled => {
self.ngram_download = None;
self.ok("n-gram download cancelled.");
}
}
}
fn poll_folder_pick(&mut self, ctx: &egui::Context) {
let Some(rx) = &self.folder_pick else {
return;
};
match rx.try_recv() {
Ok(result) => {
self.folder_pick = None;
if let Some(path) = result {
self.config.providers.languagetool.ngram_dir = Some(path);
self.clear_status();
}
ctx.request_repaint();
}
Err(mpsc::TryRecvError::Empty) => {
ctx.request_repaint_after(Duration::from_millis(150));
}
Err(mpsc::TryRecvError::Disconnected) => {
self.folder_pick = None;
}
}
}
fn persist_languagetool(&mut self) -> Result<(), String> {
let mut on_disk = hyprcorrect_core::Config::load().map_err(|e| e.to_string())?;
on_disk.providers.languagetool = self.config.providers.languagetool.clone();
on_disk.save().map_err(|e| e.to_string())?;
self.saved.providers.languagetool = self.config.providers.languagetool.clone();
Ok(())
}
fn poll_docker_op(&mut self, ctx: &egui::Context) {
let Some(handle) = &self.docker_op else {
return;
};
if let Some(result) = handle.poll() {
let kind = handle.kind();
self.docker_op = None;
self.last_status_check = Instant::now() - Duration::from_secs(60);
match result {
Ok(()) => {
let msg = match kind {
OpKind::Install => {
self.config.providers.languagetool.enabled = true;
"LanguageTool installed and started."
}
OpKind::Start => "LanguageTool started.",
OpKind::Stop => "LanguageTool stopped.",
OpKind::Remove => "LanguageTool container removed.",
OpKind::EnableNgrams => {
self.config.providers.languagetool.enabled = true;
"n-grams enabled — container recreated with the dataset."
}
OpKind::RemoveNgrams => {
self.config.providers.languagetool.ngram_dir = None;
"n-grams removed — container recreated and data deleted."
}
};
if matches!(
kind,
OpKind::Install | OpKind::EnableNgrams | OpKind::RemoveNgrams
) && let Err(e) = self.persist_languagetool()
{
self.err(format!("{msg} (but saving config to disk failed: {e})"));
return;
}
self.ok(msg);
}
Err(e) => {
let verb = match kind {
OpKind::Install => "install",
OpKind::Start => "start",
OpKind::Stop => "stop",
OpKind::Remove => "remove",
OpKind::EnableNgrams => "enable n-grams",
OpKind::RemoveNgrams => "remove n-grams",
};
self.err(format!("Docker {verb} failed: {e}"));
}
}
} else {
ctx.request_repaint_after(Duration::from_millis(500));
}
}
fn refresh_running_apps(&mut self) {
if self.last_apps_refresh.elapsed() < Duration::from_secs(3) {
return;
}
self.last_apps_refresh = Instant::now();
self.running_apps = list_running_classes();
}
fn logo_texture(&mut self, ctx: &egui::Context) -> Option<&egui::TextureHandle> {
if self.logo.is_none() {
let size = 256u32;
let rgba = icon::render_app_icon_rgba(size);
if rgba.len() == (size as usize) * (size as usize) * 4 {
let image =
egui::ColorImage::from_rgba_unmultiplied([size as usize, size as usize], &rgba);
self.logo =
Some(ctx.load_texture("hyprcorrect_logo", image, egui::TextureOptions::LINEAR));
}
}
self.logo.as_ref()
}
fn refresh_stale_check(&mut self) {
if self.last_stale_check.elapsed() < Duration::from_secs(1) {
return;
}
self.last_stale_check = Instant::now();
self.daemon_stale = daemon_is_stale();
}
fn dirty(&self) -> bool {
#[cfg(target_os = "linux")]
let autostart_changed = self.autostart_enabled != self.saved_autostart_enabled;
#[cfg(not(target_os = "linux"))]
let autostart_changed = false;
self.config != self.saved || self.llm_keys != self.saved_llm_keys || autostart_changed
}
fn ok(&mut self, text: impl Into<String>) {
self.status = Status {
text: text.into(),
is_error: false,
};
}
fn err(&mut self, text: impl Into<String>) {
self.status = Status {
text: text.into(),
is_error: true,
};
}
fn clear_status(&mut self) {
self.status = Status::default();
}
fn save(&mut self) {
if let Err(msg) = validate(&self.config) {
self.err(msg);
return;
}
if let Err(e) = self.config.save() {
self.err(format!("save failed: {e}"));
return;
}
let key_writes: Vec<(String, String)> = self
.llm_keys
.iter()
.filter(|(backend, key)| {
self.saved_llm_keys
.get(*backend)
.map(String::as_str)
.unwrap_or("")
!= key.as_str()
})
.map(|(b, k)| (b.clone(), k.clone()))
.collect();
for (backend, key) in key_writes {
let name = hyprcorrect_core::llm::key_name(&backend);
let result = if key.is_empty() {
secrets::delete(&name)
} else {
secrets::set(&name, &key)
};
if let Err(e) = result {
self.err(format!("keychain write failed: {e}"));
return;
}
}
self.saved_llm_keys = self.llm_keys.clone();
#[cfg(target_os = "linux")]
if self.autostart_enabled != self.saved_autostart_enabled {
let result = if self.autostart_enabled {
std::env::current_exe()
.map_err(|e| std::io::Error::other(format!("current_exe: {e}")))
.and_then(|exe| autostart::enable(&exe.to_string_lossy()))
} else {
autostart::disable()
};
if let Err(e) = result {
self.err(format!("autostart write failed: {e}"));
return;
}
self.saved_autostart_enabled = self.autostart_enabled;
}
self.saved = self.config.clone();
notify_daemon_reload();
self.ok("Saved.");
}
fn cancel(&mut self) {
self.config = self.saved.clone();
self.llm_keys = self.saved_llm_keys.clone();
self.llm_draft = LlmConfig {
backend: String::new(),
model: String::new(),
base_url: None,
};
self.llm_draft_key.clear();
self.llm_tab = self
.config
.providers
.llms
.first()
.map(|c| LlmTab::Provider(c.backend.clone()))
.unwrap_or(LlmTab::Add);
#[cfg(target_os = "linux")]
{
self.autostart_enabled = self.saved_autostart_enabled;
}
self.clear_status();
}
}
impl eframe::App for PrefsApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
apply_style(ctx);
self.refresh_stale_check();
self.poll_docker_op(ctx);
self.poll_ngram_download(ctx);
self.poll_folder_pick(ctx);
if self.section == Section::Providers {
self.refresh_lt_status(ctx);
}
#[cfg(target_os = "linux")]
if let Some(target) = self.capturing_chord {
if self.chord_recorder.is_none() {
match chord_capture::record_chord() {
Ok(rec) => self.chord_recorder = Some(rec),
Err(e) => {
self.capturing_chord = None;
self.err(chord_record_error(&e));
notify_daemon_reload();
}
}
}
let esc_pressed = ctx.input(|i| i.key_pressed(egui::Key::Escape));
if esc_pressed && let Some(rec) = &self.chord_recorder {
rec.abort();
}
if let Some(rec) = &self.chord_recorder {
match rec.try_recv() {
Ok(None) => {
ctx.request_repaint_after(Duration::from_millis(50));
}
Ok(Some(chord)) => {
match target {
HotkeyTarget::FixWord => self.config.hotkeys.fix_word = chord,
HotkeyTarget::FixSentence => self.config.hotkeys.fix_sentence = chord,
HotkeyTarget::Review => self.config.hotkeys.review = chord,
HotkeyTarget::ReviewLlm => self.config.hotkeys.review_llm = chord,
}
self.capturing_chord = None;
self.chord_recorder = None;
self.clear_status();
notify_daemon_reload();
}
Err(ClientError::Cancelled) => {
self.capturing_chord = None;
self.chord_recorder = None;
notify_daemon_reload();
}
Err(e) => {
self.capturing_chord = None;
self.chord_recorder = None;
self.err(chord_record_error(&e));
notify_daemon_reload();
}
}
}
}
let logo = self.logo_texture(ctx).cloned();
egui::SidePanel::left("sections")
.resizable(false)
.default_width(200.0)
.show(ctx, |ui| {
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(4.0);
if let Some(handle) = &logo {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(28.0, 34.0), egui::Sense::hover());
let icon_rect = egui::Rect::from_min_size(
rect.left_top() + egui::vec2(0.0, 6.0),
egui::vec2(28.0, 28.0),
);
egui::Image::new(handle).paint_at(ui, icon_rect);
ui.add_space(8.0);
}
ui.heading("hyprcorrect");
});
ui.add_space(14.0);
ui.separator();
ui.add_space(8.0);
for section in Section::all() {
let selected = self.section == *section;
if sidebar_item(ui, selected, section.label()).clicked() {
self.section = *section;
self.clear_status();
}
}
});
let mut quit_requested = false;
let mut relaunch_requested = false;
egui::TopBottomPanel::bottom("actions")
.resizable(false)
.min_height(54.0)
.frame(
egui::Frame::side_top_panel(&ctx.style())
.inner_margin(egui::Margin::symmetric(20, 20)),
)
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 20.0;
let quit_label = egui::RichText::new("Quit hyprcorrect")
.color(egui::Color32::from_rgb(220, 90, 90));
if ui.add(egui::Button::new(quit_label)).clicked() {
quit_requested = true;
}
if self.daemon_stale {
let relaunch_label = egui::RichText::new("Relaunch daemon (new build)")
.color(egui::Color32::from_rgb(220, 160, 50));
let resp = ui.add(egui::Button::new(relaunch_label)).on_hover_text(
"The on-disk binary is newer than the running daemon. \
Click to quit the old daemon and spawn the new one.",
);
if resp.clicked() {
relaunch_requested = true;
}
}
if !self.status.text.is_empty() {
let color = if self.status.is_error {
ui.visuals().error_fg_color
} else {
ui.visuals().widgets.active.fg_stroke.color
};
ui.colored_label(color, &self.status.text);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 20.0;
if ui
.add_enabled(self.dirty(), egui::Button::new("Save"))
.clicked()
{
self.save();
}
if ui
.add_enabled(self.dirty(), egui::Button::new("Cancel"))
.clicked()
{
self.cancel();
}
});
});
});
egui::CentralPanel::default()
.frame(
egui::Frame::central_panel(&ctx.style()).inner_margin(egui::Margin {
left: 20,
right: 0,
top: 18,
bottom: 18,
}),
)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.set_max_width((ui.available_width() - 10.0).max(0.0));
match self.section {
Section::Hotkeys => self.hotkeys_panel(ui),
Section::Providers => self.providers_panel(ui),
Section::Behavior => self.behavior_panel(ui),
Section::Privacy => self.privacy_panel(ui),
Section::About => self.about_panel(ui),
}
});
});
if quit_requested {
quit_daemon();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if relaunch_requested {
relaunch_daemon_now();
self.last_stale_check = Instant::now() - Duration::from_secs(60);
}
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if self.capturing_chord.is_some() {
notify_daemon_reload();
}
}
}
impl PrefsApp {
fn hotkeys_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("Hotkeys");
ui.add_space(14.0);
field_label(ui, "Fix last word");
ui.add_space(4.0);
let fix_word_value = self.config.hotkeys.fix_word.clone();
if hotkey_chord_row(
ui,
HotkeyTarget::FixWord,
&fix_word_value,
self.capturing_chord,
) {
self.capturing_chord = Some(HotkeyTarget::FixWord);
self.clear_status();
notify_daemon_release();
}
ui.add_space(6.0);
caption(
ui,
"Click the chip and press the chord you want. Esc cancels. \
Hyprland will eat the chord so terminals and other focused \
apps never see it.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Fix last sentence");
ui.add_space(4.0);
let fix_sentence_value = self.config.hotkeys.fix_sentence.clone();
if hotkey_chord_row(
ui,
HotkeyTarget::FixSentence,
&fix_sentence_value,
self.capturing_chord,
) {
self.capturing_chord = Some(HotkeyTarget::FixSentence);
self.clear_status();
notify_daemon_release();
}
ui.add_space(6.0);
caption(
ui,
"Corrects the previous sentence in one keypress. Routes to \
whichever provider you picked as `Smart` in Providers.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Review correction");
ui.add_space(4.0);
let review_value = self.config.hotkeys.review.clone();
if hotkey_chord_row(
ui,
HotkeyTarget::Review,
&review_value,
self.capturing_chord,
) {
self.capturing_chord = Some(HotkeyTarget::Review);
self.clear_status();
notify_daemon_release();
}
ui.add_space(6.0);
caption(
ui,
"Shows the proposed correction in a small popup; press Enter \
to apply or Esc to cancel. Useful for eyeballing LLM \
suggestions before they land.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Escalate review to LLM");
ui.add_space(4.0);
let review_llm_value = self.config.hotkeys.review_llm.clone();
if hotkey_chord_row(
ui,
HotkeyTarget::ReviewLlm,
&review_llm_value,
self.capturing_chord,
) {
self.capturing_chord = Some(HotkeyTarget::ReviewLlm);
self.clear_status();
notify_daemon_release();
}
ui.add_space(6.0);
caption(
ui,
"While the review popup is open, re-runs the original sentence \
through the LLM and reloads with its suggestions — for when \
LanguageTool's fix is wrong. Also a button in the popup.",
);
ui.add_space(SETTING_BLOCK_SPACING);
caption_with_code(
ui,
"`$HYPRCORRECT_CHORD` overrides Fix last word for one-off dev runs.",
);
}
fn providers_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("Providers");
ui.add_space(14.0);
let mut touched = false;
field_label(ui, "Default provider");
caption(ui, "Used for fix-last-word.");
ui.add_space(4.0);
touched |= provider_radio(
ui,
&mut self.config.providers.default,
Some(LLM_DEFAULT_TOOLTIP),
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label_with_info(ui, "Smart provider", SMART_PROVIDER_TOOLTIP);
caption(ui, "Used for fix-last-sentence and the review popup.");
ui.add_space(4.0);
touched |= provider_radio(ui, &mut self.config.providers.smart, None);
ui.add_space(SETTING_BLOCK_SPACING);
ui.separator();
ui.add_space(SETTING_BLOCK_SPACING);
ui.label(egui::RichText::new("LLM").size(16.0).strong());
ui.add_space(8.0);
touched |= self.llm_providers_section(ui);
ui.add_space(SETTING_BLOCK_SPACING);
ui.separator();
ui.add_space(SETTING_BLOCK_SPACING);
ui.label(egui::RichText::new("LanguageTool").size(16.0).strong());
ui.add_space(8.0);
touched |= ui
.checkbox(&mut self.config.providers.languagetool.enabled, "Enabled")
.changed();
ui.add_space(8.0);
field_label_with_note(ui, "URL", "base URL — hyprcorrect appends /v2/check");
ui.add_space(4.0);
touched |= padded_text_edit(ui, &mut self.config.providers.languagetool.url).changed();
ui.add_space(SETTING_BLOCK_SPACING);
self.languagetool_docker_row(ui);
touched |= self.ngram_folder_field(ui);
if touched {
self.clear_status();
}
}
fn ngram_folder_field(&mut self, ui: &mut egui::Ui) -> bool {
ui.add_space(SETTING_BLOCK_SPACING);
let base = hyprcorrect_core::config::ngram_data_dir();
if let Some(managed) = base
.as_deref()
.filter(|b| crate::ngrams::data_root(b).is_some())
.map(std::path::Path::to_path_buf)
{
field_label_with_info(
ui,
"n-gram data folder",
"Downloaded and installed by hyprcorrect — you don't need to set your own.",
);
ui.add_space(4.0);
let mut shown = managed.to_string_lossy().to_string();
ui.add_enabled_ui(false, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let browse_w = 88.0;
let field_w =
(ui.available_width() - browse_w - 6.0 - TEXT_EDIT_MARGIN_X).max(80.0);
bordered_text_edit(
ui,
egui::TextEdit::singleline(&mut shown)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(field_w),
);
ui.add(egui::Button::new("Browse…")).on_disabled_hover_text(
"hyprcorrect manages this folder — use Remove downloaded data to change it.",
);
});
});
ui.add_space(8.0);
let port = docker::host_port_from_url(&self.config.providers.languagetool.url);
if ui
.add_enabled(
self.docker_op.is_none() && port.is_some(),
egui::Button::new("Remove downloaded data"),
)
.on_hover_text(
"Recreates the container without n-grams and deletes the downloaded \
folder (frees ~16 GB).",
)
.clicked()
&& let Some(port) = port
{
self.docker_op = Some(docker::remove_ngrams(port, managed));
self.ok(OpKind::RemoveNgrams.label());
}
return false;
}
field_label(ui, "n-gram data folder (optional)");
ui.add_space(4.0);
let mut ngram = self
.config
.providers
.languagetool
.ngram_dir
.clone()
.unwrap_or_default();
let mut changed = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let browse_w = 88.0;
let field_w = (ui.available_width() - browse_w - 6.0 - TEXT_EDIT_MARGIN_X).max(80.0);
changed |= bordered_text_edit(
ui,
egui::TextEdit::singleline(&mut ngram)
.hint_text("/path/to/ngrams (the folder containing en/)")
.margin(egui::Margin::symmetric(8, 6))
.desired_width(field_w),
)
.changed();
if ui
.add_enabled(
self.folder_pick.is_none() && self.folder_picker_available,
egui::Button::new("Browse…"),
)
.on_disabled_hover_text("Install zenity or kdialog to browse for a folder.")
.on_hover_text("Pick the folder in a file dialog.")
.clicked()
{
self.folder_pick = Some(spawn_folder_pick(
self.config.providers.languagetool.ngram_dir.clone(),
));
}
});
if changed {
self.config.providers.languagetool.ngram_dir =
(!ngram.trim().is_empty()).then(|| ngram.trim().to_string());
}
ui.add_space(4.0);
caption_with_code(
ui,
"Only needed if you already have the unzipped n-gram data — the folder that \
contains an `en/` subfolder (e.g. `…/ngrams/`, with `en/2grams`, `en/3grams` \
inside). Point here, then click \"Enable n-grams\" above. Otherwise use \
\"Download n-grams\".",
);
changed
}
fn languagetool_docker_row(&mut self, ui: &mut egui::Ui) {
let url = self.config.providers.languagetool.url.clone();
let op_in_flight = self.docker_op.is_some();
let probe_in_flight = self.status_probe.is_some() && self.lt_status.is_none();
let status = self.lt_status.clone();
if matches!(
status,
Some(LanguageToolStatus::Reachable {
managed_container_running: true,
})
) {
field_label_with_info(
ui,
"Local server (Docker)",
"Running in the hyprcorrect-managed container. Nothing else to do.",
);
} else {
field_label(ui, "Local server (Docker)");
}
ui.add_space(4.0);
let Some(status) = status else {
if probe_in_flight {
ui.colored_label(
egui::Color32::from_gray(170),
"Checking for a running LanguageTool server…",
);
}
return;
};
match status {
LanguageToolStatus::Reachable {
managed_container_running,
} => {
ui.colored_label(
egui::Color32::from_rgb(110, 200, 130),
format!("Reachable at {url}"),
);
ui.add_space(8.0);
if managed_container_running {
ui.horizontal(|ui| {
if ui
.add_enabled(!op_in_flight, egui::Button::new("Stop"))
.on_hover_text(format!(
"docker stop {}\nLeaves the container in place; \
Start brings it back.",
docker::CONTAINER
))
.clicked()
{
self.docker_op = Some(docker::stop());
self.ok(OpKind::Stop.label());
}
if ui
.add_enabled(!op_in_flight, egui::Button::new("Remove").frame(false))
.on_hover_text("Stop and delete the container. The image stays cached.")
.clicked()
{
self.docker_op = Some(docker::remove());
self.ok(OpKind::Remove.label());
}
});
} else {
ui.add_space(4.0);
caption(
ui,
"Detected an existing LanguageTool server — hyprcorrect will \
use it as-is. No Docker setup needed.",
);
}
}
LanguageToolStatus::Unreachable(docker_state) => {
self.docker_unreachable_row(ui, &docker_state, &url, op_in_flight);
}
}
self.languagetool_ngram_row(ui, &url, op_in_flight);
}
fn languagetool_ngram_row(&mut self, ui: &mut egui::Ui, url: &str, op_in_flight: bool) {
ui.add_space(SETTING_BLOCK_SPACING);
field_label_with_info(
ui,
"n-grams (wear/where)",
"LanguageTool's statistical n-gram model catches real-word errors — words \
spelled correctly but wrong for the context, which a plain spell-checker \
can't flag. Examples: their/there/they're, its/it's, then/than, to/too, \
your/you're, of/off, lose/loose. The dataset is LanguageTool's English \
n-gram corpus (~8.4 GB download, ~16 GB unzipped to a folder containing en/).",
);
ui.add_space(4.0);
if let Some(handle) = &self.ngram_download {
use crate::ngrams::DownloadPhase;
const GB: f64 = 1_000_000_000.0;
match handle.phase() {
DownloadPhase::Downloading { done, total } => {
let frac = if total > 0 {
done as f32 / total as f32
} else {
0.0
};
let text = if total > 0 {
format!(
"Downloading {:.1} / {:.1} GB",
done as f64 / GB,
total as f64 / GB
)
} else {
format!("Downloading {:.1} GB…", done as f64 / GB)
};
ui.add(egui::ProgressBar::new(frac).text(text));
}
_ => {
ui.add(egui::ProgressBar::new(1.0).text("Unzipping (~16 GB)…"));
}
}
ui.add_space(4.0);
if ui.button("Cancel").clicked() {
handle.cancel();
}
return;
}
let port = docker::host_port_from_url(url);
let base = hyprcorrect_core::config::ngram_data_dir();
let downloaded = base.as_deref().and_then(crate::ngrams::data_root);
let user_dir = self
.config
.providers
.languagetool
.ngram_dir
.clone()
.filter(|d| !d.trim().is_empty());
if self.lt_ngrams == Some(true) {
ui.colored_label(
egui::Color32::from_rgb(110, 200, 130),
"Loaded — real-word confusions are caught (their/there, its/it's, then/than).",
);
if downloaded.is_none()
&& let (Some(dir), Some(port)) = (user_dir.as_deref(), port)
{
ui.add_space(4.0);
if ui
.add_enabled(!op_in_flight, egui::Button::new("Reload n-grams"))
.on_hover_text(
"Optional — n-grams already work. Only needed if you swap the data \
at the folder below (LanguageTool reads n-grams only at startup). \
Recreates the container.",
)
.clicked()
{
self.docker_op = Some(docker::enable_ngrams(port, dir));
self.ok(OpKind::EnableNgrams.label());
}
}
return;
}
if let Some(mount) = &downloaded {
let mount = mount.to_string_lossy().to_string();
if let Some(port) = port
&& ui
.add_enabled(!op_in_flight, egui::Button::new("Enable n-grams"))
.on_hover_text(
"Mounts the already-downloaded data and recreates the container.",
)
.clicked()
{
self.docker_op = Some(docker::enable_ngrams(port, &mount));
self.ok(OpKind::EnableNgrams.label());
}
ui.add_space(4.0);
caption(ui, "Off — data is downloaded. Click Enable to turn it on.");
return;
}
let user_valid = user_dir
.as_deref()
.and_then(|d| crate::ngrams::data_root(std::path::Path::new(d)));
if let Some(valid_root) = user_valid {
let mount = valid_root.to_string_lossy().to_string();
if let Some(port) = port
&& ui
.add_enabled(!op_in_flight, egui::Button::new("Enable n-grams"))
.on_hover_text(
"Mounts the n-gram data at the folder below and recreates the \
container.",
)
.clicked()
{
self.docker_op = Some(docker::enable_ngrams(port, &mount));
self.ok(OpKind::EnableNgrams.label());
}
ui.add_space(4.0);
caption(
ui,
"Off — n-gram data found at the folder below. Click Enable to turn it on.",
);
return;
}
if ui
.add_enabled(
!op_in_flight && base.is_some(),
egui::Button::new("Download n-grams (~8.4 GB)"),
)
.on_hover_text(
"Downloads LanguageTool's English n-gram data to the app's data \
folder and enables it. Needs ~24 GB free while unzipping.",
)
.clicked()
&& let Some(d) = base.clone()
{
self.ngram_download = Some(crate::ngrams::spawn_ngram_download(d));
self.ok("Downloading n-grams…");
}
ui.add_space(4.0);
caption(
ui,
"Off — real-word errors slip through (their/there, its/it's). Download the \
data, or point the folder below at a copy you have.",
);
}
fn docker_unreachable_row(
&mut self,
ui: &mut egui::Ui,
state: &DockerState,
url: &str,
op_in_flight: bool,
) {
let (status_text, status_color) = match state {
DockerState::NotInstalled => (
format!(
"Nothing answers at {url}, and Docker isn't installed — \
install Docker or point the URL at an existing server."
),
egui::Color32::from_gray(170),
),
DockerState::DockerUnavailable(msg) => (
format!("Docker unavailable: {msg}"),
egui::Color32::from_rgb(220, 160, 50),
),
DockerState::AbsentContainer => {
("Not installed.".to_string(), egui::Color32::from_gray(170))
}
DockerState::ContainerStopped => (
format!("Our container exists but is stopped. Start it to reach {url}."),
egui::Color32::from_rgb(220, 160, 50),
),
DockerState::ContainerRunning => (
format!(
"Our container is running but {url} doesn't answer — \
likely a port-mapping mismatch."
),
egui::Color32::from_rgb(220, 160, 50),
),
DockerState::ForeignContainer { name, running } => (
if *running {
format!(
"Found another LanguageTool container ({name}) running, but \
it doesn't answer at {url}. Update the URL to match its \
port, or stop it and install ours."
)
} else {
format!(
"Found another LanguageTool container ({name}), stopped. \
Start it manually (`docker start {name}`) or install ours."
)
},
egui::Color32::from_rgb(220, 160, 50),
),
};
ui.colored_label(status_color, status_text);
ui.add_space(8.0);
ui.horizontal(|ui| match state {
DockerState::NotInstalled => {
let _ = ui
.add_enabled(false, egui::Button::new("Install with Docker"))
.on_disabled_hover_text(
"Install Docker first: https://docs.docker.com/engine/install/",
);
}
DockerState::DockerUnavailable(_) => {
if ui
.add_enabled(!op_in_flight, egui::Button::new("Retry"))
.on_hover_text("Recheck whether the Docker daemon is reachable.")
.clicked()
{
self.last_status_check = Instant::now() - Duration::from_secs(60);
}
}
DockerState::AbsentContainer | DockerState::ForeignContainer { .. } => {
let port = docker::host_port_from_url(url);
let enabled = !op_in_flight && port.is_some();
let hover = match port {
Some(p) => format!(
"Runs:\n docker run -d --name {} --restart=unless-stopped \\\
\n -p {}:8010 {}\nFirst run downloads ~600 MB. Add n-grams \
separately below.",
docker::CONTAINER,
p,
docker::IMAGE,
),
None => "URL needs an explicit port (e.g. http://localhost:8081) before \
hyprcorrect can map it to the container."
.to_string(),
};
if ui
.add_enabled(enabled, egui::Button::new("Install with Docker"))
.on_hover_text(hover)
.clicked()
&& let Some(port) = port
{
self.docker_op = Some(docker::install(port, None));
self.ok(OpKind::Install.label());
}
}
DockerState::ContainerStopped => {
if ui
.add_enabled(!op_in_flight, egui::Button::new("Start"))
.clicked()
{
self.docker_op = Some(docker::start());
self.ok(OpKind::Start.label());
}
if ui
.add_enabled(!op_in_flight, egui::Button::new("Remove").frame(false))
.on_hover_text("Delete the container. The image stays cached locally.")
.clicked()
{
self.docker_op = Some(docker::remove());
self.ok(OpKind::Remove.label());
}
}
DockerState::ContainerRunning => {
if ui
.add_enabled(!op_in_flight, egui::Button::new("Stop"))
.on_hover_text(
"Stop the container so you can adjust the URL or re-install \
with a matching port.",
)
.clicked()
{
self.docker_op = Some(docker::stop());
self.ok(OpKind::Stop.label());
}
if ui
.add_enabled(!op_in_flight, egui::Button::new("Remove").frame(false))
.clicked()
{
self.docker_op = Some(docker::remove());
self.ok(OpKind::Remove.label());
}
}
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let muted = egui::Color32::from_gray(170);
ui.label(
egui::RichText::new("Pulls the ")
.size(CAPTION_SIZE)
.line_height(Some(CAPTION_LINE_HEIGHT))
.color(muted),
);
ui.hyperlink_to(
egui::RichText::new("erikvl87/languagetool")
.size(CAPTION_SIZE)
.line_height(Some(CAPTION_LINE_HEIGHT)),
"https://hub.docker.com/r/erikvl87/languagetool",
);
ui.label(
egui::RichText::new(
" image and runs it locally, mapped to the port in your \
URL above. Use this if you don't already self-host \
LanguageTool elsewhere.",
)
.size(CAPTION_SIZE)
.line_height(Some(CAPTION_LINE_HEIGHT))
.color(muted),
);
});
}
fn behavior_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("Behavior");
ui.add_space(14.0);
#[cfg(target_os = "linux")]
{
field_label(ui, "Start at login");
ui.add_space(4.0);
let resp = ui.checkbox(
&mut self.autostart_enabled,
"Launch hyprcorrect when I log in",
);
if resp.changed() {
self.clear_status();
}
ui.add_space(6.0);
caption_with_code(
ui,
"Drops a `hyprcorrect.desktop` into `~/.config/autostart/` \
so the daemon starts with your session. Takes effect on save.",
);
ui.add_space(SETTING_BLOCK_SPACING);
}
field_label(ui, "Review popup");
ui.add_space(4.0);
if ui
.checkbox(
&mut self.config.behavior.review_starts_in_vim,
"Open in vim mode",
)
.changed()
{
self.clear_status();
}
ui.add_space(6.0);
caption_with_code(
ui,
"Start the review popup in vim mode — modal editing of the whole \
sentence — instead of word-edit (Tab) mode. `Ctrl+E` toggles between \
the two either way, so with this on it flips to word-edit.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Provider fallback");
ui.add_space(4.0);
if ui
.checkbox(
&mut self.config.behavior.fallback_to_languagetool,
"Try LanguageTool before Spellbook",
)
.changed()
{
self.clear_status();
}
ui.add_space(6.0);
caption(
ui,
"When a fix routed to the LLM can't run — no API key, an unsupported \
backend, or the call fails — try your LanguageTool server before \
dropping to the offline Spellbook. Only takes effect when LanguageTool \
is enabled with a URL in Providers; otherwise fixes fall straight \
through to Spellbook.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Word definitions");
ui.add_space(4.0);
{
use hyprcorrect_core::DefinitionSource as DS;
let mut changed = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let cur = &mut self.config.behavior.definitions;
changed |= ui.selectable_value(cur, DS::Local, "Offline").clicked();
changed |= ui.selectable_value(cur, DS::Online, "Online").clicked();
changed |= ui.selectable_value(cur, DS::Off, "Off").clicked();
});
if changed {
self.clear_status();
}
}
ui.add_space(6.0);
caption(
ui,
"Show a word's definition under the review popup's suggestion \
options, updating as you move between them. Offline uses a bundled \
dictionary (WordNet); Online queries api.dictionaryapi.dev, which \
sends the looked-up word to a third party.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Pause per backspace");
caption(
ui,
"After hyprcorrect dispatches the backspaces, it waits \
this long per backspace before typing the replacement. \
That pause gives the focused app time to actually apply \
the deletes through its own event loop — without it, \
the typing burst can race ahead and leave a prefix of \
the original on screen. Raise it if you see leftover \
characters after a fix lands.",
);
ui.add_space(6.0);
let response = ui.add(
egui::Slider::new(&mut self.config.behavior.pause_per_backspace_ms, 0..=30)
.suffix(" ms"),
);
if response.changed() {
self.clear_status();
}
ui.add_space(6.0);
caption(
ui,
"8 ms is the default and works for most apps. Raise to \
12–15 ms for slow apps like LibreOffice Writer that \
need longer to drain a big backspace burst. Lower to \
4 ms if your apps keep up cleanly — corrections will \
feel snappier.",
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Buffer reset keys");
caption(
ui,
"When you press one of these keys, hyprcorrect clears the \
per-window typing buffer — necessary for keys that \
change the typing context (Enter submits, arrows \
scroll history, Delete edits text the daemon can't \
see). Disable a key to let the buffer survive across \
it, so a follow-up fix-word can still operate on \
already-typed text. Tab and Esc are off by default \
because they rarely change content.",
);
ui.add_space(8.0);
let mut any_changed = false;
let rk = &mut self.config.behavior.reset_keys;
for (label, slot) in [
("Enter / Return", &mut rk.enter),
("Tab", &mut rk.tab),
("Escape", &mut rk.escape),
("Up arrow", &mut rk.up),
("Down arrow", &mut rk.down),
("Page Up", &mut rk.page_up),
("Page Down", &mut rk.page_down),
("Delete (forward)", &mut rk.delete),
("Insert", &mut rk.insert),
] {
if ui.checkbox(slot, label).changed() {
any_changed = true;
}
}
if any_changed {
self.clear_status();
}
}
fn privacy_panel(&mut self, ui: &mut egui::Ui) {
self.refresh_running_apps();
ui.heading("Privacy");
ui.add_space(14.0);
field_label(ui, "App blocklist");
caption(
ui,
"Apps in this list never have their keys buffered. Match is \
case-insensitive against the window class.",
);
ui.add_space(12.0);
let blocked_ids: Vec<String> = self.config.privacy.app_blocklist.clone();
let mut remove: Option<usize> = None;
if blocked_ids.is_empty() {
caption(ui, "(none yet — pick a running app below)");
ui.add_space(8.0);
} else {
for (i, identifier) in blocked_ids.iter().enumerate() {
let meta = self.app_registry.lookup(ui.ctx(), identifier);
ui.horizontal(|ui| {
if let Some(handle) = &meta.icon {
ui.add(egui::Image::new(handle).fit_to_exact_size(egui::vec2(20.0, 20.0)));
} else {
placeholder_app_icon(ui);
}
ui.add_space(6.0);
ui.label(&meta.display_name);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("Remove").frame(false))
.on_hover_text(format!("Remove {} from the blocklist", meta.identifier))
.clicked()
{
remove = Some(i);
}
});
});
ui.add_space(2.0);
}
}
if let Some(i) = remove {
let removed = self.config.privacy.app_blocklist.remove(i);
if self.selected_app.as_deref() == Some(removed.as_str()) {
self.selected_app = None;
}
self.clear_status();
}
ui.add_space(SETTING_BLOCK_SPACING);
ui.separator();
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Add a running app");
ui.add_space(4.0);
let already_blocked: std::collections::HashSet<String> = self
.config
.privacy
.app_blocklist
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
let candidate_ids: Vec<String> = self
.running_apps
.iter()
.filter(|c| !already_blocked.contains(&c.to_ascii_lowercase()))
.cloned()
.collect();
let candidates: Vec<crate::apps::AppMeta> = candidate_ids
.iter()
.map(|id| self.app_registry.lookup(ui.ctx(), id))
.collect();
ui.horizontal(|ui| {
let selected_display = self
.selected_app
.as_deref()
.and_then(|id| candidates.iter().find(|c| c.identifier == id))
.map(|c| c.display_name.clone())
.unwrap_or_else(|| {
if candidates.is_empty() {
"(no running apps detected)".to_string()
} else {
"Choose an app…".to_string()
}
});
let selected_ref = &mut self.selected_app;
let filter = &mut self.app_filter;
let combo_w = (ui.available_width() - 64.0).max(120.0);
egui::ComboBox::from_id_salt("blocklist_app_picker")
.selected_text(selected_display)
.width(combo_w)
.show_ui(ui, |ui| {
ui.set_min_width(combo_w);
ui.add_space(2.0);
ui.add(
egui::TextEdit::singleline(filter)
.hint_text("Search")
.margin(egui::Margin::symmetric(8, 4))
.desired_width(f32::INFINITY),
);
ui.separator();
let needle = filter.to_ascii_lowercase();
egui::ScrollArea::vertical()
.max_height(260.0)
.auto_shrink([false, false])
.show(ui, |ui| {
for c in &candidates {
if !needle.is_empty()
&& !c.display_name.to_ascii_lowercase().contains(&needle)
&& !c.identifier.to_ascii_lowercase().contains(&needle)
{
continue;
}
let is_selected =
selected_ref.as_deref() == Some(c.identifier.as_str());
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), 26.0),
egui::Sense::click(),
);
if ui.is_rect_visible(rect) {
let vis = ui.style().interact_selectable(&resp, is_selected);
if is_selected || resp.hovered() {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
vis.bg_fill,
);
}
let icon_rect = egui::Rect::from_min_size(
egui::pos2(rect.left() + 4.0, rect.center().y - 10.0),
egui::vec2(20.0, 20.0),
);
if let Some(handle) = &c.icon {
egui::Image::new(handle).paint_at(ui, icon_rect);
} else {
ui.painter().rect_filled(
icon_rect.shrink(1.0),
egui::CornerRadius::same(4),
egui::Color32::from_gray(58),
);
}
ui.painter().text(
egui::pos2(icon_rect.right() + 6.0, rect.center().y),
egui::Align2::LEFT_CENTER,
&c.display_name,
egui::TextStyle::Body.resolve(ui.style()),
vis.text_color(),
);
}
if resp.clicked() {
*selected_ref = Some(c.identifier.clone());
}
}
});
});
let can_add = self.selected_app.as_ref().is_some_and(|s| {
!s.is_empty() && !already_blocked.contains(&s.to_ascii_lowercase())
});
if ui.add_enabled(can_add, egui::Button::new("Add")).clicked()
&& let Some(class) = self.selected_app.take()
{
self.config.privacy.app_blocklist.push(class);
self.app_filter.clear();
self.clear_status();
}
});
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Or add by class name");
ui.add_space(4.0);
ui.horizontal(|ui| {
let w = (ui.available_width() - 80.0).max(80.0);
let resp = bordered_text_edit(
ui,
egui::TextEdit::singleline(&mut self.blocklist_entry)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(w),
);
let add_clicked = ui.button("Add").clicked()
|| (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
if add_clicked {
let entry = self.blocklist_entry.trim().to_string();
if !entry.is_empty() && !already_blocked.contains(&entry.to_ascii_lowercase()) {
self.config.privacy.app_blocklist.push(entry);
self.blocklist_entry.clear();
self.clear_status();
}
}
});
ui.add_space(4.0);
caption_with_code(
ui,
"Useful for apps that aren't open yet. The class is whatever \
`hyprctl activewindow` shows for that app.",
);
}
fn about_panel(&mut self, ui: &mut egui::Ui) {
ui.heading("About hyprcorrect");
ui.add_space(14.0);
ui.label(
egui::RichText::new(format!("Version {}", hyprcorrect_core::version()))
.size(15.0)
.strong(),
);
ui.add_space(8.0);
ui.label("Keyboard-driven spelling and typo correction for the whole desktop.");
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Source");
ui.hyperlink("https://github.com/jondkinney/hyprcorrect");
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "License");
caption(ui, "MIT OR Apache-2.0");
}
}
const LLM_DEFAULT_TOOLTIP: &str = "\
Each fix-word chord sends the sentence around the caret plus the \
word at the caret to your configured LLM (default: Anthropic \
Claude). The LLM returns only the corrected word; sentence \
context lets it disambiguate homophones like their/there.
If the picked word looks fine, hyprcorrect tries up to 4 nearby \
words in the same buffer — covers held-arrow caret drift and \
the click-then-trigger case. On any LLM failure (no key, \
timeout, network) we fall back to the offline Spellbook so the \
chord never silently no-ops.
Privacy: your typed text leaves your machine on every chord. \
Pick Spellbook if that's a concern.";
const SMART_PROVIDER_TOOLTIP: &str = "\
Fix-last-sentence and the review popup send the whole sentence around \
the caret to the chosen provider — not just one word.
With the LLM, it returns a corrected version of the ENTIRE sentence, so \
any word in it can change to fix spelling, typos, and minor grammar \
(including homophones like their/there). It's told to preserve your \
wording, voice, and punctuation and to leave already-correct text \
unchanged — it won't freely rephrase. LanguageTool changes only the \
spans it flags; Spellbook only fixes individual misspelled words.
Privacy: the whole sentence leaves your machine when the provider is \
the LLM or a remote LanguageTool.";
fn provider_radio(
ui: &mut egui::Ui,
selection: &mut ProviderId,
llm_tooltip: Option<&str>,
) -> bool {
let before = *selection;
ui.horizontal(|ui| {
ui.radio_value(selection, ProviderId::Spellbook, "Spellbook (offline)");
ui.radio_value(selection, ProviderId::LanguageTool, "LanguageTool");
ui.radio_value(selection, ProviderId::Llm, "LLM");
if let Some(tip) = llm_tooltip {
info_icon(ui).on_hover_text(tip);
}
});
*selection != before
}
fn info_icon(ui: &mut egui::Ui) -> egui::Response {
let font_size = egui::TextStyle::Body.resolve(ui.style()).size;
let size = font_size;
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
if !ui.is_rect_visible(rect) {
return response;
}
let visuals = ui.visuals();
let stroke_color = if response.hovered() {
visuals.strong_text_color()
} else {
visuals.weak_text_color()
};
let painter = ui.painter();
let center = rect.center();
let radius = (size * 0.5) - 1.0;
painter.circle_stroke(center, radius, egui::Stroke::new(1.0, stroke_color));
painter.text(
center + egui::vec2(0.0, -0.5),
egui::Align2::CENTER_CENTER,
"i",
egui::FontId::proportional(size * 0.75),
stroke_color,
);
response
}
const LLM_BACKENDS: &[&str] = &[
"anthropic",
"openai",
"gemini",
"openrouter",
"mistral",
"groq",
"deepseek",
"xai",
"openai-compatible",
];
const CUSTOM_BACKEND: &str = "openai-compatible";
fn is_custom_backend(backend: &str) -> bool {
let b = backend.trim().to_ascii_lowercase();
b == CUSTOM_BACKEND || b == "custom"
}
fn models_for_backend(backend: &str) -> &'static [&'static str] {
match backend.trim().to_ascii_lowercase().as_str() {
"anthropic" => &["claude-haiku-4-5", "claude-sonnet-4-6", "claude-opus-4-8"],
"openai" => &["gpt-4o-mini", "gpt-4o", "o4-mini", "o3", "gpt-4.1"],
"gemini" => &["gemini-2.5-flash", "gemini-2.5-pro"],
"openrouter" => &[
"openai/gpt-4o-mini",
"anthropic/claude-haiku-4-5",
"google/gemini-2.5-flash",
],
"groq" => &["llama-3.1-8b-instant", "llama-3.3-70b-versatile"],
"mistral" => &["mistral-small-latest", "mistral-large-latest"],
"deepseek" => &["deepseek-chat", "deepseek-reasoner"],
"xai" => &["grok-3-mini", "grok-3"],
"openai-compatible" | "custom" => &["llama3.1", "qwen2.5", "gemma2"],
_ => &[],
}
}
fn backend_display(backend: &str) -> String {
let t = backend.trim();
match t.to_ascii_lowercase().as_str() {
"anthropic" => "Anthropic".into(),
"openai" => "OpenAI".into(),
"gemini" => "Gemini".into(),
"openrouter" => "OpenRouter".into(),
"mistral" => "Mistral".into(),
"groq" => "Groq".into(),
"deepseek" => "DeepSeek".into(),
"xai" => "xAI (Grok)".into(),
"openai-compatible" | "custom" => "OpenAI-compatible".into(),
_ => t.to_string(),
}
}
fn not_wired_note(ui: &mut egui::Ui, backend: &str) {
ui.label(
egui::RichText::new(format!(
"{} isn't wired up yet — until it's supported, selecting LLM falls back \
to the offline Spellbook.",
backend_display(backend)
))
.size(CAPTION_SIZE)
.line_height(Some(CAPTION_LINE_HEIGHT))
.color(egui::Color32::from_rgb(220, 160, 50)),
);
}
fn base_url_field(ui: &mut egui::Ui, slot: &mut Option<String>) -> bool {
field_label_with_note(ui, "Base URL", "OpenAI-compatible endpoint");
ui.add_space(4.0);
let mut url = slot.clone().unwrap_or_default();
let changed = padded_text_edit(ui, &mut url).changed();
if changed {
*slot = if url.trim().is_empty() {
None
} else {
Some(url)
};
}
ui.add_space(4.0);
caption(
ui,
"Up to but not including /chat/completions — e.g. http://localhost:11434/v1 \
for a local Ollama server, or your provider's OpenAI-compatible URL.",
);
changed
}
fn api_key_caption(backend: &str) -> &'static str {
if is_custom_backend(backend) {
"Stored in your OS keychain, not in config.toml. Leave blank for local \
servers (e.g. Ollama) that need no key."
} else {
"Stored in your OS keychain, not in config.toml."
}
}
fn tool_in_path(name: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(name).is_file()))
}
fn spawn_folder_pick(initial: Option<String>) -> Receiver<Option<String>> {
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("hyprcorrect-folder-pick".into())
.spawn(move || {
let _ = tx.send(pick_folder(initial.as_deref()));
})
.ok();
rx
}
fn pick_folder(initial: Option<&str>) -> Option<String> {
let initial = initial.map(str::trim).filter(|d| !d.is_empty());
if tool_in_path("zenity") {
let mut cmd = std::process::Command::new("zenity");
cmd.args([
"--file-selection",
"--directory",
"--title=Select n-gram data folder",
]);
if let Some(dir) = initial {
cmd.arg(format!("--filename={}/", dir.trim_end_matches('/')));
}
if let Ok(out) = cmd.output() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
return out
.status
.success()
.then_some(path)
.filter(|p| !p.is_empty());
}
}
if tool_in_path("kdialog") {
let mut cmd = std::process::Command::new("kdialog");
cmd.arg("--getexistingdirectory");
cmd.arg(initial.unwrap_or("."));
if let Ok(out) = cmd.output() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
return out
.status
.success()
.then_some(path)
.filter(|p| !p.is_empty());
}
}
None
}
fn placeholder_app_icon(ui: &mut egui::Ui) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::hover());
ui.painter().rect_filled(
rect.shrink(1.0),
egui::CornerRadius::same(4),
egui::Color32::from_gray(58),
);
}
fn active_dot(ui: &mut egui::Ui) {
let (rect, _) = ui.allocate_exact_size(egui::vec2(10.0, 14.0), egui::Sense::hover());
ui.painter()
.circle_filled(rect.center(), 4.0, egui::Color32::from_rgb(110, 200, 130));
}
fn combo_arrow_button(ui: &mut egui::Ui, height: f32) -> egui::Response {
let resp = ui.add(egui::Button::new("").min_size(egui::vec2(30.0, height)));
let c = resp.rect.center();
let stroke = egui::Stroke::new(1.6, egui::Color32::from_gray(200));
ui.painter().line_segment(
[c + egui::vec2(-4.0, -2.0), c + egui::vec2(0.0, 2.5)],
stroke,
);
ui.painter().line_segment(
[c + egui::vec2(4.0, -2.0), c + egui::vec2(0.0, 2.5)],
stroke,
);
resp
}
fn editable_combo(
ui: &mut egui::Ui,
id_salt: &str,
text: &mut String,
options: &[&str],
hint: &str,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let btn_w = 30.0;
let field_w = (ui.available_width() - btn_w - 4.0 - TEXT_EDIT_MARGIN_X).max(80.0);
let edit = bordered_text_edit(
ui,
egui::TextEdit::singleline(text)
.hint_text(hint)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(field_w),
);
changed |= edit.changed();
let btn = combo_arrow_button(ui, edit.rect.height());
let popup_w = (edit.rect.width() - 12.0).max(80.0);
let toggled = edit.clicked() || btn.clicked();
egui::Popup::menu(&btn)
.anchor(&edit)
.gap(4.0)
.open_memory(toggled.then_some(egui::SetOpenCommand::Toggle))
.id(ui.make_persistent_id((id_salt, "popup")))
.width(popup_w)
.close_behavior(egui::PopupCloseBehavior::CloseOnClick)
.show(|ui| {
ui.set_min_width(popup_w);
ui.set_max_width(popup_w);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
if options.is_empty() {
ui.add_enabled(false, egui::Button::new("(none available)").frame(false));
} else {
for opt in options {
if ui.selectable_label(text.as_str() == *opt, *opt).clicked() {
*text = (*opt).to_string();
changed = true;
}
}
}
});
});
changed
}
impl PrefsApp {
fn llm_providers_section(&mut self, ui: &mut egui::Ui) -> bool {
let mut touched = false;
let backends: Vec<String> = self
.config
.providers
.llms
.iter()
.map(|c| c.backend.clone())
.collect();
let can_add = backends.len() < hyprcorrect_core::config::MAX_LLM_PROVIDERS;
let valid = match &self.llm_tab {
LlmTab::Provider(b) => backends.iter().any(|x| x == b),
LlmTab::Add => can_add,
};
if !valid {
self.llm_tab = backends
.first()
.map(|b| LlmTab::Provider(b.clone()))
.unwrap_or(LlmTab::Add);
}
if !backends.is_empty() {
let mut new_tab: Option<LlmTab> = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for (i, backend) in backends.iter().enumerate() {
let selected = matches!(&self.llm_tab, LlmTab::Provider(b) if b == backend);
if i == 0 {
active_dot(ui);
}
if ui
.selectable_label(selected, backend_display(backend))
.clicked()
{
new_tab = Some(LlmTab::Provider(backend.clone()));
}
}
if can_add
&& ui
.selectable_label(matches!(self.llm_tab, LlmTab::Add), "+ Add Provider")
.clicked()
{
new_tab = Some(LlmTab::Add);
}
});
if let Some(t) = new_tab {
self.llm_tab = t;
}
ui.add_space(12.0);
}
match self.llm_tab.clone() {
LlmTab::Provider(backend) => touched |= self.llm_provider_tab(ui, &backend),
LlmTab::Add => touched |= self.llm_add_tab(ui),
}
touched
}
fn llm_provider_tab(&mut self, ui: &mut egui::Ui, backend: &str) -> bool {
let mut touched = false;
let Some(idx) = self
.config
.providers
.llms
.iter()
.position(|c| c.backend == backend)
else {
return false;
};
let is_active = idx == 0;
let mut promote = false;
let mut remove = false;
let mut active = is_active;
let resp = ui
.add_enabled(!is_active, egui::Checkbox::new(&mut active, "Active"))
.on_hover_text(if is_active {
"This is the active provider — used whenever a provider is set to LLM."
} else {
"Make this the active provider (moves it to the front of the list)."
});
if resp.changed() && active && !is_active {
promote = true;
}
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Provider");
ui.add_space(4.0);
ui.label(
egui::RichText::new(backend_display(backend))
.size(14.0)
.color(egui::Color32::from_gray(200)),
);
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Model");
ui.add_space(4.0);
touched |= editable_combo(
ui,
&format!("model_{backend}"),
&mut self.config.providers.llms[idx].model,
models_for_backend(backend),
"Pick or type a model",
);
if is_custom_backend(backend) {
ui.add_space(SETTING_BLOCK_SPACING);
touched |= base_url_field(ui, &mut self.config.providers.llms[idx].base_url);
}
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "API key");
ui.add_space(4.0);
let key = self.llm_keys.entry(backend.to_string()).or_default();
touched |= padded_password_edit(ui, key).changed();
ui.add_space(4.0);
caption(ui, api_key_caption(backend));
if !hyprcorrect_core::llm::is_backend_wired(backend) {
ui.add_space(6.0);
not_wired_note(ui, backend);
}
ui.add_space(SETTING_BLOCK_SPACING);
if ui
.add(egui::Button::new("Remove provider"))
.on_hover_text("Delete this provider tab. Its saved API key is left in the keychain.")
.clicked()
{
remove = true;
}
if promote {
self.promote_llm(backend);
self.llm_tab = LlmTab::Provider(backend.to_string());
touched = true;
}
if remove {
self.config.providers.llms.retain(|c| c.backend != backend);
self.llm_keys.remove(backend);
self.llm_tab = self
.config
.providers
.llms
.first()
.map(|c| LlmTab::Provider(c.backend.clone()))
.unwrap_or(LlmTab::Add);
touched = true;
}
touched
}
fn llm_add_tab(&mut self, ui: &mut egui::Ui) -> bool {
let mut touched = false;
caption(ui, "Add a hosted LLM (up to 5 providers).");
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Provider");
ui.add_space(4.0);
let before = self.llm_draft.backend.clone();
if editable_combo(
ui,
"add_backend",
&mut self.llm_draft.backend,
LLM_BACKENDS,
"Pick or type a provider",
) {
touched = true;
if self.llm_draft.backend != before {
self.llm_draft.model = models_for_backend(&self.llm_draft.backend)
.first()
.map(|s| (*s).to_string())
.unwrap_or_default();
}
}
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "Model");
ui.add_space(4.0);
let models = models_for_backend(&self.llm_draft.backend);
touched |= editable_combo(
ui,
"add_model",
&mut self.llm_draft.model,
models,
"Pick or type a model",
);
if is_custom_backend(&self.llm_draft.backend) {
ui.add_space(SETTING_BLOCK_SPACING);
touched |= base_url_field(ui, &mut self.llm_draft.base_url);
}
let backend = self.llm_draft.backend.trim().to_string();
let dup = self
.config
.providers
.llms
.iter()
.any(|c| c.backend.eq_ignore_ascii_case(&backend));
let full = self.config.providers.llms.len() >= hyprcorrect_core::config::MAX_LLM_PROVIDERS;
let can_add = !backend.is_empty() && !dup && !full;
ui.add_space(SETTING_BLOCK_SPACING);
field_label(ui, "API key");
ui.add_space(4.0);
let mut save_clicked = false;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let save_w = 118.0;
let field_w = (ui.available_width() - save_w - 6.0 - TEXT_EDIT_MARGIN_X).max(80.0);
touched |= bordered_text_edit(
ui,
egui::TextEdit::singleline(&mut self.llm_draft_key)
.password(true)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(field_w),
)
.changed();
save_clicked = ui
.add_enabled(can_add, egui::Button::new("Save provider"))
.clicked();
});
ui.add_space(4.0);
caption(ui, api_key_caption(&backend));
if !backend.is_empty() && !hyprcorrect_core::llm::is_backend_wired(&backend) {
ui.add_space(6.0);
not_wired_note(ui, &backend);
}
if dup && !backend.is_empty() {
ui.add_space(4.0);
caption(
ui,
&format!("{} already has a tab.", backend_display(&backend)),
);
} else if full {
ui.add_space(4.0);
caption(
ui,
"Maximum of 5 providers reached — remove one to add another.",
);
}
if save_clicked {
let model = if self.llm_draft.model.trim().is_empty() {
models_for_backend(&backend)
.first()
.map(|s| (*s).to_string())
.unwrap_or_default()
} else {
self.llm_draft.model.trim().to_string()
};
let base_url = self
.llm_draft
.base_url
.clone()
.filter(|_| is_custom_backend(&backend))
.filter(|s| !s.trim().is_empty());
self.config.providers.llms.push(LlmConfig {
backend: backend.clone(),
model,
base_url,
});
self.llm_keys
.insert(backend.clone(), self.llm_draft_key.clone());
self.llm_tab = LlmTab::Provider(backend.clone());
self.llm_draft = LlmConfig {
backend: String::new(),
model: String::new(),
base_url: None,
};
self.llm_draft_key.clear();
touched = true;
}
touched
}
fn promote_llm(&mut self, backend: &str) {
if let Some(i) = self
.config
.providers
.llms
.iter()
.position(|c| c.backend == backend)
&& i > 0
{
let c = self.config.providers.llms.remove(i);
self.config.providers.llms.insert(0, c);
}
}
}
fn sidebar_item(ui: &mut egui::Ui, selected: bool, label: &str) -> egui::Response {
let height = 32.0;
let response = ui.allocate_response(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
);
let visuals = ui.style().interact_selectable(&response, selected);
if selected || response.hovered() {
ui.painter().rect_filled(
response.rect.expand(-2.0),
egui::CornerRadius::same(6),
visuals.bg_fill,
);
}
let text_pos = response.rect.left_center() + egui::vec2(12.0, 0.0);
ui.painter().text(
text_pos,
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(14.0),
visuals.text_color(),
);
response
}
fn bordered_text_edit(ui: &mut egui::Ui, te: egui::TextEdit<'_>) -> egui::Response {
ui.add(te.min_size(egui::vec2(0.0, CONTROL_HEIGHT)))
}
fn padded_text_edit(ui: &mut egui::Ui, text: &mut String) -> egui::Response {
bordered_text_edit(
ui,
egui::TextEdit::singleline(text)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(f32::INFINITY),
)
}
fn padded_password_edit(ui: &mut egui::Ui, text: &mut String) -> egui::Response {
bordered_text_edit(
ui,
egui::TextEdit::singleline(text)
.password(true)
.margin(egui::Margin::symmetric(8, 6))
.desired_width(f32::INFINITY),
)
}
fn field_label(ui: &mut egui::Ui, text: &str) {
ui.label(egui::RichText::new(text).strong().size(15.0));
}
fn field_label_with_info(ui: &mut egui::Ui, label: &str, tip: &str) {
ui.horizontal(|ui| {
field_label(ui, label);
info_icon(ui).on_hover_text(tip);
});
}
fn field_label_with_note(ui: &mut egui::Ui, label: &str, note: &str) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
field_label(ui, label);
ui.label(
egui::RichText::new(format!("({note})"))
.size(CAPTION_SIZE)
.color(egui::Color32::from_gray(170)),
);
});
}
const CAPTION_SIZE: f32 = 13.5;
const CAPTION_LINE_HEIGHT: f32 = 20.0;
fn caption(ui: &mut egui::Ui, text: &str) {
ui.label(
egui::RichText::new(text)
.size(CAPTION_SIZE)
.line_height(Some(CAPTION_LINE_HEIGHT))
.color(egui::Color32::from_gray(170)),
);
}
fn caption_with_code(ui: &mut egui::Ui, text: &str) {
use egui::text::LayoutJob;
let plain = egui::TextFormat {
font_id: egui::FontId::proportional(CAPTION_SIZE),
color: egui::Color32::from_gray(170),
line_height: Some(CAPTION_LINE_HEIGHT),
valign: egui::Align::Center,
..Default::default()
};
let code = egui::TextFormat {
font_id: egui::FontId::monospace(CAPTION_SIZE - 1.0),
color: egui::Color32::from_gray(225),
line_height: Some(CAPTION_LINE_HEIGHT),
valign: egui::Align::Center,
..Default::default()
};
const NBSP: char = '\u{00A0}';
let mut job = LayoutJob::default();
job.wrap.max_width = ui.available_width();
let mut in_code = false;
let mut buf = String::new();
let flush = |job: &mut LayoutJob, buf: &mut String, in_code: bool| {
if buf.is_empty() {
return;
}
if in_code {
job.append(&format!("{NBSP}{buf}{NBSP}"), 0.0, code.clone());
} else {
job.append(buf, 0.0, plain.clone());
}
buf.clear();
};
for c in text.chars() {
if c == '`' {
flush(&mut job, &mut buf, in_code);
in_code = !in_code;
} else {
buf.push(c);
}
}
flush(&mut job, &mut buf, in_code);
let galley = ui.fonts(|f| f.layout_job(job));
let (rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover());
let origin = rect.min;
let painter = ui.painter();
let bg_color = egui::Color32::from_gray(48);
type CodeRun = (f32, f32, f32, f32); for row in &galley.rows {
let row_rect = row.rect();
let mut run: Option<CodeRun> = None;
let mut in_run = false;
let flush_run = |run: &mut Option<CodeRun>| {
if let Some((x0, x1, y_min, y_max)) = run.take()
&& y_min.is_finite()
&& y_max.is_finite()
{
painter.rect_filled(
egui::Rect::from_min_max(
egui::pos2(x0 + 1.0, y_min - 2.0),
egui::pos2(x1 - 1.0, y_max + 1.0),
),
3.0,
bg_color,
);
}
};
for glyph in &row.glyphs {
let x0 = origin.x + row_rect.min.x + glyph.pos.x;
let x1 = x0 + glyph.size().x;
let baseline = origin.y + row_rect.min.y + glyph.pos.y;
let gy_min = baseline - glyph.font_ascent;
let gy_max = baseline + (glyph.font_height - glyph.font_ascent);
if glyph.chr == NBSP {
match run {
Some((_, ref mut x_end, _, _)) => *x_end = x1,
None => run = Some((x0, x1, f32::INFINITY, f32::NEG_INFINITY)),
}
if in_run {
in_run = false;
flush_run(&mut run);
} else {
in_run = true;
}
} else if in_run {
match run {
Some((_, ref mut x_end, ref mut y_min, ref mut y_max)) => {
*x_end = x1;
*y_min = y_min.min(gy_min);
*y_max = y_max.max(gy_max);
}
None => run = Some((x0, x1, gy_min, gy_max)),
}
}
}
flush_run(&mut run);
}
painter.galley(origin, galley, egui::Color32::PLACEHOLDER);
}
const SETTING_BLOCK_SPACING: f32 = 22.0;
const TEXT_EDIT_MARGIN_X: f32 = 16.0;
const CONTROL_HEIGHT: f32 = 30.0;
const OMARCHY_LOGO: char = '\u{e900}';
static OMARCHY_FONT_AVAILABLE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub(crate) fn install_glyph_fonts(ctx: &egui::Context) {
use std::sync::Arc;
use std::sync::atomic::Ordering;
let mut fonts = egui::FontDefinitions::default();
let mut shortcut_chain: Vec<String> = Vec::new();
const ADWAITA_SANS: &[u8] = include_bytes!("../assets/AdwaitaSans-Regular.ttf");
fonts.font_data.insert(
"shortcut_symbols".into(),
Arc::new(egui::FontData::from_static(ADWAITA_SANS)),
);
shortcut_chain.push("shortcut_symbols".into());
if let Some(default_chain) = fonts.families.get(&egui::FontFamily::Proportional) {
shortcut_chain.extend(default_chain.iter().cloned());
}
let mut omarchy_candidates: Vec<std::path::PathBuf> = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(".local/share/fonts/omarchy.ttf");
omarchy_candidates.push(p);
}
omarchy_candidates.push("/usr/share/fonts/omarchy.ttf".into());
for path in omarchy_candidates {
if let Ok(bytes) = std::fs::read(&path) {
let mut data = egui::FontData::from_owned(bytes);
data.tweak = egui::FontTweak {
scale: 0.75,
y_offset_factor: 0.09,
..Default::default()
};
fonts.font_data.insert("omarchy".into(), Arc::new(data));
shortcut_chain.push("omarchy".into());
OMARCHY_FONT_AVAILABLE.store(true, Ordering::Relaxed);
break;
}
}
fonts
.families
.insert(egui::FontFamily::Name("shortcut".into()), shortcut_chain);
ctx.set_fonts(fonts);
}
fn chord_glyphs(stored: &str) -> String {
use std::sync::atomic::Ordering;
let omarchy = OMARCHY_FONT_AVAILABLE.load(Ordering::Relaxed);
stored
.split('+')
.filter(|t| !t.trim().is_empty())
.map(|tok| match tok.trim().to_ascii_uppercase().as_str() {
"SUPER" | "META" | "CMD" | "COMMAND" | "WIN" | "WINDOWS" => {
if omarchy {
OMARCHY_LOGO.to_string()
} else {
"⌘".to_string()
}
}
"CTRL" | "CONTROL" => "⌃".to_string(),
"SHIFT" => "⇧".to_string(),
"ALT" | "OPTION" => "⌥".to_string(),
"RETURN" | "ENTER" => "↵".to_string(),
"TAB" => "⇥".to_string(),
"ESCAPE" | "ESC" => "⎋".to_string(),
"BACKSPACE" => "⌫".to_string(),
"DELETE" => "⌦".to_string(),
"SPACE" => "␣".to_string(),
"UP" => "↑".to_string(),
"DOWN" => "↓".to_string(),
"LEFT" => "←".to_string(),
"RIGHT" => "→".to_string(),
"PRIOR" => "PgUp".to_string(),
"NEXT" => "PgDn".to_string(),
"PLUS" => "+".to_string(),
"MINUS" => "-".to_string(),
"EQUAL" => "=".to_string(),
"UNDERSCORE" => "_".to_string(),
other => other.to_string(),
})
.collect::<Vec<_>>()
.join(" ")
}
fn hotkey_chord_row(
ui: &mut egui::Ui,
target: HotkeyTarget,
value: &str,
capturing: Option<HotkeyTarget>,
) -> bool {
let is_capturing_this = capturing == Some(target);
let display = if is_capturing_this {
"Press a shortcut…".to_string()
} else if value.is_empty() {
"Click to set".to_string()
} else {
chord_glyphs(value)
};
chord_chip(ui, &display, is_capturing_this).clicked()
}
fn chord_chip(ui: &mut egui::Ui, display: &str, capturing: bool) -> egui::Response {
let chip_size = egui::vec2(280.0, 32.0);
let resp = ui.allocate_response(chip_size, egui::Sense::click());
let bg = if capturing {
egui::Color32::from_rgb(50, 90, 140)
} else if resp.hovered() {
egui::Color32::from_gray(74)
} else {
egui::Color32::from_gray(56)
};
ui.painter()
.rect_filled(resp.rect, egui::CornerRadius::same(6), bg);
ui.painter().text(
resp.rect.center(),
egui::Align2::CENTER_CENTER,
display,
shortcut_font(17.0),
egui::Color32::WHITE,
);
resp
}
fn shortcut_font(size: f32) -> egui::FontId {
egui::FontId::new(size, egui::FontFamily::Name("shortcut".into()))
}
#[cfg(target_os = "linux")]
fn chord_record_error(err: &ClientError) -> String {
match err {
ClientError::DaemonOffline => {
"Daemon not running — start hyprcorrect, then try recording again.".to_string()
}
ClientError::Cancelled => "Recording cancelled.".to_string(),
ClientError::Daemon(msg) => format!("Daemon error: {msg}"),
ClientError::Io(msg) => format!("Chord-capture IPC failed: {msg}"),
}
}
fn apply_style(ctx: &egui::Context) {
use egui::FontFamily::Proportional;
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
ctx.style_mut(|style| {
style.text_styles = [
(Heading, egui::FontId::new(21.0, Proportional)),
(Body, egui::FontId::new(14.0, Proportional)),
(
Monospace,
egui::FontId::new(13.0, egui::FontFamily::Monospace),
),
(Button, egui::FontId::new(14.0, Proportional)),
(Small, egui::FontId::new(12.0, Proportional)),
]
.into();
style.spacing.item_spacing = egui::vec2(8.0, 8.0);
style.spacing.button_padding = egui::vec2(12.0, 6.0);
style.spacing.indent = 14.0;
style.spacing.interact_size = egui::vec2(40.0, CONTROL_HEIGHT);
style.spacing.icon_width = 18.0;
style.spacing.icon_spacing = 6.0;
style.spacing.scroll = egui::style::ScrollStyle::solid();
style.spacing.scroll.dormant_background_opacity = 0.0;
style.spacing.scroll.active_background_opacity = 0.0;
style.spacing.scroll.interact_background_opacity = 0.0;
let r = egui::CornerRadius::same(4);
let w = &mut style.visuals.widgets;
w.noninteractive.corner_radius = r;
w.noninteractive.expansion = 0.0;
w.inactive.corner_radius = r;
w.inactive.expansion = 0.0;
w.inactive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(72));
w.hovered.corner_radius = r;
w.hovered.expansion = 0.0;
w.hovered.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(110));
w.active.corner_radius = r;
w.active.expansion = 0.0;
w.active.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(140));
w.open.corner_radius = r;
w.open.expansion = 0.0;
w.open.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_gray(110));
});
}
fn daemon_is_stale() -> bool {
let Ok(Some(_pid)) = runtime::read_daemon_pid() else {
return false;
};
let pid_meta = match std::fs::metadata(runtime::pid_path()) {
Ok(m) => m,
Err(_) => return false,
};
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return false,
};
let exe_meta = match std::fs::metadata(&exe) {
Ok(m) => m,
Err(_) => return false,
};
let pid_t = pid_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
let exe_t = exe_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
exe_t > pid_t
}
fn quit_daemon() {
let Ok(Some(pid)) = runtime::read_daemon_pid() else {
return;
};
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.output();
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
fn relaunch_daemon_now() {
let was_running = matches!(runtime::read_daemon_pid(), Ok(Some(_)));
if was_running {
quit_daemon();
let deadline = Instant::now() + Duration::from_secs(1);
while Instant::now() < deadline {
if matches!(runtime::read_daemon_pid(), Ok(None)) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
eprintln!("hyprcorrect: cannot find own executable to relaunch: {e}");
return;
}
};
let result = std::process::Command::new(&exe)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
if let Err(e) = result {
eprintln!("hyprcorrect: could not spawn fresh daemon: {e}");
}
}
fn list_running_classes() -> Vec<String> {
#[cfg(target_os = "linux")]
{
let Ok(output) = std::process::Command::new("hyprctl")
.args(["clients", "-j"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let Ok(text) = std::str::from_utf8(&output.stdout) else {
return Vec::new();
};
let mut classes: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
let needle = "\"class\"";
for chunk in text.split(needle).skip(1) {
let after = chunk
.split_once(':')
.map(|p| p.1)
.unwrap_or(chunk)
.trim_start();
let Some(rest) = after.strip_prefix('"') else {
continue;
};
let Some((value, _)) = rest.split_once('"') else {
continue;
};
if value.is_empty() {
continue;
}
classes
.entry(value.to_ascii_lowercase())
.or_insert_with(|| value.to_string());
}
let mut out: Vec<String> = classes.into_values().collect();
out.sort_by_key(|a| a.to_ascii_lowercase());
out
}
#[cfg(not(target_os = "linux"))]
{
Vec::new()
}
}
fn validate(config: &Config) -> Result<(), String> {
hyprcorrect_core::Chord::parse(&config.hotkeys.fix_word).map_err(|e| {
format!("Fix-last-word chord is invalid ({e}). Click the chip and re-record it.")
})?;
if !config.hotkeys.fix_sentence.is_empty() {
hyprcorrect_core::Chord::parse(&config.hotkeys.fix_sentence)
.map_err(|e| format!("Fix-last-sentence chord is invalid ({e})."))?;
}
if !config.hotkeys.review.is_empty() {
hyprcorrect_core::Chord::parse(&config.hotkeys.review)
.map_err(|e| format!("Review chord is invalid ({e})."))?;
}
Ok(())
}
fn signal_daemon(signal: &str) {
let pid = match runtime::read_daemon_pid() {
Ok(Some(pid)) => pid,
Ok(None) => return, Err(e) => {
eprintln!("hyprcorrect: could not read daemon PID file: {e}");
return;
}
};
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args([signal, &pid.to_string()])
.output();
}
#[cfg(not(unix))]
{
let _ = (pid, signal); }
}
fn notify_daemon_reload() {
signal_daemon("-HUP");
}
fn notify_daemon_release() {
signal_daemon("-USR2");
}
fn acquire_singleton() -> Option<UnixListener> {
let path = singleton_path();
if let Ok(listener) = UnixListener::bind(&path) {
return Some(listener);
}
if UnixStream::connect(&path).is_ok() {
focus_existing_prefs();
return None;
}
let _ = std::fs::remove_file(&path);
UnixListener::bind(&path).ok()
}
fn singleton_path() -> PathBuf {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join("hyprcorrect-prefs.sock")
}
fn focus_existing_prefs() {
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("hyprctl")
.args(["dispatch", "focuswindow", &format!("class:{APP_ID}")])
.output();
}
}
#[cfg(target_os = "linux")]
fn ensure_daemon_running() {
use hyprcorrect_core::runtime::chord_socket_path;
if std::os::unix::net::UnixStream::connect(chord_socket_path()).is_ok() {
return; }
let exe = if std::path::PathBuf::from("/proc/self/exe").exists() {
std::path::PathBuf::from("/proc/self/exe")
} else if let Ok(p) = std::env::current_exe() {
p
} else {
eprintln!("hyprcorrect: can't find own executable to spawn daemon");
return;
};
let result = std::process::Command::new(&exe)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
if let Err(e) = result {
eprintln!("hyprcorrect: failed to spawn daemon: {e}");
}
}
pub(crate) fn run() {
let Some(listener) = acquire_singleton() else {
eprintln!("hyprcorrect: preferences are already open");
return;
};
#[cfg(target_os = "linux")]
ensure_daemon_running();
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
let listener_thread = std::thread::Builder::new()
.name("hyprcorrect-prefs-lock".into())
.spawn(move || {
let _ = listener;
let _ = shutdown_rx.recv();
})
.ok();
let saved = Config::load().unwrap_or_else(|e| {
eprintln!("hyprcorrect: could not load config ({e}) — using defaults");
Config::default()
});
let mut saved_llm_keys: BTreeMap<String, String> = BTreeMap::new();
for llm in &saved.providers.llms {
let key = secrets::get(&hyprcorrect_core::llm::key_name(&llm.backend))
.ok()
.flatten()
.unwrap_or_default();
saved_llm_keys.insert(llm.backend.clone(), key);
}
let initial_section = std::env::var("HYPRCORRECT_PREFS_SECTION")
.ok()
.and_then(|s| Section::from_name(&s))
.unwrap_or(Section::Hotkeys);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_app_id(APP_ID)
.with_title("hyprcorrect — Preferences")
.with_inner_size([640.0, 480.0])
.with_min_inner_size([520.0, 360.0]),
vsync: false, ..Default::default()
};
let _ = eframe::run_native(
"hyprcorrect — Preferences",
options,
Box::new(move |cc| {
install_glyph_fonts(&cc.egui_ctx);
Ok(Box::new(PrefsApp::new(
saved,
saved_llm_keys,
shutdown_tx,
initial_section,
)))
}),
);
let _ = std::fs::remove_file(singleton_path());
if let Some(handle) = listener_thread {
let _ = handle.join();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_accepts_well_formed_chords() {
let mut cfg = Config::default();
for good in ["F", "CTRL+F", "SUPER+CTRL+SHIFT+ALT+F1", "ALT+space"] {
cfg.hotkeys.fix_word = good.into();
assert!(validate(&cfg).is_ok(), "should accept {good:?}");
}
}
#[test]
fn validate_rejects_empty_or_garbage() {
let mut cfg = Config::default();
for bad in ["", " ", "+", "FOO+F", "CTRL+"] {
cfg.hotkeys.fix_word = bad.into();
assert!(validate(&cfg).is_err(), "should reject {bad:?}");
}
}
}