diff --git a/docs/config.toml b/docs/config.toml index 6247cdc..b8244a5 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -23,6 +23,9 @@ show_trayicon = true # hotkey for opening clipboard history clipboard_hotkey = "SUPER+SHIFT+2" +# number of recent actions to show when search is empty +recent_actions_limit = 5 + # Create a presentation.sh file and you can make it do pretty much anything # Example usage: # - turn on / off your WM in different "modes" @@ -66,4 +69,3 @@ command = "echo " # icon_path is optional alias = "Variables 1" # the name that will be displayed in the results alias_lc = "var test" # the name used to search for it - diff --git a/docs/default.toml b/docs/default.toml index 1872c1f..239c5ef 100644 --- a/docs/default.toml +++ b/docs/default.toml @@ -7,6 +7,7 @@ haptic_feedback = false show_trayicon = true shells = [] clipboard_hotkey = "SUPER+SHIFT+C" +recent_actions_limit = 5 [buffer_rules] clear_on_hide = true diff --git a/src/app.rs b/src/app.rs index bfb59a9..7719fdd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,6 +76,7 @@ pub enum Message { UpdateAvailable, ResizeWindow(Id, f32), OpenWindow, + OpenResult(u32), OpenToSettings, SearchQueryChanged(String, Id), KeyPressed(u32), diff --git a/src/app/apps.rs b/src/app/apps.rs index 3471fd3..a5a0015 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -165,6 +165,7 @@ impl App { theme: crate::config::Theme, id_num: u32, focussed_id: u32, + on_press: Option, ) -> iced::Element<'static, Message> { let focused = focussed_id == id_num; @@ -202,11 +203,11 @@ impl App { } row = row.push(container(text_block).width(Fill)); - let msg = match self.open_command.clone() { + let msg = on_press.or(match self.open_command.clone() { AppCommand::Function(func) => Some(Message::RunFunction(func)), AppCommand::Message(msg) => Some(msg), AppCommand::Display => None, - }; + }); let theme_clone = theme.clone(); diff --git a/src/app/pages/clipboard.rs b/src/app/pages/clipboard.rs index 798d53d..efeea99 100644 --- a/src/app/pages/clipboard.rs +++ b/src/app/pages/clipboard.rs @@ -81,7 +81,7 @@ pub fn clipboard_view( Column::from_iter(clipboard_content.iter().enumerate().map(|(i, content)| { content .to_app() - .render(theme.clone(), i as u32, focussed_id) + .render(theme.clone(), i as u32, focussed_id, None) })) .width(WINDOW_WIDTH / 3.), ) diff --git a/src/app/tile.rs b/src/app/tile.rs index 14c2ea0..a3c0e3a 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -1,5 +1,6 @@ //! This module handles the logic for the tile, AKA rustcast's main window pub mod elm; +mod recent_actions; pub mod update; use crate::app::apps::App; @@ -34,6 +35,8 @@ use std::fmt::Debug; use std::str::FromStr; use std::time::Duration; +use self::recent_actions::RecentActions; + /// This is a wrapper around the sender to disable dropping #[derive(Clone, Debug)] pub struct ExtSender(pub Sender); @@ -70,6 +73,14 @@ impl AppIndex { app.ranking += 1; } + fn contains_key(&self, name: &str) -> bool { + self.by_name.contains_key(name) + } + + fn get(&self, name: &str) -> Option<&App> { + self.by_name.get(name) + } + fn empty() -> AppIndex { AppIndex { by_name: HashMap::new(), @@ -123,6 +134,7 @@ pub struct Tile { frontmost: Option>, pub config: Config, hotkeys: Hotkeys, + recent_actions: RecentActions, clipboard_content: Vec, tray_icon: Option, sender: Option, @@ -259,6 +271,32 @@ impl Tile { self.results = results; } + pub fn recent_results(&self) -> Vec { + self.recent_actions.resolve(|key| self.options.get(key)) + } + + pub fn record_recent_action(&mut self, key: &str) { + self.recent_actions.record(key); + } + + pub fn action_exists(&self, key: &str) -> bool { + self.options.contains_key(key) + } + + pub fn refresh_recent_actions(&mut self) { + let mut changed = self + .recent_actions + .set_limit(self.config.recent_actions_limit); + changed = self + .recent_actions + .prune_by(|key| self.options.contains_key(key)) + || changed; + + if changed { + self.recent_actions.persist_async(); + } + } + /// Gets the frontmost application to focus later. pub fn capture_frontmost(&mut self) { use objc2_app_kit::NSWorkspace; diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index c9d877c..0a37d94 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -54,6 +54,12 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { info!("Loaded basic apps / default apps"); options.par_sort_by_key(|x| x.display_name.len()); let options = AppIndex::from_apps(options); + let mut recent_actions = + crate::app::tile::recent_actions::RecentActions::load(config.recent_actions_limit); + + if recent_actions.prune_by(|key| options.contains_key(key)) { + recent_actions.persist_async(); + } let hotkeys = Hotkeys { toggle: hotkey, @@ -79,6 +85,7 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { focused: false, config: config.clone(), theme: config.theme.to_owned().clone().into(), + recent_actions, clipboard_content: vec![], tray_icon: None, sender: None, @@ -131,12 +138,16 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { tile.focus_id, ), Page::Settings => settings_page(tile.config.clone()), - Page::FileSearch | Page::Main => container(Column::from_iter( - tile.results.iter().enumerate().map(|(i, app)| { - app.clone() - .render(tile.config.theme.clone(), i as u32, tile.focus_id) - }), - )) + Page::FileSearch | Page::Main => container(Column::from_iter(tile.results.iter().enumerate().map( + |(i, app)| { + app.clone().render( + tile.config.theme.clone(), + i as u32, + tile.focus_id, + Some(Message::OpenResult(i as u32)), + ) + }, + ))) .into(), }; @@ -155,9 +166,7 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { .id("results") .height(height as u32); - let text = if tile.query_lc.is_empty() { - tile.page.to_string() - } else { + let text = if !tile.query_lc.is_empty() { match results_count { 1 => "1 result found".to_string(), 0 => "No results found".to_string(), @@ -165,6 +174,10 @@ pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { format!("{count} results found") } } + } else if tile.page == Page::Main { + "Recent actions".to_string() + } else { + tile.page.to_string() }; let contents = container( diff --git a/src/app/tile/recent_actions.rs b/src/app/tile/recent_actions.rs new file mode 100644 index 0000000..38bf5cb --- /dev/null +++ b/src/app/tile/recent_actions.rs @@ -0,0 +1,248 @@ +use std::collections::HashSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::app::apps::App; + +const RECENT_ACTIONS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RecentActionsFile { + version: u32, + keys: Vec, +} + +impl RecentActionsFile { + fn from_keys(keys: Vec) -> Self { + Self { + version: RECENT_ACTIONS_SCHEMA_VERSION, + keys, + } + } +} + +#[derive(Debug, Clone)] +pub struct RecentActions { + limit: usize, + keys: Vec, + storage_path: PathBuf, +} + +impl RecentActions { + pub fn load(limit: usize) -> Self { + Self::load_with_path(limit, default_storage_path()) + } + + fn load_with_path(limit: usize, storage_path: PathBuf) -> Self { + let keys = read_keys(&storage_path); + Self { + limit, + keys: normalize_keys(keys, limit), + storage_path, + } + } + + pub fn set_limit(&mut self, limit: usize) -> bool { + self.limit = limit; + let old_len = self.keys.len(); + self.keys.truncate(limit); + self.keys.len() != old_len + } + + pub fn prune_by(&mut self, keep: F) -> bool + where + F: Fn(&str) -> bool, + { + let old_len = self.keys.len(); + self.keys.retain(|key| keep(key)); + self.keys.len() != old_len + } + + pub fn resolve<'a, F>(&self, lookup: F) -> Vec + where + F: Fn(&str) -> Option<&'a App>, + { + self.keys + .iter() + .take(self.limit) + .filter_map(|key| lookup(key).cloned()) + .collect() + } + + pub fn record(&mut self, key: &str) { + if !self.record_without_persist(key) { + return; + } + + self.persist_async(); + } + + fn record_without_persist(&mut self, key: &str) -> bool { + if self.limit == 0 { + self.keys.clear(); + return false; + } + + let key = key.trim(); + if key.is_empty() { + return false; + } + + self.keys.retain(|existing| existing != key); + self.keys.insert(0, key.to_string()); + self.keys.truncate(self.limit); + true + } + + pub fn persist_async(&self) { + let path = self.storage_path.clone(); + let payload = RecentActionsFile::from_keys(self.keys.clone()); + std::thread::spawn(move || { + let _ = persist_to_disk(path.as_path(), &payload); + }); + } + + #[cfg(test)] + pub fn persist_blocking(&self) -> io::Result<()> { + let payload = RecentActionsFile::from_keys(self.keys.clone()); + persist_to_disk(self.storage_path.as_path(), &payload) + } +} + +fn default_storage_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(format!("{home}/.config/rustcast/recent_actions.json")) +} + +fn read_keys(path: &Path) -> Vec { + let Ok(content) = fs::read_to_string(path) else { + return Vec::new(); + }; + + let Ok(data) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + + if data.version != RECENT_ACTIONS_SCHEMA_VERSION { + return Vec::new(); + } + + data.keys +} + +fn normalize_keys(keys: Vec, limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let mut seen = HashSet::new(); + keys.into_iter() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty() && seen.insert(key.clone())) + .take(limit) + .collect() +} + +fn persist_to_disk(path: &Path, payload: &RecentActionsFile) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let serialized = + serde_json::to_vec(payload).map_err(|error| io::Error::other(error.to_string()))?; + let tmp_path = path.with_extension("json.tmp"); + + fs::write(&tmp_path, serialized)?; + fs::rename(tmp_path, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_file_path(suffix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should always move forward") + .as_nanos(); + std::env::temp_dir().join(format!("rustcast_recent_actions_{suffix}_{nanos}.json")) + } + + #[test] + fn record_dedupes_and_reorders() { + let path = temp_file_path("dedupe"); + let mut recents = RecentActions::load_with_path(3, path); + + recents.record_without_persist("settings"); + recents.record_without_persist("iterm"); + recents.record_without_persist("settings"); + + assert_eq!(recents.keys, vec!["settings", "iterm"]); + } + + #[test] + fn limit_truncation_works_when_limit_shrinks() { + let path = temp_file_path("truncate"); + let mut recents = RecentActions::load_with_path(4, path); + + recents.record_without_persist("a"); + recents.record_without_persist("b"); + recents.record_without_persist("c"); + recents.record_without_persist("d"); + + assert!(recents.set_limit(2)); + assert_eq!(recents.keys, vec!["d", "c"]); + } + + #[test] + fn load_handles_invalid_json_gracefully() { + let path = temp_file_path("invalid"); + fs::write(&path, "{ this is invalid json").expect("should write invalid fixture"); + + let recents = RecentActions::load_with_path(5, path.clone()); + assert!(recents.keys.is_empty()); + + let _ = fs::remove_file(path); + } + + #[test] + fn prune_removes_stale_keys() { + let path = temp_file_path("prune"); + let fixture = RecentActionsFile::from_keys(vec![ + "settings".to_string(), + "iterm".to_string(), + "finder".to_string(), + ]); + persist_to_disk(path.as_path(), &fixture).expect("fixture should be written"); + + let mut recents = RecentActions::load_with_path(5, path.clone()); + let changed = recents.prune_by(|key| key != "finder"); + + assert!(changed); + assert_eq!(recents.keys, vec!["settings", "iterm"]); + + let _ = fs::remove_file(path); + } + + #[test] + fn save_and_load_roundtrip_preserves_order() { + let path = temp_file_path("roundtrip"); + let mut recents = RecentActions::load_with_path(5, path.clone()); + + recents.record_without_persist("settings"); + recents.record_without_persist("iterm"); + recents + .persist_blocking() + .expect("recent actions should persist"); + + let loaded = RecentActions::load_with_path(5, path.clone()); + assert_eq!(loaded.keys, vec!["iterm", "settings"]); + + let _ = fs::remove_file(path); + } +} diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 28a1a30..644f753 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -47,7 +47,14 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { focus_this_app(); tile.focused = true; tile.visible = true; - Task::none() + + if tile.page == Page::Main && tile.query_lc.is_empty() { + window::latest() + .map(|x| x.unwrap()) + .map(|id| Message::SearchQueryChanged(String::new(), id)) + } else { + Task::none() + } } Message::UpdateAvailable => { @@ -217,34 +224,8 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ) } - Message::OpenFocused => { - // TODO: update ranking here - match tile.results.get(tile.focus_id as usize) { - Some(App { - search_name: name, - open_command: AppCommand::Function(func), - .. - }) => { - info!("Updating ranking for: {name}"); - tile.options.update_ranking(name); - Task::done(Message::RunFunction(func.to_owned())) - } - Some(App { - search_name: name, - open_command: AppCommand::Message(msg), - .. - }) => { - info!("Updating ranking for: {name}"); - tile.options.update_ranking(name); - Task::done(msg.to_owned()) - } - Some(App { - open_command: AppCommand::Display, - .. - }) => Task::done(Message::ReturnFocus), - None => Task::none(), - } - } + Message::OpenFocused => Task::done(Message::OpenResult(tile.focus_id)), + Message::OpenResult(id) => open_result(tile, id as usize), Message::ReloadConfig => { info!("Reloading config"); @@ -285,6 +266,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { tile.theme = new_config.theme.to_owned().into(); tile.config = new_config; tile.options = AppIndex::from_apps(new_options); + tile.refresh_recent_actions(); Task::none() } @@ -350,10 +332,18 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { _ => Task::none(), }; + let refresh_empty_main_query = if tile.page == Page::Main { + window::latest() + .map(|x| x.unwrap()) + .map(|id| Message::SearchQueryChanged(String::new(), id)) + } else { + Task::none() + }; Task::batch([ Task::done(Message::ClearSearchQuery), Task::done(Message::ClearSearchResults), task, + refresh_empty_main_query, ]) } @@ -611,6 +601,54 @@ fn zero_item_resize_task(id: Id) -> Task { Task::done(Message::ResizeWindow(id, DEFAULT_WINDOW_HEIGHT)) } +fn resize_for_results_count(id: Id, count: usize) -> Task { + if count == 0 { + return zero_item_resize_task(id); + } + if count == 1 { + return single_item_resize_task(id); + } + + let max_elem = min(5, count); + Task::done(Message::ResizeWindow( + id, + ((max_elem * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + )) +} + +fn open_result(tile: &mut Tile, id: usize) -> Task { + let Some(app) = tile.results.get(id).cloned() else { + return Task::none(); + }; + + let search_name = app.search_name.clone(); + let track_recent_action = tile.page == Page::Main + && !search_name.is_empty() + && tile.action_exists(&search_name) + && matches!( + &app.open_command, + AppCommand::Function(_) | AppCommand::Message(_) + ); + + if track_recent_action { + tile.record_recent_action(&search_name); + } + + match app.open_command { + AppCommand::Function(func) => { + info!("Updating ranking for: {search_name}"); + tile.options.update_ranking(&search_name); + Task::done(Message::RunFunction(func)) + } + AppCommand::Message(msg) => { + info!("Updating ranking for: {search_name}"); + tile.options.update_ranking(&search_name); + Task::done(msg) + } + AppCommand::Display => Task::done(Message::ReturnFocus), + } +} + /// Handling the lemon easter egg icon fn lemon_icon_handle() -> Option { image::ImageReader::new(Cursor::new(include_bytes!("../../../docs/lemon.png"))) @@ -634,6 +672,11 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { _ => {} } + if tile.page == Page::Main && tile.query_lc.is_empty() { + tile.results = tile.recent_results(); + return resize_for_results_count(id, tile.results.len()); + } + if tile.query_lc.is_empty() || (tile.query_lc.chars().count() < 2 && tile.page == Page::FileSearch) { diff --git a/src/config.rs b/src/config.rs index 06083e2..4722577 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ use crate::{ pub struct Config { pub toggle_hotkey: String, pub clipboard_hotkey: String, + pub recent_actions_limit: usize, pub buffer_rules: Buffer, pub theme: Theme, pub placeholder: String, @@ -39,6 +40,7 @@ impl Default for Config { Self { toggle_hotkey: "ALT+SPACE".to_string(), clipboard_hotkey: "SUPER+SHIFT+C".to_string(), + recent_actions_limit: 5, buffer_rules: Buffer::default(), theme: Theme::default(), placeholder: String::from("Time to be productive!"),