charming 0.1.1 → 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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -1,56 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- # KeyboardHandler is a mixin module that provides keyboard event dispatch by mapping symbolic key names
7
- # to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
8
- # a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
9
- # +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
10
- # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns :handled if an
11
- # action was found. Returns nil (via :handled being truthy or not) when no matching key exists.
12
- module KeyboardHandler
13
- VIM_KEYMAP = {
14
- up: :k,
15
- down: :j,
16
- left: :h,
17
- right: :l
18
- }.freeze
19
-
20
- def handle_key(event)
21
- key = Charming.key_of(event)
22
- action = key_actions[key]
23
- return unless action
24
-
25
- send(action)
26
- :handled
27
- end
4
+ module Components
5
+ # KeyboardHandler is a mixin module that provides keyboard event dispatch by mapping symbolic key names
6
+ # to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
7
+ # a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
8
+ # +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
9
+ # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns :handled if an
10
+ # action was found. Returns nil (via :handled being truthy or not) when no matching key exists.
11
+ module KeyboardHandler
12
+ VIM_KEYMAP = {
13
+ up: :k,
14
+ down: :j,
15
+ left: :h,
16
+ right: :l
17
+ }.freeze
18
+
19
+ def handle_key(event)
20
+ key = Charming.key_of(event)
21
+ action = key_actions[key]
22
+ return unless action
23
+
24
+ send(action)
25
+ :handled
26
+ end
28
27
 
29
- private
28
+ private
30
29
 
31
- def key_actions
32
- base_key_actions.merge(normalized_keymap)
33
- end
30
+ def key_actions
31
+ base_key_actions.merge(normalized_keymap)
32
+ end
34
33
 
35
- def base_key_actions
36
- self.class.const_get(:KEY_ACTIONS)
37
- end
34
+ def base_key_actions
35
+ self.class.const_get(:KEY_ACTIONS)
36
+ end
38
37
 
39
- def normalized_keymap
40
- resolved_keymap.each_with_object({}) do |(action_key, keys), actions|
41
- action = base_key_actions[action_key.to_sym]
42
- next unless action
38
+ def normalized_keymap
39
+ resolved_keymap.each_with_object({}) do |(action_key, keys), actions|
40
+ action = base_key_actions[action_key.to_sym]
41
+ next unless action
43
42
 
44
- Array(keys).each { |key| actions[key.to_sym] = action }
45
- end
43
+ Array(keys).each { |key| actions[key.to_sym] = action }
46
44
  end
45
+ end
47
46
 
48
- def resolved_keymap
49
- case @keymap
50
- when :vim then VIM_KEYMAP
51
- when nil then {}
52
- else @keymap
53
- end
47
+ def resolved_keymap
48
+ case @keymap
49
+ when :vim then VIM_KEYMAP
50
+ when nil then {}
51
+ else @keymap
54
52
  end
55
53
  end
56
54
  end
@@ -1,103 +1,129 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- class List < Component
7
- include KeyboardHandler
8
-
9
- # Maps navigation key symbols to instance methods consumed by the KeyboardHandler
10
- # mixin: :up moves selection up, :down moves down, :home jumps to first item,
11
- # :end jumps to last. See Viewport#KEY_ACTIONS and Table#KEY_ACTIONS for identical pattern.
12
- KEY_ACTIONS = {
13
- up: :move_up,
14
- down: :move_down,
15
- home: :move_home,
16
- end: :move_end
17
- }.freeze
18
-
19
- attr_reader :items, :selected_index
20
-
21
- def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
22
- super(theme: theme)
23
- @items = items
24
- @selected_index = selected_index
25
- @height = height
26
- @label = label || :to_s.to_proc
27
- @keymap = keymap
28
- clamp_position
29
- end
30
-
31
- def handle_key(event)
32
- return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
33
-
34
- super
35
- end
36
-
37
- def handle_mouse(event)
38
- return nil unless @height
39
- return nil unless event.respond_to?(:click?) && event.click?
40
-
41
- clicked = event.y
42
- return nil if clicked.negative? || clicked >= visible_items.length
43
-
44
- @selected_index = viewport_start + clicked
45
- clamp_position
46
- :handled
47
- end
48
-
49
- def selected_item
50
- items[selected_index]
51
- end
52
-
53
- def render
54
- visible_items.each_with_index.map do |item, index|
55
- render_item(item, viewport_start + index)
56
- end.join("\n")
57
- end
58
-
59
- private
60
-
61
- def move_up
62
- @selected_index -= 1 if selected_index.positive?
63
- end
64
-
65
- def move_down
66
- @selected_index += 1 if selected_index < items.length - 1
67
- end
68
-
69
- def move_home
70
- @selected_index = 0
71
- end
72
-
73
- def move_end
74
- @selected_index = items.length - 1 unless items.empty?
75
- end
76
-
77
- def visible_items
78
- items[viewport_start, viewport_height] || []
79
- end
80
-
81
- def viewport_start
82
- return 0 unless @height
83
-
84
- Layout.selected_window_start(selected_index: selected_index, item_count: items.length, window_size: @height)
85
- end
86
-
87
- def viewport_height
88
- @height || items.length
89
- end
90
-
91
- def render_item(item, index)
92
- prefix = (index == selected_index) ? "> " : " "
93
- rendered = "#{prefix}#{@label.call(item)}"
94
- (index == selected_index) ? theme.selected.render(rendered) : rendered
95
- end
96
-
97
- def clamp_position
98
- @selected_index = 0 if items.empty?
99
- @selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
100
- 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?
101
127
  end
102
128
  end
103
129
  end
@@ -1,24 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- class Markdown < Component
7
- def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
8
- super(theme: theme)
9
- @content = content
10
- @width = width
11
- @syntax_highlighting = syntax_highlighting
12
- 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
13
17
 
14
- def render
15
- Charming::Presentation::Markdown::Renderer.new(
16
- content: @content,
17
- width: @width,
18
- theme: theme,
19
- syntax_highlighting: @syntax_highlighting
20
- ).render
21
- 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
22
26
  end
23
27
  end
24
28
  end
@@ -1,49 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- class Modal < Component
7
- def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
8
- super(theme: theme)
9
- @content = content
10
- @title = title
11
- @help = help
12
- @width = width
13
- @style = style
14
- end
15
-
16
- def render
17
- box(column(*lines, gap: 1), style: modal_style)
18
- end
19
-
20
- private
21
-
22
- attr_reader :content, :title, :help, :width
23
-
24
- def lines
25
- [title_line, help_line, render_content].compact
26
- end
27
-
28
- def title_line
29
- text(title, style: theme.title.align(:center).width(title_width)) if title
30
- end
31
-
32
- def help_line
33
- text(help, style: theme.muted) if help
34
- end
35
-
36
- def render_content
37
- content.respond_to?(:render) ? render_component(content) : content.to_s
38
- end
39
-
40
- def modal_style
41
- @style || theme.modal.width(width)
42
- end
43
-
44
- def title_width
45
- [width - 8, 0].max
46
- 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
20
+
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
26
+
27
+ private
28
+
29
+ attr_reader :content, :title, :help, :width
30
+
31
+ # Returns the array of non-nil lines: title, help, content.
32
+ def lines
33
+ [title_line, help_line, render_content].compact
34
+ end
35
+
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
40
+
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
45
+
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
50
+
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
55
+
56
+ # Returns the title's display width, accounting for the modal's horizontal padding/border.
57
+ def title_width
58
+ [width - 8, 0].max
47
59
  end
48
60
  end
49
61
  end
@@ -1,56 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- class Progressbar < Component
7
- attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
8
-
9
- def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
10
- super()
11
- @total = [total.to_i, 0].max
12
- @complete = complete.to_s
13
- @incomplete = incomplete.to_s
14
- @bar_format = bar_format.to_sym
15
- @label = label
16
- @current = 0
17
- end
18
-
19
- def tick(count = 1)
20
- update(@current + count)
21
- self
22
- end
23
-
24
- def update(value)
25
- @current = value.to_i.clamp(0, @total)
26
- self
27
- end
28
-
29
- def complete!
30
- @current = @total
31
- self
32
- end
33
-
34
- def render
35
- width = [@total, 1].max
36
- completed = completed_width(width)
37
- incomplete = width - completed
38
- incomplete -= 1 if @current.zero?
39
- bar = (@complete * completed) + (@incomplete * incomplete)
40
- result = "[" + bar + "]"
41
-
42
- return result unless @label
43
-
44
- "#{result} #{@label}"
45
- end
46
-
47
- private
48
-
49
- def completed_width(width)
50
- return 0 unless @total.positive?
51
-
52
- ((width * @current) / @total.to_f).round
53
- end
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
12
+
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
25
+
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
31
+
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
37
+
38
+ # Jumps the bar directly to 100% completion. Returns self.
39
+ def complete!
40
+ @current = @total
41
+ self
42
+ end
43
+
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 + "]"
52
+
53
+ return result unless @label
54
+
55
+ "#{result} #{@label}"
56
+ end
57
+
58
+ private
59
+
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?
63
+
64
+ ((width * @current) / @total.to_f).round
54
65
  end
55
66
  end
56
67
  end