From e878c60b9bf2c1cb079064fefbb65b046f9a0fd3 Mon Sep 17 00:00:00 2001 From: Ting Kai Chiu Date: Thu, 9 Jul 2026 21:50:26 +0800 Subject: [PATCH 1/2] Add macOS input source switching --- FEATURES.md | 1 + docs/config.toml | 9 +++ docs/default.toml | 2 + src/app/tile.rs | 31 ++++++++ src/app/tile/elm.rs | 1 + src/app/tile/update.rs | 3 + src/config.rs | 6 ++ src/platform/macos/input_source.rs | 112 +++++++++++++++++++++++++++++ src/platform/macos/mod.rs | 1 + 9 files changed, 166 insertions(+) create mode 100644 src/platform/macos/input_source.rs diff --git a/FEATURES.md b/FEATURES.md index 23588f2b..24f3b241 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 a9ea399e..c806b41f 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -23,6 +23,15 @@ 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. +# 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 1872c1fe..5945f7bc 100644 --- a/docs/default.toml +++ b/docs/default.toml @@ -7,6 +7,8 @@ haptic_feedback = false show_trayicon = true shells = [] clipboard_hotkey = "SUPER+SHIFT+C" +# 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/tile.rs b/src/app/tile.rs index 88dfb58b..d4988899 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 ff493d88..bfbe4bce 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 8e09fc93..2d723892 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; @@ -1453,6 +1455,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 169ea9ce..79860015 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 00000000..79a248db --- /dev/null +++ b/src/platform/macos/input_source.rs @@ -0,0 +1,112 @@ +use std::ffi::{c_char, c_void}; + +use objc2_core_foundation::CFType; + +type Boolean = u8; +type CFIndex = isize; +type OSStatus = i32; +type CFStringEncoding = u32; +type TISInputSourceRef = *const c_void; + +const K_CFSTRING_ENCODING_UTF8: CFStringEncoding = 0x0800_0100; + +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + static kTISPropertyInputSourceID: *const c_void; + + fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef; + fn TISCreateInputSourceList( + properties: *const c_void, + includeAllInstalled: Boolean, + ) -> *const c_void; + fn TISGetInputSourceProperty( + inputSource: TISInputSourceRef, + propertyKey: *const c_void, + ) -> *const c_void; + fn TISSelectInputSource(inputSource: TISInputSourceRef) -> OSStatus; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFArrayGetCount(theArray: *const c_void) -> CFIndex; + fn CFArrayGetValueAtIndex(theArray: *const c_void, idx: CFIndex) -> *const c_void; + fn CFRelease(cf: *mut CFType); + fn CFStringGetCString( + theString: *const c_void, + buffer: *mut c_char, + bufferSize: CFIndex, + encoding: CFStringEncoding, + ) -> Boolean; +} + +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 sources = unsafe { TISCreateInputSourceList(std::ptr::null(), false as Boolean) }; + if sources.is_null() { + return Err("TISCreateInputSourceList returned null".to_string()); + } + + let result = select_input_source_from_list(sources, id); + unsafe { CFRelease(sources.cast_mut().cast()) }; + result +} + +fn select_input_source_from_list(sources: *const c_void, id: &str) -> Result<(), String> { + let count = unsafe { CFArrayGetCount(sources) }; + + for idx in 0..count { + let source = unsafe { CFArrayGetValueAtIndex(sources, 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_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 cf_string_to_string(value: *const c_void) -> Option { + let mut buffer = vec![0u8; 1024]; + let ok = unsafe { + CFStringGetCString( + value, + buffer.as_mut_ptr().cast(), + buffer.len() as CFIndex, + K_CFSTRING_ENCODING_UTF8, + ) + }; + + if ok == 0 { + return None; + } + + let nul = buffer.iter().position(|&byte| byte == 0)?; + Some(String::from_utf8_lossy(&buffer[..nul]).into_owned()) +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index c851b4a6..36d18e62 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; From a55b898dba7708675ab9cbae2c619ea34a16073f Mon Sep 17 00:00:00 2001 From: Ting Kai Chiu Date: Sat, 11 Jul 2026 09:32:43 +0800 Subject: [PATCH 2/2] Add settings for input source switching --- docs/config.toml | 1 + docs/default.toml | 1 + src/app.rs | 1 + src/app/pages/settings.rs | 51 ++++++++++++++++ src/app/tile/update.rs | 3 + src/platform/macos/input_source.rs | 97 ++++++++++++++++++------------ 6 files changed, 114 insertions(+), 40 deletions(-) diff --git a/docs/config.toml b/docs/config.toml index c806b41f..6a60829a 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -25,6 +25,7 @@ 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" diff --git a/docs/default.toml b/docs/default.toml index 5945f7bc..c28924e6 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" +# Also configurable from the Settings page. # input_source_on_open = "com.apple.keylayout.US" restore_input_source_on_close = true diff --git a/src/app.rs b/src/app.rs index 219636c7..aaafdcad 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 2fb80366..2c8739cd 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/update.rs b/src/app/tile/update.rs index 2d723892..d2d2282c 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -978,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(); } diff --git a/src/platform/macos/input_source.rs b/src/platform/macos/input_source.rs index 79a248db..bbee6e1b 100644 --- a/src/platform/macos/input_source.rs +++ b/src/platform/macos/input_source.rs @@ -1,42 +1,43 @@ -use std::ffi::{c_char, c_void}; +use std::{ffi::c_void, ptr::NonNull}; -use objc2_core_foundation::CFType; +use objc2_core_foundation::{CFArray, CFRetained, CFString, CFType}; type Boolean = u8; -type CFIndex = isize; type OSStatus = i32; -type CFStringEncoding = u32; type TISInputSourceRef = *const c_void; -const K_CFSTRING_ENCODING_UTF8: CFStringEncoding = 0x0800_0100; +#[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 c_void; + static kTISPropertyInputSourceID: *const CFString; + static kTISPropertyLocalizedName: *const CFString; fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef; fn TISCreateInputSourceList( properties: *const c_void, includeAllInstalled: Boolean, - ) -> *const c_void; + ) -> *const CFArray; fn TISGetInputSourceProperty( inputSource: TISInputSourceRef, - propertyKey: *const c_void, + propertyKey: *const CFString, ) -> *const c_void; fn TISSelectInputSource(inputSource: TISInputSourceRef) -> OSStatus; } #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { - fn CFArrayGetCount(theArray: *const c_void) -> CFIndex; - fn CFArrayGetValueAtIndex(theArray: *const c_void, idx: CFIndex) -> *const c_void; fn CFRelease(cf: *mut CFType); - fn CFStringGetCString( - theString: *const c_void, - buffer: *mut c_char, - bufferSize: CFIndex, - encoding: CFStringEncoding, - ) -> Boolean; } pub fn current_input_source_id() -> Option { @@ -51,21 +52,28 @@ pub fn current_input_source_id() -> Option { } pub fn select_input_source(id: &str) -> Result<(), String> { - let sources = unsafe { TISCreateInputSourceList(std::ptr::null(), false as Boolean) }; - if sources.is_null() { + let Some(sources) = input_source_list(false) else { return Err("TISCreateInputSourceList returned null".to_string()); - } + }; + + select_input_source_from_list(&sources, id) +} - let result = select_input_source_from_list(sources, id); - unsafe { CFRelease(sources.cast_mut().cast()) }; - result +pub fn enabled_input_sources() -> Vec { + input_source_list(false) + .map(|sources| input_sources_from_list(&sources)) + .unwrap_or_default() } -fn select_input_source_from_list(sources: *const c_void, id: &str) -> Result<(), String> { - let count = unsafe { CFArrayGetCount(sources) }; +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) }) +} - for idx in 0..count { - let source = unsafe { CFArrayGetValueAtIndex(sources, idx) }; +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; } @@ -83,6 +91,20 @@ fn select_input_source_from_list(sources: *const c_void, id: &str) -> Result<(), 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() { @@ -92,21 +114,16 @@ fn input_source_id(source: TISInputSourceRef) -> Option { cf_string_to_string(id_ref) } -fn cf_string_to_string(value: *const c_void) -> Option { - let mut buffer = vec![0u8; 1024]; - let ok = unsafe { - CFStringGetCString( - value, - buffer.as_mut_ptr().cast(), - buffer.len() as CFIndex, - K_CFSTRING_ENCODING_UTF8, - ) - }; - - if ok == 0 { +fn input_source_name(source: TISInputSourceRef) -> Option { + let name_ref = unsafe { TISGetInputSourceProperty(source, kTISPropertyLocalizedName) }; + if name_ref.is_null() { return None; } - let nul = buffer.iter().position(|&byte| byte == 0)?; - Some(String::from_utf8_lossy(&buffer[..nul]).into_owned()) + 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()) }