diff --git a/Cargo.toml b/Cargo.toml index 6757252b..22aa6d79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ features = [ "Win32_System_Com", "Win32_UI_Shell_Common", # for IObjectArray "Win32_Foundation", + "Win32_Globalization", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Graphics_Direct2D", diff --git a/komorebi-bar/src/keyboard.rs b/komorebi-bar/src/keyboard.rs new file mode 100755 index 00000000..14143e7d --- /dev/null +++ b/komorebi-bar/src/keyboard.rs @@ -0,0 +1,177 @@ +use crate::config::LabelPrefix; +use crate::render::RenderConfig; +use crate::widget::BarWidget; +use eframe::egui::text::LayoutJob; +use eframe::egui::Align; +use eframe::egui::Context; +use eframe::egui::Label; +use eframe::egui::TextFormat; +use eframe::egui::Ui; +use eframe::egui::WidgetText; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::time::Duration; +use std::time::Instant; +use windows::Win32::Globalization::LCIDToLocaleName; +use windows::Win32::Globalization::LOCALE_ALLOW_NEUTRAL_NAMES; +use windows::Win32::System::SystemServices::LOCALE_NAME_MAX_LENGTH; +use windows::Win32::UI::Input::KeyboardAndMouse::GetKeyboardLayout; +use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; +use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; + +const DEFAULT_DATA_REFRESH_INTERVAL: u64 = 1; +const ERROR_TEXT: &str = "Error"; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct KeyboardConfig { + /// Enable the Input widget + pub enable: bool, + /// Data refresh interval (default: 1 second) + pub data_refresh_interval: Option, + /// Display label prefix + pub label_prefix: Option, +} + +impl From for Keyboard { + fn from(value: KeyboardConfig) -> Self { + let data_refresh_interval = value + .data_refresh_interval + .unwrap_or(DEFAULT_DATA_REFRESH_INTERVAL); + + Self { + enable: value.enable, + data_refresh_interval, + label_prefix: value.label_prefix.unwrap_or(LabelPrefix::IconAndText), + last_updated: Instant::now(), + lang_name: get_lang(), + } + } +} + +pub struct Keyboard { + pub enable: bool, + data_refresh_interval: u64, + label_prefix: LabelPrefix, + last_updated: Instant, + lang_name: String, +} + +/// Retrieves the name of the active keyboard layout for the current foreground window. +/// +/// This function determines the active keyboard layout by querying the system for the +/// foreground window's thread ID and its associated keyboard layout. It then attempts +/// to retrieve the locale name corresponding to the keyboard layout. +/// +/// # Failure Cases +/// +/// This function can fail in two distinct scenarios: +/// +/// 1. **Failure to Retrieve the Locale Name**: +/// If the system fails to retrieve the locale name (e.g., due to an invalid or unsupported +/// language identifier), the function will return `Err(())`. +/// +/// 2. **Invalid UTF-16 Characters in the Locale Name**: +/// If the retrieved locale name contains invalid UTF-16 sequences, the conversion to a Rust +/// `String` will fail, and the function will return `Err(())`. +/// +/// # Returns +/// +/// - `Ok(String)`: The name of the active keyboard layout as a valid UTF-8 string. +/// - `Err(())`: Indicates that the function failed to retrieve the locale name or encountered +/// invalid UTF-16 characters during conversion. +fn get_active_keyboard_layout() -> Result { + let foreground_window_tid = unsafe { GetWindowThreadProcessId(GetForegroundWindow(), None) }; + let lcid = unsafe { GetKeyboardLayout(foreground_window_tid) }; + + // Extract the low word (language identifier) from the keyboard layout handle. + let lang_id = (lcid.0 as u32) & 0xFFFF; + let mut locale_name_buffer = [0; LOCALE_NAME_MAX_LENGTH as usize]; + let char_count = unsafe { + LCIDToLocaleName( + lang_id, + Some(&mut locale_name_buffer), + LOCALE_ALLOW_NEUTRAL_NAMES, + ) + }; + + match char_count { + 0 => Err(()), + _ => String::from_utf16(&locale_name_buffer[..char_count as usize]).map_err(|_| ()), + } +} + +/// Retrieves the name of the active keyboard layout or a fallback error message. +/// +/// # Behavior +/// +/// - **Success Case**: +/// If [`get_active_keyboard_layout`] succeeds, this function returns the retrieved keyboard +/// layout name as a `String`. +/// +/// - **Failure Case**: +/// If [`get_active_keyboard_layout`] fails, this function returns the value of `ERROR_TEXT` +/// as a fallback message. This ensures that the function always returns a valid `String`, +/// even in error scenarios. +/// +/// # Returns +/// +/// A `String` representing either: +/// - The name of the active keyboard layout, or +/// - The fallback error message (`ERROR_TEXT`) if the layout name cannot be retrieved. +fn get_lang() -> String { + get_active_keyboard_layout() + .map(|l| l.trim_end_matches('\0').to_string()) + .unwrap_or_else(|_| ERROR_TEXT.to_string()) +} + +impl Keyboard { + fn output(&mut self) -> String { + let now = Instant::now(); + if now.duration_since(self.last_updated) > Duration::from_secs(self.data_refresh_interval) { + self.last_updated = now; + self.lang_name = get_lang(); + } + + match self.label_prefix { + LabelPrefix::Text | LabelPrefix::IconAndText => format!("KB: {}", self.lang_name), + LabelPrefix::None | LabelPrefix::Icon => self.lang_name.clone(), + } + } +} + +impl BarWidget for Keyboard { + fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { + if self.enable { + let output = self.output(); + if !output.is_empty() { + let mut layout_job = LayoutJob::simple( + match self.label_prefix { + LabelPrefix::Icon | LabelPrefix::IconAndText => { + egui_phosphor::regular::KEYBOARD.to_string() + } + LabelPrefix::None | LabelPrefix::Text => String::new(), + }, + config.icon_font_id.clone(), + ctx.style().visuals.selection.stroke.color, + 100.0, + ); + + layout_job.append( + &output, + 10.0, + TextFormat { + font_id: config.text_font_id.clone(), + color: ctx.style().visuals.text_color(), + valign: Align::Center, + ..Default::default() + }, + ); + + config.apply_on_widget(true, ui, |ui| { + ui.add(Label::new(WidgetText::LayoutJob(layout_job.clone())).selectable(false)) + }); + } + } + } +} diff --git a/komorebi-bar/src/main.rs b/komorebi-bar/src/main.rs index 887710ec..b60bf449 100644 --- a/komorebi-bar/src/main.rs +++ b/komorebi-bar/src/main.rs @@ -3,6 +3,7 @@ mod battery; mod config; mod cpu; mod date; +mod keyboard; mod komorebi; mod komorebi_layout; mod media; diff --git a/komorebi-bar/src/widget.rs b/komorebi-bar/src/widget.rs index 2502f20a..aa205447 100644 --- a/komorebi-bar/src/widget.rs +++ b/komorebi-bar/src/widget.rs @@ -4,6 +4,8 @@ use crate::cpu::Cpu; use crate::cpu::CpuConfig; use crate::date::Date; use crate::date::DateConfig; +use crate::keyboard::Keyboard; +use crate::keyboard::KeyboardConfig; use crate::komorebi::Komorebi; use crate::komorebi::KomorebiConfig; use crate::media::Media; @@ -34,6 +36,7 @@ pub enum WidgetConfig { Battery(BatteryConfig), Cpu(CpuConfig), Date(DateConfig), + Keyboard(KeyboardConfig), Komorebi(KomorebiConfig), Media(MediaConfig), Memory(MemoryConfig), @@ -49,6 +52,7 @@ impl WidgetConfig { WidgetConfig::Battery(config) => Box::new(Battery::from(*config)), WidgetConfig::Cpu(config) => Box::new(Cpu::from(*config)), WidgetConfig::Date(config) => Box::new(Date::from(config.clone())), + WidgetConfig::Keyboard(config) => Box::new(Keyboard::from(*config)), WidgetConfig::Komorebi(config) => Box::new(Komorebi::from(config)), WidgetConfig::Media(config) => Box::new(Media::from(*config)), WidgetConfig::Memory(config) => Box::new(Memory::from(*config)), @@ -64,6 +68,7 @@ impl WidgetConfig { WidgetConfig::Battery(config) => config.enable, WidgetConfig::Cpu(config) => config.enable, WidgetConfig::Date(config) => config.enable, + WidgetConfig::Keyboard(config) => config.enable, WidgetConfig::Komorebi(config) => { config.workspaces.as_ref().is_some_and(|w| w.enable) || config.layout.as_ref().is_some_and(|w| w.enable) diff --git a/schema.bar.json b/schema.bar.json index fb131b4c..6ff3f81c 100644 --- a/schema.bar.json +++ b/schema.bar.json @@ -271,6 +271,66 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": [ + "Keyboard" + ], + "properties": { + "Keyboard": { + "type": "object", + "required": [ + "enable" + ], + "properties": { + "data_refresh_interval": { + "description": "Data refresh interval (default: 1 second)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "enable": { + "description": "Enable the Input widget", + "type": "boolean" + }, + "label_prefix": { + "description": "Display label prefix", + "oneOf": [ + { + "description": "Show no prefix", + "type": "string", + "enum": [ + "None" + ] + }, + { + "description": "Show an icon", + "type": "string", + "enum": [ + "Icon" + ] + }, + { + "description": "Show text", + "type": "string", + "enum": [ + "Text" + ] + }, + { + "description": "Show an icon and text", + "type": "string", + "enum": [ + "IconAndText" + ] + } + ] + } + } + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ @@ -1518,6 +1578,66 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": [ + "Keyboard" + ], + "properties": { + "Keyboard": { + "type": "object", + "required": [ + "enable" + ], + "properties": { + "data_refresh_interval": { + "description": "Data refresh interval (default: 1 second)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "enable": { + "description": "Enable the Input widget", + "type": "boolean" + }, + "label_prefix": { + "description": "Display label prefix", + "oneOf": [ + { + "description": "Show no prefix", + "type": "string", + "enum": [ + "None" + ] + }, + { + "description": "Show an icon", + "type": "string", + "enum": [ + "Icon" + ] + }, + { + "description": "Show text", + "type": "string", + "enum": [ + "Text" + ] + }, + { + "description": "Show an icon and text", + "type": "string", + "enum": [ + "IconAndText" + ] + } + ] + } + } + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ @@ -2698,6 +2818,66 @@ }, "additionalProperties": false }, + { + "type": "object", + "required": [ + "Keyboard" + ], + "properties": { + "Keyboard": { + "type": "object", + "required": [ + "enable" + ], + "properties": { + "data_refresh_interval": { + "description": "Data refresh interval (default: 1 second)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "enable": { + "description": "Enable the Input widget", + "type": "boolean" + }, + "label_prefix": { + "description": "Display label prefix", + "oneOf": [ + { + "description": "Show no prefix", + "type": "string", + "enum": [ + "None" + ] + }, + { + "description": "Show an icon", + "type": "string", + "enum": [ + "Icon" + ] + }, + { + "description": "Show text", + "type": "string", + "enum": [ + "Text" + ] + }, + { + "description": "Show an icon and text", + "type": "string", + "enum": [ + "IconAndText" + ] + } + ] + } + } + } + }, + "additionalProperties": false + }, { "type": "object", "required": [