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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +41 -3
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- 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
|
-
|
|
33
|
-
|
|
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 >=
|
|
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:
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
6
|
-
#
|
|
7
|
-
# home/end, page up/down), deletion (backspace/delete), and scrolling
|
|
8
|
-
# Vertical movement preserves a "preferred column" so left/right
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
39
|
-
|
|
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
|
-
|
|
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
|