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.
- checksums.yaml +4 -4
- data/README.md +38 -378
- data/lib/charming/application.rb +14 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +62 -3
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +46 -448
- data/lib/charming/database_commands.rb +103 -0
- data/lib/charming/database_installer.rb +152 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +147 -45
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -14
- data/lib/charming/generators/model_generator.rb +128 -0
- data/lib/charming/generators/name.rb +10 -4
- data/lib/charming/generators/screen_generator.rb +84 -52
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +26 -13
- data/lib/charming/internal/renderer/differential.rb +17 -3
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +62 -115
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +56 -0
- data/lib/charming/presentation/components/form/builder.rb +62 -0
- data/lib/charming/presentation/components/form/confirm.rb +69 -0
- data/lib/charming/presentation/components/form/field.rb +121 -0
- data/lib/charming/presentation/components/form/input.rb +71 -0
- data/lib/charming/presentation/components/form/note.rb +41 -0
- data/lib/charming/presentation/components/form/select.rb +112 -0
- data/lib/charming/presentation/components/form/textarea.rb +86 -0
- data/lib/charming/presentation/components/form.rb +156 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +132 -0
- data/lib/charming/presentation/components/markdown.rb +31 -0
- data/lib/charming/presentation/components/modal.rb +64 -0
- data/lib/charming/presentation/components/progressbar.rb +70 -0
- data/lib/charming/presentation/components/spinner.rb +49 -0
- data/lib/charming/presentation/components/table.rb +143 -0
- data/lib/charming/presentation/components/text_area.rb +267 -0
- data/lib/charming/presentation/components/text_input.rb +129 -0
- data/lib/charming/presentation/components/viewport.rb +272 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +113 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
- data/lib/charming/presentation/markdown.rb +11 -0
- data/lib/charming/presentation/template_view.rb +34 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +68 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +213 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +91 -0
- data/lib/charming/presentation/view.rb +135 -0
- data/lib/charming/runtime.rb +9 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +37 -0
- data/lib/charming/tasks/task.rb +12 -0
- data/lib/charming/tasks/threaded_executor.rb +51 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +17 -0
- metadata +170 -36
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -69
- data/lib/charming/generators/app_generator/layout_template.rb +0 -160
- data/lib/charming/generators/app_generator/model_templates.rb +0 -30
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
- data/lib/charming/generators/app_generator/view_template.rb +0 -90
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/task_executor.rb +0 -62
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /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
|