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,30 +1,37 @@
|
|
|
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
|
+
# Note is a non-interactive Form field that renders a static string of text. Notes
|
|
7
|
+
# never receive focus, never validate, and store no value — they are presentational
|
|
8
|
+
# only, useful for headings, dividers, or instructional text inside a form.
|
|
9
|
+
class Note < Field
|
|
10
|
+
# *text* is the literal string to render. *name* is unused (defaults to :note) and
|
|
11
|
+
# exists only because the Field base class requires a name.
|
|
12
|
+
def initialize(text, name: :note, theme: nil)
|
|
13
|
+
super(name, theme: theme)
|
|
14
|
+
@text = text
|
|
15
|
+
end
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
# Binds the field to the form state but does not create any per-field storage.
|
|
18
|
+
def bind(state)
|
|
19
|
+
@state = state
|
|
20
|
+
end
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
# Notes are never focusable and therefore excluded from Tab/Enter traversal.
|
|
23
|
+
def focusable?
|
|
24
|
+
false
|
|
25
|
+
end
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
# Notes never produce validation errors.
|
|
28
|
+
def validate
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
# Returns the literal text, ignoring the *active:* flag (notes have no focus state).
|
|
33
|
+
def render(active: false)
|
|
34
|
+
@text.to_s
|
|
28
35
|
end
|
|
29
36
|
end
|
|
30
37
|
end
|
|
@@ -1,87 +1,108 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
module Components
|
|
5
|
+
class Form
|
|
6
|
+
# Select is a single-choice Form field backed by a List widget. The selected option
|
|
7
|
+
# becomes the field's value; navigation keys (up/down/home/end) cycle through options
|
|
8
|
+
# and Enter has no effect (selection is applied immediately on key release).
|
|
9
|
+
class Select < Field
|
|
10
|
+
# *options* is the array of selectable values. *selected_index* defaults to 0.
|
|
11
|
+
# *option_label* is a callable used to extract the display string (default: `to_s`).
|
|
12
|
+
# All other options are forwarded to Field.
|
|
13
|
+
def initialize(name, options:, selected_index: 0, option_label: :to_s.to_proc, **field_options)
|
|
14
|
+
super(name, **field_options)
|
|
15
|
+
@options = options
|
|
16
|
+
@initial_selected_index = selected_index
|
|
17
|
+
@option_label = option_label
|
|
18
|
+
end
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
# Binds the field, then ensures the persisted selection (or initial/derived one) is applied.
|
|
21
|
+
def bind(state)
|
|
22
|
+
super
|
|
23
|
+
ensure_selection
|
|
24
|
+
end
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
# Forwards key events to the underlying List, syncing the chosen option index back
|
|
27
|
+
# into the field state. Returns :handled when consumed.
|
|
28
|
+
def handle_key(event)
|
|
29
|
+
selection = list
|
|
30
|
+
result = selection.handle_key(event)
|
|
31
|
+
return nil unless result == :handled
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
save_selection(selection.selected_index)
|
|
34
|
+
:handled
|
|
35
|
+
end
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
private
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
# The options array (used as the source of truth for default value and clamp).
|
|
40
|
+
attr_reader :options
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
# The default value is the option at the clamped initial selected index.
|
|
43
|
+
def default_value
|
|
44
|
+
options[clamped_initial_index]
|
|
45
|
+
end
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
# Renders the field as "Label: <display value>".
|
|
48
|
+
def render_control
|
|
49
|
+
"#{label}: #{display_value}"
|
|
50
|
+
end
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
# Returns the stringified value via the configured option label callable.
|
|
53
|
+
def display_value
|
|
54
|
+
value.nil? ? "" : @option_label.call(value)
|
|
55
|
+
end
|
|
44
56
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
# Builds a fresh List each render with the current options, selected index, label
|
|
58
|
+
# callable, and theme.
|
|
59
|
+
def list
|
|
60
|
+
List.new(items: options, selected_index: selected_index, label: @option_label, theme: theme)
|
|
61
|
+
end
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
# Ensures the persisted selection is set, falling back to the field's initial index
|
|
64
|
+
# or the current stored value.
|
|
65
|
+
def ensure_selection
|
|
66
|
+
if field_state.key?(:selected_index)
|
|
67
|
+
save_selection(field_state[:selected_index])
|
|
68
|
+
elsif state[:values].key?(name)
|
|
69
|
+
save_selection(index_for(state[:values][name]) || clamped_initial_index)
|
|
70
|
+
else
|
|
71
|
+
save_selection(clamped_initial_index)
|
|
57
72
|
end
|
|
73
|
+
end
|
|
58
74
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
# Persists the chosen *index* and the corresponding option as the field's value.
|
|
76
|
+
def save_selection(index)
|
|
77
|
+
field_state[:selected_index] = clamp_index(index)
|
|
78
|
+
state[:values][name] = options[field_state[:selected_index]]
|
|
79
|
+
end
|
|
63
80
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
# The currently persisted selected index (or the initial index when unset).
|
|
82
|
+
def selected_index
|
|
83
|
+
field_state[:selected_index] || clamped_initial_index
|
|
84
|
+
end
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
# Clamps the initial selected index to the valid range.
|
|
87
|
+
def clamped_initial_index
|
|
88
|
+
clamp_index(@initial_selected_index)
|
|
89
|
+
end
|
|
71
90
|
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
# Clamps *index* to the valid range. Returns 0 when there are no options.
|
|
92
|
+
def clamp_index(index)
|
|
93
|
+
return 0 if options.empty?
|
|
74
94
|
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
index.to_i.clamp(0, options.length - 1)
|
|
96
|
+
end
|
|
77
97
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
# Returns the index of *option* in the options array, or nil when absent.
|
|
99
|
+
def index_for(option)
|
|
100
|
+
options.index(option)
|
|
101
|
+
end
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
# Returns the per-field state hash for this field.
|
|
104
|
+
def field_state
|
|
105
|
+
state[:fields][name]
|
|
85
106
|
end
|
|
86
107
|
end
|
|
87
108
|
end
|
|
@@ -1,68 +1,82 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
module Components
|
|
5
|
+
class Form
|
|
6
|
+
# Textarea is a multi-line Form field backed by a TextArea widget. The cursor offset,
|
|
7
|
+
# top-visible row, and preferred vertical column are all persisted in the form's
|
|
8
|
+
# per-field state so the field behaves consistently when refocused mid-edit.
|
|
9
|
+
class Textarea < Field
|
|
10
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
11
|
+
# *width* and *height* constrain the rendered area. All other options are forwarded
|
|
12
|
+
# to Field (label, required, validate, help, theme).
|
|
13
|
+
def initialize(name, value: "", placeholder: "", width: nil, height: nil, **options)
|
|
14
|
+
super(name, **options)
|
|
15
|
+
@initial_value = value
|
|
16
|
+
@placeholder = placeholder
|
|
17
|
+
@width = width
|
|
18
|
+
@height = height
|
|
19
|
+
end
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
# Binds the field, seeds the initial value, and initializes the cursor/offset state.
|
|
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
|
+
field_state[:offset] ||= 0
|
|
27
|
+
end
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
# Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
|
|
30
|
+
# and preferred column back into the form state. Returns :handled when consumed.
|
|
31
|
+
def handle_key(event)
|
|
32
|
+
area = text_area
|
|
33
|
+
result = area.handle_key(event)
|
|
34
|
+
return nil unless result == :handled
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
state[:values][name] = area.value
|
|
37
|
+
field_state[:cursor] = area.cursor
|
|
38
|
+
field_state[:offset] = area.offset
|
|
39
|
+
field_state[:preferred_column] = area.preferred_column
|
|
40
|
+
:handled
|
|
41
|
+
end
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
# Renders the field with its label on the first line, body lines indented, and
|
|
44
|
+
# optional help/error lines below.
|
|
45
|
+
def render(active: false)
|
|
46
|
+
label_line = "#{active ? ">" : " "} #{label}:"
|
|
47
|
+
label_line = theme.selected.render(label_line) if active
|
|
48
|
+
[label_line, *body_lines, help_line, *error_lines].compact.join("\n")
|
|
49
|
+
end
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
private
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
54
|
+
def default_value
|
|
55
|
+
@initial_value
|
|
56
|
+
end
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
# Renders the multi-line body, indenting each line by two spaces.
|
|
59
|
+
def body_lines
|
|
60
|
+
text_area.render.lines(chomp: true).map { |line| " #{line}" }
|
|
61
|
+
end
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
# Builds a fresh TextArea each render, seeded from the current form-state value and
|
|
64
|
+
# the persisted cursor/offset/preferred_column.
|
|
65
|
+
def text_area
|
|
66
|
+
TextArea.new(
|
|
67
|
+
value: value.to_s,
|
|
68
|
+
placeholder: @placeholder,
|
|
69
|
+
width: @width,
|
|
70
|
+
height: @height,
|
|
71
|
+
cursor: field_state[:cursor],
|
|
72
|
+
offset: field_state[:offset],
|
|
73
|
+
preferred_column: field_state[:preferred_column]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
# Returns the per-field state hash for this field.
|
|
78
|
+
def field_state
|
|
79
|
+
state[:fields][name]
|
|
66
80
|
end
|
|
67
81
|
end
|
|
68
82
|
end
|
|
@@ -1,126 +1,153 @@
|
|
|
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
|
-
|
|
4
|
+
module Components
|
|
5
|
+
# Form is a multi-field form component with built-in focus traversal, validation, and
|
|
6
|
+
# submit/cancel handling. Fields are produced by `Form::Builder` (see `controller.form`)
|
|
7
|
+
# and bound to a per-form mutable state hash. Tab/Shift+Tab cycles focus through
|
|
8
|
+
# focusable fields, Enter advances to the next field (or submits on the last), Escape
|
|
9
|
+
# cancels, and Ctrl+S submits from any field.
|
|
10
|
+
class Form < Component
|
|
11
|
+
# The list of field objects and the mutable state hash the form is bound to.
|
|
12
|
+
attr_reader :fields, :state
|
|
13
|
+
|
|
14
|
+
# *fields* is the array of form field objects. *state* is a hash for storing field
|
|
15
|
+
# values/errors and the current focus index; usually `session[:forms][form_name]`.
|
|
16
|
+
def initialize(fields:, state: nil, theme: nil)
|
|
17
|
+
super(theme: theme)
|
|
18
|
+
@fields = fields
|
|
19
|
+
@state = normalize_state(state || {})
|
|
20
|
+
bind_fields
|
|
21
|
+
clamp_focus
|
|
22
|
+
end
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
# Handles key events: Escape cancels, Ctrl+S submits, Tab cycles focus, Enter advances
|
|
25
|
+
# or submits, and unhandled keys are passed to the focused field.
|
|
26
|
+
def handle_key(event)
|
|
27
|
+
key = Charming.key_of(event)
|
|
28
|
+
return :cancelled if key == :escape
|
|
29
|
+
return submit if submit_shortcut?(event)
|
|
30
|
+
return move_focus(tab_direction(event)) if key == :tab
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
result = handle_current_field(event)
|
|
33
|
+
return result if result
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
advance_or_submit if key == :enter
|
|
36
|
+
end
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
# Returns a hash of `{field_name => value}` for the current field values.
|
|
39
|
+
def values
|
|
40
|
+
state[:values]
|
|
41
|
+
end
|
|
32
42
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
43
|
+
# Renders each field on its own line, marking the active field with `active: true`.
|
|
44
|
+
def render
|
|
45
|
+
fields.each_with_index.map do |field, index|
|
|
46
|
+
field.render(active: index == state[:focus_index])
|
|
47
|
+
end.join("\n")
|
|
48
|
+
end
|
|
38
49
|
|
|
39
|
-
|
|
50
|
+
private
|
|
40
51
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
# Ensures the state hash has all the required sub-keys: :values, :fields, :errors, and
|
|
53
|
+
# a sensible :focus_index default.
|
|
54
|
+
def normalize_state(value)
|
|
55
|
+
value[:values] ||= {}
|
|
56
|
+
value[:fields] ||= {}
|
|
57
|
+
value[:errors] ||= {}
|
|
58
|
+
value[:focus_index] ||= first_focusable_index || 0
|
|
59
|
+
value
|
|
60
|
+
end
|
|
48
61
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
# Binds each field to the state hash so field updates write back into `state[:values]`.
|
|
63
|
+
def bind_fields
|
|
64
|
+
fields.each { |field| field.bind(state) }
|
|
65
|
+
end
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
# Forwards *event* to the currently focused field and returns its result.
|
|
68
|
+
def handle_current_field(event)
|
|
69
|
+
current_field&.handle_key(event)
|
|
70
|
+
end
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
# Returns -1 for Shift+Tab (backward), +1 for plain Tab (forward).
|
|
73
|
+
def tab_direction(event)
|
|
74
|
+
return -1 if event.respond_to?(:shift) && event.shift
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
+1
|
|
77
|
+
end
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
# True when the event is the submit shortcut (Ctrl+S).
|
|
80
|
+
def submit_shortcut?(event)
|
|
81
|
+
Charming.key_of(event) == :s && event.respond_to?(:ctrl) && event.ctrl
|
|
82
|
+
end
|
|
66
83
|
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
# On Enter: submit when the last focusable field is active, otherwise advance focus.
|
|
85
|
+
def advance_or_submit
|
|
86
|
+
return submit if last_focusable?
|
|
69
87
|
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
move_focus(+1)
|
|
89
|
+
end
|
|
72
90
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
# Validates all fields, focuses the first invalid one, and returns [:submitted, values]
|
|
92
|
+
# when there are no errors.
|
|
93
|
+
def submit
|
|
94
|
+
state[:errors] = validation_errors
|
|
95
|
+
focus_first_error unless state[:errors].empty?
|
|
96
|
+
return :handled unless state[:errors].empty?
|
|
77
97
|
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
[:submitted, values.dup]
|
|
99
|
+
end
|
|
80
100
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
# Runs each field's validator and collects per-field error messages.
|
|
102
|
+
def validation_errors
|
|
103
|
+
fields.each_with_object({}) do |field, errors|
|
|
104
|
+
messages = field.validate
|
|
105
|
+
errors[field.name] = messages unless messages.empty?
|
|
86
106
|
end
|
|
107
|
+
end
|
|
87
108
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
# Moves focus to the first focusable field with errors, when any.
|
|
110
|
+
def focus_first_error
|
|
111
|
+
invalid = fields.index { |field| field.focusable? && state[:errors].key?(field.name) }
|
|
112
|
+
state[:focus_index] = invalid if invalid
|
|
113
|
+
end
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
# Returns the field at the current focus index, or nil when out of range.
|
|
116
|
+
def current_field
|
|
117
|
+
fields[state[:focus_index]]
|
|
118
|
+
end
|
|
96
119
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
# Moves focus by *direction* (forward or backward) through the focusable fields.
|
|
121
|
+
def move_focus(direction)
|
|
122
|
+
indices = focusable_indices
|
|
123
|
+
return nil if indices.empty?
|
|
100
124
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
current = indices.index(state[:focus_index]) || 0
|
|
126
|
+
state[:focus_index] = indices[(current + direction) % indices.length]
|
|
127
|
+
:handled
|
|
128
|
+
end
|
|
105
129
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
130
|
+
# True when the current focus index is the last focusable field.
|
|
131
|
+
def last_focusable?
|
|
132
|
+
focusable_indices.last == state[:focus_index]
|
|
133
|
+
end
|
|
109
134
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
135
|
+
# Indices of focusable fields, memoized.
|
|
136
|
+
def focusable_indices
|
|
137
|
+
@focusable_indices ||= fields.each_index.select { |index| fields[index].focusable? }
|
|
138
|
+
end
|
|
113
139
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
# The first index of a focusable field, or nil when no fields are focusable.
|
|
141
|
+
def first_focusable_index
|
|
142
|
+
fields.each_index.find { |index| fields[index].focusable? }
|
|
143
|
+
end
|
|
117
144
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
145
|
+
# On initialization, ensures :focus_index points at a focusable field.
|
|
146
|
+
def clamp_focus
|
|
147
|
+
return if focusable_indices.empty?
|
|
148
|
+
return if focusable_indices.include?(state[:focus_index])
|
|
121
149
|
|
|
122
|
-
|
|
123
|
-
end
|
|
150
|
+
state[:focus_index] = focusable_indices.first
|
|
124
151
|
end
|
|
125
152
|
end
|
|
126
153
|
end
|