charming 0.1.0 → 0.1.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -7,63 +7,78 @@ require "tty-screen"
7
7
  module Charming
8
8
  module Internal
9
9
  module Terminal
10
+ # TTYBackend is the production terminal backend. It reads key and mouse events from
11
+ # a TTY::Reader, normalizes them via KeyNormalizer and MouseParser, and writes output
12
+ # frames using TTY::Cursor and TTY::Screen. It also installs SIGWINCH and SIGINFO
13
+ # handlers so the runtime can react to terminal resize and focus changes.
10
14
  class TTYBackend
11
15
  include Adapter
12
16
 
17
+ # Escape sequences for entering/leaving the alternate screen buffer.
13
18
  ALT_SCREEN_ON = "\e[?1049h"
14
19
  ALT_SCREEN_OFF = "\e[?1049l"
15
- CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
16
- MOUSE_SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
17
- MOUSE_LEGACY_PATTERN = /\e\[M(.{3})/
18
- MOUSE_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
23
20
 
21
+ # Escape sequences for disabling/enabling automatic line wrapping during frame writes.
22
+ AUTO_WRAP_OFF = "\e[?7l"
23
+ AUTO_WRAP_ON = "\e[?7h"
24
+
25
+ # *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
26
+ # tests can inject IO objects. *reader* is a TTY::Reader instance (created from
27
+ # *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
24
28
  def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor)
25
29
  @input = input
26
30
  @output = output
27
31
  @reader = reader || TTY::Reader.new(input: input, output: output)
28
32
  @cursor = cursor
33
+ @key_normalizer = KeyNormalizer.new(@reader)
29
34
  @resized = false
30
35
  @previous_winch_handler = nil
31
36
  @mouse_enabled = false
32
37
  end
33
38
 
39
+ # 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.
34
42
  def read_event(timeout: nil)
35
43
  return resize_event if resized?
36
44
 
37
45
  raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
38
46
  return nil unless raw
47
+ return MouseParser.parse(raw) if MouseParser.sequence?(raw)
39
48
 
40
- return mouse_event(raw) if mouse_sequence?(raw)
41
-
42
- normalize_keypress(raw)
49
+ @key_normalizer.normalize(raw)
43
50
  rescue Errno::EAGAIN, IO::WaitReadable
44
51
  nil
45
52
  end
46
53
 
54
+ # Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
55
+ # the previous handler so it can be restored on teardown.
47
56
  def install_resize_handler
48
57
  @previous_winch_handler = Signal.trap("WINCH") { @resized = true }
49
58
  end
50
59
 
60
+ # Installs a SIGINFO handler that marks the terminal as having received focus.
61
+ # SIGINFO is sent by some terminals (notably macOS Terminal.app) on focus changes.
51
62
  def install_focus_handler
52
63
  # Terminal focus change: some terminals send a special sequence
53
64
  # when focus changes. We use this to throttle rendering.
54
65
  @previous_focus_handler = Signal.trap("INFO") { @focused = true }
55
66
  end
56
67
 
68
+ # Restores the previous SIGINFO handler.
57
69
  def restore_focus_handler
58
70
  Signal.trap("INFO", @previous_focus_handler) if @previous_focus_handler
59
71
  @previous_focus_handler = nil
60
72
  end
61
73
 
74
+ # Restores the previous SIGWINCH handler captured by `install_resize_handler`.
62
75
  def restore_resize_handler
63
76
  Signal.trap("WINCH", @previous_winch_handler) if @previous_winch_handler
64
77
  @previous_winch_handler = nil
65
78
  end
66
79
 
80
+ # Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
81
+ # Idempotent: skipped when mouse tracking is already enabled.
67
82
  def enable_mouse_tracking
68
83
  return if @mouse_enabled
69
84
 
@@ -73,6 +88,7 @@ module Charming
73
88
  @mouse_enabled = true
74
89
  end
75
90
 
91
+ # Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
76
92
  def disable_mouse_tracking
77
93
  return unless @mouse_enabled
78
94
 
@@ -83,165 +99,96 @@ module Charming
83
99
  @mouse_enabled = false
84
100
  end
85
101
 
102
+ # Returns whether mouse tracking is currently enabled on this backend.
86
103
  def mouse_enabled?
87
104
  @mouse_enabled
88
105
  end
89
106
 
107
+ # Manually flags the backend as resized (used by tests or external integrations).
90
108
  def notify_resize
91
109
  @resized = true
92
110
  end
93
111
 
112
+ # Writes a full multi-line *frame* to the terminal, disabling auto-wrap during
113
+ # the write so overlong lines don't disturb the screen layout.
94
114
  def write_frame(frame)
95
- @output.write(frame)
96
- @output.flush
115
+ without_auto_wrap do
116
+ write_positioned_lines(frame.to_s.lines(chomp: true))
117
+ end
97
118
  end
98
119
 
120
+ # Writes a partial frame composed of [row, line] tuples (1-based rows).
99
121
  def write_lines(line_changes, **)
100
- write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
122
+ without_auto_wrap do
123
+ write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
124
+ end
101
125
  end
102
126
 
127
+ # Enters the alternate screen buffer.
103
128
  def enter_alt_screen
104
129
  write_control(ALT_SCREEN_ON)
105
130
  end
106
131
 
132
+ # Leaves the alternate screen buffer.
107
133
  def leave_alt_screen
108
134
  write_control(ALT_SCREEN_OFF)
109
135
  end
110
136
 
137
+ # Shows the terminal cursor.
111
138
  def show_cursor
112
139
  write_control(@cursor.show)
113
140
  end
114
141
 
142
+ # Hides the terminal cursor.
115
143
  def hide_cursor
116
144
  write_control(@cursor.hide)
117
145
  end
118
146
 
147
+ # Clears the terminal screen and moves the cursor to (1, 1).
119
148
  def clear
120
149
  write_control(@cursor.clear_screen)
121
150
  end
122
151
 
152
+ # Moves the terminal cursor to the given 1-based (row, column).
123
153
  def move_cursor(row, column)
124
154
  write_control(@cursor.move_to(column - 1, row - 1))
125
155
  end
126
156
 
157
+ # Returns the current terminal dimensions as [width, height] via TTY::Screen.
127
158
  def size = [TTY::Screen.width, TTY::Screen.height]
128
159
 
129
160
  private
130
161
 
131
- def mouse_sequence?(raw)
132
- return false unless raw.is_a?(String)
133
- return true if raw.match?(MOUSE_SGR_PATTERN)
134
- return true if raw.start_with?("\e[M")
135
-
136
- false
137
- end
138
-
139
- def mouse_event(raw)
140
- if raw.match?(MOUSE_SGR_PATTERN)
141
- parse_sgr_mouse(raw)
142
- else
143
- parse_legacy_mouse(raw)
144
- end
145
- end
146
-
147
- def parse_sgr_mouse(raw)
148
- match = raw.match(MOUSE_SGR_PATTERN)
149
- return nil unless match
150
-
151
- # \e[<button>;<col>;<row><mode>M
152
- button_code = match[1].to_i
153
- col = match[2].to_i - 1
154
- row = match[3].to_i - 1
155
- mode = match[4]
156
-
157
- ctrl = mode == "C"
158
- alt = raw.include?("\e[38;5;")
159
- shift = mode == "M"
160
-
161
- MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
162
- end
163
-
164
- def parse_legacy_mouse(raw)
165
- # Legacy format: \e[M + 3 bytes (button, col, row)
166
- # Each byte is 32 + value (space offset)
167
- match = raw.match(MOUSE_LEGACY_PATTERN)
168
- return nil unless match
169
-
170
- bytes = match[1].bytes
171
- return nil unless bytes.length == 3
172
-
173
- button_code = bytes[0] - 32
174
- col = bytes[1] - 32
175
- row = bytes[2] - 32
176
-
177
- MouseEvent.new(button: button_code, x: col, y: row)
178
- end
179
-
162
+ # True when the SIGWINCH flag has been set since the last read_event.
180
163
  def resized?
181
164
  @resized
182
165
  end
183
166
 
167
+ # Consumes the resize flag, measures the current terminal, and returns a ResizeEvent.
184
168
  def resize_event
185
169
  @resized = false
186
170
  width, height = size
187
- ResizeEvent.new(width: width, height: height)
171
+ Events::ResizeEvent.new(width: width, height: height)
188
172
  end
189
173
 
190
- def normalize_keypress(keypress)
191
- return nil unless keypress
192
-
193
- key_name = @reader.console.keys[keypress]
194
- return character_event(keypress) unless key_name
195
-
196
- named_event(key_name)
197
- end
198
-
199
- def character_event(keypress)
200
- KeyEvent.new(key: keypress.to_sym, char: keypress)
201
- end
202
-
203
- def named_event(key_name)
204
- normalized = normalize_key_name(key_name)
205
- KeyEvent.new(
206
- key: normalized.fetch(:key),
207
- char: normalized.fetch(:char, nil),
208
- ctrl: normalized.fetch(:ctrl, false),
209
- alt: normalized.fetch(:alt, false),
210
- shift: normalized.fetch(:shift, false)
211
- )
212
- end
213
-
214
- def normalize_key_name(key_name)
215
- name = key_name.to_s
216
- return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
217
- return {key: :tab, shift: true} if name == "back_tab"
218
-
219
- {key: normalized_key(name), char: printable_char(name)}
220
- end
221
-
222
- def normalized_key(name)
223
- return :enter if name == "return"
224
-
225
- name.to_sym
226
- end
227
-
228
- def ctrl_key(name)
229
- match = name.match(CTRL_KEY_PATTERN)
230
- {key: match[:key].to_sym, ctrl: true}
174
+ # Writes a raw escape *sequence* to the output stream and flushes.
175
+ def write_control(sequence)
176
+ @output.write(sequence)
177
+ @output.flush
231
178
  end
232
179
 
233
- def printable_char(name)
234
- case name
235
- when "space" then " "
236
- when "enter", "return" then "\n"
237
- when "tab" then "\t"
238
- else
239
- name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
240
- end
180
+ # Writes *lines* one row at a time, with each line preceded by an ANSI cursor
181
+ # position and a clear-to-end-of-line sequence.
182
+ def write_positioned_lines(lines)
183
+ write_control(lines.each_with_index.map { |line, index| "\e[#{index + 1};1H\e[2K#{line}" }.join)
241
184
  end
242
185
 
243
- def write_control(sequence)
244
- @output.write(sequence)
186
+ # Disables auto-wrap, yields, then re-enables it and flushes the output.
187
+ def without_auto_wrap
188
+ @output.write(AUTO_WRAP_OFF)
189
+ yield
190
+ ensure
191
+ @output.write(AUTO_WRAP_ON)
245
192
  @output.flush
246
193
  end
247
194
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
6
+ # helper methods (text, box, row, column, etc.), and rendering via render.
7
+ class Component < View
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ # ActivityIndicator renders a color-gradient progress or loading indicator
7
+ # as styled text. It produces a fixed-width row of characters whose colors
8
+ # interpolate between two gradient endpoints (or cycle through a single
9
+ # color). A label can be appended after the bar and an ellipsis that cycles
10
+ # through frames, useful for "loading" state display. Call `tick` to advance
11
+ # the frame counter, and call `render` to produce the styled output string.
12
+ class ActivityIndicator < Component
13
+ # Default character pool used for generating each position's character via stable hashing.
14
+ DEFAULT_CHARS = "0123456789abcdefABCDEF~!@#$%^&*+=_".chars.freeze
15
+
16
+ # The default two-color gradient applied across the bar width (red to cyan).
17
+ # The cyan endpoint mirrors the Phosphor theme palette's "cyan" token so the bar
18
+ # remains legible on Phosphor's dark navy background; gradient: accepts raw hex,
19
+ # so callers using a different theme should pass their own endpoints.
20
+ DEFAULT_GRADIENT = ["#ff0000", "#6FD0E3"].freeze
21
+
22
+ # The default label color for ellipsis and text portions when no custom
23
+ # label_style is provided.
24
+ DEFAULT_LABEL_COLOR = "#cccccc"
25
+
26
+ # Ellipsis frame sequence: four states cycle through "., "..", "...", and "" (empty).
27
+ ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze
28
+
29
+ # Number of frames in the animation cycle before the indicator pattern repeats.
30
+ FRAME_COUNT = 10
31
+
32
+ # FNV-1a variant constants used by stable_hash for reproducible character selection per position.
33
+ FNV_OFFSET = 2_166_136_261
34
+ FNV_PRIME = 16_777_619
35
+ FNV_MASK = 0xffffffff
36
+
37
+ attr_reader :width, :label, :index, :seed, :chars, :gradient, :label_style
38
+
39
+ # Initializes a new ActivityIndicator with configurable visual parameters.
40
+ # width — Display width of the gradient bar in characters (minimum 1). Default: 10.
41
+ # label — Optional text label shown adjacent to the indicator.
42
+ # index — Initial frame index for the ellipsis/frame animations. Default: 0.
43
+ # seed — Hash seed that determines which characters appear at each position.
44
+ # chars — Character pool to draw from (default is DEFAULT_CHARS).
45
+ # gradient — Two-element array of hex color strings ["#rrggbb", "#rrggbb"] for interpolation.
46
+ # label_style — A Style object to use for rendering the label text; falls back to a gray foreground.
47
+ def initialize(width: 10, label: nil, index: 0, seed: 0, chars: DEFAULT_CHARS,
48
+ gradient: DEFAULT_GRADIENT, label_style: nil)
49
+ super()
50
+ raise ArgumentError, "chars cannot be empty" if chars.empty?
51
+
52
+ @width = [width.to_i, 1].max
53
+ @label = label
54
+ @index = index.to_i
55
+ @seed = seed
56
+ @chars = chars.map(&:to_s)
57
+ @gradient = gradient
58
+ @label_style = label_style
59
+ end
60
+
61
+ # Advances the frame counter forward by +count+ steps, allowing the displayed pattern to change.
62
+ # Accepts an integer count (converted via +to_i+). Returns self for chaining.
63
+ def tick(count = 1)
64
+ @index += count.to_i
65
+ self
66
+ end
67
+
68
+ # Renders the activity indicator as a styled string. If a label was provided,
69
+ # produces "bar ellipsis" alongside it; otherwise produces only the gradient bar.
70
+ # Returns a formatted string suitable for terminal rendering.
71
+ def render
72
+ return indicator unless label
73
+
74
+ "#{indicator} #{styled_label}#{styled_ellipsis}"
75
+ end
76
+
77
+ private
78
+
79
+ # Renders the full gradient bar as an array of styled characters joined into a single string.
80
+ # Each character at +position+ is selected by hashing together seed, frame, and position —
81
+ # making the pattern stable across renders — then styled with the interpolated gradient color
82
+ # at that position.
83
+ def indicator
84
+ Array.new(width) { |position| styled_char(position) }.join
85
+ end
86
+
87
+ # Selects a character for the bar at the given +position+, styles it with the gradient color
88
+ # interpolated for that position, and returns the result as a formatted string via +render+.
89
+ def styled_char(position)
90
+ style.foreground(color_at(position)).render(char_at(position))
91
+ end
92
+
93
+ # Chooses a character from self.chars by hashing seed:frame:position together with a stable
94
+ # FNV-1a hash. The resulting index is modulated against the character pool length, ensuring
95
+ # reproducible output across renders.
96
+ def char_at(position)
97
+ chars.fetch(stable_hash("#{seed}:#{frame}:#{position}") % chars.length)
98
+ end
99
+
100
+ # Renders the label text in its own style (or fallback gray color) via a Style renderer call.
101
+ def styled_label
102
+ label_style_or_default.render(label.to_s)
103
+ end
104
+
105
+ # Renders an ellipsis frame (".", "..", "...", or empty) based on (index / 4) mod 4, styled with the label style.
106
+ def styled_ellipsis
107
+ label_style_or_default.render(ellipsis_frame)
108
+ end
109
+
110
+ # Returns the current ellipsis frame string: one of ".", "..", "...", "". Cycles through four frames per tick.
111
+ def ellipsis_frame
112
+ ELLIPSIS_FRAMES.fetch((index / 4) % ELLIPSIS_FRAMES.length)
113
+ end
114
+
115
+ # Returns the label style if set, otherwise produces a gray foreground style for fallback rendering.
116
+ def label_style_or_default
117
+ label_style || style.foreground(DEFAULT_LABEL_COLOR)
118
+ end
119
+
120
+ # Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
121
+ # Returns the first gradient color if width is 1; otherwise returns a blended hex string based on position.
122
+ def color_at(position)
123
+ return gradient.first unless width > 1
124
+
125
+ blend(gradient.first, gradient.last, position / (width - 1).to_f)
126
+ end
127
+
128
+ # Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
129
+ # Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
130
+ def blend(start_hex, end_hex, amount)
131
+ start_rgb = rgb(start_hex)
132
+ end_rgb = rgb(end_hex)
133
+ mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
134
+ "#%02x%02x%02x" % mixed
135
+ end
136
+
137
+ # Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
138
+ def rgb(hex)
139
+ value = hex.to_s.delete_prefix("#")
140
+ raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
141
+
142
+ [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
143
+ end
144
+
145
+ # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
146
+ def frame
147
+ index % FRAME_COUNT
148
+ end
149
+
150
+ # Produces a deterministic integer hash from the input string using FNV-1a hashing, ensuring the same
151
+ # characters appear at the same positions across multiple renderings of this indicator.
152
+ def stable_hash(value)
153
+ value.bytes.reduce(FNV_OFFSET) do |hash, byte|
154
+ ((hash ^ byte) * FNV_PRIME) & FNV_MASK
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ # CommandPalette renders a fuzzy-searchable command picker UI. It wraps a TextInput for search
7
+ # input and a List for result display, dispatching key events between them. Users type to filter
8
+ # the registered commands by label match, navigate with up/down/home/end keys (delegated to List),
9
+ # confirm a selection with Enter (returns [:selected, command]), or cancel with Escape (returns :cancelled).
10
+ # State is serializable as a hash of value/cursor/selected_index for session persistence.
11
+ class CommandPalette < Component
12
+ Command = Data.define(:label, :value)
13
+
14
+ # A single command palette entry: a human-readable +label+ and a callable or
15
+ # method symbol +value+ that gets executed when the user selects it.
16
+ attr_reader :commands, :input
17
+
18
+ # Initializes the dropdown widget with a list of Command entries and search
19
+ # parameters for building the underlying TextInput (placeholder text, cursor
20
+ # position, value) and List (display height, initial selection). Returns void;
21
+ # the state is later serializable via +state+ for session persistence.
22
+ def initialize(commands:, placeholder: "Search commands", height: nil, value: "", cursor: nil, selected_index: 0, theme: nil)
23
+ super(theme: theme)
24
+ @commands = commands
25
+ @height = height
26
+ @input = TextInput.new(value: value, placeholder: placeholder, cursor: cursor)
27
+ @list = build_list(selected_index: selected_index)
28
+ end
29
+
30
+ # Returns the currently displayed Command entry in the List at the time of calling.
31
+ # Returns nil if no entry is highlighted (i.e., user has opened the palette but not
32
+ # moved the selection). Useful for retrieving the result after key handling.
33
+ def selected_command
34
+ list.selected_item
35
+ end
36
+
37
+ # Collects the current state of the TextInput and List into a serializable hash
38
+ # suitable for round-trip storage in session. Returns {value:, cursor:, selected_index:}.
39
+ def state
40
+ {
41
+ value: input.value,
42
+ cursor: input.cursor,
43
+ selected_index: list.selected_index
44
+ }
45
+ end
46
+
47
+ # Handles key events by routing them to the appropriate sub-component: Escape kills the
48
+ # palette returning :cancelled; up/down/home/end keys go to the List selection handler
49
+ # via handle_list_key; all other keys (including typed characters) are passed to the TextInput
50
+ # which manages cursor position and input filtering. If a list key match fails, falls through
51
+ # to the TextInput handler. Returns nil/nil if no handler consumed the event, or :cancelled when
52
+ # Escape is pressed.
53
+ def handle_key(event)
54
+ key = Charming.key_of(event)
55
+ return :cancelled if key == :escape
56
+
57
+ return handle_list_key(event) if list_key?(key)
58
+
59
+ handle_input_key(event)
60
+ end
61
+
62
+ # Renders the command palette as a vertically-stacked text representation: the search TextInput
63
+ # row on line 1, and then the filtered List results (or "No commands found") on subsequent lines.
64
+ # Returns a multiline string suitable for terminal rendering.
65
+ def render
66
+ [input.render, render_results].join("\n")
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :height, :list
72
+
73
+ # Delegates key handling entirely to the internal List widget, which manages up/down/home/end selection.
74
+ # Returns whatever the List's handle_key returns (typically nil or the symbol from the subclass).
75
+ def handle_list_key(event)
76
+ list.handle_key(event)
77
+ end
78
+
79
+ # Passes the key event to the TextInput for cursor position and search text management.
80
+ # If the input returns :handled, rebuilds the List so that filtering is re-evaluated against
81
+ # the new input value. Returns nil/nil if no handler consumed the event.
82
+ def handle_input_key(event)
83
+ result = input.handle_key(event)
84
+ @list = build_list if result == :handled
85
+ result
86
+ end
87
+
88
+ # Checks whether the given key is a List-navigation key (up/down/home/end). Returns true for those keys
89
+ # so they can be dispatched via +handle_list_key+ rather than falling through to TextInput.
90
+ def list_key?(key)
91
+ %i[up down home end enter].include?(key)
92
+ end
93
+
94
+ # Renders the filtered results section below the search input. If no commands match the current filter text,
95
+ # returns "No commands found"; otherwise renders the List widget's styled display string. Returns a single-line string.
96
+ def render_results
97
+ return "No commands found" if filtered_commands.empty?
98
+
99
+ list.render
100
+ end
101
+
102
+ # Builds a new List from the currently filtered commands at the given selected_index height and label extractor.
103
+ # The +selected_index+ parameter defaults to the last known value in +list+ to preserve scroll position across rebuilds.
104
+ def build_list(selected_index: list&.selected_index || 0)
105
+ List.new(items: filtered_commands, selected_index: selected_index, height: height, label: :label.to_proc, theme: theme)
106
+ end
107
+
108
+ # Returns the full commands array when input value is empty; otherwise a subset whose labels match case-insensitively
109
+ # against the current TextInput value. Used to drive the fuzzy search behavior. Returns an Array of Command entries.
110
+ def filtered_commands
111
+ return commands if input.value.empty?
112
+
113
+ commands.select do |command|
114
+ command.label.downcase.include?(input.value.downcase)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ # EmptyState is a placeholder component for screens with no content. Renders one of three
7
+ # states: a default "nothing to show" message, a "loading…" message, or an error message
8
+ # with optional help text.
9
+ class EmptyState < Component
10
+ # *message* is shown in the default state. *loading* switches to the loading message
11
+ # (overrides *message*). *loading_message* is the string rendered in the loading state.
12
+ # *error* and *error_message* switch to the error state (the string form takes precedence).
13
+ # *help* is an optional muted line shown below the error message.
14
+ def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
15
+ super(theme: theme)
16
+ @message = message
17
+ @loading = loading
18
+ @loading_message = loading_message
19
+ @error = error
20
+ @error_message = error_message
21
+ @help = help
22
+ end
23
+
24
+ # Renders the appropriate state as styled text: loading → loading message, error →
25
+ # error message + help, otherwise the default message.
26
+ def render
27
+ return loading_state if @loading
28
+ return error_state if error?
29
+
30
+ text @message, style: theme.muted
31
+ end
32
+
33
+ private
34
+
35
+ # Renders the loading state as a muted line.
36
+ def loading_state
37
+ text @loading_message, style: theme.muted
38
+ end
39
+
40
+ # Renders the error state: the error message styled with the theme's warn style,
41
+ # optionally followed by a muted help line.
42
+ def error_state
43
+ lines = [text(@error_message || @error.to_s, style: theme.warn)]
44
+ lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
45
+
46
+ column(*lines)
47
+ end
48
+
49
+ # True when either the *error* or *error_message* string is non-blank.
50
+ def error?
51
+ @error.to_s.strip != "" || @error_message.to_s.strip != ""
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end