diff --git a/Cargo.lock b/Cargo.lock index 1348e6f02..d92d5d6d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5391,7 +5391,6 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-graphics", "objc2-foundation 0.3.2", - "opener", "openlogi-agent-core", "openlogi-core", "openlogi-hid", diff --git a/crates/openlogi-agent/Cargo.toml b/crates/openlogi-agent/Cargo.toml index cc8bc343f..e986223ea 100644 --- a/crates/openlogi-agent/Cargo.toml +++ b/crates/openlogi-agent/Cargo.toml @@ -35,7 +35,6 @@ rust-i18n = "4" tracing = { workspace = true } tracing-subscriber = { workspace = true } sysinfo = { version = "0.38.4", default-features = false, features = ["system"] } -opener = { workspace = true } succession = { version = "0.2", features = ["supervision", "eviction"] } tracing-appender = "0.2.5" diff --git a/crates/openlogi-agent/src/tray.rs b/crates/openlogi-agent/src/tray.rs index f8f7af15f..69fbc7f51 100644 --- a/crates/openlogi-agent/src/tray.rs +++ b/crates/openlogi-agent/src/tray.rs @@ -255,17 +255,48 @@ impl MenuTarget { } } -fn open_url(url: &str) { - match opener::open(url) { - Ok(()) => info!(url, "menu-bar — opening URL"), - Err(e) => warn!(error = %e, url, "could not open URL from menu bar"), +fn gui_bundle_id_for_profile(dev_profile: bool) -> String { + if dev_profile { + brand::dev_id(brand::APP_ID) + } else { + brand::APP_ID.to_string() } } -/// Route a GUI-directed [`DeeplinkCommand`] through the `openlogi://` scheme. -/// macOS launches the GUI (cold start) or hands the URL to the running app. +fn current_gui_bundle_id() -> String { + gui_bundle_id_for_profile(openlogi_core::paths::is_dev_profile()) +} + +/// Route a GUI-directed [`DeeplinkCommand`] to the GUI bundle that matches this +/// agent's profile. Both prod and dev bundles register `openlogi://`; a generic +/// LaunchServices open can therefore hand a dev-agent click to the installed +/// production app. `open -b` pins delivery to the matching bundle id while still +/// preserving cold-start and warm-reopen Apple Event behaviour. fn open_command(command: DeeplinkCommand) { - open_url(&command.to_url()); + let url = command.to_url(); + let bundle_id = current_gui_bundle_id(); + match std::process::Command::new("/usr/bin/open") + .arg("-b") + .arg(&bundle_id) + .arg(&url) + .status() + { + Ok(status) if status.success() => { + info!(url, bundle_id, "menu-bar — opening GUI command"); + } + Ok(status) => warn!( + %status, + url, + bundle_id, + "could not open GUI command from menu bar" + ), + Err(e) => warn!( + error = %e, + url, + bundle_id, + "could not open GUI command from menu bar" + ), + } } /// Menu-bar Quit: take a running GUI with us, then end the process. @@ -273,16 +304,11 @@ fn open_command(command: DeeplinkCommand) { /// Kept out of `define_class!` so the lint set actually sees the exit — clippy /// does not look inside macro expansions. fn quit_agent() -> ! { - // Tell a *running* GUI to quit too, but don't let `open` cold-launch one - // just to immediately quit it (it would flash a window — and on first run - // the update-consent prompt — before exiting). The gate keeps the target - // warm in the common case, so the blocking `.output()` (which guarantees - // Apple-Event delivery) returns at once; a GUI that races to exit after the - // check was quitting anyway. + // Tell only this profile's *running* GUI to quit. Matching the agent's + // profile matters when a dev bundle coexists with the installed app: seeing + // the other profile must never make us cold-launch this one just to quit it. if gui_is_running() { - let _ = std::process::Command::new("open") - .arg(DeeplinkCommand::Quit.to_url()) - .output(); + open_command(DeeplinkCommand::Quit); } crate::overlay::evict_on_quit(); info!("menu-bar Quit — exiting agent"); @@ -293,17 +319,14 @@ fn quit_agent() -> ! { std::process::exit(0) } -/// Whether an OpenLogi GUI process is currently running (prod or dev bundle). -/// Used to avoid cold-launching the GUI from the Quit handler just to quit it. +/// Whether the GUI matching this agent's prod/dev profile is currently running. +/// Used to avoid cold-launching that GUI from the Quit handler just to quit it. fn gui_is_running() -> bool { - // Release and dev; the agent's own id is `brand::AGENT_ID`, so neither - // matches the agent itself. - let dev = brand::dev_id(brand::APP_ID); - [brand::APP_ID, dev.as_str()].iter().any(|id| { - let running = - NSRunningApplication::runningApplicationsWithBundleIdentifier(&NSString::from_str(id)); - !running.is_empty() - }) + let bundle_id = current_gui_bundle_id(); + let running = NSRunningApplication::runningApplicationsWithBundleIdentifier( + &NSString::from_str(&bundle_id), + ); + !running.is_empty() } /// Run the agent's AppKit main loop: an `Accessory` `NSApplication` (no Dock @@ -502,6 +525,15 @@ mod tests { use super::*; use openlogi_hid::device_io_channel; + #[test] + fn gui_bundle_id_matches_the_agent_profile() { + assert_eq!(gui_bundle_id_for_profile(false), brand::APP_ID); + assert_eq!( + gui_bundle_id_for_profile(true), + brand::dev_id(brand::APP_ID) + ); + } + #[test] fn overlapping_suspend_sources_all_clear_before_device_io_resumes() { let (signal, gate) = device_io_channel(); diff --git a/crates/openlogi-core/src/binding/button.rs b/crates/openlogi-core/src/binding/button.rs index 8811c3d38..37d538dd9 100644 --- a/crates/openlogi-core/src/binding/button.rs +++ b/crates/openlogi-core/src/binding/button.rs @@ -4,9 +4,10 @@ use std::fmt; use serde::{Deserialize, Serialize}; -/// One of the user-rebindable hotspots on a Logi mouse. The order matches the -/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the -/// default-binding generator and the popover trigger list. +/// One user-rebindable Logitech HID control. Mouse controls occupy the legacy +/// declaration prefix; keyboard controls are appended so persisted/binary enum +/// indices remain stable. [`ButtonId::ALL`] contains mouse controls only, while +/// [`ButtonId::KEYBOARD_KEYS`] contains divertable keyboard controls. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum ButtonId { /// The primary button. Rebindable in the config schema, but the OS hook @@ -73,10 +74,20 @@ pub enum ButtonId { /// Tilting the main wheel right — `0x1b04` CID `0x005d` ("Right Scroll"), /// Logi metadata slot `SLOT_NAME_RIGHT_SCROLL_BUTTON`. Counterpart to /// [`ButtonId::WheelTiltLeft`]. + WheelTiltRight, + /// Keyboard "Calculator" control (CID `0x000a`) — one of the extra + /// programmable controls on full-size MX Mechanical boards. + KeyCalculator, + /// Keyboard "Show Desktop" control (CID `0x006e`) on full-size MX Mechanical + /// boards. + KeyShowDesktop, + /// Keyboard "Lock PC" control (CID `0x006f`) on full-size MX Mechanical + /// boards. The adjacent MultiPlatform Search control already maps to + /// [`ButtonId::KeySearch`] (`0x00d4`). /// /// Declared last: the TOML config and any serialized form encode the /// variant identifier / index, so new buttons are append-only. - WheelTiltRight, + KeyLockPC, } impl ButtonId { @@ -99,11 +110,11 @@ impl ButtonId { ButtonId::HapticPanel, ]; - /// The divertable keyboard F-row controls, in F-row order. Kept out of - /// [`ButtonId::ALL`]: that array seeds mouse defaults and the mouse - /// popover trigger list, while keyboard keys stay native unless the user - /// binds them (an unbound key is never diverted). - pub const KEYBOARD_KEYS: [ButtonId; 9] = [ + /// Divertable Logitech keyboard controls. Kept out of [`ButtonId::ALL`]: + /// that array seeds mouse defaults and the mouse popover trigger list, while + /// keyboard controls stay native unless the user binds them (an unbound key + /// is never diverted). + pub const KEYBOARD_KEYS: [ButtonId; 12] = [ ButtonId::KeySearch, ButtonId::KeyDictation, ButtonId::KeyEmoji, @@ -113,6 +124,9 @@ impl ButtonId { ButtonId::KeyMute, ButtonId::KeyVolumeDown, ButtonId::KeyVolumeUp, + ButtonId::KeyCalculator, + ButtonId::KeyShowDesktop, + ButtonId::KeyLockPC, ]; /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev) @@ -165,6 +179,9 @@ impl ButtonId { ButtonId::KeyMute => "Mute Key", ButtonId::KeyVolumeDown => "Volume Down Key", ButtonId::KeyVolumeUp => "Volume Up Key", + ButtonId::KeyCalculator => "Calculator Key", + ButtonId::KeyShowDesktop => "Show Desktop Key", + ButtonId::KeyLockPC => "Lock PC Key", ButtonId::HapticPanel => "Haptic Panel", } } @@ -194,6 +211,9 @@ impl ButtonId { ButtonId::KeyMute => "keyboard.mute_key", ButtonId::KeyVolumeDown => "keyboard.volume_down_key", ButtonId::KeyVolumeUp => "keyboard.volume_up_key", + ButtonId::KeyCalculator => "keyboard.calculator_key", + ButtonId::KeyShowDesktop => "keyboard.show_desktop_key", + ButtonId::KeyLockPC => "keyboard.lock_pc_key", ButtonId::HapticPanel => "actions.haptic_panel", } } diff --git a/crates/openlogi-core/src/binding/defaults.rs b/crates/openlogi-core/src/binding/defaults.rs index 0e3bf8dd5..51326f43d 100644 --- a/crates/openlogi-core/src/binding/defaults.rs +++ b/crates/openlogi-core/src/binding/defaults.rs @@ -79,7 +79,10 @@ pub fn default_binding(button: ButtonId) -> Action { | ButtonId::KeyPlayPause | ButtonId::KeyMute | ButtonId::KeyVolumeDown - | ButtonId::KeyVolumeUp => Action::None, + | ButtonId::KeyVolumeUp + | ButtonId::KeyCalculator + | ButtonId::KeyShowDesktop + | ButtonId::KeyLockPC => Action::None, } } diff --git a/crates/openlogi-core/src/binding/tests.rs b/crates/openlogi-core/src/binding/tests.rs index 85d0b293b..aaa7625e2 100644 --- a/crates/openlogi-core/src/binding/tests.rs +++ b/crates/openlogi-core/src/binding/tests.rs @@ -543,6 +543,18 @@ fn wheel_tilt_defaults_to_the_scroll_its_firmware_already_does() { } } +#[test] +fn mx_mechanical_extra_keyboard_controls_default_to_native() { + for key in [ + ButtonId::KeyCalculator, + ButtonId::KeyShowDesktop, + ButtonId::KeyLockPC, + ] { + assert!(ButtonId::KEYBOARD_KEYS.contains(&key)); + assert_eq!(default_binding(key), Action::None); + } +} + #[test] fn thumbwheel_defaults_match_normalised_native_direction() { // HID++ capture normalises the per-model firmware polarity to physical diff --git a/crates/openlogi-core/src/config/tests.rs b/crates/openlogi-core/src/config/tests.rs index 5f719fa98..538f1b633 100644 --- a/crates/openlogi-core/src/config/tests.rs +++ b/crates/openlogi-core/src/config/tests.rs @@ -509,6 +509,47 @@ fn bindings_roundtrip_per_device() { assert!(parsed.bindings_for("deadbeef").is_empty()); } +#[test] +fn mx_mechanical_extra_bindings_roundtrip_as_device_controls() { + let mut cfg = Config::default(); + let key = "serial:2233sct00sw8"; + cfg.set_binding( + key, + ButtonId::KeyCalculator, + Binding::Single(Action::CustomShortcut( + "Ctrl+Alt+N".parse().expect("valid shortcut"), + )), + ); + cfg.set_binding( + key, + ButtonId::KeyShowDesktop, + Binding::Single(Action::CustomShortcut( + "Ctrl+Alt+L".parse().expect("valid shortcut"), + )), + ); + + let body = toml::to_string_pretty(&cfg).expect("serialize"); + assert!(body.contains("KeyCalculator")); + assert!(body.contains("KeyShowDesktop")); + assert!(!body.contains("KeyLockPC")); + + let parsed = write_and_read(&cfg); + let bindings = parsed.bindings_for(key); + assert_eq!(bindings.len(), 2); + assert_eq!( + bindings.get(&ButtonId::KeyCalculator), + Some(&Binding::Single(Action::CustomShortcut( + "Ctrl+Alt+N".parse().expect("valid shortcut") + ))) + ); + assert_eq!( + bindings.get(&ButtonId::KeyShowDesktop), + Some(&Binding::Single(Action::CustomShortcut( + "Ctrl+Alt+L".parse().expect("valid shortcut") + ))) + ); +} + #[test] fn human_readable_toml_layout() { let mut cfg = Config::default(); diff --git a/crates/openlogi-desktop/src/features/keyboard/editors.rs b/crates/openlogi-desktop/src/features/keyboard/editors.rs index 2b6bb562b..7170456ba 100644 --- a/crates/openlogi-desktop/src/features/keyboard/editors.rs +++ b/crates/openlogi-desktop/src/features/keyboard/editors.rs @@ -25,9 +25,8 @@ use gpui_component::{ v_flex, }; use openlogi_core::binding::{Action, KeyCombo, WorkflowStep}; -use openlogi_core::config::KeyTrigger; -use super::function_row::FunctionRowView; +use super::function_row::{BindingTarget, FunctionRowView, commit_target}; use crate::features::mouse::picker::{compact_panel, divider, editor_scroll_list, title}; use crate::state::{AppState, DeviceRecord, StateEvent}; use crate::ui::components::{MenuRow, control_input}; @@ -36,6 +35,7 @@ use crate::ui::theme::{self, Palette, Typography as _}; /// Which power-user editor is showing for the selected key. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum PowerUserKind { + CustomShortcut, TypeText, RunAppleScript, RunShellCommand, @@ -45,6 +45,7 @@ pub enum PowerUserKind { impl PowerUserKind { fn heading_key(self) -> &'static str { match self { + Self::CustomShortcut => "action_ring.custom_shortcut", Self::TypeText => "actions.type_text_heading", Self::RunAppleScript => "actions.run_applescript_heading", Self::RunShellCommand => "actions.run_shell_command_heading", @@ -55,6 +56,7 @@ impl PowerUserKind { pub(crate) fn text_editor_placeholder(kind: PowerUserKind) -> gpui::SharedString { match kind { + PowerUserKind::CustomShortcut => "Ctrl+Alt+N".into(), PowerUserKind::TypeText => tr!("actions.type_text_placeholder"), PowerUserKind::RunAppleScript => "display dialog \"Hello\"".into(), PowerUserKind::RunShellCommand => "echo hello".into(), @@ -64,6 +66,9 @@ pub(crate) fn text_editor_placeholder(kind: PowerUserKind) -> gpui::SharedString pub(crate) fn text_editor_seed(action: Option<&Action>, kind: PowerUserKind) -> String { match (action, kind) { + (Some(Action::CustomShortcut(combo)), PowerUserKind::CustomShortcut) => { + combo.rendered_label() + } (Some(Action::TypeText(text)), PowerUserKind::TypeText) | (Some(Action::RunAppleScript(text)), PowerUserKind::RunAppleScript) | (Some(Action::RunShellCommand(text)), PowerUserKind::RunShellCommand) => text.clone(), @@ -80,7 +85,8 @@ pub(crate) fn workflow_editor_seed(action: Option<&Action>) -> Vec /// Render the editor card for `kind`, replacing the panel's action list. pub fn editor_card( - trigger: KeyTrigger, + target: BindingTarget, + target_name: gpui::SharedString, kind: PowerUserKind, text_state: Option>, workflow_draft: Vec, @@ -88,9 +94,11 @@ pub fn editor_card( pal: Palette, ) -> gpui::Div { match kind { - PowerUserKind::Workflow => workflow_editor_card(trigger, workflow_draft, view, pal), + PowerUserKind::Workflow => { + workflow_editor_card(target, target_name, workflow_draft, view, pal) + } _ => match text_state { - Some(state) => text_editor_card(trigger, kind, state, view, pal), + Some(state) => text_editor_card(target, target_name, kind, state, view, pal), None => compact_panel(pal) .w(px(300.)) .child(title(tr!("keyboard.editor_unavailable"), pal)), @@ -101,14 +109,15 @@ pub fn editor_card( /// The TypeText / RunAppleScript / RunShellCommand editors share a single text /// field; only the commit wrapping differs. fn text_editor_card( - trigger: KeyTrigger, + target: BindingTarget, + target_name: gpui::SharedString, kind: PowerUserKind, text_state: Entity, view: &Entity, pal: Palette, ) -> gpui::Div { let heading = tr!(kind.heading_key()); - let key_name = trigger.to_string(); + let key_name = target_name; compact_panel(pal) .w(px(300.)) @@ -122,18 +131,18 @@ fn text_editor_card( .p_2() .gap_2() .child(div().child(control_input(&text_state).cleanable(true))) - .child(editor_action_row(trigger, kind, view)), + .child(editor_action_row(target, kind, view)), ) } /// Cancel (back to list) + Save (commit the drafted text). fn editor_action_row( - trigger: KeyTrigger, + target: BindingTarget, kind: PowerUserKind, view: &Entity, ) -> impl IntoElement { let view_save = view.clone(); - let trigger_save = trigger.clone(); + let target_save = target.clone(); let view_cancel = view.clone(); h_flex() @@ -158,6 +167,12 @@ fn editor_action_row( .map(|s| s.read(cx).value().to_string()) .unwrap_or_default(); let action = match kind { + PowerUserKind::CustomShortcut => { + let Ok(combo) = text.parse::() else { + return; + }; + Action::CustomShortcut(combo) + } PowerUserKind::TypeText => Action::TypeText(text), PowerUserKind::RunAppleScript => Action::RunAppleScript(text), PowerUserKind::RunShellCommand => Action::RunShellCommand(text), @@ -165,7 +180,7 @@ fn editor_action_row( }; AppState::update(cx, |state, cx| { let key = state.current_record().map(DeviceRecord::device_key); - state.commit_keyboard_binding(trigger_save.clone(), Some(action)); + commit_target(state, &target_save, action); if let Some(key) = key { cx.emit(StateEvent::BindingsChanged(key)); } @@ -177,12 +192,13 @@ fn editor_action_row( /// The Workflow editor: a list of steps with add/remove. fn workflow_editor_card( - trigger: KeyTrigger, + target: BindingTarget, + target_name: gpui::SharedString, steps: Vec, view: &Entity, pal: Palette, ) -> gpui::Div { - let key_name = trigger.to_string(); + let key_name = target_name; let rows = steps .into_iter() @@ -229,13 +245,13 @@ fn workflow_editor_card( .label(tr!("actions.save_workflow")) .on_click({ let v = view.clone(); - let trigger = trigger.clone(); + let target = target.clone(); move |_e, _window, cx| { let steps = v.read(cx).workflow_draft().to_vec(); let action = Action::Workflow(steps); AppState::update(cx, |state, cx| { let key = state.current_record().map(DeviceRecord::device_key); - state.commit_keyboard_binding(trigger.clone(), Some(action)); + commit_target(state, &target, action); if let Some(key) = key { cx.emit(StateEvent::BindingsChanged(key)); } @@ -353,6 +369,17 @@ mod tests { ); } + #[test] + fn custom_shortcut_editor_seeds_the_canonical_chord() { + let combo = "Ctrl+Alt+N".parse::().expect("valid shortcut"); + let action = Action::CustomShortcut(combo); + + assert_eq!( + text_editor_seed(Some(&action), PowerUserKind::CustomShortcut), + "Ctrl+Alt+N" + ); + } + #[test] fn workflow_editor_seed_only_uses_workflow_action() { let steps = vec![WorkflowStep::TypeText("hello".into())]; diff --git a/crates/openlogi-desktop/src/features/keyboard/function_row.rs b/crates/openlogi-desktop/src/features/keyboard/function_row.rs index 25a09da11..31bfab4d9 100644 --- a/crates/openlogi-desktop/src/features/keyboard/function_row.rs +++ b/crates/openlogi-desktop/src/features/keyboard/function_row.rs @@ -6,9 +6,10 @@ //! right while the keyboard physically makes room. Only one key is selected at a //! time. //! -//! F-key bindings are global (`AppState`'s keyboard map), committed via -//! [`AppState::commit_keyboard_binding`]. The panel lists the same action -//! catalog the mouse picker uses, plus a Power User section. +//! Ordinary Esc/F-key bindings are global (`AppState`'s keyboard map). Dedicated +//! Logitech HID++ controls that occupy top-row positions on supported boards are +//! per-device and use the same binding projection as mouse HID++ controls. The +//! panel lists the shared action catalog plus parameterised Power User actions. #![expect( clippy::needless_pass_by_value, @@ -31,8 +32,9 @@ use gpui::{ point, prelude::FluentBuilder as _, px, rgb, svg, }; use gpui_component::{Selectable as _, h_flex, input::InputState, v_flex}; -use openlogi_core::binding::{Action, WorkflowStep}; +use openlogi_core::binding::{Action, ButtonId, WorkflowStep}; use openlogi_core::config::{KeyModifiers, KeyTrigger}; +use openlogi_core::device::DeviceModelInfo; use super::editors::{ PowerUserKind, text_editor_placeholder, text_editor_seed, workflow_editor_seed, @@ -56,6 +58,8 @@ use gpui::{Animation, AnimationExt, img}; /// boards expose all 20; boards with a shorter F-row (a G513 has F1-F12) /// surface a prefix of this list, sized by the asset's key markers — see /// [`key_points`]. +const MX_MECHANICAL_BTLE_PID: u16 = 0xb366; + const FUNCTION_KEYS: [(&str, u16); 20] = [ ("Esc", 0x35), ("F1", 0x7A), @@ -243,8 +247,8 @@ impl FunctionRowView { impl Render for FunctionRowView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let state = AppState::try_read(cx); - let asset = state.and_then(|state| state.current_record()?.asset.as_ref()); - let bindings = state.map(AppState::keyboard_bindings); + let record = state.and_then(AppState::current_record); + let asset = record.and_then(|record| record.asset.as_ref()); let glow = state.and_then(|state| { state .current_record() @@ -264,11 +268,12 @@ impl Render for FunctionRowView { keycode: *keycode, modifiers: KeyModifiers::default(), }; - let bound = bindings.and_then(|bindings| bindings.get(&trigger)); + let target = binding_target_for_slot(record, idx, trigger); + let bound = state.and_then(|state| target.current_action(state)); KeySlot { idx, label, - trigger, + target, x_frac: point.x_frac, y_frac: point.y_frac, binding: binding_label(bound), @@ -291,7 +296,7 @@ impl Render for FunctionRowView { if let (Some(selected_idx), Some(kind)) = (selected, active_editor) && let Some(slot) = slots.get(selected_idx) { - let current_action = bindings.and_then(|bindings| bindings.get(&slot.trigger)); + let current_action = state.and_then(|state| slot.target.current_action(state)); match kind { PowerUserKind::Workflow => { if self.workflow_draft.is_empty() { @@ -346,12 +351,40 @@ fn keyboard_render_size(asset: Option<&ResolvedAsset>, viewport_h: f32) -> (f32, asset_dimensions_for_png(asset, target_h, KEYBOARD_W) } +/// Where one top-row hotspot stores and dispatches its binding. +/// +/// Most function keys are host-level shortcuts shared across keyboards. Some +/// Logitech boards expose dedicated HID++ controls in positions that the +/// generic diagram historically labelled F16-F19; those must stay per-device +/// or the OS hook never sees the physical press. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum BindingTarget { + Global(KeyTrigger), + Device(ButtonId), +} + +impl BindingTarget { + fn current_action<'a>(&self, state: &'a AppState) -> Option<&'a Action> { + match self { + Self::Global(trigger) => state.keyboard_bindings().get(trigger), + Self::Device(button) => state.button_bindings().get(button), + } + } + + fn display_name(&self) -> gpui::SharedString { + match self { + Self::Global(trigger) => trigger.to_string().into(), + Self::Device(button) => tr!(button.translation_key()), + } + } +} + /// One function-row key with its resolved layout + binding. #[derive(Clone)] struct KeySlot { idx: usize, label: &'static str, - trigger: KeyTrigger, + target: BindingTarget, x_frac: f32, y_frac: f32, binding: gpui::SharedString, @@ -674,6 +707,42 @@ fn key_click_target( }) } +fn binding_target_for_slot( + record: Option<&DeviceRecord>, + idx: usize, + trigger: KeyTrigger, +) -> BindingTarget { + let Some(button) = record + .and_then(|record| record.model_info.as_ref()) + .and_then(|model| mx_mechanical_extra_button(model, idx)) + else { + return BindingTarget::Global(trigger); + }; + BindingTarget::Device(button) +} + +fn mx_mechanical_extra_button(model: &DeviceModelInfo, idx: usize) -> Option { + if !model.model_ids.contains(&MX_MECHANICAL_BTLE_PID) { + return None; + } + match idx { + 16 => Some(ButtonId::KeyCalculator), + 17 => Some(ButtonId::KeyShowDesktop), + 18 => Some(ButtonId::KeySearch), + 19 => Some(ButtonId::KeyLockPC), + _ => None, + } +} + +pub(crate) fn commit_target(state: &mut AppState, target: &BindingTarget, action: Action) { + match target { + BindingTarget::Global(trigger) => { + state.commit_keyboard_binding(trigger.clone(), Some(action)); + } + BindingTarget::Device(button) => state.commit_binding(*button, action), + } +} + fn binding_label(action: Option<&Action>) -> gpui::SharedString { match action { Some(action) => localized_action_label(action), @@ -796,13 +865,18 @@ impl FunctionRowView { ) -> gpui::Div { let pal = theme::palette(cx); let slot = &slots[selected_idx]; - let trigger = slot.trigger.clone(); - let key_name = trigger.to_string(); + let target = slot.target.clone(); + let target_name = target.display_name(); + let panel_name: gpui::SharedString = match &target { + BindingTarget::Global(_) => slot.label.into(), + BindingTarget::Device(_) => format!("{} · {}", slot.label, target_name).into(), + }; // If an editor is active, render it instead of the list. if let Some(kind) = self.active_editor { return super::editors::editor_card( - trigger, + target, + panel_name, kind, self.text_state.clone(), self.workflow_draft.clone(), @@ -811,15 +885,15 @@ impl FunctionRowView { ); } - let current = AppState::try_read(cx) - .and_then(|state| state.keyboard_bindings().get(&trigger).cloned()); + let current = + AppState::try_read(cx).and_then(|state| slot.target.current_action(state).cloned()); let view_for_pick = view.clone(); - let trigger_for_pick = trigger.clone(); + let target_for_pick = slot.target.clone(); let on_pick: PickFn = Rc::new(move |action, _window, cx| { AppState::update(cx, |state, cx| { let key = state.current_record().map(DeviceRecord::device_key); - state.commit_keyboard_binding(trigger_for_pick.clone(), Some(action)); + commit_target(state, &target_for_pick, action); if let Some(key) = key { cx.emit(StateEvent::BindingsChanged(key)); } @@ -832,14 +906,14 @@ impl FunctionRowView { compact_panel(pal) .w(px(PANEL_W)) .max_h(px(500.)) - .child(title_header(&key_name, &pal)) + .child(title_header(&panel_name, &pal)) .child(divider(pal)) .child(editor_scroll_list("key-panel-scroll", rows)) } } /// The panel's title — shows which key is selected, e.g. "F1". -fn title_header(key_name: &str, pal: &Palette) -> impl IntoElement { +fn title_header(key_name: &gpui::SharedString, pal: &Palette) -> impl IntoElement { h_flex() .items_center() .justify_between() @@ -850,7 +924,7 @@ fn title_header(key_name: &str, pal: &Palette) -> impl IntoElement { .text_caption() .font_weight(FontWeight::SEMIBOLD) .text_color(pal.text_muted) - .child(tr!("actions.bind_control", name => key_name)), + .child(tr!("actions.bind_control", name => key_name.clone())), ) } @@ -864,25 +938,30 @@ fn panel_action_rows( ) -> Vec { let mut children = action_rows("panel-action", current, on_pick, *pal); - let power_user_actions: &[(PowerUserKind, &str, &'static str)] = &[ + let power_user_actions = [ + ( + PowerUserKind::CustomShortcut, + tr!("action_ring.custom_shortcut"), + "action-icons/keyboard.svg", + ), ( PowerUserKind::TypeText, - "Type Text…", + tr!("actions.type_text_heading"), "action-icons/keyboard.svg", ), ( PowerUserKind::RunAppleScript, - "Run AppleScript…", + tr!("actions.run_applescript_heading"), "action-icons/terminal.svg", ), ( PowerUserKind::RunShellCommand, - "Run Shell Command…", + tr!("actions.run_shell_command_heading"), "action-icons/terminal.svg", ), ( PowerUserKind::Workflow, - "Workflow…", + tr!("actions.workflow_heading"), "action-icons/list-checks.svg", ), ]; @@ -896,7 +975,10 @@ fn panel_action_rows( let view = view.clone(); let selected = matches!( (current, kind), - (Some(Action::TypeText(_)), PowerUserKind::TypeText) + ( + Some(Action::CustomShortcut(_)), + PowerUserKind::CustomShortcut + ) | (Some(Action::TypeText(_)), PowerUserKind::TypeText) | ( Some(Action::RunAppleScript(_)), PowerUserKind::RunAppleScript diff --git a/crates/openlogi-desktop/src/features/keyboard/function_row/tests.rs b/crates/openlogi-desktop/src/features/keyboard/function_row/tests.rs index 3e538b310..c08eb19fd 100644 --- a/crates/openlogi-desktop/src/features/keyboard/function_row/tests.rs +++ b/crates/openlogi-desktop/src/features/keyboard/function_row/tests.rs @@ -1,6 +1,6 @@ use super::*; use openlogi_assets::{Assignment, Direction, ImageEntry, Metadata, Origin, Point}; -use openlogi_core::device::DeviceKind; +use openlogi_core::device::{DeviceKind, DeviceModelInfo, DeviceTransports}; use std::path::PathBuf; #[test] @@ -29,6 +29,55 @@ fn function_row_covers_esc_through_f19() { assert!(labels.contains(&"F19")); } +#[test] +fn mx_mechanical_extra_positions_use_device_hidpp_controls() { + let model = DeviceModelInfo { + entity_count: 1, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports { + btle: true, + ..DeviceTransports::default() + }, + model_ids: [MX_MECHANICAL_BTLE_PID, 0, 0], + extended_model_id: 0, + }; + + assert_eq!(mx_mechanical_extra_button(&model, 15), None); + assert_eq!( + mx_mechanical_extra_button(&model, 16), + Some(ButtonId::KeyCalculator) + ); + assert_eq!( + mx_mechanical_extra_button(&model, 17), + Some(ButtonId::KeyShowDesktop) + ); + assert_eq!( + mx_mechanical_extra_button(&model, 18), + Some(ButtonId::KeySearch) + ); + assert_eq!( + mx_mechanical_extra_button(&model, 19), + Some(ButtonId::KeyLockPC) + ); +} + +#[test] +fn unrelated_keyboard_keeps_high_function_keys_as_host_triggers() { + let model = DeviceModelInfo { + entity_count: 1, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports::default(), + model_ids: [0x1234, 0, 0], + extended_model_id: 0, + }; + + for idx in 16..=19 { + assert_eq!(mx_mechanical_extra_button(&model, idx), None); + } +} + #[test] fn fallback_key_positions_cover_the_full_top_row() { let positions = key_x_fractions(None); diff --git a/crates/openlogi-desktop/src/features/keyboard/mod.rs b/crates/openlogi-desktop/src/features/keyboard/mod.rs index 99179e5ed..0c62533cc 100644 --- a/crates/openlogi-desktop/src/features/keyboard/mod.rs +++ b/crates/openlogi-desktop/src/features/keyboard/mod.rs @@ -1,12 +1,10 @@ -//! Keyboard remapper UI for global function-key bindings. +//! Keyboard remapper UI for function-row and dedicated Logitech keyboard controls. //! //! Mirrors [`crate::features::mouse`]: a hardware-style diagram whose clickable -//! hotspots (here, function-row key-caps) each open the same action picker the -//! mouse buttons use. The key difference is scope — mouse bindings are -//! per-device under `config.devices[key].bindings`, while keyboard F-key -//! bindings are global (`config.keyboard.bindings`) and apply across all -//! keyboards, so the picker commits via [`AppState::commit_keyboard_binding`] -//! rather than [`AppState::commit_binding`]. +//! hotspots each open the same action picker the mouse buttons use. Ordinary +//! Esc/F-key bindings are global under `config.keyboard.bindings`; dedicated +//! HID++ keyboard controls stay per-device under `config.devices[key].bindings` +//! and commit through [`AppState::commit_binding`]. //! //! [`AppState::commit_binding`]: crate::state::AppState::commit_binding //! [`AppState::commit_keyboard_binding`]: crate::state::AppState::commit_keyboard_binding diff --git a/crates/openlogi-device/src/session/keyboard.rs b/crates/openlogi-device/src/session/keyboard.rs index 9e10b8968..683e14011 100644 --- a/crates/openlogi-device/src/session/keyboard.rs +++ b/crates/openlogi-device/src/session/keyboard.rs @@ -45,9 +45,10 @@ use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4}; /// The divertable keyboard F-row controls OpenLogi models, as /// `(0x1b04 control ID, ButtonId)` pairs. CID values match Logitech's control -/// catalog (cross-checked against Solaar's `special_keys.py`); the F-row -/// positions are the Signature-series layout. -pub const KEYBOARD_KEY_CIDS: [(u16, ButtonId); 9] = [ +/// catalog (cross-checked against Solaar's `special_keys.py`). The first group +/// covers the Signature-series F-row; the trailing controls cover the extra +/// programmable keys on full-size MX Mechanical boards. +pub const KEYBOARD_KEY_CIDS: [(u16, ButtonId); 12] = [ (0x00d4, ButtonId::KeySearch), (0x0103, ButtonId::KeyDictation), (0x0108, ButtonId::KeyEmoji), @@ -57,6 +58,11 @@ pub const KEYBOARD_KEY_CIDS: [(u16, ButtonId); 9] = [ (0x00e7, ButtonId::KeyMute), (0x00e8, ButtonId::KeyVolumeDown), (0x00e9, ButtonId::KeyVolumeUp), + // MX Mechanical's four extra programmable keys are Calculator, Show Desktop, + // MultiPlatform Search (the existing 0x00d4 above), and Lock PC. + (0x000a, ButtonId::KeyCalculator), + (0x006e, ButtonId::KeyShowDesktop), + (0x006f, ButtonId::KeyLockPC), ]; /// Capture the requested keyboard controls on `route` until `shutdown` @@ -410,6 +416,14 @@ fn device_io_suspended() -> GestureError { mod tests { use super::*; + #[test] + fn mx_mechanical_extra_controls_are_in_the_capture_catalog() { + assert!(KEYBOARD_KEY_CIDS.contains(&(0x000a, ButtonId::KeyCalculator))); + assert!(KEYBOARD_KEY_CIDS.contains(&(0x006e, ButtonId::KeyShowDesktop))); + assert!(KEYBOARD_KEY_CIDS.contains(&(0x00d4, ButtonId::KeySearch))); + assert!(KEYBOARD_KEY_CIDS.contains(&(0x006f, ButtonId::KeyLockPC))); + } + #[test] fn keyboard_snapshots_emit_balanced_edges_without_duplicates() { let diverted = BTreeMap::from([ diff --git a/crates/openlogi-ipc/src/ipc.rs b/crates/openlogi-ipc/src/ipc.rs index dd99d2fe9..a2dc3c24a 100644 --- a/crates/openlogi-ipc/src/ipc.rs +++ b/crates/openlogi-ipc/src/ipc.rs @@ -61,7 +61,9 @@ pub use succession::Identity; /// v28: `Action::HoldShortcut` appended for lifecycle-held keyboard output. /// v29: `Agent::declare_client` + [`ClientKind`] appended — typed demand for /// the macOS dormancy gate. -pub const PROTOCOL_VERSION: u32 = 29; +/// v30: `ButtonId::{KeyCalculator, KeyShowDesktop, KeyLockPC}` appended for +/// full-size MX Mechanical HID++ keyboard controls. +pub const PROTOCOL_VERSION: u32 = 30; /// Environment variable through which the agent hands a supervised helper the /// run token it will serve, so the helper knows which agent it belongs to diff --git a/crates/openlogi-ipc/tests/wire_format.rs b/crates/openlogi-ipc/tests/wire_format.rs index 3a8510816..cf0ae4db6 100644 --- a/crates/openlogi-ipc/tests/wire_format.rs +++ b/crates/openlogi-ipc/tests/wire_format.rs @@ -101,7 +101,7 @@ fn representative_smartshift_status() -> SmartShiftStatus { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 29); + assert_eq!(PROTOCOL_VERSION, 30); } #[test] diff --git a/crates/openlogi-ui/locales/be.toml b/crates/openlogi-ui/locales/be.toml index 881872d4a..e6ed8a0e9 100644 --- a/crates/openlogi-ui/locales/be.toml +++ b/crates/openlogi-ui/locales/be.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/da.toml b/crates/openlogi-ui/locales/da.toml index 166aeedec..efc676815 100644 --- a/crates/openlogi-ui/locales/da.toml +++ b/crates/openlogi-ui/locales/da.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/de.toml b/crates/openlogi-ui/locales/de.toml index 217fedcbb..4a60ed17a 100644 --- a/crates/openlogi-ui/locales/de.toml +++ b/crates/openlogi-ui/locales/de.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/el.toml b/crates/openlogi-ui/locales/el.toml index 781438008..23d5de50f 100644 --- a/crates/openlogi-ui/locales/el.toml +++ b/crates/openlogi-ui/locales/el.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/en.toml b/crates/openlogi-ui/locales/en.toml index a6165a4bb..8e33357eb 100644 --- a/crates/openlogi-ui/locales/en.toml +++ b/crates/openlogi-ui/locales/en.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/es.toml b/crates/openlogi-ui/locales/es.toml index 0308ecdd3..a21a0ef62 100644 --- a/crates/openlogi-ui/locales/es.toml +++ b/crates/openlogi-ui/locales/es.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/fi.toml b/crates/openlogi-ui/locales/fi.toml index 438acc559..189f89dfc 100644 --- a/crates/openlogi-ui/locales/fi.toml +++ b/crates/openlogi-ui/locales/fi.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/fr.toml b/crates/openlogi-ui/locales/fr.toml index 07ff7570a..cf985c5d9 100644 --- a/crates/openlogi-ui/locales/fr.toml +++ b/crates/openlogi-ui/locales/fr.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/it.toml b/crates/openlogi-ui/locales/it.toml index fc4cfbca4..cbe4960a1 100644 --- a/crates/openlogi-ui/locales/it.toml +++ b/crates/openlogi-ui/locales/it.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/ja.toml b/crates/openlogi-ui/locales/ja.toml index 4a6d7256f..db7fb654d 100644 --- a/crates/openlogi-ui/locales/ja.toml +++ b/crates/openlogi-ui/locales/ja.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/ko.toml b/crates/openlogi-ui/locales/ko.toml index d03bad7ec..0ac0fa425 100644 --- a/crates/openlogi-ui/locales/ko.toml +++ b/crates/openlogi-ui/locales/ko.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/nb.toml b/crates/openlogi-ui/locales/nb.toml index 5e465d38f..0e58e3b47 100644 --- a/crates/openlogi-ui/locales/nb.toml +++ b/crates/openlogi-ui/locales/nb.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/nl.toml b/crates/openlogi-ui/locales/nl.toml index 2b94811f9..190194659 100644 --- a/crates/openlogi-ui/locales/nl.toml +++ b/crates/openlogi-ui/locales/nl.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/pl.toml b/crates/openlogi-ui/locales/pl.toml index 5e727e3d2..1b23466a6 100644 --- a/crates/openlogi-ui/locales/pl.toml +++ b/crates/openlogi-ui/locales/pl.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/pt-BR.toml b/crates/openlogi-ui/locales/pt-BR.toml index 3b44b6f1a..eeab3590b 100644 --- a/crates/openlogi-ui/locales/pt-BR.toml +++ b/crates/openlogi-ui/locales/pt-BR.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/pt-PT.toml b/crates/openlogi-ui/locales/pt-PT.toml index d92ab6425..759b00094 100644 --- a/crates/openlogi-ui/locales/pt-PT.toml +++ b/crates/openlogi-ui/locales/pt-PT.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/ru.toml b/crates/openlogi-ui/locales/ru.toml index 718c36212..cbd68b168 100644 --- a/crates/openlogi-ui/locales/ru.toml +++ b/crates/openlogi-ui/locales/ru.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/sv.toml b/crates/openlogi-ui/locales/sv.toml index e97dd1641..7e51637b4 100644 --- a/crates/openlogi-ui/locales/sv.toml +++ b/crates/openlogi-ui/locales/sv.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/tr.toml b/crates/openlogi-ui/locales/tr.toml index 9802e43da..e5adeeb14 100644 --- a/crates/openlogi-ui/locales/tr.toml +++ b/crates/openlogi-ui/locales/tr.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/uk.toml b/crates/openlogi-ui/locales/uk.toml index dc2d5c4ab..3b6f82c90 100644 --- a/crates/openlogi-ui/locales/uk.toml +++ b/crates/openlogi-ui/locales/uk.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "Calculator Key" +show_desktop_key = "Show Desktop Key" +lock_pc_key = "Lock PC Key" diff --git a/crates/openlogi-ui/locales/zh-CN.toml b/crates/openlogi-ui/locales/zh-CN.toml index 2a807b700..c3cc4659e 100644 --- a/crates/openlogi-ui/locales/zh-CN.toml +++ b/crates/openlogi-ui/locales/zh-CN.toml @@ -547,3 +547,6 @@ play_pause_key = "播放/暂停键" mute_key = "静音键" volume_down_key = "音量减小键" volume_up_key = "音量增大键" +calculator_key = "计算器键" +show_desktop_key = "显示桌面键" +lock_pc_key = "锁定电脑键" diff --git a/crates/openlogi-ui/locales/zh-HK.toml b/crates/openlogi-ui/locales/zh-HK.toml index b29fbc189..9171f9098 100644 --- a/crates/openlogi-ui/locales/zh-HK.toml +++ b/crates/openlogi-ui/locales/zh-HK.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "計算機鍵" +show_desktop_key = "顯示桌面鍵" +lock_pc_key = "鎖定電腦鍵" diff --git a/crates/openlogi-ui/locales/zh-TW.toml b/crates/openlogi-ui/locales/zh-TW.toml index c0a7a9351..b72d23c39 100644 --- a/crates/openlogi-ui/locales/zh-TW.toml +++ b/crates/openlogi-ui/locales/zh-TW.toml @@ -547,3 +547,6 @@ play_pause_key = "Play/Pause Key" mute_key = "Mute Key" volume_down_key = "Volume Down Key" volume_up_key = "Volume Up Key" +calculator_key = "計算機鍵" +show_desktop_key = "顯示桌面鍵" +lock_pc_key = "鎖定電腦鍵"