Skip to content

Commit f5c2fb9

Browse files
committed
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
1 parent 786f539 commit f5c2fb9

File tree

4 files changed

+151
-10
lines changed

4 files changed

+151
-10
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ A wayland native krunner-like runner, made with customizability in mind.
2727
documentation of the [anyrun-plugin](anyrun-plugin) crate.
2828
- Responsive
2929
- Asynchronous running of plugin functions
30+
- State persistence
31+
- Optional saving and restoring of input text between sessions
32+
- Automatically clears state when selecting a match
33+
- Can be configured to automatically discard state after a certain time
3034
- Wayland native
3135
- GTK layer shell for overlaying the window
3236
- data-control for managing the clipboard
@@ -117,6 +121,8 @@ You may use it in your system like this:
117121
hidePluginInfo = false;
118122
closeOnClick = false;
119123
showResultsImmediately = false;
124+
persistState = false;
125+
stateTtlSecs = null;
120126
maxEntries = null;
121127
122128
plugins = [
@@ -229,13 +235,30 @@ the config directory is as follows and should be respected by plugins:
229235
- <plugin dynamic libraries>
230236
- config.ron
231237
- style.css
238+
- state.ron # Optional, used to retain state when state saving is enabled
232239
- <any plugin specific config files>
233240
```
234241

235242
The [default config file](examples/config.ron) contains the default values, and
236243
annotates all configuration options with comments on what they are and how to
237244
use them.
238245

246+
### State Saving
247+
248+
When `persist_state` is set to `true` in the config, Anyrun will:
249+
- Save the input text to `state.ron` when the window is closed
250+
- Restore this text when Anyrun is launched again
251+
- Clear the saved state when a match is selected
252+
253+
You can optionally set `state_ttl_secs` to automatically discard saved state after a certain time. For example:
254+
```ron
255+
// Enable state persistence with 2-minute TTL
256+
persist_state: true,
257+
state_ttl_secs: Some(120)
258+
```
259+
260+
This is useful for preserving your input between sessions, especially for longer queries or calculations.
261+
239262
## Styling
240263

241264
Anyrun supports [GTK+ CSS](https://docs.gtk.org/gtk3/css-overview.html) styling.

anyrun/src/main.rs

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
use std::{
22
cell::RefCell,
33
env, fs,
4-
io::{self, Write},
4+
io::{self, BufRead, BufReader, Read, Write},
55
mem,
66
path::PathBuf,
77
rc::Rc,
88
sync::Once,
9-
time::Duration,
9+
time::{Duration, SystemTime, UNIX_EPOCH},
1010
};
1111

1212
use abi_stable::std_types::{ROption, RVec};
1313
use anyrun_interface::{HandleResult, Match, PluginInfo, PluginRef, PollResult};
1414
use clap::{Parser, ValueEnum};
1515
use gtk::{gdk, gdk_pixbuf, gio, glib, prelude::*};
1616
use nix::unistd;
17-
use serde::Deserialize;
17+
use serde::{Deserialize, Serialize};
1818
use wl_clipboard_rs::copy;
1919

2020
#[anyrun_macros::config_args]
@@ -49,6 +49,10 @@ struct Config {
4949
max_entries: Option<usize>,
5050
#[serde(default = "Config::default_layer")]
5151
layer: Layer,
52+
#[serde(default)]
53+
persist_state: bool,
54+
#[serde(default)]
55+
state_ttl_secs: Option<u64>,
5256
}
5357

5458
impl Config {
@@ -97,6 +101,8 @@ impl Default for Config {
97101
show_results_immediately: false,
98102
max_entries: None,
99103
layer: Self::default_layer(),
104+
persist_state: false,
105+
state_ttl_secs: None,
100106
}
101107
}
102108
}
@@ -178,6 +184,67 @@ struct RuntimeData {
178184
config_dir: String,
179185
}
180186

187+
impl RuntimeData {
188+
fn state_file(&self) -> String {
189+
format!("{}/state.txt", self.config_dir)
190+
}
191+
192+
fn save_state(&self, text: &str) -> io::Result<()> {
193+
if !self.config.persist_state {
194+
return Ok(());
195+
}
196+
let timestamp = SystemTime::now()
197+
.duration_since(UNIX_EPOCH)
198+
.unwrap()
199+
.as_millis();
200+
201+
let mut file = fs::File::create(self.state_file())?;
202+
writeln!(file, "{}", timestamp)?;
203+
write!(file, "{}", text)
204+
}
205+
206+
fn load_state(&self) -> io::Result<String> {
207+
if !self.config.persist_state {
208+
return Ok(String::new());
209+
}
210+
match fs::File::open(self.state_file()) {
211+
Ok(file) => {
212+
let mut reader = BufReader::new(file);
213+
214+
// Read timestamp from first line
215+
let mut timestamp_str = String::new();
216+
reader.read_line(&mut timestamp_str)?;
217+
let timestamp = timestamp_str.trim().parse::<u128>().unwrap_or(0);
218+
219+
// Check if state has expired
220+
if let Some(expiry_secs) = self.config.state_ttl_secs {
221+
let now = SystemTime::now()
222+
.duration_since(UNIX_EPOCH)
223+
.unwrap()
224+
.as_millis();
225+
if now - timestamp > u128::from(expiry_secs) * 1000 {
226+
return Ok(String::new());
227+
}
228+
}
229+
230+
// Read text from second line to end
231+
let mut text = String::new();
232+
reader.read_to_string(&mut text)?;
233+
Ok(text)
234+
}
235+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(String::new()),
236+
Err(e) => Err(e),
237+
}
238+
}
239+
240+
fn clear_state(&self) -> io::Result<()> {
241+
if !self.config.persist_state {
242+
return Ok(());
243+
}
244+
fs::write(self.state_file(), "0\n")
245+
}
246+
}
247+
181248
/// The naming scheme for CSS styling
182249
///
183250
/// Refer to [GTK 3.0 CSS Overview](https://docs.gtk.org/gtk3/css-overview.html)
@@ -251,7 +318,7 @@ fn main() {
251318

252319
config.merge_opt(args.config);
253320

254-
let runtime_data: Rc<RefCell<RuntimeData>> = Rc::new(RefCell::new(RuntimeData {
321+
let runtime_data = Rc::new(RefCell::new(RuntimeData {
255322
exclusive: None,
256323
plugins: Vec::new(),
257324
post_run_action: PostRunAction::None,
@@ -465,10 +532,21 @@ fn activate(app: &gtk::Application, runtime_data: Rc<RefCell<RuntimeData>>) {
465532
.name(style_names::ENTRY)
466533
.build();
467534

468-
// Refresh the matches when text input changes
535+
// Set initial text from loaded state
536+
if let Ok(initial_text) = runtime_data.borrow().load_state() {
537+
entry.set_text(&initial_text);
538+
} else {
539+
eprintln!("Failed to load state");
540+
}
541+
542+
// Update last_input, save state and refresh matches when text changes
469543
let runtime_data_clone = runtime_data.clone();
470544
entry.connect_changed(move |entry| {
471-
refresh_matches(entry.text().to_string(), runtime_data_clone.clone())
545+
let text = entry.text().to_string();
546+
if let Err(e) = runtime_data_clone.borrow().save_state(&text) {
547+
eprintln!("Failed to save state: {}", e);
548+
}
549+
refresh_matches(text, runtime_data_clone.clone());
472550
});
473551

474552
// Handle other key presses for selection control and all other things that may be needed
@@ -584,6 +662,9 @@ fn activate(app: &gtk::Application, runtime_data: Rc<RefCell<RuntimeData>>) {
584662
(*selected_match.data::<Match>("match").unwrap().as_ptr()).clone()
585663
}) {
586664
HandleResult::Close => {
665+
if let Err(e) = _runtime_data_clone.clear_state() {
666+
eprintln!("Failed to clear state: {}", e);
667+
}
587668
window.close();
588669
Inhibit(true)
589670
}
@@ -599,13 +680,19 @@ fn activate(app: &gtk::Application, runtime_data: Rc<RefCell<RuntimeData>>) {
599680
}
600681
HandleResult::Copy(bytes) => {
601682
_runtime_data_clone.post_run_action = PostRunAction::Copy(bytes.into());
683+
if let Err(e) = _runtime_data_clone.clear_state() {
684+
eprintln!("Failed to clear state: {}", e);
685+
}
602686
window.close();
603687
Inhibit(true)
604688
}
605689
HandleResult::Stdout(bytes) => {
606690
if let Err(why) = io::stdout().lock().write_all(&bytes) {
607691
eprintln!("Error outputting content to stdout: {}", why);
608692
}
693+
if let Err(e) = _runtime_data_clone.clear_state() {
694+
eprintln!("Failed to clear state: {}", e);
695+
}
609696
window.close();
610697
Inhibit(true)
611698
}
@@ -679,11 +766,17 @@ fn activate(app: &gtk::Application, runtime_data: Rc<RefCell<RuntimeData>>) {
679766
main_vbox.add(&main_list);
680767
main_list.show();
681768
entry.grab_focus(); // Grab the focus so typing is immediately accepted by the entry box
769+
entry.set_position(-1); // -1 moves cursor to end of text in case some text was restored
682770
}
683771

684-
if runtime_data.borrow().config.show_results_immediately {
685-
// Get initial matches
686-
refresh_matches(String::new(), runtime_data);
772+
// Show initial results if state restoration is enabled or immediate results are configured
773+
let should_show_results = {
774+
let data = runtime_data.borrow();
775+
data.config.persist_state || data.config.show_results_immediately
776+
};
777+
778+
if should_show_results {
779+
refresh_matches(entry.text().to_string(), runtime_data);
687780
}
688781
});
689782

examples/config.ron

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,16 @@ Config(
3333
// Show search results immediately when Anyrun starts
3434
show_results_immediately: false,
3535

36+
// Whether to save and restore input text between sessions
37+
persist_state: false,
38+
39+
// Time in seconds after which saved state is discarded
40+
// For example, set to 120 to clear state after 2 minutes of inactivity
41+
state_ttl_secs: None,
42+
3643
// Limit amount of entries shown in total
3744
max_entries: None,
38-
45+
3946
// List of plugins to be loaded by default, can be specified with a relative path to be loaded from the
4047
// `<anyrun config dir>/plugins` directory or with an absolute path to just load the file the path points to.
4148
plugins: [

nix/modules/home-manager.nix

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,18 @@ in {
144144
description = "Show search results immediately when Anyrun starts";
145145
};
146146

147+
persistState = mkOption {
148+
type = bool;
149+
default = false;
150+
description = "Whether to save and restore input text between sessions";
151+
};
152+
153+
stateTtlSecs = mkOption {
154+
type = nullOr int;
155+
default = null;
156+
description = "Time in seconds after which saved state is discarded";
157+
};
158+
147159
maxEntries = mkOption {
148160
type = nullOr int;
149161
default = null;
@@ -233,6 +245,12 @@ in {
233245
hide_plugin_info: ${boolToString cfg.config.hidePluginInfo},
234246
close_on_click: ${boolToString cfg.config.closeOnClick},
235247
show_results_immediately: ${boolToString cfg.config.showResultsImmediately},
248+
persist_state: ${boolToString cfg.config.persistState},
249+
state_ttl_secs: ${
250+
if cfg.config.stateTtlSecs == null
251+
then "None"
252+
else "Some(${toString cfg.config.stateTtlSecs})"
253+
},
236254
max_entries: ${
237255
if cfg.config.maxEntries == null
238256
then "None"

0 commit comments

Comments
 (0)