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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
# Builder collects form field declarations inside a `form(:name) { ... }` block and
|
|
8
|
+
# assembles them into a Form component when `build` is called. Each declaration method
|
|
9
|
+
# appends a Field subclass instance to the builder's *fields* list.
|
|
10
|
+
class Builder
|
|
11
|
+
# The accumulated field list and the theme applied to each declared field.
|
|
12
|
+
attr_reader :fields, :theme
|
|
13
|
+
|
|
14
|
+
# Initializes an empty builder. *theme* is forwarded to every declared field unless
|
|
15
|
+
# the field declaration explicitly overrides it.
|
|
16
|
+
def initialize(theme: nil)
|
|
17
|
+
@theme = theme
|
|
18
|
+
@fields = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Appends a single-line Input field. *options* are passed through to Input.
|
|
22
|
+
def input(name, **options)
|
|
23
|
+
fields << Input.new(name, **field_options(options))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Appends a multi-line Textarea field.
|
|
27
|
+
def textarea(name, **options)
|
|
28
|
+
fields << Textarea.new(name, **field_options(options))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Appends a Select field with the given *options* array.
|
|
32
|
+
def select(name, **options)
|
|
33
|
+
fields << Select.new(name, **field_options(options))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Appends a Confirm (boolean) field.
|
|
37
|
+
def confirm(name, **options)
|
|
38
|
+
fields << Confirm.new(name, **field_options(options))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Appends a static Note (non-focusable).
|
|
42
|
+
def note(text, **options)
|
|
43
|
+
fields << Note.new(text, **field_options(options))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Assembles the collected fields into a Form component, bound to *state* and using
|
|
47
|
+
# the *theme* argument (falling back to the builder's theme).
|
|
48
|
+
def build(state:, theme: nil)
|
|
49
|
+
Components::Form.new(fields: fields, state: state, theme: theme || self.theme)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Merges the builder's theme into the per-field *options* so each field receives it.
|
|
55
|
+
def field_options(options)
|
|
56
|
+
{theme: theme}.merge(options)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
# Confirm is a boolean Form field that renders a checkbox-style control. Space toggles
|
|
8
|
+
# the value; y/Right sets it to true; n/Left sets it to false. Required confirms must
|
|
9
|
+
# be accepted (value == true) to pass validation.
|
|
10
|
+
class Confirm < Field
|
|
11
|
+
# *value* is the initial boolean state (default: false). All other options are
|
|
12
|
+
# forwarded to Field.
|
|
13
|
+
def initialize(name, value: false, **options)
|
|
14
|
+
super(name, **options)
|
|
15
|
+
@initial_value = value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Handles the standard confirm keys: space toggles, y/right sets to true, n/left
|
|
19
|
+
# sets to false, and a space character (when the event exposes `char`) also toggles.
|
|
20
|
+
def handle_key(event)
|
|
21
|
+
case Charming.key_of(event)
|
|
22
|
+
when :space
|
|
23
|
+
toggle
|
|
24
|
+
when :y, :right
|
|
25
|
+
state[:values][name] = true
|
|
26
|
+
when :n, :left
|
|
27
|
+
state[:values][name] = false
|
|
28
|
+
else
|
|
29
|
+
return nil unless event.respond_to?(:char) && event.char == " "
|
|
30
|
+
|
|
31
|
+
toggle
|
|
32
|
+
end
|
|
33
|
+
:handled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns ["must be accepted"] when required and the value is not true, otherwise
|
|
37
|
+
# the result of the base Field validation.
|
|
38
|
+
def validate
|
|
39
|
+
return ["must be accepted"] if required? && value != true
|
|
40
|
+
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
47
|
+
def default_value
|
|
48
|
+
@initial_value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Renders "[x] Label" or "[ ] Label" depending on the current value.
|
|
52
|
+
def render_control
|
|
53
|
+
"#{checked_marker} #{label}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the checkbox marker string.
|
|
57
|
+
def checked_marker
|
|
58
|
+
value ? "[x]" : "[ ]"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Flips the current value (true ↔ false).
|
|
62
|
+
def toggle
|
|
63
|
+
state[:values][name] = !value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
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.
|
|
10
|
+
class Field < Component
|
|
11
|
+
# The field's name symbol, human-readable label, optional help text, and bound state hash.
|
|
12
|
+
attr_reader :name, :label, :help, :state
|
|
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.
|
|
18
|
+
def initialize(name, label: nil, required: false, validate: nil, help: nil, theme: nil)
|
|
19
|
+
super(theme: theme)
|
|
20
|
+
@name = name.to_sym
|
|
21
|
+
@label = label || humanize(name)
|
|
22
|
+
@required = required
|
|
23
|
+
@validator = validate
|
|
24
|
+
@help = help
|
|
25
|
+
end
|
|
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.
|
|
29
|
+
def bind(state)
|
|
30
|
+
@state = state
|
|
31
|
+
state[:fields][name] ||= {}
|
|
32
|
+
state[:values][name] = default_value unless state[:values].key?(name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Subclasses that participate in Tab/Enter navigation return true. Default is true.
|
|
36
|
+
def focusable?
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Default key handler returns nil (no key handling). Subclasses override.
|
|
41
|
+
def handle_key(_event)
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
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.
|
|
47
|
+
def render(active: false)
|
|
48
|
+
line = "#{active ? ">" : " "} #{render_control}"
|
|
49
|
+
line = theme.selected.render(line) if active
|
|
50
|
+
[line, help_line, *error_lines].compact.join("\n")
|
|
51
|
+
end
|
|
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.
|
|
55
|
+
def validate
|
|
56
|
+
messages = []
|
|
57
|
+
messages << "is required" if required? && blank?(value)
|
|
58
|
+
messages.concat(validator_messages) if @validator
|
|
59
|
+
messages
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The current value of this field from the bound state.
|
|
63
|
+
def value
|
|
64
|
+
state[:values][name]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# The default value assigned to a freshly-bound field. Subclasses override.
|
|
70
|
+
def default_value
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Renders the control portion (label + value). Default: "Label: <value>".
|
|
75
|
+
def render_control
|
|
76
|
+
"#{label}: #{value}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# True when the field was declared with `required: true`.
|
|
80
|
+
def required?
|
|
81
|
+
@required
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True when *value* is nil, an empty string, or responds to `empty?` with true.
|
|
85
|
+
def blank?(value)
|
|
86
|
+
return true if value.nil?
|
|
87
|
+
return value.strip.empty? if value.is_a?(String)
|
|
88
|
+
|
|
89
|
+
value.respond_to?(:empty?) && value.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Normalizes the user validator's return value into an array of error message strings.
|
|
93
|
+
def validator_messages
|
|
94
|
+
result = @validator.call(value)
|
|
95
|
+
case result
|
|
96
|
+
when nil, true then []
|
|
97
|
+
when false then ["is invalid"]
|
|
98
|
+
when Array then result
|
|
99
|
+
else [result.to_s]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The muted help line (with two-space indent) when help text was given.
|
|
104
|
+
def help_line
|
|
105
|
+
" #{theme.muted.render(help)}" if help
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The list of error lines (with two-space indent) for any errors stored against this field.
|
|
109
|
+
def error_lines
|
|
110
|
+
Array(state[:errors][name]).map { |message| " #{theme.warn.render(message)}" }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Converts a snake_case symbol/string to a humanized "Capitalized" string.
|
|
114
|
+
def humanize(value)
|
|
115
|
+
value.to_s.tr("_", " ").capitalize
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
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.
|
|
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).
|
|
13
|
+
def initialize(name, value: "", placeholder: "", width: nil, **options)
|
|
14
|
+
super(name, **options)
|
|
15
|
+
@initial_value = value
|
|
16
|
+
@placeholder = placeholder
|
|
17
|
+
@width = width
|
|
18
|
+
end
|
|
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.
|
|
22
|
+
def bind(state)
|
|
23
|
+
super
|
|
24
|
+
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
25
|
+
field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
|
|
26
|
+
end
|
|
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.
|
|
30
|
+
def handle_key(event)
|
|
31
|
+
text_input = input
|
|
32
|
+
result = text_input.handle_key(event)
|
|
33
|
+
return nil unless result == :handled
|
|
34
|
+
|
|
35
|
+
state[:values][name] = text_input.value
|
|
36
|
+
field_state[:cursor] = text_input.cursor
|
|
37
|
+
:handled
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
43
|
+
def default_value
|
|
44
|
+
@initial_value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Renders the field as "Label: <text input>".
|
|
48
|
+
def render_control
|
|
49
|
+
"#{label}: #{input.render}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Builds a fresh TextInput each render, seeded from the current form-state value
|
|
53
|
+
# and the persisted cursor offset.
|
|
54
|
+
def input
|
|
55
|
+
TextInput.new(
|
|
56
|
+
value: value.to_s,
|
|
57
|
+
placeholder: @placeholder,
|
|
58
|
+
width: @width,
|
|
59
|
+
cursor: field_state[:cursor]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns the per-field state hash for this field.
|
|
64
|
+
def field_state
|
|
65
|
+
state[:fields][name]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
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.
|
|
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.
|
|
13
|
+
def initialize(text, name: :note, theme: nil)
|
|
14
|
+
super(name, theme: theme)
|
|
15
|
+
@text = text
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Binds the field to the form state but does not create any per-field storage.
|
|
19
|
+
def bind(state)
|
|
20
|
+
@state = state
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Notes are never focusable and therefore excluded from Tab/Enter traversal.
|
|
24
|
+
def focusable?
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Notes never produce validation errors.
|
|
29
|
+
def validate
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the literal text, ignoring the *active:* flag (notes have no focus state).
|
|
34
|
+
def render(active: false)
|
|
35
|
+
@text.to_s
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
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).
|
|
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.
|
|
14
|
+
def initialize(name, options:, selected_index: 0, option_label: :to_s.to_proc, **field_options)
|
|
15
|
+
super(name, **field_options)
|
|
16
|
+
@options = options
|
|
17
|
+
@initial_selected_index = selected_index
|
|
18
|
+
@option_label = option_label
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Binds the field, then ensures the persisted selection (or initial/derived one) is applied.
|
|
22
|
+
def bind(state)
|
|
23
|
+
super
|
|
24
|
+
ensure_selection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Forwards key events to the underlying List, syncing the chosen option index back
|
|
28
|
+
# into the field state. Returns :handled when consumed.
|
|
29
|
+
def handle_key(event)
|
|
30
|
+
selection = list
|
|
31
|
+
result = selection.handle_key(event)
|
|
32
|
+
return nil unless result == :handled
|
|
33
|
+
|
|
34
|
+
save_selection(selection.selected_index)
|
|
35
|
+
:handled
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# The options array (used as the source of truth for default value and clamp).
|
|
41
|
+
attr_reader :options
|
|
42
|
+
|
|
43
|
+
# The default value is the option at the clamped initial selected index.
|
|
44
|
+
def default_value
|
|
45
|
+
options[clamped_initial_index]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Renders the field as "Label: <display value>".
|
|
49
|
+
def render_control
|
|
50
|
+
"#{label}: #{display_value}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the stringified value via the configured option label callable.
|
|
54
|
+
def display_value
|
|
55
|
+
value.nil? ? "" : @option_label.call(value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Builds a fresh List each render with the current options, selected index, label
|
|
59
|
+
# callable, and theme.
|
|
60
|
+
def list
|
|
61
|
+
List.new(items: options, selected_index: selected_index, label: @option_label, theme: theme)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Ensures the persisted selection is set, falling back to the field's initial index
|
|
65
|
+
# or the current stored value.
|
|
66
|
+
def ensure_selection
|
|
67
|
+
if field_state.key?(:selected_index)
|
|
68
|
+
save_selection(field_state[:selected_index])
|
|
69
|
+
elsif state[:values].key?(name)
|
|
70
|
+
save_selection(index_for(state[:values][name]) || clamped_initial_index)
|
|
71
|
+
else
|
|
72
|
+
save_selection(clamped_initial_index)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Persists the chosen *index* and the corresponding option as the field's value.
|
|
77
|
+
def save_selection(index)
|
|
78
|
+
field_state[:selected_index] = clamp_index(index)
|
|
79
|
+
state[:values][name] = options[field_state[:selected_index]]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The currently persisted selected index (or the initial index when unset).
|
|
83
|
+
def selected_index
|
|
84
|
+
field_state[:selected_index] || clamped_initial_index
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Clamps the initial selected index to the valid range.
|
|
88
|
+
def clamped_initial_index
|
|
89
|
+
clamp_index(@initial_selected_index)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Clamps *index* to the valid range. Returns 0 when there are no options.
|
|
93
|
+
def clamp_index(index)
|
|
94
|
+
return 0 if options.empty?
|
|
95
|
+
|
|
96
|
+
index.to_i.clamp(0, options.length - 1)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the index of *option* in the options array, or nil when absent.
|
|
100
|
+
def index_for(option)
|
|
101
|
+
options.index(option)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns the per-field state hash for this field.
|
|
105
|
+
def field_state
|
|
106
|
+
state[:fields][name]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
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.
|
|
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).
|
|
14
|
+
def initialize(name, value: "", placeholder: "", width: nil, height: nil, **options)
|
|
15
|
+
super(name, **options)
|
|
16
|
+
@initial_value = value
|
|
17
|
+
@placeholder = placeholder
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Binds the field, seeds the initial value, and initializes the cursor/offset state.
|
|
23
|
+
def bind(state)
|
|
24
|
+
super
|
|
25
|
+
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
26
|
+
field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
|
|
27
|
+
field_state[:offset] ||= 0
|
|
28
|
+
end
|
|
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.
|
|
32
|
+
def handle_key(event)
|
|
33
|
+
area = text_area
|
|
34
|
+
result = area.handle_key(event)
|
|
35
|
+
return nil unless result == :handled
|
|
36
|
+
|
|
37
|
+
state[:values][name] = area.value
|
|
38
|
+
field_state[:cursor] = area.cursor
|
|
39
|
+
field_state[:offset] = area.offset
|
|
40
|
+
field_state[:preferred_column] = area.preferred_column
|
|
41
|
+
:handled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Renders the field with its label on the first line, body lines indented, and
|
|
45
|
+
# optional help/error lines below.
|
|
46
|
+
def render(active: false)
|
|
47
|
+
label_line = "#{active ? ">" : " "} #{label}:"
|
|
48
|
+
label_line = theme.selected.render(label_line) if active
|
|
49
|
+
[label_line, *body_lines, help_line, *error_lines].compact.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
55
|
+
def default_value
|
|
56
|
+
@initial_value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Renders the multi-line body, indenting each line by two spaces.
|
|
60
|
+
def body_lines
|
|
61
|
+
text_area.render.lines(chomp: true).map { |line| " #{line}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Builds a fresh TextArea each render, seeded from the current form-state value and
|
|
65
|
+
# the persisted cursor/offset/preferred_column.
|
|
66
|
+
def text_area
|
|
67
|
+
TextArea.new(
|
|
68
|
+
value: value.to_s,
|
|
69
|
+
placeholder: @placeholder,
|
|
70
|
+
width: @width,
|
|
71
|
+
height: @height,
|
|
72
|
+
cursor: field_state[:cursor],
|
|
73
|
+
offset: field_state[:offset],
|
|
74
|
+
preferred_column: field_state[:preferred_column]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the per-field state hash for this field.
|
|
79
|
+
def field_state
|
|
80
|
+
state[:fields][name]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|