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 +4 -4
- data/README.md +142 -7
- data/fusuma-plugin-remap.gemspec +1 -1
- data/lib/fusuma/plugin/inputs/remap_touchpad_input.rb +15 -28
- data/lib/fusuma/plugin/remap/device_matcher.rb +41 -0
- data/lib/fusuma/plugin/remap/device_selector.rb +62 -0
- data/lib/fusuma/plugin/remap/keyboard_remapper.rb +350 -67
- 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/touchpad_remapper.rb +45 -1
- data/lib/fusuma/plugin/remap/version.rb +1 -1
- metadata +7 -4
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.
|
data/fusuma-plugin-remap.gemspec
CHANGED
|
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
|
|
|
24
24
|
# https://packages.ubuntu.com/search?keywords=ruby&searchon=names&exact=1&suite=all§ion=main
|
|
25
25
|
# support focal (20.04LTS) 2.7
|
|
26
26
|
|
|
27
|
-
spec.add_dependency "fusuma", ">= 3.
|
|
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(
|
|
45
|
-
|
|
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
|
-
|
|
81
|
+
# Clear device mapping cache when layer changes
|
|
82
|
+
@device_mappings = {}
|
|
83
|
+
@layer_changed = true
|
|
54
84
|
next
|
|
55
85
|
end
|
|
56
86
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
127
|
+
remapped, is_modifier_remap = find_remapping(current_mapping, effective_key)
|
|
84
128
|
case remapped
|
|
85
129
|
when String, Symbol
|
|
86
|
-
#
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
660
|
+
logged_no_device = false
|
|
373
661
|
loop do
|
|
374
|
-
|
|
375
|
-
devices = Fusuma::Device.all.select do |d|
|
|
376
|
-
next if d.name == VIRTUAL_KEYBOARD_NAME
|
|
662
|
+
keyboards = try_open_devices
|
|
377
663
|
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
667
|
+
logged_no_device = true
|
|
384
668
|
end
|
|
385
|
-
wait_for_device
|
|
386
669
|
|
|
387
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
@names = names
|
|
402
|
-
end
|
|
682
|
+
Array(@names).any? { |name| d.name =~ /#{name}/ }
|
|
683
|
+
end
|
|
403
684
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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:
|
|
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:
|
|
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:
|
|
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
|