charming 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +3 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +39 -3
  6. data/lib/charming/controller.rb +146 -24
  7. data/lib/charming/database_commands.rb +87 -0
  8. data/lib/charming/database_installer.rb +125 -0
  9. data/lib/charming/events/key_event.rb +15 -0
  10. data/lib/charming/events/mouse_event.rb +42 -0
  11. data/lib/charming/events/resize_event.rb +9 -0
  12. data/lib/charming/events/task_event.rb +19 -0
  13. data/lib/charming/events/timer_event.rb +9 -0
  14. data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
  15. data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
  16. data/lib/charming/generators/app_generator/component_templates.rb +1 -1
  17. data/lib/charming/generators/app_generator/controller_template.rb +3 -12
  18. data/lib/charming/generators/app_generator/database_templates.rb +45 -0
  19. data/lib/charming/generators/app_generator/layout_template.rb +51 -145
  20. data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
  21. data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
  22. data/lib/charming/generators/app_generator/view_template.rb +12 -18
  23. data/lib/charming/generators/app_generator.rb +37 -11
  24. data/lib/charming/generators/component_generator.rb +1 -1
  25. data/lib/charming/generators/controller_generator.rb +1 -4
  26. data/lib/charming/generators/model_generator.rb +119 -0
  27. data/lib/charming/generators/name.rb +0 -4
  28. data/lib/charming/generators/screen_generator.rb +14 -28
  29. data/lib/charming/generators/view_generator.rb +11 -14
  30. data/lib/charming/internal/renderer/differential.rb +2 -3
  31. data/lib/charming/internal/terminal/tty_backend.rb +25 -8
  32. data/lib/charming/presentation/component.rb +10 -0
  33. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  34. data/lib/charming/presentation/components/command_palette.rb +120 -0
  35. data/lib/charming/presentation/components/empty_state.rb +43 -0
  36. data/lib/charming/presentation/components/form/builder.rb +48 -0
  37. data/lib/charming/presentation/components/form/confirm.rb +56 -0
  38. data/lib/charming/presentation/components/form/field.rb +96 -0
  39. data/lib/charming/presentation/components/form/input.rb +57 -0
  40. data/lib/charming/presentation/components/form/note.rb +32 -0
  41. data/lib/charming/presentation/components/form/select.rb +89 -0
  42. data/lib/charming/presentation/components/form/textarea.rb +70 -0
  43. data/lib/charming/presentation/components/form.rb +127 -0
  44. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  45. data/lib/charming/presentation/components/list.rb +104 -0
  46. data/lib/charming/presentation/components/markdown.rb +25 -0
  47. data/lib/charming/presentation/components/modal.rb +50 -0
  48. data/lib/charming/presentation/components/progressbar.rb +57 -0
  49. data/lib/charming/presentation/components/spinner.rb +39 -0
  50. data/lib/charming/presentation/components/table.rb +118 -0
  51. data/lib/charming/presentation/components/text_area.rb +219 -0
  52. data/lib/charming/presentation/components/text_input.rb +105 -0
  53. data/lib/charming/presentation/components/viewport.rb +220 -0
  54. data/lib/charming/presentation/layout.rb +43 -0
  55. data/lib/charming/presentation/markdown/renderer.rb +203 -0
  56. data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
  57. data/lib/charming/presentation/markdown.rb +8 -0
  58. data/lib/charming/presentation/template_view.rb +27 -0
  59. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  60. data/lib/charming/presentation/templates.rb +51 -0
  61. data/lib/charming/presentation/ui/border.rb +35 -0
  62. data/lib/charming/presentation/ui/style.rb +246 -0
  63. data/lib/charming/presentation/ui/theme.rb +180 -0
  64. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  65. data/lib/charming/presentation/ui/width.rb +26 -0
  66. data/lib/charming/presentation/ui.rb +232 -0
  67. data/lib/charming/presentation/view.rb +118 -0
  68. data/lib/charming/runtime.rb +7 -7
  69. data/lib/charming/screen.rb +5 -1
  70. data/lib/charming/tasks/inline_executor.rb +28 -0
  71. data/lib/charming/tasks/task.rb +9 -0
  72. data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
  73. data/lib/charming/version.rb +1 -1
  74. data/lib/charming.rb +4 -0
  75. metadata +114 -29
  76. data/lib/charming/component.rb +0 -8
  77. data/lib/charming/components/activity_indicator.rb +0 -158
  78. data/lib/charming/components/command_palette.rb +0 -118
  79. data/lib/charming/components/keyboard_handler.rb +0 -22
  80. data/lib/charming/components/list.rb +0 -105
  81. data/lib/charming/components/modal.rb +0 -48
  82. data/lib/charming/components/progressbar.rb +0 -55
  83. data/lib/charming/components/spinner.rb +0 -37
  84. data/lib/charming/components/table.rb +0 -115
  85. data/lib/charming/components/text_input.rb +0 -103
  86. data/lib/charming/components/viewport.rb +0 -191
  87. data/lib/charming/key_event.rb +0 -13
  88. data/lib/charming/mouse_event.rb +0 -40
  89. data/lib/charming/resize_event.rb +0 -7
  90. data/lib/charming/task.rb +0 -7
  91. data/lib/charming/task_event.rb +0 -17
  92. data/lib/charming/timer_event.rb +0 -7
  93. data/lib/charming/ui/border.rb +0 -33
  94. data/lib/charming/ui/style.rb +0 -244
  95. data/lib/charming/ui/theme.rb +0 -178
  96. data/lib/charming/ui/width.rb +0 -24
  97. data/lib/charming/ui.rb +0 -230
  98. data/lib/charming/view.rb +0 -116
  99. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -11,10 +11,10 @@ module Charming
11
11
  end
12
12
 
13
13
  def generate
14
- create_file(model_path, model)
14
+ create_file(state_path, state)
15
15
  create_file(controller_path, controller)
16
16
  create_file(view_path, view)
17
- create_file(spec_model_path, spec_model)
17
+ create_file(spec_state_path, spec_state)
18
18
  create_file(spec_controller_path, spec_controller)
19
19
  create_file(spec_view_path, spec_view)
20
20
  insert_route
@@ -27,8 +27,8 @@ module Charming
27
27
  "screen"
28
28
  end
29
29
 
30
- def model_path
31
- File.join("app", "models", "#{name.snake_name}_model.rb")
30
+ def state_path
31
+ File.join("app", "state", "#{name.snake_name}_state.rb")
32
32
  end
33
33
 
34
34
  def controller_path
@@ -36,11 +36,11 @@ module Charming
36
36
  end
37
37
 
38
38
  def view_path
39
- File.join("app", "views", "#{name.snake_name}_view.rb")
39
+ File.join("app", "views", name.snake_name, "show.tui.erb")
40
40
  end
41
41
 
42
- def spec_model_path
43
- File.join("spec", "models", "#{name.snake_name}_model_spec.rb")
42
+ def spec_state_path
43
+ File.join("spec", "state", "#{name.snake_name}_state_spec.rb")
44
44
  end
45
45
 
46
46
  def spec_controller_path
@@ -48,7 +48,7 @@ module Charming
48
48
  end
49
49
 
50
50
  def spec_view_path
51
- File.join("spec", "views", "#{name.snake_name}_view_spec.rb")
51
+ File.join("spec", "views", name.snake_name, "show_template_spec.rb")
52
52
  end
53
53
 
54
54
  def route_path
@@ -59,11 +59,11 @@ module Charming
59
59
  File.join(destination, "app", "controllers", "application_controller.rb")
60
60
  end
61
61
 
62
- def model
62
+ def state
63
63
  %(# frozen_string_literal: true
64
64
 
65
65
  module #{app_name.class_name}
66
- class #{name.class_name}Model < ApplicationModel
66
+ class #{name.class_name}State < ApplicationState
67
67
  attribute :title, :string, default: "#{name.class_name}"
68
68
  end
69
69
  end
@@ -83,37 +83,23 @@ end
83
83
 
84
84
  def controller_body
85
85
  %( def show
86
- render #{name.view_class_name}.new(
86
+ render :show,
87
87
  #{name.snake_name}: #{name.snake_name},
88
- palette: command_palette,
89
- screen: screen
90
- )
88
+ palette: command_palette
91
89
  end
92
90
 
93
91
  private
94
92
 
95
93
  def #{name.snake_name}
96
- model(:#{name.snake_name}, #{name.class_name}Model)
94
+ state(:#{name.snake_name}, #{name.class_name}State)
97
95
  end)
98
96
  end
99
97
 
100
98
  def view
101
- %(# frozen_string_literal: true
102
-
103
- module #{app_name.class_name}
104
- class #{name.view_class_name} < Charming::View
105
- #{view_body}
106
- end
107
- end
99
+ %(<%= #{name.snake_name}.title %>
108
100
  )
109
101
  end
110
102
 
111
- def view_body
112
- %( def render
113
- #{name.snake_name}.title
114
- end)
115
- end
116
-
117
103
  def insert_route
118
104
  route = %( screen "/#{name.snake_name}", to: "#{name.snake_name}#show", title: "#{name.class_name}")
119
105
  insert_before_end(route_path, route, "route", "end")
@@ -3,32 +3,29 @@
3
3
  module Charming
4
4
  module Generators
5
5
  class ViewGenerator < AppFileGenerator
6
+ def initialize(name, args, out:, destination:, force: false)
7
+ super
8
+ raise Error, "Usage: charming generate view NAME [ACTION]" if args.length > 1
9
+
10
+ @action = args.fetch(0, "show")
11
+ end
12
+
6
13
  def generate
7
- create_file(app_path("app", "views"), view)
14
+ create_file(File.join("app", "views", name.snake_name, "#{action}.tui.erb"), view)
8
15
  end
9
16
 
10
17
  private
11
18
 
19
+ attr_reader :action
20
+
12
21
  def suffix
13
22
  "view"
14
23
  end
15
24
 
16
25
  def view
17
- %(# frozen_string_literal: true
18
-
19
- module #{app_name.class_name}
20
- class #{name.view_class_name} < Charming::View
21
- #{view_body}
22
- end
23
- end
26
+ %(<%= "#{name.class_name}" %>
24
27
  )
25
28
  end
26
-
27
- def view_body
28
- %( def render
29
- "#{name.class_name}"
30
- end)
31
- end
32
29
  end
33
30
  end
34
31
  end
@@ -42,9 +42,8 @@ module Charming
42
42
  lines = frame.lines(chomp: true)
43
43
  line_count = [previous_lines.length, lines.length].max
44
44
 
45
- line_count.times.filter_map do |index|
46
- line = lines[index] || ""
47
- [index + 1, line] unless previous_lines[index] == line
45
+ line_count.times.map do |index|
46
+ [index + 1, lines[index] || ""]
48
47
  end
49
48
  end
50
49
  end
@@ -12,6 +12,8 @@ module Charming
12
12
 
13
13
  ALT_SCREEN_ON = "\e[?1049h"
14
14
  ALT_SCREEN_OFF = "\e[?1049l"
15
+ AUTO_WRAP_OFF = "\e[?7l"
16
+ AUTO_WRAP_ON = "\e[?7h"
15
17
  CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
16
18
  MOUSE_SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
17
19
  MOUSE_LEGACY_PATTERN = /\e\[M(.{3})/
@@ -92,12 +94,15 @@ module Charming
92
94
  end
93
95
 
94
96
  def write_frame(frame)
95
- @output.write(frame)
96
- @output.flush
97
+ without_auto_wrap do
98
+ write_positioned_lines(frame.to_s.lines(chomp: true))
99
+ end
97
100
  end
98
101
 
99
102
  def write_lines(line_changes, **)
100
- write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
103
+ without_auto_wrap do
104
+ write_control(line_changes.map { |row, line| "\e[#{row};1H\e[2K#{line}" }.join)
105
+ end
101
106
  end
102
107
 
103
108
  def enter_alt_screen
@@ -158,7 +163,7 @@ module Charming
158
163
  alt = raw.include?("\e[38;5;")
159
164
  shift = mode == "M"
160
165
 
161
- MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
166
+ Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
162
167
  end
163
168
 
164
169
  def parse_legacy_mouse(raw)
@@ -174,7 +179,7 @@ module Charming
174
179
  col = bytes[1] - 32
175
180
  row = bytes[2] - 32
176
181
 
177
- MouseEvent.new(button: button_code, x: col, y: row)
182
+ Events::MouseEvent.new(button: button_code, x: col, y: row)
178
183
  end
179
184
 
180
185
  def resized?
@@ -184,7 +189,7 @@ module Charming
184
189
  def resize_event
185
190
  @resized = false
186
191
  width, height = size
187
- ResizeEvent.new(width: width, height: height)
192
+ Events::ResizeEvent.new(width: width, height: height)
188
193
  end
189
194
 
190
195
  def normalize_keypress(keypress)
@@ -197,12 +202,12 @@ module Charming
197
202
  end
198
203
 
199
204
  def character_event(keypress)
200
- KeyEvent.new(key: keypress.to_sym, char: keypress)
205
+ Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
201
206
  end
202
207
 
203
208
  def named_event(key_name)
204
209
  normalized = normalize_key_name(key_name)
205
- KeyEvent.new(
210
+ Events::KeyEvent.new(
206
211
  key: normalized.fetch(:key),
207
212
  char: normalized.fetch(:char, nil),
208
213
  ctrl: normalized.fetch(:ctrl, false),
@@ -244,6 +249,18 @@ module Charming
244
249
  @output.write(sequence)
245
250
  @output.flush
246
251
  end
252
+
253
+ def write_positioned_lines(lines)
254
+ write_control(lines.each_with_index.map { |line, index| "\e[#{index + 1};1H\e[2K#{line}" }.join)
255
+ end
256
+
257
+ def without_auto_wrap
258
+ @output.write(AUTO_WRAP_OFF)
259
+ yield
260
+ ensure
261
+ @output.write(AUTO_WRAP_ON)
262
+ @output.flush
263
+ end
247
264
  end
248
265
  end
249
266
  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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class EmptyState < Component
7
+ def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
8
+ super(theme: theme)
9
+ @message = message
10
+ @loading = loading
11
+ @loading_message = loading_message
12
+ @error = error
13
+ @error_message = error_message
14
+ @help = help
15
+ end
16
+
17
+ def render
18
+ return loading_state if @loading
19
+ return error_state if error?
20
+
21
+ text @message, style: theme.muted
22
+ end
23
+
24
+ private
25
+
26
+ def loading_state
27
+ text @loading_message, style: theme.muted
28
+ end
29
+
30
+ def error_state
31
+ lines = [text(@error_message || @error.to_s, style: theme.warn)]
32
+ lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
33
+
34
+ column(*lines)
35
+ end
36
+
37
+ def error?
38
+ @error.to_s.strip != "" || @error_message.to_s.strip != ""
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class Form
7
+ class Builder
8
+ attr_reader :fields, :theme
9
+
10
+ def initialize(theme: nil)
11
+ @theme = theme
12
+ @fields = []
13
+ end
14
+
15
+ def input(name, **options)
16
+ fields << Input.new(name, **field_options(options))
17
+ end
18
+
19
+ def textarea(name, **options)
20
+ fields << Textarea.new(name, **field_options(options))
21
+ end
22
+
23
+ def select(name, **options)
24
+ fields << Select.new(name, **field_options(options))
25
+ end
26
+
27
+ def confirm(name, **options)
28
+ fields << Confirm.new(name, **field_options(options))
29
+ end
30
+
31
+ def note(text, **options)
32
+ fields << Note.new(text, **field_options(options))
33
+ end
34
+
35
+ def build(state:, theme: nil)
36
+ Components::Form.new(fields: fields, state: state, theme: theme || self.theme)
37
+ end
38
+
39
+ private
40
+
41
+ def field_options(options)
42
+ {theme: theme}.merge(options)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ class Form
7
+ class Confirm < Field
8
+ def initialize(name, value: false, **options)
9
+ super(name, **options)
10
+ @initial_value = value
11
+ end
12
+
13
+ def handle_key(event)
14
+ case Charming.key_of(event)
15
+ when :space
16
+ toggle
17
+ when :y, :right
18
+ state[:values][name] = true
19
+ when :n, :left
20
+ state[:values][name] = false
21
+ else
22
+ return nil unless event.respond_to?(:char) && event.char == " "
23
+
24
+ toggle
25
+ end
26
+ :handled
27
+ end
28
+
29
+ def validate
30
+ return ["must be accepted"] if required? && value != true
31
+
32
+ super
33
+ end
34
+
35
+ private
36
+
37
+ def default_value
38
+ @initial_value
39
+ end
40
+
41
+ def render_control
42
+ "#{checked_marker} #{label}"
43
+ end
44
+
45
+ def checked_marker
46
+ value ? "[x]" : "[ ]"
47
+ end
48
+
49
+ def toggle
50
+ state[:values][name] = !value
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end