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
|
@@ -1,131 +1,129 @@
|
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
@selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
|
|
128
|
-
end
|
|
4
|
+
module Components
|
|
5
|
+
# List is a vertically-scrollable selectable list. Supports keyboard navigation
|
|
6
|
+
# (up/down/home/end, Enter to activate) and mouse click selection. When a *height* is
|
|
7
|
+
# given, the list renders a fixed-height window over its items with auto-scroll
|
|
8
|
+
# keeping the selected item in view.
|
|
9
|
+
class List < Component
|
|
10
|
+
include KeyboardHandler
|
|
11
|
+
|
|
12
|
+
# Maps navigation key symbols to instance methods consumed by the KeyboardHandler
|
|
13
|
+
# mixin: :up moves selection up, :down moves down, :home jumps to first item,
|
|
14
|
+
# :end jumps to last. See Viewport#KEY_ACTIONS and Table#KEY_ACTIONS for identical pattern.
|
|
15
|
+
KEY_ACTIONS = {
|
|
16
|
+
up: :move_up,
|
|
17
|
+
down: :move_down,
|
|
18
|
+
home: :move_home,
|
|
19
|
+
end: :move_end
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# The item array and the currently selected index within it.
|
|
23
|
+
attr_reader :items, :selected_index
|
|
24
|
+
|
|
25
|
+
# *items* is the array of selectable objects. *selected_index* defaults to 0.
|
|
26
|
+
# *height* optionally constrains the visible window; *label* is a callable that
|
|
27
|
+
# extracts the display string from an item (defaults to `to_s`).
|
|
28
|
+
# *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
|
|
29
|
+
def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
|
|
30
|
+
super(theme: theme)
|
|
31
|
+
@items = items
|
|
32
|
+
@selected_index = selected_index
|
|
33
|
+
@height = height
|
|
34
|
+
@label = label || :to_s.to_proc
|
|
35
|
+
@keymap = keymap
|
|
36
|
+
clamp_position
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Handles key events. Returns `[:selected, item]` on Enter when an item is selected;
|
|
40
|
+
# otherwise delegates to the KeyboardHandler for navigation keys.
|
|
41
|
+
def handle_key(event)
|
|
42
|
+
return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
|
|
43
|
+
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handles mouse events: a click within the visible window selects the clicked row.
|
|
48
|
+
# Returns :handled on a successful click, nil otherwise.
|
|
49
|
+
def handle_mouse(event)
|
|
50
|
+
return nil unless @height
|
|
51
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
52
|
+
|
|
53
|
+
clicked = event.y
|
|
54
|
+
return nil if clicked.negative? || clicked >= visible_items.length
|
|
55
|
+
|
|
56
|
+
@selected_index = viewport_start + clicked
|
|
57
|
+
clamp_position
|
|
58
|
+
:handled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the currently selected item, or nil when the list is empty.
|
|
62
|
+
def selected_item
|
|
63
|
+
items[selected_index]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Renders the visible window of items, prefixing each with "> " (and applying the
|
|
67
|
+
# selected style) or " ".
|
|
68
|
+
def render
|
|
69
|
+
visible_items.each_with_index.map do |item, index|
|
|
70
|
+
render_item(item, viewport_start + index)
|
|
71
|
+
end.join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Moves the selection up one position.
|
|
77
|
+
def move_up
|
|
78
|
+
@selected_index -= 1 if selected_index.positive?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Moves the selection down one position.
|
|
82
|
+
def move_down
|
|
83
|
+
@selected_index += 1 if selected_index < items.length - 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Moves the selection to the first item.
|
|
87
|
+
def move_home
|
|
88
|
+
@selected_index = 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Moves the selection to the last item (no-op when the list is empty).
|
|
92
|
+
def move_end
|
|
93
|
+
@selected_index = items.length - 1 unless items.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the slice of items currently in the visible window.
|
|
97
|
+
def visible_items
|
|
98
|
+
items[viewport_start, viewport_height] || []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns the index of the topmost visible item, computed so the selected item stays
|
|
102
|
+
# in view when the list is taller than the visible window.
|
|
103
|
+
def viewport_start
|
|
104
|
+
return 0 unless @height
|
|
105
|
+
|
|
106
|
+
Layout.selected_window_start(selected_index: selected_index, item_count: items.length, window_size: @height)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns the number of items visible in the window (the configured *height* or the
|
|
110
|
+
# total item count when no height was set).
|
|
111
|
+
def viewport_height
|
|
112
|
+
@height || items.length
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Renders a single item: prefix with "> " (selected) or " " (unselected), then apply
|
|
116
|
+
# the theme's selected style to the selected item's row.
|
|
117
|
+
def render_item(item, index)
|
|
118
|
+
prefix = (index == selected_index) ? "> " : " "
|
|
119
|
+
rendered = "#{prefix}#{@label.call(item)}"
|
|
120
|
+
(index == selected_index) ? theme.selected.render(rendered) : rendered
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resets the selection when the list is empty, otherwise clamps it to the valid range.
|
|
124
|
+
def clamp_position
|
|
125
|
+
@selected_index = 0 if items.empty?
|
|
126
|
+
@selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
|
|
129
127
|
end
|
|
130
128
|
end
|
|
131
129
|
end
|
|
@@ -1,30 +1,28 @@
|
|
|
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
|
-
end
|
|
4
|
+
module Components
|
|
5
|
+
# Markdown renders Markdown source as ANSI-styled terminal text. Parsing is delegated to
|
|
6
|
+
# `Charming::Markdown::Renderer`; set *syntax_highlighting* to false to disable
|
|
7
|
+
# Rouge-backed code block highlighting.
|
|
8
|
+
class Markdown < Component
|
|
9
|
+
# *content* is the Markdown source string. *width* optionally sets the wrap width.
|
|
10
|
+
# *syntax_highlighting* enables Rouge for code blocks (defaults to true).
|
|
11
|
+
def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
|
|
12
|
+
super(theme: theme)
|
|
13
|
+
@content = content
|
|
14
|
+
@width = width
|
|
15
|
+
@syntax_highlighting = syntax_highlighting
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
end
|
|
18
|
+
# Renders the Markdown body to a styled, terminal-safe string.
|
|
19
|
+
def render
|
|
20
|
+
Charming::Markdown::Renderer.new(
|
|
21
|
+
content: @content,
|
|
22
|
+
width: @width,
|
|
23
|
+
theme: theme,
|
|
24
|
+
syntax_highlighting: @syntax_highlighting
|
|
25
|
+
).render
|
|
28
26
|
end
|
|
29
27
|
end
|
|
30
28
|
end
|
|
@@ -1,63 +1,61 @@
|
|
|
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
|
-
end
|
|
4
|
+
module Components
|
|
5
|
+
# Modal is a centered, boxed overlay with an optional title, help line, and body content.
|
|
6
|
+
# The body may be a string, View, or Component; when it responds to `render`, its output
|
|
7
|
+
# is used. The result is wrapped in a UI::Style border with padding.
|
|
8
|
+
class Modal < Component
|
|
9
|
+
# *content* is the modal body. *title* (optional) is rendered centered at the top.
|
|
10
|
+
# *help* (optional) is rendered as a muted footer line. *width* is the modal's total width.
|
|
11
|
+
# *style* overrides the default `theme.modal` style.
|
|
12
|
+
def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
|
|
13
|
+
super(theme: theme)
|
|
14
|
+
@content = content
|
|
15
|
+
@title = title
|
|
16
|
+
@help = help
|
|
17
|
+
@width = width
|
|
18
|
+
@style = style
|
|
19
|
+
end
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
# Renders the modal as a bordered, padded string with the title and help lines stacked
|
|
22
|
+
# above the content.
|
|
23
|
+
def render
|
|
24
|
+
box(column(*lines, gap: 1), style: modal_style)
|
|
25
|
+
end
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
private
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
attr_reader :content, :title, :help, :width
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
# Returns the array of non-nil lines: title, help, content.
|
|
32
|
+
def lines
|
|
33
|
+
[title_line, help_line, render_content].compact
|
|
34
|
+
end
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
# Returns the centered title line styled with the theme's title style, when a title was given.
|
|
37
|
+
def title_line
|
|
38
|
+
text(title, style: theme.title.align(:center).width(title_width)) if title
|
|
39
|
+
end
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
# Returns the help line styled with the theme's muted style, when help was given.
|
|
42
|
+
def help_line
|
|
43
|
+
text(help, style: theme.muted) if help
|
|
44
|
+
end
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
# Returns the rendered content string, calling `render` on the body when applicable.
|
|
47
|
+
def render_content
|
|
48
|
+
content.respond_to?(:render) ? render_component(content) : content.to_s
|
|
49
|
+
end
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
# Returns the modal's outer style: the user-provided style or `theme.modal` at the given width.
|
|
52
|
+
def modal_style
|
|
53
|
+
@style || theme.modal.width(width)
|
|
54
|
+
end
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
end
|
|
56
|
+
# Returns the title's display width, accounting for the modal's horizontal padding/border.
|
|
57
|
+
def title_width
|
|
58
|
+
[width - 8, 0].max
|
|
61
59
|
end
|
|
62
60
|
end
|
|
63
61
|
end
|
|
@@ -1,69 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
|
|
4
|
+
module Components
|
|
5
|
+
# Progressbar renders a fixed-width ASCII progress bar. The bar is sized to the configured
|
|
6
|
+
# *total* (in arbitrary units) and fills proportionally to the current value. Optionally
|
|
7
|
+
# appends a label after the bar.
|
|
8
|
+
class Progressbar < Component
|
|
9
|
+
# Public accessors: total units, current value, label text, completed and remaining
|
|
10
|
+
# characters, and the bar format symbol.
|
|
11
|
+
attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
# *total* is the maximum unit count. *complete* and *incomplete* are the characters used
|
|
14
|
+
# for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
|
|
15
|
+
# future format variants. *label* is an optional suffix shown after the bar.
|
|
16
|
+
def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
|
|
17
|
+
super()
|
|
18
|
+
@total = [total.to_i, 0].max
|
|
19
|
+
@complete = complete.to_s
|
|
20
|
+
@incomplete = incomplete.to_s
|
|
21
|
+
@bar_format = bar_format.to_sym
|
|
22
|
+
@label = label
|
|
23
|
+
@current = 0
|
|
24
|
+
end
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
# Advances the current value by *count* (default 1), clamping to `[0, total]`. Returns self.
|
|
27
|
+
def tick(count = 1)
|
|
28
|
+
update(@current + count)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
# Sets the current value, clamping to `[0, total]`. Returns self.
|
|
33
|
+
def update(value)
|
|
34
|
+
@current = value.to_i.clamp(0, @total)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
# Jumps the bar directly to 100% completion. Returns self.
|
|
39
|
+
def complete!
|
|
40
|
+
@current = @total
|
|
41
|
+
self
|
|
42
|
+
end
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
# Renders the bar as `[==== ]` (with the *label* appended when present).
|
|
45
|
+
def render
|
|
46
|
+
width = [@total, 1].max
|
|
47
|
+
completed = completed_width(width)
|
|
48
|
+
incomplete = width - completed
|
|
49
|
+
incomplete -= 1 if @current.zero?
|
|
50
|
+
bar = (@complete * completed) + (@incomplete * incomplete)
|
|
51
|
+
result = "[" + bar + "]"
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
return result unless @label
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
"#{result} #{@label}"
|
|
56
|
+
end
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
private
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
# Returns the number of `complete` characters to draw, rounded to the nearest integer.
|
|
61
|
+
def completed_width(width)
|
|
62
|
+
return 0 unless @total.positive?
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
end
|
|
64
|
+
((width * @current) / @total.to_f).round
|
|
67
65
|
end
|
|
68
66
|
end
|
|
69
67
|
end
|
|
@@ -1,48 +1,46 @@
|
|
|
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
|
-
frames.fetch(index % frames.length)
|
|
45
|
-
end
|
|
4
|
+
module Components
|
|
5
|
+
# Spinner is a simple rotating-frame indicator. The component cycles through a list of
|
|
6
|
+
# frames on each `tick`; pair it with a controller timer to drive animation. An optional
|
|
7
|
+
# *label* is appended after the current frame on each render.
|
|
8
|
+
class Spinner < Component
|
|
9
|
+
# The default frame set: a 4-frame ASCII spinner.
|
|
10
|
+
DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
|
|
11
|
+
|
|
12
|
+
# The current frame list, frame index, and optional label string.
|
|
13
|
+
attr_reader :frames, :index, :label
|
|
14
|
+
|
|
15
|
+
# *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
|
|
16
|
+
# *index* is the starting frame index. *label* is an optional suffix shown after the frame.
|
|
17
|
+
def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
|
|
18
|
+
super()
|
|
19
|
+
raise ArgumentError, "frames cannot be empty" if frames.empty?
|
|
20
|
+
|
|
21
|
+
@frames = frames
|
|
22
|
+
@index = index
|
|
23
|
+
@label = label
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Advances the frame index by one position, wrapping around. Returns self for chaining.
|
|
27
|
+
def tick
|
|
28
|
+
@index = (index + 1) % frames.length
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Renders the current frame, optionally followed by the label and a space.
|
|
33
|
+
def render
|
|
34
|
+
return frame unless label
|
|
35
|
+
|
|
36
|
+
"#{frame} #{label}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Returns the current frame string (with index modulo frame count to be safe).
|
|
42
|
+
def frame
|
|
43
|
+
frames.fetch(index % frames.length)
|
|
46
44
|
end
|
|
47
45
|
end
|
|
48
46
|
end
|