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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/.mdl_rules.rb +2 -0
  4. data/.mdlrc +2 -0
  5. data/.quiet_quality.yml +9 -0
  6. data/.rspec +1 -0
  7. data/.rubocop.yml +21 -0
  8. data/.standard.yml +3 -0
  9. data/Gemfile +3 -0
  10. data/LICENSE +21 -0
  11. data/README.md +116 -0
  12. data/bin/cfg2tw7 +13 -0
  13. data/bin/tw72cfg +9 -0
  14. data/bin/twiddling +9 -0
  15. data/configs/v7/default.cfg +0 -0
  16. data/configs/v7/default.tw7 +168 -0
  17. data/configs/v7/ericspace2.cfg +0 -0
  18. data/configs/v7/ericspace2.tw7 +203 -0
  19. data/fixtures/v7/README.md +31 -0
  20. data/fixtures/v7/button-mode-keyboard.cfg +0 -0
  21. data/fixtures/v7/button-mode-keyboard.tw7 +16 -0
  22. data/fixtures/v7/cycle-config-chord.cfg +0 -0
  23. data/fixtures/v7/cycle-config-chord.tw7 +12 -0
  24. data/fixtures/v7/empty.cfg +0 -0
  25. data/fixtures/v7/empty.tw7 +10 -0
  26. data/fixtures/v7/haptic-feedback-off.cfg +0 -0
  27. data/fixtures/v7/haptic-feedback-off.tw7 +16 -0
  28. data/fixtures/v7/idle-time-8m.cfg +0 -0
  29. data/fixtures/v7/idle-time-8m.tw7 +12 -0
  30. data/fixtures/v7/key-repeat-1020.cfg +0 -0
  31. data/fixtures/v7/key-repeat-1020.tw7 +12 -0
  32. data/fixtures/v7/key-repeat-disabled.cfg +0 -0
  33. data/fixtures/v7/key-repeat-disabled.tw7 +12 -0
  34. data/fixtures/v7/large.cfg +0 -0
  35. data/fixtures/v7/large.tw7 +142 -0
  36. data/fixtures/v7/mini-buttons.cfg +0 -0
  37. data/fixtures/v7/mini-buttons.tw7 +14 -0
  38. data/fixtures/v7/modifier-key.cfg +0 -0
  39. data/fixtures/v7/modifier-key.tw7 +12 -0
  40. data/fixtures/v7/multi-char.cfg +0 -0
  41. data/fixtures/v7/multi-char.tw7 +12 -0
  42. data/fixtures/v7/nav-invert-x-axis.cfg +0 -0
  43. data/fixtures/v7/nav-invert-x-axis.tw7 +16 -0
  44. data/fixtures/v7/nav-sensitivity-lowered.cfg +0 -0
  45. data/fixtures/v7/nav-sensitivity-lowered.tw7 +12 -0
  46. data/fixtures/v7/nav-up-east.cfg +0 -0
  47. data/fixtures/v7/nav-up-east.tw7 +12 -0
  48. data/fixtures/v7/no-right-mouse-button.cfg +0 -0
  49. data/fixtures/v7/no-right-mouse-button.tw7 +12 -0
  50. data/fixtures/v7/no-t0-dedicated.cfg +0 -0
  51. data/fixtures/v7/no-t0-dedicated.tw7 +12 -0
  52. data/fixtures/v7/shifted-key.cfg +0 -0
  53. data/fixtures/v7/shifted-key.tw7 +12 -0
  54. data/fixtures/v7/single-unmodified-key.cfg +0 -0
  55. data/fixtures/v7/single-unmodified-key.tw7 +12 -0
  56. data/formats/tw7.md +222 -0
  57. data/formats/v7-cfg.md +272 -0
  58. data/lib/twiddling/cli/convert.rb +70 -0
  59. data/lib/twiddling/cli/diff.rb +192 -0
  60. data/lib/twiddling/cli/help.rb +26 -0
  61. data/lib/twiddling/cli/read.rb +56 -0
  62. data/lib/twiddling/cli/search.rb +131 -0
  63. data/lib/twiddling/cli/twiddling.rb +33 -0
  64. data/lib/twiddling/cli.rb +8 -0
  65. data/lib/twiddling/v7/chord.rb +48 -0
  66. data/lib/twiddling/v7/chord_constants.rb +82 -0
  67. data/lib/twiddling/v7/config.rb +102 -0
  68. data/lib/twiddling/v7/config_constants.rb +30 -0
  69. data/lib/twiddling/v7/data/default_base.cfg +0 -0
  70. data/lib/twiddling/v7/reader/chord.rb +49 -0
  71. data/lib/twiddling/v7/reader/config.rb +64 -0
  72. data/lib/twiddling/v7/reader/settings.rb +26 -0
  73. data/lib/twiddling/v7/reader/string_table.rb +69 -0
  74. data/lib/twiddling/v7/settings.rb +21 -0
  75. data/lib/twiddling/v7/string_table.rb +21 -0
  76. data/lib/twiddling/v7/string_table_entry.rb +14 -0
  77. data/lib/twiddling/v7/tw7/button_formatter.rb +63 -0
  78. data/lib/twiddling/v7/tw7/button_parser.rb +78 -0
  79. data/lib/twiddling/v7/tw7/effect_formatter.rb +78 -0
  80. data/lib/twiddling/v7/tw7/effect_parser.rb +121 -0
  81. data/lib/twiddling/v7/tw7/parser.rb +133 -0
  82. data/lib/twiddling/v7/tw7/printer.rb +105 -0
  83. data/lib/twiddling/v7/tw7/settings_formatter.rb +96 -0
  84. data/lib/twiddling/v7/tw7/settings_parser.rb +97 -0
  85. data/lib/twiddling/v7/validator.rb +93 -0
  86. data/lib/twiddling/v7/writer/chord.rb +36 -0
  87. data/lib/twiddling/v7/writer/config.rb +79 -0
  88. data/lib/twiddling/v7/writer/settings.rb +19 -0
  89. data/lib/twiddling/v7/writer/string_table.rb +40 -0
  90. data/lib/twiddling/v7.rb +34 -0
  91. data/lib/twiddling/version.rb +3 -0
  92. data/lib/twiddling.rb +8 -0
  93. data/tmp/.gitkeep +0 -0
  94. data/twiddling.gemspec +46 -0
  95. 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,8 @@
1
+ module Twiddling
2
+ module Cli
3
+ ExitException = Class.new(StandardError)
4
+ end
5
+ end
6
+
7
+ glob = File.expand_path("../cli/*.rb", __FILE__)
8
+ Dir.glob(glob).sort.each { |f| require(f) }
@@ -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
@@ -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