diff --git a/i18n/en.toml b/i18n/en.toml index cc1c283b..f0648e84 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -946,6 +946,8 @@ move_up = "Move up" move_down = "Move down" text = "Text" types_text_characters_one_by_one = "Types text characters one by one" +host_text = "Unicode" +host_text_requires_entropy_running = "Types Unicode text through Entropy; Entropy and its Universal Symbols backend must be running" tap = "Tap" press_and_release_a_key = "Press and release a key" down = "Down" @@ -956,6 +958,8 @@ delay = "Delay" wait_before_next_action = "Wait before next action" type_text_here = "Type text here" characters_to_type_when_this_macro_runs = "Characters to type when this macro runs" +type_unicode_text_here = "Type Unicode text here" +unicode_text_requires_entropy_running = "Unicode text requires Entropy and its Universal Symbols backend to be running" click_to_change_key_press_and_release_this_key = "Click to change key — press and release this key" click_to_change_key_holds_down_until_up = "Click to change key — holds down until Up" click_to_change_key_releases_this_key = "Click to change key — releases this key" @@ -963,6 +967,7 @@ delay_is_in_milliseconds = "Delay is in milliseconds" remove_this_action = "Remove this action" plus_text = "+ Text" type_characters = "Type characters" +plus_host_text = "+ Unicode" plus_tap = "+ Tap" plus_down = "+ Down" hold_a_key = "Hold a key" diff --git a/i18n/ru.toml b/i18n/ru.toml index 7239a681..c4bd6eae 100644 --- a/i18n/ru.toml +++ b/i18n/ru.toml @@ -946,6 +946,8 @@ move_up = "Выше" move_down = "Ниже" text = "Текст" types_text_characters_one_by_one = "Вводит символы текста по одному" +host_text = "Unicode" +host_text_requires_entropy_running = "Вводит Unicode-текст через Entropy; Entropy и бэкенд Universal Symbols должны быть запущены" tap = "Tap" press_and_release_a_key = "Нажать и отпустить клавишу" down = "Down" @@ -956,6 +958,8 @@ delay = "Задержка" wait_before_next_action = "Пауза перед следующим действием" type_text_here = "Введите текст здесь" characters_to_type_when_this_macro_runs = "Символы, которые макрос будет вводить" +type_unicode_text_here = "Введите Unicode-текст здесь" +unicode_text_requires_entropy_running = "Для Unicode-текста Entropy и бэкенд Universal Symbols должны быть запущены" click_to_change_key_press_and_release_this_key = "Нажмите, чтобы сменить клавишу — press/release" click_to_change_key_holds_down_until_up = "Нажмите, чтобы сменить клавишу — удерживать до Up" click_to_change_key_releases_this_key = "Нажмите, чтобы сменить клавишу — отпустить" @@ -963,6 +967,7 @@ delay_is_in_milliseconds = "Задержка в миллисекундах" remove_this_action = "Удалить это действие" plus_text = "+ Текст" type_characters = "Ввести символы" +plus_host_text = "+ Unicode" plus_tap = "+ Tap" plus_down = "+ Down" hold_a_key = "Удерживать клавишу" diff --git a/linux/fcitx5/src/entropyuniversalsymbols.cpp b/linux/fcitx5/src/entropyuniversalsymbols.cpp index 605da398..b08ba8da 100644 --- a/linux/fcitx5/src/entropyuniversalsymbols.cpp +++ b/linux/fcitx5/src/entropyuniversalsymbols.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "fcitx-utils/handlertable.h" #include "fcitx-utils/key.h" #include "fcitx-utils/keysym.h" @@ -25,6 +26,12 @@ constexpr uint16_t MOD_CTRL = 0x0100; constexpr uint16_t MOD_SHIFT = 0x0200; constexpr uint16_t MOD_ALT = 0x0400; constexpr uint16_t MOD_GUI = 0x0800; +constexpr uint16_t HOST_TEXT_START_TRIGGER = + MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | (KC_F13 + 7); +constexpr uint16_t HOST_TEXT_END_TRIGGER = + MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | (KC_F13 + 6); +constexpr uint16_t HOST_TEXT_DATA_MODIFIERS = MOD_CTRL | MOD_ALT | MOD_GUI; +constexpr size_t HOST_TEXT_MAX_DIGITS = 3 * 1024; struct SmartSymbol { uint16_t trigger; @@ -166,6 +173,78 @@ std::optional symbolForKey(const Key &key) { return std::nullopt; } +bool isHostTextTransportTrigger(uint16_t trigger) { + const auto base = trigger & 0x00ff; + const auto modifiers = trigger & 0xff00; + return trigger == HOST_TEXT_START_TRIGGER || trigger == HOST_TEXT_END_TRIGGER || + (modifiers == HOST_TEXT_DATA_MODIFIERS && base >= KC_F13 && base <= KC_F13 + 7); +} + +bool isTransportModifierKey(KeySym sym) { + switch (sym) { + case FcitxKey_Shift_L: + case FcitxKey_Shift_R: + case FcitxKey_Control_L: + case FcitxKey_Control_R: + case FcitxKey_Alt_L: + case FcitxKey_Alt_R: + case FcitxKey_Super_L: + case FcitxKey_Super_R: + return true; + default: + return false; + } +} + +bool isValidUtf8(const std::string &text) { + for (size_t index = 0; index < text.size();) { + const auto byte = static_cast(text[index]); + size_t width = 0; + if (byte <= 0x7f) { + width = 1; + } else if (byte >= 0xc2 && byte <= 0xdf) { + width = 2; + } else if (byte >= 0xe0 && byte <= 0xef) { + width = 3; + } else if (byte >= 0xf0 && byte <= 0xf4) { + width = 4; + } else { + return false; + } + if (index + width > text.size()) { + return false; + } + for (size_t continuation = 1; continuation < width; ++continuation) { + if ((static_cast(text[index + continuation]) & 0xc0) != 0x80) { + return false; + } + } + if ((byte == 0xe0 && static_cast(text[index + 1]) < 0xa0) || + (byte == 0xed && static_cast(text[index + 1]) >= 0xa0) || + (byte == 0xf0 && static_cast(text[index + 1]) < 0x90) || + (byte == 0xf4 && static_cast(text[index + 1]) >= 0x90)) { + return false; + } + index += width; + } + return true; +} + +std::optional decodeHostTextDigits(const std::vector &digits) { + if (digits.size() % 3 != 0) { + return std::nullopt; + } + std::string text; + text.reserve(digits.size() / 3); + for (size_t index = 0; index < digits.size(); index += 3) { + if (digits[index] > 3 || digits[index + 1] > 7 || digits[index + 2] > 7) { + return std::nullopt; + } + text.push_back(char((digits[index] << 6) | (digits[index + 1] << 3) | digits[index + 2])); + } + return isValidUtf8(text) ? std::optional(text) : std::nullopt; +} + } // namespace class EntropyUniversalSymbols final : public AddonInstance { @@ -179,6 +258,38 @@ class EntropyUniversalSymbols final : public AddonInstance { private: void handleKeyEvent(Event &event) { auto &keyEvent = static_cast(event); + const auto base = baseKeycodeForSym(keyEvent.key().sym()); + if (base) { + const auto trigger = uint16_t(*base | transportModifiers(keyEvent.key().states())); + if (isHostTextTransportTrigger(trigger)) { + if (!keyEvent.isRelease()) { + if (trigger == HOST_TEXT_START_TRIGGER) { + hostTextActive_ = true; + hostTextDigits_.clear(); + } else if (trigger == HOST_TEXT_END_TRIGGER && hostTextActive_) { + hostTextActive_ = false; + if (const auto text = decodeHostTextDigits(hostTextDigits_)) { + keyEvent.inputContext()->commitString(*text); + } + hostTextDigits_.clear(); + } else if (hostTextActive_ && + (trigger & 0xff00) == HOST_TEXT_DATA_MODIFIERS) { + if (hostTextDigits_.size() < HOST_TEXT_MAX_DIGITS) { + hostTextDigits_.push_back(uint8_t((trigger & 0x00ff) - KC_F13)); + } else { + hostTextActive_ = false; + hostTextDigits_.clear(); + } + } + } + keyEvent.filterAndAccept(); + return; + } + } + if (!keyEvent.isRelease() && !isTransportModifierKey(keyEvent.key().sym())) { + hostTextActive_ = false; + hostTextDigits_.clear(); + } const auto symbol = symbolForKey(keyEvent.key()); if (!symbol) { return; @@ -194,6 +305,8 @@ class EntropyUniversalSymbols final : public AddonInstance { Instance *instance_; std::unique_ptr> eventHandler_; + bool hostTextActive_ = false; + std::vector hostTextDigits_; }; class EntropyUniversalSymbolsFactory final : public AddonFactory { diff --git a/linux/ibus/entropy-ibus-engine b/linux/ibus/entropy-ibus-engine index 569b203e..f0520e26 100755 --- a/linux/ibus/entropy-ibus-engine +++ b/linux/ibus/entropy-ibus-engine @@ -98,6 +98,10 @@ MOD_CTRL = 0x0100 MOD_SHIFT = 0x0200 MOD_ALT = 0x0400 MOD_GUI = 0x0800 +HOST_TEXT_START_TRIGGER = MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | (KC_F13 + 7) +HOST_TEXT_END_TRIGGER = MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | (KC_F13 + 6) +HOST_TEXT_DATA_MODIFIERS = MOD_CTRL | MOD_ALT | MOD_GUI +HOST_TEXT_MAX_DIGITS = 3 * 1024 EVDEV_F13_KEYCODE = 183 XKB_F13_KEYCODE = EVDEV_F13_KEYCODE + 8 TERMINAL_CLIENT_MARKERS = ( @@ -666,6 +670,45 @@ def _transport_modifiers(state): return modifiers +def _host_text_transport_trigger(base_keycode, modifiers): + trigger = base_keycode | modifiers + return ( + trigger == HOST_TEXT_START_TRIGGER + or trigger == HOST_TEXT_END_TRIGGER + or ( + modifiers == HOST_TEXT_DATA_MODIFIERS + and KC_F13 <= base_keycode <= KC_F13 + 7 + ) + ) + + +def _transport_modifier_key(keyval): + return keyval in { + IBus.KEY_Shift_L, + IBus.KEY_Shift_R, + IBus.KEY_Control_L, + IBus.KEY_Control_R, + IBus.KEY_Alt_L, + IBus.KEY_Alt_R, + IBus.KEY_Super_L, + IBus.KEY_Super_R, + } + + +def _decode_host_text_digits(digits): + if len(digits) % 3: + return None + payload = [] + for high, middle, low in zip(digits[::3], digits[1::3], digits[2::3]): + if high > 3 or middle > 7 or low > 7: + return None + payload.append((high << 6) | (middle << 3) | low) + try: + return bytes(payload).decode("utf-8") + except UnicodeDecodeError: + return None + + class EntropyEngine(IBus.Engine): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -673,6 +716,7 @@ class EntropyEngine(IBus.Engine): self._client = "" self._client_capabilities = 0 self._input_purpose = IBus.InputPurpose.FREE_FORM + self._host_text_digits = None def do_focus_in_id(self, object_path, client): del object_path @@ -681,6 +725,7 @@ class EntropyEngine(IBus.Engine): def do_focus_out_id(self, object_path): del object_path self._client = "" + self._host_text_digits = None def do_set_capabilities(self, caps): self._client_capabilities = int(caps) @@ -693,6 +738,14 @@ class EntropyEngine(IBus.Engine): base_keycode, source = _transport_keycode_from_event(keyval, keycode) if base_keycode is not None: modifiers = _transport_modifiers(state) + if _host_text_transport_trigger(base_keycode, modifiers): + if not (state & IBus.ModifierType.RELEASE_MASK): + text = self._process_host_text_transport(base_keycode | modifiers) + if text: + self.commit_text(IBus.Text.new_from_string(text)) + return True + if not (state & IBus.ModifierType.RELEASE_MASK): + self._host_text_digits = None symbol = SMART_SYMBOLS.get((base_keycode, modifiers)) del source if symbol is not None: @@ -701,8 +754,29 @@ class EntropyEngine(IBus.Engine): self._commit_text_expander_aware_text(symbol) return True + if not (state & IBus.ModifierType.RELEASE_MASK) and not _transport_modifier_key(keyval): + self._host_text_digits = None return self._process_text_expander_key(keyval, keycode, state) + def _process_host_text_transport(self, trigger): + if trigger == HOST_TEXT_START_TRIGGER: + self._host_text_digits = [] + return None + if self._host_text_digits is None: + return None + if trigger == HOST_TEXT_END_TRIGGER: + digits = self._host_text_digits + self._host_text_digits = None + return _decode_host_text_digits(digits) + if trigger & ~0xFF == HOST_TEXT_DATA_MODIFIERS: + if len(self._host_text_digits) >= HOST_TEXT_MAX_DIGITS: + self._host_text_digits = None + return None + self._host_text_digits.append((trigger & 0xFF) - KC_F13) + return None + self._host_text_digits = None + return None + def _process_text_expander_key(self, keyval, keycode, state): del keycode if state & IBus.ModifierType.RELEASE_MASK: diff --git a/src/hid.rs b/src/hid.rs index ef0a10f6..3535aced 100644 --- a/src/hid.rs +++ b/src/hid.rs @@ -517,6 +517,9 @@ impl HidDevice { let mut response = [0; MSG_LEN]; match (request[0], request[1], request[2]) { + (CMD_VIA_MACRO_GET_BUFFER_SIZE, _, _) => { + response[1..3].copy_from_slice(&1024u16.to_be_bytes()); + } (CMD_VIA_VIAL_PREFIX, CMD_VIAL_DYNAMIC_ENTRY_OP, DYNAMIC_VIAL_COMBO_SET) => { let mut keys = [0; 4]; for (index, key) in keys.iter_mut().enumerate() { @@ -1047,6 +1050,13 @@ fn hex_nibble(byte: u8) -> Result { mod tests { use super::*; + #[test] + fn test_hid_reports_macro_buffer_capacity() { + let (device, _) = HidDevice::test_device(); + + assert_eq!(device.get_macro_buffer_size().unwrap(), 1024); + } + fn qmk_settings_command(subcommand: u8, qsid: u16) -> [u8; MSG_LEN] { let mut command = [0u8; MSG_LEN]; command[0] = CMD_VIA_VIAL_PREFIX; diff --git a/src/hid_macros.rs b/src/hid_macros.rs index a4958a08..85dedf7c 100644 --- a/src/hid_macros.rs +++ b/src/hid_macros.rs @@ -84,4 +84,27 @@ impl HidDevice { pub fn encode_macros(macros: &[Vec], buf_size: u16) -> Vec { encode_macro_buffer(macros, buf_size) } + + /// Number of bytes required to write all macro entries without truncation. + pub fn encoded_macros_len(macros: &[Vec]) -> usize { + macros + .iter() + .map(|macro_bytes| macro_bytes.len() + 1) + .sum::() + .max(1) + } +} + +#[cfg(test)] +mod tests { + use super::HidDevice; + + #[test] + fn encoded_macros_len_includes_each_terminator() { + assert_eq!( + HidDevice::encoded_macros_len(&[b"a".to_vec(), b"bc".to_vec()]), + 5 + ); + assert_eq!(HidDevice::encoded_macros_len(&[]), 1); + } } diff --git a/src/i18n.rs b/src/i18n.rs index 799f2f76..750e3562 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -687,6 +687,8 @@ fn static_catalog_key(text: &str) -> Option<&'static str> { "Move down" => Some("macro_editor.move_down"), "Text" => Some("macro_editor.text"), "Types text characters one by one" => Some("macro_editor.types_text_characters_one_by_one"), + "Unicode" => Some("macro_editor.host_text"), + "Types Unicode text through Entropy; Entropy and its Universal Symbols backend must be running" => Some("macro_editor.host_text_requires_entropy_running"), "Tap" => Some("macro_editor.tap"), "Press and release a key" => Some("macro_editor.press_and_release_a_key"), "Down" => Some("macro_editor.down"), @@ -697,6 +699,8 @@ fn static_catalog_key(text: &str) -> Option<&'static str> { "Wait before next action" => Some("macro_editor.wait_before_next_action"), "Type text here" => Some("macro_editor.type_text_here"), "Characters to type when this macro runs" => Some("macro_editor.characters_to_type_when_this_macro_runs"), + "Type Unicode text here" => Some("macro_editor.type_unicode_text_here"), + "Unicode text requires Entropy and its Universal Symbols backend to be running" => Some("macro_editor.unicode_text_requires_entropy_running"), "Click to change key — press and release this key" => Some("macro_editor.click_to_change_key_press_and_release_this_key"), "Click to change key — holds down until Up" => Some("macro_editor.click_to_change_key_holds_down_until_up"), "Click to change key — releases this key" => Some("macro_editor.click_to_change_key_releases_this_key"), @@ -704,6 +708,7 @@ fn static_catalog_key(text: &str) -> Option<&'static str> { "Remove this action" => Some("macro_editor.remove_this_action"), "+ Text" => Some("macro_editor.plus_text"), "Type characters" => Some("macro_editor.type_characters"), + "+ Unicode" => Some("macro_editor.plus_host_text"), "+ Tap" => Some("macro_editor.plus_tap"), "+ Down" => Some("macro_editor.plus_down"), "Hold a key" => Some("macro_editor.hold_a_key"), diff --git a/src/keycode_picker.rs b/src/keycode_picker.rs index ad9ce056..98bc1c06 100644 --- a/src/keycode_picker.rs +++ b/src/keycode_picker.rs @@ -100,6 +100,9 @@ pub struct TapDanceEntry { #[derive(Clone, Debug, PartialEq, Eq)] pub enum MacroAction { Text(String), + /// UTF-8 text carried through Entropy's reserved Universal Symbols transport. + /// Requires Entropy to be running on the host. + HostText(String), Tap(u16), // QMK/Vial keycode Down(u16), // key press Up(u16), // key release diff --git a/src/keycode_picker_macro.rs b/src/keycode_picker_macro.rs index fae913ee..dbb046bd 100644 --- a/src/keycode_picker_macro.rs +++ b/src/keycode_picker_macro.rs @@ -9,6 +9,18 @@ const VIAL_MACRO_EXT_TAP: u8 = 5; const VIAL_MACRO_EXT_DOWN: u8 = 6; const VIAL_MACRO_EXT_UP: u8 = 7; +// Host Text uses otherwise-unassigned Universal Symbols transport chords. The +// payload is UTF-8 encoded as three base-8 digits per byte, so firmware needs +// no custom opcode and Vial continues to execute the macro normally. +const HOST_TEXT_CTRL: u16 = 0x00E0; +const HOST_TEXT_SHIFT: u16 = 0x00E1; +const HOST_TEXT_ALT: u16 = 0x00E2; +const HOST_TEXT_GUI: u16 = 0x00E3; +const HOST_TEXT_F13: u16 = 0x0068; +const HOST_TEXT_START: u16 = 0x006F; // Hyper+F20 +const HOST_TEXT_END: u16 = 0x006E; // Hyper+F19 +const HOST_TEXT_CHAR_LIMIT: usize = 24; + fn append_macro_key_action(encoded: &mut Vec, basic_opcode: u8, ext_opcode: u8, keycode: u16) { encoded.push(SS_QMK_PREFIX); if keycode < 0x0100 { @@ -123,7 +135,7 @@ pub(crate) fn decode_macro_actions(bytes: &[u8]) -> Vec { push_macro_text_or_raw(&mut actions, &bytes[start..i]); } } - actions + collapse_host_text_actions(actions) } fn is_send_string_keycode(keycode: u8) -> bool { @@ -135,6 +147,9 @@ pub(crate) fn encode_macro_actions(actions: &[MacroAction]) -> Vec { for action in actions { match action { MacroAction::Text(s) => encoded.extend_from_slice(s.as_bytes()), + MacroAction::HostText(s) => { + encoded.extend(encode_macro_actions(&host_text_actions(s))); + } MacroAction::Tap(kc) => { append_macro_key_action(&mut encoded, SS_TAP_CODE, VIAL_MACRO_EXT_TAP, *kc); } @@ -158,6 +173,98 @@ pub(crate) fn encode_macro_actions(actions: &[MacroAction]) -> Vec { encoded } +fn host_text_actions(text: &str) -> Vec { + let mut actions = vec![ + MacroAction::Down(HOST_TEXT_CTRL), + MacroAction::Down(HOST_TEXT_SHIFT), + MacroAction::Down(HOST_TEXT_ALT), + MacroAction::Down(HOST_TEXT_GUI), + MacroAction::Tap(HOST_TEXT_START), + MacroAction::Up(HOST_TEXT_SHIFT), + ]; + for byte in text.bytes() { + actions.push(MacroAction::Tap( + HOST_TEXT_F13 + ((byte >> 6) & 0x07) as u16, + )); + actions.push(MacroAction::Tap( + HOST_TEXT_F13 + ((byte >> 3) & 0x07) as u16, + )); + actions.push(MacroAction::Tap(HOST_TEXT_F13 + (byte & 0x07) as u16)); + } + actions.extend([ + MacroAction::Down(HOST_TEXT_SHIFT), + MacroAction::Tap(HOST_TEXT_END), + MacroAction::Up(HOST_TEXT_GUI), + MacroAction::Up(HOST_TEXT_ALT), + MacroAction::Up(HOST_TEXT_SHIFT), + MacroAction::Up(HOST_TEXT_CTRL), + ]); + actions +} + +fn collapse_host_text_actions(actions: Vec) -> Vec { + let mut collapsed = Vec::with_capacity(actions.len()); + let mut offset = 0; + while offset < actions.len() { + if let Some((text, next_offset)) = decode_host_text_actions(&actions, offset) { + collapsed.push(MacroAction::HostText(text)); + offset = next_offset; + } else { + collapsed.push(actions[offset].clone()); + offset += 1; + } + } + collapsed +} + +fn decode_host_text_actions(actions: &[MacroAction], offset: usize) -> Option<(String, usize)> { + let prefix = [ + MacroAction::Down(HOST_TEXT_CTRL), + MacroAction::Down(HOST_TEXT_SHIFT), + MacroAction::Down(HOST_TEXT_ALT), + MacroAction::Down(HOST_TEXT_GUI), + MacroAction::Tap(HOST_TEXT_START), + MacroAction::Up(HOST_TEXT_SHIFT), + ]; + if actions.get(offset..offset + prefix.len()) != Some(prefix.as_slice()) { + return None; + } + + let mut digits = Vec::new(); + let mut cursor = offset + prefix.len(); + while let Some(MacroAction::Tap(keycode)) = actions.get(cursor) { + if !(HOST_TEXT_F13..=HOST_TEXT_F13 + 7).contains(keycode) { + break; + } + digits.push((keycode - HOST_TEXT_F13) as u8); + cursor += 1; + } + let suffix = [ + MacroAction::Down(HOST_TEXT_SHIFT), + MacroAction::Tap(HOST_TEXT_END), + MacroAction::Up(HOST_TEXT_GUI), + MacroAction::Up(HOST_TEXT_ALT), + MacroAction::Up(HOST_TEXT_SHIFT), + MacroAction::Up(HOST_TEXT_CTRL), + ]; + if digits.len() % 3 != 0 + || actions.get(cursor..cursor + suffix.len()) != Some(suffix.as_slice()) + { + return None; + } + + let mut bytes = Vec::with_capacity(digits.len() / 3); + for digit_group in digits.chunks_exact(3) { + if digit_group[0] > 3 { + return None; + } + bytes.push((digit_group[0] << 6) | (digit_group[1] << 3) | digit_group[2]); + } + String::from_utf8(bytes) + .ok() + .map(|text| (text, cursor + suffix.len())) +} + fn push_macro_text_or_raw(actions: &mut Vec, bytes: &[u8]) { if bytes.is_empty() { return; @@ -378,6 +485,14 @@ impl KeycodePicker { "macro_editor.types_text_characters_one_by_one", ), ), + MacroAction::HostText(_) => ( + crate::i18n::tr_catalog(self.language, "macro_editor.host_text"), + Color32::from_rgb(126, 184, 112), + crate::i18n::tr_catalog( + self.language, + "macro_editor.host_text_requires_entropy_running", + ), + ), MacroAction::Tap(_) => ( crate::i18n::tr_catalog(self.language, "macro_editor.tap"), crate::ui_style::accent(), @@ -454,6 +569,30 @@ impl KeycodePicker { macro_changed = true; } } + MacroAction::HostText(text) => { + let text_w = (avail_w - 220.0 * scale).max(150.0 * scale); + if crate::ui_style::modern_text_field_sized( + ui, + ui.make_persistent_id(("macro_host_text_action", grid_id, n, i)), + text, + text_w, + 32.0 * scale, + crate::i18n::tr_catalog( + self.language, + "macro_editor.type_unicode_text_here", + ), + HOST_TEXT_CHAR_LIMIT, + egui::Align::Min, + ) + .on_hover_text(crate::i18n::tr_catalog( + self.language, + "macro_editor.unicode_text_requires_entropy_running", + )) + .changed() + { + macro_changed = true; + } + } MacroAction::Tap(kc) => { let label = keycode_label_with_names_and_layout( *kc , @@ -633,6 +772,22 @@ impl KeycodePicker { self.macro_actions[n].push(MacroAction::Text(String::new())); macro_changed = true; } + if picker_button( + ui, + crate::i18n::tr_catalog(self.language, "macro_editor.plus_host_text"), + picker_scaled_size(ui.ctx(), 94.0, 30.0), + true, + false, + ) + .on_hover_text(crate::i18n::tr_catalog( + self.language, + "macro_editor.unicode_text_requires_entropy_running", + )) + .clicked() + { + self.macro_actions[n].push(MacroAction::HostText(String::new())); + macro_changed = true; + } if picker_button( ui, crate::i18n::tr_catalog(self.language, "macro_editor.plus_tap"), @@ -969,4 +1124,22 @@ mod tests { ); assert_eq!(encode_macro_actions(&actions), raw_macro); } + + #[test] + fn encodes_and_decodes_host_text_macro() { + let actions = vec![MacroAction::HostText("Straße 😀".to_owned())]; + + let encoded = encode_macro_actions(&actions); + + assert!(encoded.len() > "Straße 😀".len()); + assert_eq!(decode_macro_actions(&encoded), actions); + } + + #[test] + fn host_text_ui_limit_fits_a_one_kib_macro_buffer() { + let text = "😀".repeat(HOST_TEXT_CHAR_LIMIT); + let encoded = encode_macro_actions(&[MacroAction::HostText(text)]); + + assert!(encoded.len() <= 1024); + } } diff --git a/src/smart_input.rs b/src/smart_input.rs index 5a6070c6..8b4740c0 100644 --- a/src/smart_input.rs +++ b/src/smart_input.rs @@ -23,6 +23,92 @@ type ForegroundCacheState = Option<(std::time::Instant, Option> = OnceLock::new(); static TEXT_EXPANDER_ENGINE: OnceLock> = OnceLock::new(); static RECENT_FOREGROUND_APPS: OnceLock>> = OnceLock::new(); +static HOST_TEXT_DECODER: OnceLock> = OnceLock::new(); + +const HOST_TEXT_F13: u16 = KC_F13; +const HOST_TEXT_START_TRIGGER: u16 = MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | (KC_F13 + 7); +const HOST_TEXT_END_TRIGGER: u16 = MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | (KC_F13 + 6); +const HOST_TEXT_DATA_MODIFIERS: u16 = MOD_CTRL | MOD_ALT | MOD_GUI; +const HOST_TEXT_MAX_DIGITS: usize = 3 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum HostTextTransportEvent { + NotHandled, + Consumed, + Complete(String), +} + +#[derive(Default)] +struct HostTextDecoder { + active: bool, + digits: Vec, +} + +impl HostTextDecoder { + fn receive(&mut self, trigger_keycode: u16) -> HostTextTransportEvent { + if trigger_keycode == HOST_TEXT_START_TRIGGER { + self.active = true; + self.digits.clear(); + return HostTextTransportEvent::Consumed; + } + if !self.active { + return HostTextTransportEvent::NotHandled; + } + if trigger_keycode == HOST_TEXT_END_TRIGGER { + self.active = false; + let digits = std::mem::take(&mut self.digits); + return decode_host_text_digits(&digits) + .map(HostTextTransportEvent::Complete) + .unwrap_or(HostTextTransportEvent::Consumed); + } + if trigger_keycode & !0x00ff == HOST_TEXT_DATA_MODIFIERS + && (HOST_TEXT_F13..=HOST_TEXT_F13 + 7).contains(&(trigger_keycode & 0x00ff)) + { + if self.digits.len() < HOST_TEXT_MAX_DIGITS { + self.digits + .push((trigger_keycode & 0x00ff) as u8 - HOST_TEXT_F13 as u8); + } else { + self.active = false; + self.digits.clear(); + } + return HostTextTransportEvent::Consumed; + } + self.active = false; + self.digits.clear(); + HostTextTransportEvent::NotHandled + } +} + +fn decode_host_text_digits(digits: &[u8]) -> Option { + if digits.len() % 3 != 0 { + return None; + } + let mut bytes = Vec::with_capacity(digits.len() / 3); + for group in digits.chunks_exact(3) { + if group[0] > 3 || group[1] > 7 || group[2] > 7 { + return None; + } + bytes.push((group[0] << 6) | (group[1] << 3) | group[2]); + } + String::from_utf8(bytes).ok() +} + +pub(crate) fn is_host_text_transport_trigger(trigger_keycode: u16) -> bool { + trigger_keycode == HOST_TEXT_START_TRIGGER + || trigger_keycode == HOST_TEXT_END_TRIGGER + || (trigger_keycode & !0x00ff == HOST_TEXT_DATA_MODIFIERS + && (HOST_TEXT_F13..=HOST_TEXT_F13 + 7).contains(&(trigger_keycode & 0x00ff))) +} + +pub(crate) fn host_text_transport_key_down(trigger_keycode: u16) -> HostTextTransportEvent { + let Ok(mut decoder) = HOST_TEXT_DECODER + .get_or_init(|| Mutex::new(HostTextDecoder::default())) + .lock() + else { + return HostTextTransportEvent::NotHandled; + }; + decoder.receive(trigger_keycode) +} fn text_expander_config() -> &'static RwLock { TEXT_EXPANDER_CONFIG.get_or_init(|| RwLock::new(TextExpansionConfig::default())) @@ -765,7 +851,37 @@ mod macos { let alt = flags & K_CG_EVENT_FLAG_MASK_ALTERNATE != 0; let command = flags & K_CG_EVENT_FLAG_MASK_COMMAND != 0; - if let Some(base_keycode) = mac_keycode_to_qmk_f_key(keycode) { + let transport_trigger = mac_keycode_to_qmk_f_key(keycode).map(|base_keycode| { + let mut trigger_keycode = base_keycode; + if ctrl { + trigger_keycode |= MOD_CTRL; + } + if shift { + trigger_keycode |= MOD_SHIFT; + } + if alt { + trigger_keycode |= MOD_ALT; + } + if command { + trigger_keycode |= MOD_GUI; + } + trigger_keycode + }); + if event_type == K_CG_EVENT_KEY_DOWN && !is_transport_modifier_key(keycode) { + match host_text_transport_key_down(transport_trigger.unwrap_or_default()) { + HostTextTransportEvent::Complete(text) => { + schedule_unicode_text(text); + return null_mut(); + } + HostTextTransportEvent::Consumed => return null_mut(), + HostTextTransportEvent::NotHandled => {} + } + } + if let Some(trigger_keycode) = transport_trigger { + if is_host_text_transport_trigger(trigger_keycode) { + return null_mut(); + } + let base_keycode = trigger_keycode & 0x00ff; if let Some((symbol, _trigger_keycode)) = smart_symbol_for_transport(base_keycode, ctrl, shift, alt, command) { @@ -786,6 +902,10 @@ mod macos { event } + fn is_transport_modifier_key(keycode: u16) -> bool { + matches!(keycode, 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62) + } + pub(super) fn foreground_app_blacklisted(app_blacklist: &[String]) -> bool { if app_blacklist.is_empty() { return false; @@ -1024,6 +1144,12 @@ end tell"#; }); } + fn schedule_unicode_text(text: String) { + std::thread::spawn(move || unsafe { + send_unicode_text(&text); + }); + } + unsafe fn send_unicode_char(symbol: char) { let mut buffer = [0u16; 2]; let units = symbol.encode_utf16(&mut buffer); @@ -1158,11 +1284,25 @@ mod linux_x11 { let shift = xkey.state & SHIFT_MASK != 0; let alt = xkey.state & MOD1_MASK != 0; let gui = xkey.state & MOD4_MASK != 0; - if let Some((symbol, _trigger_keycode)) = - smart_symbol_for_transport(*base_keycode, ctrl, shift, alt, gui) - { - if event_type == KEY_PRESS { - type_unicode(symbol); + let trigger_keycode = *base_keycode + | if ctrl { MOD_CTRL } else { 0 } + | if shift { MOD_SHIFT } else { 0 } + | if alt { MOD_ALT } else { 0 } + | if gui { MOD_GUI } else { 0 }; + if event_type == KEY_PRESS { + match host_text_transport_key_down(trigger_keycode) { + HostTextTransportEvent::Complete(text) => type_unicode_text(&text), + HostTextTransportEvent::Consumed => continue, + HostTextTransportEvent::NotHandled => {} + } + } + if !is_host_text_transport_trigger(trigger_keycode) { + if let Some((symbol, _trigger_keycode)) = + smart_symbol_for_transport(*base_keycode, ctrl, shift, alt, gui) + { + if event_type == KEY_PRESS { + type_unicode(symbol); + } } } } @@ -1199,10 +1339,14 @@ mod linux_x11 { } fn type_unicode(symbol: char) { + type_unicode_text(&symbol.to_string()); + } + + fn type_unicode_text(text: &str) { let status = Command::new("xdotool") .arg("type") .arg("--clearmodifiers") - .arg(symbol.to_string()) + .arg(text) .status(); if !matches!(status, Ok(status) if status.success()) { log::warn!("Smart Input: xdotool failed or is not installed"); @@ -1333,4 +1477,65 @@ mod tests { fn macos_active_event_tap_counts_as_input_monitoring_granted() { assert!(macos_effective_input_monitoring_granted(false, true)); } + + #[test] + fn host_text_transport_decodes_utf8_payload() { + let mut decoder = HostTextDecoder::default(); + assert_eq!( + decoder.receive(HOST_TEXT_START_TRIGGER), + HostTextTransportEvent::Consumed + ); + for byte in "ß😀".bytes() { + for digit in [(byte >> 6) & 0x07, (byte >> 3) & 0x07, byte & 0x07] { + assert_eq!( + decoder.receive(HOST_TEXT_DATA_MODIFIERS | HOST_TEXT_F13 + digit as u16), + HostTextTransportEvent::Consumed + ); + } + } + assert_eq!( + decoder.receive(HOST_TEXT_END_TRIGGER), + HostTextTransportEvent::Complete("ß😀".to_owned()) + ); + } + + #[test] + fn host_text_transport_rejects_invalid_utf8() { + let mut decoder = HostTextDecoder::default(); + decoder.receive(HOST_TEXT_START_TRIGGER); + for digit in [3, 7, 7] { + decoder.receive(HOST_TEXT_DATA_MODIFIERS | HOST_TEXT_F13 + digit); + } + assert_eq!( + decoder.receive(HOST_TEXT_END_TRIGGER), + HostTextTransportEvent::Consumed + ); + } + + #[test] + fn host_text_transport_resets_after_an_unrelated_key() { + let mut decoder = HostTextDecoder::default(); + decoder.receive(HOST_TEXT_START_TRIGGER); + + assert_eq!(decoder.receive(KC_F13), HostTextTransportEvent::NotHandled); + assert_eq!( + decoder.receive(HOST_TEXT_END_TRIGGER), + HostTextTransportEvent::NotHandled + ); + } + + #[test] + fn host_text_transport_aborts_when_digit_limit_is_exceeded() { + let mut decoder = HostTextDecoder { + active: true, + digits: vec![0; HOST_TEXT_MAX_DIGITS], + }; + + assert_eq!( + decoder.receive(HOST_TEXT_DATA_MODIFIERS | HOST_TEXT_F13), + HostTextTransportEvent::Consumed + ); + assert!(!decoder.active); + assert!(decoder.digits.is_empty()); + } } diff --git a/src/smart_input_symbols.rs b/src/smart_input_symbols.rs index 019a33a0..744663a0 100644 --- a/src/smart_input_symbols.rs +++ b/src/smart_input_symbols.rs @@ -869,6 +869,19 @@ mod tests { } } + #[test] + fn host_text_transport_slots_do_not_collide_with_universal_symbols() { + for base_keycode in KC_F13..=KC_F13 + 7 { + assert!( + smart_symbol_for_keycode(MOD_CTRL | MOD_ALT | MOD_GUI | base_keycode).is_none() + ); + assert!(smart_symbol_for_keycode( + MOD_CTRL | MOD_SHIFT | MOD_ALT | MOD_GUI | base_keycode + ) + .is_none()); + } + } + #[test] fn remapped_universal_symbols_use_mac_friendly_transport_slots() { let expected = [ diff --git a/src/smart_input_windows.rs b/src/smart_input_windows.rs index edbe3cbe..bf8514f9 100644 --- a/src/smart_input_windows.rs +++ b/src/smart_input_windows.rs @@ -152,6 +152,29 @@ fn symbol_for_vk(vk: u32) -> Option<(char, u16)> { ) } +#[cfg(target_os = "windows")] +fn host_text_trigger_for_vk(vk: u32) -> Option { + let base_keycode = match vk { + 0x7C..=0x83 => KC_F13 + (vk - 0x7C) as u16, + _ => return None, + }; + let mut trigger_keycode = base_keycode; + let observed_modifiers = active_transport_modifiers(); + if modifier_down(VK_CONTROL) || observed_modifiers & MOD_CTRL != 0 { + trigger_keycode |= MOD_CTRL; + } + if modifier_down(VK_SHIFT) || observed_modifiers & MOD_SHIFT != 0 { + trigger_keycode |= MOD_SHIFT; + } + if modifier_down(VK_MENU) || observed_modifiers & MOD_ALT != 0 { + trigger_keycode |= MOD_ALT; + } + if modifier_down(VK_LWIN) || modifier_down(VK_RWIN) || observed_modifiers & MOD_GUI != 0 { + trigger_keycode |= MOD_GUI; + } + super::is_host_text_transport_trigger(trigger_keycode).then_some(trigger_keycode) +} + #[cfg(target_os = "windows")] fn modifier_down(vk: i32) -> bool { unsafe { GetAsyncKeyState(vk) & 0x8000u16 as i16 != 0 } @@ -282,7 +305,7 @@ unsafe extern "system" fn keyboard_proc(n_code: i32, w_param: usize, l_param: is let injected = info.flags & LLKHF_INJECTED != 0; if !injected { update_active_transport_modifier_state(info.vkCode, is_key_down, is_key_up); - if is_key_down { + if is_key_down && !is_transport_modifier_vk(info.vkCode) { remember_current_foreground_app(); } if foreground_is_current_process() { @@ -291,6 +314,20 @@ unsafe extern "system" fn keyboard_proc(n_code: i32, w_param: usize, l_param: is if is_key_up && should_suppress_transport_modifier_keyup(info.vkCode) { return 1; } + if is_key_down { + match super::host_text_transport_key_down( + host_text_trigger_for_vk(info.vkCode).unwrap_or_default(), + ) { + super::HostTextTransportEvent::Complete(text) => { + let trigger_keycode = host_text_trigger_for_vk(info.vkCode).unwrap(); + suppress_transport_modifier_keyups(trigger_keycode); + schedule_host_text(text, trigger_keycode); + return 1; + } + super::HostTextTransportEvent::Consumed => return 1, + super::HostTextTransportEvent::NotHandled => {} + } + } if is_key_down && text_expander_enabled() { if text_expander_suppressed_for_context() { if let Ok(mut engine) = text_expander_engine().lock() { @@ -315,6 +352,12 @@ unsafe extern "system" fn keyboard_proc(n_code: i32, w_param: usize, l_param: is } } + if let Some(trigger_keycode) = host_text_trigger_for_vk(info.vkCode) { + if is_key_down || is_key_up { + return 1; + } + } + if let Some((symbol, trigger_keycode)) = symbol_for_vk(info.vkCode) { if is_key_down { suppress_transport_modifier_keyups(trigger_keycode); @@ -329,6 +372,11 @@ unsafe extern "system" fn keyboard_proc(n_code: i32, w_param: usize, l_param: is CallNextHookEx(std::ptr::null_mut(), n_code, w_param, l_param) } +#[cfg(target_os = "windows")] +fn is_transport_modifier_vk(vk_code: u32) -> bool { + matches!(vk_code, 0x10 | 0x11 | 0x12 | 0x5B | 0x5C | 0xA0..=0xA5) +} + #[cfg(target_os = "windows")] unsafe fn text_expander_char_for_key(info: &KBDLLHOOKSTRUCT) -> Option { if modifier_down(VK_CONTROL) @@ -654,6 +702,19 @@ fn schedule_unicode_char(symbol: char, trigger_keycode: u16) { }); } +#[cfg(target_os = "windows")] +fn schedule_host_text(text: String, trigger_keycode: u16) { + std::thread::spawn(move || unsafe { + std::thread::sleep(std::time::Duration::from_millis(2)); + neutralize_transport_alt_menu(trigger_keycode); + neutralize_transport_gui_menu(trigger_keycode); + release_transport_modifiers(trigger_keycode); + if !should_use_clipboard_paste(&text) || !paste_text_with_clipboard_restore(&text) { + send_unicode_text(&text); + } + }); +} + #[cfg(target_os = "windows")] unsafe fn send_unicode_char(symbol: char, trigger_keycode: u16) { neutralize_transport_alt_menu(trigger_keycode); diff --git a/src/ui/app_lifecycle.rs b/src/ui/app_lifecycle.rs index 45aa442f..7fdd38e9 100644 --- a/src/ui/app_lifecycle.rs +++ b/src/ui/app_lifecycle.rs @@ -1535,25 +1535,41 @@ impl eframe::App for EntropyApp { if let Some(hid) = &self.hid_device { match hid.get_macro_buffer_size() { Ok(size) => { - let buf = crate::hid::HidDevice::encode_macros( + let required = crate::hid::HidDevice::encoded_macros_len( &self.keycode_picker.macro_texts, - size, ); - match hid.set_macro_buffer(&buf) { - Ok(()) => { - self.keycode_picker.macros_dirty = false; - self.status_msg = crate::i18n::tr_catalog( - self.app_settings.language, - "status_messages.macros_saved", - ) - .into() - } - Err(e) => { - self.status_msg = crate::i18n::tr_catalog_format( - self.app_settings.language, - "status_messages.macro_write_error", - &[("error", &e.to_string())], - ) + if required > size as usize { + self.status_msg = crate::i18n::tr_catalog_format( + self.app_settings.language, + "status_messages.macro_write_error", + &[( + "error", + &format!( + "macros require {required} bytes but device buffer holds {size}" + ), + )], + ); + } else { + let buf = crate::hid::HidDevice::encode_macros( + &self.keycode_picker.macro_texts, + size, + ); + match hid.set_macro_buffer(&buf) { + Ok(()) => { + self.keycode_picker.macros_dirty = false; + self.status_msg = crate::i18n::tr_catalog( + self.app_settings.language, + "status_messages.macros_saved", + ) + .into() + } + Err(e) => { + self.status_msg = crate::i18n::tr_catalog_format( + self.app_settings.language, + "status_messages.macro_write_error", + &[("error", &e.to_string())], + ) + } } } }