charming 0.2.0 → 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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
@@ -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
@@ -22,6 +22,19 @@ module Charming
22
22
  AUTO_WRAP_OFF = "\e[?7l"
23
23
  AUTO_WRAP_ON = "\e[?7h"
24
24
 
25
+ # Escape sequences for enabling/disabling bracketed-paste mode, and the markers
26
+ # the terminal wraps around pasted text.
27
+ BRACKETED_PASTE_ON = "\e[?2004h"
28
+ BRACKETED_PASTE_OFF = "\e[?2004l"
29
+ PASTE_START = "\e[200~"
30
+ PASTE_END = "\e[201~"
31
+
32
+ # Escape sequences for terminal focus reporting and the focus-in/out markers.
33
+ FOCUS_REPORTING_ON = "\e[?1004h"
34
+ FOCUS_REPORTING_OFF = "\e[?1004l"
35
+ FOCUS_IN = "\e[I"
36
+ FOCUS_OUT = "\e[O"
37
+
25
38
  # *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
26
39
  # tests can inject IO objects. *reader* is a TTY::Reader instance (created from
27
40
  # *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
@@ -37,13 +50,17 @@ module Charming
37
50
  end
38
51
 
39
52
  # Reads the next event. If a SIGWINCH was received, returns a ResizeEvent with the
40
- # current terminal dimensions. Mouse escape sequences are parsed by MouseParser;
41
- # other input is normalized via KeyNormalizer. Returns nil on timeout.
53
+ # current terminal dimensions. Bracketed pastes return a PasteEvent; mouse escape
54
+ # sequences are parsed by MouseParser; other input is normalized via KeyNormalizer.
55
+ # Returns nil on timeout.
42
56
  def read_event(timeout: nil)
43
57
  return resize_event if resized?
44
58
 
45
59
  raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
46
60
  return nil unless raw
61
+ return Events::FocusEvent.new(focused: true) if raw == FOCUS_IN
62
+ return Events::FocusEvent.new(focused: false) if raw == FOCUS_OUT
63
+ return paste_event(raw) if raw.start_with?(PASTE_START)
47
64
  return MouseParser.parse(raw) if MouseParser.sequence?(raw)
48
65
 
49
66
  @key_normalizer.normalize(raw)
@@ -51,6 +68,87 @@ module Charming
51
68
  nil
52
69
  end
53
70
 
71
+ # True when stdin has bytes ready to read right now — a genuine nonblocking check (0s
72
+ # wait), unlike read_event whose tty-reader nonblock path waits up to 0.1s. Lets the
73
+ # runtime drain buffered key auto-repeat and stop the instant the buffer empties.
74
+ def input_pending?
75
+ resized? || !@input.wait_readable(0).nil?
76
+ end
77
+
78
+ # Emits the ANSI sequence enabling terminal focus reporting. Idempotent.
79
+ def enable_focus_reporting
80
+ return if @focus_reporting
81
+
82
+ write_control(FOCUS_REPORTING_ON)
83
+ @focus_reporting = true
84
+ end
85
+
86
+ # Emits the ANSI sequence disabling terminal focus reporting. Idempotent.
87
+ def disable_focus_reporting
88
+ return unless @focus_reporting
89
+
90
+ write_control(FOCUS_REPORTING_OFF)
91
+ @focus_reporting = false
92
+ end
93
+
94
+ # Emits the ANSI sequence enabling bracketed-paste mode. Idempotent.
95
+ def enable_bracketed_paste
96
+ return if @bracketed_paste
97
+
98
+ write_control(BRACKETED_PASTE_ON)
99
+ @bracketed_paste = true
100
+ end
101
+
102
+ # Emits the ANSI sequence disabling bracketed-paste mode. Idempotent.
103
+ def disable_bracketed_paste
104
+ return unless @bracketed_paste
105
+
106
+ write_control(BRACKETED_PASTE_OFF)
107
+ @bracketed_paste = false
108
+ end
109
+
110
+ # Returns the terminal to its normal state for a shell suspend (Ctrl+Z):
111
+ # reporting modes off, cursor visible, primary screen, cooked input.
112
+ def suspend
113
+ @mouse_was_enabled = @mouse_enabled
114
+ disable_mouse_tracking
115
+ disable_bracketed_paste
116
+ disable_focus_reporting
117
+ show_cursor
118
+ leave_alt_screen
119
+ @input.cooked! if @input.respond_to?(:cooked!)
120
+ end
121
+
122
+ # Re-enters the TUI after a resume (SIGCONT): raw/no-echo input, alt
123
+ # screen, hidden cursor, and the reporting modes that were active before
124
+ # the suspend. The caller is responsible for triggering a repaint.
125
+ def resume
126
+ @input.raw! if @input.respond_to?(:raw!)
127
+ @input.echo = false if @input.respond_to?(:echo=)
128
+ enter_alt_screen
129
+ hide_cursor
130
+ enable_mouse_tracking(motion: @mouse_motion || :drag) if @mouse_was_enabled
131
+ enable_bracketed_paste
132
+ enable_focus_reporting
133
+ clear
134
+ end
135
+
136
+ # The OSC 11 background-color query and the terminators a reply may end with.
137
+ BACKGROUND_QUERY = "\e]11;?\e\\"
138
+ OSC_TERMINATORS = ["\a", "\e\\"].freeze
139
+
140
+ # Queries the terminal's background color via OSC 11 and classifies the
141
+ # reply as :dark or :light. Returns nil when the input cannot be polled,
142
+ # the terminal stays silent past *timeout*, or the reply is unparseable.
143
+ # Call before the event loop starts — a reply arriving later would be
144
+ # read as keyboard input.
145
+ def query_background_color(timeout: 0.15)
146
+ return nil unless @input.respond_to?(:wait_readable)
147
+
148
+ write_control(BACKGROUND_QUERY)
149
+ UI::Background.parse_osc11(read_reply(timeout))
150
+ end
151
+
54
152
  # Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
55
153
  # single keypress in raw mode is not enough: keys pressed while rendering or dispatching
56
154
  # events can otherwise be echoed into the alternate screen before the next read.
@@ -93,13 +191,15 @@ module Charming
93
191
 
94
192
  # Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
95
193
  # Idempotent: skipped when mouse tracking is already enabled.
96
- def enable_mouse_tracking
194
+ def enable_mouse_tracking(motion: :drag)
97
195
  return if @mouse_enabled
98
196
 
99
197
  write_control("\e[?1000h")
100
198
  write_control("\e[?1002h")
199
+ write_control("\e[?1003h") if motion == :all
101
200
  write_control("\e[?1006h")
102
201
  @mouse_enabled = true
202
+ @mouse_motion = motion
103
203
  end
104
204
 
105
205
  # Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
@@ -108,7 +208,7 @@ module Charming
108
208
 
109
209
  write_control("\e[?1000l")
110
210
  write_control("\e[?1002l")
111
- write_control("\e[?1003l")
211
+ write_control("\e[?1003l") if @mouse_motion == :all
112
212
  write_control("\e[?1006l")
113
213
  @mouse_enabled = false
114
214
  end
@@ -138,6 +238,12 @@ module Charming
138
238
  end
139
239
  end
140
240
 
241
+ # Writes an out-of-band escape *sequence* (image transmission, clipboard, notification, title)
242
+ # raw — no cursor positioning, line-clearing, or auto-wrap handling that would corrupt it.
243
+ def write_escape(sequence)
244
+ write_control(sequence.payload)
245
+ end
246
+
141
247
  # Enters the alternate screen buffer.
142
248
  def enter_alt_screen
143
249
  write_control(ALT_SCREEN_ON)
@@ -173,6 +279,26 @@ module Charming
173
279
 
174
280
  private
175
281
 
282
+ # Accumulates an OSC reply from the input until a terminator arrives or
283
+ # *timeout* elapses. Returns whatever was read (possibly "").
284
+ def read_reply(timeout)
285
+ reply = +""
286
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
287
+ while (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) > 0
288
+ break unless @input.wait_readable(remaining)
289
+ break if append_reply_chunk(reply).nil?
290
+ break if OSC_TERMINATORS.any? { |terminator| reply.include?(terminator) }
291
+ end
292
+ reply
293
+ end
294
+
295
+ # Reads one available chunk into *reply*; nil at EOF or read error.
296
+ def append_reply_chunk(reply)
297
+ reply << @input.readpartial(64)
298
+ rescue IOError, SystemCallError
299
+ nil
300
+ end
301
+
176
302
  # True when the SIGWINCH flag has been set since the last read_event.
177
303
  def resized?
178
304
  @resized
@@ -185,6 +311,19 @@ module Charming
185
311
  Events::ResizeEvent.new(width: width, height: height)
186
312
  end
187
313
 
314
+ # Builds a PasteEvent from a bracketed-paste chunk, reading further keypresses
315
+ # until the paste-end marker arrives (large pastes can span multiple reads).
316
+ def paste_event(raw)
317
+ buffer = raw.delete_prefix(PASTE_START)
318
+ until buffer.include?(PASTE_END)
319
+ chunk = @reader.read_keypress(echo: false, raw: true, nonblock: 0.2)
320
+ break unless chunk
321
+
322
+ buffer << chunk
323
+ end
324
+ Events::PasteEvent.new(text: buffer.sub(/#{Regexp.escape(PASTE_END)}.*\z/mo, ""))
325
+ end
326
+
188
327
  # Writes a raw escape *sequence* to the output stream and flushes.
189
328
  def write_control(sequence)
190
329
  @output.write(sequence)
@@ -4,5 +4,12 @@ module Charming
4
4
  # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
5
5
  # helper methods (text, box, row, column, etc.), and rendering via render.
6
6
  class Component < View
7
+ # True for components that accept free-typed text (TextInput, TextArea, Form, …).
8
+ # While such a component is focused, the controller routes printable characters to
9
+ # it BEFORE global/content key bindings — so typing "q" or "?" into a field inserts
10
+ # the character instead of triggering an app shortcut.
11
+ def captures_text?
12
+ false
13
+ end
7
14
  end
8
15
  end
@@ -154,7 +154,7 @@ module Charming
154
154
  end
155
155
 
156
156
  def widest_ellipsis_width
157
- @widest_ellipsis_width ||= ELLIPSIS_FRAMES.map { |frame| UI::Width.measure(frame) }.max
157
+ @widest_ellipsis_width ||= UI::Width.widest(ELLIPSIS_FRAMES)
158
158
  end
159
159
 
160
160
  # Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
@@ -162,24 +162,7 @@ module Charming
162
162
  def color_at(position)
163
163
  return gradient.first unless indicator_width > 1
164
164
 
165
- blend(gradient.first, gradient.last, position / (indicator_width - 1).to_f)
166
- end
167
-
168
- # Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
169
- # Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
170
- def blend(start_hex, end_hex, amount)
171
- start_rgb = rgb(start_hex)
172
- end_rgb = rgb(end_hex)
173
- mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
174
- "#%02x%02x%02x" % mixed
175
- end
176
-
177
- # Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
178
- def rgb(hex)
179
- value = hex.to_s.delete_prefix("#")
180
- raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
181
-
182
- [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
165
+ UI::Gradient.blend(gradient.first, gradient.last, position / (indicator_width - 1).to_f)
183
166
  end
184
167
 
185
168
  # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Audio is a one-line playback-status indicator for a {Charming::Audio::Player}. It
6
+ # reads the player's `playing?` state and renders a play/stop glyph with an optional
7
+ # *label*; pair it with a controller timer (or `on_task` re-render) to keep it live.
8
+ #
9
+ # Note: this view component (`Charming::Components::Audio`) is distinct from the
10
+ # playback engine namespace (`Charming::Audio`) — the component only displays state,
11
+ # the engine spawns the sound.
12
+ class Audio < Component
13
+ # *player* is the {Charming::Audio::Player} whose state is shown. *label* is an
14
+ # optional suffix (e.g. the track name) appended after the glyph. *theme* is the
15
+ # active theme, forwarded to the view layer.
16
+ def initialize(player:, label: nil, theme: nil)
17
+ super(theme: theme)
18
+ @player = player
19
+ @label = label
20
+ end
21
+
22
+ # Renders `▶`/`■` for playing/stopped, followed by the label when present.
23
+ def render
24
+ glyph = @player.playing? ? "▶" : "■"
25
+ return glyph unless @label
26
+
27
+ "#{glyph} #{@label}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Autocomplete is a combobox: a TextInput with a suggestion list beneath it,
6
+ # filtered live against the typed value. Up/down move through suggestions,
7
+ # Enter submits the highlighted suggestion (or the free text when nothing
8
+ # matches), Escape cancels.
9
+ #
10
+ # Autocomplete.new(suggestions: ["ruby", "rails", "rspec"], value: "r")
11
+ #
12
+ # `handle_key` returns `[:submitted, value]` on Enter, `:cancelled` on Escape,
13
+ # `:handled` for consumed keys, nil otherwise.
14
+ class Autocomplete < Component
15
+ DEFAULT_MAX_SUGGESTIONS = 6
16
+
17
+ # The current typed value and the highlighted suggestion index.
18
+ attr_reader :selected_index
19
+
20
+ # *suggestions* is the full candidate list. *value*/*cursor* seed the inner
21
+ # TextInput. *max_suggestions* caps the visible dropdown rows.
22
+ def initialize(suggestions:, value: "", cursor: nil, placeholder: "", selected_index: 0,
23
+ max_suggestions: DEFAULT_MAX_SUGGESTIONS, theme: nil)
24
+ super(theme: theme)
25
+ @suggestions = Array(suggestions).map(&:to_s)
26
+ @input = TextInput.new(value: value, cursor: cursor, placeholder: placeholder)
27
+ @selected_index = selected_index
28
+ @max_suggestions = max_suggestions
29
+ clamp_selection
30
+ end
31
+
32
+ # The typed text.
33
+ def value
34
+ @input.value
35
+ end
36
+
37
+ # The inner input's cursor offset.
38
+ def cursor
39
+ @input.cursor
40
+ end
41
+
42
+ # The suggestions matching the current value (case-insensitive substring),
43
+ # capped at max_suggestions. All suggestions when the value is empty.
44
+ def filtered_suggestions
45
+ query = value.downcase
46
+ matches = query.empty? ? @suggestions : @suggestions.select { |s| s.downcase.include?(query) }
47
+ matches.first(@max_suggestions)
48
+ end
49
+
50
+ # Free-typed characters belong to this component while it is focused.
51
+ def captures_text?
52
+ true
53
+ end
54
+
55
+ # Enter submits, Escape cancels, up/down move the highlight, everything else
56
+ # edits the text (resetting the highlight).
57
+ def handle_key(event)
58
+ case Charming.key_of(event)
59
+ when :escape then :cancelled
60
+ when :enter then [:submitted, submission_value]
61
+ when :up then move_selection(-1)
62
+ when :down then move_selection(+1)
63
+ else
64
+ result = @input.handle_key(event)
65
+ clamp_selection if result
66
+ result
67
+ end
68
+ end
69
+
70
+ # Renders the input row followed by the suggestion dropdown.
71
+ def render
72
+ [input_line, *suggestion_lines].join("\n")
73
+ end
74
+
75
+ private
76
+
77
+ # The highlighted suggestion, or the raw text when none match.
78
+ def submission_value
79
+ filtered_suggestions[selected_index] || value
80
+ end
81
+
82
+ def input_line
83
+ @input.render
84
+ end
85
+
86
+ # One row per suggestion; the highlighted row in the selected style.
87
+ def suggestion_lines
88
+ filtered_suggestions.each_with_index.map do |suggestion, index|
89
+ line = " #{suggestion}"
90
+ (index == selected_index) ? theme.selected.render(line) : theme.muted.render(line)
91
+ end
92
+ end
93
+
94
+ def move_selection(delta)
95
+ count = filtered_suggestions.length
96
+ return :handled if count.zero?
97
+
98
+ @selected_index = (selected_index + delta).clamp(0, count - 1)
99
+ :handled
100
+ end
101
+
102
+ def clamp_selection
103
+ max = [filtered_suggestions.length - 1, 0].max
104
+ @selected_index = selected_index.clamp(0, max)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Badge is a small inline label rendered as a styled pill — useful for versions,
6
+ # counts, and statuses inside status bars, lists, and headers.
7
+ #
8
+ # Badge.new("v1.2").render # themed default
9
+ # Badge.new("3 errors", style: theme.warn).render
10
+ class Badge < Component
11
+ # *label* is the badge text. *style* overrides the default themed style.
12
+ def initialize(label, style: nil, theme: nil)
13
+ super(theme: theme)
14
+ @label = label.to_s
15
+ @badge_style = style
16
+ end
17
+
18
+ # Renders the pill: a space-padded label with the badge style applied.
19
+ def render
20
+ resolved_style.render(" #{@label} ")
21
+ end
22
+
23
+ private
24
+
25
+ # The user style or the theme's selected style (which guarantees contrast).
26
+ def resolved_style
27
+ @badge_style || theme.selected
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Breadcrumbs renders a navigation trail like `Home › Projects › My App`, with the
6
+ # final (current) item highlighted and ancestors muted.
7
+ class Breadcrumbs < Component
8
+ DEFAULT_SEPARATOR = " › "
9
+
10
+ # *items* is the trail (strings or anything responding to to_s), first-to-last.
11
+ # *separator* joins the items.
12
+ def initialize(items:, separator: DEFAULT_SEPARATOR, theme: nil)
13
+ super(theme: theme)
14
+ @items = Array(items).map(&:to_s)
15
+ @separator = separator
16
+ end
17
+
18
+ # Renders the trail; ancestors muted, current item in the title style.
19
+ def render
20
+ return "" if @items.empty?
21
+
22
+ *ancestors, current = @items
23
+ parts = ancestors.map { |item| theme.muted.render(item) }
24
+ parts << theme.title.render(current)
25
+ parts.join(theme.muted.render(@separator))
26
+ end
27
+ end
28
+ end
29
+ end