diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e96839..90a9274 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,16 +32,22 @@ For bug fixes, and helping people to solve their github issues: see ├── docs # Website and documentation related stuff. If something new is added to config, then modify this as well before PR-ing ├── Cargo.lock ├── Cargo.toml -├── CONTRIBUTING.md -├── EXTENSIONS.md -├── LICENSE.md -├── README.md +├── CONTRIBUTING.md # Contributing guidelines and codebase structure +├── EXTENSIONS.md # Discussions about extensions implementation +├── LICENSE.md # License file +├── README.md # Readme file └── src - ├── app.rs # Main app logic - ├── calculator.rs # Calculator logic - ├── commands.rs # Logic for different commands - ├── config.rs # Configuration related stuff - ├── macos.rs # Macos specific config - ├── main.rs # Start app - └── utils.rs # Common functions that are used across files + ├── app + │   ├── apps.rs # Logic for the "apps" / commands that rustcast can perform + │   ├── tile # Logic for the tile (rustcast window) + │   │   ├── elm.rs # Logic for the elm architecture of the rustcast window (New and View) + │   │   └── update.rs # Logic for the updating (elm architecture update) of the rustcast window + │   └── tile.rs + ├── calculator.rs # Calculator logic + ├── commands.rs # Logic for different commands + ├── clipboard.rs # Logic for the clipboard history feature of rustcast + ├── config.rs # Configuration related stuff + ├── macos.rs # Macos specific config + ├── main.rs # Start app + └── utils.rs # Common functions that are used across files ``` diff --git a/src/app.rs b/src/app.rs index 0a5cd57..f6011d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,36 +1,11 @@ //! Main logic for the app -use crate::calculator::Expression; use crate::clipboard::ClipBoardContentType; use crate::commands::Function; -use crate::config::Config; -use crate::macos::{focus_this_app, transform_process_to_ui_element}; -use crate::{macos, utils::get_installed_apps}; -use arboard::Clipboard; -use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; -use iced::futures::SinkExt; -use iced::widget::text::LineHeight; -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_input, - }, - window::{self, Id, Settings}, -}; +pub mod apps; +pub mod tile; -use objc2::rc::Retained; -use objc2_app_kit::NSRunningApplication; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; -use rayon::slice::ParallelSliceMut; - -use std::cmp::min; -use std::time::Duration; -use std::{fs, thread}; +use iced::window::{self, Id, Settings}; /// The default window width pub const WINDOW_WIDTH: f32 = 500.; @@ -41,96 +16,6 @@ pub const DEFAULT_WINDOW_HEIGHT: f32 = 65.; /// The rustcast descriptor name to be put for all rustcast commands pub const RUSTCAST_DESC_NAME: &str = "RustCast"; -/// The main app struct, that represents an "App" -/// -/// This struct represents a command that rustcast can perform, providing the rustcast -/// the data needed to search for the app, to display the app in search results, and to actually -/// "run" the app. -#[derive(Debug, Clone)] -pub struct App { - pub open_command: Function, - pub desc: String, - pub icons: Option, - pub name: String, - pub name_lc: String, -} - -impl App { - /// This returns the basic apps that rustcast has, such as quiting rustcast and opening preferences - pub fn basic_apps() -> Vec { - vec![ - App { - open_command: Function::Quit, - desc: RUSTCAST_DESC_NAME.to_string(), - icons: None, - name: "Quit RustCast".to_string(), - name_lc: "quit".to_string(), - }, - App { - open_command: Function::OpenPrefPane, - desc: RUSTCAST_DESC_NAME.to_string(), - icons: None, - name: "Open RustCast Preferences".to_string(), - name_lc: "settings".to_string(), - }, - ] - } - - /// This renders the app into an iced element, allowing it to be displayed in the search results - pub fn render(&self, theme: &crate::config::Theme) -> impl Into> { - let mut tile = Row::new().width(Fill).height(55); - - if theme.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.push( - Button::new( - Text::new(&self.name) - .height(Fill) - .width(Fill) - .color(theme.text_color(1.)) - .align_y(Vertical::Center), - ) - .on_press(Message::RunFunction(self.open_command.clone())) - .style(|_, _| iced::widget::button::Style { - background: Some(iced::Background::Color( - Theme::KanagawaDragon.palette().background, - )), - text_color: Theme::KanagawaDragon.palette().text, - ..Default::default() - }) - .width(Fill) - .height(55), - ); - - tile = tile - .push(container(Text::new(&self.desc).color(theme.text_color(0.4))).padding(15)) - .width(Fill); - - container(tile) - .style(|_| iced::widget::container::Style { - text_color: Some(Theme::KanagawaDragon.palette().text), - background: Some(iced::Background::Color( - Theme::KanagawaDragon.palette().background, - )), - ..Default::default() - }) - .width(Fill) - .height(Fill) - } -} - /// The different pages that rustcast can have / has #[derive(Debug, Clone, PartialEq)] pub enum Page { @@ -170,492 +55,3 @@ pub fn default_settings() -> Settings { ..Default::default() } } - -/// This is the base window, and its a "Tile" -/// Its fields are: -/// - Theme ([`iced::Theme`]) -/// - Query (String) -/// - Query Lowercase (String, but lowercase) -/// - Previous Query Lowercase (String) -/// - Results (Vec<[`App`]>) the results of the search -/// - Options (Vec<[`App`]>) the options to search through -/// - Visible (bool) whether the window is visible or not -/// - Focused (bool) whether the window is focused or not -/// - Frontmost ([`Option>`]) the frontmost application before the window was opened -/// - Config ([`Config`]) the app's config -/// - Open Hotkey ID (`u32`) the id of the hotkey that opens the window -/// - Clipboard Content (`Vec<`[`ClipBoardContentType`]`>`) all of the cliboard contents -/// - Page ([`Page`]) the current page of the window (main or clipboard history) -#[derive(Debug, Clone)] -pub struct Tile { - theme: iced::Theme, - query: String, - query_lc: String, - prev_query_lc: String, - results: Vec, - options: Vec, - visible: bool, - focused: bool, - frontmost: Option>, - config: Config, - open_hotkey_id: u32, - clipboard_content: Vec, - page: Page, -} - -impl Tile { - /// Initialise the base window - pub fn new(keybind_id: u32, config: &Config) -> (Self, Task) { - let (id, open) = window::open(default_settings()); - - let open = open.discard().chain(window::run(id, |handle| { - macos::macos_window_config( - &handle.window_handle().expect("Unable to get window handle"), - ); - // should work now that we have a window - transform_process_to_ui_element(); - })); - - let store_icons = config.theme.show_icons; - - let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; - - let paths = vec![ - "/Applications/", - user_local_path.as_str(), - "/System/Applications/", - "/System/Applications/Utilities/", - ]; - - let mut options: Vec = paths - .par_iter() - .map(|path| get_installed_apps(path, store_icons)) - .flatten() - .collect(); - - options.extend(config.shells.iter().map(|x| x.to_app())); - options.extend(App::basic_apps()); - options.par_sort_by_key(|x| x.name.len()); - - ( - Self { - query: String::new(), - query_lc: String::new(), - prev_query_lc: String::new(), - results: vec![], - options, - visible: true, - frontmost: None, - focused: false, - config: config.clone(), - theme: config.theme.to_owned().into(), - open_hotkey_id: keybind_id, - clipboard_content: vec![], - page: Page::Main, - }, - Task::batch([open.map(|_| Message::OpenWindow)]), - ) - } - - /// This handles the iced's updates, which have all the variants of [Message] - pub fn update(&mut self, message: Message) -> Task { - match message { - Message::OpenWindow => { - self.capture_frontmost(); - focus_this_app(); - self.focused = true; - Task::none() - } - - Message::SearchQueryChanged(input, id) => { - self.query_lc = input.trim().to_lowercase(); - self.query = input; - let prev_size = self.results.len(); - if self.query_lc.is_empty() && self.page == Page::Main { - self.results = vec![]; - return window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: DEFAULT_WINDOW_HEIGHT, - }, - ); - } else if self.query_lc == "randomvar" { - let rand_num = rand::random_range(0..100); - self.results = vec![App { - open_command: Function::RandomVar(rand_num), - desc: "Easter egg".to_string(), - icons: None, - name: rand_num.to_string(), - name_lc: String::new(), - }]; - return window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: 55. + DEFAULT_WINDOW_HEIGHT, - }, - ); - } else if self.query_lc.ends_with("?") { - self.results = vec![App { - open_command: Function::GoogleSearch(self.query.clone()), - icons: None, - desc: "Search".to_string(), - name: format!("Search for: {}", self.query), - name_lc: String::new(), - }]; - return window::resize( - id, - iced::Size::new(WINDOW_WIDTH, 55. + DEFAULT_WINDOW_HEIGHT), - ); - } else if self.query_lc == "cbhist" { - self.page = Page::ClipboardHistory - } else if self.query_lc == "main" { - self.page = Page::Main - } - - self.handle_search_query_changed(); - - if self.results.is_empty() - && let Some(res) = Expression::from_str(&self.query) - { - self.results.push(App { - open_command: Function::Calculate(res), - desc: RUSTCAST_DESC_NAME.to_string(), - icons: None, - name: res.eval().to_string(), - name_lc: "".to_string(), - }); - } - let new_length = self.results.len(); - - let max_elem = min(5, new_length); - - if prev_size != new_length && self.page == Page::Main { - thread::sleep(Duration::from_millis(30)); - - window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: ((max_elem * 55) + DEFAULT_WINDOW_HEIGHT as usize) as f32, - }, - ) - } else if self.page == Page::ClipboardHistory { - let element_count = min(self.clipboard_content.len(), 5); - window::resize( - id, - iced::Size { - width: WINDOW_WIDTH, - height: ((element_count * 55) + DEFAULT_WINDOW_HEIGHT as usize) as f32, - }, - ) - } else { - Task::none() - } - } - - Message::ClearSearchQuery => { - self.query_lc = String::new(); - self.query = String::new(); - Task::none() - } - - Message::ReloadConfig => { - self.config = toml::from_str( - &fs::read_to_string( - std::env::var("HOME").unwrap_or("".to_owned()) - + "/.config/rustcast/config.toml", - ) - .unwrap_or("".to_owned()), - ) - .unwrap(); - - Task::none() - } - - Message::KeyPressed(hk_id) => { - if hk_id == self.open_hotkey_id { - self.visible = !self.visible; - if self.visible { - Task::chain( - window::open(default_settings()) - .1 - .map(|_| Message::OpenWindow), - operation::focus("query"), - ) - } else { - let to_close = window::latest().map(|x| x.unwrap()); - Task::batch([ - to_close.map(Message::HideWindow), - Task::done(if self.config.buffer_rules.clone().clear_on_hide { - Message::ClearSearchQuery - } else { - Message::_Nothing - }), - ]) - } - } else { - Task::none() - } - } - - Message::RunFunction(command) => { - command.execute(&self.config, &self.query); - - if self.config.buffer_rules.clear_on_enter { - window::latest() - .map(|x| x.unwrap()) - .map(Message::HideWindow) - .chain(Task::done(Message::ClearSearchQuery)) - } else { - Task::none() - } - } - - Message::HideWindow(a) => { - self.restore_frontmost(); - self.visible = false; - self.focused = false; - self.page = Page::Main; - Task::batch([window::close(a), Task::done(Message::ClearSearchResults)]) - } - Message::ClearSearchResults => { - self.results = vec![]; - Task::none() - } - Message::WindowFocusChanged(wid, focused) => { - self.focused = focused; - if !focused { - Task::done(Message::HideWindow(wid)) - .chain(Task::done(Message::ClearSearchQuery)) - } else { - Task::none() - } - } - - Message::ClipboardHistory(clip_content) => { - self.clipboard_content.insert(0, clip_content); - Task::none() - } - - Message::_Nothing => Task::none(), - } - } - - /// This is the view of the window. It handles the rendering of the window - /// - /// The rendering of the window size (the resizing of the window) is handled by the - /// [`Tile::update`] function. - pub fn view(&self, wid: window::Id) -> Element<'_, Message> { - if self.visible { - let title_input = text_input(self.config.placeholder.as_str(), &self.query) - .on_input(move |a| Message::SearchQueryChanged(a, wid)) - .on_paste(move |a| Message::SearchQueryChanged(a, wid)) - .on_submit({ - if self.results.is_empty() { - Message::_Nothing - } else { - Message::RunFunction(self.results.first().unwrap().to_owned().open_command) - } - }) - .id("query") - .width(Fill) - .line_height(LineHeight::Relative(1.5)) - .padding(20); - - match self.page { - Page::Main => { - let mut search_results = Column::new(); - for result in &self.results { - search_results = search_results.push(result.render(&self.config.theme)); - } - Column::new() - .push(title_input) - .push(scrollable(search_results)) - .into() - } - Page::ClipboardHistory => { - let mut clipboard_history = Column::new(); - for result in &self.clipboard_content { - clipboard_history = clipboard_history.push(result.render_clipboard_item()); - } - Column::new() - .push(title_input) - .push(scrollable(clipboard_history)) - .into() - } - } - } else { - space().into() - } - } - - /// This returns the theme of the window - pub fn theme(&self, _: window::Id) -> Option { - Some(self.theme.clone()) - } - - /// This handles the subscriptions of the window - /// - /// The subscriptions are: - /// - Hotkeys - /// - Hot reloading - /// - Clipboard history - /// - Window close events - /// - Keypresses (escape to close the window) - /// - Window focus changes - pub fn subscription(&self) -> Subscription { - Subscription::batch([ - Subscription::run(handle_hotkeys), - Subscription::run(handle_hot_reloading), - Subscription::run(handle_clipboard_history), - window::close_events().map(Message::HideWindow), - keyboard::listen().filter_map(|event| { - if let keyboard::Event::KeyPressed { key, .. } = event { - match key { - keyboard::Key::Named(Named::Escape) => Some(Message::KeyPressed(65598)), - _ => None, - } - } else { - None - } - }), - window::events() - .with(self.focused) - .filter_map(|(focused, (wid, event))| match event { - window::Event::Unfocused => { - if focused { - Some(Message::WindowFocusChanged(wid, false)) - } else { - None - } - } - window::Event::Focused => Some(Message::WindowFocusChanged(wid, true)), - _ => None, - }), - ]) - } - - /// Handles the search query changed event. - /// - /// This is separate from the `update` function because it has a decent amount of logic, and - /// should be separated out to make it easier to test. This function is called by the `update` - /// function to handle the search query changed event. - pub fn handle_search_query_changed(&mut self) { - let filter_vec: &Vec = if self.query_lc.starts_with(&self.prev_query_lc) { - self.prev_query_lc = self.query_lc.to_owned(); - &self.results - } else { - &self.options - }; - - let query = self.query_lc.clone(); - - let mut exact: Vec = filter_vec - .par_iter() - .filter(|x| match &x.open_command { - Function::RunShellCommand(_, _) => x - .name_lc - .starts_with(query.split_once(" ").unwrap_or((&query, "")).0), - _ => x.name_lc == query, - }) - .cloned() - .collect(); - - let mut prefix: Vec = filter_vec - .par_iter() - .filter(|x| match x.open_command { - Function::RunShellCommand(_, _) => false, - _ => x.name_lc != query && x.name_lc.starts_with(&query), - }) - .cloned() - .collect(); - - exact.append(&mut prefix); - self.results = exact; - } - - /// Gets the frontmost application to focus later. - pub fn capture_frontmost(&mut self) { - use objc2_app_kit::NSWorkspace; - - let ws = NSWorkspace::sharedWorkspace(); - self.frontmost = ws.frontmostApplication(); - } - - /// Restores the frontmost application. - #[allow(deprecated)] - pub fn restore_frontmost(&mut self) { - use objc2_app_kit::NSApplicationActivationOptions; - - if let Some(app) = self.frontmost.take() { - app.activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps); - } - } -} - -/// This is the subscription function that handles hot reloading of the config -fn handle_hot_reloading() -> impl futures::Stream { - stream::channel(100, async |mut output| { - let content = fs::read_to_string( - std::env::var("HOME").unwrap_or("".to_owned()) + "/.config/rustcast/config.toml", - ) - .unwrap_or("".to_string()); - loop { - let current_content = fs::read_to_string( - std::env::var("HOME").unwrap_or("".to_owned()) + "/.config/rustcast/config.toml", - ) - .unwrap_or("".to_string()); - - if current_content != content { - output.send(Message::ReloadConfig).await.unwrap(); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) -} - -/// This is the subscription function that handles hotkeys for hiding / showing the window -fn handle_hotkeys() -> impl futures::Stream { - stream::channel(100, async |mut output| { - let receiver = GlobalHotKeyEvent::receiver(); - loop { - if let Ok(event) = receiver.recv() - && event.state == HotKeyState::Pressed - { - output.try_send(Message::KeyPressed(event.id)).unwrap(); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) -} - -/// This is the subscription function that handles the change in clipboard history -fn handle_clipboard_history() -> impl futures::Stream { - stream::channel(100, async |mut output| { - let mut clipboard = Clipboard::new().unwrap(); - let mut prev_byte_rep: Option = None; - - loop { - let byte_rep = if let Ok(a) = clipboard.get_image() { - Some(ClipBoardContentType::Image(a)) - } else if let Ok(a) = clipboard.get_text() { - Some(ClipBoardContentType::Text(a)) - } else { - None - }; - - if byte_rep != prev_byte_rep - && let Some(content) = &byte_rep - { - output - .send(Message::ClipboardHistory(content.to_owned())) - .await - .ok(); - prev_byte_rep = byte_rep; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) -} diff --git a/src/app/apps.rs b/src/app/apps.rs new file mode 100644 index 0000000..cfad6e9 --- /dev/null +++ b/src/app/apps.rs @@ -0,0 +1,103 @@ +//! This modules handles the logic for each "app" that rustcast can load +//! +//! An "app" is effectively, one of the results that rustcast returns when you search for something +use iced::{ + Alignment, Background, + Length::Fill, + alignment::Vertical, + widget::{Button, Row, Text, container, image::Viewer, space}, +}; + +use crate::{ + app::{Message, RUSTCAST_DESC_NAME}, + commands::Function, +}; + +/// The main app struct, that represents an "App" +/// +/// This struct represents a command that rustcast can perform, providing the rustcast +/// the data needed to search for the app, to display the app in search results, and to actually +/// "run" the app. +#[derive(Debug, Clone)] +pub struct App { + pub open_command: Function, + pub desc: String, + pub icons: Option, + pub name: String, + pub name_lc: String, +} + +impl App { + /// This returns the basic apps that rustcast has, such as quiting rustcast and opening preferences + pub fn basic_apps() -> Vec { + vec![ + App { + open_command: Function::Quit, + desc: RUSTCAST_DESC_NAME.to_string(), + icons: None, + name: "Quit RustCast".to_string(), + name_lc: "quit".to_string(), + }, + App { + open_command: Function::OpenPrefPane, + desc: RUSTCAST_DESC_NAME.to_string(), + icons: None, + name: "Open RustCast Preferences".to_string(), + name_lc: "settings".to_string(), + }, + ] + } + + /// This renders the app into an iced element, allowing it to be displayed in the search results + pub fn render<'a>( + &'a self, + theme: &'a crate::config::Theme, + ) -> impl Into> { + let mut tile = Row::new().width(Fill).height(55); + + if theme.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.push( + Button::new( + Text::new(&self.name) + .height(Fill) + .width(Fill) + .color(theme.text_color(1.)) + .align_y(Vertical::Center), + ) + .on_press(Message::RunFunction(self.open_command.clone())) + .style(|_, _| iced::widget::button::Style { + background: Some(Background::Color(theme.bg_color())), + text_color: theme.text_color(1.), + ..Default::default() + }) + .width(Fill) + .height(55), + ); + + tile = tile + .push(container(Text::new(&self.desc).color(theme.text_color(0.4))).padding(15)) + .width(Fill); + + container(tile) + .style(|_| iced::widget::container::Style { + text_color: Some(theme.text_color(1.)), + background: Some(Background::Color(theme.bg_color())), + ..Default::default() + }) + .width(Fill) + .height(Fill) + } +} diff --git a/src/app/tile.rs b/src/app/tile.rs new file mode 100644 index 0000000..551c13d --- /dev/null +++ b/src/app/tile.rs @@ -0,0 +1,247 @@ +//! This module handles the logic for the tile, AKA rustcast's main window +mod elm; +mod update; + +use crate::app::apps::App; +use crate::app::{Message, Page}; +use crate::clipboard::ClipBoardContentType; +use crate::commands::Function; +use crate::config::Config; + +use arboard::Clipboard; +use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; + +use iced::futures::SinkExt; +use iced::window; +use iced::{ + Element, Subscription, Task, Theme, futures, + keyboard::{self, key::Named}, + stream, +}; + +use objc2::rc::Retained; +use objc2_app_kit::NSRunningApplication; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use std::fs; +use std::time::Duration; + +/// This is the base window, and its a "Tile" +/// Its fields are: +/// - Theme ([`iced::Theme`]) +/// - Query (String) +/// - Query Lowercase (String, but lowercase) +/// - Previous Query Lowercase (String) +/// - Results (Vec<[`App`]>) the results of the search +/// - Options (Vec<[`App`]>) the options to search through +/// - Visible (bool) whether the window is visible or not +/// - Focused (bool) whether the window is focused or not +/// - Frontmost ([`Option>`]) the frontmost application before the window was opened +/// - Config ([`Config`]) the app's config +/// - Open Hotkey ID (`u32`) the id of the hotkey that opens the window +/// - Clipboard Content (`Vec<`[`ClipBoardContentType`]`>`) all of the cliboard contents +/// - Page ([`Page`]) the current page of the window (main or clipboard history) +#[derive(Debug, Clone)] +pub struct Tile { + theme: iced::Theme, + query: String, + query_lc: String, + prev_query_lc: String, + results: Vec, + options: Vec, + visible: bool, + focused: bool, + frontmost: Option>, + config: Config, + open_hotkey_id: u32, + clipboard_content: Vec, + page: Page, +} + +impl Tile { + /// Initialise the base window + pub fn new(keybind_id: u32, config: &Config) -> (Self, Task) { + elm::new(keybind_id, config) + } + + /// This handles the iced's updates, which have all the variants of [Message] + pub fn update(&mut self, message: Message) -> Task { + update::handle_update(self, message) + } + + /// This is the view of the window. It handles the rendering of the window + /// + /// The rendering of the window size (the resizing of the window) is handled by the + /// [`Tile::update`] function. + pub fn view(&self, wid: window::Id) -> Element<'_, Message> { + elm::view(self, wid) + } + + /// This returns the theme of the window + pub fn theme(&self, _: window::Id) -> Option { + Some(self.theme.clone()) + } + + /// This handles the subscriptions of the window + /// + /// The subscriptions are: + /// - Hotkeys + /// - Hot reloading + /// - Clipboard history + /// - Window close events + /// - Keypresses (escape to close the window) + /// - Window focus changes + pub fn subscription(&self) -> Subscription { + Subscription::batch([ + Subscription::run(handle_hotkeys), + Subscription::run(handle_hot_reloading), + Subscription::run(handle_clipboard_history), + window::close_events().map(Message::HideWindow), + keyboard::listen().filter_map(|event| { + if let keyboard::Event::KeyPressed { key, .. } = event { + match key { + keyboard::Key::Named(Named::Escape) => Some(Message::KeyPressed(65598)), + _ => None, + } + } else { + None + } + }), + window::events() + .with(self.focused) + .filter_map(|(focused, (wid, event))| match event { + window::Event::Unfocused => { + if focused { + Some(Message::WindowFocusChanged(wid, false)) + } else { + None + } + } + window::Event::Focused => Some(Message::WindowFocusChanged(wid, true)), + _ => None, + }), + ]) + } + + /// Handles the search query changed event. + /// + /// This is separate from the `update` function because it has a decent amount of logic, and + /// should be separated out to make it easier to test. This function is called by the `update` + /// function to handle the search query changed event. + pub fn handle_search_query_changed(&mut self) { + let filter_vec: &Vec = if self.query_lc.starts_with(&self.prev_query_lc) { + self.prev_query_lc = self.query_lc.to_owned(); + &self.results + } else { + &self.options + }; + + let query = self.query_lc.clone(); + + let mut exact: Vec = filter_vec + .par_iter() + .filter(|x| match &x.open_command { + Function::RunShellCommand(_, _) => x + .name_lc + .starts_with(query.split_once(" ").unwrap_or((&query, "")).0), + _ => x.name_lc == query, + }) + .cloned() + .collect(); + + let mut prefix: Vec = filter_vec + .par_iter() + .filter(|x| match x.open_command { + Function::RunShellCommand(_, _) => false, + _ => x.name_lc != query && x.name_lc.starts_with(&query), + }) + .cloned() + .collect(); + + exact.append(&mut prefix); + self.results = exact; + } + + /// Gets the frontmost application to focus later. + pub fn capture_frontmost(&mut self) { + use objc2_app_kit::NSWorkspace; + + let ws = NSWorkspace::sharedWorkspace(); + self.frontmost = ws.frontmostApplication(); + } + + /// Restores the frontmost application. + #[allow(deprecated)] + pub fn restore_frontmost(&mut self) { + use objc2_app_kit::NSApplicationActivationOptions; + + if let Some(app) = self.frontmost.take() { + app.activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps); + } + } +} + +/// This is the subscription function that handles hot reloading of the config +fn handle_hot_reloading() -> impl futures::Stream { + stream::channel(100, async |mut output| { + let content = fs::read_to_string( + std::env::var("HOME").unwrap_or("".to_owned()) + "/.config/rustcast/config.toml", + ) + .unwrap_or("".to_string()); + loop { + let current_content = fs::read_to_string( + std::env::var("HOME").unwrap_or("".to_owned()) + "/.config/rustcast/config.toml", + ) + .unwrap_or("".to_string()); + + if current_content != content { + output.send(Message::ReloadConfig).await.unwrap(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) +} + +/// This is the subscription function that handles hotkeys for hiding / showing the window +fn handle_hotkeys() -> impl futures::Stream { + stream::channel(100, async |mut output| { + let receiver = GlobalHotKeyEvent::receiver(); + loop { + if let Ok(event) = receiver.recv() + && event.state == HotKeyState::Pressed + { + output.try_send(Message::KeyPressed(event.id)).unwrap(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) +} + +/// This is the subscription function that handles the change in clipboard history +fn handle_clipboard_history() -> impl futures::Stream { + stream::channel(100, async |mut output| { + let mut clipboard = Clipboard::new().unwrap(); + let mut prev_byte_rep: Option = None; + + loop { + let byte_rep = if let Ok(a) = clipboard.get_image() { + Some(ClipBoardContentType::Image(a)) + } else if let Ok(a) = clipboard.get_text() { + Some(ClipBoardContentType::Text(a)) + } else { + None + }; + + if byte_rep != prev_byte_rep + && let Some(content) = &byte_rep + { + output + .send(Message::ClipboardHistory(content.to_owned())) + .await + .ok(); + prev_byte_rep = byte_rep; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) +} diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs new file mode 100644 index 0000000..3bc3cc2 --- /dev/null +++ b/src/app/tile/elm.rs @@ -0,0 +1,113 @@ +//! This module handles the logic for the new and view functions according to the elm +//! architecture. If the subscription function becomes too large, it should be moved to this file +use iced::widget::text::LineHeight; +use iced::widget::{Column, scrollable, space}; +use iced::window; +use iced::{Element, Task}; +use iced::{Length::Fill, widget::text_input}; +use rayon::{ + iter::{IntoParallelRefIterator, ParallelIterator}, + slice::ParallelSliceMut, +}; + +use crate::{ + app::{Message, Page, apps::App, default_settings, tile::Tile}, + config::Config, + macos::{self, transform_process_to_ui_element}, + utils::get_installed_apps, +}; + +/// Initialise the base window +pub fn new(keybind_id: u32, config: &Config) -> (Tile, Task) { + let (id, open) = window::open(default_settings()); + + let open = open.discard().chain(window::run(id, |handle| { + macos::macos_window_config(&handle.window_handle().expect("Unable to get window handle")); + // should work now that we have a window + transform_process_to_ui_element(); + })); + + let store_icons = config.theme.show_icons; + + let user_local_path = std::env::var("home").unwrap() + "/applications/"; + + let paths = vec![ + "/applications/", + user_local_path.as_str(), + "/system/applications/", + "/system/applications/utilities/", + ]; + + let mut options: Vec = paths + .par_iter() + .map(|path| get_installed_apps(path, store_icons)) + .flatten() + .collect(); + + options.extend(config.shells.iter().map(|x| x.to_app())); + options.extend(App::basic_apps()); + options.par_sort_by_key(|x| x.name.len()); + + ( + Tile { + query: String::new(), + query_lc: String::new(), + prev_query_lc: String::new(), + results: vec![], + options, + visible: true, + frontmost: None, + focused: false, + config: config.clone(), + theme: config.theme.to_owned().into(), + open_hotkey_id: keybind_id, + clipboard_content: vec![], + page: Page::Main, + }, + Task::batch([open.map(|_| Message::OpenWindow)]), + ) +} + +pub fn view(tile: &Tile, wid: window::Id) -> Element<'_, Message> { + if tile.visible { + let title_input = text_input(tile.config.placeholder.as_str(), &tile.query) + .on_input(move |a| Message::SearchQueryChanged(a, wid)) + .on_paste(move |a| Message::SearchQueryChanged(a, wid)) + .on_submit({ + if tile.results.is_empty() { + Message::_Nothing + } else { + Message::RunFunction(tile.results.first().unwrap().to_owned().open_command) + } + }) + .id("query") + .width(Fill) + .line_height(LineHeight::Relative(1.5)) + .padding(20); + + match tile.page { + Page::Main => { + let mut search_results = Column::new(); + for result in &tile.results { + search_results = search_results.push(result.render(&tile.config.theme)); + } + Column::new() + .push(title_input) + .push(scrollable(search_results)) + .into() + } + Page::ClipboardHistory => { + let mut clipboard_history = Column::new(); + for result in &tile.clipboard_content { + clipboard_history = clipboard_history.push(result.render_clipboard_item()); + } + Column::new() + .push(title_input) + .push(scrollable(clipboard_history)) + .into() + } + } + } else { + space().into() + } +} diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs new file mode 100644 index 0000000..49f76fd --- /dev/null +++ b/src/app/tile/update.rs @@ -0,0 +1,204 @@ +//! This handles the update logic for the tile (AKA rustcast's main window) +use std::cmp::min; +use std::fs; +use std::time::Duration; + +use iced::Task; +use iced::widget::operation; +use iced::window; + +use crate::app::DEFAULT_WINDOW_HEIGHT; +use crate::app::RUSTCAST_DESC_NAME; +use crate::app::WINDOW_WIDTH; +use crate::app::apps::App; +use crate::app::default_settings; +use crate::calculator::Expression; +use crate::commands::Function; +use crate::{ + app::{Message, Page, tile::Tile}, + macos::focus_this_app, +}; + +pub fn handle_update(tile: &mut Tile, message: Message) -> Task { + match message { + Message::OpenWindow => { + tile.capture_frontmost(); + focus_this_app(); + tile.focused = true; + Task::none() + } + + Message::SearchQueryChanged(input, id) => { + tile.query_lc = input.trim().to_lowercase(); + tile.query = input; + let prev_size = tile.results.len(); + if tile.query_lc.is_empty() && tile.page == Page::Main { + tile.results = vec![]; + return window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + }, + ); + } else if tile.query_lc == "randomvar" { + let rand_num = rand::random_range(0..100); + tile.results = vec![App { + open_command: Function::RandomVar(rand_num), + desc: "Easter egg".to_string(), + icons: None, + name: rand_num.to_string(), + name_lc: String::new(), + }]; + return window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: 55. + DEFAULT_WINDOW_HEIGHT, + }, + ); + } else if tile.query_lc.ends_with("?") { + tile.results = vec![App { + open_command: Function::GoogleSearch(tile.query.clone()), + icons: None, + desc: "Search".to_string(), + name: format!("Search for: {}", tile.query), + name_lc: String::new(), + }]; + return window::resize( + id, + iced::Size::new(WINDOW_WIDTH, 55. + DEFAULT_WINDOW_HEIGHT), + ); + } else if tile.query_lc == "cbhist" { + tile.page = Page::ClipboardHistory + } else if tile.query_lc == "main" { + tile.page = Page::Main + } + + tile.handle_search_query_changed(); + + if tile.results.is_empty() + && let Some(res) = Expression::from_str(&tile.query) + { + tile.results.push(App { + open_command: Function::Calculate(res), + desc: RUSTCAST_DESC_NAME.to_string(), + icons: None, + name: res.eval().to_string(), + name_lc: "".to_string(), + }); + } + let new_length = tile.results.len(); + + let max_elem = min(5, new_length); + + if prev_size != new_length && tile.page == Page::Main { + std::thread::sleep(Duration::from_millis(30)); + + window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: ((max_elem * 55) + DEFAULT_WINDOW_HEIGHT as usize) as f32, + }, + ) + } else if tile.page == Page::ClipboardHistory { + let element_count = min(tile.clipboard_content.len(), 5); + window::resize( + id, + iced::Size { + width: WINDOW_WIDTH, + height: ((element_count * 55) + DEFAULT_WINDOW_HEIGHT as usize) as f32, + }, + ) + } else { + Task::none() + } + } + + Message::ClearSearchQuery => { + tile.query_lc = String::new(); + tile.query = String::new(); + Task::none() + } + + Message::ReloadConfig => { + tile.config = toml::from_str( + &fs::read_to_string( + std::env::var("HOME").unwrap_or("".to_owned()) + + "/.config/rustcast/config.toml", + ) + .unwrap_or("".to_owned()), + ) + .unwrap(); + + Task::none() + } + + Message::KeyPressed(hk_id) => { + if hk_id == tile.open_hotkey_id { + tile.visible = !tile.visible; + if tile.visible { + Task::chain( + window::open(default_settings()) + .1 + .map(|_| Message::OpenWindow), + operation::focus("query"), + ) + } else { + let to_close = window::latest().map(|x| x.unwrap()); + Task::batch([ + to_close.map(Message::HideWindow), + Task::done(if tile.config.buffer_rules.clone().clear_on_hide { + Message::ClearSearchQuery + } else { + Message::_Nothing + }), + ]) + } + } else { + Task::none() + } + } + + Message::RunFunction(command) => { + command.execute(&tile.config, &tile.query); + + if tile.config.buffer_rules.clear_on_enter { + window::latest() + .map(|x| x.unwrap()) + .map(Message::HideWindow) + .chain(Task::done(Message::ClearSearchQuery)) + } else { + Task::none() + } + } + + Message::HideWindow(a) => { + tile.restore_frontmost(); + tile.visible = false; + tile.focused = false; + tile.page = Page::Main; + Task::batch([window::close(a), Task::done(Message::ClearSearchResults)]) + } + Message::ClearSearchResults => { + tile.results = vec![]; + Task::none() + } + Message::WindowFocusChanged(wid, focused) => { + tile.focused = focused; + if !focused { + Task::done(Message::HideWindow(wid)).chain(Task::done(Message::ClearSearchQuery)) + } else { + Task::none() + } + } + + Message::ClipboardHistory(clip_content) => { + tile.clipboard_content.insert(0, clip_content); + Task::none() + } + + Message::_Nothing => Task::none(), + } +} diff --git a/src/config.rs b/src/config.rs index 9a0c300..d074d7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use std::{path::Path, sync::Arc}; use iced::{theme::Custom, widget::image::Handle}; use serde::{Deserialize, Serialize}; -use crate::{app::App, commands::Function, utils::handle_from_icns}; +use crate::{app::apps::App, commands::Function, utils::handle_from_icns}; /// The main config struct (effectively the config file's "schema") #[derive(Debug, Clone, Deserialize, Serialize)] @@ -120,7 +120,7 @@ impl Theme { /// /// - clear_on_hide is whether the buffer should be cleared when the window is hidden /// - clear_on_enter is whether the buffer should be cleared when the user presses enter after -/// searching +/// searching #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Buffer { diff --git a/src/main.rs b/src/main.rs index 6b66de4..6f7069d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod utils; use std::path::Path; -use crate::{app::Tile, config::Config, utils::to_key_code}; +use crate::{app::tile::Tile, config::Config, utils::to_key_code}; use global_hotkey::{ GlobalHotKeyManager, diff --git a/src/utils.rs b/src/utils.rs index f6a4c53..53717ea 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,7 +12,7 @@ use icns::IconFamily; use image::RgbaImage; use rayon::iter::{IntoParallelIterator, ParallelIterator}; -use crate::{app::App, commands::Function}; +use crate::{app::apps::App, commands::Function}; /// The default error log path (works only on unix systems, and must be changed for windows /// support) @@ -53,7 +53,7 @@ pub(crate) fn handle_from_icns(path: &Path) -> Option { /// This gets all the installed apps in the given directory /// -/// the directories are defined in [`crate::app::Tile::new`] +/// the directories are defined in [`crate::app::tile::Tile::new`] pub(crate) fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { let entries: Vec<_> = fs::read_dir(dir.as_ref()) .unwrap_or_else(|x| {