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,21 @@
1
+ module Twiddling
2
+ module V7
3
+ class StringTable
4
+ attr_reader :entries
5
+
6
+ def initialize(entries = [])
7
+ @entries = entries
8
+ end
9
+
10
+ def entry_at_offset(byte_offset)
11
+ offset_lookup[byte_offset]
12
+ end
13
+
14
+ private
15
+
16
+ def offset_lookup
17
+ @offset_lookup ||= entries.to_h { |e| [e.byte_offset, e] }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ module Twiddling
2
+ module V7
3
+ class StringTableEntry
4
+ attr_reader :keys, :byte_offset
5
+
6
+ def initialize(keys:, byte_offset:)
7
+ @keys = keys
8
+ @byte_offset = byte_offset
9
+ end
10
+
11
+ def byte_size = (keys.length + 1) * 4
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,63 @@
1
+ module Twiddling
2
+ module V7
3
+ module Tw7
4
+ # Formats a chord bitmask as T4 notation button strings.
5
+ #
6
+ # Produces compact notation: thumb buttons combined (T14),
7
+ # finger buttons grouped by row (F1LR, F2M).
8
+ module ButtonFormatter
9
+ include ConfigConstants
10
+
11
+ module_function
12
+
13
+ # Returns the full button string for a bitmask (e.g. "T14 F1L F2M").
14
+ def format(bitmask)
15
+ thumbs, fingers = partition_buttons(bitmask)
16
+ parts = []
17
+ parts << format_thumbs(thumbs) unless thumbs.empty?
18
+ fingers.sort.each { |row, cols| parts << "#{row}#{cols.join}" }
19
+ parts.join(" ")
20
+ end
21
+
22
+ # Returns just the thumb portion of a bitmask (e.g. "T14").
23
+ # Returns nil if no thumb buttons.
24
+ def thumb_key(bitmask)
25
+ thumbs, _ = partition_buttons(bitmask)
26
+ return nil if thumbs.empty?
27
+ format_thumbs(thumbs)
28
+ end
29
+
30
+ # Returns the finger portion of a bitmask (e.g. "F1L F2M"),
31
+ # excluding thumb buttons.
32
+ def finger_part(bitmask)
33
+ _, fingers = partition_buttons(bitmask)
34
+ fingers.sort.map { |row, cols| "#{row}#{cols.join}" }.join(" ")
35
+ end
36
+
37
+ def partition_buttons(bitmask)
38
+ thumbs = []
39
+ fingers = {}
40
+
41
+ BUTTON_BITS.each do |bit, name|
42
+ next unless bitmask[bit] == 1
43
+ classify_button(name, thumbs, fingers)
44
+ end
45
+
46
+ [thumbs, fingers]
47
+ end
48
+
49
+ def classify_button(name, thumbs, fingers)
50
+ if name.start_with?("T")
51
+ thumbs << name[1..].to_i
52
+ else
53
+ (fingers[name[1].to_i] ||= []) << name[2..].to_sym
54
+ end
55
+ end
56
+
57
+ def format_thumbs(thumbs)
58
+ "T#{thumbs.sort.join}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,78 @@
1
+ module Twiddling
2
+ module V7
3
+ module Tw7
4
+ # Parses T4 button notation into a bitmask.
5
+ #
6
+ # Accepts various forms:
7
+ # "T14 F1L F2M" - full notation
8
+ # "T14 1L 2M" - F prefix optional for fingers
9
+ # "T14 1L2M" - spaces optional between finger tokens
10
+ # "t14 f1l f2m" - case-insensitive
11
+ # "1LR" - multiple columns in one token
12
+ #
13
+ # A space or F is required between thumb buttons and finger
14
+ # buttons (T141R is ambiguous, use T14 1R).
15
+ module ButtonParser
16
+ include ConfigConstants
17
+
18
+ BUTTON_NAME_TO_BIT = BUTTON_BITS.to_h { |bit, name|
19
+ [name.to_s, bit]
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ def parse(text)
25
+ thumb_part, finger_part = split_thumb_finger(text.strip.upcase)
26
+ mask = 0
27
+ mask |= parse_thumbs(thumb_part) if thumb_part
28
+ mask |= parse_fingers(finger_part) if finger_part
29
+ raise ArgumentError, "Invalid button spec: #{text}" if mask == 0
30
+ mask
31
+ end
32
+
33
+ private
34
+
35
+ # Split on the boundary between thumb (T...) and finger parts.
36
+ # Returns [thumb_string_or_nil, finger_string_or_nil].
37
+ def split_thumb_finger(text)
38
+ if text.start_with?("T")
39
+ match = text.match(/\A(T\d+)\s*(.*)?\z/)
40
+ raise ArgumentError, "Invalid button spec: #{text}" unless match
41
+ thumb = match[1]
42
+ rest = match[2]&.strip
43
+ [thumb, rest&.empty? ? nil : rest]
44
+ else
45
+ [nil, text]
46
+ end
47
+ end
48
+
49
+ # "T14" -> T1 + T4
50
+ def parse_thumbs(text)
51
+ text[1..].chars.reduce(0) do |mask, digit|
52
+ bit = BUTTON_NAME_TO_BIT["T#{digit}"]
53
+ raise ArgumentError, "Unknown thumb button: T#{digit}" unless bit
54
+ mask | (1 << bit)
55
+ end
56
+ end
57
+
58
+ # "F1L2M" or "1L2M" or "F1LR" -> walk chars
59
+ def parse_fingers(text)
60
+ tokens = text.delete(" \t").gsub(/F(?=\d)/, "").scan(/\d[LMR]+/)
61
+ tokens.reduce(0) { |mask, token| mask | resolve_finger_token(token) }
62
+ end
63
+
64
+ def resolve_finger_token(token)
65
+ row = token[0]
66
+ token[1..].chars.reduce(0) do |mask, col|
67
+ bit = BUTTON_NAME_TO_BIT["F#{row}#{col}"]
68
+ raise ArgumentError, "Unknown button: F#{row}#{col}" unless bit
69
+ mask | (1 << bit)
70
+ end
71
+ end
72
+
73
+ module_function :split_thumb_finger, :parse_thumbs, :parse_fingers,
74
+ :resolve_finger_token
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,78 @@
1
+ module Twiddling
2
+ module V7
3
+ module Tw7
4
+ # Formats a chord's effect (output) as tw7 text.
5
+ module EffectFormatter
6
+ include ChordConstants
7
+
8
+ module_function
9
+
10
+ def format_effect(chord)
11
+ case chord.type_name
12
+ when :keyboard then format_keyboard(chord)
13
+ when :device then format_device(chord)
14
+ when :multichar then format_multichar(chord)
15
+ else "0x%04x" % chord.modifier_type
16
+ end
17
+ end
18
+
19
+ def format_keyboard(chord)
20
+ mods = chord.modifier_names.map(&:downcase)
21
+ key = chord.key_name || ("0x%04x" % chord.keycode)
22
+
23
+ result = if mods == ["shift"] && SHIFTED_KEYS[key]
24
+ SHIFTED_KEYS[key]
25
+ elsif mods.empty?
26
+ key
27
+ else
28
+ (mods + [key]).join("+")
29
+ end
30
+
31
+ format_ambiguous(result, chord)
32
+ end
33
+
34
+ # Characters that can't appear as bare effects.
35
+ # # starts a comment, " starts a string literal.
36
+ QUOTE_CHARS = Set.new(["#"]).freeze
37
+ UNQUOTABLE_CHARS = Set.new(['"']).freeze
38
+
39
+ def format_ambiguous(result, chord)
40
+ if UNQUOTABLE_CHARS.include?(result)
41
+ # Can't quote " inside double quotes - use explicit modifier form
42
+ mods = chord.modifier_names.map(&:downcase)
43
+ key = chord.key_name
44
+ (mods + [key]).join("+")
45
+ elsif QUOTE_CHARS.include?(result)
46
+ %("#{result}")
47
+ else
48
+ result
49
+ end
50
+ end
51
+
52
+ def format_device(chord)
53
+ chord.device_function&.to_s || ("device_0x%02x" % chord.modifier_byte)
54
+ end
55
+
56
+ def format_multichar(chord)
57
+ return "multichar_0x%04x" % chord.modifier_type unless chord.string_keys
58
+
59
+ chars = chord.string_keys.map { |sk| string_key_to_char(sk) }.join
60
+ %("#{chars}")
61
+ end
62
+
63
+ def string_key_to_char(sk)
64
+ key = HID_KEYS[sk[:hid_code]]
65
+ return "?" unless key
66
+ return KEY_TO_CHAR[key] if KEY_TO_CHAR.key?(key)
67
+
68
+ shifted = ((sk[:modifier] >> 8) & MODIFIER_SHIFT) != 0
69
+ if shifted && key.length == 1
70
+ SHIFTED_KEYS[key] || key.upcase
71
+ else
72
+ key
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,121 @@
1
+ module Twiddling
2
+ module V7
3
+ module Tw7
4
+ # Parses a chord effect string into modifier_type, keycode,
5
+ # and optional string_keys.
6
+ #
7
+ # Accepts:
8
+ # "c" - plain key
9
+ # "@" - shifted symbol
10
+ # "ctrl+c" - modifier+key
11
+ # "cmd+shift+a" - multiple modifiers
12
+ # '"the "' - multi-char string (double-quoted)
13
+ # "speed_cycle" - device function name
14
+ module EffectParser
15
+ include ChordConstants
16
+
17
+ # Reverse lookups
18
+ KEY_TO_HID = HID_KEYS.to_h { |hid, name| [name, hid] }.freeze
19
+ SHIFTED_TO_BASE = SHIFTED_KEYS.to_h { |base, shifted| [shifted, base] }.freeze
20
+ FUNCTION_TO_CODE = DEVICE_FUNCTIONS.to_h { |code, name| [name.to_s, code] }.freeze
21
+ MODIFIER_NAME_TO_BIT = MODIFIERS.to_h { |bit, name| [name.downcase, bit] }.freeze
22
+ CHAR_TO_KEY = KEY_TO_CHAR.to_h { |name, char| [char, name] }.freeze
23
+
24
+ module_function
25
+
26
+ # Returns {modifier_type:, keycode:, string_keys:}
27
+ def parse(text)
28
+ text = text.strip
29
+ return parse_quoted(text) if text.start_with?('"') && text.end_with?('"')
30
+
31
+ classify_and_parse(text)
32
+ end
33
+
34
+ def parse_quoted(text)
35
+ inner = text[1..-2]
36
+ (inner.length == 1) ? parse_quoted_char(inner) : parse_string(inner)
37
+ end
38
+
39
+ def classify_and_parse(text)
40
+ if FUNCTION_TO_CODE.key?(text) then parse_device_function(text)
41
+ elsif SHIFTED_TO_BASE.key?(text) then parse_key(text)
42
+ elsif text.include?("+") then parse_modified_key(text)
43
+ elsif text.match?(/\A0x[0-9a-f]+\z/i) then parse_hex_key(text)
44
+ else
45
+ parse_key(text)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def parse_key(text)
52
+ if (base = SHIFTED_TO_BASE[text])
53
+ hid = KEY_TO_HID[base] || raise(ArgumentError, "Unknown key: #{text}")
54
+ {modifier_type: TYPE_KEYBOARD | (MODIFIER_SHIFT << 8), keycode: hid}
55
+ else
56
+ hid = KEY_TO_HID[text] || raise(ArgumentError, "Unknown key: #{text}")
57
+ {modifier_type: TYPE_KEYBOARD, keycode: hid}
58
+ end
59
+ end
60
+
61
+ def parse_modified_key(text)
62
+ parts = text.split("+")
63
+ key_name = parts.pop
64
+ mod_byte = parts.reduce(0) { |m, name|
65
+ bit = MODIFIER_NAME_TO_BIT[name.downcase]
66
+ raise ArgumentError, "Unknown modifier: #{name}" unless bit
67
+ m | bit
68
+ }
69
+ hid = KEY_TO_HID[key_name] || raise(ArgumentError, "Unknown key: #{key_name}")
70
+ {modifier_type: TYPE_KEYBOARD | (mod_byte << 8), keycode: hid}
71
+ end
72
+
73
+ # A single-char quoted string like "#" is a keyboard chord,
74
+ # not a multi-char string.
75
+ def parse_quoted_char(char)
76
+ sk = char_to_key(char)
77
+ {modifier_type: sk[:modifier], keycode: sk[:hid_code]}
78
+ end
79
+
80
+ def parse_hex_key(text)
81
+ {modifier_type: TYPE_KEYBOARD, keycode: Integer(text, 16)}
82
+ end
83
+
84
+ def parse_device_function(text)
85
+ code = FUNCTION_TO_CODE[text]
86
+ {modifier_type: TYPE_DEVICE | (code << 8), keycode: 0}
87
+ end
88
+
89
+ def parse_string(text)
90
+ keys = text.chars.map { |ch| char_to_key(ch) }
91
+ {modifier_type: TYPE_MULTICHAR, keycode: 0, string_keys: keys}
92
+ end
93
+
94
+ def char_to_key(char)
95
+ hid, shifted = resolve_char(char)
96
+ modifier = shifted ? (TYPE_KEYBOARD | (MODIFIER_SHIFT << 8)) : TYPE_KEYBOARD
97
+ {modifier: modifier, hid_code: hid}
98
+ end
99
+
100
+ def resolve_char(char)
101
+ hid, shifted = lookup_char(char)
102
+ raise ArgumentError, "Unknown character: #{char}" unless hid
103
+ [hid, shifted]
104
+ end
105
+
106
+ def lookup_char(char)
107
+ if (base = SHIFTED_TO_BASE[char]) then [KEY_TO_HID[base], true]
108
+ elsif char.match?(/[A-Z]/) then [KEY_TO_HID[char.downcase], true]
109
+ else
110
+ [KEY_TO_HID[CHAR_TO_KEY[char] || char], false]
111
+ end
112
+ end
113
+
114
+ module_function :parse_quoted, :classify_and_parse, :parse_key,
115
+ :parse_quoted_char, :parse_hex_key, :parse_modified_key,
116
+ :parse_device_function, :parse_string, :char_to_key,
117
+ :resolve_char, :lookup_char
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,133 @@
1
+ module Twiddling
2
+ module V7
3
+ module Tw7
4
+ # Parses a .tw7 text file into a V7::Config.
5
+ class Parser
6
+ DIVIDER = /\A={5,}\z/
7
+ MOUSEMODE_SCOPE = "[MOUSEMODE]"
8
+
9
+ def initialize(text, base: nil)
10
+ @text = text
11
+ @base = base || default_base
12
+ end
13
+
14
+ def parse
15
+ settings_lines, chord_lines = split_sections
16
+ attrs = build_attrs(settings_lines)
17
+ chords = parse_chords(chord_lines)
18
+ sorted = chords.sort_by(&:bitmask)
19
+ attrs[:chords] = sorted
20
+ attrs[:index_table] = Config.compute_index_table(sorted)
21
+ Config.new(attrs)
22
+ end
23
+
24
+ private
25
+
26
+ def default_base
27
+ Config.from_file(
28
+ File.expand_path("../data/default_base.cfg", __dir__)
29
+ )
30
+ end
31
+
32
+ def split_sections
33
+ lines = strip_comments(@text.lines.map(&:rstrip))
34
+ divider_idx = lines.index { |l| l.match?(DIVIDER) }
35
+ if divider_idx
36
+ [lines[0...divider_idx], lines[(divider_idx + 1)..]]
37
+ else
38
+ [[], lines]
39
+ end
40
+ end
41
+
42
+ def strip_comments(lines)
43
+ lines.map { |line| strip_line_comment(line) }
44
+ end
45
+
46
+ # Remove # comments. A # starts a comment unless it's
47
+ # inside double quotes.
48
+ def strip_line_comment(line)
49
+ in_quotes = false
50
+ line.each_char.with_index do |ch, i|
51
+ if ch == '"'
52
+ in_quotes = !in_quotes
53
+ elsif ch == "#" && !in_quotes
54
+ return line[0, i].rstrip
55
+ end
56
+ end
57
+ line
58
+ end
59
+
60
+ def build_attrs(settings_lines)
61
+ non_blank = settings_lines.reject(&:empty?)
62
+ parsed = SettingsParser.parse(non_blank)
63
+ settings = Settings.new(
64
+ thumb_modifiers: parsed.delete(:thumb_modifiers),
65
+ dedicated_buttons: parsed.delete(:dedicated_buttons),
66
+ reserved: @base.settings.reserved
67
+ )
68
+ base_attrs.merge(parsed).merge(settings: settings)
69
+ end
70
+
71
+ def base_attrs
72
+ Config::ATTR_NAMES.to_h { |name| [name, @base.public_send(name)] }
73
+ end
74
+
75
+ def parse_chords(lines)
76
+ chords = []
77
+ scope_bitmask = 0
78
+ mousemode = false
79
+
80
+ lines.each_with_index do |line, idx|
81
+ next if line.strip.empty?
82
+ parse_chord_line(line, idx, chords, scope_bitmask, mousemode)
83
+ .then { |new_scope, new_mouse| scope_bitmask, mousemode = new_scope, new_mouse }
84
+ end
85
+
86
+ chords
87
+ end
88
+
89
+ def parse_chord_line(line, idx, chords, scope_bitmask, mousemode)
90
+ indented = line.start_with?(" ", "\t")
91
+ content = line.strip
92
+
93
+ if content.end_with?("::")
94
+ open_scope(content, mousemode)
95
+ elsif indented
96
+ chords << build_chord(content, scope_bitmask, mousemode, idx)
97
+ [scope_bitmask, mousemode]
98
+ else
99
+ chords << build_chord(content, 0, false, idx)
100
+ [0, false]
101
+ end
102
+ end
103
+
104
+ def open_scope(content, _mousemode)
105
+ scope_text = content.chomp("::")
106
+ if scope_text == MOUSEMODE_SCOPE
107
+ [0, true]
108
+ else
109
+ [ButtonParser.parse(scope_text), false]
110
+ end
111
+ end
112
+
113
+ def build_chord(content, scope_bitmask, mousemode, line_idx)
114
+ buttons_text, effect_text = content.split(":", 2)
115
+ raise ArgumentError, "Line #{line_idx + 1}: missing effect" unless effect_text
116
+
117
+ bitmask = ButtonParser.parse(buttons_text) | scope_bitmask
118
+ bitmask |= ChordConstants::MOUSE_MODE_FLAG if mousemode
119
+ effect = EffectParser.parse(effect_text)
120
+
121
+ Chord.new(
122
+ bitmask: bitmask,
123
+ modifier_type: effect[:modifier_type],
124
+ keycode: effect[:keycode],
125
+ string_keys: effect[:string_keys]
126
+ )
127
+ rescue ArgumentError => e
128
+ raise ArgumentError, "Line #{line_idx + 1}: #{e.message}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,105 @@
1
+ module Twiddling
2
+ module V7
3
+ module Tw7
4
+ # Prints a V7::Config as .tw7 text format.
5
+ class Printer
6
+ MOUSEMODE_KEY = "[MOUSEMODE]"
7
+
8
+ def initialize(config, io: $stdout)
9
+ @config = config
10
+ @io = io
11
+ end
12
+
13
+ def print
14
+ settings_lines = SettingsFormatter.format(@config)
15
+ print_settings(settings_lines) if settings_lines.any?
16
+ print_chords
17
+ end
18
+
19
+ private
20
+
21
+ def print_settings(lines)
22
+ lines.each { |line| @io.puts line }
23
+ @io.puts "====="
24
+ end
25
+
26
+ def print_chords
27
+ groups = group_chords
28
+ groups.each_with_index do |(group_key, chords), idx|
29
+ @io.puts if idx > 0
30
+ print_group(group_key, chords)
31
+ end
32
+ end
33
+
34
+ def group_chords
35
+ grouped = {}
36
+ @config.chords.each do |chord|
37
+ key = group_key(chord)
38
+ (grouped[key] ||= []) << chord
39
+ end
40
+ sort_groups(grouped)
41
+ end
42
+
43
+ def group_key(chord)
44
+ if chord.mouse_mode?
45
+ MOUSEMODE_KEY
46
+ else
47
+ ButtonFormatter.thumb_key(chord.bitmask) || ""
48
+ end
49
+ end
50
+
51
+ # Sort: no-thumb first, then by thumb combo length, then
52
+ # alphabetically. MOUSEMODE sorts last.
53
+ def sort_groups(grouped)
54
+ grouped.sort_by { |key, _| sort_key(key) }
55
+ end
56
+
57
+ def sort_key(key)
58
+ case key
59
+ when "" then [0, 0, key]
60
+ when MOUSEMODE_KEY then [3, 0, key]
61
+ else [1, key.length, key]
62
+ end
63
+ end
64
+
65
+ def print_group(group_key, chords)
66
+ if group_key.empty?
67
+ chords.each { |chord| print_standalone(chord) }
68
+ elsif chords.length == 1
69
+ print_standalone(chords[0])
70
+ else
71
+ @io.puts "#{group_key}::"
72
+ chords.each { |chord| print_indented(chord, group_key) }
73
+ end
74
+ end
75
+
76
+ def print_standalone(chord)
77
+ buttons = format_all_buttons(chord)
78
+ @io.puts "#{buttons}: #{EffectFormatter.format_effect(chord)}"
79
+ end
80
+
81
+ def print_indented(chord, group_key)
82
+ buttons = format_nested_buttons(chord, group_key)
83
+ @io.puts " #{buttons}: #{EffectFormatter.format_effect(chord)}"
84
+ end
85
+
86
+ # Full button string for standalone chords, stripping mouse flag.
87
+ def format_all_buttons(chord)
88
+ ButtonFormatter.format(chord.bitmask & ~ChordConstants::MOUSE_MODE_FLAG)
89
+ end
90
+
91
+ # For indented chords, strip the group's contribution.
92
+ # Thumb groups: show only fingers.
93
+ # MOUSEMODE: show all buttons except the mouse flag.
94
+ def format_nested_buttons(chord, group_key)
95
+ clean = chord.bitmask & ~ChordConstants::MOUSE_MODE_FLAG
96
+ if group_key == MOUSEMODE_KEY
97
+ ButtonFormatter.format(clean)
98
+ else
99
+ ButtonFormatter.finger_part(clean)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end