charming 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +14 -3
- 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 +110 -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 +25 -2
- 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 +65 -115
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -36
- data/lib/charming/presentation/components/form/builder.rb +52 -40
- data/lib/charming/presentation/components/form/confirm.rb +50 -39
- data/lib/charming/presentation/components/form/field.rb +94 -71
- data/lib/charming/presentation/components/form/input.rb +61 -49
- data/lib/charming/presentation/components/form/note.rb +27 -20
- data/lib/charming/presentation/components/form/select.rb +84 -63
- data/lib/charming/presentation/components/form/textarea.rb +67 -53
- data/lib/charming/presentation/components/form.rb +120 -93
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -97
- data/lib/charming/presentation/components/markdown.rb +21 -17
- data/lib/charming/presentation/components/modal.rb +55 -43
- data/lib/charming/presentation/components/progressbar.rb +61 -50
- data/lib/charming/presentation/components/spinner.rb +35 -27
- data/lib/charming/presentation/components/table.rb +108 -85
- data/lib/charming/presentation/components/text_area.rb +219 -173
- data/lib/charming/presentation/components/text_input.rb +120 -98
- data/lib/charming/presentation/components/viewport.rb +218 -168
- data/lib/charming/presentation/layout/builder.rb +84 -0
- data/lib/charming/presentation/layout/overlay.rb +55 -0
- data/lib/charming/presentation/layout/pane.rb +149 -0
- data/lib/charming/presentation/layout/rect.rb +21 -0
- data/lib/charming/presentation/layout/screen_layout.rb +58 -0
- data/lib/charming/presentation/layout/split.rb +132 -0
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
- data/lib/charming/presentation/markdown/render_context.rb +20 -0
- data/lib/charming/presentation/markdown/renderer.rb +82 -174
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
- data/lib/charming/presentation/markdown.rb +4 -3
- data/lib/charming/presentation/template_view.rb +22 -17
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -32
- data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +56 -0
- data/lib/charming/presentation/ui/canvas.rb +80 -0
- data/lib/charming/presentation/ui/style.rb +170 -205
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +70 -213
- data/lib/charming/presentation/view.rb +107 -92
- data/lib/charming/runtime.rb +25 -10
- 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 +16 -2
- metadata +60 -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
|
@@ -1,42 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
4
|
+
module Components
|
|
5
|
+
# EmptyState is a placeholder component for screens with no content. Renders one of three
|
|
6
|
+
# states: a default "nothing to show" message, a "loading…" message, or an error message
|
|
7
|
+
# with optional help text.
|
|
8
|
+
class EmptyState < Component
|
|
9
|
+
# *message* is shown in the default state. *loading* switches to the loading message
|
|
10
|
+
# (overrides *message*). *loading_message* is the string rendered in the loading state.
|
|
11
|
+
# *error* and *error_message* switch to the error state (the string form takes precedence).
|
|
12
|
+
# *help* is an optional muted line shown below the error message.
|
|
13
|
+
def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
|
|
14
|
+
super(theme: theme)
|
|
15
|
+
@message = message
|
|
16
|
+
@loading = loading
|
|
17
|
+
@loading_message = loading_message
|
|
18
|
+
@error = error
|
|
19
|
+
@error_message = error_message
|
|
20
|
+
@help = help
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Renders the appropriate state as styled text: loading → loading message, error →
|
|
24
|
+
# error message + help, otherwise the default message.
|
|
25
|
+
def render
|
|
26
|
+
return loading_state if @loading
|
|
27
|
+
return error_state if error?
|
|
28
|
+
|
|
29
|
+
text @message, style: theme.muted
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Renders the loading state as a muted line.
|
|
35
|
+
def loading_state
|
|
36
|
+
text @loading_message, style: theme.muted
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Renders the error state: the error message styled with the theme's warn style,
|
|
40
|
+
# optionally followed by a muted help line.
|
|
41
|
+
def error_state
|
|
42
|
+
lines = [text(@error_message || @error.to_s, style: theme.warn)]
|
|
43
|
+
lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
|
|
44
|
+
|
|
45
|
+
column(*lines)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# True when either the *error* or *error_message* string is non-blank.
|
|
49
|
+
def error?
|
|
50
|
+
@error.to_s.strip != "" || @error_message.to_s.strip != ""
|
|
40
51
|
end
|
|
41
52
|
end
|
|
42
53
|
end
|
|
@@ -1,46 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
4
|
+
module Components
|
|
5
|
+
class Form
|
|
6
|
+
# Builder collects form field declarations inside a `form(:name) { ... }` block and
|
|
7
|
+
# assembles them into a Form component when `build` is called. Each declaration method
|
|
8
|
+
# appends a Field subclass instance to the builder's *fields* list.
|
|
9
|
+
class Builder
|
|
10
|
+
# The accumulated field list and the theme applied to each declared field.
|
|
11
|
+
attr_reader :fields, :theme
|
|
12
|
+
|
|
13
|
+
# Initializes an empty builder. *theme* is forwarded to every declared field unless
|
|
14
|
+
# the field declaration explicitly overrides it.
|
|
15
|
+
def initialize(theme: nil)
|
|
16
|
+
@theme = theme
|
|
17
|
+
@fields = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Appends a single-line Input field. *options* are passed through to Input.
|
|
21
|
+
def input(name, **options)
|
|
22
|
+
fields << Input.new(name, **field_options(options))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Appends a multi-line Textarea field.
|
|
26
|
+
def textarea(name, **options)
|
|
27
|
+
fields << Textarea.new(name, **field_options(options))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Appends a Select field with the given *options* array.
|
|
31
|
+
def select(name, **options)
|
|
32
|
+
fields << Select.new(name, **field_options(options))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Appends a Confirm (boolean) field.
|
|
36
|
+
def confirm(name, **options)
|
|
37
|
+
fields << Confirm.new(name, **field_options(options))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Appends a static Note (non-focusable).
|
|
41
|
+
def note(text, **options)
|
|
42
|
+
fields << Note.new(text, **field_options(options))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Assembles the collected fields into a Form component, bound to *state* and using
|
|
46
|
+
# the *theme* argument (falling back to the builder's theme).
|
|
47
|
+
def build(state:, theme: nil)
|
|
48
|
+
Components::Form.new(fields: fields, state: state, theme: theme || self.theme)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Merges the builder's theme into the per-field *options* so each field receives it.
|
|
54
|
+
def field_options(options)
|
|
55
|
+
{theme: theme}.merge(options)
|
|
44
56
|
end
|
|
45
57
|
end
|
|
46
58
|
end
|
|
@@ -1,54 +1,65 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
module Components
|
|
5
|
+
class Form
|
|
6
|
+
# Confirm is a boolean Form field that renders a checkbox-style control. Space toggles
|
|
7
|
+
# the value; y/Right sets it to true; n/Left sets it to false. Required confirms must
|
|
8
|
+
# be accepted (value == true) to pass validation.
|
|
9
|
+
class Confirm < Field
|
|
10
|
+
# *value* is the initial boolean state (default: false). All other options are
|
|
11
|
+
# forwarded to Field.
|
|
12
|
+
def initialize(name, value: false, **options)
|
|
13
|
+
super(name, **options)
|
|
14
|
+
@initial_value = value
|
|
15
|
+
end
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
# Handles the standard confirm keys: space toggles, y/right sets to true, n/left
|
|
18
|
+
# sets to false, and a space character (when the event exposes `char`) also toggles.
|
|
19
|
+
def handle_key(event)
|
|
20
|
+
case Charming.key_of(event)
|
|
21
|
+
when :space
|
|
22
|
+
toggle
|
|
23
|
+
when :y, :right
|
|
24
|
+
state[:values][name] = true
|
|
25
|
+
when :n, :left
|
|
26
|
+
state[:values][name] = false
|
|
27
|
+
else
|
|
28
|
+
return nil unless event.respond_to?(:char) && event.char == " "
|
|
29
|
+
|
|
30
|
+
toggle
|
|
27
31
|
end
|
|
32
|
+
:handled
|
|
33
|
+
end
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
# Returns ["must be accepted"] when required and the value is not true, otherwise
|
|
36
|
+
# the result of the base Field validation.
|
|
37
|
+
def validate
|
|
38
|
+
return ["must be accepted"] if required? && value != true
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
super
|
|
41
|
+
end
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
private
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
46
|
+
def default_value
|
|
47
|
+
@initial_value
|
|
48
|
+
end
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
# Renders "[x] Label" or "[ ] Label" depending on the current value.
|
|
51
|
+
def render_control
|
|
52
|
+
"#{checked_marker} #{label}"
|
|
53
|
+
end
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
# Returns the checkbox marker string.
|
|
56
|
+
def checked_marker
|
|
57
|
+
value ? "[x]" : "[ ]"
|
|
58
|
+
end
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
# Flips the current value (true ↔ false).
|
|
61
|
+
def toggle
|
|
62
|
+
state[:values][name] = !value
|
|
52
63
|
end
|
|
53
64
|
end
|
|
54
65
|
end
|
|
@@ -1,94 +1,117 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
class Form
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
4
|
+
module Components
|
|
5
|
+
class Form
|
|
6
|
+
# Field is the abstract base class for Form fields. Subclasses define `default_value`
|
|
7
|
+
# and `render_control` (or override `render`); the base class supplies validation,
|
|
8
|
+
# help-line rendering, error-line rendering, and value lookup against the form state.
|
|
9
|
+
class Field < Component
|
|
10
|
+
# The field's name symbol, human-readable label, optional help text, and bound state hash.
|
|
11
|
+
attr_reader :name, :label, :help, :state
|
|
12
|
+
|
|
13
|
+
# *name* is the value key (a Symbol). *label* defaults to a humanized version of *name*.
|
|
14
|
+
# *required* enables a "is required" validator. *validate* is an optional callable
|
|
15
|
+
# (returning nil/true → ok, false → "is invalid", Array → messages, anything else → stringified).
|
|
16
|
+
# *help* is an optional muted helper line rendered under the field.
|
|
17
|
+
def initialize(name, label: nil, required: false, validate: nil, help: nil, theme: nil)
|
|
18
|
+
super(theme: theme)
|
|
19
|
+
@name = name.to_sym
|
|
20
|
+
@label = label || humanize(name)
|
|
21
|
+
@required = required
|
|
22
|
+
@validator = validate
|
|
23
|
+
@help = help
|
|
24
|
+
end
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
# Binds the field to the form's *state* hash, ensuring the field's per-field state
|
|
27
|
+
# and a default value are present.
|
|
28
|
+
def bind(state)
|
|
29
|
+
@state = state
|
|
30
|
+
state[:fields][name] ||= {}
|
|
31
|
+
state[:values][name] = default_value unless state[:values].key?(name)
|
|
32
|
+
end
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
# Subclasses that participate in Tab/Enter navigation return true. Default is true.
|
|
35
|
+
def focusable?
|
|
36
|
+
true
|
|
37
|
+
end
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
# Default key handler returns nil (no key handling). Subclasses override.
|
|
40
|
+
def handle_key(_event)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
32
43
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
# Renders the field as a control line prefixed with ">" (active) or " " (inactive),
|
|
45
|
+
# optionally followed by the help line and any error lines.
|
|
46
|
+
def render(active: false)
|
|
47
|
+
line = "#{active ? ">" : " "} #{render_control}"
|
|
48
|
+
line = theme.selected.render(line) if active
|
|
49
|
+
[line, help_line, *error_lines].compact.join("\n")
|
|
50
|
+
end
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
# Returns an array of validation error messages. Includes "is required" when
|
|
53
|
+
# the field is required and blank, plus any messages produced by the user validator.
|
|
54
|
+
def validate
|
|
55
|
+
messages = []
|
|
56
|
+
messages << "is required" if required? && blank?(value)
|
|
57
|
+
messages.concat(validator_messages) if @validator
|
|
58
|
+
messages
|
|
59
|
+
end
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
# The current value of this field from the bound state.
|
|
62
|
+
def value
|
|
63
|
+
state[:values][name]
|
|
64
|
+
end
|
|
49
65
|
|
|
50
|
-
|
|
66
|
+
private
|
|
51
67
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
# The default value assigned to a freshly-bound field. Subclasses override.
|
|
69
|
+
def default_value
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
# Renders the control portion (label + value). Default: "Label: <value>".
|
|
74
|
+
def render_control
|
|
75
|
+
"#{label}: #{value}"
|
|
76
|
+
end
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
# True when the field was declared with `required: true`.
|
|
79
|
+
def required?
|
|
80
|
+
@required
|
|
81
|
+
end
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
# True when *value* is nil, an empty string, or responds to `empty?` with true.
|
|
84
|
+
def blank?(value)
|
|
85
|
+
return true if value.nil?
|
|
86
|
+
return value.strip.empty? if value.is_a?(String)
|
|
67
87
|
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
value.respond_to?(:empty?) && value.empty?
|
|
89
|
+
end
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
# Normalizes the user validator's return value into an array of error message strings.
|
|
92
|
+
def validator_messages
|
|
93
|
+
result = @validator.call(value)
|
|
94
|
+
case result
|
|
95
|
+
when nil, true then []
|
|
96
|
+
when false then ["is invalid"]
|
|
97
|
+
when Array then result
|
|
98
|
+
else [result.to_s]
|
|
79
99
|
end
|
|
100
|
+
end
|
|
80
101
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
102
|
+
# The muted help line (with two-space indent) when help text was given.
|
|
103
|
+
def help_line
|
|
104
|
+
" #{theme.muted.render(help)}" if help
|
|
105
|
+
end
|
|
84
106
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
# The list of error lines (with two-space indent) for any errors stored against this field.
|
|
108
|
+
def error_lines
|
|
109
|
+
Array(state[:errors][name]).map { |message| " #{theme.warn.render(message)}" }
|
|
110
|
+
end
|
|
88
111
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
# Converts a snake_case symbol/string to a humanized "Capitalized" string.
|
|
113
|
+
def humanize(value)
|
|
114
|
+
value.to_s.tr("_", " ").capitalize
|
|
92
115
|
end
|
|
93
116
|
end
|
|
94
117
|
end
|
|
@@ -1,55 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
4
|
+
module Components
|
|
5
|
+
class Form
|
|
6
|
+
# Input is a single-line Form field backed by a TextInput widget. The cursor position
|
|
7
|
+
# is persisted in the form's per-field state so the field can be refocused mid-edit.
|
|
8
|
+
class Input < Field
|
|
9
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
10
|
+
# *width* optionally constrains the rendered width. All other options are forwarded
|
|
11
|
+
# to Field (label, required, validate, help, theme).
|
|
12
|
+
def initialize(name, value: "", placeholder: "", width: nil, **options)
|
|
13
|
+
super(name, **options)
|
|
14
|
+
@initial_value = value
|
|
15
|
+
@placeholder = placeholder
|
|
16
|
+
@width = width
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Binds the field to the form state, sets the initial value if absent, and initializes
|
|
20
|
+
# the per-field cursor offset to the end of the value.
|
|
21
|
+
def bind(state)
|
|
22
|
+
super
|
|
23
|
+
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
24
|
+
field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Forwards key events to the underlying TextInput, syncing the value and cursor
|
|
28
|
+
# back into the form state. Returns :handled when the event was consumed.
|
|
29
|
+
def handle_key(event)
|
|
30
|
+
text_input = input
|
|
31
|
+
result = text_input.handle_key(event)
|
|
32
|
+
return nil unless result == :handled
|
|
33
|
+
|
|
34
|
+
state[:values][name] = text_input.value
|
|
35
|
+
field_state[:cursor] = text_input.cursor
|
|
36
|
+
:handled
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
42
|
+
def default_value
|
|
43
|
+
@initial_value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Renders the field as "Label: <text input>".
|
|
47
|
+
def render_control
|
|
48
|
+
"#{label}: #{input.render}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds a fresh TextInput each render, seeded from the current form-state value
|
|
52
|
+
# and the persisted cursor offset.
|
|
53
|
+
def input
|
|
54
|
+
TextInput.new(
|
|
55
|
+
value: value.to_s,
|
|
56
|
+
placeholder: @placeholder,
|
|
57
|
+
width: @width,
|
|
58
|
+
cursor: field_state[:cursor]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the per-field state hash for this field.
|
|
63
|
+
def field_state
|
|
64
|
+
state[:fields][name]
|
|
53
65
|
end
|
|
54
66
|
end
|
|
55
67
|
end
|