fusuma-plugin-remap 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22d4ef7f153876d648359c14463da56777ecc7878cac97f79d1e29784a6673e8
4
- data.tar.gz: f4381c0dcc9c5277fdeb14f0a19ee49772a3a06b07adfd9a31e0f43f5fff99a2
3
+ metadata.gz: ac440e4e38328075243e062af637b5bd00aa71a9d5afe32cc68d39a1410d2bc9
4
+ data.tar.gz: c629dc8add177f2a69e4cc6901a499633028458e4ccaf3ce9dd04563011b3960
5
5
  SHA512:
6
- metadata.gz: a8111498a2c1d85caaf3a3c03af770c308f4e1b4c395a739f14e7832fd91280755b65424a9626a700b411112ab54b25a27f61a2d37aa64daf460573a3a4fe925
7
- data.tar.gz: 54b0014a0f13b5a040f284ba8c3f87ecde1729ac0643b1fcb9d17411dfa9f1cd79ed03c8aa24dfeed2c2856df47ac76e69970eddba433b09a223bd695635edb2
6
+ metadata.gz: 257834827d4e8a1c05eeb5f52d2c1c803e32f2617d699373208cb41513d54abaa01937468087fb9b382ff67b76ebdde9bbb2a14ae9c8c89a3ea8d37259c1bde7
7
+ data.tar.gz: e8f5b3f9ab09e6c2575c747ed7a9f2abf52e85ad685974ddf7aa95f07f1f8ade8f594ba303f2cf0bb18dce4da6d4c64f82e78f3f9d8df32aea41214f4b011d49
data/README.md CHANGED
@@ -41,20 +41,83 @@ $ sudo gem install fusuma-plugin-remap
41
41
 
42
42
  ### Remap
43
43
 
44
- Currently, remapping is only possible in the thumbsense context.
45
- Please install [fusuma-plugin-thumbsense](https://github.com/iberianpig/fusuma-plugin-thumbsense)
44
+ You can remap keys in `~/.config/fusuma/config.yml`. The `remap` section defines key remappings.
46
45
 
47
- First, add the 'thumbsense' context to `~/.config/fusuma/config.yml`.
48
- The context is separated by `---` and specified by `context: { thumbsense: true }`.
46
+ #### Basic Remap
49
47
 
50
- ### Example
48
+ Simple key-to-key remapping without any context:
51
49
 
52
- Set the following code in `~/.config/fusuma/config.yml`.
50
+ ```yaml
51
+ remap:
52
+ CAPSLOCK: LEFTCTRL
53
+ LEFTALT: LEFTMETA
54
+ LEFTMETA: LEFTALT
55
+ ```
56
+
57
+ #### Key Alias for Modifiers
58
+
59
+ When a simple key remap is defined (e.g., `CAPSLOCK: LEFTCTRL`), the key acts as an **alias** for the target modifier. The modifier state tracks the remapped key, not the physical key.
60
+
61
+ ```yaml
62
+ remap:
63
+ CAPSLOCK: LEFTCTRL # CAPSLOCK acts as LEFTCTRL alias
64
+ LEFTCTRL+LEFTSHIFT+J: LEFTMETA+LEFTCTRL+DOWN # Combination using the alias
65
+ ```
66
+
67
+ With this configuration:
68
+ - Physical `CAPSLOCK` is treated as `LEFTCTRL`
69
+ - Physical `CAPSLOCK+LEFTSHIFT+J` triggers `LEFTCTRL+LEFTSHIFT+J` combination
70
+ - This outputs `LEFTMETA+LEFTCTRL+DOWN`
71
+
72
+ #### Output Sequence
73
+
74
+ You can send multiple key combinations in sequence using an array:
75
+
76
+ ```yaml
77
+ remap:
78
+ LEFTCTRL+U: [LEFTSHIFT+HOME, DELETE] # Select to line start, then delete
79
+ LEFTCTRL+K: [LEFTSHIFT+END, DELETE] # Select to line end, then delete
80
+ ```
81
+
82
+ #### Modifier + Key Combinations
83
+
84
+ Remap modifier key combinations to other keys or combinations:
85
+
86
+ ```yaml
87
+ remap:
88
+ LEFTCTRL+J: DOWN
89
+ LEFTCTRL+K: UP
90
+ LEFTCTRL+H: LEFT
91
+ LEFTCTRL+L: RIGHT
92
+ LEFTALT+N: LEFTCTRL+TAB # Next tab
93
+ LEFTALT+P: LEFTCTRL+LEFTSHIFT+TAB # Previous tab
94
+ ```
95
+
96
+ ### Context
97
+
98
+ You can define different remappings for different contexts. Contexts are separated by `---`.
99
+
100
+ #### Application Context
101
+
102
+ Remap keys only for specific applications:
53
103
 
54
104
  ```yaml
105
+ ---
106
+ context:
107
+ application: Alacritty
55
108
 
109
+ remap:
110
+ LEFTMETA+N: LEFTCTRL+LEFTSHIFT+T # New tab in terminal
111
+ LEFTMETA+W: LEFTCTRL+LEFTSHIFT+W # Close tab
112
+ ```
113
+
114
+ #### Thumbsense Context
115
+
116
+ For thumbsense mode, install [fusuma-plugin-thumbsense](https://github.com/iberianpig/fusuma-plugin-thumbsense):
117
+
118
+ ```yaml
56
119
  ---
57
- context:
120
+ context:
58
121
  thumbsense: true
59
122
 
60
123
  remap:
@@ -65,6 +128,78 @@ remap:
65
128
  SPACE: BTN_LEFT
66
129
  ```
67
130
 
131
+ #### Device Context
132
+
133
+ You can define different remappings for specific keyboard devices. The `device` pattern uses case-insensitive partial matching against the physical device name.
134
+
135
+ ```yaml
136
+ ---
137
+ # HHKB-specific remappings (matches device names containing "HHKB")
138
+ context:
139
+ device: HHKB
140
+
141
+ remap:
142
+ LEFTCTRL: LEFTMETA # Swap Ctrl to Meta on HHKB
143
+
144
+ ---
145
+ # Built-in keyboard remappings (matches "AT Translated Set 2 keyboard")
146
+ context:
147
+ device: AT Translated
148
+
149
+ remap:
150
+ LEFTALT: LEFTCTRL # Remap Alt to Ctrl on built-in keyboard
151
+ ```
152
+
153
+ To find your keyboard's device name, run:
154
+ ```sh
155
+ libinput list-devices
156
+ ```
157
+
158
+ #### Combined Contexts
159
+
160
+ You can combine multiple context conditions. When multiple contexts are active, mappings are merged with priority order: `device` < `thumbsense` < `application`.
161
+
162
+ ```yaml
163
+ ---
164
+ # HHKB + Thumbsense mode
165
+ context:
166
+ device: HHKB
167
+ thumbsense: true
168
+
169
+ remap:
170
+ J: BTN_LEFT
171
+ K: BTN_RIGHT
172
+ ```
173
+
174
+ ### Complete Example
175
+
176
+ ```yaml
177
+ # Default remappings (always active)
178
+ remap:
179
+ CAPSLOCK: LEFTCTRL
180
+ LEFTALT: LEFTMETA
181
+ LEFTMETA: LEFTALT
182
+ LEFTCTRL+J: DOWN
183
+ LEFTCTRL+K: UP
184
+
185
+ ---
186
+ # Application-specific remappings
187
+ context:
188
+ application: Alacritty
189
+
190
+ remap:
191
+ LEFTMETA+N: LEFTCTRL+LEFTSHIFT+T
192
+
193
+ ---
194
+ # Thumbsense mode
195
+ context:
196
+ thumbsense: true
197
+
198
+ remap:
199
+ J: BTN_LEFT
200
+ K: BTN_RIGHT
201
+ ```
202
+
68
203
  ## Emergency Stop Keybind for Virtual Keyboard
69
204
 
70
205
  This plugin includes a special keybind for emergency stop. Pressing this key combination will ungrab the physical keyboard and terminate the Fusuma process. This feature is particularly useful in situations where the plugin or system becomes unresponsive.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fusuma/config"
4
+
5
+ module Fusuma
6
+ module Plugin
7
+ module Remap
8
+ # Matches device names against device patterns defined in config
9
+ class DeviceMatcher
10
+ def initialize
11
+ @patterns = nil
12
+ end
13
+
14
+ # Find matching device pattern for a device name
15
+ # @param device_name [String] physical device name (e.g., "PFU HHKB-Hybrid")
16
+ # @return [String, nil] matched pattern (e.g., "HHKB"), or nil if no match
17
+ def match(device_name)
18
+ return nil if device_name.nil?
19
+
20
+ patterns.find { |pattern| device_name =~ /#{pattern}/i }
21
+ end
22
+
23
+ private
24
+
25
+ # Collect device patterns from config (cached)
26
+ # @return [Array<String>] device patterns (e.g., ["HHKB", "AT Translated"])
27
+ def patterns
28
+ @patterns ||= collect_patterns
29
+ end
30
+
31
+ # Collect unique device patterns from all context sections in keymap
32
+ def collect_patterns
33
+ keymap = Config.instance.keymap
34
+ return [] unless keymap.is_a?(Array)
35
+
36
+ keymap.filter_map { |section| section.dig(:context, :device) }.uniq
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -4,6 +4,8 @@ require "set"
4
4
  require_relative "layer_manager"
5
5
  require_relative "uinput_keyboard"
6
6
  require_relative "device_selector"
7
+ require_relative "device_matcher"
8
+ require_relative "modifier_state"
7
9
  require "fusuma/device"
8
10
 
9
11
  module Fusuma
@@ -14,6 +16,7 @@ module Fusuma
14
16
 
15
17
  VIRTUAL_KEYBOARD_NAME = "fusuma_virtual_keyboard"
16
18
  DEFAULT_EMERGENCY_KEYBIND = "RIGHTCTRL+LEFTCTRL".freeze
19
+ DEVICE_CHECK_INTERVAL = 3 # seconds - interval for checking new devices
17
20
 
18
21
  # Key conversion tables for better performance and readability
19
22
  KEYMAP = Revdev.constants.select { |c| c.start_with?("KEY_", "BTN_") }
@@ -30,20 +33,44 @@ module Fusuma
30
33
  @layer_manager = layer_manager # request to change layer
31
34
  @fusuma_writer = fusuma_writer # write event to original keyboard
32
35
  @config = config
36
+ @device_matcher = DeviceMatcher.new
37
+ @device_mappings = {}
33
38
  end
34
39
 
35
40
  def run
36
41
  create_virtual_keyboard
37
42
  @source_keyboards = reload_keyboards
38
43
 
44
+ # Manage modifier key states
45
+ @modifier_state = ModifierState.new
46
+
39
47
  old_ie = nil
40
48
  layer = nil
41
- next_mapping = nil
42
49
  current_mapping = {}
43
50
 
44
51
  loop do
45
- ios = IO.select([*@source_keyboards.map(&:file), @layer_manager.reader])
46
- io = ios.first.first
52
+ ios = IO.select(
53
+ [*@source_keyboards.map(&:file), @layer_manager.reader],
54
+ nil,
55
+ nil,
56
+ DEVICE_CHECK_INTERVAL
57
+ )
58
+
59
+ # Timeout - check for new devices
60
+ if ios.nil?
61
+ check_and_add_new_devices
62
+ next
63
+ end
64
+
65
+ readable_ios = ios.first
66
+
67
+ # Prioritize layer changes over keyboard events to ensure
68
+ # layer state is updated before processing key inputs
69
+ io = if readable_ios.include?(@layer_manager.reader)
70
+ @layer_manager.reader
71
+ else
72
+ readable_ios.first
73
+ end
47
74
 
48
75
  if io == @layer_manager.reader
49
76
  layer = @layer_manager.receive_layer # update @current_layer
@@ -51,24 +78,40 @@ module Fusuma
51
78
  next
52
79
  end
53
80
 
54
- next_mapping = @layer_manager.find_mapping(layer)
81
+ # Clear device mapping cache when layer changes
82
+ @device_mappings = {}
83
+ @layer_changed = true
55
84
  next
56
85
  end
57
86
 
58
- if next_mapping && virtual_keyboard_all_key_released?
59
- if current_mapping != next_mapping
60
- current_mapping = next_mapping
61
- end
62
- next_mapping = nil
87
+ source_keyboard = @source_keyboards.find { |kbd| kbd.file == io }
88
+ input_event = source_keyboard.read_input_event
89
+ current_device_name = source_keyboard.device_name
90
+
91
+ # Get device-specific mapping
92
+ device_mapping = get_mapping_for_device(current_device_name, layer || {})
93
+
94
+ # Wait until all virtual keys are released before applying new mapping
95
+ if @layer_changed && virtual_keyboard_all_key_released?
96
+ @layer_changed = false
63
97
  end
64
98
 
65
- input_event = @source_keyboards.find { |kbd| kbd.file == io }.read_input_event
99
+ # Use device-specific mapping (wait during layer change to prevent key stuck)
100
+ current_mapping = @layer_changed ? current_mapping : device_mapping
101
+
66
102
  input_key = code_to_key(input_event.code)
67
103
 
104
+ # Apply simple key-to-key remapping first (modmap-style)
105
+ # e.g., CAPSLOCK -> LEFTCTRL, so modifier state tracks the remapped key
106
+ effective_key = apply_simple_remap(current_mapping, input_key)
107
+
68
108
  if input_event.type == EV_KEY
69
109
  @emergency_stop.call(old_ie, input_event)
70
110
 
71
111
  old_ie = input_event
112
+
113
+ @modifier_state.update(effective_key, input_event.value)
114
+
72
115
  if input_event.value != 2 # repeat
73
116
  data = {key: input_key, status: input_event.value, layer: layer}
74
117
  begin
@@ -81,28 +124,60 @@ module Fusuma
81
124
  end
82
125
  end
83
126
 
84
- remapped = current_mapping.fetch(input_key.to_sym, nil)
127
+ remapped, is_modifier_remap = find_remapping(current_mapping, effective_key)
85
128
  case remapped
86
129
  when String, Symbol
87
- # Remapped to another key - continue processing below
130
+ # Continue to key output processing below
131
+ when Array
132
+ # Output sequence: e.g., [LEFTSHIFT+HOME, DELETE]
133
+ if input_event.value == 1
134
+ execute_modifier_remap(remapped, input_event)
135
+ end
136
+ next
88
137
  when Hash
89
138
  # Command execution (e.g., {:SENDKEY=>"LEFTCTRL+BTN_LEFT", :CLEARMODIFIERS=>true})
90
139
  # Skip input event processing and let Fusuma's Executor handle this
91
140
  next
92
141
  when nil
93
- # Not remapped - write original key event as-is
94
- uinput_keyboard.write_input_event(input_event)
142
+ if effective_key != input_key
143
+ # Output simple-remapped key (e.g., CAPSLOCK -> LEFTCTRL)
144
+ remapped_code = key_to_code(effective_key)
145
+ if remapped_code
146
+ remapped_event = InputEvent.new(nil, input_event.type, remapped_code, input_event.value)
147
+ update_virtual_key_state(effective_key, remapped_event.value)
148
+ write_event_with_log(remapped_event, context: "simple remap from #{input_key}")
149
+ else
150
+ write_event_with_log(input_event, context: "simple remap failed")
151
+ end
152
+ else
153
+ write_event_with_log(input_event, context: "passthrough")
154
+ end
95
155
  next
96
156
  else
97
- # Invalid remapping configuration
98
157
  MultiLogger.warn("Invalid remapped value - type: #{remapped.class}, key: #{input_key}")
99
158
  next
100
159
  end
101
160
 
161
+ # For modifier remaps, handle specially:
162
+ # Release currently pressed modifiers → Send remapped key → Re-press modifiers
163
+ if is_modifier_remap && input_event.value == 1
164
+ execute_modifier_remap(remapped, input_event)
165
+ next
166
+ end
167
+
168
+ # Handle key combination output (e.g., "LEFTALT+LEFT")
169
+ # If remapped value contains "+", it's a key combination that needs special handling
170
+ if remapped.to_s.include?("+")
171
+ if input_event.value == 1 # only on key press
172
+ send_key_combination(remapped, input_event.type)
173
+ end
174
+ next
175
+ end
176
+
102
177
  remapped_code = key_to_code(remapped)
103
178
  if remapped_code.nil?
104
179
  MultiLogger.warn("Invalid remapped value - unknown key: #{remapped}, input: #{input_key}")
105
- uinput_keyboard.write_input_event(input_event)
180
+ write_event_with_log(input_event, context: "remap failed")
106
181
  next
107
182
  end
108
183
 
@@ -122,9 +197,10 @@ module Fusuma
122
197
  # this is because the command will be executed by fusuma process
123
198
  next if remapped_event.code.nil?
124
199
 
125
- uinput_keyboard.write_input_event(remapped_event)
200
+ write_event_with_log(remapped_event, context: "remapped from #{input_key}")
126
201
  rescue Errno::ENODEV => e # device is removed
127
202
  MultiLogger.error "Device is removed: #{e.message}"
203
+ @device_mappings = {} # Clear cache for new device configuration
128
204
  @source_keyboards = reload_keyboards
129
205
  end
130
206
  rescue EOFError => e # device is closed
@@ -148,6 +224,56 @@ module Fusuma
148
224
  MultiLogger.error e.backtrace.join("\n")
149
225
  end
150
226
 
227
+ # Get mapping for specific device from cache or LayerManager
228
+ # @param device_name [String] Physical device name
229
+ # @param layer [Hash] Layer information
230
+ # @return [Hash] Mapping for the device
231
+ def get_mapping_for_device(device_name, layer)
232
+ matched_pattern = @device_matcher.match(device_name)
233
+ effective_layer = matched_pattern ? layer.merge(device: matched_pattern) : layer
234
+ cache_key = [device_name, layer].hash
235
+ @device_mappings[cache_key] ||= @layer_manager.find_merged_mapping(effective_layer)
236
+ end
237
+
238
+ # Check for newly connected devices and add them to source_keyboards
239
+ # Called periodically via IO.select timeout
240
+ def check_and_add_new_devices
241
+ current_device_paths = @source_keyboards.map { |kbd| kbd.file.path }
242
+
243
+ selector = KeyboardSelector.new(@config[:keyboard_name_patterns])
244
+ available_devices = selector.try_open_devices
245
+
246
+ new_devices = available_devices.reject do |device|
247
+ current_device_paths.include?(device.file.path)
248
+ end
249
+
250
+ # Close devices that are already in source_keyboards to avoid duplicate file handles
251
+ available_devices.each do |device|
252
+ device.file.close if current_device_paths.include?(device.file.path)
253
+ end
254
+
255
+ return if new_devices.empty?
256
+
257
+ MultiLogger.info("New keyboard(s) detected: #{new_devices.map(&:device_name)}")
258
+
259
+ grabbed_devices = []
260
+ new_devices.each do |device|
261
+ wait_release_all_keys(device)
262
+ device.grab
263
+ MultiLogger.info "Grabbed keyboard: #{device.device_name}"
264
+ grabbed_devices << device
265
+ rescue Errno::EBUSY
266
+ MultiLogger.error "Failed to grab keyboard: #{device.device_name}"
267
+ rescue Errno::ENODEV
268
+ MultiLogger.warn "Device removed during grab: #{device.device_name}"
269
+ end
270
+
271
+ return if grabbed_devices.empty?
272
+
273
+ @source_keyboards.concat(grabbed_devices)
274
+ @device_mappings = {} # Clear cache for new device configuration
275
+ end
276
+
151
277
  def uinput_keyboard
152
278
  @uinput_keyboard ||= UinputKeyboard.new("/dev/uinput")
153
279
  end
@@ -284,7 +410,7 @@ module Fusuma
284
410
  second_keycode = key_to_code(keybinds[1])
285
411
 
286
412
  @emergency_stop = lambda do |prev, current|
287
- if (prev&.code == first_keycode && prev.value != 0) && (current.code == second_keycode && current.value != 0)
413
+ if prev&.code == first_keycode && prev.value != 0 && current.code == second_keycode && current.value != 0
288
414
  MultiLogger.info "Emergency ungrab keybind is pressed: #{keybinds[0]}+#{keybinds[1]}"
289
415
  @destroy.call
290
416
  end
@@ -367,6 +493,160 @@ module Fusuma
367
493
  end
368
494
  end
369
495
 
496
+ # Apply simple key-to-key remapping (modmap-style)
497
+ # - Returns remapped key if simple remap exists
498
+ # - Skips combinations (containing "+"), Arrays, and Hashes
499
+ # - Returns original key if no match
500
+ #
501
+ # @param mapping [Hash] remapping configuration
502
+ # @param key [String] input key name
503
+ # @return [String] remapped key or original key
504
+ def apply_simple_remap(mapping, key)
505
+ remapped = mapping.fetch(key.to_sym, nil)
506
+ if remapped.is_a?(String) && !remapped.include?("+")
507
+ remapped
508
+ else
509
+ key
510
+ end
511
+ end
512
+
513
+ # Search for remapping
514
+ # If modifier keys are pressed, first search with modifier+key
515
+ #
516
+ # @param mapping [Hash] remapping configuration
517
+ # @param input_key [String] input key name
518
+ # @return [Array] [remapped key, is modifier remap]
519
+ def find_remapping(mapping, input_key)
520
+ # If modifier keys are pressed, first search with modifier+key (e.g., "LEFTCTRL+A")
521
+ if @modifier_state&.pressed_modifiers&.any?
522
+ combined_key = @modifier_state.current_combination(input_key)
523
+ remapped = mapping.fetch(combined_key.to_sym, nil)
524
+ if remapped
525
+ # For modifier key remapping (e.g., LEFTMETA: LEFTALT), set is_modifier_remap to false
526
+ # This distinguishes it from modifier+key combinations (e.g., LEFTCTRL+A: HOME)
527
+ # Modifier key itself should be remapped directly without execute_modifier_remap
528
+ is_modifier_remap = !@modifier_state.modifier?(input_key)
529
+ return [remapped.is_a?(Array) ? remapped : remapped.to_s, is_modifier_remap]
530
+ end
531
+ end
532
+
533
+ # If not found, search with simple key (e.g., "A")
534
+ remapped = mapping.fetch(input_key.to_sym, nil)
535
+ # If remapped is an Array (output sequence), return as is
536
+ result = remapped.is_a?(Array) ? remapped : remapped&.to_s
537
+ [result, false]
538
+ end
539
+
540
+ # Execute remapping with modifier keys
541
+ # @param remapped [String, Array] remapped key (single or array)
542
+ # @param input_event [InputEvent] original input event
543
+ #
544
+ # === Output sequence support ===
545
+ # If remapped is an Array, send each element in order
546
+ # e.g., ["LEFTSHIFT+HOME", "DELETE"]
547
+ # → Shift+Home (press/release) → Delete (press/release)
548
+ def execute_modifier_remap(remapped, input_event)
549
+ # 1. Temporarily release currently pressed modifier keys
550
+ release_current_modifiers
551
+
552
+ # 2. Send remapped key(s) (press + release)
553
+ # === Output sequence support ===
554
+ send_key_combination(remapped, input_event.type)
555
+
556
+ # 3. Re-press modifier keys
557
+ restore_current_modifiers
558
+ end
559
+
560
+ # Release currently pressed modifier keys
561
+ def release_current_modifiers
562
+ return unless @modifier_state
563
+
564
+ @modifier_state.pressed_modifiers.each do |modifier_key|
565
+ code = key_to_code(modifier_key)
566
+ next unless code
567
+
568
+ release_event = InputEvent.new(nil, EV_KEY, code, 0)
569
+ write_event_with_log(release_event, context: "modifier release")
570
+ end
571
+ end
572
+
573
+ # Re-press modifier keys
574
+ def restore_current_modifiers
575
+ return unless @modifier_state
576
+
577
+ @modifier_state.pressed_modifiers.each do |modifier_key|
578
+ code = key_to_code(modifier_key)
579
+ next unless code
580
+
581
+ press_event = InputEvent.new(nil, EV_KEY, code, 1)
582
+ write_event_with_log(press_event, context: "modifier restore")
583
+ end
584
+ end
585
+
586
+ # Send a key combination (e.g., "LEFTCTRL+O" → press Ctrl, press O, release O, release Ctrl)
587
+ # @param key_input [String, Array] Key string or array
588
+ # - String: Single key combination (e.g., "Q", "LEFTCTRL+O")
589
+ # - Array: Output sequence (e.g., ["LEFTSHIFT+HOME", "DELETE"])
590
+ # @param event_type [Integer] Event type
591
+ # @return [void]
592
+ #
593
+ # === Output sequence support ===
594
+ # If Array, send each element in order
595
+ def send_key_combination(key_input, event_type)
596
+ # If Array, send each element in order
597
+ if key_input.is_a?(Array)
598
+ key_input.each { |key| send_key_combination(key.to_s, event_type) }
599
+ return
600
+ end
601
+
602
+ keys = key_input.to_s.split("+")
603
+
604
+ # Press all keys
605
+ keys.each do |key|
606
+ code = key_to_code(key)
607
+ next unless code
608
+
609
+ press_event = InputEvent.new(nil, event_type, code, 1)
610
+ write_event_with_log(press_event, context: "combination")
611
+ end
612
+
613
+ # Release all keys in reverse order
614
+ keys.reverse.each do |key|
615
+ code = key_to_code(key)
616
+ next unless code
617
+
618
+ release_event = InputEvent.new(nil, event_type, code, 0)
619
+ write_event_with_log(release_event, context: "combination")
620
+ end
621
+ end
622
+
623
+ # Convert key event value to state string
624
+ # @param value [Integer] 0=released, 1=pressed, 2=repeat
625
+ # @return [String]
626
+ def value_to_state(value)
627
+ case value
628
+ when 0 then "released"
629
+ when 1 then "pressed"
630
+ when 2 then "repeat"
631
+ else "unknown(#{value})"
632
+ end
633
+ end
634
+
635
+ # Write input event with debug logging
636
+ # @param event [InputEvent] event to send
637
+ # @param context [String, nil] additional context info
638
+ def write_event_with_log(event, context: nil)
639
+ if event.type == EV_KEY
640
+ key = code_to_key(event.code)
641
+ state = value_to_state(event.value)
642
+ msg = "[REMAP] #{key} #{state}"
643
+ msg += " (#{context})" if context
644
+ MultiLogger.debug(msg)
645
+ end
646
+
647
+ uinput_keyboard.write_input_event(event)
648
+ end
649
+
370
650
  # Devices to detect key presses and releases
371
651
  class KeyboardSelector
372
652
  def initialize(names)
@@ -377,28 +657,41 @@ module Fusuma
377
657
  # If no device is found, it will wait for 3 seconds and try again
378
658
  # @return [Array<Revdev::EventDevice>]
379
659
  def select
380
- displayed_no_keyboard = false
660
+ logged_no_device = false
381
661
  loop do
382
- Fusuma::Device.reset # reset cache to get the latest device information
383
- devices = Fusuma::Device.all.select do |d|
384
- next if d.name == VIRTUAL_KEYBOARD_NAME
662
+ keyboards = try_open_devices
385
663
 
386
- Array(@names).any? { |name| d.name =~ /#{name}/ }
387
- end
388
- if devices.empty?
389
- unless displayed_no_keyboard
664
+ if keyboards.empty?
665
+ unless logged_no_device
390
666
  MultiLogger.warn "No keyboard found: #{@names}"
391
- displayed_no_keyboard = true
667
+ logged_no_device = true
392
668
  end
393
- wait_for_device
394
669
 
395
- next
670
+ wait_for_device
671
+ else
672
+ return keyboards
396
673
  end
674
+ end
675
+ end
676
+
677
+ def try_open_devices
678
+ Fusuma::Device.reset # reset cache to get the latest device information
679
+ devices = Fusuma::Device.all.select do |d|
680
+ next if d.name == VIRTUAL_KEYBOARD_NAME
397
681
 
398
- return devices.map { |d| Revdev::EventDevice.new("/dev/input/#{d.id}") }
682
+ Array(@names).any? { |name| d.name =~ /#{name}/ }
683
+ end
684
+
685
+ devices.filter_map do |d|
686
+ Revdev::EventDevice.new("/dev/input/#{d.id}")
687
+ rescue Errno::ENOENT, Errno::ENODEV, Errno::EACCES => e
688
+ MultiLogger.warn "Failed to open #{d.name} (/dev/input/#{d.id}): #{e.message}"
689
+ nil
399
690
  end
400
691
  end
401
692
 
693
+ private
694
+
402
695
  def wait_for_device
403
696
  sleep 3
404
697
  end
@@ -8,6 +8,14 @@ module Fusuma
8
8
  class LayerManager
9
9
  require "singleton"
10
10
  include Singleton
11
+
12
+ # Priority order for context types (higher number = higher priority)
13
+ CONTEXT_PRIORITIES = {
14
+ device: 1,
15
+ thumbsense: 2,
16
+ application: 3
17
+ }.freeze
18
+
11
19
  attr_reader :reader, :writer, :current_layer, :layers
12
20
 
13
21
  def initialize
@@ -74,6 +82,56 @@ module Fusuma
74
82
  result || {}
75
83
  end
76
84
  end
85
+
86
+ # Find merged mapping from all applicable layers
87
+ # Merges mappings from default, individual contexts, and complete match
88
+ # Higher priority contexts override lower priority ones
89
+ # @param [Hash] layer current active layer (e.g., { thumbsense: true, application: "Google-chrome" })
90
+ # @return [Hash] merged remap mapping
91
+ def find_merged_mapping(layer)
92
+ @merged_layers ||= {}
93
+ @merged_layers[layer] ||= merge_all_applicable_mappings(layer)
94
+ end
95
+
96
+ private
97
+
98
+ def merge_all_applicable_mappings(layer)
99
+ mappings = []
100
+
101
+ # 1. default (no context) - priority 0
102
+ default_mapping = find_mapping_for_context({})
103
+ mappings << [0, default_mapping] if default_mapping
104
+
105
+ # 2. Each single context key's mapping
106
+ layer.each do |key, value|
107
+ single_context = {key => value}
108
+ mapping = find_mapping_for_context(single_context)
109
+ if mapping
110
+ priority = CONTEXT_PRIORITIES.fetch(key, 1)
111
+ mappings << [priority, mapping]
112
+ end
113
+ end
114
+
115
+ # 3. Complete match (multiple keys) - highest priority
116
+ if layer.keys.size > 1
117
+ complete_mapping = find_mapping_for_context(layer)
118
+ mappings << [100, complete_mapping] if complete_mapping
119
+ end
120
+
121
+ # Merge in priority order (lower priority first, higher priority overwrites)
122
+ mappings.sort_by(&:first).reduce({}) { |merged, (_, m)| merged.merge(m) }
123
+ end
124
+
125
+ def find_mapping_for_context(context)
126
+ result = nil
127
+ Fusuma::Config::Searcher.with_context(context) do
128
+ result = Fusuma::Config.search(Fusuma::Config::Index.new(:remap))
129
+ next unless result
130
+
131
+ result = result.deep_transform_keys { |key| key.upcase.to_sym }
132
+ end
133
+ result
134
+ end
77
135
  end
78
136
  end
79
137
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Fusuma
6
+ module Plugin
7
+ module Remap
8
+ # Tracks the pressed state of modifier keys
9
+ class ModifierState
10
+ MODIFIERS = Set.new(%w[
11
+ LEFTCTRL RIGHTCTRL
12
+ LEFTALT RIGHTALT
13
+ LEFTSHIFT RIGHTSHIFT
14
+ LEFTMETA RIGHTMETA
15
+ ]).freeze
16
+
17
+ def initialize
18
+ @pressed = Set.new
19
+ end
20
+
21
+ def update(key, event_value)
22
+ return unless modifier?(key)
23
+
24
+ case event_value
25
+ when 1 then @pressed.add(key)
26
+ when 0 then @pressed.delete(key)
27
+ end
28
+ end
29
+
30
+ def current_combination(key)
31
+ return key if modifier?(key)
32
+
33
+ modifiers = pressed_modifiers
34
+ if modifiers.empty?
35
+ key
36
+ else
37
+ "#{modifiers.join("+")}+#{key}"
38
+ end
39
+ end
40
+
41
+ def pressed_modifiers
42
+ @pressed.to_a.sort
43
+ end
44
+
45
+ def modifier?(key)
46
+ MODIFIERS.include?(key)
47
+ end
48
+
49
+ def reset
50
+ @pressed.clear
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -3,7 +3,7 @@
3
3
  module Fusuma
4
4
  module Plugin
5
5
  module Remap
6
- VERSION = "0.12.0"
6
+ VERSION = "0.13.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fusuma-plugin-remap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - iberianpig
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-09 00:00:00.000000000 Z
11
+ date: 2026-01-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fusuma
@@ -120,9 +120,11 @@ files:
120
120
  - lib/fusuma/plugin/inputs/remap_touchpad_input.rb
121
121
  - lib/fusuma/plugin/inputs/remap_touchpad_input.yml
122
122
  - lib/fusuma/plugin/remap.rb
123
+ - lib/fusuma/plugin/remap/device_matcher.rb
123
124
  - lib/fusuma/plugin/remap/device_selector.rb
124
125
  - lib/fusuma/plugin/remap/keyboard_remapper.rb
125
126
  - lib/fusuma/plugin/remap/layer_manager.rb
127
+ - lib/fusuma/plugin/remap/modifier_state.rb
126
128
  - lib/fusuma/plugin/remap/touchpad_remapper.rb
127
129
  - lib/fusuma/plugin/remap/uinput_keyboard.rb
128
130
  - lib/fusuma/plugin/remap/uinput_touchpad.rb