charming 0.2.0 → 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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
@@ -16,7 +16,9 @@ module Charming
16
16
  up: :move_up,
17
17
  down: :move_down,
18
18
  home: :move_home,
19
- end: :move_end
19
+ end: :move_end,
20
+ page_up: :page_up,
21
+ page_down: :page_down
20
22
  }.freeze
21
23
 
22
24
  # Number of terminal rows occupied by the table's top border and header line. Used by
@@ -29,12 +31,15 @@ module Charming
29
31
  # *header* is an array of column labels. *rows* is the array of body rows (each either a
30
32
  # String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
31
33
  # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
32
- def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
33
- super()
34
+ # *height* optionally limits the visible body rows; the window auto-scrolls to keep
35
+ # the selection in view, and page up/down move by a full window.
36
+ def initialize(header:, rows: [], selected_index: 0, keymap: :vim, theme: nil, height: nil)
37
+ super(theme: theme)
34
38
  @header = Array(header).map(&:to_s)
35
39
  @rows = Array(rows)
36
40
  @selected_index = clamp_index(selected_index)
37
41
  @keymap = keymap
42
+ @height = height
38
43
  end
39
44
 
40
45
  # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
@@ -48,16 +53,17 @@ module Charming
48
53
  end
49
54
  end
50
55
 
51
- # Handles mouse events: a click within the body area selects the clicked row.
56
+ # Handles mouse events: a click within the body area selects the clicked row
57
+ # (relative to the visible window when a height is set).
52
58
  # Returns :handled on a successful click.
53
59
  def handle_mouse(event)
54
60
  return nil if rows.empty?
55
61
  return nil unless event.respond_to?(:click?) && event.click?
56
62
 
57
63
  clicked = event.y - HEADER_HEIGHT
58
- return nil if clicked.negative? || clicked >= rows.length
64
+ return nil if clicked.negative? || clicked >= visible_row_count
59
65
 
60
- @selected_index = clicked
66
+ @selected_index = viewport_start + clicked
61
67
  :handled
62
68
  end
63
69
 
@@ -66,12 +72,30 @@ module Charming
66
72
  rows[selected_index]
67
73
  end
68
74
 
75
+ # Sorts the body rows by *column* (a header label or 0-based index).
76
+ # Numeric-looking cells compare numerically; everything else as strings.
77
+ # The sorted column is marked ▲/▼ in the rendered header. Returns self.
78
+ def sort_by!(column, direction: :asc)
79
+ @sort_column = column_index(column)
80
+ @sort_direction = direction
81
+ sorted = @rows.sort_by { |row| sort_key(row) }
82
+ @rows = (direction == :desc) ? sorted.reverse : sorted
83
+ self
84
+ end
85
+
86
+ # Sorts by *column*, flipping the direction on repeated calls for the same
87
+ # column (ascending first). Returns self.
88
+ def toggle_sort(column)
89
+ flipping = @sort_column == column_index(column) && @sort_direction == :asc
90
+ sort_by!(column, direction: flipping ? :desc : :asc)
91
+ end
92
+
69
93
  # Renders the table to a string. Returns a placeholder when both header and rows are empty.
70
94
  def render
71
95
  return "(empty table)" if header.empty? && rows.empty?
72
96
 
73
97
  normalized = rows.map { |row| normalize_row(row) }
74
- lines = TTY::Table.new(header: header, rows: normalized)
98
+ lines = TTY::Table.new(header: sort_marked_header, rows: normalized)
75
99
  .render(:unicode)
76
100
  .lines(chomp: true)
77
101
 
@@ -95,7 +119,8 @@ module Charming
95
119
  kept + [merged]
96
120
  end
97
121
 
98
- # Applies the selected-row highlight and trims unused body rows below the actual row count.
122
+ # Applies the selected-row highlight, windows the body to the configured height,
123
+ # and trims unused body rows below the actual row count.
99
124
  def compact_layout(lines)
100
125
  return lines.join("\n") if lines.length < 4
101
126
 
@@ -103,13 +128,37 @@ module Charming
103
128
  body = rest.first(rows.length)
104
129
  bottom = rest[rows.length]
105
130
 
106
- highlighted = body.each_with_index.map do |line, index|
107
- (index == selected_index) ? "\e[7m#{line}\e[m" : line
131
+ window = body[viewport_start, visible_row_count] || []
132
+ highlighted = window.each_with_index.map do |line, index|
133
+ ((viewport_start + index) == selected_index) ? theme.selected.render(line) : line
108
134
  end
109
135
 
110
136
  [top, header_line, *highlighted, bottom].compact.join("\n")
111
137
  end
112
138
 
139
+ # The top body row of the visible window (0 when no height is set), keeping the
140
+ # selection in view.
141
+ def viewport_start
142
+ return 0 unless @height
143
+
144
+ Layout.selected_window_start(selected_index: selected_index, item_count: rows.length, window_size: @height)
145
+ end
146
+
147
+ # The number of body rows shown at once.
148
+ def visible_row_count
149
+ @height ? [@height, rows.length].min : rows.length
150
+ end
151
+
152
+ # Moves the selection up by one window.
153
+ def page_up
154
+ @selected_index = [selected_index - visible_row_count, 0].max
155
+ end
156
+
157
+ # Moves the selection down by one window.
158
+ def page_down
159
+ @selected_index = [selected_index + visible_row_count, rows.length - 1].min
160
+ end
161
+
113
162
  # Moves the selection up one row.
114
163
  def move_up
115
164
  @selected_index -= 1 if selected_index.positive?
@@ -130,6 +179,29 @@ module Charming
130
179
  @selected_index = rows.length - 1
131
180
  end
132
181
 
182
+ # Resolves *column* (label or 0-based index) to a column index.
183
+ def column_index(column)
184
+ return column if column.is_a?(Integer) && column.between?(0, header.length - 1)
185
+
186
+ header.index(column.to_s) ||
187
+ raise(ArgumentError, "unknown column: #{column.inspect} (columns: #{header.join(", ")})")
188
+ end
189
+
190
+ # The comparable key for *row* at the active sort column: [0, number] for
191
+ # numeric-looking cells, [1, string] otherwise, so mixed columns still sort.
192
+ def sort_key(row)
193
+ cell = normalize_row(row)[@sort_column].to_s
194
+ cell.match?(/\A-?\d+(\.\d+)?\z/) ? [0, cell.to_f] : [1, cell]
195
+ end
196
+
197
+ # The header with the sorted column marked ▲ (ascending) or ▼ (descending).
198
+ def sort_marked_header
199
+ return header unless @sort_column
200
+
201
+ marker = (@sort_direction == :desc) ? "▼" : "▲"
202
+ header.each_with_index.map { |label, index| (index == @sort_column) ? "#{label} #{marker}" : label }
203
+ end
204
+
133
205
  # Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
134
206
  def clamp_index(value)
135
207
  return 0 if rows.empty?
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Charming
4
4
  module Components
5
- # TextArea is a multi-line text editor component. Supports character insertion (with
6
- # newline insertion via Shift+Enter or Ctrl+J), cursor movement (left/right/up/down,
7
- # home/end, page up/down), deletion (backspace/delete), and scrolling for long buffers.
8
- # Vertical movement preserves a "preferred column" so left/right navigation feels stable.
5
+ # TextArea is a multi-line text editor component. Supports character insertion, newline
6
+ # insertion (plain Enter by default, plus Shift+Enter/Ctrl+J/Ctrl+N), cursor movement
7
+ # (left/right/up/down, home/end, page up/down), deletion (backspace/delete), and scrolling
8
+ # for long buffers. Vertical movement preserves a "preferred column" so left/right
9
+ # navigation feels stable.
9
10
  class TextArea < Component
10
11
  # The current text value, cursor byte offset, top-visible row offset, and remembered
11
12
  # column for vertical navigation, respectively.
@@ -15,7 +16,9 @@ module Charming
15
16
  # *height* constrain the rendered output. *cursor* defaults to the end of the value.
16
17
  # *offset* is the top-visible row. *preferred_column* is the column to resume at on
17
18
  # vertical movement (defaults to the current column on first use).
18
- def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
19
+ # *enter_newline* (default true) makes plain Enter insert a newline set false when a
20
+ # host widget wants Enter for itself (the key then falls through unhandled).
21
+ def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil, enter_newline: true)
19
22
  super()
20
23
  @value = value.dup
21
24
  @placeholder = placeholder
@@ -24,10 +27,16 @@ module Charming
24
27
  @cursor = cursor || @value.length
25
28
  @offset = offset
26
29
  @preferred_column = preferred_column
30
+ @enter_newline = enter_newline
27
31
  clamp_position
28
32
  ensure_cursor_visible
29
33
  end
30
34
 
35
+ # Free-typed characters belong to this component while it is focused.
36
+ def captures_text?
37
+ true
38
+ end
39
+
31
40
  # Routes key events to the appropriate cursor/text mutation. Returns :handled when the
32
41
  # event was consumed, nil otherwise.
33
42
  def handle_key(event)
@@ -52,6 +61,14 @@ module Charming
52
61
  :handled
53
62
  end
54
63
 
64
+ # Inserts pasted text at the cursor. Newlines are preserved; other control
65
+ # characters (and CRLF carriage returns) are stripped. Returns :handled.
66
+ def handle_paste(event)
67
+ sanitized = event.text.to_s.tr("\r", "").gsub(/[^[:print:]\n]/, "")
68
+ insert(sanitized) unless sanitized.empty?
69
+ :handled
70
+ end
71
+
55
72
  # Renders the visible portion of the text buffer (scrolled to `offset`), with each
56
73
  # visible line either clipped to `width` or padded to it.
57
74
  def render
@@ -62,11 +79,17 @@ module Charming
62
79
 
63
80
  attr_reader :placeholder, :width, :height
64
81
 
65
- # True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
82
+ # True when the event represents a newline request. Plain Enter inserts a newline
83
+ # by default (the natural expectation in a text editor); Shift+Enter, Ctrl+J, and
84
+ # Ctrl+N always work, even when `enter_newline: false` reserves plain Enter for the
85
+ # host widget. (Shift+Enter is indistinguishable from Enter in many terminals, so
86
+ # Ctrl+N remains the TTY-safe fallback in that mode.)
66
87
  def newline_event?(event)
67
88
  key = Charming.key_of(event)
89
+ return true if key == :enter && @enter_newline
68
90
  return true if key == :enter && event.respond_to?(:shift) && event.shift
69
91
  return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
92
+ return true if key == :n && event.respond_to?(:ctrl) && event.ctrl
70
93
 
71
94
  false
72
95
  end
@@ -162,12 +185,13 @@ module Charming
162
185
  end
163
186
 
164
187
  # Moves the cursor vertically by *delta* rows. Stays within the line count and uses
165
- # `preferred_column` so up/down movement feels stable on short lines.
188
+ # `preferred_column` (in display columns) so up/down movement feels stable on short lines.
166
189
  def move_vertical(delta)
167
190
  row, column = cursor_position
168
191
  target_row = (row + delta).clamp(0, lines.length - 1)
169
192
  @preferred_column ||= column
170
- @cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
193
+ target_line = lines.fetch(target_row, "")
194
+ @cursor = line_start(target_row) + char_offset_at_display_col(target_line, @preferred_column)
171
195
  ensure_cursor_visible
172
196
  end
173
197
 
@@ -177,12 +201,14 @@ module Charming
177
201
  end
178
202
 
179
203
  # Returns the cursor's current position as `[row, column]`, where row is the zero-based
180
- # line index and column is the character offset within that line.
204
+ # line index and column is the *display-column* offset within that line (wide characters
205
+ # such as emoji or CJK occupy two display columns each).
181
206
  def cursor_position
182
207
  before = value[0...cursor].to_s
183
208
  row = before.count("\n")
184
209
  last_newline = before.rindex("\n")
185
- column = last_newline ? before.length - last_newline - 1 : before.length
210
+ line_before_cursor = last_newline ? before[(last_newline + 1)..] : before
211
+ column = UI::Width.measure(line_before_cursor)
186
212
  [row, column]
187
213
  end
188
214
 
@@ -196,6 +222,17 @@ module Charming
196
222
  lines.fetch(row, "").length
197
223
  end
198
224
 
225
+ # Returns the character offset within *line* where the display column reaches *display_col*.
226
+ # Stops at the last character when the line is shorter than *display_col*.
227
+ def char_offset_at_display_col(line, display_col)
228
+ col = 0
229
+ line.each_char.with_index do |char, idx|
230
+ return idx if col >= display_col
231
+ col += UI::Width.measure(char)
232
+ end
233
+ line.length
234
+ end
235
+
199
236
  # Splits the value into an array of lines (preserving trailing empty lines).
200
237
  def lines
201
238
  value.empty? ? [""] : value.split("\n", -1)
@@ -222,8 +259,7 @@ module Charming
222
259
  def render_line(line)
223
260
  return line unless width
224
261
 
225
- clipped = UI.visible_slice(line, 0, width)
226
- clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
262
+ UI::Width.pad_to(UI.visible_slice(line, 0, width), width)
227
263
  end
228
264
 
229
265
  # Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
@@ -6,6 +6,10 @@ module Charming
6
6
  # cursor movement (left/right/home/end), and deletion (backspace/delete). The component
7
7
  # exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
8
8
  # is given, the rendered output is padded to that width via a UI::Style.
9
+ #
10
+ # Options:
11
+ # - `masked: true` renders every character as `*` (password entry)
12
+ # - `history: [...]` enables REPL-style recall — up/down cycle through prior values
9
13
  class TextInput < Component
10
14
  include KeyboardHandler
11
15
 
@@ -26,23 +30,49 @@ module Charming
26
30
 
27
31
  # *value* is the initial text. *placeholder* is shown when the value is empty.
28
32
  # *width* optionally constrains the rendered output width; *cursor* defaults to the end.
29
- def initialize(value: "", placeholder: "", width: nil, cursor: nil)
33
+ # *masked* renders characters as `*`. *history* is an array of prior values cycled
34
+ # with up/down (most recent last, like a shell).
35
+ def initialize(value: "", placeholder: "", width: nil, cursor: nil, masked: false, history: nil)
30
36
  super()
31
37
  @value = value.dup
32
38
  @placeholder = placeholder
33
39
  @width = width
34
40
  @cursor = cursor || @value.length
41
+ @masked = masked
42
+ @history = history
43
+ @history_index = nil
44
+ @draft = nil
35
45
  clamp_position
36
46
  end
37
47
 
38
- # Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
39
- # Returns :handled when the event was consumed, nil otherwise.
48
+ # Free-typed characters belong to this component while it is focused.
49
+ def captures_text?
50
+ true
51
+ end
52
+
53
+ # Handles key events. Inserts printable characters, submits on Enter
54
+ # (returning `[:submitted, value]` so a focused slot dispatches
55
+ # `<slot>_submitted(value)`), recalls history on up/down (when enabled),
56
+ # otherwise dispatches via KEY_ACTIONS.
57
+ # Returns :handled or `[:submitted, value]` when the event was consumed, nil otherwise.
40
58
  def handle_key(event)
41
59
  return :handled if character_event?(event) && insert(event.char)
42
60
 
61
+ key = Charming.key_of(event)
62
+ return [:submitted, value] if key == :enter
63
+ return :handled if history_event(key)
64
+
43
65
  super
44
66
  end
45
67
 
68
+ # Inserts pasted text at the cursor (newlines and control characters are
69
+ # stripped — this is a single-line input). Returns :handled.
70
+ def handle_paste(event)
71
+ sanitized = event.text.to_s.gsub(/[[:cntrl:]]/, "")
72
+ insert(sanitized) unless sanitized.empty?
73
+ :handled
74
+ end
75
+
46
76
  # Renders the value with a cursor marker. When *width* was given at construction, the
47
77
  # output is padded to that width via the configured style.
48
78
  def render
@@ -105,12 +135,62 @@ module Charming
105
135
  @value = value[0...cursor] + value[(cursor + 1)..]
106
136
  end
107
137
 
138
+ # Cycles through history on :up / :down. Returns true when the event was consumed.
139
+ def history_event(key)
140
+ return false unless @history && !@history.empty?
141
+
142
+ case key
143
+ when :up then recall_previous
144
+ when :down then recall_next
145
+ else false
146
+ end
147
+ end
148
+
149
+ # Steps back through history (saving the in-progress draft first).
150
+ def recall_previous
151
+ if @history_index.nil?
152
+ @draft = value
153
+ @history_index = @history.length - 1
154
+ elsif @history_index.positive?
155
+ @history_index -= 1
156
+ end
157
+ replace_value(@history[@history_index])
158
+ true
159
+ end
160
+
161
+ # Steps forward through history; past the newest entry restores the draft.
162
+ def recall_next
163
+ return false if @history_index.nil?
164
+
165
+ @history_index += 1
166
+ if @history_index >= @history.length
167
+ @history_index = nil
168
+ replace_value(@draft.to_s)
169
+ else
170
+ replace_value(@history[@history_index])
171
+ end
172
+ true
173
+ end
174
+
175
+ # Replaces the value and moves the cursor to the end.
176
+ def replace_value(new_value)
177
+ @value = new_value.dup
178
+ @cursor = @value.length
179
+ end
180
+
108
181
  # Renders the value with a "|" cursor marker at the current position. When the value is
109
- # empty, the placeholder is rendered instead, preceded by the cursor marker.
182
+ # empty, the placeholder is rendered instead, preceded by the cursor marker. Masked
183
+ # inputs render `*` per character.
110
184
  def render_value
111
185
  return cursor_marker + placeholder if value.empty?
112
186
 
113
- value[0...cursor] + cursor_marker + value[cursor..]
187
+ shown = display_value
188
+ shown[0...cursor] + cursor_marker + shown[cursor..]
189
+ end
190
+
191
+ # The value as displayed: masked inputs substitute `*` for every character.
192
+ def display_value
193
+ @masked ? "*" * value.length : value
114
194
  end
115
195
 
116
196
  # The literal character used to mark the cursor position in `render`.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # TimeDisplay formats whole-second durations as clock strings: "mm:ss", or
6
+ # "h:mm:ss" once an hour is reached. Shared by Timer and Stopwatch.
7
+ module TimeDisplay
8
+ module_function
9
+
10
+ def clock(total_seconds)
11
+ seconds = [total_seconds.to_i, 0].max
12
+ hours, remainder = seconds.divmod(3600)
13
+ minutes, secs = remainder.divmod(60)
14
+ return format("%d:%02d:%02d", hours, minutes, secs) if hours.positive?
15
+
16
+ format("%02d:%02d", minutes, secs)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Timer is a countdown display. Drive it from a controller timer: call
6
+ # `tick` per interval and check `expired?` to act when time runs out.
7
+ class Timer < Component
8
+ attr_reader :duration, :remaining, :label
9
+
10
+ # *duration* is the countdown length in seconds. *label* is an optional
11
+ # suffix shown after the time.
12
+ def initialize(duration:, label: nil, theme: nil)
13
+ super(theme: theme)
14
+ @duration = [duration.to_i, 0].max
15
+ @remaining = @duration
16
+ @label = label
17
+ end
18
+
19
+ # Counts down by *seconds* (default 1), clamping at zero. Returns self.
20
+ def tick(seconds = 1)
21
+ @remaining = [@remaining - seconds.to_i, 0].max
22
+ self
23
+ end
24
+
25
+ # True once the countdown has reached zero.
26
+ def expired?
27
+ remaining.zero?
28
+ end
29
+
30
+ # Restores the full duration. Returns self.
31
+ def reset
32
+ @remaining = duration
33
+ self
34
+ end
35
+
36
+ # Renders the remaining time (with the label appended when present).
37
+ def render
38
+ clock = TimeDisplay.clock(remaining)
39
+ label ? "#{clock} #{label}" : clock
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Toast is a small auto-dismissing notification panel, usually composited as an
6
+ # overlay anchored to a screen corner. Controllers manage its lifetime with the
7
+ # `show_toast` / `dismiss_toast` helpers (which pair it with a timer); the component
8
+ # itself just renders the styled box.
9
+ #
10
+ # Toast.new(message: "Saved!", kind: :success)
11
+ #
12
+ # *kind* picks the accent style: :info (default), :success, :warn, or :error.
13
+ class Toast < Component
14
+ KINDS = %i[info success warn error].freeze
15
+
16
+ attr_reader :message, :kind
17
+
18
+ # *message* is the toast text. *kind* is the visual accent. *width* optionally
19
+ # fixes the box width (otherwise it hugs the message).
20
+ def initialize(message:, kind: :info, width: nil, theme: nil)
21
+ super(theme: theme)
22
+ @message = message.to_s
23
+ @kind = KINDS.include?(kind) ? kind : :info
24
+ @width = width
25
+ end
26
+
27
+ # Renders the bordered toast box with a kind-colored border.
28
+ def render
29
+ box(message, style: toast_style)
30
+ end
31
+
32
+ private
33
+
34
+ # A rounded-border box accented by the kind's theme style.
35
+ def toast_style
36
+ base = style.border(:rounded, foreground: accent_color).padding(0, 1)
37
+ @width ? base.width(@width) : base
38
+ end
39
+
40
+ # Maps the kind to a border color: info/cyan-ish, success/green, warn/yellow, error/red.
41
+ def accent_color
42
+ case kind
43
+ when :success then :green
44
+ when :warn then :yellow
45
+ when :error then :red
46
+ else :cyan
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end