diff --git a/Cargo.lock b/Cargo.lock index 73e9596..2ba7d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3376,7 +3376,9 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "rand", + "serde", "tokio", + "toml", ] [[package]] @@ -3511,6 +3513,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3975,6 +3986,21 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.7.3" @@ -4005,6 +4031,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tracing" version = "0.1.43" diff --git a/Cargo.toml b/Cargo.toml index 64e12a7..7587db0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ objc2 = "0.6.3" objc2-app-kit = "0.3.2" objc2-foundation = "0.3.2" rand = "0.9.2" +serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1.48.0", features = ["full"] } +toml = "0.9.8" [package.metadata.bundle] name = "RustCast" diff --git a/LICENSE.md b/LICENSE.md index 6bdd13a..e9297ca 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,18 @@ Copyright 2025 Umang Surana -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 90c4260..1c4d205 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,24 @@ 3. Open it, and if there is a "this app is damaged", run the command `xattr -cr ` +## Config: + +The config file should be located at: `~/.config/rustcast/config.toml` RustCast +doesn't create the default configuration for you, but it does use its +[default options](docs/default.toml) Here's a full list of what all you can +configure [The list](docs/config.toml) The blurring and background is still a +bit wonky, and will be fixed in the upcoming releases + ## Feature list: ### Planned: -- [ ] Tray Icon for quitting the app 12/12/2025 - [ ] Select the options using arrow keys 13/12/2025 - [ ] Calculator 15/12/2025 - [ ] Popup note-taking 18/12/2025 - [ ] Clipboard History 20/12/2025 -- [ ] Customisable themes (21/12/2025) - - [ ] Blur / transparent background - - [ ] Configurable colours for selected option - [ ] Plugin Support 31/12/2025 +- [ ] Blur / transparent background (Partially implemented on 13/12/2025) ### Finished: @@ -36,6 +41,13 @@ the app. Simply type `randomvar` and it will generate the num for you - [x] Image icons next to the text 13/12/2025 - [x] Scrollable options 12/12/2025 +- [x] Customisable themes (13/12/2025) + - [x] Configurable colours + +### Not Planned: + +- [ ] Tray Icon for quitting the app. One may ask why? Well, because I CAN'T GET + IT TO WORK.. I've SPENT TOO LONG ON THIS ## Motivations: diff --git a/docs/config.toml b/docs/config.toml new file mode 100644 index 0000000..f038290 --- /dev/null +++ b/docs/config.toml @@ -0,0 +1,26 @@ +# Things you can configure + +# Full list of modifiers: https://docs.rs/global-hotkey/0.7.0/global_hotkey/hotkey/struct.Modifiers.html#impl-Modifiers +# Do note, CMD is Super on MacOS +# If you are not sure, use google or GenAI to find out. +toggle_mod = "SHIFT" + +# Full list of keys: https://docs.rs/global-hotkey/0.7.0/global_hotkey/hotkey/enum.Code.html#variants +# Same things as Modifiers, google if unsure. If something should work, but isn't working, open an issue and I'll help +toggle_key = "1" +placeholder = "Oopsie Dasies" + +# Buffer (all fields are optional bools) +clear_on_hide = false +clear_on_enter = true + +[theme] + +text_color = [0.95, 0.95, 0.96] +background_color = [0.11, 0.11, 0.13] + +background_opacity = 1.0 + +blur = false +show_icons = true +show_scroll_bar = true diff --git a/docs/default.toml b/docs/default.toml new file mode 100644 index 0000000..78120f6 --- /dev/null +++ b/docs/default.toml @@ -0,0 +1,17 @@ +# Default Config + +toggle_mod = "ALT" +toggle_key = "Space" +placeholder = "Time to be productive!" + +[buffer_rules] +clear_on_hide = true +clear_on_enter = true + +[theme] +text_color = [0.95, 0.95, 0.96] +background_color = [0.11, 0.11, 0.13] +background_opacity = 1.0 +blur = false +show_icons = true +show_scroll_bar = true diff --git a/src/app.rs b/src/app.rs index 6c75d9e..5a4e138 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,208 +1,56 @@ -use crate::macos; +use crate::config::Config; use crate::macos::focus_this_app; +use crate::{macos, utils::get_installed_apps}; use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; -use iced::Alignment; -use iced::Fill; -use iced::alignment::Vertical; -use iced::futures; -use iced::keyboard; -use iced::keyboard::key::Named; -use iced::stream; -use iced::widget::Button; -use iced::widget::Row; -use iced::widget::Text; -use iced::widget::container; -use iced::widget::image::Handle; -use iced::widget::image::Viewer; -use iced::widget::scrollable; -use iced::widget::text::LineHeight; -use iced::widget::{Column, operation, space, text_input}; -use iced::window::{self, Id, Settings}; -use iced::{Element, Subscription, Task, Theme}; -use icns::IconFamily; -use image::RgbaImage; +use iced::{ + Alignment, Element, Fill, Subscription, Task, Theme, + alignment::Vertical, + futures, + keyboard::{self, key::Named}, + stream, + widget::{ + Button, Column, Row, Text, container, image::Viewer, operation, scrollable, space, + text::LineHeight, text_input, + }, + window::{self, Id, Settings}, +}; + use objc2::rc::Retained; use objc2_app_kit::NSRunningApplication; use std::cmp::min; -use std::fs; -use std::fs::File; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; use std::process::Command; -use std::process::exit; use std::time::Duration; -const WINDOW_WIDTH: f32 = 500.; -const DEFAULT_WINDOW_HEIGHT: f32 = 65.; -const ERR_LOG_PATH: &str = "/tmp/rustscan-err.log"; - -fn log_error(msg: &str) { - if let Ok(mut file) = File::options().create(true).append(true).open(ERR_LOG_PATH) { - let _ = file.write_all(msg.as_bytes()).ok(); - } -} - -fn log_error_and_exit(msg: &str) { - log_error(msg); - exit(-1) -} - -fn handle_from_icns(path: &Path) -> Option { - let data = std::fs::read(path).ok()?; - let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?; - - let icon_type = family.available_icons(); - - let icon = family.get_icon_with_type(*icon_type.first()?).ok()?; - let image = RgbaImage::from_raw( - icon.width() as u32, - icon.height() as u32, - icon.data().to_vec(), - )?; - Some(Handle::from_rgba( - image.width(), - image.height(), - image.into_raw(), - )) -} - -pub fn get_installed_apps(dir: impl AsRef) -> Vec { - fs::read_dir(dir) - .unwrap_or_else(|x| { - log_error_and_exit(&x.to_string()); - exit(-1) - }) - .filter_map(|x| x.ok()) - .filter_map(|x| { - let file_type = x.file_type().unwrap_or_else(|e| { - log_error(&e.to_string()); - exit(-1) - }); - - if !file_type.is_dir() { - return None; - } - - let file_name_os = x.file_name(); - let file_name = file_name_os.into_string().unwrap_or_else(|e| { - log_error(e.to_str().unwrap_or("")); - exit(-1) - }); - - if !file_name.ends_with(".app") { - return None; - } - - let path_str = x.path().to_str().map(|x| x.to_string()).unwrap_or_else(|| { - log_error("Unable to get file_name"); - exit(-1) - }); - - let icons = match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map( - |content| { - let icon_line = content - .lines() - .scan(false, |expect_next, line| { - if *expect_next { - *expect_next = false; - // Return this line to the iterator - return Some(Some(line)); - } - - if line.trim() == "CFBundleIconFile" { - *expect_next = true; - } - - // For lines that are not the one after the key, return None to skip - Some(None) - }) - .flatten() // remove the Nones - .next() - .map(|x| { - x.trim() - .strip_prefix("") - .unwrap_or("") - .strip_suffix("") - .unwrap_or("") - }); - - handle_from_icns(Path::new(&format!( - "{}/Contents/Resources/{}", - path_str, - icon_line.unwrap_or("AppIcon.icns") - ))) - }, - ) { - Ok(Some(a)) => Some(a), - _ => { - // Fallback method - let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) - .into_iter() - .flatten() - .filter_map(|x| { - let file = x.ok()?; - let name = file.file_name(); - let file_name = name.to_str()?; - if file_name.ends_with(".icns") { - Some(file.path()) - } else { - None - } - }) - .collect::>(); - let icons = if direntry.len() > 1 { - let icns_vec = direntry - .iter() - .filter(|x| x.ends_with("AppIcon.icns")) - .collect::>(); - handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) - } else if !direntry.is_empty() { - handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) - } else { - None - }; - icons - } - }; - - let name = file_name.strip_suffix(".app").unwrap().to_string(); - - Some(App { - open_command: format!("open {}", path_str), - icons, - name_lc: name.to_lowercase(), - name, - }) - }) - .collect() -} +pub const WINDOW_WIDTH: f32 = 500.; +pub const DEFAULT_WINDOW_HEIGHT: f32 = 65.; #[allow(unused)] #[derive(Debug, Clone)] pub struct App { - open_command: String, - icons: Option, - name: String, - name_lc: String, + pub open_command: String, + pub icons: Option, + pub name: String, + pub name_lc: String, } impl App { - pub fn render(&self) -> impl Into> { + pub fn render(&self, show_icons: bool) -> impl Into> { let mut tile = Row::new().width(Fill).height(55); - if let Some(icon) = &self.icons { - tile = tile - .push(Viewer::new(icon).height(35).width(35)) - .align_y(Alignment::Center); - } else { - tile = tile - .push(space().height(Fill)) - .width(55) - .height(55) - .align_y(Alignment::Center); + if show_icons { + if let Some(icon) = &self.icons { + tile = tile + .push(Viewer::new(icon).height(35).width(35)) + .align_y(Alignment::Center); + } else { + tile = tile + .push(space().height(Fill)) + .width(55) + .height(55) + .align_y(Alignment::Center); + } } tile = tile @@ -251,21 +99,6 @@ pub enum Message { _Nothing, } -#[derive(Debug, Clone)] -pub enum Hotkeys { - AltSpace, - Nothing, -} - -impl Hotkeys { - pub fn from_u32_hotkey_id(id: u32) -> Hotkeys { - match id { - 65598 => Hotkeys::AltSpace, - _ => Hotkeys::Nothing, - } - } -} - pub fn default_settings() -> Settings { Settings { resizable: false, @@ -284,20 +117,23 @@ pub fn default_settings() -> Settings { #[derive(Debug, Clone)] pub struct Tile { + theme: iced::Theme, query: String, query_lc: String, prev_query_lc: String, - theme: Theme, results: Vec, options: Vec, visible: bool, focused: bool, frontmost: Option>, + config: Config, + default_config: Config, + open_hotkey_id: u32, } impl Tile { /// A base window - pub fn new() -> (Self, Task) { + pub fn new(keybind_id: u32, config: &Config) -> (Self, Task) { let (id, open) = window::open(default_settings()); let _ = window::run(id, |handle| { macos::macos_window_config( @@ -305,14 +141,29 @@ impl Tile { ); }); - let mut apps = get_installed_apps("/Applications/"); - apps.append(&mut get_installed_apps("/System/Applications/")); - apps.append(&mut get_installed_apps("/System/Applications/Utilities/")); + // SHOULD NEVER HAVE NONE VALUES + let default_config = Config::default(); + + let store_icons = config + .theme + .as_ref() + .unwrap_or(default_config.theme.as_ref().unwrap()) + .show_icons + .unwrap(); + + let mut apps = get_installed_apps("/Applications/", store_icons); + apps.append(&mut get_installed_apps( + "/System/Applications/", + store_icons, + )); + apps.append(&mut get_installed_apps( + "/System/Applications/Utilities/", + store_icons, + )); apps.sort_by_key(|x| x.name.len()); ( Self { - theme: Theme::KanagawaWave, query: String::new(), query_lc: String::new(), prev_query_lc: String::new(), @@ -321,6 +172,10 @@ impl Tile { visible: true, frontmost: None, focused: false, + config: config.clone(), + default_config, + theme: config.theme.to_owned().unwrap().to_iced_theme(), + open_hotkey_id: keybind_id, }, Task::batch([open.map(|_| Message::OpenWindow)]), ) @@ -364,29 +219,7 @@ impl Tile { ); } - let filter_vec = if self.query_lc.starts_with(&self.prev_query_lc) { - self.prev_query_lc = self.query_lc.to_owned(); - &self.results.clone() - } else { - &self.options - }; - - self.results = vec![]; - self.results.extend( - &mut filter_vec - .iter() - .filter(|x| x.name_lc == self.query_lc) - .map(|x| x.to_owned()), - ); - - self.results.extend( - &mut filter_vec - .iter() - .filter(|x| { - x.name_lc != self.query_lc && x.name_lc.starts_with(&self.query_lc) - }) - .map(|x| x.to_owned()), - ); + self.handle_search_query_changed(); let new_length = self.results.len(); let max_elem = min(5, new_length); @@ -409,8 +242,8 @@ impl Tile { Task::none() } - Message::KeyPressed(hk_id) => match Hotkeys::from_u32_hotkey_id(hk_id) { - Hotkeys::AltSpace => { + Message::KeyPressed(hk_id) => { + if hk_id == self.open_hotkey_id { self.visible = !self.visible; if self.visible { Task::chain( @@ -421,11 +254,34 @@ impl Tile { ) } else { let to_close = window::latest().map(|x| x.unwrap()); - to_close.map(Message::HideWindow) + Task::batch([ + to_close.map(Message::HideWindow), + Task::done( + if self + .config + .buffer_rules + .clone() + .and_then(|x| x.clear_on_hide) + .unwrap_or( + self.default_config + .buffer_rules + .clone() + .unwrap() + .clear_on_hide + .unwrap(), + ) + { + Message::ClearSearchQuery + } else { + Message::_Nothing + }, + ), + ]) } + } else { + Task::none() } - _ => Task::none(), - }, + } Message::RunShellCommand(shell_command) => { let cmd = shell_command.split_once(" ").unwrap_or(("", "")); @@ -433,7 +289,26 @@ impl Tile { window::latest() .map(|x| x.unwrap()) .map(Message::HideWindow) - .chain(Task::done(Message::ClearSearchQuery)) + .chain({ + let buf_rules = self + .config + .buffer_rules + .clone() + .and_then(|x| x.clear_on_enter) + .unwrap_or_else(|| { + self.default_config + .buffer_rules + .clone() + .unwrap() + .clear_on_enter + .unwrap() + }); + if buf_rules { + Task::done(Message::ClearSearchQuery) + } else { + Task::none() + } + }) } Message::HideWindow(a) => { @@ -450,6 +325,7 @@ impl Tile { self.focused = focused; if !focused { Task::done(Message::HideWindow(wid)) + .chain(Task::done(Message::ClearSearchQuery)) } else { Task::none() } @@ -461,7 +337,7 @@ impl Tile { pub fn view(&self, wid: window::Id) -> Element<'_, Message> { if self.visible { - let title_input = text_input("Time to be productive!", &self.query) + let title_input = text_input(self.config.placeholder.as_ref().unwrap(), &self.query) .on_input(move |a| Message::SearchQueryChanged(a, wid)) .on_paste(move |a| Message::SearchQueryChanged(a, wid)) .on_submit({ @@ -480,7 +356,8 @@ impl Tile { let mut search_results = Column::new(); for result in &self.results { - search_results = search_results.push(result.render()); + search_results = search_results + .push(result.render(self.config.theme.clone().unwrap().show_icons.unwrap())); } Column::new() @@ -500,7 +377,6 @@ impl Tile { Subscription::batch([ Subscription::run(handle_hotkeys), window::close_events().map(Message::HideWindow), - window::resize_events().map(|_| Message::_Nothing), keyboard::listen().filter_map(|event| { if let keyboard::Event::KeyPressed { key, .. } = event { match key { @@ -527,6 +403,30 @@ impl Tile { ]) } + pub fn handle_search_query_changed(&mut self) { + let filter_vec = if self.query_lc.starts_with(&self.prev_query_lc) { + self.prev_query_lc = self.query_lc.to_owned(); + &self.results.clone() + } else { + &self.options + }; + + self.results = vec![]; + self.results.extend( + &mut filter_vec + .iter() + .filter(|x| x.name_lc == self.query_lc) + .map(|x| x.to_owned()), + ); + + self.results.extend( + &mut filter_vec + .iter() + .filter(|x| x.name_lc != self.query_lc && x.name_lc.starts_with(&self.query_lc)) + .map(|x| x.to_owned()), + ); + } + pub fn capture_frontmost(&mut self) { use objc2_app_kit::NSWorkspace; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..dca8db3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use iced::theme::Custom; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Config { + pub toggle_mod: Option, + pub toggle_key: Option, + pub buffer_rules: Option, + pub theme: Option, + pub placeholder: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + toggle_mod: Some("ALT".to_string()), + toggle_key: Some("Space".to_string()), + buffer_rules: Some(Buffer::default()), + theme: Some(Theme::default()), + placeholder: Some(String::from("Time to be productive!")), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Theme { + pub text_color: Option<(f32, f32, f32)>, + pub background_color: Option<(f32, f32, f32)>, + pub background_opacity: Option, + pub blur: Option, + pub show_icons: Option, + pub show_scroll_bar: Option, +} + +impl Default for Theme { + fn default() -> Self { + Self { + text_color: Some((0.95, 0.95, 0.96)), + background_color: Some((0.11, 0.11, 0.13)), + background_opacity: Some(1.), + blur: Some(false), + show_icons: Some(true), + show_scroll_bar: Some(true), + } + } +} + +impl Theme { + pub fn to_iced_theme(&self) -> iced::Theme { + let default = Self::default(); + let text_color = self.text_color.unwrap_or(default.text_color.unwrap()); + let bg_color = self + .background_color + .unwrap_or(default.background_color.unwrap()); + let palette = iced::theme::Palette { + background: iced::Color { + r: bg_color.0, + g: bg_color.1, + b: bg_color.2, + a: self.background_opacity.unwrap_or(1.), + }, + text: iced::Color { + r: text_color.0, + g: text_color.1, + b: text_color.2, + a: 1., + }, + primary: iced::Color { + r: 0.22, + g: 0.55, + b: 0.96, + a: 1.0, + }, + danger: iced::Color { + r: 0.95, + g: 0.26, + b: 0.21, + a: 1.0, + }, + warning: iced::Color { + r: 1.0, + g: 0.76, + b: 0.03, + a: 1.0, + }, + success: iced::Color { + r: 0.30, + g: 0.69, + b: 0.31, + a: 1.0, + }, + }; + iced::Theme::Custom(Arc::new(Custom::new("RustCast Theme".to_string(), palette))) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Buffer { + pub clear_on_hide: Option, + pub clear_on_enter: Option, +} + +impl Default for Buffer { + fn default() -> Self { + Buffer { + clear_on_hide: Some(true), + clear_on_enter: Some(true), + } + } +} diff --git a/src/main.rs b/src/main.rs index 6a4cfb8..ea3786e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ mod app; +mod config; mod macos; +mod utils; -use crate::app::Tile; +use crate::{app::Tile, config::Config, utils::to_key_code}; use global_hotkey::{ GlobalHotKeyManager, @@ -14,14 +16,32 @@ fn main() -> iced::Result { macos::set_activation_policy_accessory(); } + let file_path = std::env::var("HOME").unwrap() + "/.config/rustcast/config.toml"; + let config: Config = match std::fs::read_to_string(file_path) { + Ok(a) => toml::from_str(&a).unwrap(), + Err(_) => Config::default(), + }; let manager = GlobalHotKeyManager::new().unwrap(); - let altspace = HotKey::new(Some(Modifiers::ALT), Code::Space); + + let show_hide = HotKey::new( + Some( + Modifiers::from_name(&config.toggle_mod.clone().unwrap_or("ALT".to_string())) + .unwrap_or(Modifiers::ALT), + ), + to_key_code(&config.toggle_key.clone().unwrap_or("SPACE".to_string())) + .unwrap_or(Code::Space), + ); + manager - .register_all(&[altspace]) + .register_all(&[show_hide]) .expect("Unable to register hotkey"); - iced::daemon(Tile::new, Tile::update, Tile::view) - .subscription(Tile::subscription) - .theme(Tile::theme) - .run() + iced::daemon( + move || Tile::new(show_hide.id(), &config), + Tile::update, + Tile::view, + ) + .subscription(Tile::subscription) + .theme(Tile::theme) + .run() } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..07e6609 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,285 @@ +use std::{ + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, + process::exit, +}; + +use global_hotkey::hotkey::Code; +use iced::widget::image::Handle; +use icns::IconFamily; +use image::RgbaImage; + +use crate::app::App; + +const ERR_LOG_PATH: &str = "/tmp/rustscan-err.log"; + +pub(crate) fn log_error(msg: &str) { + if let Ok(mut file) = File::options().create(true).append(true).open(ERR_LOG_PATH) { + let _ = file.write_all(msg.as_bytes()).ok(); + } +} + +pub(crate) fn log_error_and_exit(msg: &str) { + log_error(msg); + exit(-1) +} + +pub(crate) fn handle_from_icns(path: &Path) -> Option { + let data = std::fs::read(path).ok()?; + let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?; + + let icon_type = family.available_icons(); + + let icon = family.get_icon_with_type(*icon_type.first()?).ok()?; + let image = RgbaImage::from_raw( + icon.width() as u32, + icon.height() as u32, + icon.data().to_vec(), + )?; + Some(Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} + +pub(crate) fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { + fs::read_dir(dir) + .unwrap_or_else(|x| { + log_error_and_exit(&x.to_string()); + exit(-1) + }) + .filter_map(|x| x.ok()) + .filter_map(|x| { + let file_type = x.file_type().unwrap_or_else(|e| { + log_error(&e.to_string()); + exit(-1) + }); + + if !file_type.is_dir() { + return None; + } + + let file_name_os = x.file_name(); + let file_name = file_name_os.into_string().unwrap_or_else(|e| { + log_error(e.to_str().unwrap_or("")); + exit(-1) + }); + + if !file_name.ends_with(".app") { + return None; + } + + let path_str = x.path().to_str().map(|x| x.to_string()).unwrap_or_else(|| { + log_error("Unable to get file_name"); + exit(-1) + }); + + let icons = if store_icons { + match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map( + |content| { + let icon_line = content + .lines() + .scan(false, |expect_next, line| { + if *expect_next { + *expect_next = false; + // Return this line to the iterator + return Some(Some(line)); + } + + if line.trim() == "CFBundleIconFile" { + *expect_next = true; + } + + // For lines that are not the one after the key, return None to skip + Some(None) + }) + .flatten() // remove the Nones + .next() + .map(|x| { + x.trim() + .strip_prefix("") + .unwrap_or("") + .strip_suffix("") + .unwrap_or("") + }); + + handle_from_icns(Path::new(&format!( + "{}/Contents/Resources/{}", + path_str, + icon_line.unwrap_or("AppIcon.icns") + ))) + }, + ) { + Ok(Some(a)) => Some(a), + _ => { + // Fallback method + let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) + .into_iter() + .flatten() + .filter_map(|x| { + let file = x.ok()?; + let name = file.file_name(); + let file_name = name.to_str()?; + if file_name.ends_with(".icns") { + Some(file.path()) + } else { + None + } + }) + .collect::>(); + + if direntry.len() > 1 { + let icns_vec = direntry + .iter() + .filter(|x| x.ends_with("AppIcon.icns")) + .collect::>(); + handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) + } else if !direntry.is_empty() { + handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) + } else { + None + } + } + } + } else { + None + }; + let name = file_name.strip_suffix(".app").unwrap().to_string(); + + Some(App { + open_command: format!("open {}", path_str), + icons, + name_lc: name.to_lowercase(), + name, + }) + }) + .collect() +} + +pub fn to_key_code(key_str: &str) -> Option { + match key_str.to_lowercase().as_str() { + // Letters + "a" => Some(Code::KeyA), + "b" => Some(Code::KeyB), + "c" => Some(Code::KeyC), + "d" => Some(Code::KeyD), + "e" => Some(Code::KeyE), + "f" => Some(Code::KeyF), + "g" => Some(Code::KeyG), + "h" => Some(Code::KeyH), + "i" => Some(Code::KeyI), + "j" => Some(Code::KeyJ), + "k" => Some(Code::KeyK), + "l" => Some(Code::KeyL), + "m" => Some(Code::KeyM), + "n" => Some(Code::KeyN), + "o" => Some(Code::KeyO), + "p" => Some(Code::KeyP), + "q" => Some(Code::KeyQ), + "r" => Some(Code::KeyR), + "s" => Some(Code::KeyS), + "t" => Some(Code::KeyT), + "u" => Some(Code::KeyU), + "v" => Some(Code::KeyV), + "w" => Some(Code::KeyW), + "x" => Some(Code::KeyX), + "y" => Some(Code::KeyY), + "z" => Some(Code::KeyZ), + + // Digits (main row) + "0" => Some(Code::Digit0), + "1" => Some(Code::Digit1), + "2" => Some(Code::Digit2), + "3" => Some(Code::Digit3), + "4" => Some(Code::Digit4), + "5" => Some(Code::Digit5), + "6" => Some(Code::Digit6), + "7" => Some(Code::Digit7), + "8" => Some(Code::Digit8), + "9" => Some(Code::Digit9), + + // Function keys + "f1" => Some(Code::F1), + "f2" => Some(Code::F2), + "f3" => Some(Code::F3), + "f4" => Some(Code::F4), + "f5" => Some(Code::F5), + "f6" => Some(Code::F6), + "f7" => Some(Code::F7), + "f8" => Some(Code::F8), + "f9" => Some(Code::F9), + "f10" => Some(Code::F10), + "f11" => Some(Code::F11), + "f12" => Some(Code::F12), + + // Arrows + "up" | "arrowup" => Some(Code::ArrowUp), + "down" | "arrowdown" => Some(Code::ArrowDown), + "left" | "arrowleft" => Some(Code::ArrowLeft), + "right" | "arrowright" => Some(Code::ArrowRight), + + // Modifiers + "shift" | "lshift" => Some(Code::ShiftLeft), + "rshift" => Some(Code::ShiftRight), + "ctrl" | "control" | "lctrl" => Some(Code::ControlLeft), + "rctrl" => Some(Code::ControlRight), + "alt" | "lalt" => Some(Code::AltLeft), + "ralt" => Some(Code::AltRight), + "meta" | "super" | "win" | "lmeta" => Some(Code::MetaLeft), + "rmeta" => Some(Code::MetaRight), + + // Whitespace / editing + "space" => Some(Code::Space), + "enter" => Some(Code::Enter), + "tab" => Some(Code::Tab), + "backspace" => Some(Code::Backspace), + "delete" => Some(Code::Delete), + "insert" => Some(Code::Insert), + "escape" | "esc" => Some(Code::Escape), + + // Punctuation (US layout-style names) + "-" | "minus" => Some(Code::Minus), + "=" | "equal" => Some(Code::Equal), + "[" | "bracketleft" => Some(Code::BracketLeft), + "]" | "bracketright" => Some(Code::BracketRight), + "\\" | "backslash" => Some(Code::Backslash), + ";" | "semicolon" => Some(Code::Semicolon), + "'" | "quote" => Some(Code::Quote), + "," | "comma" => Some(Code::Comma), + "." | "period" => Some(Code::Period), + "/" | "slash" => Some(Code::Slash), + "`" | "backquote" | "grave" => Some(Code::Backquote), + + // Numpad + "numpad0" => Some(Code::Numpad0), + "numpad1" => Some(Code::Numpad1), + "numpad2" => Some(Code::Numpad2), + "numpad3" => Some(Code::Numpad3), + "numpad4" => Some(Code::Numpad4), + "numpad5" => Some(Code::Numpad5), + "numpad6" => Some(Code::Numpad6), + "numpad7" => Some(Code::Numpad7), + "numpad8" => Some(Code::Numpad8), + "numpad9" => Some(Code::Numpad9), + "numpadadd" | "numadd" | "kp+" => Some(Code::NumpadAdd), + "numpadsubtract" | "numsub" | "kp-" => Some(Code::NumpadSubtract), + "numpadmultiply" | "nummul" | "kp*" => Some(Code::NumpadMultiply), + "numpaddivide" | "numdiv" | "kp/" => Some(Code::NumpadDivide), + "numpaddecimal" | "numdecimal" | "kp." => Some(Code::NumpadDecimal), + "numpadenter" | "numenter" => Some(Code::NumpadEnter), + + // Navigation / misc + "home" => Some(Code::Home), + "end" => Some(Code::End), + "pageup" => Some(Code::PageUp), + "pagedown" => Some(Code::PageDown), + "capslock" => Some(Code::CapsLock), + "scrolllock" => Some(Code::ScrollLock), + "numlock" => Some(Code::NumLock), + "pause" => Some(Code::Pause), + + _ => None, + } +}