diff --git a/FEATURES.md b/FEATURES.md index 23588f2..24f3b24 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -8,3 +8,4 @@ 1. Easter eggs (_randomvar_, _67_ and _lemon_) 1. Haptics (for macos only rn) 1. Opening settings file from searching +1. Switching macOS input source while RustCast is open diff --git a/docs/config.toml b/docs/config.toml index a9ea399..6a60829 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -23,6 +23,16 @@ show_trayicon = true # hotkey for opening clipboard history clipboard_hotkey = "SUPER+SHIFT+2" +# Switch to this macOS input source when RustCast opens. +# Leave unset to keep the current input source. +# You can also configure this from the Settings page. +# The input source must be enabled in macOS Keyboard settings. +# Examples: "com.apple.keylayout.US", "com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese" +# input_source_on_open = "com.apple.keylayout.US" + +# Restore the previously active input source when RustCast closes. +restore_input_source_on_close = true + # Create a presentation.sh file and you can make it do pretty much anything # Example usage: # - turn on / off your WM in different "modes" diff --git a/docs/default.toml b/docs/default.toml index 1872c1f..c28924e 100644 --- a/docs/default.toml +++ b/docs/default.toml @@ -7,6 +7,9 @@ haptic_feedback = false show_trayicon = true shells = [] clipboard_hotkey = "SUPER+SHIFT+C" +# Also configurable from the Settings page. +# input_source_on_open = "com.apple.keylayout.US" +restore_input_source_on_close = true [buffer_rules] clear_on_hide = true diff --git a/src/app.rs b/src/app.rs index 219636c..aaafdca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -194,6 +194,7 @@ pub enum SetConfigFields { SetThemeFields(SetConfigThemeFields), SetBufferFields(SetConfigBufferFields), ClipboardPasteOnSelect(bool), + InputSourceOnOpen(Option), } #[derive(Debug, Clone)] diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 2fb8036..2c8739c 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -347,6 +347,55 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat .height(SETTINGS_ITEM_HEIGHT), ); + let input_sources = crate::platform::macos::input_source::enabled_input_sources(); + let input_source_enabled = config.input_source_on_open.is_some(); + let input_source_default = config + .input_source_on_open + .clone() + .filter(|selected| input_sources.iter().any(|source| &source.id == selected)) + .or_else(|| input_sources.first().map(|source| source.id.clone())); + let input_source_toggle = settings_row_without_reset(settings_item_row([ + settings_hint_text( + theme.clone(), + "Input source switching", + Some("Switch keyboard input while RustCast is open"), + ), + Space::new().width(Length::Fill).into(), + toggler(input_source_enabled) + .style(move |_, status| settings_toggle_style(status)) + .on_toggle(move |enabled| { + Message::SetConfig(SetConfigFields::InputSourceOnOpen(if enabled { + input_source_default.clone() + } else { + None + })) + }) + .into(), + ])); + + let input_source_dropdown = if input_source_enabled && !input_sources.is_empty() { + let selected_input_source = config.input_source_on_open.as_ref().and_then(|selected| { + input_sources + .iter() + .find(|source| &source.id == selected) + .cloned() + }); + let theme_clone = theme.clone(); + let theme_clone_2 = theme.clone(); + settings_row_without_reset(settings_item_row([ + settings_hint_text(theme.clone(), "Input source on open", None::), + Space::new().width(Length::Fill).into(), + pick_list(input_sources, selected_input_source, |source| { + Message::SetConfig(SetConfigFields::InputSourceOnOpen(Some(source.id))) + }) + .style(move |_, status| picklist_style(&theme_clone, status)) + .menu_style(move |_| picklist_menu_style(&theme_clone_2)) + .into(), + ])) + } else { + Space::new().height(0).into() + }; + let theme_clone = theme.clone(); let auto_suggest = settings_row_without_reset(settings_item_row([ settings_hint_text(theme.clone(), "Suggestions on open", None::), @@ -422,6 +471,8 @@ fn general_tab(config: Box, theme: crate::config::Theme) -> Column<'stat tray_icon, clipboard_history, cbhist_paste_on_select, + input_source_toggle, + input_source_dropdown, auto_suggest, ]) .spacing(10) diff --git a/src/app/tile.rs b/src/app/tile.rs index 88dfb58..d498889 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -204,6 +204,37 @@ pub struct Tile { pub settings_tab: crate::app::SettingsTab, debouncer: Debouncer, pub settings_window: Option, + previous_input_source: Option, +} + +impl Tile { + pub fn switch_input_source_on_open(&mut self) { + let Some(input_source) = self.config.input_source_on_open.clone() else { + return; + }; + + self.previous_input_source = + crate::platform::macos::input_source::current_input_source_id(); + + if let Err(err) = crate::platform::macos::input_source::select_input_source(&input_source) { + log::error!("Failed to switch input source to {input_source}: {err}"); + } + } + + pub fn restore_input_source_on_close(&mut self) { + if !self.config.restore_input_source_on_close { + self.previous_input_source = None; + return; + } + + let Some(input_source) = self.previous_input_source.take() else { + return; + }; + + if let Err(err) = crate::platform::macos::input_source::select_input_source(&input_source) { + log::error!("Failed to restore input source to {input_source}: {err}"); + } + } } /// A struct to store all the hotkeys diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index ff493d8..bfbe4bc 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -106,6 +106,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { settings_tab: SettingsTab::General, debouncer: Debouncer::new(config.debounce_delay), settings_window: None, + previous_input_source: None, }, Task::batch([open.map(|_| Message::OpenWindow)]), ) diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 8e09fc9..d2d2282 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -115,6 +115,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { Message::OpenWindow => { tile.capture_frontmost(); + tile.switch_input_source_on_open(); focus_this_app(); tile.focused = true; tile.visible = true; @@ -566,6 +567,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { return Task::none(); } info!("Hiding RustCast window"); + tile.restore_input_source_on_close(); tile.visible = false; tile.focused = false; tile.page = Page::Main; @@ -976,6 +978,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { SetConfigFields::ClipboardPasteOnSelect(v) => { final_config.cbhist_paste_on_select = v } + SetConfigFields::InputSourceOnOpen(input_source) => { + final_config.input_source_on_open = input_source + } SetConfigFields::ToDefault => { final_config = Config::default(); } @@ -1453,6 +1458,7 @@ mod tests { settings_tab: crate::app::SettingsTab::General, debouncer: crate::debounce::Debouncer::new(10), settings_window: None, + previous_input_source: None, } } diff --git a/src/config.rs b/src/config.rs index 169ea9c..7986001 100644 --- a/src/config.rs +++ b/src/config.rs @@ -114,6 +114,8 @@ pub struct Config { pub log_path: String, pub debounce_delay: u64, pub auto_update: bool, + pub input_source_on_open: Option, + pub restore_input_source_on_close: bool, } impl Default for Config { @@ -141,6 +143,8 @@ impl Default for Config { aliases: HashMap::new(), shells: vec![], debounce_delay: 300, + input_source_on_open: None, + restore_input_source_on_close: true, } } } @@ -386,6 +390,8 @@ mod tests { assert_eq!(config.search_dirs, vec!["~".to_string()]); assert_eq!(config.debounce_delay, 300); assert_eq!(config.main_page, MainPage::Blank); + assert_eq!(config.input_source_on_open, None); + assert!(config.restore_input_source_on_close); } #[test] diff --git a/src/platform/macos/input_source.rs b/src/platform/macos/input_source.rs new file mode 100644 index 0000000..bbee6e1 --- /dev/null +++ b/src/platform/macos/input_source.rs @@ -0,0 +1,129 @@ +use std::{ffi::c_void, ptr::NonNull}; + +use objc2_core_foundation::{CFArray, CFRetained, CFString, CFType}; + +type Boolean = u8; +type OSStatus = i32; +type TISInputSourceRef = *const c_void; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputSource { + pub id: String, + pub name: String, +} + +impl std::fmt::Display for InputSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + static kTISPropertyInputSourceID: *const CFString; + static kTISPropertyLocalizedName: *const CFString; + + fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef; + fn TISCreateInputSourceList( + properties: *const c_void, + includeAllInstalled: Boolean, + ) -> *const CFArray; + fn TISGetInputSourceProperty( + inputSource: TISInputSourceRef, + propertyKey: *const CFString, + ) -> *const c_void; + fn TISSelectInputSource(inputSource: TISInputSourceRef) -> OSStatus; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFRelease(cf: *mut CFType); +} + +pub fn current_input_source_id() -> Option { + let source = unsafe { TISCopyCurrentKeyboardInputSource() }; + if source.is_null() { + return None; + } + + let id = input_source_id(source); + unsafe { CFRelease(source.cast_mut().cast()) }; + id +} + +pub fn select_input_source(id: &str) -> Result<(), String> { + let Some(sources) = input_source_list(false) else { + return Err("TISCreateInputSourceList returned null".to_string()); + }; + + select_input_source_from_list(&sources, id) +} + +pub fn enabled_input_sources() -> Vec { + input_source_list(false) + .map(|sources| input_sources_from_list(&sources)) + .unwrap_or_default() +} + +fn input_source_list(include_all_installed: bool) -> Option> { + let sources = + unsafe { TISCreateInputSourceList(std::ptr::null(), include_all_installed as Boolean) }; + NonNull::new(sources.cast_mut()).map(|sources| unsafe { CFRetained::from_raw(sources) }) +} + +fn select_input_source_from_list(sources: &CFArray, id: &str) -> Result<(), String> { + for idx in 0..sources.count() { + let source = unsafe { sources.value_at_index(idx) }; + if source.is_null() { + continue; + } + + if input_source_id(source).as_deref() == Some(id) { + let status = unsafe { TISSelectInputSource(source) }; + return if status == 0 { + Ok(()) + } else { + Err(format!("TISSelectInputSource failed with status {status}")) + }; + } + } + + Err(format!("input source not found: {id}")) +} + +fn input_sources_from_list(sources: &CFArray) -> Vec { + (0..sources.count()) + .filter_map(|idx| { + let source = unsafe { sources.value_at_index(idx) }; + if source.is_null() { + return None; + } + let id = input_source_id(source)?; + let name = input_source_name(source).unwrap_or_else(|| id.clone()); + Some(InputSource { id, name }) + }) + .collect() +} + +fn input_source_id(source: TISInputSourceRef) -> Option { + let id_ref = unsafe { TISGetInputSourceProperty(source, kTISPropertyInputSourceID) }; + if id_ref.is_null() { + return None; + } + + cf_string_to_string(id_ref) +} + +fn input_source_name(source: TISInputSourceRef) -> Option { + let name_ref = unsafe { TISGetInputSourceProperty(source, kTISPropertyLocalizedName) }; + if name_ref.is_null() { + return None; + } + + cf_string_to_string(name_ref) +} + +fn cf_string_to_string(value: *const c_void) -> Option { + NonNull::new(value.cast_mut().cast::()) + .map(|value| unsafe { value.as_ref() }.to_string()) +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index c851b4a..36d18e6 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -3,6 +3,7 @@ pub mod accessibility; pub mod discovery; pub mod events; pub mod haptics; +pub mod input_source; pub mod launching; pub mod urlscheme; pub mod window;