charming 0.2.0 → 0.2.1
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 +96 -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/session_state.rb +16 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller.rb +62 -10
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/events/focus_event.rb +12 -0
- 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 +38 -1
- 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/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- 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/internal/terminal/memory_backend.rb +6 -0
- data/lib/charming/internal/terminal/tty_backend.rb +64 -2
- data/lib/charming/presentation/component.rb +7 -0
- 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/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -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/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/progressbar.rb +0 -1
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +40 -9
- data/lib/charming/presentation/components/text_area.rb +47 -10
- data/lib/charming/presentation/components/text_input.rb +79 -4
- 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 +10 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
- data/lib/charming/presentation/markdown/text_wrapper.rb +40 -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/ansi_codes.rb +11 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/color_support.rb +129 -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/width.rb +27 -2
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +122 -15
- 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 +36 -49
|
@@ -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)
|
|
@@ -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,44 @@ 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
|
-
#
|
|
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, recalls history on up/down
|
|
54
|
+
# (when enabled), otherwise dispatches via KEY_ACTIONS.
|
|
39
55
|
# Returns :handled when the event was consumed, nil otherwise.
|
|
40
56
|
def handle_key(event)
|
|
41
57
|
return :handled if character_event?(event) && insert(event.char)
|
|
58
|
+
return :handled if history_event(Charming.key_of(event))
|
|
42
59
|
|
|
43
60
|
super
|
|
44
61
|
end
|
|
45
62
|
|
|
63
|
+
# Inserts pasted text at the cursor (newlines and control characters are
|
|
64
|
+
# stripped — this is a single-line input). Returns :handled.
|
|
65
|
+
def handle_paste(event)
|
|
66
|
+
sanitized = event.text.to_s.gsub(/[[:cntrl:]]/, "")
|
|
67
|
+
insert(sanitized) unless sanitized.empty?
|
|
68
|
+
:handled
|
|
69
|
+
end
|
|
70
|
+
|
|
46
71
|
# Renders the value with a cursor marker. When *width* was given at construction, the
|
|
47
72
|
# output is padded to that width via the configured style.
|
|
48
73
|
def render
|
|
@@ -105,12 +130,62 @@ module Charming
|
|
|
105
130
|
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
106
131
|
end
|
|
107
132
|
|
|
133
|
+
# Cycles through history on :up / :down. Returns true when the event was consumed.
|
|
134
|
+
def history_event(key)
|
|
135
|
+
return false unless @history && !@history.empty?
|
|
136
|
+
|
|
137
|
+
case key
|
|
138
|
+
when :up then recall_previous
|
|
139
|
+
when :down then recall_next
|
|
140
|
+
else false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Steps back through history (saving the in-progress draft first).
|
|
145
|
+
def recall_previous
|
|
146
|
+
if @history_index.nil?
|
|
147
|
+
@draft = value
|
|
148
|
+
@history_index = @history.length - 1
|
|
149
|
+
elsif @history_index.positive?
|
|
150
|
+
@history_index -= 1
|
|
151
|
+
end
|
|
152
|
+
replace_value(@history[@history_index])
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Steps forward through history; past the newest entry restores the draft.
|
|
157
|
+
def recall_next
|
|
158
|
+
return false if @history_index.nil?
|
|
159
|
+
|
|
160
|
+
@history_index += 1
|
|
161
|
+
if @history_index >= @history.length
|
|
162
|
+
@history_index = nil
|
|
163
|
+
replace_value(@draft.to_s)
|
|
164
|
+
else
|
|
165
|
+
replace_value(@history[@history_index])
|
|
166
|
+
end
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Replaces the value and moves the cursor to the end.
|
|
171
|
+
def replace_value(new_value)
|
|
172
|
+
@value = new_value.dup
|
|
173
|
+
@cursor = @value.length
|
|
174
|
+
end
|
|
175
|
+
|
|
108
176
|
# 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.
|
|
177
|
+
# empty, the placeholder is rendered instead, preceded by the cursor marker. Masked
|
|
178
|
+
# inputs render `*` per character.
|
|
110
179
|
def render_value
|
|
111
180
|
return cursor_marker + placeholder if value.empty?
|
|
112
181
|
|
|
113
|
-
|
|
182
|
+
shown = display_value
|
|
183
|
+
shown[0...cursor] + cursor_marker + shown[cursor..]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The value as displayed: masked inputs substitute `*` for every character.
|
|
187
|
+
def display_value
|
|
188
|
+
@masked ? "*" * value.length : value
|
|
114
189
|
end
|
|
115
190
|
|
|
116
191
|
# The literal character used to mark the cursor position in `render`.
|
|
@@ -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
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Tree renders a collapsible hierarchy (file explorers, nested data). Nodes are
|
|
6
|
+
# hashes: `{label: "src", children: [...], expanded: true}` — `children` and
|
|
7
|
+
# `expanded` are optional. Navigation: up/down move the cursor through *visible*
|
|
8
|
+
# nodes, right expands, left collapses (or jumps to the parent), Enter returns
|
|
9
|
+
# `[:selected, node]` for leaves and toggles branches. Mouse clicks move the cursor
|
|
10
|
+
# and toggle branches.
|
|
11
|
+
class Tree < Component
|
|
12
|
+
include KeyboardHandler
|
|
13
|
+
|
|
14
|
+
KEY_ACTIONS = {
|
|
15
|
+
up: :move_up,
|
|
16
|
+
down: :move_down,
|
|
17
|
+
left: :collapse_or_parent,
|
|
18
|
+
right: :expand,
|
|
19
|
+
home: :move_home,
|
|
20
|
+
end: :move_end
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# The root node list and the cursor index into the visible-node list.
|
|
24
|
+
attr_reader :nodes, :cursor_index
|
|
25
|
+
|
|
26
|
+
# *nodes* is the array of root node hashes (mutated in place to track expansion).
|
|
27
|
+
# *height* optionally constrains the visible window.
|
|
28
|
+
def initialize(nodes:, cursor_index: 0, height: nil, keymap: :vim, theme: nil)
|
|
29
|
+
super(theme: theme)
|
|
30
|
+
@nodes = nodes
|
|
31
|
+
@cursor_index = cursor_index
|
|
32
|
+
@height = height
|
|
33
|
+
@keymap = keymap
|
|
34
|
+
clamp_cursor
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Enter selects a leaf (`[:selected, node]`) or toggles a branch. Navigation keys
|
|
38
|
+
# are handled by KeyboardHandler.
|
|
39
|
+
def handle_key(event)
|
|
40
|
+
node = current_node
|
|
41
|
+
return nil unless node
|
|
42
|
+
return select_or_toggle(node) if Charming.key_of(event) == :enter
|
|
43
|
+
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A click moves the cursor to the clicked row; clicking a branch toggles it.
|
|
48
|
+
def handle_mouse(event)
|
|
49
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
50
|
+
|
|
51
|
+
clicked = viewport_start + event.y
|
|
52
|
+
return nil if clicked >= visible_nodes.length || event.y.negative?
|
|
53
|
+
|
|
54
|
+
@cursor_index = clicked
|
|
55
|
+
node = current_node
|
|
56
|
+
toggle(node) if branch?(node)
|
|
57
|
+
:handled
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The node under the cursor (a node hash), or nil for an empty tree.
|
|
61
|
+
def current_node
|
|
62
|
+
visible_nodes[cursor_index]&.fetch(:node)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Renders the visible window of the flattened tree.
|
|
66
|
+
def render
|
|
67
|
+
window = visible_nodes[viewport_start, viewport_height] || []
|
|
68
|
+
window.each_with_index.map do |entry, index|
|
|
69
|
+
render_node(entry, viewport_start + index)
|
|
70
|
+
end.join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Flattens the tree into visible rows: `{node:, depth:}` entries, skipping
|
|
76
|
+
# children of collapsed branches.
|
|
77
|
+
def visible_nodes
|
|
78
|
+
flatten(nodes, 0)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def flatten(list, depth)
|
|
82
|
+
Array(list).flat_map do |node|
|
|
83
|
+
entry = [{node: node, depth: depth}]
|
|
84
|
+
entry += flatten(node[:children], depth + 1) if branch?(node) && node[:expanded]
|
|
85
|
+
entry
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Renders one row: indentation, expansion marker, label; cursor row uses the
|
|
90
|
+
# selected style.
|
|
91
|
+
def render_node(entry, index)
|
|
92
|
+
node = entry.fetch(:node)
|
|
93
|
+
marker = if branch?(node)
|
|
94
|
+
node[:expanded] ? "▾ " : "▸ "
|
|
95
|
+
else
|
|
96
|
+
" "
|
|
97
|
+
end
|
|
98
|
+
line = "#{" " * entry.fetch(:depth)}#{marker}#{node[:label]}"
|
|
99
|
+
(index == cursor_index) ? theme.selected.render(line) : line
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def branch?(node)
|
|
103
|
+
node && node[:children] && !node[:children].empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def select_or_toggle(node)
|
|
107
|
+
return [:selected, node] unless branch?(node)
|
|
108
|
+
|
|
109
|
+
toggle(node)
|
|
110
|
+
:handled
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def toggle(node)
|
|
114
|
+
node[:expanded] = !node[:expanded]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def expand
|
|
118
|
+
node = current_node
|
|
119
|
+
node[:expanded] = true if branch?(node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Collapses an expanded branch, or moves the cursor to the parent of a leaf or
|
|
123
|
+
# collapsed node.
|
|
124
|
+
def collapse_or_parent
|
|
125
|
+
node = current_node
|
|
126
|
+
if branch?(node) && node[:expanded]
|
|
127
|
+
node[:expanded] = false
|
|
128
|
+
else
|
|
129
|
+
parent = parent_index(cursor_index)
|
|
130
|
+
@cursor_index = parent if parent
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Index of the nearest row above with a smaller depth (the parent), or nil.
|
|
135
|
+
def parent_index(index)
|
|
136
|
+
rows = visible_nodes
|
|
137
|
+
depth = rows[index]&.fetch(:depth)
|
|
138
|
+
return nil unless depth&.positive?
|
|
139
|
+
|
|
140
|
+
(index - 1).downto(0).find { |candidate| rows[candidate].fetch(:depth) < depth }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def move_up
|
|
144
|
+
@cursor_index -= 1 if cursor_index.positive?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def move_down
|
|
148
|
+
@cursor_index += 1 if cursor_index < visible_nodes.length - 1
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def move_home
|
|
152
|
+
@cursor_index = 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def move_end
|
|
156
|
+
@cursor_index = [visible_nodes.length - 1, 0].max
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Top row of the visible window, keeping the cursor in view.
|
|
160
|
+
def viewport_start
|
|
161
|
+
return 0 unless @height
|
|
162
|
+
|
|
163
|
+
Layout.selected_window_start(selected_index: cursor_index, item_count: visible_nodes.length, window_size: @height)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def viewport_height
|
|
167
|
+
@height || visible_nodes.length
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def clamp_cursor
|
|
171
|
+
max = [visible_nodes.length - 1, 0].max
|
|
172
|
+
@cursor_index = cursor_index.clamp(0, max)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
class Viewport
|
|
6
|
+
# ContentLines normalizes viewport content into display lines.
|
|
7
|
+
class ContentLines
|
|
8
|
+
def initialize(content:, width:, wrap:)
|
|
9
|
+
@content = content
|
|
10
|
+
@window_width = width
|
|
11
|
+
@wrap = wrap
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def lines
|
|
15
|
+
return wrapped_lines if wrap?
|
|
16
|
+
|
|
17
|
+
rendered_content.lines(chomp: true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def display_width
|
|
21
|
+
lines.map { |line| UI::Width.measure(line) }.max || 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :content, :window_width, :wrap
|
|
27
|
+
|
|
28
|
+
def wrapped_lines
|
|
29
|
+
rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def wrap_line(line)
|
|
33
|
+
line_width = UI::Width.measure(line)
|
|
34
|
+
return [""] if line_width.zero?
|
|
35
|
+
|
|
36
|
+
wrap_slices(line, line_width)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wrap_slices(line, line_width)
|
|
40
|
+
(0...line_width).step(window_width).map do |start_column|
|
|
41
|
+
UI.visible_slice(line, start_column, window_width)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rendered_content
|
|
46
|
+
content.respond_to?(:render) ? content.render.to_s : content.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wrap?
|
|
50
|
+
wrap && window_width&.positive?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Components
|
|
7
|
+
class Viewport
|
|
8
|
+
# LineWindow renders one content line inside the viewport's horizontal window.
|
|
9
|
+
class LineWindow
|
|
10
|
+
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
11
|
+
|
|
12
|
+
def initialize(width:, column:, wrap:)
|
|
13
|
+
@width = width
|
|
14
|
+
@column = column
|
|
15
|
+
@wrap = wrap
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(line)
|
|
19
|
+
return line unless width
|
|
20
|
+
return pad_line(line, width) if wrap
|
|
21
|
+
|
|
22
|
+
pad_line(clip_line(line), width)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :width, :column, :wrap
|
|
28
|
+
|
|
29
|
+
def clip_line(line)
|
|
30
|
+
clipped = clip_tokens(line.to_s)
|
|
31
|
+
needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clip_tokens(line)
|
|
35
|
+
state = {cursor: 0, output: +""}
|
|
36
|
+
line.scan(/#{ANSI_PATTERN}|./mo) do |token|
|
|
37
|
+
ansi?(token) ? append_ansi(state, token) : append_character(state, token)
|
|
38
|
+
end
|
|
39
|
+
state.fetch(:output)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def append_ansi(state, token)
|
|
43
|
+
state.fetch(:output) << token
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def append_character(state, char)
|
|
47
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
48
|
+
cursor = state.fetch(:cursor)
|
|
49
|
+
state.fetch(:output) << char if visible?(cursor, char_width)
|
|
50
|
+
state[:cursor] = cursor + char_width
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visible?(cursor, char_width)
|
|
54
|
+
cursor >= column && cursor + char_width <= column + width
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def needs_reset?(value)
|
|
58
|
+
value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pad_line(line, target_width)
|
|
62
|
+
line + (" " * [target_width - UI::Width.measure(line), 0].max)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ansi?(token)
|
|
66
|
+
token.match?(ANSI_PATTERN)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
class Viewport
|
|
6
|
+
# Position owns the viewport's mutable row and column offsets.
|
|
7
|
+
class Position
|
|
8
|
+
attr_reader :offset, :column
|
|
9
|
+
|
|
10
|
+
def initialize(offset:, column:)
|
|
11
|
+
@offset = offset
|
|
12
|
+
@column = column
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scroll_up(bounds)
|
|
16
|
+
@offset -= 1
|
|
17
|
+
clamp(bounds)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scroll_down(bounds)
|
|
21
|
+
@offset += 1
|
|
22
|
+
clamp(bounds)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def page_up(page_size, bounds)
|
|
26
|
+
@offset -= page_size
|
|
27
|
+
clamp(bounds)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def page_down(page_size, bounds)
|
|
31
|
+
@offset += page_size
|
|
32
|
+
clamp(bounds)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def home
|
|
36
|
+
@offset = 0
|
|
37
|
+
@column = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def end_at(bounds)
|
|
41
|
+
@offset = bounds.fetch(:max_offset)
|
|
42
|
+
@column = bounds.fetch(:max_column)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scroll_left(bounds)
|
|
46
|
+
@column -= 1
|
|
47
|
+
clamp(bounds)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scroll_right(bounds)
|
|
51
|
+
@column += 1
|
|
52
|
+
clamp(bounds)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def move_to(row, bounds)
|
|
56
|
+
@offset = row
|
|
57
|
+
clamp(bounds)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clamp(bounds)
|
|
61
|
+
@offset = offset.clamp(0, bounds.fetch(:max_offset))
|
|
62
|
+
@column = column.clamp(0, bounds.fetch(:max_column))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|