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