charming 0.1.1 → 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 +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- 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 +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -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 +123 -47
- 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 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- 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 +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- 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 +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -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/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 +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -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_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
|
@@ -4,9 +4,17 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Field is the abstract base class for Form fields. Subclasses define `default_value`
|
|
8
|
+
# and `render_control` (or override `render`); the base class supplies validation,
|
|
9
|
+
# help-line rendering, error-line rendering, and value lookup against the form state.
|
|
7
10
|
class Field < Component
|
|
11
|
+
# The field's name symbol, human-readable label, optional help text, and bound state hash.
|
|
8
12
|
attr_reader :name, :label, :help, :state
|
|
9
13
|
|
|
14
|
+
# *name* is the value key (a Symbol). *label* defaults to a humanized version of *name*.
|
|
15
|
+
# *required* enables a "is required" validator. *validate* is an optional callable
|
|
16
|
+
# (returning nil/true → ok, false → "is invalid", Array → messages, anything else → stringified).
|
|
17
|
+
# *help* is an optional muted helper line rendered under the field.
|
|
10
18
|
def initialize(name, label: nil, required: false, validate: nil, help: nil, theme: nil)
|
|
11
19
|
super(theme: theme)
|
|
12
20
|
@name = name.to_sym
|
|
@@ -16,26 +24,34 @@ module Charming
|
|
|
16
24
|
@help = help
|
|
17
25
|
end
|
|
18
26
|
|
|
27
|
+
# Binds the field to the form's *state* hash, ensuring the field's per-field state
|
|
28
|
+
# and a default value are present.
|
|
19
29
|
def bind(state)
|
|
20
30
|
@state = state
|
|
21
31
|
state[:fields][name] ||= {}
|
|
22
32
|
state[:values][name] = default_value unless state[:values].key?(name)
|
|
23
33
|
end
|
|
24
34
|
|
|
35
|
+
# Subclasses that participate in Tab/Enter navigation return true. Default is true.
|
|
25
36
|
def focusable?
|
|
26
37
|
true
|
|
27
38
|
end
|
|
28
39
|
|
|
40
|
+
# Default key handler returns nil (no key handling). Subclasses override.
|
|
29
41
|
def handle_key(_event)
|
|
30
42
|
nil
|
|
31
43
|
end
|
|
32
44
|
|
|
45
|
+
# Renders the field as a control line prefixed with ">" (active) or " " (inactive),
|
|
46
|
+
# optionally followed by the help line and any error lines.
|
|
33
47
|
def render(active: false)
|
|
34
48
|
line = "#{active ? ">" : " "} #{render_control}"
|
|
35
49
|
line = theme.selected.render(line) if active
|
|
36
50
|
[line, help_line, *error_lines].compact.join("\n")
|
|
37
51
|
end
|
|
38
52
|
|
|
53
|
+
# Returns an array of validation error messages. Includes "is required" when
|
|
54
|
+
# the field is required and blank, plus any messages produced by the user validator.
|
|
39
55
|
def validate
|
|
40
56
|
messages = []
|
|
41
57
|
messages << "is required" if required? && blank?(value)
|
|
@@ -43,24 +59,29 @@ module Charming
|
|
|
43
59
|
messages
|
|
44
60
|
end
|
|
45
61
|
|
|
62
|
+
# The current value of this field from the bound state.
|
|
46
63
|
def value
|
|
47
64
|
state[:values][name]
|
|
48
65
|
end
|
|
49
66
|
|
|
50
67
|
private
|
|
51
68
|
|
|
69
|
+
# The default value assigned to a freshly-bound field. Subclasses override.
|
|
52
70
|
def default_value
|
|
53
71
|
nil
|
|
54
72
|
end
|
|
55
73
|
|
|
74
|
+
# Renders the control portion (label + value). Default: "Label: <value>".
|
|
56
75
|
def render_control
|
|
57
76
|
"#{label}: #{value}"
|
|
58
77
|
end
|
|
59
78
|
|
|
79
|
+
# True when the field was declared with `required: true`.
|
|
60
80
|
def required?
|
|
61
81
|
@required
|
|
62
82
|
end
|
|
63
83
|
|
|
84
|
+
# True when *value* is nil, an empty string, or responds to `empty?` with true.
|
|
64
85
|
def blank?(value)
|
|
65
86
|
return true if value.nil?
|
|
66
87
|
return value.strip.empty? if value.is_a?(String)
|
|
@@ -68,6 +89,7 @@ module Charming
|
|
|
68
89
|
value.respond_to?(:empty?) && value.empty?
|
|
69
90
|
end
|
|
70
91
|
|
|
92
|
+
# Normalizes the user validator's return value into an array of error message strings.
|
|
71
93
|
def validator_messages
|
|
72
94
|
result = @validator.call(value)
|
|
73
95
|
case result
|
|
@@ -78,14 +100,17 @@ module Charming
|
|
|
78
100
|
end
|
|
79
101
|
end
|
|
80
102
|
|
|
103
|
+
# The muted help line (with two-space indent) when help text was given.
|
|
81
104
|
def help_line
|
|
82
105
|
" #{theme.muted.render(help)}" if help
|
|
83
106
|
end
|
|
84
107
|
|
|
108
|
+
# The list of error lines (with two-space indent) for any errors stored against this field.
|
|
85
109
|
def error_lines
|
|
86
110
|
Array(state[:errors][name]).map { |message| " #{theme.warn.render(message)}" }
|
|
87
111
|
end
|
|
88
112
|
|
|
113
|
+
# Converts a snake_case symbol/string to a humanized "Capitalized" string.
|
|
89
114
|
def humanize(value)
|
|
90
115
|
value.to_s.tr("_", " ").capitalize
|
|
91
116
|
end
|
|
@@ -4,7 +4,12 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Input is a single-line Form field backed by a TextInput widget. The cursor position
|
|
8
|
+
# is persisted in the form's per-field state so the field can be refocused mid-edit.
|
|
7
9
|
class Input < Field
|
|
10
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
11
|
+
# *width* optionally constrains the rendered width. All other options are forwarded
|
|
12
|
+
# to Field (label, required, validate, help, theme).
|
|
8
13
|
def initialize(name, value: "", placeholder: "", width: nil, **options)
|
|
9
14
|
super(name, **options)
|
|
10
15
|
@initial_value = value
|
|
@@ -12,12 +17,16 @@ module Charming
|
|
|
12
17
|
@width = width
|
|
13
18
|
end
|
|
14
19
|
|
|
20
|
+
# Binds the field to the form state, sets the initial value if absent, and initializes
|
|
21
|
+
# the per-field cursor offset to the end of the value.
|
|
15
22
|
def bind(state)
|
|
16
23
|
super
|
|
17
24
|
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
18
25
|
field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
# Forwards key events to the underlying TextInput, syncing the value and cursor
|
|
29
|
+
# back into the form state. Returns :handled when the event was consumed.
|
|
21
30
|
def handle_key(event)
|
|
22
31
|
text_input = input
|
|
23
32
|
result = text_input.handle_key(event)
|
|
@@ -30,14 +39,18 @@ module Charming
|
|
|
30
39
|
|
|
31
40
|
private
|
|
32
41
|
|
|
42
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
33
43
|
def default_value
|
|
34
44
|
@initial_value
|
|
35
45
|
end
|
|
36
46
|
|
|
47
|
+
# Renders the field as "Label: <text input>".
|
|
37
48
|
def render_control
|
|
38
49
|
"#{label}: #{input.render}"
|
|
39
50
|
end
|
|
40
51
|
|
|
52
|
+
# Builds a fresh TextInput each render, seeded from the current form-state value
|
|
53
|
+
# and the persisted cursor offset.
|
|
41
54
|
def input
|
|
42
55
|
TextInput.new(
|
|
43
56
|
value: value.to_s,
|
|
@@ -47,6 +60,7 @@ module Charming
|
|
|
47
60
|
)
|
|
48
61
|
end
|
|
49
62
|
|
|
63
|
+
# Returns the per-field state hash for this field.
|
|
50
64
|
def field_state
|
|
51
65
|
state[:fields][name]
|
|
52
66
|
end
|
|
@@ -4,24 +4,33 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Note is a non-interactive Form field that renders a static string of text. Notes
|
|
8
|
+
# never receive focus, never validate, and store no value — they are presentational
|
|
9
|
+
# only, useful for headings, dividers, or instructional text inside a form.
|
|
7
10
|
class Note < Field
|
|
11
|
+
# *text* is the literal string to render. *name* is unused (defaults to :note) and
|
|
12
|
+
# exists only because the Field base class requires a name.
|
|
8
13
|
def initialize(text, name: :note, theme: nil)
|
|
9
14
|
super(name, theme: theme)
|
|
10
15
|
@text = text
|
|
11
16
|
end
|
|
12
17
|
|
|
18
|
+
# Binds the field to the form state but does not create any per-field storage.
|
|
13
19
|
def bind(state)
|
|
14
20
|
@state = state
|
|
15
21
|
end
|
|
16
22
|
|
|
23
|
+
# Notes are never focusable and therefore excluded from Tab/Enter traversal.
|
|
17
24
|
def focusable?
|
|
18
25
|
false
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
# Notes never produce validation errors.
|
|
21
29
|
def validate
|
|
22
30
|
[]
|
|
23
31
|
end
|
|
24
32
|
|
|
33
|
+
# Returns the literal text, ignoring the *active:* flag (notes have no focus state).
|
|
25
34
|
def render(active: false)
|
|
26
35
|
@text.to_s
|
|
27
36
|
end
|
|
@@ -4,7 +4,13 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Select is a single-choice Form field backed by a List widget. The selected option
|
|
8
|
+
# becomes the field's value; navigation keys (up/down/home/end) cycle through options
|
|
9
|
+
# and Enter has no effect (selection is applied immediately on key release).
|
|
7
10
|
class Select < Field
|
|
11
|
+
# *options* is the array of selectable values. *selected_index* defaults to 0.
|
|
12
|
+
# *option_label* is a callable used to extract the display string (default: `to_s`).
|
|
13
|
+
# All other options are forwarded to Field.
|
|
8
14
|
def initialize(name, options:, selected_index: 0, option_label: :to_s.to_proc, **field_options)
|
|
9
15
|
super(name, **field_options)
|
|
10
16
|
@options = options
|
|
@@ -12,11 +18,14 @@ module Charming
|
|
|
12
18
|
@option_label = option_label
|
|
13
19
|
end
|
|
14
20
|
|
|
21
|
+
# Binds the field, then ensures the persisted selection (or initial/derived one) is applied.
|
|
15
22
|
def bind(state)
|
|
16
23
|
super
|
|
17
24
|
ensure_selection
|
|
18
25
|
end
|
|
19
26
|
|
|
27
|
+
# Forwards key events to the underlying List, syncing the chosen option index back
|
|
28
|
+
# into the field state. Returns :handled when consumed.
|
|
20
29
|
def handle_key(event)
|
|
21
30
|
selection = list
|
|
22
31
|
result = selection.handle_key(event)
|
|
@@ -28,24 +37,32 @@ module Charming
|
|
|
28
37
|
|
|
29
38
|
private
|
|
30
39
|
|
|
40
|
+
# The options array (used as the source of truth for default value and clamp).
|
|
31
41
|
attr_reader :options
|
|
32
42
|
|
|
43
|
+
# The default value is the option at the clamped initial selected index.
|
|
33
44
|
def default_value
|
|
34
45
|
options[clamped_initial_index]
|
|
35
46
|
end
|
|
36
47
|
|
|
48
|
+
# Renders the field as "Label: <display value>".
|
|
37
49
|
def render_control
|
|
38
50
|
"#{label}: #{display_value}"
|
|
39
51
|
end
|
|
40
52
|
|
|
53
|
+
# Returns the stringified value via the configured option label callable.
|
|
41
54
|
def display_value
|
|
42
55
|
value.nil? ? "" : @option_label.call(value)
|
|
43
56
|
end
|
|
44
57
|
|
|
58
|
+
# Builds a fresh List each render with the current options, selected index, label
|
|
59
|
+
# callable, and theme.
|
|
45
60
|
def list
|
|
46
61
|
List.new(items: options, selected_index: selected_index, label: @option_label, theme: theme)
|
|
47
62
|
end
|
|
48
63
|
|
|
64
|
+
# Ensures the persisted selection is set, falling back to the field's initial index
|
|
65
|
+
# or the current stored value.
|
|
49
66
|
def ensure_selection
|
|
50
67
|
if field_state.key?(:selected_index)
|
|
51
68
|
save_selection(field_state[:selected_index])
|
|
@@ -56,29 +73,35 @@ module Charming
|
|
|
56
73
|
end
|
|
57
74
|
end
|
|
58
75
|
|
|
76
|
+
# Persists the chosen *index* and the corresponding option as the field's value.
|
|
59
77
|
def save_selection(index)
|
|
60
78
|
field_state[:selected_index] = clamp_index(index)
|
|
61
79
|
state[:values][name] = options[field_state[:selected_index]]
|
|
62
80
|
end
|
|
63
81
|
|
|
82
|
+
# The currently persisted selected index (or the initial index when unset).
|
|
64
83
|
def selected_index
|
|
65
84
|
field_state[:selected_index] || clamped_initial_index
|
|
66
85
|
end
|
|
67
86
|
|
|
87
|
+
# Clamps the initial selected index to the valid range.
|
|
68
88
|
def clamped_initial_index
|
|
69
89
|
clamp_index(@initial_selected_index)
|
|
70
90
|
end
|
|
71
91
|
|
|
92
|
+
# Clamps *index* to the valid range. Returns 0 when there are no options.
|
|
72
93
|
def clamp_index(index)
|
|
73
94
|
return 0 if options.empty?
|
|
74
95
|
|
|
75
96
|
index.to_i.clamp(0, options.length - 1)
|
|
76
97
|
end
|
|
77
98
|
|
|
99
|
+
# Returns the index of *option* in the options array, or nil when absent.
|
|
78
100
|
def index_for(option)
|
|
79
101
|
options.index(option)
|
|
80
102
|
end
|
|
81
103
|
|
|
104
|
+
# Returns the per-field state hash for this field.
|
|
82
105
|
def field_state
|
|
83
106
|
state[:fields][name]
|
|
84
107
|
end
|
|
@@ -4,7 +4,13 @@ module Charming
|
|
|
4
4
|
module Presentation
|
|
5
5
|
module Components
|
|
6
6
|
class Form
|
|
7
|
+
# Textarea is a multi-line Form field backed by a TextArea widget. The cursor offset,
|
|
8
|
+
# top-visible row, and preferred vertical column are all persisted in the form's
|
|
9
|
+
# per-field state so the field behaves consistently when refocused mid-edit.
|
|
7
10
|
class Textarea < Field
|
|
11
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
12
|
+
# *width* and *height* constrain the rendered area. All other options are forwarded
|
|
13
|
+
# to Field (label, required, validate, help, theme).
|
|
8
14
|
def initialize(name, value: "", placeholder: "", width: nil, height: nil, **options)
|
|
9
15
|
super(name, **options)
|
|
10
16
|
@initial_value = value
|
|
@@ -13,6 +19,7 @@ module Charming
|
|
|
13
19
|
@height = height
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
# Binds the field, seeds the initial value, and initializes the cursor/offset state.
|
|
16
23
|
def bind(state)
|
|
17
24
|
super
|
|
18
25
|
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
@@ -20,6 +27,8 @@ module Charming
|
|
|
20
27
|
field_state[:offset] ||= 0
|
|
21
28
|
end
|
|
22
29
|
|
|
30
|
+
# Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
|
|
31
|
+
# and preferred column back into the form state. Returns :handled when consumed.
|
|
23
32
|
def handle_key(event)
|
|
24
33
|
area = text_area
|
|
25
34
|
result = area.handle_key(event)
|
|
@@ -32,6 +41,8 @@ module Charming
|
|
|
32
41
|
:handled
|
|
33
42
|
end
|
|
34
43
|
|
|
44
|
+
# Renders the field with its label on the first line, body lines indented, and
|
|
45
|
+
# optional help/error lines below.
|
|
35
46
|
def render(active: false)
|
|
36
47
|
label_line = "#{active ? ">" : " "} #{label}:"
|
|
37
48
|
label_line = theme.selected.render(label_line) if active
|
|
@@ -40,14 +51,18 @@ module Charming
|
|
|
40
51
|
|
|
41
52
|
private
|
|
42
53
|
|
|
54
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
43
55
|
def default_value
|
|
44
56
|
@initial_value
|
|
45
57
|
end
|
|
46
58
|
|
|
59
|
+
# Renders the multi-line body, indenting each line by two spaces.
|
|
47
60
|
def body_lines
|
|
48
61
|
text_area.render.lines(chomp: true).map { |line| " #{line}" }
|
|
49
62
|
end
|
|
50
63
|
|
|
64
|
+
# Builds a fresh TextArea each render, seeded from the current form-state value and
|
|
65
|
+
# the persisted cursor/offset/preferred_column.
|
|
51
66
|
def text_area
|
|
52
67
|
TextArea.new(
|
|
53
68
|
value: value.to_s,
|
|
@@ -60,6 +75,7 @@ module Charming
|
|
|
60
75
|
)
|
|
61
76
|
end
|
|
62
77
|
|
|
78
|
+
# Returns the per-field state hash for this field.
|
|
63
79
|
def field_state
|
|
64
80
|
state[:fields][name]
|
|
65
81
|
end
|
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
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.
|
|
6
11
|
class Form < Component
|
|
12
|
+
# The list of field objects and the mutable state hash the form is bound to.
|
|
7
13
|
attr_reader :fields, :state
|
|
8
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]`.
|
|
9
17
|
def initialize(fields:, state: nil, theme: nil)
|
|
10
18
|
super(theme: theme)
|
|
11
19
|
@fields = fields
|
|
@@ -14,6 +22,8 @@ module Charming
|
|
|
14
22
|
clamp_focus
|
|
15
23
|
end
|
|
16
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.
|
|
17
27
|
def handle_key(event)
|
|
18
28
|
key = Charming.key_of(event)
|
|
19
29
|
return :cancelled if key == :escape
|
|
@@ -26,10 +36,12 @@ module Charming
|
|
|
26
36
|
advance_or_submit if key == :enter
|
|
27
37
|
end
|
|
28
38
|
|
|
39
|
+
# Returns a hash of `{field_name => value}` for the current field values.
|
|
29
40
|
def values
|
|
30
41
|
state[:values]
|
|
31
42
|
end
|
|
32
43
|
|
|
44
|
+
# Renders each field on its own line, marking the active field with `active: true`.
|
|
33
45
|
def render
|
|
34
46
|
fields.each_with_index.map do |field, index|
|
|
35
47
|
field.render(active: index == state[:focus_index])
|
|
@@ -38,6 +50,8 @@ module Charming
|
|
|
38
50
|
|
|
39
51
|
private
|
|
40
52
|
|
|
53
|
+
# Ensures the state hash has all the required sub-keys: :values, :fields, :errors, and
|
|
54
|
+
# a sensible :focus_index default.
|
|
41
55
|
def normalize_state(value)
|
|
42
56
|
value[:values] ||= {}
|
|
43
57
|
value[:fields] ||= {}
|
|
@@ -46,30 +60,37 @@ module Charming
|
|
|
46
60
|
value
|
|
47
61
|
end
|
|
48
62
|
|
|
63
|
+
# Binds each field to the state hash so field updates write back into `state[:values]`.
|
|
49
64
|
def bind_fields
|
|
50
65
|
fields.each { |field| field.bind(state) }
|
|
51
66
|
end
|
|
52
67
|
|
|
68
|
+
# Forwards *event* to the currently focused field and returns its result.
|
|
53
69
|
def handle_current_field(event)
|
|
54
70
|
current_field&.handle_key(event)
|
|
55
71
|
end
|
|
56
72
|
|
|
73
|
+
# Returns -1 for Shift+Tab (backward), +1 for plain Tab (forward).
|
|
57
74
|
def tab_direction(event)
|
|
58
75
|
return -1 if event.respond_to?(:shift) && event.shift
|
|
59
76
|
|
|
60
77
|
+1
|
|
61
78
|
end
|
|
62
79
|
|
|
80
|
+
# True when the event is the submit shortcut (Ctrl+S).
|
|
63
81
|
def submit_shortcut?(event)
|
|
64
82
|
Charming.key_of(event) == :s && event.respond_to?(:ctrl) && event.ctrl
|
|
65
83
|
end
|
|
66
84
|
|
|
85
|
+
# On Enter: submit when the last focusable field is active, otherwise advance focus.
|
|
67
86
|
def advance_or_submit
|
|
68
87
|
return submit if last_focusable?
|
|
69
88
|
|
|
70
89
|
move_focus(+1)
|
|
71
90
|
end
|
|
72
91
|
|
|
92
|
+
# Validates all fields, focuses the first invalid one, and returns [:submitted, values]
|
|
93
|
+
# when there are no errors.
|
|
73
94
|
def submit
|
|
74
95
|
state[:errors] = validation_errors
|
|
75
96
|
focus_first_error unless state[:errors].empty?
|
|
@@ -78,6 +99,7 @@ module Charming
|
|
|
78
99
|
[:submitted, values.dup]
|
|
79
100
|
end
|
|
80
101
|
|
|
102
|
+
# Runs each field's validator and collects per-field error messages.
|
|
81
103
|
def validation_errors
|
|
82
104
|
fields.each_with_object({}) do |field, errors|
|
|
83
105
|
messages = field.validate
|
|
@@ -85,15 +107,18 @@ module Charming
|
|
|
85
107
|
end
|
|
86
108
|
end
|
|
87
109
|
|
|
110
|
+
# Moves focus to the first focusable field with errors, when any.
|
|
88
111
|
def focus_first_error
|
|
89
112
|
invalid = fields.index { |field| field.focusable? && state[:errors].key?(field.name) }
|
|
90
113
|
state[:focus_index] = invalid if invalid
|
|
91
114
|
end
|
|
92
115
|
|
|
116
|
+
# Returns the field at the current focus index, or nil when out of range.
|
|
93
117
|
def current_field
|
|
94
118
|
fields[state[:focus_index]]
|
|
95
119
|
end
|
|
96
120
|
|
|
121
|
+
# Moves focus by *direction* (forward or backward) through the focusable fields.
|
|
97
122
|
def move_focus(direction)
|
|
98
123
|
indices = focusable_indices
|
|
99
124
|
return nil if indices.empty?
|
|
@@ -103,18 +128,22 @@ module Charming
|
|
|
103
128
|
:handled
|
|
104
129
|
end
|
|
105
130
|
|
|
131
|
+
# True when the current focus index is the last focusable field.
|
|
106
132
|
def last_focusable?
|
|
107
133
|
focusable_indices.last == state[:focus_index]
|
|
108
134
|
end
|
|
109
135
|
|
|
136
|
+
# Indices of focusable fields, memoized.
|
|
110
137
|
def focusable_indices
|
|
111
138
|
@focusable_indices ||= fields.each_index.select { |index| fields[index].focusable? }
|
|
112
139
|
end
|
|
113
140
|
|
|
141
|
+
# The first index of a focusable field, or nil when no fields are focusable.
|
|
114
142
|
def first_focusable_index
|
|
115
143
|
fields.each_index.find { |index| fields[index].focusable? }
|
|
116
144
|
end
|
|
117
145
|
|
|
146
|
+
# On initialization, ensures :focus_index points at a focusable field.
|
|
118
147
|
def clamp_focus
|
|
119
148
|
return if focusable_indices.empty?
|
|
120
149
|
return if focusable_indices.include?(state[:focus_index])
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
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.
|
|
6
10
|
class List < Component
|
|
7
11
|
include KeyboardHandler
|
|
8
12
|
|
|
@@ -16,8 +20,13 @@ module Charming
|
|
|
16
20
|
end: :move_end
|
|
17
21
|
}.freeze
|
|
18
22
|
|
|
23
|
+
# The item array and the currently selected index within it.
|
|
19
24
|
attr_reader :items, :selected_index
|
|
20
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).
|
|
21
30
|
def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
|
|
22
31
|
super(theme: theme)
|
|
23
32
|
@items = items
|
|
@@ -28,12 +37,16 @@ module Charming
|
|
|
28
37
|
clamp_position
|
|
29
38
|
end
|
|
30
39
|
|
|
40
|
+
# Handles key events. Returns `[:selected, item]` on Enter when an item is selected;
|
|
41
|
+
# otherwise delegates to the KeyboardHandler for navigation keys.
|
|
31
42
|
def handle_key(event)
|
|
32
43
|
return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
|
|
33
44
|
|
|
34
45
|
super
|
|
35
46
|
end
|
|
36
47
|
|
|
48
|
+
# Handles mouse events: a click within the visible window selects the clicked row.
|
|
49
|
+
# Returns :handled on a successful click, nil otherwise.
|
|
37
50
|
def handle_mouse(event)
|
|
38
51
|
return nil unless @height
|
|
39
52
|
return nil unless event.respond_to?(:click?) && event.click?
|
|
@@ -46,10 +59,13 @@ module Charming
|
|
|
46
59
|
:handled
|
|
47
60
|
end
|
|
48
61
|
|
|
62
|
+
# Returns the currently selected item, or nil when the list is empty.
|
|
49
63
|
def selected_item
|
|
50
64
|
items[selected_index]
|
|
51
65
|
end
|
|
52
66
|
|
|
67
|
+
# Renders the visible window of items, prefixing each with "> " (and applying the
|
|
68
|
+
# selected style) or " ".
|
|
53
69
|
def render
|
|
54
70
|
visible_items.each_with_index.map do |item, index|
|
|
55
71
|
render_item(item, viewport_start + index)
|
|
@@ -58,42 +74,54 @@ module Charming
|
|
|
58
74
|
|
|
59
75
|
private
|
|
60
76
|
|
|
77
|
+
# Moves the selection up one position.
|
|
61
78
|
def move_up
|
|
62
79
|
@selected_index -= 1 if selected_index.positive?
|
|
63
80
|
end
|
|
64
81
|
|
|
82
|
+
# Moves the selection down one position.
|
|
65
83
|
def move_down
|
|
66
84
|
@selected_index += 1 if selected_index < items.length - 1
|
|
67
85
|
end
|
|
68
86
|
|
|
87
|
+
# Moves the selection to the first item.
|
|
69
88
|
def move_home
|
|
70
89
|
@selected_index = 0
|
|
71
90
|
end
|
|
72
91
|
|
|
92
|
+
# Moves the selection to the last item (no-op when the list is empty).
|
|
73
93
|
def move_end
|
|
74
94
|
@selected_index = items.length - 1 unless items.empty?
|
|
75
95
|
end
|
|
76
96
|
|
|
97
|
+
# Returns the slice of items currently in the visible window.
|
|
77
98
|
def visible_items
|
|
78
99
|
items[viewport_start, viewport_height] || []
|
|
79
100
|
end
|
|
80
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.
|
|
81
104
|
def viewport_start
|
|
82
105
|
return 0 unless @height
|
|
83
106
|
|
|
84
107
|
Layout.selected_window_start(selected_index: selected_index, item_count: items.length, window_size: @height)
|
|
85
108
|
end
|
|
86
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).
|
|
87
112
|
def viewport_height
|
|
88
113
|
@height || items.length
|
|
89
114
|
end
|
|
90
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.
|
|
91
118
|
def render_item(item, index)
|
|
92
119
|
prefix = (index == selected_index) ? "> " : " "
|
|
93
120
|
rendered = "#{prefix}#{@label.call(item)}"
|
|
94
121
|
(index == selected_index) ? theme.selected.render(rendered) : rendered
|
|
95
122
|
end
|
|
96
123
|
|
|
124
|
+
# Resets the selection when the list is empty, otherwise clamps it to the valid range.
|
|
97
125
|
def clamp_position
|
|
98
126
|
@selected_index = 0 if items.empty?
|
|
99
127
|
@selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
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.
|
|
6
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).
|
|
7
12
|
def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
|
|
8
13
|
super(theme: theme)
|
|
9
14
|
@content = content
|
|
@@ -11,6 +16,7 @@ module Charming
|
|
|
11
16
|
@syntax_highlighting = syntax_highlighting
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
# Renders the Markdown body to a styled, terminal-safe string.
|
|
14
20
|
def render
|
|
15
21
|
Charming::Presentation::Markdown::Renderer.new(
|
|
16
22
|
content: @content,
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
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.
|
|
6
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.
|
|
7
13
|
def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
|
|
8
14
|
super(theme: theme)
|
|
9
15
|
@content = content
|
|
@@ -13,6 +19,8 @@ module Charming
|
|
|
13
19
|
@style = style
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
# Renders the modal as a bordered, padded string with the title and help lines stacked
|
|
23
|
+
# above the content.
|
|
16
24
|
def render
|
|
17
25
|
box(column(*lines, gap: 1), style: modal_style)
|
|
18
26
|
end
|
|
@@ -21,26 +29,32 @@ module Charming
|
|
|
21
29
|
|
|
22
30
|
attr_reader :content, :title, :help, :width
|
|
23
31
|
|
|
32
|
+
# Returns the array of non-nil lines: title, help, content.
|
|
24
33
|
def lines
|
|
25
34
|
[title_line, help_line, render_content].compact
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
# Returns the centered title line styled with the theme's title style, when a title was given.
|
|
28
38
|
def title_line
|
|
29
39
|
text(title, style: theme.title.align(:center).width(title_width)) if title
|
|
30
40
|
end
|
|
31
41
|
|
|
42
|
+
# Returns the help line styled with the theme's muted style, when help was given.
|
|
32
43
|
def help_line
|
|
33
44
|
text(help, style: theme.muted) if help
|
|
34
45
|
end
|
|
35
46
|
|
|
47
|
+
# Returns the rendered content string, calling `render` on the body when applicable.
|
|
36
48
|
def render_content
|
|
37
49
|
content.respond_to?(:render) ? render_component(content) : content.to_s
|
|
38
50
|
end
|
|
39
51
|
|
|
52
|
+
# Returns the modal's outer style: the user-provided style or `theme.modal` at the given width.
|
|
40
53
|
def modal_style
|
|
41
54
|
@style || theme.modal.width(width)
|
|
42
55
|
end
|
|
43
56
|
|
|
57
|
+
# Returns the title's display width, accounting for the modal's horizontal padding/border.
|
|
44
58
|
def title_width
|
|
45
59
|
[width - 8, 0].max
|
|
46
60
|
end
|