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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bc5c3942786d07631d42361391e76f4f42275cc472c8ff7e37669880263b978
4
- data.tar.gz: b6dc9eef28cf8eb6849a9ae34a4af8e6902a0e263529762cce9a3e591ed06396
3
+ metadata.gz: dc7f17fae2c6012942e00a20986ee7928b3f1a71e9d6fcb4a9855439e1281fd3
4
+ data.tar.gz: 63d288c1cbecc5830b2c1d3cfece4d0a15a58241ee9b6e130d6ea13c9256767f
5
5
  SHA512:
6
- metadata.gz: e772a624b0f4a51d722ed40863bfae85161ac9bc1b508d7accb6cc7a4fc8f30352a79b66a9d42416992d38477c145f5ecc55c953b77aca1cf787a03a5f2f0e64
7
- data.tar.gz: 61dc03c8e8ade6e62fbc86c6f76e382063cc13aa1f60cc8afa161f6a646d1e4836483f9ca9a2e8446881f5b5a65d4b8e5107e39300c5844034dbbcb33f37a47f
6
+ metadata.gz: 374717171efb3639c11cd96748fb41e02a9bff74ec3c7e2325770d90f5b5db193df8c7ebaca1e73dffcf597f1e14fc3af6971d8cf9780026dbe5b3db6513cebd
7
+ data.tar.gz: 1870fb231844e57394d0412de0a752d1927a8bf6c4347daff86d26bed96c4226744f9cc26a9fe8ce0992b7bb604e87a4763b3747ad0ea58a56876cf59f2692bf
@@ -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
- Presentation::UI::Theme.load_builtin(built_in)
40
+ UI::Theme.load_builtin(built_in)
41
41
  else
42
- Presentation::UI::Theme.load_file(resolve_theme_path(from))
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 Presentation::UI::Theme.default unless theme_name
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 << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action)
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 Presentation::Templates), or `false` to
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
- Presentation::Components::CommandPalette.new(
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
- Presentation::Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
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
- Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
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
- Presentation::Templates.resolve(name, root: application.class.root)
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 = Presentation::Components::Form::Builder.new(theme: theme)
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::Presentation::Component` subclass to `app/components/<name>_component.rb`.
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::Presentation::UI::Theme.built_in_names.each do |theme_name|
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::Presentation::View
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
- "\#{cursor} \#{active} \#{route.title}"
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::Presentation::Components::Modal.new(
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module __APP_CLASS__
4
4
  module Home
5
- class ShowView < Charming::Presentation::View
5
+ class ShowView < Charming::View
6
6
  def render
7
7
  column(title_line, help_line, gap: 1)
8
8
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module __APP_CLASS__
4
- class __COMPONENT_CLASS__ < Charming::Presentation::Component
4
+ class __COMPONENT_CLASS__ < Charming::Component
5
5
  def render
6
6
  text "__RESOURCE_NAME__"
7
7
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module __APP_CLASS__
4
4
  module __RESOURCE_MODULE__
5
- class ShowView < Charming::Presentation::View
5
+ class ShowView < Charming::View
6
6
  def render
7
7
  __SCREEN_NAME__.title
8
8
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module __APP_CLASS__
4
4
  module __RESOURCE_MODULE__
5
- class __ACTION_VIEW_CLASS__View < Charming::Presentation::View
5
+ class __ACTION_VIEW_CLASS__View < Charming::View
6
6
  def render
7
7
  "__RESOURCE_NAME__"
8
8
  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 the changed lines,
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 covering the larger of the two
54
- # frames' line counts, with empty strings padding the shorter frame.
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.map do |index|
61
- [index + 1, lines[index] || ""]
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| "\e[#{row};1H\e[2K#{line}" }.join)
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| "\e[#{index + 1};1H\e[2K#{line}" }.join)
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
- module Presentation
5
- # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
6
- # helper methods (text, box, row, column, etc.), and rendering via render.
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 Presentation
5
- module Components
6
- # ActivityIndicator renders a color-gradient progress or loading indicator
7
- # as styled text. It produces a fixed-width row of characters whose colors
8
- # interpolate between two gradient endpoints (or cycle through a single
9
- # color). A label can be appended after the bar and an ellipsis that cycles
10
- # through frames, useful for "loading" state display. Call `tick` to advance
11
- # the frame counter, and call `render` to produce the styled output string.
12
- class ActivityIndicator < Component
13
- # Default character pool used for generating each position's character via stable hashing.
14
- DEFAULT_CHARS = "0123456789abcdefABCDEF~!@#$%^&*+=_".chars.freeze
15
-
16
- # The default two-color gradient applied across the bar width (red to cyan).
17
- # The cyan endpoint mirrors the Phosphor theme palette's "cyan" token so the bar
18
- # remains legible on Phosphor's dark navy background; gradient: accepts raw hex,
19
- # so callers using a different theme should pass their own endpoints.
20
- DEFAULT_GRADIENT = ["#ff0000", "#6FD0E3"].freeze
21
-
22
- # The default label color for ellipsis and text portions when no custom
23
- # label_style is provided.
24
- DEFAULT_LABEL_COLOR = "#cccccc"
25
-
26
- # Ellipsis frame sequence: four states cycle through "., "..", "...", and "" (empty).
27
- ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze
28
-
29
- # Number of frames in the animation cycle before the indicator pattern repeats.
30
- FRAME_COUNT = 10
31
-
32
- # FNV-1a variant constants used by stable_hash for reproducible character selection per position.
33
- FNV_OFFSET = 2_166_136_261
34
- FNV_PRIME = 16_777_619
35
- FNV_MASK = 0xffffffff
36
-
37
- attr_reader :width, :label, :index, :seed, :chars, :gradient, :label_style
38
-
39
- # Initializes a new ActivityIndicator with configurable visual parameters.
40
- # width — Display width of the gradient bar in characters (minimum 1). Default: 10.
41
- # label — Optional text label shown adjacent to the indicator.
42
- # indexInitial frame index for the ellipsis/frame animations. Default: 0.
43
- # seed Hash seed that determines which characters appear at each position.
44
- # charsCharacter pool to draw from (default is DEFAULT_CHARS).
45
- # gradient Two-element array of hex color strings ["#rrggbb", "#rrggbb"] for interpolation.
46
- # label_style A Style object to use for rendering the label text; falls back to a gray foreground.
47
- def initialize(width: 10, label: nil, index: 0, seed: 0, chars: DEFAULT_CHARS,
48
- gradient: DEFAULT_GRADIENT, label_style: nil)
49
- super()
50
- raise ArgumentError, "chars cannot be empty" if chars.empty?
51
-
52
- @width = [width.to_i, 1].max
53
- @label = label
54
- @index = index.to_i
55
- @seed = seed
56
- @chars = chars.map(&:to_s)
57
- @gradient = gradient
58
- @label_style = label_style
59
- end
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
+ # widthDisplay width of the gradient bar in characters (minimum 1). Default: 10.
43
+ # label Optional text label shown adjacent to the indicator.
44
+ # indexInitial 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
- # Advances the frame counter forward by +count+ steps, allowing the displayed pattern to change.
62
- # Accepts an integer count (converted via +to_i+). Returns self for chaining.
63
- def tick(count = 1)
64
- @index += count.to_i
65
- self
66
- end
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
- # Renders the activity indicator as a styled string. If a label was provided,
69
- # produces "bar ellipsis" alongside it; otherwise produces only the gradient bar.
70
- # Returns a formatted string suitable for terminal rendering.
71
- def render
72
- return indicator unless label
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
- "#{indicator} #{styled_label}#{styled_ellipsis}"
75
- end
80
+ "#{indicator} #{styled_label}#{styled_ellipsis}"
81
+ end
76
82
 
77
- private
83
+ private
78
84
 
79
- # Renders the full gradient bar as an array of styled characters joined into a single string.
80
- # Each character at +position+ is selected by hashing together seed, frame, and position —
81
- # making the pattern stable across renders — then styled with the interpolated gradient color
82
- # at that position.
83
- def indicator
84
- Array.new(width) { |position| styled_char(position) }.join
85
- end
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
- # Selects a character for the bar at the given +position+, styles it with the gradient color
88
- # interpolated for that position, and returns the result as a formatted string via +render+.
89
- def styled_char(position)
90
- style.foreground(color_at(position)).render(char_at(position))
91
- end
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
- # Chooses a character from self.chars by hashing seed:frame:position together with a stable
94
- # FNV-1a hash. The resulting index is modulated against the character pool length, ensuring
95
- # reproducible output across renders.
96
- def char_at(position)
97
- chars.fetch(stable_hash("#{seed}:#{frame}:#{position}") % chars.length)
98
- end
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
- # Renders the label text in its own style (or fallback gray color) via a Style renderer call.
101
- def styled_label
102
- label_style_or_default.render(label.to_s)
103
- end
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
- # Renders an ellipsis frame (".", "..", "...", or empty) based on (index / 4) mod 4, styled with the label style.
106
- def styled_ellipsis
107
- label_style_or_default.render(ellipsis_frame)
108
- end
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
- # Returns the current ellipsis frame string: one of ".", "..", "...", "". Cycles through four frames per tick.
111
- def ellipsis_frame
112
- ELLIPSIS_FRAMES.fetch((index / 4) % ELLIPSIS_FRAMES.length)
113
- end
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
- # Returns the label style if set, otherwise produces a gray foreground style for fallback rendering.
116
- def label_style_or_default
117
- label_style || style.foreground(DEFAULT_LABEL_COLOR)
118
- end
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
- # Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
121
- # Returns the first gradient color if width is 1; otherwise returns a blended hex string based on position.
122
- def color_at(position)
123
- return gradient.first unless width > 1
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
- blend(gradient.first, gradient.last, position / (width - 1).to_f)
126
- end
131
+ fallback_label.to_s
132
+ end
127
133
 
128
- # Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
129
- # Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
130
- def blend(start_hex, end_hex, amount)
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
- # Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
138
- def rgb(hex)
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
- [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
143
- end
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
- # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
146
- def frame
147
- index % FRAME_COUNT
148
- end
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
- # Produces a deterministic integer hash from the input string using FNV-1a hashing, ensuring the same
151
- # characters appear at the same positions across multiple renderings of this indicator.
152
- def stable_hash(value)
153
- value.bytes.reduce(FNV_OFFSET) do |hash, byte|
154
- ((hash ^ byte) * FNV_PRIME) & FNV_MASK
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