charming 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/controller/key_dispatch.rb +113 -0
  5. data/lib/charming/controller/session_state.rb +11 -0
  6. data/lib/charming/controller/terminal.rb +33 -0
  7. data/lib/charming/controller.rb +12 -39
  8. data/lib/charming/escape.rb +81 -0
  9. data/lib/charming/events/mouse_event.rb +22 -9
  10. data/lib/charming/generators/app_generator.rb +3 -2
  11. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  12. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  13. data/lib/charming/image/protocol/kitty.rb +126 -0
  14. data/lib/charming/image/protocol.rb +18 -0
  15. data/lib/charming/image/source.rb +85 -0
  16. data/lib/charming/image/terminal.rb +52 -0
  17. data/lib/charming/image/transmit.rb +11 -0
  18. data/lib/charming/image.rb +21 -0
  19. data/lib/charming/internal/event_loop.rb +155 -0
  20. data/lib/charming/internal/terminal/adapter.rb +14 -0
  21. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  22. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  23. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  24. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  25. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  26. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  27. data/lib/charming/presentation/components/chart.rb +80 -0
  28. data/lib/charming/presentation/components/error_screen.rb +1 -1
  29. data/lib/charming/presentation/components/filepicker.rb +101 -0
  30. data/lib/charming/presentation/components/form/builder.rb +5 -0
  31. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  32. data/lib/charming/presentation/components/image.rb +38 -0
  33. data/lib/charming/presentation/components/list.rb +22 -4
  34. data/lib/charming/presentation/components/paginator.rb +54 -0
  35. data/lib/charming/presentation/components/progressbar.rb +26 -4
  36. data/lib/charming/presentation/components/sparkline.rb +38 -0
  37. data/lib/charming/presentation/components/spinner.rb +22 -3
  38. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  39. data/lib/charming/presentation/components/table.rb +42 -1
  40. data/lib/charming/presentation/components/text_area.rb +1 -2
  41. data/lib/charming/presentation/components/text_input.rb +9 -4
  42. data/lib/charming/presentation/components/time_display.rb +20 -0
  43. data/lib/charming/presentation/components/timer.rb +43 -0
  44. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  45. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  46. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  47. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  48. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  49. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  50. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  51. data/lib/charming/presentation/ui/background.rb +58 -0
  52. data/lib/charming/presentation/ui/border.rb +14 -1
  53. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  54. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  55. data/lib/charming/presentation/ui/canvas.rb +1 -0
  56. data/lib/charming/presentation/ui/gradient.rb +47 -0
  57. data/lib/charming/presentation/ui/style.rb +119 -19
  58. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  59. data/lib/charming/presentation/ui/truncate.rb +29 -0
  60. data/lib/charming/presentation/ui/width.rb +11 -0
  61. data/lib/charming/presentation/ui.rb +52 -11
  62. data/lib/charming/presentation/view.rb +8 -6
  63. data/lib/charming/response.rb +11 -6
  64. data/lib/charming/runtime.rb +154 -88
  65. data/lib/charming/version.rb +1 -1
  66. metadata +29 -3
@@ -68,6 +68,13 @@ module Charming
68
68
  nil
69
69
  end
70
70
 
71
+ # True when stdin has bytes ready to read right now — a genuine nonblocking check (0s
72
+ # wait), unlike read_event whose tty-reader nonblock path waits up to 0.1s. Lets the
73
+ # runtime drain buffered key auto-repeat and stop the instant the buffer empties.
74
+ def input_pending?
75
+ resized? || !@input.wait_readable(0).nil?
76
+ end
77
+
71
78
  # Emits the ANSI sequence enabling terminal focus reporting. Idempotent.
72
79
  def enable_focus_reporting
73
80
  return if @focus_reporting
@@ -100,6 +107,48 @@ module Charming
100
107
  @bracketed_paste = false
101
108
  end
102
109
 
110
+ # Returns the terminal to its normal state for a shell suspend (Ctrl+Z):
111
+ # reporting modes off, cursor visible, primary screen, cooked input.
112
+ def suspend
113
+ @mouse_was_enabled = @mouse_enabled
114
+ disable_mouse_tracking
115
+ disable_bracketed_paste
116
+ disable_focus_reporting
117
+ show_cursor
118
+ leave_alt_screen
119
+ @input.cooked! if @input.respond_to?(:cooked!)
120
+ end
121
+
122
+ # Re-enters the TUI after a resume (SIGCONT): raw/no-echo input, alt
123
+ # screen, hidden cursor, and the reporting modes that were active before
124
+ # the suspend. The caller is responsible for triggering a repaint.
125
+ def resume
126
+ @input.raw! if @input.respond_to?(:raw!)
127
+ @input.echo = false if @input.respond_to?(:echo=)
128
+ enter_alt_screen
129
+ hide_cursor
130
+ enable_mouse_tracking(motion: @mouse_motion || :drag) if @mouse_was_enabled
131
+ enable_bracketed_paste
132
+ enable_focus_reporting
133
+ clear
134
+ end
135
+
136
+ # The OSC 11 background-color query and the terminators a reply may end with.
137
+ BACKGROUND_QUERY = "\e]11;?\e\\"
138
+ OSC_TERMINATORS = ["\a", "\e\\"].freeze
139
+
140
+ # Queries the terminal's background color via OSC 11 and classifies the
141
+ # reply as :dark or :light. Returns nil when the input cannot be polled,
142
+ # the terminal stays silent past *timeout*, or the reply is unparseable.
143
+ # Call before the event loop starts — a reply arriving later would be
144
+ # read as keyboard input.
145
+ def query_background_color(timeout: 0.15)
146
+ return nil unless @input.respond_to?(:wait_readable)
147
+
148
+ write_control(BACKGROUND_QUERY)
149
+ UI::Background.parse_osc11(read_reply(timeout))
150
+ end
151
+
103
152
  # Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
104
153
  # single keypress in raw mode is not enough: keys pressed while rendering or dispatching
105
154
  # events can otherwise be echoed into the alternate screen before the next read.
@@ -142,13 +191,15 @@ module Charming
142
191
 
143
192
  # Emits the ANSI sequences that enable terminal mouse reporting (press, motion, SGR).
144
193
  # Idempotent: skipped when mouse tracking is already enabled.
145
- def enable_mouse_tracking
194
+ def enable_mouse_tracking(motion: :drag)
146
195
  return if @mouse_enabled
147
196
 
148
197
  write_control("\e[?1000h")
149
198
  write_control("\e[?1002h")
199
+ write_control("\e[?1003h") if motion == :all
150
200
  write_control("\e[?1006h")
151
201
  @mouse_enabled = true
202
+ @mouse_motion = motion
152
203
  end
153
204
 
154
205
  # Emits the ANSI sequences that disable terminal mouse reporting. Idempotent.
@@ -157,7 +208,7 @@ module Charming
157
208
 
158
209
  write_control("\e[?1000l")
159
210
  write_control("\e[?1002l")
160
- write_control("\e[?1003l")
211
+ write_control("\e[?1003l") if @mouse_motion == :all
161
212
  write_control("\e[?1006l")
162
213
  @mouse_enabled = false
163
214
  end
@@ -187,6 +238,12 @@ module Charming
187
238
  end
188
239
  end
189
240
 
241
+ # Writes an out-of-band escape *sequence* (image transmission, clipboard, notification, title)
242
+ # raw — no cursor positioning, line-clearing, or auto-wrap handling that would corrupt it.
243
+ def write_escape(sequence)
244
+ write_control(sequence.payload)
245
+ end
246
+
190
247
  # Enters the alternate screen buffer.
191
248
  def enter_alt_screen
192
249
  write_control(ALT_SCREEN_ON)
@@ -222,6 +279,26 @@ module Charming
222
279
 
223
280
  private
224
281
 
282
+ # Accumulates an OSC reply from the input until a terminator arrives or
283
+ # *timeout* elapses. Returns whatever was read (possibly "").
284
+ def read_reply(timeout)
285
+ reply = +""
286
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
287
+ while (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) > 0
288
+ break unless @input.wait_readable(remaining)
289
+ break if append_reply_chunk(reply).nil?
290
+ break if OSC_TERMINATORS.any? { |terminator| reply.include?(terminator) }
291
+ end
292
+ reply
293
+ end
294
+
295
+ # Reads one available chunk into *reply*; nil at EOF or read error.
296
+ def append_reply_chunk(reply)
297
+ reply << @input.readpartial(64)
298
+ rescue IOError, SystemCallError
299
+ nil
300
+ end
301
+
225
302
  # True when the SIGWINCH flag has been set since the last read_event.
226
303
  def resized?
227
304
  @resized
@@ -154,7 +154,7 @@ module Charming
154
154
  end
155
155
 
156
156
  def widest_ellipsis_width
157
- @widest_ellipsis_width ||= ELLIPSIS_FRAMES.map { |frame| UI::Width.measure(frame) }.max
157
+ @widest_ellipsis_width ||= UI::Width.widest(ELLIPSIS_FRAMES)
158
158
  end
159
159
 
160
160
  # Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
@@ -162,24 +162,7 @@ module Charming
162
162
  def color_at(position)
163
163
  return gradient.first unless indicator_width > 1
164
164
 
165
- blend(gradient.first, gradient.last, position / (indicator_width - 1).to_f)
166
- end
167
-
168
- # Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
169
- # Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
170
- def blend(start_hex, end_hex, amount)
171
- start_rgb = rgb(start_hex)
172
- end_rgb = rgb(end_hex)
173
- mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
174
- "#%02x%02x%02x" % mixed
175
- end
176
-
177
- # Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
178
- def rgb(hex)
179
- value = hex.to_s.delete_prefix("#")
180
- raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
181
-
182
- [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
165
+ UI::Gradient.blend(gradient.first, gradient.last, position / (indicator_width - 1).to_f)
183
166
  end
184
167
 
185
168
  # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Chart plots a numeric *series* into a `width`×`height` box of character cells. `kind: :line`
6
+ # (default) draws a connected line on a {Charming::UI::BrailleCanvas} (subpixel resolution);
7
+ # `kind: :bar` draws vertical eighth-block bars. Pure text — works on every terminal — and
8
+ # composes with `row`/`column`/`box`. Pass an optional `style:` ({Charming::UI::Style}) to colour it.
9
+ class Chart < Component
10
+ # Vertical fill levels for bar mode, empty (0) to full (8 eighths).
11
+ VBARS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"].freeze
12
+
13
+ # *series* is the numeric data. *width*/*height* size the chart in character cells. *kind* is
14
+ # `:line` or `:bar`. *style* optionally paints the result. *theme* is forwarded.
15
+ def initialize(series:, width:, height:, kind: :line, style: nil, theme: nil)
16
+ super(theme: theme)
17
+ @series = series
18
+ @width = width
19
+ @height = height
20
+ @kind = kind
21
+ @style = style
22
+ end
23
+
24
+ # Renders the chart (empty string for an empty series or a non-positive box).
25
+ def render
26
+ return "" if @series.empty? || @width < 1 || @height < 1
27
+
28
+ body = (@kind == :bar) ? bars : line_plot
29
+ @style ? @style.render(body) : body
30
+ end
31
+
32
+ private
33
+
34
+ # A connected line on a braille canvas sized to the cell box.
35
+ def line_plot
36
+ canvas = UI::BrailleCanvas.new(@width * 2, @height * 4)
37
+ points = scaled_points(@width * 2, @height * 4)
38
+ points.each { |x, y| canvas.set(x, y) }
39
+ points.each_cons(2) { |(x0, y0), (x1, y1)| canvas.line(x0, y0, x1, y1) }
40
+ canvas.to_s
41
+ end
42
+
43
+ # Maps the series to canvas pixels: x spread across the width, y inverted (0 at top) and scaled
44
+ # between the series' min and max.
45
+ def scaled_points(pixel_width, pixel_height)
46
+ min, max = @series.minmax
47
+ span = (max - min).to_f
48
+ span = 1.0 if span.zero?
49
+ last = @series.length - 1
50
+ @series.each_with_index.map do |value, index|
51
+ x = last.zero? ? 0 : (index * (pixel_width - 1) / last.to_f).round
52
+ y = ((1 - (value - min) / span) * (pixel_height - 1)).round
53
+ [x, y]
54
+ end
55
+ end
56
+
57
+ # Vertical eighth-block bars, one column per cell, sampled from the series and scaled from a
58
+ # baseline of min(0, series-min).
59
+ def bars
60
+ columns = sample(@series, @width)
61
+ base = [columns.min, 0].min
62
+ span = (columns.max - base).to_f
63
+ span = 1.0 if span.zero?
64
+ eighths = columns.map { |value| ((value - base) / span * @height * 8).round }
65
+ Array.new(@height) do |row|
66
+ from_bottom = @height - 1 - row
67
+ eighths.map { |total| VBARS[(total - from_bottom * 8).clamp(0, 8)] }.join
68
+ end.join("\n")
69
+ end
70
+
71
+ # Resamples *series* to exactly *count* values via nearest-neighbour (identity when sizes match).
72
+ def sample(series, count)
73
+ size = series.length
74
+ return series if size == count
75
+
76
+ Array.new(count) { |index| series[(index * size / count.to_f).floor.clamp(0, size - 1)] }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # The exception message, wrapped to the panel's inner width.
42
42
  def wrapped_message
43
- Charming::Markdown::TextWrapper.new(width: inner_width).wrap(error.message.to_s)
43
+ Charming::UI::TextWrapper.new(width: inner_width).wrap(error.message.to_s)
44
44
  end
45
45
 
46
46
  # The first few backtrace lines with the app root stripped, styled muted.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Filepicker is a directory browser built on List. Enter descends into the
6
+ # highlighted directory or returns `[:selected, absolute_path]` for a file;
7
+ # Backspace (or the "../" entry) goes up, never above the configured root.
8
+ # Dotfiles are hidden until `toggle_hidden`.
9
+ class Filepicker < Component
10
+ PARENT_ENTRY = "../"
11
+
12
+ # The directory currently being browsed.
13
+ attr_reader :current_dir
14
+
15
+ # *root* is the starting directory and the upper boundary for navigation.
16
+ # *show_hidden* includes dotfiles from the start. *height* windows the
17
+ # listing like List's height.
18
+ def initialize(root: Dir.pwd, show_hidden: false, height: nil, theme: nil, keymap: :vim)
19
+ super(theme: theme)
20
+ @root = File.expand_path(root)
21
+ @current_dir = @root
22
+ @show_hidden = show_hidden
23
+ @height = height
24
+ @keymap = keymap
25
+ rebuild_list
26
+ end
27
+
28
+ # The display entries for the current directory: an optional parent entry,
29
+ # then directories ("name/") before files, alphabetically.
30
+ def entries
31
+ list.items
32
+ end
33
+
34
+ # Handles navigation: Enter descends/selects, Backspace ascends, and all
35
+ # other keys delegate to the underlying List.
36
+ def handle_key(event)
37
+ case Charming.key_of(event)
38
+ when :enter then activate(list.selected_item)
39
+ when :backspace then ascend
40
+ else list.handle_key(event)
41
+ end
42
+ end
43
+
44
+ # Shows or hides dotfiles, refreshing the listing. Returns self.
45
+ def toggle_hidden
46
+ @show_hidden = !@show_hidden
47
+ rebuild_list
48
+ self
49
+ end
50
+
51
+ # Renders the current directory's listing via the underlying List.
52
+ def render
53
+ list.render
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :list
59
+
60
+ # Descends into directories, returns files as a selection.
61
+ def activate(entry)
62
+ return nil unless entry
63
+ return ascend if entry == PARENT_ENTRY
64
+ return descend(entry.delete_suffix("/")) if entry.end_with?("/")
65
+
66
+ [:selected, File.join(current_dir, entry)]
67
+ end
68
+
69
+ # Enters *name* under the current directory.
70
+ def descend(name)
71
+ @current_dir = File.join(current_dir, name)
72
+ rebuild_list
73
+ :handled
74
+ end
75
+
76
+ # Moves to the parent directory, unless already at the root.
77
+ def ascend
78
+ return nil if current_dir == @root
79
+
80
+ @current_dir = File.dirname(current_dir)
81
+ rebuild_list
82
+ :handled
83
+ end
84
+
85
+ # Builds a fresh List over the current directory's entries.
86
+ def rebuild_list
87
+ @list = List.new(items: directory_entries, height: @height, theme: theme, keymap: @keymap)
88
+ end
89
+
90
+ # Reads the current directory: parent entry (when below root), then
91
+ # directories before files, each group alphabetical, dotfiles filtered.
92
+ def directory_entries
93
+ names = Dir.children(current_dir).sort
94
+ names = names.reject { |name| name.start_with?(".") } unless @show_hidden
95
+ directories, files = names.partition { |name| File.directory?(File.join(current_dir, name)) }
96
+ parent = (current_dir == @root) ? [] : [PARENT_ENTRY]
97
+ parent + directories.map { |name| "#{name}/" } + files
98
+ end
99
+ end
100
+ end
101
+ end
@@ -32,6 +32,11 @@ module Charming
32
32
  fields << Select.new(name, **field_options(options))
33
33
  end
34
34
 
35
+ # Appends a Multiselect field with the given *options* array.
36
+ def multiselect(name, **options)
37
+ fields << Multiselect.new(name, **field_options(options))
38
+ end
39
+
35
40
  # Appends a Confirm (boolean) field.
36
41
  def confirm(name, **options)
37
42
  fields << Confirm.new(name, **field_options(options))
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Form
6
+ # Multiselect is a multiple-choice Form field backed by a MultiSelectList.
7
+ # Space toggles the highlighted option's checkbox, navigation keys move the
8
+ # highlight, and the checked set (in option order) becomes the field's value.
9
+ # Enter is left to the Form so it advances/submits like every other field.
10
+ class Multiselect < Field
11
+ # *options* is the array of selectable values. *selected_indices* pre-checks
12
+ # options. *max_selections* caps how many can be checked (nil = unlimited).
13
+ # *option_label* extracts the display string (default: `to_s`). All other
14
+ # options are forwarded to Field.
15
+ def initialize(name, options:, selected_indices: [], max_selections: nil, option_label: :to_s.to_proc, **field_options)
16
+ super(name, **field_options)
17
+ @options = options
18
+ @initial_indices = selected_indices
19
+ @max_selections = max_selections
20
+ @option_label = option_label
21
+ end
22
+
23
+ # Binds the field, then ensures the persisted checked set is applied.
24
+ def bind(state)
25
+ super
26
+ ensure_selection
27
+ end
28
+
29
+ # Forwards key events to the underlying MultiSelectList, syncing the checked
30
+ # set and highlight cursor back into the field state. Returns :handled when
31
+ # consumed; Enter (the list's submit) is left unconsumed for the Form.
32
+ def handle_key(event)
33
+ widget = list
34
+ result = widget.handle_key(event)
35
+ return nil if result.is_a?(Array)
36
+ return nil unless result == :handled
37
+
38
+ save_selection(widget)
39
+ :handled
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :options
45
+
46
+ # The default value is the pre-checked options, in option order.
47
+ def default_value
48
+ checked_options(normalized_initial_indices)
49
+ end
50
+
51
+ # Renders the field as "Label: choice, choice".
52
+ def render_control
53
+ "#{label}: #{display_value}"
54
+ end
55
+
56
+ # The checked options' labels joined for compact display.
57
+ def display_value
58
+ Array(value).map { |item| @option_label.call(item) }.join(", ")
59
+ end
60
+
61
+ # Builds a fresh MultiSelectList from the persisted checked set and cursor.
62
+ def list
63
+ MultiSelectList.new(
64
+ items: options,
65
+ selected_indices: field_state[:selected_indices],
66
+ selected_index: field_state[:cursor],
67
+ max_selections: @max_selections,
68
+ label: @option_label,
69
+ theme: theme
70
+ )
71
+ end
72
+
73
+ # Applies the persisted checked set (or the initial one) and highlight cursor.
74
+ def ensure_selection
75
+ checked = field_state[:selected_indices] || normalized_initial_indices
76
+ field_state[:selected_indices] = checked.sort
77
+ field_state[:cursor] ||= 0
78
+ state[:values][name] = checked_options(checked)
79
+ end
80
+
81
+ # Persists the widget's checked set and cursor, and the options as the value.
82
+ def save_selection(widget)
83
+ field_state[:selected_indices] = widget.selected_indices.sort
84
+ field_state[:cursor] = widget.selected_index
85
+ state[:values][name] = widget.selected_items
86
+ end
87
+
88
+ # The options at *indices*, in option order.
89
+ def checked_options(indices)
90
+ indices.sort.map { |index| options[index] }
91
+ end
92
+
93
+ # The initial indices, de-duplicated and clamped to the option range.
94
+ def normalized_initial_indices
95
+ @initial_indices.to_a.map(&:to_i).uniq.select { |index| index.between?(0, options.length - 1) }
96
+ end
97
+
98
+ # The per-field state hash for this field.
99
+ def field_state
100
+ state[:fields][name]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Image displays a {Charming::Image::Source} inline in the terminal, sized to *rows*×*cols*
6
+ # character cells. On a graphics-capable terminal (Phase 1: Ghostty/Kitty) it returns a block of
7
+ # Unicode placeholder cells and registers the image's one-time out-of-band transmission; on other
8
+ # terminals it returns *fallback* so layouts degrade gracefully.
9
+ #
10
+ # Like {Charming::Components::Audio}, the view itself is thin: the {Charming::Image::Source}
11
+ # (held in `session`) owns the bytes and transmit state. The placeholder block is a normal
12
+ # width-`cols` string that composes with `row`/`column`/`box` like any other view output.
13
+ class Image < Component
14
+ # *source* is the {Charming::Image::Source} to display. *rows*/*cols* size the image in
15
+ # character cells. *fallback* is shown when the terminal lacks graphics support. *theme* is
16
+ # forwarded to the view layer.
17
+ def initialize(source:, rows:, cols:, fallback: "", theme: nil)
18
+ super(theme: theme)
19
+ @source = source
20
+ @rows = rows
21
+ @cols = cols
22
+ @fallback = fallback
23
+ end
24
+
25
+ # Returns the placeholder block (registering the transmit once) on a graphics-capable terminal,
26
+ # otherwise the fallback string.
27
+ def render
28
+ return @fallback.to_s unless @source.supports_graphics?
29
+
30
+ unless @source.transmitted?
31
+ Charming::Escape.register(@source.transmit(rows: @rows, cols: @cols))
32
+ @source.mark_transmitted
33
+ end
34
+ @source.placement(rows: @rows, cols: @cols)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -19,20 +19,38 @@ module Charming
19
19
  end: :move_end
20
20
  }.freeze
21
21
 
22
- # The item array and the currently selected index within it.
23
- attr_reader :items, :selected_index
22
+ # The currently selected index (within the filtered view) and active filter query.
23
+ attr_reader :selected_index, :filter
24
24
 
25
25
  # *items* is the array of selectable objects. *selected_index* defaults to 0.
26
26
  # *height* optionally constrains the visible window; *label* is a callable that
27
27
  # extracts the display string from an item (defaults to `to_s`).
28
28
  # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
29
- def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
29
+ # *filter* optionally narrows items by fuzzy-matching the label (see FuzzyMatcher);
30
+ # navigation, rendering, and selection all operate on the filtered view.
31
+ def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim, filter: nil)
30
32
  super(theme: theme)
31
- @items = items
33
+ @source_items = items
32
34
  @selected_index = selected_index
33
35
  @height = height
34
36
  @label = label || :to_s.to_proc
35
37
  @keymap = keymap
38
+ @filter = filter
39
+ clamp_position
40
+ end
41
+
42
+ # The visible items: the source list narrowed by the active filter (best
43
+ # fuzzy match first), or the full source list when no filter is set.
44
+ def items
45
+ return @source_items if filter.nil? || filter.to_s.empty?
46
+
47
+ FuzzyMatcher.filter(filter, @source_items, &@label)
48
+ end
49
+
50
+ # Replaces the filter query (nil clears it) and reclamps the selection to
51
+ # the narrowed view.
52
+ def filter=(query)
53
+ @filter = query
36
54
  clamp_position
37
55
  end
38
56
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Paginator tracks a current page over a collection and renders a compact
6
+ # page indicator: bubbles-style dots ("○ ● ○") or arabic "2/3". Pair it with
7
+ # a List or Table by slicing items through `page_items`.
8
+ class Paginator < Component
9
+ ACTIVE_DOT = "●"
10
+ INACTIVE_DOT = "○"
11
+
12
+ attr_reader :page, :per_page, :total
13
+
14
+ # *total* is the collection size, *per_page* the page size, *page* the
15
+ # 0-based starting page, and *format* either :dots (default) or :arabic.
16
+ def initialize(total:, per_page:, page: 0, format: :dots, theme: nil)
17
+ super(theme: theme)
18
+ @total = [total.to_i, 0].max
19
+ @per_page = [per_page.to_i, 1].max
20
+ @format = format
21
+ @page = page.to_i.clamp(0, page_count - 1)
22
+ end
23
+
24
+ # The number of pages — at least 1, even for an empty collection.
25
+ def page_count
26
+ [(total.to_f / per_page).ceil, 1].max
27
+ end
28
+
29
+ # The slice of *items* belonging to the current page.
30
+ def page_items(items)
31
+ items[page * per_page, per_page] || []
32
+ end
33
+
34
+ # Advances one page, clamping at the last. Returns self.
35
+ def next_page
36
+ @page = [page + 1, page_count - 1].min
37
+ self
38
+ end
39
+
40
+ # Steps back one page, clamping at the first. Returns self.
41
+ def prev_page
42
+ @page = [page - 1, 0].max
43
+ self
44
+ end
45
+
46
+ # Renders the page indicator in the configured format.
47
+ def render
48
+ return "#{page + 1}/#{page_count}" if @format == :arabic
49
+
50
+ Array.new(page_count) { |index| (index == page) ? ACTIVE_DOT : INACTIVE_DOT }.join(" ")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -12,14 +12,17 @@ module Charming
12
12
 
13
13
  # *total* is the maximum unit count. *complete* and *incomplete* are the characters used
14
14
  # for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
15
- # future format variants. *label* is an optional suffix shown after the bar.
16
- def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
15
+ # future format variants. *label* is an optional suffix shown after the bar. *gradient*
16
+ # is an optional ["#rrggbb", "#rrggbb"] pair that colors the filled region with a sweep
17
+ # across the full bar width.
18
+ def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil, gradient: nil)
17
19
  super()
18
20
  @total = [total.to_i, 0].max
19
21
  @complete = complete.to_s
20
22
  @incomplete = incomplete.to_s
21
23
  @bar_format = bar_format.to_sym
22
24
  @label = label
25
+ @gradient = gradient
23
26
  @current = 0
24
27
  end
25
28
 
@@ -45,8 +48,7 @@ module Charming
45
48
  def render
46
49
  width = [@total, 1].max
47
50
  completed = completed_width(width)
48
- incomplete = width - completed
49
- bar = (@complete * completed) + (@incomplete * incomplete)
51
+ bar = filled_cells(completed, width) + (@incomplete * (width - completed))
50
52
  result = "[" + bar + "]"
51
53
 
52
54
  return result unless @label
@@ -54,8 +56,28 @@ module Charming
54
56
  "#{result} #{@label}"
55
57
  end
56
58
 
59
+ # The current completion as an integer percentage (0-100).
60
+ def percent
61
+ return 0 unless @total.positive?
62
+
63
+ ((@current * 100) / @total.to_f).round
64
+ end
65
+
57
66
  private
58
67
 
68
+ # The filled portion of the bar: plain characters, or — with a gradient —
69
+ # each cell colored along a sweep spanning the full bar width, so the
70
+ # visible colors stay stable as the bar fills.
71
+ def filled_cells(completed, width)
72
+ return @complete * completed unless @gradient
73
+
74
+ span = [width - 1, 1].max
75
+ Array.new(completed) do |index|
76
+ color = UI::Gradient.blend(@gradient.first, @gradient.last, index.to_f / span)
77
+ UI::Style.new(foreground: color).render(@complete)
78
+ end.join
79
+ end
80
+
59
81
  # Returns the number of `complete` characters to draw, rounded to the nearest integer.
60
82
  def completed_width(width)
61
83
  return 0 unless @total.positive?