charming 0.1.0 → 0.1.2

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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ # Form is a multi-field form component with built-in focus traversal, validation, and
7
+ # submit/cancel handling. Fields are produced by `Form::Builder` (see `controller.form`)
8
+ # and bound to a per-form mutable state hash. Tab/Shift+Tab cycles focus through
9
+ # focusable fields, Enter advances to the next field (or submits on the last), Escape
10
+ # cancels, and Ctrl+S submits from any field.
11
+ class Form < Component
12
+ # The list of field objects and the mutable state hash the form is bound to.
13
+ attr_reader :fields, :state
14
+
15
+ # *fields* is the array of form field objects. *state* is a hash for storing field
16
+ # values/errors and the current focus index; usually `session[:forms][form_name]`.
17
+ def initialize(fields:, state: nil, theme: nil)
18
+ super(theme: theme)
19
+ @fields = fields
20
+ @state = normalize_state(state || {})
21
+ bind_fields
22
+ clamp_focus
23
+ end
24
+
25
+ # Handles key events: Escape cancels, Ctrl+S submits, Tab cycles focus, Enter advances
26
+ # or submits, and unhandled keys are passed to the focused field.
27
+ def handle_key(event)
28
+ key = Charming.key_of(event)
29
+ return :cancelled if key == :escape
30
+ return submit if submit_shortcut?(event)
31
+ return move_focus(tab_direction(event)) if key == :tab
32
+
33
+ result = handle_current_field(event)
34
+ return result if result
35
+
36
+ advance_or_submit if key == :enter
37
+ end
38
+
39
+ # Returns a hash of `{field_name => value}` for the current field values.
40
+ def values
41
+ state[:values]
42
+ end
43
+
44
+ # Renders each field on its own line, marking the active field with `active: true`.
45
+ def render
46
+ fields.each_with_index.map do |field, index|
47
+ field.render(active: index == state[:focus_index])
48
+ end.join("\n")
49
+ end
50
+
51
+ private
52
+
53
+ # Ensures the state hash has all the required sub-keys: :values, :fields, :errors, and
54
+ # a sensible :focus_index default.
55
+ def normalize_state(value)
56
+ value[:values] ||= {}
57
+ value[:fields] ||= {}
58
+ value[:errors] ||= {}
59
+ value[:focus_index] ||= first_focusable_index || 0
60
+ value
61
+ end
62
+
63
+ # Binds each field to the state hash so field updates write back into `state[:values]`.
64
+ def bind_fields
65
+ fields.each { |field| field.bind(state) }
66
+ end
67
+
68
+ # Forwards *event* to the currently focused field and returns its result.
69
+ def handle_current_field(event)
70
+ current_field&.handle_key(event)
71
+ end
72
+
73
+ # Returns -1 for Shift+Tab (backward), +1 for plain Tab (forward).
74
+ def tab_direction(event)
75
+ return -1 if event.respond_to?(:shift) && event.shift
76
+
77
+ +1
78
+ end
79
+
80
+ # True when the event is the submit shortcut (Ctrl+S).
81
+ def submit_shortcut?(event)
82
+ Charming.key_of(event) == :s && event.respond_to?(:ctrl) && event.ctrl
83
+ end
84
+
85
+ # On Enter: submit when the last focusable field is active, otherwise advance focus.
86
+ def advance_or_submit
87
+ return submit if last_focusable?
88
+
89
+ move_focus(+1)
90
+ end
91
+
92
+ # Validates all fields, focuses the first invalid one, and returns [:submitted, values]
93
+ # when there are no errors.
94
+ def submit
95
+ state[:errors] = validation_errors
96
+ focus_first_error unless state[:errors].empty?
97
+ return :handled unless state[:errors].empty?
98
+
99
+ [:submitted, values.dup]
100
+ end
101
+
102
+ # Runs each field's validator and collects per-field error messages.
103
+ def validation_errors
104
+ fields.each_with_object({}) do |field, errors|
105
+ messages = field.validate
106
+ errors[field.name] = messages unless messages.empty?
107
+ end
108
+ end
109
+
110
+ # Moves focus to the first focusable field with errors, when any.
111
+ def focus_first_error
112
+ invalid = fields.index { |field| field.focusable? && state[:errors].key?(field.name) }
113
+ state[:focus_index] = invalid if invalid
114
+ end
115
+
116
+ # Returns the field at the current focus index, or nil when out of range.
117
+ def current_field
118
+ fields[state[:focus_index]]
119
+ end
120
+
121
+ # Moves focus by *direction* (forward or backward) through the focusable fields.
122
+ def move_focus(direction)
123
+ indices = focusable_indices
124
+ return nil if indices.empty?
125
+
126
+ current = indices.index(state[:focus_index]) || 0
127
+ state[:focus_index] = indices[(current + direction) % indices.length]
128
+ :handled
129
+ end
130
+
131
+ # True when the current focus index is the last focusable field.
132
+ def last_focusable?
133
+ focusable_indices.last == state[:focus_index]
134
+ end
135
+
136
+ # Indices of focusable fields, memoized.
137
+ def focusable_indices
138
+ @focusable_indices ||= fields.each_index.select { |index| fields[index].focusable? }
139
+ end
140
+
141
+ # The first index of a focusable field, or nil when no fields are focusable.
142
+ def first_focusable_index
143
+ fields.each_index.find { |index| fields[index].focusable? }
144
+ end
145
+
146
+ # On initialization, ensures :focus_index points at a focusable field.
147
+ def clamp_focus
148
+ return if focusable_indices.empty?
149
+ return if focusable_indices.include?(state[:focus_index])
150
+
151
+ state[:focus_index] = focusable_indices.first
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
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
28
+
29
+ private
30
+
31
+ def key_actions
32
+ base_key_actions.merge(normalized_keymap)
33
+ end
34
+
35
+ def base_key_actions
36
+ self.class.const_get(:KEY_ACTIONS)
37
+ end
38
+
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
43
+
44
+ Array(keys).each { |key| actions[key.to_sym] = action }
45
+ end
46
+ end
47
+
48
+ def resolved_keymap
49
+ case @keymap
50
+ when :vim then VIM_KEYMAP
51
+ when nil then {}
52
+ else @keymap
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
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
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
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
18
+
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
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
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
21
+
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
27
+
28
+ private
29
+
30
+ attr_reader :content, :title, :help, :width
31
+
32
+ # Returns the array of non-nil lines: title, help, content.
33
+ def lines
34
+ [title_line, help_line, render_content].compact
35
+ end
36
+
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
41
+
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
46
+
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
51
+
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
56
+
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
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
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
13
+
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
26
+
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
32
+
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
38
+
39
+ # Jumps the bar directly to 100% completion. Returns self.
40
+ def complete!
41
+ @current = @total
42
+ self
43
+ end
44
+
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 + "]"
53
+
54
+ return result unless @label
55
+
56
+ "#{result} #{@label}"
57
+ end
58
+
59
+ private
60
+
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?
64
+
65
+ ((width * @current) / @total.to_f).round
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
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
46
+ end
47
+ end
48
+ end
49
+ end