charming 0.1.2 → 0.1.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/lib/charming/application.rb +3 -3
- data/lib/charming/controller/class_methods.rb +2 -2
- data/lib/charming/controller/command_palette.rb +2 -2
- data/lib/charming/controller/rendering.rb +2 -2
- data/lib/charming/controller/session_state.rb +1 -1
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/templates/app/application.template +1 -1
- data/lib/charming/generators/templates/app/layout.template +3 -6
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/generators/templates/component/component.rb.template +1 -1
- data/lib/charming/generators/templates/screen/view.rb.template +1 -1
- data/lib/charming/generators/templates/view/view.rb.template +1 -1
- data/lib/charming/internal/renderer/differential.rb +13 -5
- data/lib/charming/internal/terminal/tty_backend.rb +22 -2
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -49
- data/lib/charming/presentation/components/form/builder.rb +52 -54
- data/lib/charming/presentation/components/form/confirm.rb +49 -51
- data/lib/charming/presentation/components/form/field.rb +94 -96
- data/lib/charming/presentation/components/form/input.rb +53 -55
- data/lib/charming/presentation/components/form/note.rb +27 -29
- data/lib/charming/presentation/components/form/select.rb +84 -86
- data/lib/charming/presentation/components/form/textarea.rb +67 -69
- data/lib/charming/presentation/components/form.rb +120 -122
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -125
- data/lib/charming/presentation/components/markdown.rb +21 -23
- data/lib/charming/presentation/components/modal.rb +46 -48
- data/lib/charming/presentation/components/progressbar.rb +51 -53
- data/lib/charming/presentation/components/spinner.rb +40 -42
- data/lib/charming/presentation/components/table.rb +109 -111
- data/lib/charming/presentation/components/text_area.rb +219 -221
- data/lib/charming/presentation/components/text_input.rb +120 -122
- data/lib/charming/presentation/components/viewport.rb +218 -220
- data/lib/charming/presentation/layout/builder.rb +64 -66
- data/lib/charming/presentation/layout/overlay.rb +48 -50
- data/lib/charming/presentation/layout/pane.rb +122 -118
- data/lib/charming/presentation/layout/rect.rb +14 -16
- data/lib/charming/presentation/layout/screen_layout.rb +40 -42
- data/lib/charming/presentation/layout/split.rb +101 -103
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +94 -96
- data/lib/charming/presentation/markdown/inline_renderers.rb +52 -54
- data/lib/charming/presentation/markdown/render_context.rb +12 -14
- data/lib/charming/presentation/markdown/renderer.rb +84 -86
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +57 -59
- data/lib/charming/presentation/markdown.rb +4 -6
- data/lib/charming/presentation/template_view.rb +22 -24
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -49
- data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
- data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +37 -39
- data/lib/charming/presentation/ui/canvas.rb +59 -61
- data/lib/charming/presentation/ui/style.rb +173 -175
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +69 -71
- data/lib/charming/presentation/view.rb +103 -105
- data/lib/charming/runtime.rb +23 -10
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +3 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc7f17fae2c6012942e00a20986ee7928b3f1a71e9d6fcb4a9855439e1281fd3
|
|
4
|
+
data.tar.gz: 63d288c1cbecc5830b2c1d3cfece4d0a15a58241ee9b6e130d6ea13c9256767f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 374717171efb3639c11cd96748fb41e02a9bff74ec3c7e2325770d90f5b5db193df8c7ebaca1e73dffcf597f1e14fc3af6971d8cf9780026dbe5b3db6513cebd
|
|
7
|
+
data.tar.gz: 1870fb231844e57394d0412de0a752d1927a8bf6c4347daff86d26bed96c4226744f9cc26a9fe8ce0992b7bb604e87a4763b3747ad0ea58a56876cf59f2692bf
|
data/lib/charming/application.rb
CHANGED
|
@@ -37,9 +37,9 @@ module Charming
|
|
|
37
37
|
raise ArgumentError, "theme expects either from: or built_in:, not both" if from && built_in
|
|
38
38
|
|
|
39
39
|
themes[name.to_sym] = if built_in
|
|
40
|
-
|
|
40
|
+
UI::Theme.load_builtin(built_in)
|
|
41
41
|
else
|
|
42
|
-
|
|
42
|
+
UI::Theme.load_file(resolve_theme_path(from))
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
@@ -60,7 +60,7 @@ module Charming
|
|
|
60
60
|
# built-in theme if no name is given and no default is registered.
|
|
61
61
|
def theme_for(name = nil)
|
|
62
62
|
theme_name = name || default_theme
|
|
63
|
-
return
|
|
63
|
+
return UI::Theme.default unless theme_name
|
|
64
64
|
|
|
65
65
|
themes.fetch(theme_name.to_sym)
|
|
66
66
|
end
|
|
@@ -19,7 +19,7 @@ module Charming
|
|
|
19
19
|
# Adds a CommandPalette entry with the given *label*. *action* is a method name to send on
|
|
20
20
|
# the controller, or a block to instance_exec when selected.
|
|
21
21
|
def command(label, action = nil, &block)
|
|
22
|
-
command_bindings <<
|
|
22
|
+
command_bindings << Components::CommandPalette::Command.new(label: label, value: block || action)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# Declares a timer that fires every *every* seconds and dispatches *action* on the controller.
|
|
@@ -49,7 +49,7 @@ module Charming
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# Sets or returns the controller's layout. Pass a layout class (instantiated per request),
|
|
52
|
-
# a String/Symbol template name (resolved through
|
|
52
|
+
# a String/Symbol template name (resolved through Templates), or `false` to
|
|
53
53
|
# disable inherited layout wrapping. Called with no arguments returns the resolved layout.
|
|
54
54
|
def layout(layout_class = :__charming_layout_reader__)
|
|
55
55
|
return resolved_layout if layout_class == :__charming_layout_reader__
|
|
@@ -70,7 +70,7 @@ module Charming
|
|
|
70
70
|
|
|
71
71
|
# Constructs the CommandPalette widget with a *commands* list and persisted *state* hash.
|
|
72
72
|
def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
|
|
73
|
-
|
|
73
|
+
Components::CommandPalette.new(
|
|
74
74
|
commands: commands,
|
|
75
75
|
placeholder: placeholder,
|
|
76
76
|
height: height,
|
|
@@ -112,7 +112,7 @@ module Charming
|
|
|
112
112
|
# Returns the theme-switching commands used by the theme picker palette.
|
|
113
113
|
def theme_commands
|
|
114
114
|
application.class.themes.keys.map do |name|
|
|
115
|
-
|
|
115
|
+
Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
@@ -55,12 +55,12 @@ module Charming
|
|
|
55
55
|
|
|
56
56
|
# Resolves a template by *name* and returns a TemplateView bound to the application's namespace.
|
|
57
57
|
def template_body(name, **assigns)
|
|
58
|
-
|
|
58
|
+
TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# Looks up the template file under `app/views` relative to the application root.
|
|
62
62
|
def resolve_template(name)
|
|
63
|
-
|
|
63
|
+
Templates.resolve(name, root: application.class.root)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Returns the assigns hash passed to templates: `screen:`, `controller:`, `theme:` plus user *assigns*.
|
|
@@ -25,7 +25,7 @@ module Charming
|
|
|
25
25
|
def form(name, &block)
|
|
26
26
|
session[:forms] ||= {}
|
|
27
27
|
form_state = session[:forms][name.to_sym] ||= {}
|
|
28
|
-
builder =
|
|
28
|
+
builder = Components::Form::Builder.new(theme: theme)
|
|
29
29
|
block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
|
|
30
30
|
builder.build(state: form_state, theme: theme)
|
|
31
31
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Generators
|
|
5
5
|
# ComponentGenerator implements `charming generate component NAME`. Writes a
|
|
6
|
-
# `Charming::
|
|
6
|
+
# `Charming::Component` subclass to `app/components/<name>_component.rb`.
|
|
7
7
|
class ComponentGenerator < AppFileGenerator
|
|
8
8
|
# Writes the component file to the standard `app/components` path.
|
|
9
9
|
def generate
|
|
@@ -4,7 +4,7 @@ module __APP_CLASS__
|
|
|
4
4
|
class Application < Charming::Application
|
|
5
5
|
root File.expand_path("../..", __dir__)
|
|
6
6
|
|
|
7
|
-
Charming::
|
|
7
|
+
Charming::UI::Theme.built_in_names.each do |theme_name|
|
|
8
8
|
theme theme_name.to_sym, built_in: theme_name
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module __APP_CLASS__
|
|
4
4
|
module Layouts
|
|
5
|
-
class ApplicationLayout < Charming::
|
|
5
|
+
class ApplicationLayout < Charming::View
|
|
6
6
|
def render
|
|
7
7
|
screen_layout(background: theme.background) do
|
|
8
8
|
split(narrow? ? :vertical : :horizontal, gap: 1) do
|
|
@@ -54,7 +54,7 @@ module __APP_CLASS__
|
|
|
54
54
|
def nav_item_label(route, index)
|
|
55
55
|
cursor = (sidebar_focused? && index == sidebar_index) ? ">" : " "
|
|
56
56
|
active = current_route?(route) ? "\u{25cf}" : " "
|
|
57
|
-
"
|
|
57
|
+
"#{cursor} #{active} #{route.title}"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def nav_item_style(route, index)
|
|
@@ -84,11 +84,8 @@ module __APP_CLASS__
|
|
|
84
84
|
def command_palette_modal
|
|
85
85
|
return unless palette_component
|
|
86
86
|
|
|
87
|
-
render_component Charming::
|
|
87
|
+
render_component Charming::Components::CommandPaletteModal.new(
|
|
88
88
|
content: palette_component,
|
|
89
|
-
title: "Command palette",
|
|
90
|
-
help: "Type to filter. Enter selects. Escape closes.",
|
|
91
|
-
width: 52,
|
|
92
89
|
theme: theme
|
|
93
90
|
)
|
|
94
91
|
end
|
|
@@ -27,6 +27,12 @@ module Charming
|
|
|
27
27
|
render_changes(frame)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Discards the cached previous frame so the next render performs a full repaint.
|
|
31
|
+
# Call this when the screen contents are no longer trustworthy (e.g. terminal resize).
|
|
32
|
+
def invalidate
|
|
33
|
+
@previous_frame = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
30
36
|
private
|
|
31
37
|
|
|
32
38
|
# Performs the initial full repaint and records the first frame.
|
|
@@ -35,7 +41,7 @@ module Charming
|
|
|
35
41
|
@previous_frame = frame
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
# Computes the per-line diff against the previous frame, writes
|
|
44
|
+
# Computes the per-line diff against the previous frame, writes only changed lines,
|
|
39
45
|
# and records the new frame. Falls back to a full repaint when the output backend
|
|
40
46
|
# doesn't support partial writes.
|
|
41
47
|
def render_changes(frame)
|
|
@@ -50,15 +56,17 @@ module Charming
|
|
|
50
56
|
@previous_frame = frame
|
|
51
57
|
end
|
|
52
58
|
|
|
53
|
-
# Returns an array of [1-based-row, line] tuples
|
|
54
|
-
#
|
|
59
|
+
# Returns an array of [1-based-row, line] tuples for rows whose content changed.
|
|
60
|
+
# Empty strings clear rows that existed in the previous frame but not the new one.
|
|
55
61
|
def changed_lines(previous_frame, frame)
|
|
56
62
|
previous_lines = previous_frame.lines(chomp: true)
|
|
57
63
|
lines = frame.lines(chomp: true)
|
|
58
64
|
line_count = [previous_lines.length, lines.length].max
|
|
59
65
|
|
|
60
|
-
line_count.times.
|
|
61
|
-
|
|
66
|
+
line_count.times.filter_map do |index|
|
|
67
|
+
line = lines[index] || ""
|
|
68
|
+
previous_line = previous_lines[index] || ""
|
|
69
|
+
[index + 1, line] unless line == previous_line
|
|
62
70
|
end
|
|
63
71
|
end
|
|
64
72
|
end
|
|
@@ -51,6 +51,20 @@ module Charming
|
|
|
51
51
|
nil
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
|
|
55
|
+
# single keypress in raw mode is not enough: keys pressed while rendering or dispatching
|
|
56
|
+
# events can otherwise be echoed into the alternate screen before the next read.
|
|
57
|
+
def with_raw_input
|
|
58
|
+
return yield unless @input.respond_to?(:tty?) && @input.tty?
|
|
59
|
+
return yield unless @input.respond_to?(:raw) && @input.respond_to?(:noecho)
|
|
60
|
+
|
|
61
|
+
@input.raw do
|
|
62
|
+
@input.noecho do
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
# Installs a SIGWINCH handler that sets the internal `@resized` flag, returning
|
|
55
69
|
# the previous handler so it can be restored on teardown.
|
|
56
70
|
def install_resize_handler
|
|
@@ -120,7 +134,7 @@ module Charming
|
|
|
120
134
|
# Writes a partial frame composed of [row, line] tuples (1-based rows).
|
|
121
135
|
def write_lines(line_changes, **)
|
|
122
136
|
without_auto_wrap do
|
|
123
|
-
write_control(line_changes.map { |row, line|
|
|
137
|
+
write_control(line_changes.map { |row, line| positioned_line(row, line) }.join)
|
|
124
138
|
end
|
|
125
139
|
end
|
|
126
140
|
|
|
@@ -180,7 +194,13 @@ module Charming
|
|
|
180
194
|
# Writes *lines* one row at a time, with each line preceded by an ANSI cursor
|
|
181
195
|
# position and a clear-to-end-of-line sequence.
|
|
182
196
|
def write_positioned_lines(lines)
|
|
183
|
-
write_control(lines.each_with_index.map { |line, index|
|
|
197
|
+
write_control(lines.each_with_index.map { |line, index| positioned_line(index + 1, line) }.join)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Resets SGR before and after each row so partial repaint rows cannot inherit
|
|
201
|
+
# colors/backgrounds from the previous physical terminal line.
|
|
202
|
+
def positioned_line(row, line)
|
|
203
|
+
"\e[0m\e[#{row};1H\e[2K#{line}\e[0m"
|
|
184
204
|
end
|
|
185
205
|
|
|
186
206
|
# Disables auto-wrap, yields, then re-enables it and flushes the output.
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Component < View
|
|
8
|
-
end
|
|
4
|
+
# Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
|
|
5
|
+
# helper methods (text, box, row, column, etc.), and rendering via render.
|
|
6
|
+
class Component < View
|
|
9
7
|
end
|
|
10
8
|
end
|
|
@@ -1,158 +1,197 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
4
|
+
module Components
|
|
5
|
+
# ActivityIndicator renders a color-gradient progress or loading indicator
|
|
6
|
+
# as styled text. It produces a fixed-width row of characters whose colors
|
|
7
|
+
# interpolate between two gradient endpoints (or cycle through a single
|
|
8
|
+
# color). A label can be appended after the bar and an ellipsis that cycles
|
|
9
|
+
# through frames, useful for "loading" state display. Call `tick` to advance
|
|
10
|
+
# the frame counter, and call `render` to produce the styled output string.
|
|
11
|
+
class ActivityIndicator < Component
|
|
12
|
+
# Default character pool used for generating each position's character via stable hashing.
|
|
13
|
+
DEFAULT_CHARS = "0123456789abcdefABCDEF~!@#$%^&*+=_".chars.freeze
|
|
14
|
+
|
|
15
|
+
# The default two-color gradient applied across the bar width (red to cyan).
|
|
16
|
+
# The cyan endpoint mirrors the Phosphor theme palette's "cyan" token so the bar
|
|
17
|
+
# remains legible on Phosphor's dark navy background; gradient: accepts raw hex,
|
|
18
|
+
# so callers using a different theme should pass their own endpoints.
|
|
19
|
+
DEFAULT_GRADIENT = ["#ff0000", "#6FD0E3"].freeze
|
|
20
|
+
|
|
21
|
+
# The default label color for ellipsis and text portions when no custom
|
|
22
|
+
# label_style is provided.
|
|
23
|
+
DEFAULT_LABEL_COLOR = "#cccccc"
|
|
24
|
+
|
|
25
|
+
# Ellipsis frame sequence: four states cycle through "., "..", "...", and "" (empty).
|
|
26
|
+
ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze
|
|
27
|
+
|
|
28
|
+
# Minimum bar width reserved when deciding whether a long label can fit before falling back.
|
|
29
|
+
MIN_FITTED_INDICATOR_WIDTH = 4
|
|
30
|
+
|
|
31
|
+
# Number of frames in the animation cycle before the indicator pattern repeats.
|
|
32
|
+
FRAME_COUNT = 10
|
|
33
|
+
|
|
34
|
+
# FNV-1a variant constants used by stable_hash for reproducible character selection per position.
|
|
35
|
+
FNV_OFFSET = 2_166_136_261
|
|
36
|
+
FNV_PRIME = 16_777_619
|
|
37
|
+
FNV_MASK = 0xffffffff
|
|
38
|
+
|
|
39
|
+
attr_reader :width, :label, :index, :seed, :chars, :gradient, :label_style, :max_width, :fallback_label
|
|
40
|
+
|
|
41
|
+
# Initializes a new ActivityIndicator with configurable visual parameters.
|
|
42
|
+
# width — Display width of the gradient bar in characters (minimum 1). Default: 10.
|
|
43
|
+
# label — Optional text label shown adjacent to the indicator.
|
|
44
|
+
# index — Initial frame index for the ellipsis/frame animations. Default: 0.
|
|
45
|
+
# seed — Hash seed that determines which characters appear at each position.
|
|
46
|
+
# chars — Character pool to draw from (default is DEFAULT_CHARS).
|
|
47
|
+
# gradient — Two-element array of hex color strings ["#rrggbb", "#rrggbb"] for interpolation.
|
|
48
|
+
# label_style — A Style object to use for rendering the label text; falls back to a gray foreground.
|
|
49
|
+
# max_width — Optional total display width cap for the indicator, label, and ellipsis.
|
|
50
|
+
# fallback_label — Optional shorter label used when the primary label cannot fit within max_width.
|
|
51
|
+
def initialize(width: 10, label: nil, index: 0, seed: 0, chars: DEFAULT_CHARS,
|
|
52
|
+
gradient: DEFAULT_GRADIENT, label_style: nil, max_width: nil, fallback_label: nil)
|
|
53
|
+
super()
|
|
54
|
+
raise ArgumentError, "chars cannot be empty" if chars.empty?
|
|
55
|
+
|
|
56
|
+
@width = [width.to_i, 1].max
|
|
57
|
+
@label = label
|
|
58
|
+
@index = index.to_i
|
|
59
|
+
@seed = seed
|
|
60
|
+
@chars = chars.map(&:to_s)
|
|
61
|
+
@gradient = gradient
|
|
62
|
+
@label_style = label_style
|
|
63
|
+
@max_width = max_width&.to_i
|
|
64
|
+
@fallback_label = fallback_label
|
|
65
|
+
end
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
# Advances the frame counter forward by +count+ steps, allowing the displayed pattern to change.
|
|
68
|
+
# Accepts an integer count (converted via +to_i+). Returns self for chaining.
|
|
69
|
+
def tick(count = 1)
|
|
70
|
+
@index += count.to_i
|
|
71
|
+
self
|
|
72
|
+
end
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
# Renders the activity indicator as a styled string. If a label was provided,
|
|
75
|
+
# produces "bar ellipsis" alongside it; otherwise produces only the gradient bar.
|
|
76
|
+
# Returns a formatted string suitable for terminal rendering.
|
|
77
|
+
def render
|
|
78
|
+
return indicator unless label
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
"#{indicator} #{styled_label}#{styled_ellipsis}"
|
|
81
|
+
end
|
|
76
82
|
|
|
77
|
-
|
|
83
|
+
private
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
# Renders the full gradient bar as an array of styled characters joined into a single string.
|
|
86
|
+
# Each character at +position+ is selected by hashing together seed, frame, and position —
|
|
87
|
+
# making the pattern stable across renders — then styled with the interpolated gradient color
|
|
88
|
+
# at that position.
|
|
89
|
+
def indicator
|
|
90
|
+
Array.new(indicator_width) { |position| styled_char(position) }.join
|
|
91
|
+
end
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
# Selects a character for the bar at the given +position+, styles it with the gradient color
|
|
94
|
+
# interpolated for that position, and returns the result as a formatted string via +render+.
|
|
95
|
+
def styled_char(position)
|
|
96
|
+
style.foreground(color_at(position)).render(char_at(position))
|
|
97
|
+
end
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
# Chooses a character from self.chars by hashing seed:frame:position together with a stable
|
|
100
|
+
# FNV-1a hash. The resulting index is modulated against the character pool length, ensuring
|
|
101
|
+
# reproducible output across renders.
|
|
102
|
+
def char_at(position)
|
|
103
|
+
chars.fetch(stable_hash("#{seed}:#{frame}:#{position}") % chars.length)
|
|
104
|
+
end
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
# Renders the label text in its own style (or fallback gray color) via a Style renderer call.
|
|
107
|
+
def styled_label
|
|
108
|
+
label_style_or_default.render(label_text)
|
|
109
|
+
end
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
# Renders an ellipsis frame (".", "..", "...", or empty) based on (index / 4) mod 4, styled with the label style.
|
|
112
|
+
def styled_ellipsis
|
|
113
|
+
label_style_or_default.render(ellipsis_frame)
|
|
114
|
+
end
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
# Returns the current ellipsis frame string: one of ".", "..", "...", "". Cycles through four frames per tick.
|
|
117
|
+
def ellipsis_frame
|
|
118
|
+
ELLIPSIS_FRAMES.fetch((index / 4) % ELLIPSIS_FRAMES.length)
|
|
119
|
+
end
|
|
114
120
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
# Returns the label style if set, otherwise produces a gray foreground style for fallback rendering.
|
|
122
|
+
def label_style_or_default
|
|
123
|
+
label_style || style.foreground(DEFAULT_LABEL_COLOR)
|
|
124
|
+
end
|
|
119
125
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
# Returns the label to render, using fallback_label when the primary label cannot fit
|
|
127
|
+
# alongside a minimal indicator and the widest ellipsis frame.
|
|
128
|
+
def label_text
|
|
129
|
+
return label.to_s unless use_fallback_label?
|
|
124
130
|
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
fallback_label.to_s
|
|
132
|
+
end
|
|
127
133
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
start_rgb = rgb(start_hex)
|
|
132
|
-
end_rgb = rgb(end_hex)
|
|
133
|
-
mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
|
|
134
|
-
"#%02x%02x%02x" % mixed
|
|
135
|
-
end
|
|
134
|
+
# True when the primary label cannot fit within max_width even with a compact indicator.
|
|
135
|
+
def use_fallback_label?
|
|
136
|
+
return false unless max_width && fallback_label
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
value = hex.to_s.delete_prefix("#")
|
|
140
|
-
raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
138
|
+
fitted_width(label.to_s, MIN_FITTED_INDICATOR_WIDTH) > max_width
|
|
139
|
+
end
|
|
141
140
|
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
# Returns the fitted indicator width after reserving room for label text and the widest ellipsis.
|
|
142
|
+
def indicator_width
|
|
143
|
+
return width unless max_width && label
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
(max_width - label_width - 1 - widest_ellipsis_width).clamp(1, width)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def label_width
|
|
149
|
+
UI::Width.measure(label_text)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def fitted_width(text, indicator_width)
|
|
153
|
+
indicator_width + 1 + UI::Width.measure(text) + widest_ellipsis_width
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def widest_ellipsis_width
|
|
157
|
+
@widest_ellipsis_width ||= ELLIPSIS_FRAMES.map { |frame| UI::Width.measure(frame) }.max
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
|
|
161
|
+
# Returns the first gradient color if width is 1; otherwise returns a blended hex string based on position.
|
|
162
|
+
def color_at(position)
|
|
163
|
+
return gradient.first unless indicator_width > 1
|
|
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) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
|
|
186
|
+
def frame
|
|
187
|
+
index % FRAME_COUNT
|
|
188
|
+
end
|
|
149
189
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
end
|
|
190
|
+
# Produces a deterministic integer hash from the input string using FNV-1a hashing, ensuring the same
|
|
191
|
+
# characters appear at the same positions across multiple renderings of this indicator.
|
|
192
|
+
def stable_hash(value)
|
|
193
|
+
value.bytes.reduce(FNV_OFFSET) do |hash, byte|
|
|
194
|
+
((hash ^ byte) * FNV_PRIME) & FNV_MASK
|
|
156
195
|
end
|
|
157
196
|
end
|
|
158
197
|
end
|