diff --git a/Cargo.lock b/Cargo.lock index 551121be..0d8c64a9 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,11 +166,13 @@ dependencies = [ "anyrun-macros", "chrono", "clap", + "dirs", "gtk", "gtk-layer-shell", "nix", "ron", "serde", + "signal-hook", "wl-clipboard-rs", ] @@ -526,6 +528,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 +1280,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" @@ -1317,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]] @@ -1427,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" @@ -1462,6 +1485,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 +1744,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" @@ -2037,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" @@ -2070,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]] @@ -2244,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/README.md b/README.md index 97d49e6b..9e49bf34 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 = [ @@ -220,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: ``` @@ -236,6 +242,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 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 or copied + +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/Cargo.toml b/anyrun/Cargo.toml index f36b1ac9..561f516a 100644 --- a/anyrun/Cargo.toml +++ b/anyrun/Cargo.toml @@ -15,5 +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 59ff302d..af7a0312 100644 --- a/anyrun/src/main.rs +++ b/anyrun/src/main.rs @@ -5,16 +5,20 @@ use std::{ mem, path::PathBuf, rc::Rc, - sync::Once, - time::Duration, + sync::{Arc, Once}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; 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; +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] @@ -45,10 +49,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 { @@ -97,6 +105,8 @@ impl Default for Config { show_results_immediately: false, max_entries: None, layer: Self::default_layer(), + persist_state: false, + state_ttl_secs: None, } } } @@ -176,6 +186,153 @@ struct RuntimeData { /// Used for displaying errors later on error_label: String, config_dir: String, + state_dir: Option, + last_input: 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 + 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, + last_input: None, + } + } + + 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.ron").to_str().unwrap().to_string() + } + + fn save_state(&self, text: &str) -> io::Result<()> { + if !self.config.persist_state { + return Ok(()); + } + + 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> { + if !self.config.persist_state { + return Ok(None); + } + + 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_secs(); + if now - state.timestamp > u64::from(expiry_secs) { + return Ok(None); + } + } + + Ok(Some(state.text)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } + } + + fn clear_state(&self) -> io::Result<()> { + 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 @@ -214,59 +371,42 @@ 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() + let runtime_data = Rc::new(RefCell::new(RuntimeData::new( + args.config_dir, + 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) }); - // 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> = Rc::new(RefCell::new(RuntimeData { - exclusive: None, - plugins: Vec::new(), - post_run_action: PostRunAction::None, - config, - error_label, - config_dir, - })); - 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 { @@ -468,13 +608,31 @@ fn activate(app: >k::Application, runtime_data: Rc>) { // Refresh the matches when text input 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(); + + 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(); + app.connect_shutdown(move |_| { + if let Err(e) = runtime_data_clone.borrow().persist_state() { + eprintln!("Failed to handle state persistence on shutdown: {}", e); + } }); // 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() { @@ -584,6 +742,7 @@ fn activate(app: >k::Application, runtime_data: Rc>) { (*selected_match.data::("match").unwrap().as_ptr()).clone() }) { HandleResult::Close => { + _runtime_data_clone.last_input = None; window.close(); Inhibit(true) } @@ -599,6 +758,7 @@ fn activate(app: >k::Application, runtime_data: Rc>) { } HandleResult::Copy(bytes) => { _runtime_data_clone.post_run_action = PostRunAction::Copy(bytes.into()); + _runtime_data_clone.last_input = None; window.close(); Inhibit(true) } @@ -606,6 +766,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); } + _runtime_data_clone.last_input = None; window.close(); Inhibit(true) } @@ -631,11 +792,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 || { { @@ -679,11 +848,22 @@ 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 + + // 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 + } } - if runtime_data.borrow().config.show_results_immediately { - // Get initial matches - refresh_matches(String::new(), runtime_data); + // Show initial results if state was loaded or immediate results are configured + let should_show_results = { + let data = runtime_data.borrow(); + initial_text_inner.is_some() || 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"