charming 0.2.1 → 0.2.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/key_dispatch.rb +113 -0
  6. data/lib/charming/controller/session_state.rb +11 -0
  7. data/lib/charming/controller/terminal.rb +33 -0
  8. data/lib/charming/controller.rb +13 -40
  9. data/lib/charming/escape.rb +81 -0
  10. data/lib/charming/events/mouse_event.rb +22 -9
  11. data/lib/charming/generators/app_file_generator.rb +29 -0
  12. data/lib/charming/generators/app_generator.rb +13 -25
  13. data/lib/charming/generators/base.rb +5 -0
  14. data/lib/charming/generators/layout_generator.rb +155 -0
  15. data/lib/charming/generators/screen_generator.rb +0 -29
  16. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  17. data/lib/charming/generators/templates/app/README.md.template +15 -1
  18. data/lib/charming/generators/templates/app/application.template +0 -6
  19. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  20. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  21. data/lib/charming/generators/templates/app/layout.template +4 -93
  22. data/lib/charming/generators/templates/app/routes.template +3 -1
  23. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  24. data/lib/charming/image/protocol/kitty.rb +133 -0
  25. data/lib/charming/image/protocol.rb +18 -0
  26. data/lib/charming/image/source.rb +95 -0
  27. data/lib/charming/image/terminal.rb +52 -0
  28. data/lib/charming/image/transmit.rb +11 -0
  29. data/lib/charming/image.rb +21 -0
  30. data/lib/charming/internal/event_loop.rb +155 -0
  31. data/lib/charming/internal/terminal/adapter.rb +14 -0
  32. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  33. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  34. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  35. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  36. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  37. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  38. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  39. data/lib/charming/presentation/components/chart.rb +80 -0
  40. data/lib/charming/presentation/components/error_screen.rb +1 -1
  41. data/lib/charming/presentation/components/filepicker.rb +101 -0
  42. data/lib/charming/presentation/components/form/builder.rb +5 -0
  43. data/lib/charming/presentation/components/form/field.rb +5 -0
  44. data/lib/charming/presentation/components/form/input.rb +14 -4
  45. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  46. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  47. data/lib/charming/presentation/components/form.rb +6 -0
  48. data/lib/charming/presentation/components/image.rb +38 -0
  49. data/lib/charming/presentation/components/list.rb +22 -4
  50. data/lib/charming/presentation/components/modal.rb +4 -4
  51. data/lib/charming/presentation/components/paginator.rb +54 -0
  52. data/lib/charming/presentation/components/progressbar.rb +26 -4
  53. data/lib/charming/presentation/components/sparkline.rb +38 -0
  54. data/lib/charming/presentation/components/spinner.rb +22 -3
  55. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  56. data/lib/charming/presentation/components/table.rb +42 -1
  57. data/lib/charming/presentation/components/text_area.rb +3 -4
  58. data/lib/charming/presentation/components/text_input.rb +11 -6
  59. data/lib/charming/presentation/components/time_display.rb +20 -0
  60. data/lib/charming/presentation/components/timer.rb +43 -0
  61. data/lib/charming/presentation/components/toast.rb +4 -3
  62. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  63. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  64. data/lib/charming/presentation/components/viewport.rb +11 -1
  65. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  66. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  67. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  68. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  69. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  70. data/lib/charming/presentation/ui/background.rb +58 -0
  71. data/lib/charming/presentation/ui/border.rb +14 -1
  72. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  73. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  74. data/lib/charming/presentation/ui/canvas.rb +1 -0
  75. data/lib/charming/presentation/ui/gradient.rb +47 -0
  76. data/lib/charming/presentation/ui/style.rb +119 -19
  77. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  78. data/lib/charming/presentation/ui/truncate.rb +29 -0
  79. data/lib/charming/presentation/ui/width.rb +11 -0
  80. data/lib/charming/presentation/ui.rb +52 -11
  81. data/lib/charming/presentation/view.rb +8 -6
  82. data/lib/charming/response.rb +11 -6
  83. data/lib/charming/runtime.rb +167 -90
  84. data/lib/charming/version.rb +1 -1
  85. data/lib/charming/welcome/controller.rb +23 -0
  86. data/lib/charming/welcome/show_view.rb +113 -0
  87. data/lib/charming/welcome.rb +13 -0
  88. metadata +34 -9
  89. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  90. data/lib/charming/generators/templates/app/home_state.template +0 -7
  91. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  92. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  93. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  94. data/lib/charming/generators/templates/app/view.template +0 -21
@@ -5,21 +5,23 @@ module Charming
5
5
  module Terminal
6
6
  # MouseParser parses raw terminal escape sequences into MouseEvent objects.
7
7
  # Supports both modern SGR sequences (the most common, used by current terminals)
8
- # and the older 3-byte legacy sequences. The public API is class methods; no
9
- # instance state is required.
8
+ # and the older 3-byte legacy sequences. Both encodings pack modifiers and motion
9
+ # into the button code: bit 2 shift, bit 3 alt/meta, bit 4 ctrl, bit 5 motion.
10
+ # SGR signals release with a final "m" instead of "M". The public API is class
11
+ # methods; no instance state is required.
10
12
  class MouseParser
11
- # Matches an SGR-encoded mouse sequence: "\e[<button;col;row[mode]M"
12
- SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)([HmMhCc]?)(M|m)/
13
+ # Matches an SGR-encoded mouse sequence: "\e[<button;col;row" + "M" (press) or "m" (release).
14
+ SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)(M|m)/
13
15
 
14
16
  # Matches the legacy 3-byte mouse sequence: "\e[M" followed by 3 bytes.
15
17
  LEGACY_PATTERN = /\e\[M(.{3})/
16
18
 
17
- # Maps raw button codes to semantic symbols used by MouseEvent#button_name.
18
- BUTTON_MAP = {
19
- 0 => :left, 1 => :middle, 2 => :right, 3 => :release,
20
- 64 => :scroll_up, 65 => :scroll_down,
21
- 66 => :scroll_up, 67 => :scroll_down
22
- }.freeze
19
+ # Button-code bits for modifier keys and motion (shared by SGR and legacy).
20
+ SHIFT_BIT = 4
21
+ ALT_BIT = 8
22
+ CTRL_BIT = 16
23
+ MOTION_BIT = 32
24
+ FLAG_BITS = SHIFT_BIT | ALT_BIT | CTRL_BIT | MOTION_BIT
23
25
 
24
26
  # Returns true when *raw* looks like a recognizable mouse sequence (SGR or legacy).
25
27
  # Lets the TTYBackend short-circuit and dispatch to MouseParser without allocation.
@@ -41,23 +43,13 @@ module Charming
41
43
  nil
42
44
  end
43
45
 
44
- # Parses an SGR-format mouse sequence. Decodes button code, 1-based (col, row),
45
- # the modifier "C" (ctrl) and "M" (shift) suffix, and the highlight alt (256-color)
46
- # sequence as a heuristic for the alt modifier.
46
+ # Parses an SGR-format mouse sequence: button code with flag bits, 1-based
47
+ # (col, row), and the press/release final byte.
47
48
  def self.parse_sgr(raw)
48
49
  match = raw.match(SGR_PATTERN)
49
50
  return nil unless match
50
51
 
51
- button_code = match[1].to_i
52
- col = match[2].to_i - 1
53
- row = match[3].to_i - 1
54
- mode = match[4]
55
-
56
- ctrl = mode == "C"
57
- alt = raw.include?("\e[38;5;")
58
- shift = mode == "M"
59
-
60
- Events::MouseEvent.new(button: button_code, x: col, y: row, ctrl: ctrl, alt: alt, shift: shift)
52
+ build_event(match[1].to_i, match[2].to_i - 1, match[3].to_i - 1, release: match[4] == "m")
61
53
  end
62
54
 
63
55
  # Parses a legacy 3-byte mouse sequence. Each of the 3 bytes has 32 subtracted
@@ -69,12 +61,25 @@ module Charming
69
61
  bytes = match[1].bytes
70
62
  return nil unless bytes.length == 3
71
63
 
72
- button_code = bytes[0] - 32
73
- col = bytes[1] - 32
74
- row = bytes[2] - 32
64
+ build_event(bytes[0] - 32, bytes[1] - 32, bytes[2] - 32, release: false)
65
+ end
75
66
 
76
- Events::MouseEvent.new(button: button_code, x: col, y: row)
67
+ # Builds a MouseEvent from a raw button *code*, splitting out the modifier
68
+ # and motion flag bits so `button` carries only the button/wheel identity.
69
+ def self.build_event(code, x, y, release:)
70
+ Events::MouseEvent.new(
71
+ button: code & ~FLAG_BITS,
72
+ x: x,
73
+ y: y,
74
+ shift: code.anybits?(SHIFT_BIT),
75
+ alt: code.anybits?(ALT_BIT),
76
+ ctrl: code.anybits?(CTRL_BIT),
77
+ motion: code.anybits?(MOTION_BIT),
78
+ release: release
79
+ )
77
80
  end
81
+
82
+ private_class_method :build_event
78
83
  end
79
84
  end
80
85
  end
@@ -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.
@@ -67,6 +67,13 @@ module Charming
67
67
  end
68
68
  end
69
69
 
70
+ # Forwards pasted text to the inner input and re-filters the suggestions.
71
+ def handle_paste(event)
72
+ result = @input.handle_paste(event)
73
+ clamp_selection if result
74
+ result
75
+ end
76
+
70
77
  # Renders the input row followed by the suggestion dropdown.
71
78
  def render
72
79
  [input_line, *suggestion_lines].join("\n")
@@ -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))
@@ -41,6 +41,11 @@ module Charming
41
41
  nil
42
42
  end
43
43
 
44
+ # Default paste handler returns nil (paste ignored). Text fields override.
45
+ def handle_paste(_event)
46
+ nil
47
+ end
48
+
44
49
  # Renders the field as a control line prefixed with ">" (active) or " " (inactive),
45
50
  # optionally followed by the help line and any error lines.
46
51
  def render(active: false)
@@ -27,17 +27,27 @@ module Charming
27
27
  # Forwards key events to the underlying TextInput, syncing the value and cursor
28
28
  # back into the form state. Returns :handled when the event was consumed.
29
29
  def handle_key(event)
30
+ forward_to_input(:handle_key, event)
31
+ end
32
+
33
+ # Forwards pasted text to the underlying TextInput the same way.
34
+ def handle_paste(event)
35
+ forward_to_input(:handle_paste, event)
36
+ end
37
+
38
+ private
39
+
40
+ # Sends *message* to a freshly-built TextInput and, when the widget consumed
41
+ # the event, persists the resulting value and cursor into the form state.
42
+ def forward_to_input(message, event)
30
43
  text_input = input
31
- result = text_input.handle_key(event)
32
- return nil unless result == :handled
44
+ return nil unless text_input.public_send(message, event) == :handled
33
45
 
34
46
  state[:values][name] = text_input.value
35
47
  field_state[:cursor] = text_input.cursor
36
48
  :handled
37
49
  end
38
50
 
39
- private
40
-
41
51
  # The default value for a freshly-bound field is the *value* passed at construction.
42
52
  def default_value
43
53
  @initial_value
@@ -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
@@ -29,15 +29,12 @@ module Charming
29
29
  # Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
30
30
  # and preferred column back into the form state. Returns :handled when consumed.
31
31
  def handle_key(event)
32
- area = text_area
33
- result = area.handle_key(event)
34
- return nil unless result == :handled
32
+ forward_to_text_area(:handle_key, event)
33
+ end
35
34
 
36
- state[:values][name] = area.value
37
- field_state[:cursor] = area.cursor
38
- field_state[:offset] = area.offset
39
- field_state[:preferred_column] = area.preferred_column
40
- :handled
35
+ # Forwards pasted text (newlines preserved) to the underlying TextArea the same way.
36
+ def handle_paste(event)
37
+ forward_to_text_area(:handle_paste, event)
41
38
  end
42
39
 
43
40
  # Renders the field with its label on the first line, body lines indented, and
@@ -50,6 +47,19 @@ module Charming
50
47
 
51
48
  private
52
49
 
50
+ # Sends *message* to a freshly-built TextArea and, when the widget consumed the
51
+ # event, persists the value, cursor, offset, and preferred column into form state.
52
+ def forward_to_text_area(message, event)
53
+ area = text_area
54
+ return nil unless area.public_send(message, event) == :handled
55
+
56
+ state[:values][name] = area.value
57
+ field_state[:cursor] = area.cursor
58
+ field_state[:offset] = area.offset
59
+ field_state[:preferred_column] = area.preferred_column
60
+ :handled
61
+ end
62
+
53
63
  # The default value for a freshly-bound field is the *value* passed at construction.
54
64
  def default_value
55
65
  @initial_value
@@ -38,6 +38,12 @@ module Charming
38
38
  advance_or_submit if key == :enter
39
39
  end
40
40
 
41
+ # Forwards pasted text to the currently focused field. Text fields insert it
42
+ # at their cursor; other fields ignore it.
43
+ def handle_paste(event)
44
+ current_field&.handle_paste(event)
45
+ end
46
+
41
47
  # Forms accept free-typed text (their input/textarea fields do), so printable
42
48
  # characters route here before global/content key bindings.
43
49
  def captures_text?