From f5c2fb9afee6bbbfa567cc8a72db700dffd81ca4 Mon Sep 17 00:00:00 2001 From: Jeremy T Date: Thu, 10 Apr 2025 23:33:41 +0200 Subject: [PATCH 1/6] feat: add input state persistence Add the ability to save and restore input text between sessions - save input text to state.ron when window closes - restore text when application launches - clear state when match is selected - add persist_state config option - add state_ttl_secs config option to expiry a saved state after some time - update documentation and examples - add home-manager module support --- README.md | 23 ++++++++ anyrun/src/main.rs | 111 ++++++++++++++++++++++++++++++++--- examples/config.ron | 9 ++- nix/modules/home-manager.nix | 18 ++++++ 4 files changed, 151 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 97d49e6b..1550da35 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ A wayland native krunner-like runner, made with customizability in mind. documentation of the [anyrun-plugin](anyrun-plugin) crate. - Responsive - Asynchronous running of plugin functions +- State persistence + - Optional saving and restoring of input text between sessions + - Automatically clears state when selecting a match + - Can be configured to automatically discard state after a certain time - Wayland native - GTK layer shell for overlaying the window - data-control for managing the clipboard @@ -117,6 +121,8 @@ You may use it in your system like this: hidePluginInfo = false; closeOnClick = false; showResultsImmediately = false; + persistState = false; + stateTtlSecs = null; maxEntries = null; plugins = [ @@ -229,6 +235,7 @@ the config directory is as follows and should be respected by plugins: - - config.ron - style.css + - state.ron # Optional, used to retain state when state saving is enabled - ``` @@ -236,6 +243,22 @@ The [default config file](examples/config.ron) contains the default values, and annotates all configuration options with comments on what they are and how to use them. +### State Saving + +When `persist_state` is set to `true` in the config, Anyrun will: +- Save the input text to `state.ron` when the window is closed +- Restore this text when Anyrun is launched again +- Clear the saved state when a match is selected + +You can optionally set `state_ttl_secs` to automatically discard saved state after a certain time. For example: +```ron +// Enable state persistence with 2-minute TTL +persist_state: true, +state_ttl_secs: Some(120) +``` + +This is useful for preserving your input between sessions, especially for longer queries or calculations. + ## Styling Anyrun supports [GTK+ CSS](https://docs.gtk.org/gtk3/css-overview.html) styling. diff --git a/anyrun/src/main.rs b/anyrun/src/main.rs index 59ff302d..124fcbce 100644 --- a/anyrun/src/main.rs +++ b/anyrun/src/main.rs @@ -1,12 +1,12 @@ use std::{ cell::RefCell, env, fs, - io::{self, Write}, + io::{self, BufRead, BufReader, Read, Write}, mem, path::PathBuf, rc::Rc, sync::Once, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use abi_stable::std_types::{ROption, RVec}; @@ -14,7 +14,7 @@ use anyrun_interface::{HandleResult, Match, PluginInfo, PluginRef, PollResult}; use clap::{Parser, ValueEnum}; use gtk::{gdk, gdk_pixbuf, gio, glib, prelude::*}; use nix::unistd; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use wl_clipboard_rs::copy; #[anyrun_macros::config_args] @@ -49,6 +49,10 @@ struct Config { max_entries: Option, #[serde(default = "Config::default_layer")] layer: Layer, + #[serde(default)] + persist_state: bool, + #[serde(default)] + state_ttl_secs: Option, } impl Config { @@ -97,6 +101,8 @@ impl Default for Config { show_results_immediately: false, max_entries: None, layer: Self::default_layer(), + persist_state: false, + state_ttl_secs: None, } } } @@ -178,6 +184,67 @@ struct RuntimeData { config_dir: String, } +impl RuntimeData { + fn state_file(&self) -> String { + format!("{}/state.txt", self.config_dir) + } + + fn save_state(&self, text: &str) -> io::Result<()> { + if !self.config.persist_state { + return Ok(()); + } + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + + let mut file = fs::File::create(self.state_file())?; + writeln!(file, "{}", timestamp)?; + write!(file, "{}", text) + } + + fn load_state(&self) -> io::Result { + if !self.config.persist_state { + return Ok(String::new()); + } + match fs::File::open(self.state_file()) { + Ok(file) => { + let mut reader = BufReader::new(file); + + // Read timestamp from first line + let mut timestamp_str = String::new(); + reader.read_line(&mut timestamp_str)?; + let timestamp = timestamp_str.trim().parse::().unwrap_or(0); + + // Check if state has expired + if let Some(expiry_secs) = self.config.state_ttl_secs { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + if now - timestamp > u128::from(expiry_secs) * 1000 { + return Ok(String::new()); + } + } + + // Read text from second line to end + let mut text = String::new(); + reader.read_to_string(&mut text)?; + Ok(text) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(e), + } + } + + fn clear_state(&self) -> io::Result<()> { + if !self.config.persist_state { + return Ok(()); + } + fs::write(self.state_file(), "0\n") + } +} + /// The naming scheme for CSS styling /// /// Refer to [GTK 3.0 CSS Overview](https://docs.gtk.org/gtk3/css-overview.html) @@ -251,7 +318,7 @@ fn main() { config.merge_opt(args.config); - let runtime_data: Rc> = Rc::new(RefCell::new(RuntimeData { + let runtime_data = Rc::new(RefCell::new(RuntimeData { exclusive: None, plugins: Vec::new(), post_run_action: PostRunAction::None, @@ -465,10 +532,21 @@ fn activate(app: >k::Application, runtime_data: Rc>) { .name(style_names::ENTRY) .build(); - // Refresh the matches when text input changes + // Set initial text from loaded state + if let Ok(initial_text) = runtime_data.borrow().load_state() { + entry.set_text(&initial_text); + } else { + eprintln!("Failed to load state"); + } + + // Update last_input, save state and refresh matches when text changes let runtime_data_clone = runtime_data.clone(); entry.connect_changed(move |entry| { - refresh_matches(entry.text().to_string(), runtime_data_clone.clone()) + let text = entry.text().to_string(); + if let Err(e) = runtime_data_clone.borrow().save_state(&text) { + eprintln!("Failed to save state: {}", e); + } + refresh_matches(text, runtime_data_clone.clone()); }); // Handle other key presses for selection control and all other things that may be needed @@ -584,6 +662,9 @@ fn activate(app: >k::Application, runtime_data: Rc>) { (*selected_match.data::("match").unwrap().as_ptr()).clone() }) { HandleResult::Close => { + if let Err(e) = _runtime_data_clone.clear_state() { + eprintln!("Failed to clear state: {}", e); + } window.close(); Inhibit(true) } @@ -599,6 +680,9 @@ fn activate(app: >k::Application, runtime_data: Rc>) { } HandleResult::Copy(bytes) => { _runtime_data_clone.post_run_action = PostRunAction::Copy(bytes.into()); + if let Err(e) = _runtime_data_clone.clear_state() { + eprintln!("Failed to clear state: {}", e); + } window.close(); Inhibit(true) } @@ -606,6 +690,9 @@ fn activate(app: >k::Application, runtime_data: Rc>) { if let Err(why) = io::stdout().lock().write_all(&bytes) { eprintln!("Error outputting content to stdout: {}", why); } + if let Err(e) = _runtime_data_clone.clear_state() { + eprintln!("Failed to clear state: {}", e); + } window.close(); Inhibit(true) } @@ -679,11 +766,17 @@ fn activate(app: >k::Application, runtime_data: Rc>) { main_vbox.add(&main_list); main_list.show(); entry.grab_focus(); // Grab the focus so typing is immediately accepted by the entry box + entry.set_position(-1); // -1 moves cursor to end of text in case some text was restored } - if runtime_data.borrow().config.show_results_immediately { - // Get initial matches - refresh_matches(String::new(), runtime_data); + // Show initial results if state restoration is enabled or immediate results are configured + let should_show_results = { + let data = runtime_data.borrow(); + data.config.persist_state || data.config.show_results_immediately + }; + + if should_show_results { + refresh_matches(entry.text().to_string(), runtime_data); } }); diff --git a/examples/config.ron b/examples/config.ron index 145be442..e761ab39 100644 --- a/examples/config.ron +++ b/examples/config.ron @@ -33,9 +33,16 @@ Config( // Show search results immediately when Anyrun starts show_results_immediately: false, + // Whether to save and restore input text between sessions + persist_state: false, + + // Time in seconds after which saved state is discarded + // For example, set to 120 to clear state after 2 minutes of inactivity + state_ttl_secs: None, + // Limit amount of entries shown in total max_entries: None, - + // List of plugins to be loaded by default, can be specified with a relative path to be loaded from the // `/plugins` directory or with an absolute path to just load the file the path points to. plugins: [ diff --git a/nix/modules/home-manager.nix b/nix/modules/home-manager.nix index a38a6e00..4692435a 100644 --- a/nix/modules/home-manager.nix +++ b/nix/modules/home-manager.nix @@ -144,6 +144,18 @@ in { description = "Show search results immediately when Anyrun starts"; }; + persistState = mkOption { + type = bool; + default = false; + description = "Whether to save and restore input text between sessions"; + }; + + stateTtlSecs = mkOption { + type = nullOr int; + default = null; + description = "Time in seconds after which saved state is discarded"; + }; + maxEntries = mkOption { type = nullOr int; default = null; @@ -233,6 +245,12 @@ in { hide_plugin_info: ${boolToString cfg.config.hidePluginInfo}, close_on_click: ${boolToString cfg.config.closeOnClick}, show_results_immediately: ${boolToString cfg.config.showResultsImmediately}, + persist_state: ${boolToString cfg.config.persistState}, + state_ttl_secs: ${ + if cfg.config.stateTtlSecs == null + then "None" + else "Some(${toString cfg.config.stateTtlSecs})" + }, max_entries: ${ if cfg.config.maxEntries == null then "None" From 599dd13da65e39f0746e89e57e9358538cb2a46e Mon Sep 17 00:00:00 2001 From: Jeremy T Date: Fri, 11 Apr 2025 12:32:32 +0200 Subject: [PATCH 2/6] pr: uniformise dirs for state and config --- Cargo.lock | 51 +++++++++++++++++++- anyrun/Cargo.toml | 1 + anyrun/src/main.rs | 117 +++++++++++++++++++++++++++------------------ 3 files changed, 122 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 551121be..a8b9c545 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "abi_stable" @@ -166,6 +166,7 @@ dependencies = [ "anyrun-macros", "chrono", "clap", + "dirs", "gtk", "gtk-layer-shell", "nix", @@ -526,6 +527,27 @@ dependencies = [ "serde", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dlib" version = "0.5.2" @@ -1257,6 +1279,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.4.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1462,6 +1494,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_pipe" version = "1.2.1" @@ -1715,6 +1753,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.9.3" diff --git a/anyrun/Cargo.toml b/anyrun/Cargo.toml index f36b1ac9..772a1fbe 100644 --- a/anyrun/Cargo.toml +++ b/anyrun/Cargo.toml @@ -17,3 +17,4 @@ wl-clipboard-rs = "0.9.1" nix = { version = "0.29", default-features = false, features = ["process"] } clap = { version = "4.2.7", features = ["derive"] } chrono = { version = "0.4.38", default-features = false, features = ["clock"] } +dirs = "5.0.1" diff --git a/anyrun/src/main.rs b/anyrun/src/main.rs index 124fcbce..862dd34f 100644 --- a/anyrun/src/main.rs +++ b/anyrun/src/main.rs @@ -12,6 +12,7 @@ use std::{ use abi_stable::std_types::{ROption, RVec}; use anyrun_interface::{HandleResult, Match, PluginInfo, PluginRef, PollResult}; use clap::{Parser, ValueEnum}; +use dirs; use gtk::{gdk, gdk_pixbuf, gio, glib, prelude::*}; use nix::unistd; use serde::{Deserialize, Serialize}; @@ -182,11 +183,76 @@ struct RuntimeData { /// Used for displaying errors later on error_label: String, config_dir: String, + state_dir: Option, } impl RuntimeData { + fn new(config_dir_path: Option, cli_config: ConfigArgs) -> Self { + // Setup config directory + let config_dir = config_dir_path.unwrap_or_else(|| { + dirs::config_dir() + .map(|dir| dir.join("anyrun")) + .and_then(|path| path.to_str().map(String::from)) + .filter(|path| PathBuf::from(path).exists()) + .unwrap_or_else(|| DEFAULT_CONFIG_DIR.to_string()) + }); + + // Load config, if unable to then read default config + let (mut config, error_label) = match fs::read_to_string(format!("{}/config.ron", config_dir)) { + Ok(content) => ron::from_str(&content) + .map(|config| (config, String::new())) + .unwrap_or_else(|why| { + ( + Config::default(), + format!( + "Failed to parse Anyrun config file, using default config: {}", + why + ), + ) + }), + Err(why) => ( + Config::default(), + format!( + "Failed to read Anyrun config file, using default config: {}", + why + ), + ), + }; + + // Merge CLI config if provided + config.merge_opt(cli_config); + + // Setup state directory only if persistence is enabled + let state_dir = if config.persist_state { + let state_dir = dirs::state_dir() + .unwrap_or_else(|| dirs::cache_dir().expect("Failed to get state or cache directory")) + .join("anyrun"); + + // Ensure atomically that the directory exists + if let Err(e) = fs::create_dir_all(&state_dir) { + eprintln!("Failed to create state directory at {}: {}", state_dir.display(), e); + std::process::exit(1); + } + + Some(state_dir.to_str().unwrap().to_string()) + } else { + None + }; + + Self { + exclusive: None, + plugins: Vec::new(), + post_run_action: PostRunAction::None, + config, + error_label, + config_dir, + state_dir, + } + } + fn state_file(&self) -> String { - format!("{}/state.txt", self.config_dir) + let state_dir = self.state_dir.as_ref().expect("state operations called when persistence is disabled"); + PathBuf::from(state_dir).join("state.txt").to_str().unwrap().to_string() } fn save_state(&self, text: &str) -> io::Result<()> { @@ -281,51 +347,10 @@ fn main() { let args = Args::parse(); - // Figure out the config dir - let user_dir = format!( - "{}/.config/anyrun", - env::var("HOME").expect("Could not determine home directory! Is $HOME set?") - ); - let config_dir = args.config_dir.unwrap_or_else(|| { - if PathBuf::from(&user_dir).exists() { - user_dir - } else { - DEFAULT_CONFIG_DIR.to_string() - } - }); - - // Load config, if unable to then read default config. If an error occurs the message will be displayed. - let (mut config, error_label) = match fs::read_to_string(format!("{}/config.ron", config_dir)) { - Ok(content) => ron::from_str(&content) - .map(|config| (config, String::new())) - .unwrap_or_else(|why| { - ( - Config::default(), - format!( - "Failed to parse Anyrun config file, using default config: {}", - why - ), - ) - }), - Err(why) => ( - Config::default(), - format!( - "Failed to read Anyrun config file, using default config: {}", - why - ), - ), - }; - - config.merge_opt(args.config); - - let runtime_data = Rc::new(RefCell::new(RuntimeData { - exclusive: None, - plugins: Vec::new(), - post_run_action: PostRunAction::None, - config, - error_label, - config_dir, - })); + let runtime_data = Rc::new(RefCell::new(RuntimeData::new( + args.config_dir, + args.config, + ))); let runtime_data_clone = runtime_data.clone(); app.connect_activate(move |app| activate(app, runtime_data_clone.clone())); From 9709ddb41c921e073e54a7040ec3afea44f98543 Mon Sep 17 00:00:00 2001 From: Jeremy T Date: Fri, 11 Apr 2025 14:31:57 +0200 Subject: [PATCH 3/6] pr: switched to ron for state persistance --- anyrun/src/main.rs | 102 ++++++++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/anyrun/src/main.rs b/anyrun/src/main.rs index 862dd34f..c10672d0 100644 --- a/anyrun/src/main.rs +++ b/anyrun/src/main.rs @@ -1,7 +1,7 @@ use std::{ cell::RefCell, env, fs, - io::{self, BufRead, BufReader, Read, Write}, + io::{self, Write}, mem, path::PathBuf, rc::Rc, @@ -46,14 +46,14 @@ struct Config { close_on_click: bool, #[serde(default)] show_results_immediately: bool, - #[serde(default)] - max_entries: Option, #[serde(default = "Config::default_layer")] layer: Layer, #[serde(default)] persist_state: bool, #[serde(default)] state_ttl_secs: Option, + #[serde(default)] + max_entries: Option, } impl Config { @@ -186,6 +186,12 @@ struct RuntimeData { state_dir: Option, } +#[derive(Deserialize, Serialize)] +struct PersistentState { + timestamp: u64, + text: String, +} + impl RuntimeData { fn new(config_dir_path: Option, cli_config: ConfigArgs) -> Self { // Setup config directory @@ -252,53 +258,50 @@ impl RuntimeData { fn state_file(&self) -> String { let state_dir = self.state_dir.as_ref().expect("state operations called when persistence is disabled"); - PathBuf::from(state_dir).join("state.txt").to_str().unwrap().to_string() + PathBuf::from(state_dir).join("state.ron").to_str().unwrap().to_string() } fn save_state(&self, text: &str) -> io::Result<()> { if !self.config.persist_state { return Ok(()); } - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); - - let mut file = fs::File::create(self.state_file())?; - writeln!(file, "{}", timestamp)?; - write!(file, "{}", text) + + let state = PersistentState { + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + text: text.to_string(), + }; + + fs::write(self.state_file(), ron::ser::to_string_pretty(&state, ron::ser::PrettyConfig::default()) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?) } - fn load_state(&self) -> io::Result { + fn load_state(&self) -> io::Result> { if !self.config.persist_state { - return Ok(String::new()); + return Ok(None); } - match fs::File::open(self.state_file()) { - Ok(file) => { - let mut reader = BufReader::new(file); - - // Read timestamp from first line - let mut timestamp_str = String::new(); - reader.read_line(&mut timestamp_str)?; - let timestamp = timestamp_str.trim().parse::().unwrap_or(0); - + + match fs::read_to_string(self.state_file()) { + Ok(content) => { + let state: PersistentState = ron::from_str(&content) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + // Check if state has expired if let Some(expiry_secs) = self.config.state_ttl_secs { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() - .as_millis(); - if now - timestamp > u128::from(expiry_secs) * 1000 { - return Ok(String::new()); + .as_secs(); + if now - state.timestamp > u64::from(expiry_secs) { + return Ok(None); } } - - // Read text from second line to end - let mut text = String::new(); - reader.read_to_string(&mut text)?; - Ok(text) + + Ok(Some(state.text)) } - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(String::new()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e), } } @@ -307,7 +310,12 @@ impl RuntimeData { if !self.config.persist_state { return Ok(()); } - fs::write(self.state_file(), "0\n") + + match fs::remove_file(self.state_file()) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), // File doesn't exist = already cleared + Err(e) => Err(e), + } } } @@ -557,13 +565,6 @@ fn activate(app: >k::Application, runtime_data: Rc>) { .name(style_names::ENTRY) .build(); - // Set initial text from loaded state - if let Ok(initial_text) = runtime_data.borrow().load_state() { - entry.set_text(&initial_text); - } else { - eprintln!("Failed to load state"); - } - // Update last_input, save state and refresh matches when text changes let runtime_data_clone = runtime_data.clone(); entry.connect_changed(move |entry| { @@ -743,11 +744,19 @@ fn activate(app: >k::Application, runtime_data: Rc>) { // Only create the widgets once to avoid issues let configure_once = Once::new(); - // Create widgets here for proper positioning + // Load initial state before configuring + let initial_text = if runtime_data.borrow().config.persist_state { + runtime_data.borrow().load_state().ok().flatten() + } else { + None + }; + let initial_text_clone = initial_text.clone(); + window.connect_configure_event(move |window, event| { let runtime_data = runtime_data.clone(); let entry = entry.clone(); let main_list = main_list.clone(); + let initial_text_inner = initial_text_clone.clone(); configure_once.call_once(move || { { @@ -791,13 +800,18 @@ fn activate(app: >k::Application, runtime_data: Rc>) { main_vbox.add(&main_list); main_list.show(); entry.grab_focus(); // Grab the focus so typing is immediately accepted by the entry box - entry.set_position(-1); // -1 moves cursor to end of text in case some text was restored + + // Set initial text if we loaded state + if let Some(text) = &initial_text_inner { + entry.set_text(text); + entry.set_position(-1); // -1 moves cursor to end of text + } } - // Show initial results if state restoration is enabled or immediate results are configured + // Show initial results if state was loaded or immediate results are configured let should_show_results = { let data = runtime_data.borrow(); - data.config.persist_state || data.config.show_results_immediately + initial_text_inner.is_some() || data.config.show_results_immediately }; if should_show_results { From 5fd356edb45be8a52dc52f2e9d99f4b8614d6075 Mon Sep 17 00:00:00 2001 From: Jeremy T Date: Fri, 11 Apr 2025 17:14:56 +0200 Subject: [PATCH 4/6] pr: saving at exit or kill, on window removal --- anyrun/src/main.rs | 58 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/anyrun/src/main.rs b/anyrun/src/main.rs index c10672d0..1bc4c580 100644 --- a/anyrun/src/main.rs +++ b/anyrun/src/main.rs @@ -184,6 +184,7 @@ struct RuntimeData { error_label: String, config_dir: String, state_dir: Option, + last_input: Option, } #[derive(Deserialize, Serialize)] @@ -253,6 +254,7 @@ impl RuntimeData { error_label, config_dir, state_dir, + last_input: None, } } @@ -265,7 +267,7 @@ impl RuntimeData { if !self.config.persist_state { return Ok(()); } - + let state = PersistentState { timestamp: SystemTime::now() .duration_since(UNIX_EPOCH) @@ -273,11 +275,11 @@ impl RuntimeData { .as_secs(), text: text.to_string(), }; - + fs::write(self.state_file(), ron::ser::to_string_pretty(&state, ron::ser::PrettyConfig::default()) .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?) } - + fn load_state(&self) -> io::Result> { if !self.config.persist_state { return Ok(None); @@ -310,13 +312,24 @@ impl RuntimeData { if !self.config.persist_state { return Ok(()); } - + match fs::remove_file(self.state_file()) { Ok(()) => Ok(()), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), // File doesn't exist = already cleared Err(e) => Err(e), } } + + fn persist_state(&self) -> io::Result<()> { + if !self.config.persist_state { + return Ok(()); + } + + match &self.last_input { + Some(text) => self.save_state(text), + None => self.clear_state(), + } + } } /// The naming scheme for CSS styling @@ -565,20 +578,35 @@ fn activate(app: >k::Application, runtime_data: Rc>) { .name(style_names::ENTRY) .build(); - // Update last_input, save state and refresh matches when text changes + // Refresh the matches when text input changes let runtime_data_clone = runtime_data.clone(); entry.connect_changed(move |entry| { let text = entry.text().to_string(); - if let Err(e) = runtime_data_clone.borrow().save_state(&text) { - eprintln!("Failed to save state: {}", e); + + refresh_matches(text.clone(), runtime_data_clone.clone()); + + let runtime_data_update = runtime_data_clone.clone(); + + // idle_add_local_once is needed to avoid borrow conflicts with the entry widget + glib::idle_add_local_once(move || { + runtime_data_update.borrow_mut().last_input = Some(text); + }); + }); + + + // Persist state when window is removed + let runtime_data_clone = runtime_data.clone(); + window.connect_delete_event(move |_, _| { + if let Err(e) = runtime_data_clone.borrow().persist_state() { + eprintln!("Failed to handle state persistence on shutdown: {}", e); } - refresh_matches(text, runtime_data_clone.clone()); + Inhibit(false) }); // Handle other key presses for selection control and all other things that may be needed let entry_clone = entry.clone(); let runtime_data_clone = runtime_data.clone(); - + window.connect_key_press_event(move |window, event| { use gdk::keys::constants; match event.keyval() { @@ -688,9 +716,7 @@ fn activate(app: >k::Application, runtime_data: Rc>) { (*selected_match.data::("match").unwrap().as_ptr()).clone() }) { HandleResult::Close => { - if let Err(e) = _runtime_data_clone.clear_state() { - eprintln!("Failed to clear state: {}", e); - } + _runtime_data_clone.last_input = None; window.close(); Inhibit(true) } @@ -706,9 +732,7 @@ fn activate(app: >k::Application, runtime_data: Rc>) { } HandleResult::Copy(bytes) => { _runtime_data_clone.post_run_action = PostRunAction::Copy(bytes.into()); - if let Err(e) = _runtime_data_clone.clear_state() { - eprintln!("Failed to clear state: {}", e); - } + _runtime_data_clone.last_input = None; window.close(); Inhibit(true) } @@ -716,9 +740,7 @@ fn activate(app: >k::Application, runtime_data: Rc>) { if let Err(why) = io::stdout().lock().write_all(&bytes) { eprintln!("Error outputting content to stdout: {}", why); } - if let Err(e) = _runtime_data_clone.clear_state() { - eprintln!("Failed to clear state: {}", e); - } + _runtime_data_clone.last_input = None; window.close(); Inhibit(true) } From d5782884e87c74de7c82a7c5766cd5da8aa52c27 Mon Sep 17 00:00:00 2001 From: Jeremy T Date: Fri, 11 Apr 2025 17:19:44 +0200 Subject: [PATCH 5/6] doc: fix doc file locations --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1550da35..9e49bf34 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ list of plugins in this repository is as follows: ## Configuration -The default configuration directory is `$HOME/.config/anyrun` the structure of +The default configuration directory in the config dir (`$XDG_CONFIG_HOME/anyrun` or `$HOME/.config/anyrun`), the structure of the config directory is as follows and should be respected by plugins: ``` @@ -235,7 +235,6 @@ the config directory is as follows and should be respected by plugins: - - config.ron - style.css - - state.ron # Optional, used to retain state when state saving is enabled - ``` @@ -246,9 +245,9 @@ use them. ### State Saving When `persist_state` is set to `true` in the config, Anyrun will: -- Save the input text to `state.ron` when the window is closed +- Save the input text to a state file (`$XDG_STATE_HOME/anyrun` or `$HOME/.local/state/anyrun`), when the window is closed - Restore this text when Anyrun is launched again -- Clear the saved state when a match is selected +- Clear the saved state when a match is selected or copied You can optionally set `state_ttl_secs` to automatically discard saved state after a certain time. For example: ```ron From 0fcd9a68603d01ee401dd2d6ad8f4d8d96190d18 Mon Sep 17 00:00:00 2001 From: Jeremy T Date: Fri, 11 Apr 2025 23:19:58 +0200 Subject: [PATCH 6/6] pr: fix saving not working sometime by handling directly signals sigint, sigterm, ... --- Cargo.lock | 55 +++++++++++++++++++++++++++------------------- anyrun/Cargo.toml | 1 + anyrun/src/main.rs | 34 ++++++++++++++++++++++++---- 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8b9c545..0d8c64a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,7 @@ dependencies = [ "nix", "ron", "serde", + "signal-hook", "wl-clipboard-rs", ] @@ -1349,13 +1350,13 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -1459,16 +1460,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "object" version = "0.31.1" @@ -2086,6 +2077,25 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -2119,12 +2129,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.3" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -2293,26 +2303,25 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.32.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +checksum = "2209a14885b74764cce87ffa777ffa1b8ce81a3f3166c6f886b83337fe7e077f" dependencies = [ "backtrace", "bytes", "libc", "mio", - "num_cpus", "pin-project-lite", - "socket2 0.5.3", + "socket2 0.5.8", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", diff --git a/anyrun/Cargo.toml b/anyrun/Cargo.toml index 772a1fbe..561f516a 100644 --- a/anyrun/Cargo.toml +++ b/anyrun/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1.0.210", features = ["derive"] } anyrun-interface = { path = "../anyrun-interface" } wl-clipboard-rs = "0.9.1" nix = { version = "0.29", default-features = false, features = ["process"] } +signal-hook = "0.3.17" clap = { version = "4.2.7", features = ["derive"] } chrono = { version = "0.4.38", default-features = false, features = ["clock"] } dirs = "5.0.1" diff --git a/anyrun/src/main.rs b/anyrun/src/main.rs index 1bc4c580..af7a0312 100644 --- a/anyrun/src/main.rs +++ b/anyrun/src/main.rs @@ -5,7 +5,7 @@ use std::{ mem, path::PathBuf, rc::Rc, - sync::Once, + sync::{Arc, Once}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -16,6 +16,9 @@ use dirs; use gtk::{gdk, gdk_pixbuf, gio, glib, prelude::*}; use nix::unistd; use serde::{Deserialize, Serialize}; +use signal_hook::consts::TERM_SIGNALS; +use signal_hook::flag as signal_flag; +use std::sync::atomic::{AtomicBool, Ordering}; use wl_clipboard_rs::copy; #[anyrun_macros::config_args] @@ -373,13 +376,37 @@ fn main() { args.config, ))); + // Register termination signal handlers (SIGTERM, SIGINT, etc.) + let termination_requested = Arc::new(AtomicBool::new(false)); + for sig in TERM_SIGNALS { + signal_flag::register(*sig, Arc::clone(&termination_requested)) + .expect("Failed to register signal handler"); + } + + // Set up a periodic check for termination signals + let term_flag = Arc::clone(&termination_requested); + let runtime_data_sig = runtime_data.clone(); + + glib::timeout_add_local(Duration::from_millis(100), move || { + if term_flag.load(Ordering::Relaxed) { + // A termination signal was received, save state before exiting + if let Err(e) = runtime_data_sig.borrow().persist_state() { + eprintln!("Failed to save state on signal termination: {}", e); + } + + std::process::exit(0); + } + + glib::Continue(true) + }); + let runtime_data_clone = runtime_data.clone(); app.connect_activate(move |app| activate(app, runtime_data_clone.clone())); // Run with no args to make sure only clap is used app.run_with_args::(&[]); - let runtime_data = runtime_data.borrow_mut(); + let runtime_data = runtime_data.borrow(); // Perform a post run action if one is set match &runtime_data.post_run_action { @@ -596,11 +623,10 @@ fn activate(app: >k::Application, runtime_data: Rc>) { // Persist state when window is removed let runtime_data_clone = runtime_data.clone(); - window.connect_delete_event(move |_, _| { + app.connect_shutdown(move |_| { if let Err(e) = runtime_data_clone.borrow().persist_state() { eprintln!("Failed to handle state persistence on shutdown: {}", e); } - Inhibit(false) }); // Handle other key presses for selection control and all other things that may be needed