Skip to content

Commit a55b898

Browse files
committed
Add settings for input source switching
1 parent e878c60 commit a55b898

6 files changed

Lines changed: 114 additions & 40 deletions

File tree

‎docs/config.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ clipboard_hotkey = "SUPER+SHIFT+2"
2525

2626
# Switch to this macOS input source when RustCast opens.
2727
# Leave unset to keep the current input source.
28+
# You can also configure this from the Settings page.
2829
# The input source must be enabled in macOS Keyboard settings.
2930
# Examples: "com.apple.keylayout.US", "com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese"
3031
# input_source_on_open = "com.apple.keylayout.US"

‎docs/default.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ haptic_feedback = false
77
show_trayicon = true
88
shells = []
99
clipboard_hotkey = "SUPER+SHIFT+C"
10+
# Also configurable from the Settings page.
1011
# input_source_on_open = "com.apple.keylayout.US"
1112
restore_input_source_on_close = true
1213

‎src/app.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ pub enum SetConfigFields {
194194
SetThemeFields(SetConfigThemeFields),
195195
SetBufferFields(SetConfigBufferFields),
196196
ClipboardPasteOnSelect(bool),
197+
InputSourceOnOpen(Option<String>),
197198
}
198199

199200
#[derive(Debug, Clone)]

‎src/app/pages/settings.rs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,55 @@ fn general_tab(config: Box<Config>, theme: crate::config::Theme) -> Column<'stat
347347
.height(SETTINGS_ITEM_HEIGHT),
348348
);
349349

350+
let input_sources = crate::platform::macos::input_source::enabled_input_sources();
351+
let input_source_enabled = config.input_source_on_open.is_some();
352+
let input_source_default = config
353+
.input_source_on_open
354+
.clone()
355+
.filter(|selected| input_sources.iter().any(|source| &source.id == selected))
356+
.or_else(|| input_sources.first().map(|source| source.id.clone()));
357+
let input_source_toggle = settings_row_without_reset(settings_item_row([
358+
settings_hint_text(
359+
theme.clone(),
360+
"Input source switching",
361+
Some("Switch keyboard input while RustCast is open"),
362+
),
363+
Space::new().width(Length::Fill).into(),
364+
toggler(input_source_enabled)
365+
.style(move |_, status| settings_toggle_style(status))
366+
.on_toggle(move |enabled| {
367+
Message::SetConfig(SetConfigFields::InputSourceOnOpen(if enabled {
368+
input_source_default.clone()
369+
} else {
370+
None
371+
}))
372+
})
373+
.into(),
374+
]));
375+
376+
let input_source_dropdown = if input_source_enabled && !input_sources.is_empty() {
377+
let selected_input_source = config.input_source_on_open.as_ref().and_then(|selected| {
378+
input_sources
379+
.iter()
380+
.find(|source| &source.id == selected)
381+
.cloned()
382+
});
383+
let theme_clone = theme.clone();
384+
let theme_clone_2 = theme.clone();
385+
settings_row_without_reset(settings_item_row([
386+
settings_hint_text(theme.clone(), "Input source on open", None::<String>),
387+
Space::new().width(Length::Fill).into(),
388+
pick_list(input_sources, selected_input_source, |source| {
389+
Message::SetConfig(SetConfigFields::InputSourceOnOpen(Some(source.id)))
390+
})
391+
.style(move |_, status| picklist_style(&theme_clone, status))
392+
.menu_style(move |_| picklist_menu_style(&theme_clone_2))
393+
.into(),
394+
]))
395+
} else {
396+
Space::new().height(0).into()
397+
};
398+
350399
let theme_clone = theme.clone();
351400
let auto_suggest = settings_row_without_reset(settings_item_row([
352401
settings_hint_text(theme.clone(), "Suggestions on open", None::<String>),
@@ -422,6 +471,8 @@ fn general_tab(config: Box<Config>, theme: crate::config::Theme) -> Column<'stat
422471
tray_icon,
423472
clipboard_history,
424473
cbhist_paste_on_select,
474+
input_source_toggle,
475+
input_source_dropdown,
425476
auto_suggest,
426477
])
427478
.spacing(10)

‎src/app/tile/update.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task<Message> {
978978
SetConfigFields::ClipboardPasteOnSelect(v) => {
979979
final_config.cbhist_paste_on_select = v
980980
}
981+
SetConfigFields::InputSourceOnOpen(input_source) => {
982+
final_config.input_source_on_open = input_source
983+
}
981984
SetConfigFields::ToDefault => {
982985
final_config = Config::default();
983986
}

‎src/platform/macos/input_source.rs‎

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,43 @@
1-
use std::ffi::{c_char, c_void};
1+
use std::{ffi::c_void, ptr::NonNull};
22

3-
use objc2_core_foundation::CFType;
3+
use objc2_core_foundation::{CFArray, CFRetained, CFString, CFType};
44

55
type Boolean = u8;
6-
type CFIndex = isize;
76
type OSStatus = i32;
8-
type CFStringEncoding = u32;
97
type TISInputSourceRef = *const c_void;
108

11-
const K_CFSTRING_ENCODING_UTF8: CFStringEncoding = 0x0800_0100;
9+
#[derive(Debug, Clone, PartialEq, Eq)]
10+
pub struct InputSource {
11+
pub id: String,
12+
pub name: String,
13+
}
14+
15+
impl std::fmt::Display for InputSource {
16+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17+
write!(f, "{} ({})", self.name, self.id)
18+
}
19+
}
1220

1321
#[link(name = "Carbon", kind = "framework")]
1422
unsafe extern "C" {
15-
static kTISPropertyInputSourceID: *const c_void;
23+
static kTISPropertyInputSourceID: *const CFString;
24+
static kTISPropertyLocalizedName: *const CFString;
1625

1726
fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef;
1827
fn TISCreateInputSourceList(
1928
properties: *const c_void,
2029
includeAllInstalled: Boolean,
21-
) -> *const c_void;
30+
) -> *const CFArray;
2231
fn TISGetInputSourceProperty(
2332
inputSource: TISInputSourceRef,
24-
propertyKey: *const c_void,
33+
propertyKey: *const CFString,
2534
) -> *const c_void;
2635
fn TISSelectInputSource(inputSource: TISInputSourceRef) -> OSStatus;
2736
}
2837

2938
#[link(name = "CoreFoundation", kind = "framework")]
3039
unsafe extern "C" {
31-
fn CFArrayGetCount(theArray: *const c_void) -> CFIndex;
32-
fn CFArrayGetValueAtIndex(theArray: *const c_void, idx: CFIndex) -> *const c_void;
3340
fn CFRelease(cf: *mut CFType);
34-
fn CFStringGetCString(
35-
theString: *const c_void,
36-
buffer: *mut c_char,
37-
bufferSize: CFIndex,
38-
encoding: CFStringEncoding,
39-
) -> Boolean;
4041
}
4142

4243
pub fn current_input_source_id() -> Option<String> {
@@ -51,21 +52,28 @@ pub fn current_input_source_id() -> Option<String> {
5152
}
5253

5354
pub fn select_input_source(id: &str) -> Result<(), String> {
54-
let sources = unsafe { TISCreateInputSourceList(std::ptr::null(), false as Boolean) };
55-
if sources.is_null() {
55+
let Some(sources) = input_source_list(false) else {
5656
return Err("TISCreateInputSourceList returned null".to_string());
57-
}
57+
};
58+
59+
select_input_source_from_list(&sources, id)
60+
}
5861

59-
let result = select_input_source_from_list(sources, id);
60-
unsafe { CFRelease(sources.cast_mut().cast()) };
61-
result
62+
pub fn enabled_input_sources() -> Vec<InputSource> {
63+
input_source_list(false)
64+
.map(|sources| input_sources_from_list(&sources))
65+
.unwrap_or_default()
6266
}
6367

64-
fn select_input_source_from_list(sources: *const c_void, id: &str) -> Result<(), String> {
65-
let count = unsafe { CFArrayGetCount(sources) };
68+
fn input_source_list(include_all_installed: bool) -> Option<CFRetained<CFArray>> {
69+
let sources =
70+
unsafe { TISCreateInputSourceList(std::ptr::null(), include_all_installed as Boolean) };
71+
NonNull::new(sources.cast_mut()).map(|sources| unsafe { CFRetained::from_raw(sources) })
72+
}
6673

67-
for idx in 0..count {
68-
let source = unsafe { CFArrayGetValueAtIndex(sources, idx) };
74+
fn select_input_source_from_list(sources: &CFArray, id: &str) -> Result<(), String> {
75+
for idx in 0..sources.count() {
76+
let source = unsafe { sources.value_at_index(idx) };
6977
if source.is_null() {
7078
continue;
7179
}
@@ -83,6 +91,20 @@ fn select_input_source_from_list(sources: *const c_void, id: &str) -> Result<(),
8391
Err(format!("input source not found: {id}"))
8492
}
8593

94+
fn input_sources_from_list(sources: &CFArray) -> Vec<InputSource> {
95+
(0..sources.count())
96+
.filter_map(|idx| {
97+
let source = unsafe { sources.value_at_index(idx) };
98+
if source.is_null() {
99+
return None;
100+
}
101+
let id = input_source_id(source)?;
102+
let name = input_source_name(source).unwrap_or_else(|| id.clone());
103+
Some(InputSource { id, name })
104+
})
105+
.collect()
106+
}
107+
86108
fn input_source_id(source: TISInputSourceRef) -> Option<String> {
87109
let id_ref = unsafe { TISGetInputSourceProperty(source, kTISPropertyInputSourceID) };
88110
if id_ref.is_null() {
@@ -92,21 +114,16 @@ fn input_source_id(source: TISInputSourceRef) -> Option<String> {
92114
cf_string_to_string(id_ref)
93115
}
94116

95-
fn cf_string_to_string(value: *const c_void) -> Option<String> {
96-
let mut buffer = vec![0u8; 1024];
97-
let ok = unsafe {
98-
CFStringGetCString(
99-
value,
100-
buffer.as_mut_ptr().cast(),
101-
buffer.len() as CFIndex,
102-
K_CFSTRING_ENCODING_UTF8,
103-
)
104-
};
105-
106-
if ok == 0 {
117+
fn input_source_name(source: TISInputSourceRef) -> Option<String> {
118+
let name_ref = unsafe { TISGetInputSourceProperty(source, kTISPropertyLocalizedName) };
119+
if name_ref.is_null() {
107120
return None;
108121
}
109122

110-
let nul = buffer.iter().position(|&byte| byte == 0)?;
111-
Some(String::from_utf8_lossy(&buffer[..nul]).into_owned())
123+
cf_string_to_string(name_ref)
124+
}
125+
126+
fn cf_string_to_string(value: *const c_void) -> Option<String> {
127+
NonNull::new(value.cast_mut().cast::<CFString>())
128+
.map(|value| unsafe { value.as_ref() }.to_string())
112129
}

0 commit comments

Comments
 (0)