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.
- checksums.yaml +4 -4
- data/README.md +16 -3
- data/lib/charming/application.rb +48 -0
- data/lib/charming/cli.rb +16 -4
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +13 -40
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_file_generator.rb +29 -0
- data/lib/charming/generators/app_generator.rb +13 -25
- data/lib/charming/generators/base.rb +5 -0
- data/lib/charming/generators/layout_generator.rb +155 -0
- data/lib/charming/generators/screen_generator.rb +0 -29
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/README.md.template +15 -1
- data/lib/charming/generators/templates/app/application.template +0 -6
- data/lib/charming/generators/templates/app/application_controller.template +0 -10
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +4 -93
- data/lib/charming/generators/templates/app/routes.template +3 -1
- data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
- data/lib/charming/image/protocol/kitty.rb +133 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +95 -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 +21 -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 +79 -2
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/autocomplete.rb +7 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/error_screen.rb +1 -1
- 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/field.rb +5 -0
- data/lib/charming/presentation/components/form/input.rb +14 -4
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form/textarea.rb +18 -8
- data/lib/charming/presentation/components/form.rb +6 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/modal.rb +4 -4
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -4
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/table.rb +42 -1
- data/lib/charming/presentation/components/text_area.rb +3 -4
- data/lib/charming/presentation/components/text_input.rb +11 -6
- 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 +4 -3
- data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
- data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
- data/lib/charming/presentation/components/viewport.rb +11 -1
- data/lib/charming/presentation/markdown/renderer.rb +1 -1
- data/lib/charming/presentation/markdown/style_config.rb +1 -0
- data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
- 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/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +11 -0
- 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/runtime.rb +167 -90
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome/controller.rb +23 -0
- data/lib/charming/welcome/show_view.rb +113 -0
- data/lib/charming/welcome.rb +13 -0
- metadata +34 -9
- data/lib/charming/generators/templates/app/home_controller.template +0 -6
- data/lib/charming/generators/templates/app/home_state.template +0 -7
- data/lib/charming/generators/templates/app/spec_controller.template +0 -17
- data/lib/charming/generators/templates/app/spec_state.template +0 -17
- data/lib/charming/generators/templates/app/spec_view.template +0 -16
- 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.
|
|
9
|
-
#
|
|
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
|
|
12
|
-
SGR_PATTERN = /\e\[<(\d+);(\d+);(\d+)(
|
|
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
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
45
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
row = bytes[2] - 32
|
|
64
|
+
build_event(bytes[0] - 32, bytes[1] - 32, bytes[2] - 32, release: false)
|
|
65
|
+
end
|
|
75
66
|
|
|
76
|
-
|
|
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 ||=
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
return nil unless result == :handled
|
|
32
|
+
forward_to_text_area(:handle_key, event)
|
|
33
|
+
end
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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?
|