charming 0.1.1 → 0.1.3

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -1,158 +1,197 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
- # indexInitial frame index for the ellipsis/frame animations. Default: 0.
43
- # seed Hash seed that determines which characters appear at each position.
44
- # charsCharacter 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
4
+ module Components
5
+ # ActivityIndicator renders a color-gradient progress or loading indicator
6
+ # as styled text. It produces a fixed-width row of characters whose colors
7
+ # interpolate between two gradient endpoints (or cycle through a single
8
+ # color). A label can be appended after the bar and an ellipsis that cycles
9
+ # through frames, useful for "loading" state display. Call `tick` to advance
10
+ # the frame counter, and call `render` to produce the styled output string.
11
+ class ActivityIndicator < Component
12
+ # Default character pool used for generating each position's character via stable hashing.
13
+ DEFAULT_CHARS = "0123456789abcdefABCDEF~!@#$%^&*+=_".chars.freeze
14
+
15
+ # The default two-color gradient applied across the bar width (red to cyan).
16
+ # The cyan endpoint mirrors the Phosphor theme palette's "cyan" token so the bar
17
+ # remains legible on Phosphor's dark navy background; gradient: accepts raw hex,
18
+ # so callers using a different theme should pass their own endpoints.
19
+ DEFAULT_GRADIENT = ["#ff0000", "#6FD0E3"].freeze
20
+
21
+ # The default label color for ellipsis and text portions when no custom
22
+ # label_style is provided.
23
+ DEFAULT_LABEL_COLOR = "#cccccc"
24
+
25
+ # Ellipsis frame sequence: four states cycle through "., "..", "...", and "" (empty).
26
+ ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze
27
+
28
+ # Minimum bar width reserved when deciding whether a long label can fit before falling back.
29
+ MIN_FITTED_INDICATOR_WIDTH = 4
30
+
31
+ # Number of frames in the animation cycle before the indicator pattern repeats.
32
+ FRAME_COUNT = 10
33
+
34
+ # FNV-1a variant constants used by stable_hash for reproducible character selection per position.
35
+ FNV_OFFSET = 2_166_136_261
36
+ FNV_PRIME = 16_777_619
37
+ FNV_MASK = 0xffffffff
38
+
39
+ attr_reader :width, :label, :index, :seed, :chars, :gradient, :label_style, :max_width, :fallback_label
40
+
41
+ # Initializes a new ActivityIndicator with configurable visual parameters.
42
+ # widthDisplay width of the gradient bar in characters (minimum 1). Default: 10.
43
+ # label Optional text label shown adjacent to the indicator.
44
+ # indexInitial frame index for the ellipsis/frame animations. Default: 0.
45
+ # seed Hash seed that determines which characters appear at each position.
46
+ # chars Character pool to draw from (default is DEFAULT_CHARS).
47
+ # gradient — Two-element array of hex color strings ["#rrggbb", "#rrggbb"] for interpolation.
48
+ # label_style A Style object to use for rendering the label text; falls back to a gray foreground.
49
+ # max_width — Optional total display width cap for the indicator, label, and ellipsis.
50
+ # fallback_label Optional shorter label used when the primary label cannot fit within max_width.
51
+ def initialize(width: 10, label: nil, index: 0, seed: 0, chars: DEFAULT_CHARS,
52
+ gradient: DEFAULT_GRADIENT, label_style: nil, max_width: nil, fallback_label: nil)
53
+ super()
54
+ raise ArgumentError, "chars cannot be empty" if chars.empty?
55
+
56
+ @width = [width.to_i, 1].max
57
+ @label = label
58
+ @index = index.to_i
59
+ @seed = seed
60
+ @chars = chars.map(&:to_s)
61
+ @gradient = gradient
62
+ @label_style = label_style
63
+ @max_width = max_width&.to_i
64
+ @fallback_label = fallback_label
65
+ end
60
66
 
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
+ # Advances the frame counter forward by +count+ steps, allowing the displayed pattern to change.
68
+ # Accepts an integer count (converted via +to_i+). Returns self for chaining.
69
+ def tick(count = 1)
70
+ @index += count.to_i
71
+ self
72
+ end
67
73
 
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
74
+ # Renders the activity indicator as a styled string. If a label was provided,
75
+ # produces "bar ellipsis" alongside it; otherwise produces only the gradient bar.
76
+ # Returns a formatted string suitable for terminal rendering.
77
+ def render
78
+ return indicator unless label
73
79
 
74
- "#{indicator} #{styled_label}#{styled_ellipsis}"
75
- end
80
+ "#{indicator} #{styled_label}#{styled_ellipsis}"
81
+ end
76
82
 
77
- private
83
+ private
78
84
 
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
85
+ # Renders the full gradient bar as an array of styled characters joined into a single string.
86
+ # Each character at +position+ is selected by hashing together seed, frame, and position —
87
+ # making the pattern stable across renders — then styled with the interpolated gradient color
88
+ # at that position.
89
+ def indicator
90
+ Array.new(indicator_width) { |position| styled_char(position) }.join
91
+ end
86
92
 
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
93
+ # Selects a character for the bar at the given +position+, styles it with the gradient color
94
+ # interpolated for that position, and returns the result as a formatted string via +render+.
95
+ def styled_char(position)
96
+ style.foreground(color_at(position)).render(char_at(position))
97
+ end
92
98
 
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
+ # Chooses a character from self.chars by hashing seed:frame:position together with a stable
100
+ # FNV-1a hash. The resulting index is modulated against the character pool length, ensuring
101
+ # reproducible output across renders.
102
+ def char_at(position)
103
+ chars.fetch(stable_hash("#{seed}:#{frame}:#{position}") % chars.length)
104
+ end
99
105
 
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
106
+ # Renders the label text in its own style (or fallback gray color) via a Style renderer call.
107
+ def styled_label
108
+ label_style_or_default.render(label_text)
109
+ end
104
110
 
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
111
+ # Renders an ellipsis frame (".", "..", "...", or empty) based on (index / 4) mod 4, styled with the label style.
112
+ def styled_ellipsis
113
+ label_style_or_default.render(ellipsis_frame)
114
+ end
109
115
 
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
116
+ # Returns the current ellipsis frame string: one of ".", "..", "...", "". Cycles through four frames per tick.
117
+ def ellipsis_frame
118
+ ELLIPSIS_FRAMES.fetch((index / 4) % ELLIPSIS_FRAMES.length)
119
+ end
114
120
 
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
121
+ # Returns the label style if set, otherwise produces a gray foreground style for fallback rendering.
122
+ def label_style_or_default
123
+ label_style || style.foreground(DEFAULT_LABEL_COLOR)
124
+ end
119
125
 
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
126
+ # Returns the label to render, using fallback_label when the primary label cannot fit
127
+ # alongside a minimal indicator and the widest ellipsis frame.
128
+ def label_text
129
+ return label.to_s unless use_fallback_label?
124
130
 
125
- blend(gradient.first, gradient.last, position / (width - 1).to_f)
126
- end
131
+ fallback_label.to_s
132
+ end
127
133
 
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
134
+ # True when the primary label cannot fit within max_width even with a compact indicator.
135
+ def use_fallback_label?
136
+ return false unless max_width && fallback_label
136
137
 
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/)
138
+ fitted_width(label.to_s, MIN_FITTED_INDICATOR_WIDTH) > max_width
139
+ end
141
140
 
142
- [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
143
- end
141
+ # Returns the fitted indicator width after reserving room for label text and the widest ellipsis.
142
+ def indicator_width
143
+ return width unless max_width && label
144
144
 
145
- # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
146
- def frame
147
- index % FRAME_COUNT
148
- end
145
+ (max_width - label_width - 1 - widest_ellipsis_width).clamp(1, width)
146
+ end
147
+
148
+ def label_width
149
+ UI::Width.measure(label_text)
150
+ end
151
+
152
+ def fitted_width(text, indicator_width)
153
+ indicator_width + 1 + UI::Width.measure(text) + widest_ellipsis_width
154
+ end
155
+
156
+ def widest_ellipsis_width
157
+ @widest_ellipsis_width ||= ELLIPSIS_FRAMES.map { |frame| UI::Width.measure(frame) }.max
158
+ end
159
+
160
+ # Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
161
+ # Returns the first gradient color if width is 1; otherwise returns a blended hex string based on position.
162
+ def color_at(position)
163
+ return gradient.first unless indicator_width > 1
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) }
183
+ end
184
+
185
+ # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
186
+ def frame
187
+ index % FRAME_COUNT
188
+ end
149
189
 
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
190
+ # Produces a deterministic integer hash from the input string using FNV-1a hashing, ensuring the same
191
+ # characters appear at the same positions across multiple renderings of this indicator.
192
+ def stable_hash(value)
193
+ value.bytes.reduce(FNV_OFFSET) do |hash, byte|
194
+ ((hash ^ byte) * FNV_PRIME) & FNV_MASK
156
195
  end
157
196
  end
158
197
  end
@@ -1,118 +1,116 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
+ module Components
5
+ # CommandPalette renders a fuzzy-searchable command picker UI. It wraps a TextInput for search
6
+ # input and a List for result display, dispatching key events between them. Users type to filter
7
+ # the registered commands by label match, navigate with up/down/home/end keys (delegated to List),
8
+ # confirm a selection with Enter (returns [:selected, command]), or cancel with Escape (returns :cancelled).
9
+ # State is serializable as a hash of value/cursor/selected_index for session persistence.
10
+ class CommandPalette < Component
11
+ Command = Data.define(:label, :value)
12
+
13
+ # A single command palette entry: a human-readable +label+ and a callable or
14
+ # method symbol +value+ that gets executed when the user selects it.
15
+ attr_reader :commands, :input
16
+
17
+ # Initializes the dropdown widget with a list of Command entries and search
18
+ # parameters for building the underlying TextInput (placeholder text, cursor
19
+ # position, value) and List (display height, initial selection). Returns void;
20
+ # the state is later serializable via +state+ for session persistence.
21
+ def initialize(commands:, placeholder: "Search commands", height: nil, value: "", cursor: nil, selected_index: 0, theme: nil)
22
+ super(theme: theme)
23
+ @commands = commands
24
+ @height = height
25
+ @input = TextInput.new(value: value, placeholder: placeholder, cursor: cursor)
26
+ @list = build_list(selected_index: selected_index)
27
+ end
29
28
 
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
29
+ # Returns the currently displayed Command entry in the List at the time of calling.
30
+ # Returns nil if no entry is highlighted (i.e., user has opened the palette but not
31
+ # moved the selection). Useful for retrieving the result after key handling.
32
+ def selected_command
33
+ list.selected_item
34
+ end
36
35
 
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
36
+ # Collects the current state of the TextInput and List into a serializable hash
37
+ # suitable for round-trip storage in session. Returns {value:, cursor:, selected_index:}.
38
+ def state
39
+ {
40
+ value: input.value,
41
+ cursor: input.cursor,
42
+ selected_index: list.selected_index
43
+ }
44
+ end
46
45
 
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
46
+ # Handles key events by routing them to the appropriate sub-component: Escape kills the
47
+ # palette returning :cancelled; up/down/home/end keys go to the List selection handler
48
+ # via handle_list_key; all other keys (including typed characters) are passed to the TextInput
49
+ # which manages cursor position and input filtering. If a list key match fails, falls through
50
+ # to the TextInput handler. Returns nil/nil if no handler consumed the event, or :cancelled when
51
+ # Escape is pressed.
52
+ def handle_key(event)
53
+ key = Charming.key_of(event)
54
+ return :cancelled if key == :escape
56
55
 
57
- return handle_list_key(event) if list_key?(key)
56
+ return handle_list_key(event) if list_key?(key)
58
57
 
59
- handle_input_key(event)
60
- end
58
+ handle_input_key(event)
59
+ end
61
60
 
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
61
+ # Renders the command palette as a vertically-stacked text representation: the search TextInput
62
+ # row on line 1, and then the filtered List results (or "No commands found") on subsequent lines.
63
+ # Returns a multiline string suitable for terminal rendering.
64
+ def render
65
+ [input.render, render_results].join("\n")
66
+ end
68
67
 
69
- private
68
+ private
70
69
 
71
- attr_reader :height, :list
70
+ attr_reader :height, :list
72
71
 
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
72
+ # Delegates key handling entirely to the internal List widget, which manages up/down/home/end selection.
73
+ # Returns whatever the List's handle_key returns (typically nil or the symbol from the subclass).
74
+ def handle_list_key(event)
75
+ list.handle_key(event)
76
+ end
78
77
 
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
78
+ # Passes the key event to the TextInput for cursor position and search text management.
79
+ # If the input returns :handled, rebuilds the List so that filtering is re-evaluated against
80
+ # the new input value. Returns nil/nil if no handler consumed the event.
81
+ def handle_input_key(event)
82
+ result = input.handle_key(event)
83
+ @list = build_list if result == :handled
84
+ result
85
+ end
87
86
 
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
87
+ # Checks whether the given key is a List-navigation key (up/down/home/end). Returns true for those keys
88
+ # so they can be dispatched via +handle_list_key+ rather than falling through to TextInput.
89
+ def list_key?(key)
90
+ %i[up down home end enter].include?(key)
91
+ end
93
92
 
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?
93
+ # Renders the filtered results section below the search input. If no commands match the current filter text,
94
+ # returns "No commands found"; otherwise renders the List widget's styled display string. Returns a single-line string.
95
+ def render_results
96
+ return "No commands found" if filtered_commands.empty?
98
97
 
99
- list.render
100
- end
98
+ list.render
99
+ end
101
100
 
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
101
+ # Builds a new List from the currently filtered commands at the given selected_index height and label extractor.
102
+ # The +selected_index+ parameter defaults to the last known value in +list+ to preserve scroll position across rebuilds.
103
+ def build_list(selected_index: list&.selected_index || 0)
104
+ List.new(items: filtered_commands, selected_index: selected_index, height: height, label: :label.to_proc, theme: theme)
105
+ end
107
106
 
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?
107
+ # Returns the full commands array when input value is empty; otherwise a subset whose labels match case-insensitively
108
+ # against the current TextInput value. Used to drive the fuzzy search behavior. Returns an Array of Command entries.
109
+ def filtered_commands
110
+ return commands if input.value.empty?
112
111
 
113
- commands.select do |command|
114
- command.label.downcase.include?(input.value.downcase)
115
- end
112
+ commands.select do |command|
113
+ command.label.downcase.include?(input.value.downcase)
116
114
  end
117
115
  end
118
116
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # CommandPaletteModal wraps command palette content in the framework's standard modal chrome.
6
+ class CommandPaletteModal < Component
7
+ DEFAULT_TITLE = "Command palette"
8
+ DEFAULT_HELP = "Type to filter. Enter selects. Escape closes."
9
+ DEFAULT_WIDTH = 52
10
+
11
+ def initialize(content:, title: DEFAULT_TITLE, help: DEFAULT_HELP, width: DEFAULT_WIDTH, style: nil, theme: nil)
12
+ super(theme: theme)
13
+ @content = content
14
+ @title = title
15
+ @help = help
16
+ @width = width
17
+ @style = style
18
+ end
19
+
20
+ def render
21
+ render_component Modal.new(content: content, title: title, help: help, width: width, style: modal_style, theme: theme)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :content, :title, :help, :width
27
+
28
+ def modal_style
29
+ @style
30
+ end
31
+ end
32
+ end
33
+ end