twiddling 0.1
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 +7 -0
- data/.gitignore +7 -0
- data/.mdl_rules.rb +2 -0
- data/.mdlrc +2 -0
- data/.quiet_quality.yml +9 -0
- data/.rspec +1 -0
- data/.rubocop.yml +21 -0
- data/.standard.yml +3 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +116 -0
- data/bin/cfg2tw7 +13 -0
- data/bin/tw72cfg +9 -0
- data/bin/twiddling +9 -0
- data/configs/v7/default.cfg +0 -0
- data/configs/v7/default.tw7 +168 -0
- data/configs/v7/ericspace2.cfg +0 -0
- data/configs/v7/ericspace2.tw7 +203 -0
- data/fixtures/v7/README.md +31 -0
- data/fixtures/v7/button-mode-keyboard.cfg +0 -0
- data/fixtures/v7/button-mode-keyboard.tw7 +16 -0
- data/fixtures/v7/cycle-config-chord.cfg +0 -0
- data/fixtures/v7/cycle-config-chord.tw7 +12 -0
- data/fixtures/v7/empty.cfg +0 -0
- data/fixtures/v7/empty.tw7 +10 -0
- data/fixtures/v7/haptic-feedback-off.cfg +0 -0
- data/fixtures/v7/haptic-feedback-off.tw7 +16 -0
- data/fixtures/v7/idle-time-8m.cfg +0 -0
- data/fixtures/v7/idle-time-8m.tw7 +12 -0
- data/fixtures/v7/key-repeat-1020.cfg +0 -0
- data/fixtures/v7/key-repeat-1020.tw7 +12 -0
- data/fixtures/v7/key-repeat-disabled.cfg +0 -0
- data/fixtures/v7/key-repeat-disabled.tw7 +12 -0
- data/fixtures/v7/large.cfg +0 -0
- data/fixtures/v7/large.tw7 +142 -0
- data/fixtures/v7/mini-buttons.cfg +0 -0
- data/fixtures/v7/mini-buttons.tw7 +14 -0
- data/fixtures/v7/modifier-key.cfg +0 -0
- data/fixtures/v7/modifier-key.tw7 +12 -0
- data/fixtures/v7/multi-char.cfg +0 -0
- data/fixtures/v7/multi-char.tw7 +12 -0
- data/fixtures/v7/nav-invert-x-axis.cfg +0 -0
- data/fixtures/v7/nav-invert-x-axis.tw7 +16 -0
- data/fixtures/v7/nav-sensitivity-lowered.cfg +0 -0
- data/fixtures/v7/nav-sensitivity-lowered.tw7 +12 -0
- data/fixtures/v7/nav-up-east.cfg +0 -0
- data/fixtures/v7/nav-up-east.tw7 +12 -0
- data/fixtures/v7/no-right-mouse-button.cfg +0 -0
- data/fixtures/v7/no-right-mouse-button.tw7 +12 -0
- data/fixtures/v7/no-t0-dedicated.cfg +0 -0
- data/fixtures/v7/no-t0-dedicated.tw7 +12 -0
- data/fixtures/v7/shifted-key.cfg +0 -0
- data/fixtures/v7/shifted-key.tw7 +12 -0
- data/fixtures/v7/single-unmodified-key.cfg +0 -0
- data/fixtures/v7/single-unmodified-key.tw7 +12 -0
- data/formats/tw7.md +222 -0
- data/formats/v7-cfg.md +272 -0
- data/lib/twiddling/cli/convert.rb +70 -0
- data/lib/twiddling/cli/diff.rb +192 -0
- data/lib/twiddling/cli/help.rb +26 -0
- data/lib/twiddling/cli/read.rb +56 -0
- data/lib/twiddling/cli/search.rb +131 -0
- data/lib/twiddling/cli/twiddling.rb +33 -0
- data/lib/twiddling/cli.rb +8 -0
- data/lib/twiddling/v7/chord.rb +48 -0
- data/lib/twiddling/v7/chord_constants.rb +82 -0
- data/lib/twiddling/v7/config.rb +102 -0
- data/lib/twiddling/v7/config_constants.rb +30 -0
- data/lib/twiddling/v7/data/default_base.cfg +0 -0
- data/lib/twiddling/v7/reader/chord.rb +49 -0
- data/lib/twiddling/v7/reader/config.rb +64 -0
- data/lib/twiddling/v7/reader/settings.rb +26 -0
- data/lib/twiddling/v7/reader/string_table.rb +69 -0
- data/lib/twiddling/v7/settings.rb +21 -0
- data/lib/twiddling/v7/string_table.rb +21 -0
- data/lib/twiddling/v7/string_table_entry.rb +14 -0
- data/lib/twiddling/v7/tw7/button_formatter.rb +63 -0
- data/lib/twiddling/v7/tw7/button_parser.rb +78 -0
- data/lib/twiddling/v7/tw7/effect_formatter.rb +78 -0
- data/lib/twiddling/v7/tw7/effect_parser.rb +121 -0
- data/lib/twiddling/v7/tw7/parser.rb +133 -0
- data/lib/twiddling/v7/tw7/printer.rb +105 -0
- data/lib/twiddling/v7/tw7/settings_formatter.rb +96 -0
- data/lib/twiddling/v7/tw7/settings_parser.rb +97 -0
- data/lib/twiddling/v7/validator.rb +93 -0
- data/lib/twiddling/v7/writer/chord.rb +36 -0
- data/lib/twiddling/v7/writer/config.rb +79 -0
- data/lib/twiddling/v7/writer/settings.rb +19 -0
- data/lib/twiddling/v7/writer/string_table.rb +40 -0
- data/lib/twiddling/v7.rb +34 -0
- data/lib/twiddling/version.rb +3 -0
- data/lib/twiddling.rb +8 -0
- data/tmp/.gitkeep +0 -0
- data/twiddling.gemspec +46 -0
- metadata +284 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Twiddling
|
|
4
|
+
module Cli
|
|
5
|
+
# twiddling search <file> [options]
|
|
6
|
+
#
|
|
7
|
+
# Searches for chords matching all supplied filters.
|
|
8
|
+
# --chord BUTTONS Exact button match (low bits only)
|
|
9
|
+
# --button BUTTON Chord includes this button (repeatable)
|
|
10
|
+
# --result EFFECT Chord produces this output
|
|
11
|
+
class Search
|
|
12
|
+
READABLE_EXTS = %w[.cfg .tw7].freeze
|
|
13
|
+
|
|
14
|
+
HELP_TEXT = <<~TEXT
|
|
15
|
+
Usage: twiddling search <file> [filters]
|
|
16
|
+
|
|
17
|
+
Searches for chords matching all supplied filters.
|
|
18
|
+
|
|
19
|
+
Filters:
|
|
20
|
+
--chord BUTTONS Exact button combination match
|
|
21
|
+
--button BUTTON Includes this button (repeatable)
|
|
22
|
+
--result EFFECT Produces this output
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
twiddling search my.cfg --chord "T1 1L"
|
|
26
|
+
twiddling search my.tw7 --result "@"
|
|
27
|
+
twiddling search my.cfg --button T4 --button 0M
|
|
28
|
+
TEXT
|
|
29
|
+
|
|
30
|
+
def initialize(argv:, stdout: $stdout, stderr: $stderr)
|
|
31
|
+
@argv = argv
|
|
32
|
+
@stdout = stdout
|
|
33
|
+
@stderr = stderr
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
return @stdout.puts(HELP_TEXT) if help?
|
|
38
|
+
validate!
|
|
39
|
+
matches = apply_filters
|
|
40
|
+
if matches.empty?
|
|
41
|
+
@stderr.puts "No matching chords found."
|
|
42
|
+
else
|
|
43
|
+
print_matches(matches)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def options
|
|
48
|
+
@options ||= parse_options
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_options
|
|
54
|
+
opts = {buttons: []}
|
|
55
|
+
remaining = option_parser(opts).parse(@argv.dup)
|
|
56
|
+
opts[:path] = remaining.shift
|
|
57
|
+
opts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def option_parser(opts)
|
|
61
|
+
OptionParser.new do |parser|
|
|
62
|
+
parser.on("--chord BUTTONS", "Exact chord match") { |c| opts[:chord] = c }
|
|
63
|
+
parser.on("--button BUTTON", "Includes button (repeatable)") { |b| opts[:buttons] << b }
|
|
64
|
+
parser.on("--result EFFECT", "Produces this output") { |r| opts[:result] = r }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def path = options[:path]
|
|
69
|
+
|
|
70
|
+
def help? = @argv.include?("-h") || @argv.include?("--help")
|
|
71
|
+
|
|
72
|
+
def validate!
|
|
73
|
+
raise ExitException, HELP_TEXT if path.nil?
|
|
74
|
+
raise ExitException, "File not found: #{path}" unless File.exist?(path)
|
|
75
|
+
validate_ext!
|
|
76
|
+
raise ExitException, "No search criteria specified" unless any_filters?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_ext!
|
|
80
|
+
return if READABLE_EXTS.include?(File.extname(path))
|
|
81
|
+
|
|
82
|
+
raise ExitException, "Unsupported file type: #{path} (expected .cfg or .tw7)"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def any_filters?
|
|
86
|
+
options[:chord] || options[:buttons].any? || options[:result]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def config
|
|
90
|
+
@config ||= case File.extname(path)
|
|
91
|
+
when ".cfg" then V7::Config.from_file(path)
|
|
92
|
+
when ".tw7" then V7::Tw7::Parser.new(File.read(path)).parse
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def apply_filters
|
|
97
|
+
results = config.chords
|
|
98
|
+
results = filter_by_chord(results) if options[:chord]
|
|
99
|
+
results = filter_by_buttons(results) if options[:buttons].any?
|
|
100
|
+
results = filter_by_result(results) if options[:result]
|
|
101
|
+
results
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def filter_by_chord(chords)
|
|
105
|
+
target = V7::Tw7::ButtonParser.parse(options[:chord])
|
|
106
|
+
chords.select { |c| (c.bitmask & 0x7FFFF) == target }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def filter_by_buttons(chords)
|
|
110
|
+
bits = options[:buttons].map { |b| V7::Tw7::ButtonParser.parse(b) }
|
|
111
|
+
chords.select { |c| bits.all? { |b| c.bitmask & b == b } }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def filter_by_result(chords)
|
|
115
|
+
target = options[:result]
|
|
116
|
+
chords.select { |c| V7::Tw7::EffectFormatter.format_effect(c) == target }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def print_matches(chords)
|
|
120
|
+
chords.each do |chord|
|
|
121
|
+
buttons = V7::Tw7::ButtonFormatter.format(
|
|
122
|
+
chord.bitmask & ~V7::ChordConstants::MOUSE_MODE_FLAG
|
|
123
|
+
)
|
|
124
|
+
effect = V7::Tw7::EffectFormatter.format_effect(chord)
|
|
125
|
+
prefix = chord.mouse_mode? ? "[MOUSEMODE] " : ""
|
|
126
|
+
@stdout.puts "#{prefix}#{buttons}: #{effect}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module Cli
|
|
3
|
+
class Twiddling
|
|
4
|
+
SUBCOMMANDS = {
|
|
5
|
+
"read" => :Read,
|
|
6
|
+
"convert" => :Convert,
|
|
7
|
+
"search" => :Search,
|
|
8
|
+
"diff" => :Diff,
|
|
9
|
+
"help" => :Help
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(argv:, stdout: $stdout, stderr: $stderr)
|
|
13
|
+
@argv = argv
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
@stderr = stderr
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
subcommand = @argv.shift || "help"
|
|
20
|
+
klass = resolve(subcommand)
|
|
21
|
+
klass.new(argv: @argv, stdout: @stdout, stderr: @stderr).run
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve(name)
|
|
27
|
+
const = SUBCOMMANDS[name]
|
|
28
|
+
raise ExitException, "Unknown subcommand: #{name}\n\n#{Help::HELP_TEXT}" unless const
|
|
29
|
+
Cli.const_get(const)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
class Chord
|
|
4
|
+
include ChordConstants
|
|
5
|
+
|
|
6
|
+
attr_reader :bitmask, :modifier_type, :keycode, :string_keys
|
|
7
|
+
|
|
8
|
+
def initialize(bitmask:, modifier_type:, keycode:, string_keys: nil)
|
|
9
|
+
@bitmask = bitmask
|
|
10
|
+
@modifier_type = modifier_type
|
|
11
|
+
@keycode = keycode
|
|
12
|
+
@string_keys = string_keys
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def type_byte = modifier_type & 0xFF
|
|
16
|
+
|
|
17
|
+
def type_name
|
|
18
|
+
case type_byte
|
|
19
|
+
when TYPE_DEVICE then :device
|
|
20
|
+
when TYPE_KEYBOARD then :keyboard
|
|
21
|
+
when TYPE_MULTICHAR then :multichar
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def modifier_byte = (modifier_type >> 8) & 0xFF
|
|
26
|
+
|
|
27
|
+
def mouse_mode? = bitmask & MOUSE_MODE_FLAG != 0
|
|
28
|
+
|
|
29
|
+
def key_name = HID_KEYS[keycode]
|
|
30
|
+
|
|
31
|
+
def modifier_names
|
|
32
|
+
MODIFIERS.filter_map { |bit, name| name if modifier_byte & bit != 0 }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def device_function
|
|
36
|
+
DEVICE_FUNCTIONS[modifier_byte] if type_byte == TYPE_DEVICE
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
other.is_a?(self.class) &&
|
|
41
|
+
bitmask == other.bitmask &&
|
|
42
|
+
modifier_type == other.modifier_type &&
|
|
43
|
+
keycode == other.keycode &&
|
|
44
|
+
string_keys == other.string_keys
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
# Constants shared between Chord, Reader::Chord, and Writer::Chord.
|
|
4
|
+
module ChordConstants
|
|
5
|
+
ENTRY_SIZE = 8
|
|
6
|
+
|
|
7
|
+
# Chord type (low byte of modifier_type)
|
|
8
|
+
TYPE_DEVICE = 0x01
|
|
9
|
+
TYPE_KEYBOARD = 0x02
|
|
10
|
+
TYPE_MULTICHAR = 0x07
|
|
11
|
+
|
|
12
|
+
# Keyboard modifier bits (high byte of modifier_type when type = 0x02)
|
|
13
|
+
MODIFIER_CTRL = 0x01
|
|
14
|
+
MODIFIER_ALT = 0x04
|
|
15
|
+
MODIFIER_CMD = 0x08
|
|
16
|
+
MODIFIER_SHIFT = 0x20
|
|
17
|
+
|
|
18
|
+
MODIFIERS = {
|
|
19
|
+
MODIFIER_CTRL => "Ctrl",
|
|
20
|
+
MODIFIER_ALT => "Alt",
|
|
21
|
+
MODIFIER_CMD => "Cmd",
|
|
22
|
+
MODIFIER_SHIFT => "Shift"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Device function codes (high byte of modifier_type when type = 0x01)
|
|
26
|
+
DEVICE_FUNCTIONS = {
|
|
27
|
+
0x01 => :mouse_toggle,
|
|
28
|
+
0x02 => :left_click,
|
|
29
|
+
0x04 => :scroll_toggle,
|
|
30
|
+
0x05 => :speed_decrease,
|
|
31
|
+
0x06 => :speed_cycle,
|
|
32
|
+
0x0a => :middle_click,
|
|
33
|
+
0x0b => :speed_increase,
|
|
34
|
+
0x0c => :right_click,
|
|
35
|
+
0x0d => :print_stats,
|
|
36
|
+
0x0e => :config_cycle
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# USB HID keycodes -> key names
|
|
40
|
+
HID_KEYS = {
|
|
41
|
+
0x04 => "a", 0x05 => "b", 0x06 => "c", 0x07 => "d",
|
|
42
|
+
0x08 => "e", 0x09 => "f", 0x0a => "g", 0x0b => "h",
|
|
43
|
+
0x0c => "i", 0x0d => "j", 0x0e => "k", 0x0f => "l",
|
|
44
|
+
0x10 => "m", 0x11 => "n", 0x12 => "o", 0x13 => "p",
|
|
45
|
+
0x14 => "q", 0x15 => "r", 0x16 => "s", 0x17 => "t",
|
|
46
|
+
0x18 => "u", 0x19 => "v", 0x1a => "w", 0x1b => "x",
|
|
47
|
+
0x1c => "y", 0x1d => "z",
|
|
48
|
+
0x1e => "1", 0x1f => "2", 0x20 => "3", 0x21 => "4",
|
|
49
|
+
0x22 => "5", 0x23 => "6", 0x24 => "7", 0x25 => "8",
|
|
50
|
+
0x26 => "9", 0x27 => "0",
|
|
51
|
+
0x28 => "enter", 0x29 => "esc", 0x2a => "backspace",
|
|
52
|
+
0x2b => "tab", 0x2c => "space",
|
|
53
|
+
0x2d => "-", 0x2e => "=", 0x2f => "[", 0x30 => "]",
|
|
54
|
+
0x31 => "\\", 0x33 => ";", 0x34 => "'", 0x35 => "`",
|
|
55
|
+
0x36 => ",", 0x37 => ".", 0x38 => "/",
|
|
56
|
+
0x39 => "caps_lock",
|
|
57
|
+
0x3a => "f1", 0x3b => "f2", 0x3c => "f3", 0x3d => "f4",
|
|
58
|
+
0x3e => "f5", 0x3f => "f6", 0x40 => "f7", 0x41 => "f8",
|
|
59
|
+
0x42 => "f9", 0x43 => "f10", 0x44 => "f11", 0x45 => "f12",
|
|
60
|
+
0x49 => "insert", 0x4a => "home", 0x4b => "page_up",
|
|
61
|
+
0x4c => "delete", 0x4d => "end", 0x4e => "page_down",
|
|
62
|
+
0x4f => "right", 0x50 => "left", 0x51 => "down", 0x52 => "up",
|
|
63
|
+
0x53 => "num_lock"
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
# Base key -> shifted symbol (for Shift+key display)
|
|
67
|
+
SHIFTED_KEYS = {
|
|
68
|
+
"1" => "!", "2" => "@", "3" => "#", "4" => "$",
|
|
69
|
+
"5" => "%", "6" => "^", "7" => "&", "8" => "*",
|
|
70
|
+
"9" => "(", "0" => ")",
|
|
71
|
+
"-" => "_", "=" => "+", "[" => "{", "]" => "}",
|
|
72
|
+
"\\" => "|", ";" => ":", "'" => '"', "`" => "~",
|
|
73
|
+
"," => "<", "." => ">", "/" => "?"
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
# Characters that need special handling in string output
|
|
77
|
+
KEY_TO_CHAR = {"space" => " ", "tab" => "\t"}.freeze
|
|
78
|
+
|
|
79
|
+
MOUSE_MODE_FLAG = 0x00080000
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
class Config
|
|
4
|
+
include ConfigConstants
|
|
5
|
+
|
|
6
|
+
ATTR_NAMES = %i[
|
|
7
|
+
version format_version flags_1 flags_2 flags_3
|
|
8
|
+
idle_time key_repeat reserved_0e reserved_10
|
|
9
|
+
settings index_table chords
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader(*ATTR_NAMES)
|
|
13
|
+
|
|
14
|
+
def initialize(attrs)
|
|
15
|
+
ATTR_NAMES.each { |name| instance_variable_set(:"@#{name}", attrs[name]) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_file(path)
|
|
19
|
+
config = Reader::Config.new(File.binread(path)).parse
|
|
20
|
+
config.validate!
|
|
21
|
+
config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.from_binary(data)
|
|
25
|
+
config = Reader::Config.new(data).parse
|
|
26
|
+
config.validate!
|
|
27
|
+
config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_binary = Writer::Config.new(self).to_binary
|
|
31
|
+
|
|
32
|
+
def write(path) = File.binwrite(path, to_binary)
|
|
33
|
+
|
|
34
|
+
def chord_count = chords.length
|
|
35
|
+
|
|
36
|
+
# Returns a new Config with the chord added, sorted by bitmask,
|
|
37
|
+
# with the index table recomputed.
|
|
38
|
+
def add_chord(chord)
|
|
39
|
+
new_chords = (chords + [chord]).sort_by(&:bitmask)
|
|
40
|
+
with(chords: new_chords, index_table: self.class.compute_index_table(new_chords))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns a new Config with all chords matching the given bitmask
|
|
44
|
+
# (low 16 bits) removed, with the index table recomputed.
|
|
45
|
+
def remove_chord(bitmask)
|
|
46
|
+
target = bitmask & 0xFFFF
|
|
47
|
+
new_chords = chords.reject { |c| (c.bitmask & 0xFFFF) == target }
|
|
48
|
+
with(chords: new_chords, index_table: self.class.compute_index_table(new_chords))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Computes the 32-byte index table for a sorted list of chords.
|
|
52
|
+
# Each entry maps the low 5 bits of a bitmask prefix to the
|
|
53
|
+
# index of the first chord with that prefix. 0x80 = no match.
|
|
54
|
+
def self.compute_index_table(chords)
|
|
55
|
+
table = Array.new(32, 0x80)
|
|
56
|
+
chords.each_with_index do |chord, idx|
|
|
57
|
+
prefix = chord.bitmask & 0x1F
|
|
58
|
+
table[prefix] = idx if table[prefix] == 0x80
|
|
59
|
+
end
|
|
60
|
+
table
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns a new Config with the given attributes replaced.
|
|
64
|
+
def set(**overrides)
|
|
65
|
+
with(**overrides)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns a new Config with the given Settings.
|
|
69
|
+
def with_settings(new_settings)
|
|
70
|
+
with(settings: new_settings)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validator = @validator ||= Validator.new(self)
|
|
74
|
+
|
|
75
|
+
def validate = validator.validate
|
|
76
|
+
|
|
77
|
+
def validate! = validator.validate!
|
|
78
|
+
|
|
79
|
+
# Convenience delegators to settings
|
|
80
|
+
def thumb_modifiers = settings.thumb_modifiers
|
|
81
|
+
|
|
82
|
+
def dedicated_buttons = settings.dedicated_buttons
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def attrs
|
|
87
|
+
ATTR_NAMES.to_h { |name| [name, public_send(name)] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def with(**overrides)
|
|
91
|
+
config = self.class.new(attrs.merge(overrides))
|
|
92
|
+
config.validate!
|
|
93
|
+
config
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Builds a new Config without validation - only for testing.
|
|
97
|
+
def with_no_validate(**overrides)
|
|
98
|
+
self.class.new(attrs.merge(overrides))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
# Constants shared between Config, Reader::Config, and Writer::Config.
|
|
4
|
+
module ConfigConstants
|
|
5
|
+
HEADER_SIZE = 128
|
|
6
|
+
|
|
7
|
+
# Button bitmask bit positions (bits 0-18 of chord bitmask)
|
|
8
|
+
# Note: for rows 1-4, L and R are swapped from the nchorder spec.
|
|
9
|
+
BUTTON_BITS = {
|
|
10
|
+
0 => :T1, 1 => :F1R, 2 => :F1M, 3 => :F1L,
|
|
11
|
+
4 => :T2, 5 => :F2R, 6 => :F2M, 7 => :F2L,
|
|
12
|
+
8 => :T3, 9 => :F3R, 10 => :F3M, 11 => :F3L,
|
|
13
|
+
12 => :T4, 13 => :F4R, 14 => :F4M, 15 => :F4L,
|
|
14
|
+
16 => :F0L, 17 => :F0M, 18 => :F0R
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Thumb button modifier assignment codes (offsets 0x40-0x4F)
|
|
18
|
+
THUMB_MODIFIERS = {
|
|
19
|
+
0 => :none, 1 => :l_control, 2 => :l_shift,
|
|
20
|
+
3 => :l_option, 4 => :l_command
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Dedicated button function codes (offsets 0x50-0x53)
|
|
24
|
+
DEDICATED_FUNCTIONS = {
|
|
25
|
+
0x00 => :none, 0x09 => :mouse_left,
|
|
26
|
+
0x0a => :mouse_right, 0x0b => :mouse_middle
|
|
27
|
+
}.freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
module Reader
|
|
4
|
+
# Parses an 8-byte chord entry from v7 binary config data.
|
|
5
|
+
#
|
|
6
|
+
# Binary layout (all little-endian):
|
|
7
|
+
#
|
|
8
|
+
# Bytes 0-3: bitmask (u32) - button combination + flags
|
|
9
|
+
# Bytes 4-5: modifier_type (u16) - type in low byte, modifier/function/offset in high byte
|
|
10
|
+
# Bytes 6-7: keycode (u16) - HID keycode for keyboard chords, 0 otherwise
|
|
11
|
+
#
|
|
12
|
+
# For multi-char chords (type 0x07), the high byte of modifier_type
|
|
13
|
+
# is a byte offset into the string table. The string_table argument
|
|
14
|
+
# is used to resolve that offset into a key sequence.
|
|
15
|
+
class Chord
|
|
16
|
+
include ChordConstants
|
|
17
|
+
|
|
18
|
+
def initialize(data, string_table: nil)
|
|
19
|
+
@data = data
|
|
20
|
+
@string_table = string_table
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse
|
|
24
|
+
bitmask, modifier_type, keycode = @data.unpack("Vvv")
|
|
25
|
+
string_keys = resolve_string_keys(modifier_type)
|
|
26
|
+
|
|
27
|
+
V7::Chord.new(
|
|
28
|
+
bitmask: bitmask,
|
|
29
|
+
modifier_type: modifier_type,
|
|
30
|
+
keycode: keycode,
|
|
31
|
+
string_keys: string_keys
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Looks up the string table entry for a multi-char chord.
|
|
38
|
+
# Returns nil for non-multichar chords or when no table is present.
|
|
39
|
+
def resolve_string_keys(modifier_type)
|
|
40
|
+
return nil unless @string_table
|
|
41
|
+
return nil unless (modifier_type & 0xFF) == TYPE_MULTICHAR
|
|
42
|
+
|
|
43
|
+
offset = (modifier_type >> 8) & 0xFF
|
|
44
|
+
@string_table.entry_at_offset(offset)&.keys
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
module Reader
|
|
4
|
+
# Parses a complete v7 binary config file into a V7::Config.
|
|
5
|
+
#
|
|
6
|
+
# Binary layout:
|
|
7
|
+
# 0x00-0x7F: 128-byte header (flags, settings, index table)
|
|
8
|
+
# 0x80+: 8-byte chord entries (chord_count of them)
|
|
9
|
+
# after chords: string table (variable length, may be empty)
|
|
10
|
+
class Config
|
|
11
|
+
include ConfigConstants
|
|
12
|
+
|
|
13
|
+
def initialize(data)
|
|
14
|
+
@data = data
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse
|
|
18
|
+
header = parse_header
|
|
19
|
+
string_table = parse_string_table(header[:chord_count])
|
|
20
|
+
chords = parse_chords(header[:chord_count], string_table)
|
|
21
|
+
V7::Config.new(header.merge(chords: chords))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse_header
|
|
27
|
+
parse_header_fields.merge(parse_header_regions)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse_header_fields
|
|
31
|
+
version, format_version, flags_1, flags_2, flags_3,
|
|
32
|
+
chord_count, idle_time, key_repeat, reserved_0e =
|
|
33
|
+
@data[0, 16].unpack("VCCCCvvvv")
|
|
34
|
+
|
|
35
|
+
{version:, format_version:, flags_1:, flags_2:, flags_3:,
|
|
36
|
+
chord_count:, idle_time:, key_repeat:, reserved_0e:}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_header_regions
|
|
40
|
+
{
|
|
41
|
+
reserved_10: @data[0x10, 48],
|
|
42
|
+
settings: Reader::Settings.new(@data[0x40, 32]).parse,
|
|
43
|
+
index_table: @data[0x60, 32].unpack("C32")
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_string_table(chord_count)
|
|
48
|
+
offset = HEADER_SIZE + (chord_count * ChordConstants::ENTRY_SIZE)
|
|
49
|
+
table_data = @data[offset..]
|
|
50
|
+
return nil unless table_data && !table_data.empty?
|
|
51
|
+
|
|
52
|
+
Reader::StringTable.new(table_data).parse
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_chords(chord_count, string_table)
|
|
56
|
+
chord_count.times.map do |i|
|
|
57
|
+
offset = HEADER_SIZE + (i * ChordConstants::ENTRY_SIZE)
|
|
58
|
+
Reader::Chord.new(@data[offset, ChordConstants::ENTRY_SIZE], string_table: string_table).parse
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
module Reader
|
|
4
|
+
# Parses the 32-byte settings region (offsets 0x40-0x5F) from
|
|
5
|
+
# a v7 config header.
|
|
6
|
+
#
|
|
7
|
+
# Binary layout:
|
|
8
|
+
# 0x40-0x4F: thumb modifier assignments (4 x u32 LE)
|
|
9
|
+
# 0x50-0x53: dedicated button functions (4 x u8)
|
|
10
|
+
# 0x54-0x5F: reserved (12 bytes, always zeros)
|
|
11
|
+
class Settings
|
|
12
|
+
def initialize(data)
|
|
13
|
+
@data = data
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def parse
|
|
17
|
+
V7::Settings.new(
|
|
18
|
+
thumb_modifiers: @data[0, 16].unpack("V4"),
|
|
19
|
+
dedicated_buttons: @data[16, 4].unpack("C4"),
|
|
20
|
+
reserved: @data[20, 12]
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
module Reader
|
|
4
|
+
# Parses the string table region of a v7 binary config file.
|
|
5
|
+
#
|
|
6
|
+
# The string table is a sequence of null-terminated entries, each
|
|
7
|
+
# consisting of (modifier u16 LE, hid_code u16 LE) pairs. It
|
|
8
|
+
# appears at the end of the file, immediately after the chord
|
|
9
|
+
# entries. Multi-char chords reference entries by byte offset.
|
|
10
|
+
#
|
|
11
|
+
# Binary layout of a single entry ("te"):
|
|
12
|
+
#
|
|
13
|
+
# 02 00 17 00 modifier=0x0002 hid_code=0x0017 (t)
|
|
14
|
+
# 02 00 08 00 modifier=0x0002 hid_code=0x0008 (e)
|
|
15
|
+
# 00 00 00 00 null terminator
|
|
16
|
+
#
|
|
17
|
+
# Multiple entries are concatenated. The byte offset of each entry
|
|
18
|
+
# is stored in the chord's modifier_type high byte so the firmware
|
|
19
|
+
# can jump directly to it.
|
|
20
|
+
class StringTable
|
|
21
|
+
# Each key pair is 4 bytes: modifier u16 + hid_code u16
|
|
22
|
+
PAIR_SIZE = 4
|
|
23
|
+
|
|
24
|
+
def initialize(data)
|
|
25
|
+
@data = data.b
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Parses all entries sequentially, returning a V7::StringTable
|
|
29
|
+
# with StringTableEntry objects that track their byte offsets.
|
|
30
|
+
def parse
|
|
31
|
+
entries = []
|
|
32
|
+
offset = 0
|
|
33
|
+
|
|
34
|
+
while offset < @data.length
|
|
35
|
+
keys = read_keys(offset)
|
|
36
|
+
break if keys.nil?
|
|
37
|
+
|
|
38
|
+
entries << StringTableEntry.new(keys: keys, byte_offset: offset)
|
|
39
|
+
# Advance past this entry's key pairs + the null terminator
|
|
40
|
+
offset += (keys.length + 1) * PAIR_SIZE
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
V7::StringTable.new(entries)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Reads a single entry's keys starting at the given byte offset.
|
|
49
|
+
# Returns nil if no valid pairs are found (end of table).
|
|
50
|
+
def read_keys(offset)
|
|
51
|
+
pairs = read_pairs(offset)
|
|
52
|
+
keys = pairs.map { |mod, hid| {modifier: mod, hid_code: hid} }
|
|
53
|
+
keys.empty? ? nil : keys
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads (modifier, hid_code) pairs until hitting a null terminator.
|
|
57
|
+
def read_pairs(offset)
|
|
58
|
+
each_pair(offset).take_while { |mod, hid| !null_terminator?(mod, hid) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Unpacks the raw bytes from the given offset as u16 LE values,
|
|
62
|
+
# yielding [modifier, hid_code] pairs.
|
|
63
|
+
def each_pair(offset) = @data[offset..].unpack("v*").each_slice(2)
|
|
64
|
+
|
|
65
|
+
def null_terminator?(mod, hid) = mod == 0 && hid == 0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Twiddling
|
|
2
|
+
module V7
|
|
3
|
+
# Device settings from the config header (offsets 0x40-0x5F).
|
|
4
|
+
#
|
|
5
|
+
# Contains thumb button modifier assignments, dedicated button
|
|
6
|
+
# functions, and a reserved region.
|
|
7
|
+
class Settings
|
|
8
|
+
include ConfigConstants
|
|
9
|
+
|
|
10
|
+
BINARY_SIZE = 32
|
|
11
|
+
|
|
12
|
+
attr_reader :thumb_modifiers, :dedicated_buttons, :reserved
|
|
13
|
+
|
|
14
|
+
def initialize(thumb_modifiers:, dedicated_buttons:, reserved:)
|
|
15
|
+
@thumb_modifiers = thumb_modifiers
|
|
16
|
+
@dedicated_buttons = dedicated_buttons
|
|
17
|
+
@reserved = reserved
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|