fusuma-plugin-remap 0.11.2 → 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: 5eb6be3cd4ee3e56a0fa412b29381e336c0b25207ca42d716a8a124cf907a5f8
4
- data.tar.gz: d3b19a185e0cc03b7abcb00be6a8d7f63340827dafd1136adc941671457b2a80
3
+ metadata.gz: ac440e4e38328075243e062af637b5bd00aa71a9d5afe32cc68d39a1410d2bc9
4
+ data.tar.gz: c629dc8add177f2a69e4cc6901a499633028458e4ccaf3ce9dd04563011b3960
5
5
  SHA512:
6
- metadata.gz: 593c365c6ef1ffc67a3865e188baeb8c59fa69a37aaaa3f75bad64b251d77d9abd9f914f2b90739f59dffc40b94dbb29c50fb894b8a524ba5820980ba364d8e6
7
- data.tar.gz: 4cbd18fda7bfa3a05622f82cb4d558e7d00ea4b6d1a97c7c6617003eec64e1b6cb6caabd3fcfff91ef9c6c5c957b7681004c4a234f5c3a5e75a6878ba05526ca
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.
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
  # https://packages.ubuntu.com/search?keywords=ruby&searchon=names&exact=1&suite=all&section=main
25
25
  # support focal (20.04LTS) 2.7
26
26
 
27
- spec.add_dependency "fusuma", ">= 3.4"
27
+ spec.add_dependency "fusuma", ">= 3.11.0"
28
28
  spec.add_dependency "fusuma-plugin-keypress", ">= 0.11.0"
29
29
  spec.add_dependency "fusuma-plugin-sendkey", ">= 0.12.0"
30
30
  spec.add_dependency "msgpack"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../remap/touchpad_remapper"
4
+ require_relative "../remap/device_selector"
4
5
 
5
6
  module Fusuma
6
7
  module Plugin
@@ -46,51 +47,37 @@ module Fusuma
46
47
  private
47
48
 
48
49
  def setup_remapper
49
- source_touchpads = TouchpadSelector.new(config_params(:touchpad_name_patterns)).select
50
- if source_touchpads.empty?
51
- MultiLogger.error("No touchpad found: #{config_params(:touchpad_name_patterns)}")
52
- exit
53
- end
54
-
55
- MultiLogger.info("set up remapper")
56
- MultiLogger.info("touchpad: #{source_touchpads}")
57
-
58
50
  # layer_manager = Remap::LayerManager.instance
59
51
 
60
52
  # physical touchpad input event
61
53
  @fusuma_reader, fusuma_writer = IO.pipe
54
+ touchpad_name_patterns = config_params(:touchpad_name_patterns)
62
55
 
63
56
  fork do
64
57
  # layer_manager.writer.close
65
58
  @fusuma_reader.close
59
+
60
+ # DeviceSelector waits until touchpad is found (like KeyboardSelector)
61
+ # NOTE: This must be inside fork to avoid blocking the main Fusuma process
62
+ source_touchpads = Remap::DeviceSelector.new(
63
+ name_patterns: touchpad_name_patterns,
64
+ device_type: :touchpad
65
+ ).select(wait: true)
66
+
67
+ MultiLogger.info("set up remapper")
68
+ MultiLogger.info("touchpad: #{source_touchpads}")
69
+
66
70
  remapper = Remap::TouchpadRemapper.new(
67
71
  # layer_manager: layer_manager,
68
72
  fusuma_writer: fusuma_writer,
69
- source_touchpads: source_touchpads
73
+ source_touchpads: source_touchpads,
74
+ touchpad_name_patterns: touchpad_name_patterns
70
75
  )
71
76
  remapper.run
72
77
  end
73
78
  # layer_manager.reader.close
74
79
  fusuma_writer.close
75
80
  end
76
-
77
- class TouchpadSelector
78
- def initialize(names = nil)
79
- @names = names
80
- end
81
-
82
- # @return [Array<Revdev::EventDevice>]
83
- def select
84
- devices = if @names
85
- Fusuma::Device.all.select { |d| Array(@names).any? { |name| d.name =~ /#{name}/ } }
86
- else
87
- # available returns only touchpad devices
88
- Fusuma::Device.available
89
- end
90
-
91
- devices.map { |d| Revdev::EventDevice.new("/dev/input/#{d.id}") }
92
- end
93
- end
94
81
  end
95
82
  end
96
83
  end
@@ -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
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "revdev"
4
+ require "fusuma/device"
5
+
6
+ module Fusuma
7
+ module Plugin
8
+ module Remap
9
+ # Common device selector for touchpad and keyboard detection
10
+ # Unifies TouchpadSelector implementations across the codebase
11
+ class DeviceSelector
12
+ POLL_INTERVAL = 3 # seconds
13
+
14
+ # @param name_patterns [Array, String, nil] patterns for device names
15
+ # @param device_type [Symbol] :touchpad or :keyboard (for logging)
16
+ def initialize(name_patterns: nil, device_type: :touchpad)
17
+ @name_patterns = name_patterns
18
+ @device_type = device_type
19
+ @displayed_waiting = false
20
+ end
21
+
22
+ # Select devices that match the name patterns
23
+ # @param wait [Boolean] if true, wait until device is found (polling loop)
24
+ # @return [Array<Revdev::EventDevice>]
25
+ def select(wait: false)
26
+ loop do
27
+ Fusuma::Device.reset # reset cache to get the latest device information
28
+ devices = find_devices
29
+ return to_event_devices(devices) unless devices.empty?
30
+ return [] unless wait
31
+
32
+ log_waiting_message unless @displayed_waiting
33
+ sleep POLL_INTERVAL
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def find_devices
40
+ if @name_patterns
41
+ Fusuma::Device.all.select { |d|
42
+ Array(@name_patterns).any? { |name| d.name =~ /#{name}/ }
43
+ }
44
+ else
45
+ # available returns only touchpad devices
46
+ Fusuma::Device.available
47
+ end
48
+ end
49
+
50
+ def to_event_devices(devices)
51
+ devices.map { |d| Revdev::EventDevice.new("/dev/input/#{d.id}") }
52
+ end
53
+
54
+ def log_waiting_message
55
+ MultiLogger.warn "No #{@device_type} found: #{@name_patterns || "(default patterns)"}"
56
+ MultiLogger.warn "Waiting for #{@device_type} to be connected..."
57
+ @displayed_waiting = true
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -3,6 +3,9 @@ require "msgpack"
3
3
  require "set"
4
4
  require_relative "layer_manager"
5
5
  require_relative "uinput_keyboard"
6
+ require_relative "device_selector"
7
+ require_relative "device_matcher"
8
+ require_relative "modifier_state"
6
9
  require "fusuma/device"
7
10
 
8
11
  module Fusuma
@@ -13,6 +16,7 @@ module Fusuma
13
16
 
14
17
  VIRTUAL_KEYBOARD_NAME = "fusuma_virtual_keyboard"
15
18
  DEFAULT_EMERGENCY_KEYBIND = "RIGHTCTRL+LEFTCTRL".freeze
19
+ DEVICE_CHECK_INTERVAL = 3 # seconds - interval for checking new devices
16
20
 
17
21
  # Key conversion tables for better performance and readability
18
22
  KEYMAP = Revdev.constants.select { |c| c.start_with?("KEY_", "BTN_") }
@@ -29,20 +33,44 @@ module Fusuma
29
33
  @layer_manager = layer_manager # request to change layer
30
34
  @fusuma_writer = fusuma_writer # write event to original keyboard
31
35
  @config = config
36
+ @device_matcher = DeviceMatcher.new
37
+ @device_mappings = {}
32
38
  end
33
39
 
34
40
  def run
35
41
  create_virtual_keyboard
36
42
  @source_keyboards = reload_keyboards
37
43
 
44
+ # Manage modifier key states
45
+ @modifier_state = ModifierState.new
46
+
38
47
  old_ie = nil
39
48
  layer = nil
40
- next_mapping = nil
41
49
  current_mapping = {}
42
50
 
43
51
  loop do
44
- ios = IO.select([*@source_keyboards.map(&:file), @layer_manager.reader])
45
- 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
46
74
 
47
75
  if io == @layer_manager.reader
48
76
  layer = @layer_manager.receive_layer # update @current_layer
@@ -50,24 +78,40 @@ module Fusuma
50
78
  next
51
79
  end
52
80
 
53
- next_mapping = @layer_manager.find_mapping(layer)
81
+ # Clear device mapping cache when layer changes
82
+ @device_mappings = {}
83
+ @layer_changed = true
54
84
  next
55
85
  end
56
86
 
57
- if next_mapping && virtual_keyboard_all_key_released?
58
- if current_mapping != next_mapping
59
- current_mapping = next_mapping
60
- end
61
- 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
62
97
  end
63
98
 
64
- 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
+
65
102
  input_key = code_to_key(input_event.code)
66
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
+
67
108
  if input_event.type == EV_KEY
68
109
  @emergency_stop.call(old_ie, input_event)
69
110
 
70
111
  old_ie = input_event
112
+
113
+ @modifier_state.update(effective_key, input_event.value)
114
+
71
115
  if input_event.value != 2 # repeat
72
116
  data = {key: input_key, status: input_event.value, layer: layer}
73
117
  begin
@@ -80,28 +124,60 @@ module Fusuma
80
124
  end
81
125
  end
82
126
 
83
- remapped = current_mapping.fetch(input_key.to_sym, nil)
127
+ remapped, is_modifier_remap = find_remapping(current_mapping, effective_key)
84
128
  case remapped
85
129
  when String, Symbol
86
- # 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
87
137
  when Hash
88
138
  # Command execution (e.g., {:SENDKEY=>"LEFTCTRL+BTN_LEFT", :CLEARMODIFIERS=>true})
89
139
  # Skip input event processing and let Fusuma's Executor handle this
90
140
  next
91
141
  when nil
92
- # Not remapped - write original key event as-is
93
- 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
94
155
  next
95
156
  else
96
- # Invalid remapping configuration
97
157
  MultiLogger.warn("Invalid remapped value - type: #{remapped.class}, key: #{input_key}")
98
158
  next
99
159
  end
100
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
+
101
177
  remapped_code = key_to_code(remapped)
102
178
  if remapped_code.nil?
103
179
  MultiLogger.warn("Invalid remapped value - unknown key: #{remapped}, input: #{input_key}")
104
- uinput_keyboard.write_input_event(input_event)
180
+ write_event_with_log(input_event, context: "remap failed")
105
181
  next
106
182
  end
107
183
 
@@ -121,9 +197,10 @@ module Fusuma
121
197
  # this is because the command will be executed by fusuma process
122
198
  next if remapped_event.code.nil?
123
199
 
124
- uinput_keyboard.write_input_event(remapped_event)
200
+ write_event_with_log(remapped_event, context: "remapped from #{input_key}")
125
201
  rescue Errno::ENODEV => e # device is removed
126
202
  MultiLogger.error "Device is removed: #{e.message}"
203
+ @device_mappings = {} # Clear cache for new device configuration
127
204
  @source_keyboards = reload_keyboards
128
205
  end
129
206
  rescue EOFError => e # device is closed
@@ -147,6 +224,56 @@ module Fusuma
147
224
  MultiLogger.error e.backtrace.join("\n")
148
225
  end
149
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
+
150
277
  def uinput_keyboard
151
278
  @uinput_keyboard ||= UinputKeyboard.new("/dev/uinput")
152
279
  end
@@ -192,29 +319,36 @@ module Fusuma
192
319
 
193
320
  def create_virtual_keyboard
194
321
  touchpad_name_patterns = @config[:touchpad_name_patterns]
195
- internal_touchpad = TouchpadSelector.new(touchpad_name_patterns).select.first
196
-
197
- if internal_touchpad.nil?
198
- MultiLogger.error("No touchpad found: #{touchpad_name_patterns}")
199
- exit
200
- end
322
+ # Use DeviceSelector without wait - keyboard remap should work even without touchpad
323
+ internal_touchpad = DeviceSelector.new(
324
+ name_patterns: touchpad_name_patterns,
325
+ device_type: :touchpad
326
+ ).select(wait: false).first
201
327
 
202
328
  MultiLogger.info "Create virtual keyboard: #{VIRTUAL_KEYBOARD_NAME}"
203
329
 
204
- uinput_keyboard.create VIRTUAL_KEYBOARD_NAME,
205
- Revdev::InputId.new(
206
- # disable while typing is enabled when
207
- # - Both the keyboard and touchpad are BUS_I8042
208
- # - The touchpad and keyboard have the same vendor/product
209
- # ref: (https://wayland.freedesktop.org/libinput/doc/latest/palm-detection.html#disable-while-typing)
210
- #
211
- {
212
- bustype: Revdev::BUS_I8042,
213
- vendor: internal_touchpad.device_id.vendor,
214
- product: internal_touchpad.device_id.product,
215
- version: internal_touchpad.device_id.version
216
- }
217
- )
330
+ if internal_touchpad.nil?
331
+ MultiLogger.warn("No touchpad found: #{touchpad_name_patterns}")
332
+ MultiLogger.warn("Disable-while-typing feature will not work without a touchpad")
333
+ # Create virtual keyboard without touchpad device ID
334
+ # disable-while-typing will not work in this case
335
+ uinput_keyboard.create VIRTUAL_KEYBOARD_NAME
336
+ else
337
+ uinput_keyboard.create VIRTUAL_KEYBOARD_NAME,
338
+ Revdev::InputId.new(
339
+ # disable while typing is enabled when
340
+ # - Both the keyboard and touchpad are BUS_I8042
341
+ # - The touchpad and keyboard have the same vendor/product
342
+ # ref: (https://wayland.freedesktop.org/libinput/doc/latest/palm-detection.html#disable-while-typing)
343
+ #
344
+ {
345
+ bustype: Revdev::BUS_I8042,
346
+ vendor: internal_touchpad.device_id.vendor,
347
+ product: internal_touchpad.device_id.product,
348
+ version: internal_touchpad.device_id.version
349
+ }
350
+ )
351
+ end
218
352
  end
219
353
 
220
354
  def grab_keyboards(keyboards)
@@ -276,7 +410,7 @@ module Fusuma
276
410
  second_keycode = key_to_code(keybinds[1])
277
411
 
278
412
  @emergency_stop = lambda do |prev, current|
279
- 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
280
414
  MultiLogger.info "Emergency ungrab keybind is pressed: #{keybinds[0]}+#{keybinds[1]}"
281
415
  @destroy.call
282
416
  end
@@ -359,6 +493,160 @@ module Fusuma
359
493
  end
360
494
  end
361
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
+
362
650
  # Devices to detect key presses and releases
363
651
  class KeyboardSelector
364
652
  def initialize(names)
@@ -369,48 +657,43 @@ module Fusuma
369
657
  # If no device is found, it will wait for 3 seconds and try again
370
658
  # @return [Array<Revdev::EventDevice>]
371
659
  def select
372
- displayed_no_keyboard = false
660
+ logged_no_device = false
373
661
  loop do
374
- Fusuma::Device.reset # reset cache to get the latest device information
375
- devices = Fusuma::Device.all.select do |d|
376
- next if d.name == VIRTUAL_KEYBOARD_NAME
662
+ keyboards = try_open_devices
377
663
 
378
- Array(@names).any? { |name| d.name =~ /#{name}/ }
379
- end
380
- if devices.empty?
381
- unless displayed_no_keyboard
664
+ if keyboards.empty?
665
+ unless logged_no_device
382
666
  MultiLogger.warn "No keyboard found: #{@names}"
383
- displayed_no_keyboard = true
667
+ logged_no_device = true
384
668
  end
385
- wait_for_device
386
669
 
387
- next
670
+ wait_for_device
671
+ else
672
+ return keyboards
388
673
  end
389
-
390
- return devices.map { |d| Revdev::EventDevice.new("/dev/input/#{d.id}") }
391
674
  end
392
675
  end
393
676
 
394
- def wait_for_device
395
- sleep 3
396
- end
397
- end
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
398
681
 
399
- class TouchpadSelector
400
- def initialize(names = nil)
401
- @names = names
402
- end
682
+ Array(@names).any? { |name| d.name =~ /#{name}/ }
683
+ end
403
684
 
404
- # @return [Array<Revdev::EventDevice>]
405
- def select
406
- devices = if @names
407
- Fusuma::Device.all.select { |d| Array(@names).any? { |name| d.name =~ /#{name}/ } }
408
- else
409
- # available returns only touchpad devices
410
- Fusuma::Device.available
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
411
690
  end
691
+ end
412
692
 
413
- devices.map { |d| Revdev::EventDevice.new("/dev/input/#{d.id}") }
693
+ private
694
+
695
+ def wait_for_device
696
+ sleep 3
414
697
  end
415
698
  end
416
699
  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,6 +3,8 @@ require "msgpack"
3
3
  require "set"
4
4
 
5
5
  require_relative "uinput_touchpad"
6
+ require_relative "device_selector"
7
+ require "fusuma/device"
6
8
 
7
9
  module Fusuma
8
10
  module Plugin
@@ -14,9 +16,11 @@ module Fusuma
14
16
 
15
17
  # @param fusuma_writer [IO]
16
18
  # @param source_touchpads [Revdev::Device]
17
- def initialize(fusuma_writer:, source_touchpads:)
19
+ # @param touchpad_name_patterns [Array, String, nil] patterns for touchpad device names (for reconnection)
20
+ def initialize(fusuma_writer:, source_touchpads:, touchpad_name_patterns: nil)
18
21
  @source_touchpads = source_touchpads # original touchpad
19
22
  @fusuma_writer = fusuma_writer # write event to fusuma_input
23
+ @touchpad_name_patterns = touchpad_name_patterns # for reconnection
20
24
 
21
25
  @palm_detectors = @source_touchpads.each_with_object({}) do |source_touchpad, palm_detectors|
22
26
  palm_detectors[source_touchpad] = PalmDetection.new(source_touchpad)
@@ -157,7 +161,19 @@ module Fusuma
157
161
  prev_status = status
158
162
  prev_valid_touch = valid_touch
159
163
  end
164
+ rescue Errno::ENODEV => e
165
+ MultiLogger.error "Touchpad device is removed: #{e.message}"
166
+ MultiLogger.info "Waiting for touchpad to reconnect..."
167
+ reload_touchpads
168
+ touch_state = {}
169
+ mt_slot = 0
170
+ finger_state = nil
171
+ prev_valid_touch = false
172
+ prev_status = nil
173
+ retry
160
174
  end
175
+ rescue IOError => e
176
+ MultiLogger.error "Touchpad IO error: #{e.message}"
161
177
  rescue => e
162
178
  MultiLogger.error "An error occurred: #{e.message}"
163
179
  ensure
@@ -176,6 +192,34 @@ module Fusuma
176
192
  uinput.create_from_device(name: VIRTUAL_TOUCHPAD_NAME, device: @source_touchpads.first)
177
193
  end
178
194
 
195
+ # Reload touchpads after device disconnection
196
+ # This method waits until a touchpad is reconnected
197
+ def reload_touchpads
198
+ # Destroy virtual touchpad
199
+ begin
200
+ uinput.destroy
201
+ rescue IOError
202
+ # already destroyed
203
+ end
204
+ @uinput = nil
205
+
206
+ # Wait and detect touchpad using DeviceSelector
207
+ @source_touchpads = DeviceSelector.new(
208
+ name_patterns: @touchpad_name_patterns,
209
+ device_type: :touchpad
210
+ ).select(wait: true)
211
+
212
+ # Reinitialize palm detectors
213
+ @palm_detectors = @source_touchpads.each_with_object({}) do |source_touchpad, palm_detectors|
214
+ palm_detectors[source_touchpad] = PalmDetection.new(source_touchpad)
215
+ end
216
+
217
+ # Recreate virtual touchpad
218
+ create_virtual_touchpad
219
+
220
+ MultiLogger.info "Touchpad reconnected: #{@source_touchpads}"
221
+ end
222
+
179
223
  def set_trap
180
224
  @destroy = lambda do
181
225
  begin
@@ -3,7 +3,7 @@
3
3
  module Fusuma
4
4
  module Plugin
5
5
  module Remap
6
- VERSION = "0.11.2"
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.11.2
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: 2025-09-14 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
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '3.4'
19
+ version: 3.11.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '3.4'
26
+ version: 3.11.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: fusuma-plugin-keypress
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -120,8 +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
124
+ - lib/fusuma/plugin/remap/device_selector.rb
123
125
  - lib/fusuma/plugin/remap/keyboard_remapper.rb
124
126
  - lib/fusuma/plugin/remap/layer_manager.rb
127
+ - lib/fusuma/plugin/remap/modifier_state.rb
125
128
  - lib/fusuma/plugin/remap/touchpad_remapper.rb
126
129
  - lib/fusuma/plugin/remap/uinput_keyboard.rb
127
130
  - lib/fusuma/plugin/remap/uinput_touchpad.rb