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
data/formats/v7-cfg.md ADDED
@@ -0,0 +1,272 @@
1
+ # Twiddler 4 Binary Configuration Format (v7)
2
+
3
+ The Twiddler 4 uses a binary `.cfg` format for its configuration files.
4
+ The format is informally called "v7", after the format version byte in
5
+ the flags field.
6
+
7
+ ## Credits
8
+
9
+ The binary format was originally reverse-engineered by the
10
+ [nchorder](https://github.com/GlassOnTin/nchorder) project
11
+ ([spec](https://github.com/GlassOnTin/nchorder/blob/master/docs/twiddler4/06-CONFIG_FORMAT.md)).
12
+ This documentation builds on that work, validated and extended using
13
+ configs produced by the official
14
+ [Twiddler tuner](https://tuner.mytwiddler.com).
15
+
16
+ ## File structure
17
+
18
+ A v7 config file has three sections laid out sequentially:
19
+
20
+ 1. **Header** - 128 bytes (offsets 0x00-0x7F)
21
+ 1. **Chord entries** - 8 bytes each (starting at offset 0x80)
22
+ 1. **String table** - variable length, immediately after the last chord
23
+
24
+ The string table begins at offset `128 + (chord_count * 8)`. There is no
25
+ pointer to it in the header - its location is computed from the chord
26
+ count.
27
+
28
+ All multi-byte integers are little-endian.
29
+
30
+ ## Header (128 bytes)
31
+
32
+ | Offset | Size | Type | Field | Description |
33
+ |--------|------|------|-------|-------------|
34
+ | 0x00 | 4 | u32 | version | Always 0 in observed configs |
35
+ | 0x04 | 1 | u8 | format_version | Always 7 (the "v7" in the format name) |
36
+ | 0x05 | 1 | u8 | flags_1 | See [Flags byte 1](#flags-byte-1) |
37
+ | 0x06 | 1 | u8 | flags_2 | See [Flags byte 2](#flags-byte-2) |
38
+ | 0x07 | 1 | u8 | flags_3 | Always 0x00 in observed configs |
39
+ | 0x08 | 2 | u16 | chord_count | Number of chord entries |
40
+ | 0x0A | 2 | u16 | idle_time | Seconds until sleep (default 600 = 10 min) |
41
+ | 0x0C | 2 | u16 | key_repeat | Repeat threshold in 10ms units (default 100) |
42
+ | 0x0E | 2 | - | reserved_0e | Always 0 in observed configs |
43
+ | 0x10 | 48 | - | reserved_10 | Always all zeros |
44
+ | 0x40 | 4 | u32 | thumb_t1_modifier | Thumb button T1 modifier assignment |
45
+ | 0x44 | 4 | u32 | thumb_t2_modifier | Thumb button T2 modifier assignment |
46
+ | 0x48 | 4 | u32 | thumb_t3_modifier | Thumb button T3 modifier assignment |
47
+ | 0x4C | 4 | u32 | thumb_t4_modifier | Thumb button T4 modifier assignment |
48
+ | 0x50 | 1 | u8 | dedicated_f0l | Dedicated button function for F0L |
49
+ | 0x51 | 1 | u8 | dedicated_f0m | Dedicated button function for F0M |
50
+ | 0x52 | 1 | u8 | dedicated_f0r | Dedicated button function for F0R |
51
+ | 0x53 | 1 | u8 | dedicated_t0 | Dedicated button function for T0 |
52
+ | 0x54 | 12 | - | reserved_54 | Always all zeros |
53
+ | 0x60 | 32 | u8[32] | index_table | See [Index table](#index-table) |
54
+
55
+ ### Flags byte 1 (offset 0x05)
56
+
57
+ | Bit | Mask | Meaning | Default |
58
+ |-----|------|---------|---------|
59
+ | 0 | 0x01 | Key repeat enabled | 1 (enabled) |
60
+ | 1 | 0x02 | Button mode: keyboard | 0 (chord mode) |
61
+ | 3 | 0x08 | Haptic feedback enabled | 1 (enabled) |
62
+
63
+ Default value: 0x09 (key repeat + haptic enabled).
64
+
65
+ ### Flags byte 2 (offset 0x06)
66
+
67
+ This byte encodes navigation settings as a composite value:
68
+
69
+ ```text
70
+ Bits 7-3: Nav sensitivity (0-31, default 4)
71
+ Bit 2: Invert X axis (0 = normal, 1 = inverted)
72
+ Bits 1-0: Nav-up direction (0 = north, 1 = east, ...)
73
+ ```
74
+
75
+ The byte value is: `(sensitivity << 3) | (invert_x << 2) | direction`
76
+
77
+ | Example | Value | Sensitivity | Invert X | Direction |
78
+ |---------|-------|-------------|----------|-----------|
79
+ | Default | 0x20 | 4 | no | north |
80
+ | Sensitivity min | 0x00 | 0 | no | north |
81
+ | Invert X | 0x24 | 4 | yes | north |
82
+ | Nav-up east | 0x21 | 4 | no | east |
83
+
84
+ ### Thumb modifier assignments (offsets 0x40-0x4F)
85
+
86
+ Each thumb button can be assigned a modifier key. Stored as four u32 LE
87
+ values (one per thumb button, T1-T4):
88
+
89
+ | Code | Modifier |
90
+ |------|----------|
91
+ | 0 | None (default for T1) |
92
+ | 1 | LControl (default for T3) |
93
+ | 2 | LShift (default for T4) |
94
+ | 3 | LOption / Alt (default for T2) |
95
+ | 4 | LCommand / Windows |
96
+
97
+ Default assignments: T1=None(0), T2=LOption(3), T3=LControl(1), T4=LShift(2).
98
+
99
+ ### Dedicated button functions (offsets 0x50-0x53)
100
+
101
+ Each of the four special buttons (three mini-buttons + T0) can have a
102
+ dedicated function that fires on press without requiring a chord. Stored
103
+ as four u8 values:
104
+
105
+ | Offset | Button | Default | Function |
106
+ |--------|--------|---------|----------|
107
+ | 0x50 | F0L | 0x0a | Mouse Button Right |
108
+ | 0x51 | F0M | 0x0b | Mouse Button Middle |
109
+ | 0x52 | F0R | 0x09 | Mouse Button Left |
110
+ | 0x53 | T0 | 0x09 | Mouse Button Left |
111
+
112
+ A value of 0x00 means no dedicated function.
113
+
114
+ Known function codes:
115
+
116
+ | Code | Function |
117
+ |------|----------|
118
+ | 0x00 | None (disabled) |
119
+ | 0x09 | Mouse Button Left |
120
+ | 0x0a | Mouse Button Right |
121
+ | 0x0b | Mouse Button Middle |
122
+
123
+ Note: These codes differ from the chord device function codes.
124
+
125
+ ### Index table (offset 0x60, 32 bytes)
126
+
127
+ A lookup table for fast chord matching. Each of the 32 entries
128
+ corresponds to the low 5 bits of a chord's bitmask (the "prefix").
129
+ The value at each entry is the index of the first chord in the sorted
130
+ chord array that has that prefix. A value of 0x80 means no chords exist
131
+ with that prefix.
132
+
133
+ Chords **must** be sorted by bitmask in ascending order for the index
134
+ table to function correctly. The index table must be recomputed whenever
135
+ chords are added or removed.
136
+
137
+ ## Chord entry (8 bytes)
138
+
139
+ | Offset | Size | Type | Field |
140
+ |--------|------|------|-------|
141
+ | 0 | 4 | u32 LE | bitmask |
142
+ | 4 | 2 | u16 LE | modifier_type |
143
+ | 6 | 2 | u16 LE | keycode |
144
+
145
+ ### Bitmask
146
+
147
+ The bitmask encodes which physical buttons are pressed. The Twiddler 4
148
+ has 21 buttons: 5 thumb buttons (T0-T4), 15 finger buttons (rows 0-4,
149
+ columns L/M/R), plus a mouse-mode flag.
150
+
151
+ | Bit | Button | Bit | Button | Bit | Button |
152
+ |-----|--------|-----|--------|-----|--------|
153
+ | 0 | T1 | 8 | T3 | 16 | F0L |
154
+ | 1 | F1R | 9 | F3R | 17 | F0M |
155
+ | 2 | F1M | 10 | F3M | 18 | F0R |
156
+ | 3 | F1L | 11 | F3L | 19 | mouse-mode-only |
157
+ | 4 | T2 | 12 | T4 | | |
158
+ | 5 | F2R | 13 | F4R | | |
159
+ | 6 | F2M | 14 | F4M | | |
160
+ | 7 | F2L | 15 | F4L | | |
161
+
162
+ Bits 0-18 encode physical buttons. Bit 19 is a flag: when set, the
163
+ chord is only active in mouse mode. Bits 20-31 are unused (always 0 in
164
+ observed configs). T0 does not appear in the bitmask - it only has a
165
+ dedicated button function.
166
+
167
+ The buttons are named using
168
+ [T4 chord notation](https://www.mytwiddler.com/doc/doku.php?id=chordnotation):
169
+
170
+ - Thumb buttons: `T1`, `T2`, `T3`, `T4`
171
+ - Finger buttons: `F<row><L|M|R>` (e.g. `F1R`, `F2L`, `F0M`)
172
+ - Row 0 is the mini-buttons above the main finger pad
173
+
174
+ ### Modifier/type field
175
+
176
+ The low byte encodes the chord type. The high byte's meaning depends on
177
+ the type:
178
+
179
+ | Type | Low byte | High byte meaning |
180
+ |------|----------|-------------------|
181
+ | Device function | 0x01 | Function code |
182
+ | Keyboard | 0x02 | Modifier key bits |
183
+ | Multi-char string | 0x07 | String table byte offset |
184
+
185
+ #### Keyboard modifier bits (high byte, type 0x02)
186
+
187
+ | Bit | Mask | Modifier |
188
+ |-----|------|----------|
189
+ | 0 | 0x01 | Ctrl |
190
+ | 2 | 0x04 | Alt |
191
+ | 3 | 0x08 | Cmd / Windows |
192
+ | 5 | 0x20 | Shift |
193
+
194
+ #### Device function codes (high byte, type 0x01)
195
+
196
+ | Code | Function |
197
+ |------|----------|
198
+ | 0x01 | Mouse mode toggle |
199
+ | 0x02 | Left click |
200
+ | 0x04 | Scroll mode toggle |
201
+ | 0x05 | Speed decrease |
202
+ | 0x06 | Speed cycle |
203
+ | 0x0a | Middle click |
204
+ | 0x0b | Speed increase |
205
+ | 0x0c | Right click |
206
+ | 0x0d | Print stats |
207
+ | 0x0e | Config cycle |
208
+
209
+ The keycode field is 0x0000 for device function chords.
210
+
211
+ #### Multi-char string (type 0x07)
212
+
213
+ The high byte of the modifier_type field is the byte offset into the
214
+ string table where the character sequence for this chord begins. The
215
+ keycode field is 0x0000.
216
+
217
+ ### Keycode
218
+
219
+ For keyboard chords (type 0x02), this is a standard USB HID keycode:
220
+
221
+ | Range | Keys |
222
+ |-------|------|
223
+ | 0x04-0x1D | a-z |
224
+ | 0x1E-0x27 | 1-0 (number row) |
225
+ | 0x28 | Enter |
226
+ | 0x29 | Escape |
227
+ | 0x2A | Backspace |
228
+ | 0x2B | Tab |
229
+ | 0x2C | Space |
230
+ | 0x2D-0x38 | Punctuation: - = [ ] \ ; ' ` , . / |
231
+ | 0x39 | Caps Lock |
232
+ | 0x3A-0x45 | F1-F12 |
233
+ | 0x49 | Insert |
234
+ | 0x4A | Home |
235
+ | 0x4B | Page Up |
236
+ | 0x4C | Delete |
237
+ | 0x4D | End |
238
+ | 0x4E | Page Down |
239
+ | 0x4F-0x52 | Right, Left, Down, Up arrows |
240
+ | 0x53 | Num Lock |
241
+
242
+ ## String table
243
+
244
+ The string table stores character sequences for multi-char chords. It
245
+ immediately follows the chord entries (no header pointer - the location
246
+ is computed from the chord count).
247
+
248
+ Each entry is a sequence of (modifier u16 LE, HID keycode u16 LE) pairs
249
+ terminated by a null pair (0x0000, 0x0000):
250
+
251
+ ```text
252
+ [mod1:u16][key1:u16][mod2:u16][key2:u16]...[0x0000][0x0000]
253
+ ```
254
+
255
+ The modifier values use the same encoding as keyboard chord modifier_type
256
+ fields: 0x0002 for an unmodified key, 0x2002 for Shift, etc.
257
+
258
+ ### Example: "test"
259
+
260
+ ```text
261
+ Offset 0: (0x0002, 0x0017) t
262
+ Offset 4: (0x0002, 0x0008) e
263
+ Offset 8: (0x0002, 0x0016) s
264
+ Offset C: (0x0002, 0x0017) t
265
+ Offset 10: (0x0000, 0x0000) terminator
266
+ ```
267
+
268
+ The chord entry's modifier_type high byte would be 0x00 (byte offset 0
269
+ into the string table), giving a full modifier_type of 0x0007.
270
+
271
+ A second string entry would start at offset 0x14 (20 bytes), and its
272
+ chord's modifier_type would be 0x1407.
@@ -0,0 +1,70 @@
1
+ module Twiddling
2
+ module Cli
3
+ # twiddling convert <input> <output>
4
+ #
5
+ # Converts between .cfg and .tw7 formats. The direction is
6
+ # determined by the file extensions.
7
+ class Convert
8
+ VALID_EXTS = %w[.cfg .tw7].freeze
9
+
10
+ HELP_TEXT = <<~TEXT
11
+ Usage: twiddling convert <input> <output>
12
+
13
+ Converts between .cfg (binary) and .tw7 (text) formats.
14
+ The direction is determined by the file extensions.
15
+
16
+ Examples:
17
+ twiddling convert my_config.cfg layout.tw7
18
+ twiddling convert layout.tw7 my_config.cfg
19
+ TEXT
20
+
21
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
22
+ @argv = argv
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ end
26
+
27
+ def run
28
+ return @stdout.puts(HELP_TEXT) if help?
29
+ validate!
30
+ write_output
31
+ end
32
+
33
+ private
34
+
35
+ def input_path = @argv[0]
36
+
37
+ def output_path = @argv[1]
38
+
39
+ def help? = @argv.include?("-h") || @argv.include?("--help")
40
+
41
+ def validate!
42
+ raise ExitException, HELP_TEXT unless input_path && output_path
43
+ raise ExitException, "File not found: #{input_path}" unless File.exist?(input_path)
44
+ validate_ext!(input_path)
45
+ validate_ext!(output_path)
46
+ end
47
+
48
+ def validate_ext!(file)
49
+ return if VALID_EXTS.include?(File.extname(file))
50
+
51
+ raise ExitException, "Unsupported file type: #{file} (expected .cfg or .tw7)"
52
+ end
53
+
54
+ def config
55
+ @config ||= case File.extname(input_path)
56
+ when ".cfg" then V7::Config.from_file(input_path)
57
+ when ".tw7" then V7::Tw7::Parser.new(File.read(input_path)).parse
58
+ end
59
+ end
60
+
61
+ def write_output
62
+ case File.extname(output_path)
63
+ when ".cfg" then config.write(output_path)
64
+ when ".tw7"
65
+ File.open(output_path, "w") { |f| V7::Tw7::Printer.new(config, io: f).print }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,192 @@
1
+ require "optparse"
2
+
3
+ module Twiddling
4
+ module Cli
5
+ # twiddling diff <file_a> <file_b> [--no-color]
6
+ #
7
+ # Shows differences between two configs: settings changes,
8
+ # removed chords, changed chords, and added chords.
9
+ class Diff
10
+ READABLE_EXTS = %w[.cfg .tw7].freeze
11
+
12
+ HELP_TEXT = <<~TEXT
13
+ Usage: twiddling diff <file_a> <file_b> [--no-color]
14
+
15
+ Shows differences between two Twiddler configs.
16
+
17
+ Prints changed settings, then removed, changed, and added
18
+ chords. Output is colorized by default (red=removed,
19
+ yellow=changed, green=added).
20
+
21
+ Examples:
22
+ twiddling diff old.cfg new.cfg
23
+ twiddling diff base.tw7 mine.tw7 --no-color
24
+ TEXT
25
+
26
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
27
+ @argv = argv
28
+ @stdout = stdout
29
+ @stderr = stderr
30
+ @color = true
31
+ parse_flags!
32
+ end
33
+
34
+ def run
35
+ return @stdout.puts(HELP_TEXT) if help?
36
+ validate!
37
+ print_diff
38
+ end
39
+
40
+ private
41
+
42
+ def path_a = @positional[0]
43
+
44
+ def path_b = @positional[1]
45
+
46
+ def help? = @argv.include?("-h") || @argv.include?("--help")
47
+
48
+ def parse_flags!
49
+ @positional = []
50
+ @argv.each do |arg|
51
+ if arg == "--no-color"
52
+ @color = false
53
+ elsif !arg.start_with?("-")
54
+ @positional << arg
55
+ end
56
+ end
57
+ end
58
+
59
+ def validate!
60
+ raise ExitException, HELP_TEXT unless path_a && path_b
61
+ [path_a, path_b].each do |path|
62
+ raise ExitException, "File not found: #{path}" unless File.exist?(path)
63
+ next if READABLE_EXTS.include?(File.extname(path))
64
+ raise ExitException, "Unsupported file type: #{path}"
65
+ end
66
+ end
67
+
68
+ def config_a = @config_a ||= load_config(path_a)
69
+
70
+ def config_b = @config_b ||= load_config(path_b)
71
+
72
+ def load_config(path)
73
+ case File.extname(path)
74
+ when ".cfg" then V7::Config.from_file(path)
75
+ when ".tw7" then V7::Tw7::Parser.new(File.read(path)).parse
76
+ end
77
+ end
78
+
79
+ def print_diff
80
+ printed = false
81
+ printed |= print_settings_diff
82
+ printed |= print_chord_diff
83
+ @stdout.puts "No differences." unless printed
84
+ end
85
+
86
+ def print_settings_diff
87
+ changes = settings_changes
88
+ return false if changes.empty?
89
+
90
+ @stdout.puts "Settings:"
91
+ changes.each do |key, old_val, new_val|
92
+ @stdout.puts color(" #{key}: #{old_val} -> #{new_val}", :yellow)
93
+ end
94
+ @stdout.puts
95
+ true
96
+ end
97
+
98
+ def settings_changes
99
+ old_s = effective_settings(config_a)
100
+ new_s = effective_settings(config_b)
101
+ old_s.filter_map do |key, old_val|
102
+ new_val = new_s[key]
103
+ [key, old_val, new_val] if old_val != new_val
104
+ end
105
+ end
106
+
107
+ def effective_settings(config)
108
+ V7::Tw7::SettingsFormatter.extract_settings(config)
109
+ end
110
+
111
+ def print_chord_diff
112
+ old_chords = chord_map(config_a)
113
+ new_chords = chord_map(config_b)
114
+ buckets = classify_chord_changes(old_chords, new_chords)
115
+
116
+ printed = false
117
+ printed |= print_removed(buckets[:removed], old_chords)
118
+ printed |= print_changed(buckets[:changed], old_chords, new_chords)
119
+ printed |= print_added(buckets[:added], new_chords)
120
+ printed
121
+ end
122
+
123
+ def classify_chord_changes(old_chords, new_chords)
124
+ {
125
+ removed: old_chords.keys - new_chords.keys,
126
+ added: new_chords.keys - old_chords.keys,
127
+ changed: (old_chords.keys & new_chords.keys).select { |bm| old_chords[bm] != new_chords[bm] }
128
+ }
129
+ end
130
+
131
+ def chord_map(config)
132
+ config.chords.to_h { |c| [c.bitmask, c] }
133
+ end
134
+
135
+ def print_removed(bitmasks, chords)
136
+ return false if bitmasks.empty?
137
+
138
+ @stdout.puts "Removed:"
139
+ bitmasks.sort.each { |bm| @stdout.puts color(" #{format_chord(chords[bm])}", :red) }
140
+ @stdout.puts
141
+ true
142
+ end
143
+
144
+ def print_changed(bitmasks, old_chords, new_chords)
145
+ return false if bitmasks.empty?
146
+
147
+ @stdout.puts "Changed:"
148
+ bitmasks.sort.each do |bm|
149
+ buttons = format_buttons(old_chords[bm])
150
+ old_effect = format_effect(old_chords[bm])
151
+ new_effect = format_effect(new_chords[bm])
152
+ @stdout.puts color(" #{buttons}: #{old_effect} -> #{new_effect}", :yellow)
153
+ end
154
+ @stdout.puts
155
+ true
156
+ end
157
+
158
+ def print_added(bitmasks, chords)
159
+ return false if bitmasks.empty?
160
+
161
+ @stdout.puts "Added:"
162
+ bitmasks.sort.each { |bm| @stdout.puts color(" #{format_chord(chords[bm])}", :green) }
163
+ @stdout.puts
164
+ true
165
+ end
166
+
167
+ def format_chord(chord)
168
+ "#{format_buttons(chord)}: #{format_effect(chord)}"
169
+ end
170
+
171
+ def format_buttons(chord)
172
+ prefix = chord.mouse_mode? ? "[MOUSEMODE] " : ""
173
+ buttons = V7::Tw7::ButtonFormatter.format(
174
+ chord.bitmask & ~V7::ChordConstants::MOUSE_MODE_FLAG
175
+ )
176
+ "#{prefix}#{buttons}"
177
+ end
178
+
179
+ def format_effect(chord)
180
+ V7::Tw7::EffectFormatter.format_effect(chord)
181
+ end
182
+
183
+ COLORS = {red: 31, green: 32, yellow: 33}.freeze
184
+
185
+ def color(text, name)
186
+ return text unless @color
187
+
188
+ "\e[#{COLORS[name]}m#{text}\e[0m"
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,26 @@
1
+ module Twiddling
2
+ module Cli
3
+ class Help
4
+ HELP_TEXT = <<~TEXT
5
+ Usage: twiddling <subcommand> [args]
6
+
7
+ Subcommands:
8
+ help Show this help message
9
+ read <file> Read a .cfg or .tw7 file
10
+ convert <input> <output> Convert between .cfg and .tw7 formats
11
+ search <file> [filters] Search chords by button or result
12
+ diff <file_a> <file_b> Show differences between two configs
13
+
14
+ Run `twiddling <subcommand> -h` for details on each command.
15
+ TEXT
16
+
17
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
18
+ @stdout = stdout
19
+ end
20
+
21
+ def run
22
+ @stdout.puts HELP_TEXT
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,56 @@
1
+ module Twiddling
2
+ module Cli
3
+ # twiddling read <file.cfg|file.tw7>
4
+ #
5
+ # Reads a config file and prints it as .tw7 text to stdout.
6
+ # Accepts both .cfg (binary) and .tw7 (text) formats.
7
+ class Read
8
+ READABLE_EXTS = %w[.cfg .tw7].freeze
9
+
10
+ HELP_TEXT = <<~TEXT
11
+ Usage: twiddling read <file>
12
+
13
+ Reads a .cfg or .tw7 config file and prints it as .tw7 text
14
+ to stdout.
15
+
16
+ Examples:
17
+ twiddling read my_config.cfg
18
+ twiddling read layout.tw7
19
+ TEXT
20
+
21
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
22
+ @argv = argv
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ end
26
+
27
+ def run
28
+ return @stdout.puts(HELP_TEXT) if help?
29
+ validate!
30
+ V7::Tw7::Printer.new(config, io: @stdout).print
31
+ end
32
+
33
+ private
34
+
35
+ def path = @argv[0]
36
+
37
+ def help? = @argv.include?("-h") || @argv.include?("--help")
38
+
39
+ def validate!
40
+ raise ExitException, HELP_TEXT if path.nil?
41
+ raise ExitException, "File not found: #{path}" unless File.exist?(path)
42
+
43
+ return if READABLE_EXTS.include?(File.extname(path))
44
+
45
+ raise ExitException, "Unsupported file type: #{path} (expected .cfg or .tw7)"
46
+ end
47
+
48
+ def config
49
+ @config ||= case File.extname(path)
50
+ when ".cfg" then V7::Config.from_file(path)
51
+ when ".tw7" then V7::Tw7::Parser.new(File.read(path)).parse
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end