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 +4 -4
- data/README.md +142 -7
- data/lib/fusuma/plugin/remap/device_matcher.rb +41 -0
- data/lib/fusuma/plugin/remap/keyboard_remapper.rb +323 -30
- data/lib/fusuma/plugin/remap/layer_manager.rb +58 -0
- data/lib/fusuma/plugin/remap/modifier_state.rb +55 -0
- data/lib/fusuma/plugin/remap/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac440e4e38328075243e062af637b5bd00aa71a9d5afe32cc68d39a1410d2bc9
|
|
4
|
+
data.tar.gz: c629dc8add177f2a69e4cc6901a499633028458e4ccaf3ce9dd04563011b3960
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
The context is separated by `---` and specified by `context: { thumbsense: true }`.
|
|
46
|
+
#### Basic Remap
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
Simple key-to-key remapping without any context:
|
|
51
49
|
|
|
52
|
-
|
|
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(
|
|
46
|
-
|
|
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
|
-
|
|
81
|
+
# Clear device mapping cache when layer changes
|
|
82
|
+
@device_mappings = {}
|
|
83
|
+
@layer_changed = true
|
|
55
84
|
next
|
|
56
85
|
end
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
127
|
+
remapped, is_modifier_remap = find_remapping(current_mapping, effective_key)
|
|
85
128
|
case remapped
|
|
86
129
|
when String, Symbol
|
|
87
|
-
#
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
660
|
+
logged_no_device = false
|
|
381
661
|
loop do
|
|
382
|
-
|
|
383
|
-
devices = Fusuma::Device.all.select do |d|
|
|
384
|
-
next if d.name == VIRTUAL_KEYBOARD_NAME
|
|
662
|
+
keyboards = try_open_devices
|
|
385
663
|
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
667
|
+
logged_no_device = true
|
|
392
668
|
end
|
|
393
|
-
wait_for_device
|
|
394
669
|
|
|
395
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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-
|
|
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
|