charming 0.2.1 → 0.2.2

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/controller/key_dispatch.rb +113 -0
  5. data/lib/charming/controller/session_state.rb +11 -0
  6. data/lib/charming/controller/terminal.rb +33 -0
  7. data/lib/charming/controller.rb +12 -39
  8. data/lib/charming/escape.rb +81 -0
  9. data/lib/charming/events/mouse_event.rb +22 -9
  10. data/lib/charming/generators/app_generator.rb +3 -2
  11. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  12. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  13. data/lib/charming/image/protocol/kitty.rb +126 -0
  14. data/lib/charming/image/protocol.rb +18 -0
  15. data/lib/charming/image/source.rb +85 -0
  16. data/lib/charming/image/terminal.rb +52 -0
  17. data/lib/charming/image/transmit.rb +11 -0
  18. data/lib/charming/image.rb +21 -0
  19. data/lib/charming/internal/event_loop.rb +155 -0
  20. data/lib/charming/internal/terminal/adapter.rb +14 -0
  21. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  22. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  23. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  24. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  25. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  26. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  27. data/lib/charming/presentation/components/chart.rb +80 -0
  28. data/lib/charming/presentation/components/error_screen.rb +1 -1
  29. data/lib/charming/presentation/components/filepicker.rb +101 -0
  30. data/lib/charming/presentation/components/form/builder.rb +5 -0
  31. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  32. data/lib/charming/presentation/components/image.rb +38 -0
  33. data/lib/charming/presentation/components/list.rb +22 -4
  34. data/lib/charming/presentation/components/paginator.rb +54 -0
  35. data/lib/charming/presentation/components/progressbar.rb +26 -4
  36. data/lib/charming/presentation/components/sparkline.rb +38 -0
  37. data/lib/charming/presentation/components/spinner.rb +22 -3
  38. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  39. data/lib/charming/presentation/components/table.rb +42 -1
  40. data/lib/charming/presentation/components/text_area.rb +1 -2
  41. data/lib/charming/presentation/components/text_input.rb +9 -4
  42. data/lib/charming/presentation/components/time_display.rb +20 -0
  43. data/lib/charming/presentation/components/timer.rb +43 -0
  44. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  45. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  46. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  47. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  48. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  49. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  50. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  51. data/lib/charming/presentation/ui/background.rb +58 -0
  52. data/lib/charming/presentation/ui/border.rb +14 -1
  53. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  54. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  55. data/lib/charming/presentation/ui/canvas.rb +1 -0
  56. data/lib/charming/presentation/ui/gradient.rb +47 -0
  57. data/lib/charming/presentation/ui/style.rb +119 -19
  58. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  59. data/lib/charming/presentation/ui/truncate.rb +29 -0
  60. data/lib/charming/presentation/ui/width.rb +11 -0
  61. data/lib/charming/presentation/ui.rb +52 -11
  62. data/lib/charming/presentation/view.rb +8 -6
  63. data/lib/charming/response.rb +11 -6
  64. data/lib/charming/runtime.rb +154 -88
  65. data/lib/charming/version.rb +1 -1
  66. metadata +29 -3
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Charming
6
+ module Image
7
+ # Source owns an image's bytes, its stable id, and its transmit state — the "engine" half of the
8
+ # image feature, analogous to {Charming::Audio::Player}. Keep it in `session` (built once) rather
9
+ # than rebuilt per render, so {transmitted?} reliably gates the one-time transmission.
10
+ #
11
+ # It dispatches to the active {Protocol} (chosen by its {Terminal}) to build the out-of-band
12
+ # {Transmit} and the in-frame placeholder block, and is a no-op on terminals without graphics
13
+ # support, letting {Charming::Components::Image} fall back gracefully.
14
+ class Source
15
+ # *path* or *data* supplies the (PNG) bytes — exactly one is required. *id* overrides the
16
+ # derived image id. *terminal* is the protocol-detection seam (injectable for specs).
17
+ def initialize(path: nil, data: nil, id: nil, terminal: Terminal.new)
18
+ raise ArgumentError, "provide either path: or data:" unless path || data
19
+
20
+ @path = path
21
+ @data = data
22
+ @id = id
23
+ @terminal = terminal
24
+ @transmitted = false
25
+ end
26
+
27
+ # The protocol-detection seam, exposed so the component can ask {supports_graphics?}.
28
+ attr_reader :terminal
29
+
30
+ # The stable 32-bit image id (kept within 24 bits so the placeholder foreground colour fully
31
+ # encodes it). Derived from a digest of the bytes unless an explicit id was given.
32
+ def image_id
33
+ @image_id ||= @id || derive_id
34
+ end
35
+
36
+ # True when the host terminal supports a graphics protocol.
37
+ def supports_graphics?
38
+ @terminal.supports_graphics?
39
+ end
40
+
41
+ # Builds the out-of-band {Transmit} for a *rows*×*cols* placement, or nil when graphics are
42
+ # unsupported. The {Runtime} flushes it to the backend; {Charming::Escape.register} collects it.
43
+ def transmit(rows:, cols:)
44
+ return unless protocol
45
+
46
+ Transmit.new(image_id: image_id, payload: protocol.transmit(image_id: image_id, png_bytes: bytes, rows: rows, cols: cols))
47
+ end
48
+
49
+ # The in-frame placeholder block sized *rows*×*cols*, or "" when graphics are unsupported.
50
+ def placement(rows:, cols:)
51
+ return "" unless protocol
52
+
53
+ protocol.placeholder_block(image_id: image_id, rows: rows, cols: cols)
54
+ end
55
+
56
+ # True once {mark_transmitted} has recorded that the image was sent to the terminal.
57
+ def transmitted?
58
+ @transmitted
59
+ end
60
+
61
+ # Records that the image has been transmitted, so it is not re-sent on later renders.
62
+ def mark_transmitted
63
+ @transmitted = true
64
+ end
65
+
66
+ private
67
+
68
+ # The encoder module for the detected protocol, or nil when none applies.
69
+ def protocol
70
+ @protocol = Protocol.for(@terminal.protocol) unless defined?(@protocol)
71
+ @protocol
72
+ end
73
+
74
+ # The raw image bytes, read once from *path* (binary) or taken from *data*.
75
+ def bytes
76
+ @bytes ||= @data || File.binread(@path)
77
+ end
78
+
79
+ # A stable, nonzero 24-bit id derived from the image bytes.
80
+ def derive_id
81
+ (Digest::SHA256.digest(bytes).unpack1("N") % 0xFFFFFE) + 1
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Image
5
+ # Terminal detects which terminal graphics protocol the host emulator supports, from
6
+ # environment variables. It is the single detection seam: the rest of the image engine
7
+ # dispatches on {protocol}. Injectable (`env:`) so specs never depend on the real terminal,
8
+ # mirroring {Charming::Audio::System}.
9
+ #
10
+ # Phase 1 detects only the Kitty graphics protocol (Ghostty + Kitty). Other terminals report
11
+ # `:none`, letting {Charming::Components::Image} fall back gracefully. Future protocols
12
+ # (iTerm2, Sixel, half-block) slot in here without touching {Source} or the component.
13
+ class Terminal
14
+ # *env* is the environment hash probed for terminal identity (defaults to the process env).
15
+ def initialize(env: ENV)
16
+ @env = env
17
+ end
18
+
19
+ # The supported graphics protocol as a symbol: `:kitty` for Kitty/Ghostty, else `:none`.
20
+ def protocol
21
+ return :kitty if kitty?
22
+
23
+ :none
24
+ end
25
+
26
+ # True when a real graphics protocol is available (i.e. {protocol} is not `:none`).
27
+ def supports_graphics?
28
+ protocol != :none
29
+ end
30
+
31
+ private
32
+
33
+ # True when the host terminal speaks the Kitty graphics protocol (Kitty or Ghostty).
34
+ def kitty?
35
+ present?("KITTY_WINDOW_ID") ||
36
+ term.match?(/kitty|ghostty/i) ||
37
+ @env["TERM_PROGRAM"].to_s.casecmp?("ghostty") ||
38
+ present?("GHOSTTY_RESOURCES_DIR")
39
+ end
40
+
41
+ # The `TERM` value as a string (never nil).
42
+ def term
43
+ @env["TERM"].to_s
44
+ end
45
+
46
+ # True when *key* is set to a non-empty value in the environment.
47
+ def present?(key)
48
+ !@env[key].to_s.empty?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Image
5
+ # Transmit is a single out-of-band terminal graphics payload: the escape-sequence string that
6
+ # transmits an image (and creates its virtual placement) for a given *image_id*. It travels on
7
+ # {Response}#graphics and is written verbatim to the backend by the {Runtime}, bypassing the
8
+ # line-based frame pipeline. *image_id* is retained for deduplication and spec assertions.
9
+ Transmit = Data.define(:image_id, :payload)
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Image provides terminal image display. Unlike {Charming::Audio} (which spawns an out-of-band
5
+ # process and never touches the TTY), images must be written *into* the terminal — but the
6
+ # framework's renderer is line-based and width-measured, and would shred raw image escape
7
+ # sequences. So images use the Kitty graphics protocol's *Unicode placeholders*: the image bytes
8
+ # are transmitted once out-of-band (see {Charming::Image::Transmit} and the {Runtime}'s graphics
9
+ # flush), then placed by printing ordinary width-1 placeholder cells that ride the normal frame
10
+ # pipeline (see {Charming::Image::Protocol::Kitty}).
11
+ #
12
+ # The engine here ({Source}, {Terminal}, {Protocol}) mirrors {Charming::Audio}'s split; the view
13
+ # is {Charming::Components::Image}. Terminal support is detected via {Terminal}; Phase 1 targets
14
+ # Ghostty/Kitty and degrades to a fallback string elsewhere.
15
+ #
16
+ # Image transmissions ride the shared out-of-band {Charming::Escape} channel: {Transmit} responds
17
+ # to `#payload`, so the component registers it via `Charming::Escape.register` and the {Runtime}
18
+ # flushes it before the frame.
19
+ module Image
20
+ end
21
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ # EventLoop pumps events for a Charming runtime: it delivers the next due
6
+ # event — completed background tasks first, then due timers, then terminal
7
+ # input (with optional held-key coalescing) — to the block passed to {#run},
8
+ # until the block returns :quit, the interrupt check trips, or a test
9
+ # backend runs out of events. It knows nothing about controllers, routes,
10
+ # or rendering; deciding what an event *means* belongs to its caller.
11
+ class EventLoop
12
+ DEFAULT_READ_TIMEOUT = 0.05
13
+
14
+ # *timer_bindings* is the list of controller timer bindings (each
15
+ # responding to `name` and `interval`) to schedule. *interrupted* is a
16
+ # callable checked every iteration so a signal handler can stop the loop.
17
+ def initialize(backend:, clock:, task_queue:, timer_bindings: [], coalesce_input: false, interrupted: -> { false })
18
+ @backend = backend
19
+ @clock = clock
20
+ @task_queue = task_queue
21
+ @coalesce_input = coalesce_input
22
+ @interrupted = interrupted
23
+ @timers = build_timers(timer_bindings)
24
+ @pending_event = nil
25
+ end
26
+
27
+ # Pumps events, yielding each to the caller together with a flag saying
28
+ # whether another event is already due (letting the caller coalesce a
29
+ # burst into one repaint). Stops when the block returns :quit, when the
30
+ # interrupt check returns true, or when a test backend is exhausted.
31
+ # Closes the task queue on the way out so producer threads can't block on
32
+ # a loop that is no longer draining them.
33
+ def run
34
+ loop do
35
+ break if @interrupted.call
36
+
37
+ event = next_event
38
+ unless event
39
+ break if backend_exhausted?
40
+ next
41
+ end
42
+ break if yield(event, more_ready?) == :quit
43
+ end
44
+ ensure
45
+ @task_queue.close
46
+ end
47
+
48
+ # Replaces the scheduled timers — called when navigation lands on a
49
+ # controller with different timer bindings.
50
+ def reset_timers(timer_bindings)
51
+ @timers = build_timers(timer_bindings)
52
+ end
53
+
54
+ private
55
+
56
+ # The next due event in priority order: task results, then timers, then input.
57
+ def next_event
58
+ next_task_event || next_timer_event || next_input_event
59
+ end
60
+
61
+ # True when another task result is already queued. Only the task queue is
62
+ # considered: timer ticks drive animations and must each paint, and key
63
+ # floods are handled separately by input coalescing.
64
+ def more_ready?
65
+ !@task_queue.empty?
66
+ end
67
+
68
+ # Pops a task event from the thread-safe queue if one is available.
69
+ # Non-blocking — returns nil immediately when the queue is empty.
70
+ def next_task_event
71
+ @task_queue.pop(true)
72
+ rescue ThreadError
73
+ nil
74
+ end
75
+
76
+ # Returns a TimerEvent for the first due timer and advances its next fire
77
+ # time. Returns nil if no timers are ready or registered.
78
+ def next_timer_event
79
+ timer = due_timer
80
+ return unless timer
81
+
82
+ now = clock_now
83
+ timer[:next_at] = now + timer.fetch(:binding).interval
84
+ Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
85
+ end
86
+
87
+ # Reads the next input event, consuming a stashed event first, then
88
+ # collapsing any auto-repeat burst behind it.
89
+ def next_input_event
90
+ event = @pending_event
91
+ @pending_event = nil
92
+ event ||= @backend.read_event(timeout: read_timeout)
93
+ return event unless event && @coalesce_input
94
+
95
+ coalesce(event)
96
+ end
97
+
98
+ # Collapses a run of identical key events — the flood the terminal emits while
99
+ # a key is held down — into a single delivered event, so holding a key can't
100
+ # queue a backlog that keeps acting after release. The first non-matching event
101
+ # encountered is stashed for the next loop iteration, so distinct keys and
102
+ # non-key events (resize/paste/mouse) are never lost. Only KeyEvents are
103
+ # coalesced; everything else passes straight through.
104
+ def coalesce(event)
105
+ return event unless event.is_a?(Events::KeyEvent)
106
+
107
+ # Only read while input is *immediately* available, so the drain never blocks
108
+ # on an empty buffer (input_pending? is a true 0s check).
109
+ while @backend.input_pending?
110
+ nxt = @backend.read_event(timeout: 0)
111
+ break unless nxt
112
+ next if nxt == event # identical auto-repeat — discard the older one, keep draining
113
+
114
+ @pending_event = nxt
115
+ break
116
+ end
117
+ event
118
+ end
119
+
120
+ # Computes how long to block waiting for input based on when the next timer
121
+ # is due, clamped between 0 and DEFAULT_READ_TIMEOUT. Returns DEFAULT when
122
+ # no timers exist.
123
+ def read_timeout
124
+ return DEFAULT_READ_TIMEOUT if @timers.empty?
125
+
126
+ next_at = @timers.map { |timer| timer.fetch(:next_at) }.min
127
+ (next_at - clock_now).clamp(0, DEFAULT_READ_TIMEOUT)
128
+ end
129
+
130
+ # Returns the timer due at or before now with the earliest fire time.
131
+ def due_timer
132
+ now = clock_now
133
+ @timers.select { |timer| timer.fetch(:next_at) <= now }.min_by { |timer| timer.fetch(:next_at) }
134
+ end
135
+
136
+ # Builds timer states from bindings, scheduling each one interval from now.
137
+ def build_timers(timer_bindings)
138
+ now = clock_now
139
+ timer_bindings.map { |binding| {binding: binding, next_at: now + binding.interval} }
140
+ end
141
+
142
+ # True when the backend reports it has no more events to deliver (test
143
+ # backends only — the TTY backend never exhausts). Prevents the loop from
144
+ # spinning forever in tests that forget a trailing quit event.
145
+ def backend_exhausted?
146
+ @backend.respond_to?(:exhausted?) && @backend.exhausted?
147
+ end
148
+
149
+ # Returns current clock time.
150
+ def clock_now
151
+ @clock.call
152
+ end
153
+ end
154
+ end
155
+ end
@@ -26,6 +26,14 @@ module Charming
26
26
  raise NotImplementedError, "#{self.class} must implement #read_event"
27
27
  end
28
28
 
29
+ # True when at least one input event is immediately available to read without blocking.
30
+ # The runtime uses this to drain held-key auto-repeat without stalling on the final,
31
+ # empty read. Defaults to false so a backend that can't answer simply opts out of
32
+ # coalescing (the runtime then dispatches each event individually).
33
+ def input_pending?
34
+ false
35
+ end
36
+
29
37
  # Returns the current terminal dimensions as [width, height] in cells.
30
38
  def size
31
39
  raise NotImplementedError, "#{self.class} must implement #size"
@@ -72,6 +80,12 @@ module Charming
72
80
  def write_lines(line_changes, frame: nil)
73
81
  raise NotImplementedError, "#{self.class} must implement #write_lines"
74
82
  end
83
+
84
+ # Writes an out-of-band escape sequence (image transmission, clipboard write, notification,
85
+ # window title) straight to the terminal, bypassing the line-based frame pipeline. *sequence*
86
+ # responds to `payload` (the escape-sequence string). Defaults to a no-op so backends opt in.
87
+ def write_escape(sequence)
88
+ end
75
89
  end
76
90
  end
77
91
  end
@@ -17,9 +17,17 @@ module Charming
17
17
  end
18
18
 
19
19
  # Converts a raw *keypress* string into a KeyEvent. Returns nil when *keypress* is nil.
20
+ # Modified CSI sequences (shift/alt/ctrl arrows, function keys) and ESC-prefixed alt
21
+ # chords are decoded first; everything else goes through tty-reader's key table.
20
22
  def normalize(keypress)
21
23
  return nil unless keypress
22
24
 
25
+ modified = ModifiedKeyParser.parse(keypress)
26
+ return modified if modified
27
+
28
+ alt = alt_prefixed_event(keypress)
29
+ return alt if alt
30
+
23
31
  key_name = @reader.console.keys[keypress]
24
32
  return character_event(keypress) unless key_name
25
33
 
@@ -28,6 +36,18 @@ module Charming
28
36
 
29
37
  private
30
38
 
39
+ # Decodes "\e<key>" (Alt held while pressing <key>) by normalizing the inner
40
+ # key and flagging alt. Bare escapes and CSI/SS3 sequences are left alone.
41
+ def alt_prefixed_event(keypress)
42
+ return nil unless keypress.start_with?("\e") && keypress.length >= 2
43
+ return nil if ["[", "O", "\e"].include?(keypress[1])
44
+
45
+ inner = normalize(keypress[1..])
46
+ return nil unless inner
47
+
48
+ Events::KeyEvent.new(key: inner.key, char: inner.char, ctrl: inner.ctrl, alt: true, shift: inner.shift)
49
+ end
50
+
31
51
  # Builds a KeyEvent for a raw character keypress (no semantic name was matched).
32
52
  def character_event(keypress)
33
53
  Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
@@ -16,6 +16,9 @@ module Charming
16
16
  # The array of recorded operation tuples: [:method_name, *args].
17
17
  attr_reader :operations
18
18
 
19
+ # The array of out-of-band escape sequences written via `write_escape`.
20
+ attr_reader :escapes
21
+
19
22
  # *events* is the queue of pre-seeded events to return from `read_event`.
20
23
  # *width*/*height* set the initial terminal dimensions reported by `size`.
21
24
  def initialize(events: [], width: 80, height: 24)
@@ -24,6 +27,7 @@ module Charming
24
27
  @height = height
25
28
  @frames = []
26
29
  @operations = []
30
+ @escapes = []
27
31
  @mouse_enabled = false
28
32
  end
29
33
 
@@ -39,6 +43,12 @@ module Charming
39
43
  @events.empty?
40
44
  end
41
45
 
46
+ # True when a pre-seeded event is still queued (mirrors a TTY with bytes ready). Lets
47
+ # the runtime's held-key coalescing drain seeded repeats in specs.
48
+ def input_pending?
49
+ !@events.empty?
50
+ end
51
+
42
52
  # Stores *frame* as the current frame and appends it to `frames`.
43
53
  def write_frame(frame)
44
54
  @current_frame = frame
@@ -55,6 +65,13 @@ module Charming
55
65
  @operations << [:write_lines, line_changes]
56
66
  end
57
67
 
68
+ # Records an out-of-band escape sequence, appending it to `escapes` and recording the
69
+ # operation (so specs can assert both its content and its ordering relative to frames).
70
+ def write_escape(sequence)
71
+ @escapes << sequence
72
+ @operations << [:write_escape, sequence]
73
+ end
74
+
58
75
  # Records an enter-alt-screen operation.
59
76
  def enter_alt_screen
60
77
  @operations << :enter_alt_screen
@@ -90,10 +107,11 @@ module Charming
90
107
  [@width, @height]
91
108
  end
92
109
 
93
- # Marks the backend as having mouse tracking enabled and records the operation.
94
- def enable_mouse_tracking
110
+ # Marks the backend as having mouse tracking enabled and records the operation
111
+ # with the requested motion mode (:drag or :all).
112
+ def enable_mouse_tracking(motion: :drag)
95
113
  @mouse_enabled = true
96
- @operations << :enable_mouse_tracking
114
+ @operations << [:enable_mouse_tracking, motion]
97
115
  end
98
116
 
99
117
  # Marks the backend as having mouse tracking disabled and records the operation.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # ModifiedKeyParser decodes xterm CSI sequences that carry a modifier
7
+ # parameter — "\e[1;2A" (shift+up), "\e[3;5~" (ctrl+delete) — which
8
+ # tty-reader's key table does not cover. The modifier parameter encodes
9
+ # 1 + (1 shift | 2 alt | 4 ctrl | 8 meta); meta is treated as alt.
10
+ class ModifiedKeyParser
11
+ # "\e[1;<mod><final>" — arrows, home/end, and the SS3-style F1-F4 finals.
12
+ LETTER_PATTERN = /\A\e\[1;(\d+)([A-DFHPQRS])\z/
13
+
14
+ # "\e[<code>;<mod>~" — insert/delete, page keys, home/end, F5-F12.
15
+ TILDE_PATTERN = /\A\e\[(\d+);(\d+)~\z/
16
+
17
+ LETTER_KEYS = {
18
+ "A" => :up, "B" => :down, "C" => :right, "D" => :left,
19
+ "F" => :end, "H" => :home,
20
+ "P" => :f1, "Q" => :f2, "R" => :f3, "S" => :f4
21
+ }.freeze
22
+
23
+ TILDE_KEYS = {
24
+ 1 => :home, 2 => :insert, 3 => :delete, 4 => :end,
25
+ 5 => :page_up, 6 => :page_down, 7 => :home, 8 => :end,
26
+ 15 => :f5, 17 => :f6, 18 => :f7, 19 => :f8,
27
+ 20 => :f9, 21 => :f10, 23 => :f11, 24 => :f12
28
+ }.freeze
29
+
30
+ SHIFT_BIT = 1
31
+ ALT_BIT = 2
32
+ CTRL_BIT = 4
33
+ META_BIT = 8
34
+
35
+ # Parses *raw* into a KeyEvent with modifier flags, or nil when it is not
36
+ # a modified CSI sequence.
37
+ def self.parse(raw)
38
+ return nil unless raw.is_a?(String)
39
+
40
+ if (match = raw.match(LETTER_PATTERN))
41
+ build_event(LETTER_KEYS[match[2]], match[1].to_i)
42
+ elsif (match = raw.match(TILDE_PATTERN))
43
+ build_event(TILDE_KEYS[match[1].to_i], match[2].to_i)
44
+ end
45
+ end
46
+
47
+ def self.build_event(key, modifier_param)
48
+ return nil unless key
49
+
50
+ bits = modifier_param - 1
51
+ Events::KeyEvent.new(
52
+ key: key,
53
+ shift: bits.anybits?(SHIFT_BIT),
54
+ alt: bits.anybits?(ALT_BIT | META_BIT),
55
+ ctrl: bits.anybits?(CTRL_BIT)
56
+ )
57
+ end
58
+
59
+ private_class_method :build_event
60
+ end
61
+ end
62
+ end
63
+ end
@@ -5,21 +5,23 @@ module Charming
5
5
  module Terminal
6
6
  # MouseParser parses raw terminal escape sequences into MouseEvent objects.
7
7
  # Supports both modern SGR sequences (the most common, used by current terminals)
8
- # and the older 3-byte legacy sequences. The public API is class methods; no
9
- # instance state is required.
8
+ # and the older 3-byte legacy sequences. Both encodings pack modifiers and motion
9
+ # into the button code: bit 2 shift, bit 3 alt/meta, bit 4 ctrl, bit 5 motion.
10
+ # SGR signals release with a final "m" instead of "M". The public API is class
11
+ # methods; no instance state is required.
10
12
  class MouseParser
11
- # Matches an SGR-encoded mouse sequence: "\e[<button;col;row[mode]M"
12
- SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
13
+ # Matches an SGR-encoded mouse sequence: "\e[<button;col;row" + "M" (press) or "m" (release).
14
+ SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)(M|m)/
13
15
 
14
16
  # Matches the legacy 3-byte mouse sequence: "\e[M" followed by 3 bytes.
15
17
  LEGACY_PATTERN = /\e\[M(.{3})/
16
18
 
17
- # Maps raw button codes to semantic symbols used by MouseEvent#button_name.
18
- BUTTON_MAP = {
19
- 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
20
- 64 => :scroll_up, 65 => :scroll_down,
21
- 66 => :scroll_up, 67 => :scroll_down
22
- }.freeze
19
+ # Button-code bits for modifier keys and motion (shared by SGR and legacy).
20
+ SHIFT_BIT = 4
21
+ ALT_BIT = 8
22
+ CTRL_BIT = 16
23
+ MOTION_BIT = 32
24
+ FLAG_BITS = SHIFT_BIT | ALT_BIT | CTRL_BIT | MOTION_BIT
23
25
 
24
26
  # Returns true when *raw* looks like a recognizable mouse sequence (SGR or legacy).
25
27
  # Lets the TTYBackend short-circuit and dispatch to MouseParser without allocation.
@@ -41,23 +43,13 @@ module Charming
41
43
  nil
42
44
  end
43
45
 
44
- # Parses an SGR-format mouse sequence. Decodes button code, 1-based (col, row),
45
- # the modifier "C" (ctrl) and "M" (shift) suffix, and the highlight alt (256-color)
46
- # sequence as a heuristic for the alt modifier.
46
+ # Parses an SGR-format mouse sequence: button code with flag bits, 1-based
47
+ # (col, row), and the press/release final byte.
47
48
  def self.parse_sgr(raw)
48
49
  match = raw.match(SGR_PATTERN)
49
50
  return nil unless match
50
51
 
51
- button_code = match[1].to_i
52
- col = match[2].to_i - 1
53
- row = match[3].to_i - 1
54
- mode = match[4]
55
-
56
- ctrl = mode == "C"
57
- alt = raw.include?("\e[38;5;")
58
- shift = mode == "M"
59
-
60
- Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
52
+ build_event(match[1].to_i, match[2].to_i - 1, match[3].to_i - 1, release: match[4] == "m")
61
53
  end
62
54
 
63
55
  # Parses a legacy 3-byte mouse sequence. Each of the 3 bytes has 32 subtracted
@@ -69,12 +61,25 @@ module Charming
69
61
  bytes = match[1].bytes
70
62
  return nil unless bytes.length == 3
71
63
 
72
- button_code = bytes[0] - 32
73
- col = bytes[1] - 32
74
- row = bytes[2] - 32
64
+ build_event(bytes[0] - 32, bytes[1] - 32, bytes[2] - 32, release: false)
65
+ end
75
66
 
76
- Events::MouseEvent.new(button: button_code, x: col, y: row)
67
+ # Builds a MouseEvent from a raw button *code*, splitting out the modifier
68
+ # and motion flag bits so `button` carries only the button/wheel identity.
69
+ def self.build_event(code, x, y, release:)
70
+ Events::MouseEvent.new(
71
+ button: code & ~FLAG_BITS,
72
+ x: x,
73
+ y: y,
74
+ shift: code.anybits?(SHIFT_BIT),
75
+ alt: code.anybits?(ALT_BIT),
76
+ ctrl: code.anybits?(CTRL_BIT),
77
+ motion: code.anybits?(MOTION_BIT),
78
+ release: release
79
+ )
77
80
  end
81
+
82
+ private_class_method :build_event
78
83
  end
79
84
  end
80
85
  end