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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. 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 Presentation
5
- module Components
6
- class Form
7
- class Note < Field
8
- def initialize(text, name: :note, theme: nil)
9
- super(name, theme: theme)
10
- @text = text
11
- end
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
- def bind(state)
14
- @state = state
15
- end
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
- def focusable?
18
- false
19
- end
22
+ # Notes are never focusable and therefore excluded from Tab/Enter traversal.
23
+ def focusable?
24
+ false
25
+ end
20
26
 
21
- def validate
22
- []
23
- end
27
+ # Notes never produce validation errors.
28
+ def validate
29
+ []
30
+ end
24
31
 
25
- def render(active: false)
26
- @text.to_s
27
- end
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 Presentation
5
- module Components
6
- class Form
7
- class Select < Field
8
- def initialize(name, options:, selected_index: 0, option_label: :to_s.to_proc, **field_options)
9
- super(name, **field_options)
10
- @options = options
11
- @initial_selected_index = selected_index
12
- @option_label = option_label
13
- end
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
- def bind(state)
16
- super
17
- ensure_selection
18
- end
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
- def handle_key(event)
21
- selection = list
22
- result = selection.handle_key(event)
23
- return nil unless result == :handled
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
- save_selection(selection.selected_index)
26
- :handled
27
- end
33
+ save_selection(selection.selected_index)
34
+ :handled
35
+ end
28
36
 
29
- private
37
+ private
30
38
 
31
- attr_reader :options
39
+ # The options array (used as the source of truth for default value and clamp).
40
+ attr_reader :options
32
41
 
33
- def default_value
34
- options[clamped_initial_index]
35
- end
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
- def render_control
38
- "#{label}: #{display_value}"
39
- end
47
+ # Renders the field as "Label: <display value>".
48
+ def render_control
49
+ "#{label}: #{display_value}"
50
+ end
40
51
 
41
- def display_value
42
- value.nil? ? "" : @option_label.call(value)
43
- end
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
- def list
46
- List.new(items: options, selected_index: selected_index, label: @option_label, theme: theme)
47
- end
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
- def ensure_selection
50
- if field_state.key?(:selected_index)
51
- save_selection(field_state[:selected_index])
52
- elsif state[:values].key?(name)
53
- save_selection(index_for(state[:values][name]) || clamped_initial_index)
54
- else
55
- save_selection(clamped_initial_index)
56
- end
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
- def save_selection(index)
60
- field_state[:selected_index] = clamp_index(index)
61
- state[:values][name] = options[field_state[:selected_index]]
62
- end
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
- def selected_index
65
- field_state[:selected_index] || clamped_initial_index
66
- end
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
- def clamped_initial_index
69
- clamp_index(@initial_selected_index)
70
- end
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
- def clamp_index(index)
73
- return 0 if options.empty?
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
- index.to_i.clamp(0, options.length - 1)
76
- end
95
+ index.to_i.clamp(0, options.length - 1)
96
+ end
77
97
 
78
- def index_for(option)
79
- options.index(option)
80
- end
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
- def field_state
83
- state[:fields][name]
84
- end
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 Presentation
5
- module Components
6
- class Form
7
- class Textarea < Field
8
- def initialize(name, value: "", placeholder: "", width: nil, height: nil, **options)
9
- super(name, **options)
10
- @initial_value = value
11
- @placeholder = placeholder
12
- @width = width
13
- @height = height
14
- end
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
- def bind(state)
17
- super
18
- state[:values][name] = @initial_value if state[:values][name].nil?
19
- field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
20
- field_state[:offset] ||= 0
21
- end
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
- def handle_key(event)
24
- area = text_area
25
- result = area.handle_key(event)
26
- return nil unless result == :handled
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
- state[:values][name] = area.value
29
- field_state[:cursor] = area.cursor
30
- field_state[:offset] = area.offset
31
- field_state[:preferred_column] = area.preferred_column
32
- :handled
33
- end
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
- def render(active: false)
36
- label_line = "#{active ? ">" : " "} #{label}:"
37
- label_line = theme.selected.render(label_line) if active
38
- [label_line, *body_lines, help_line, *error_lines].compact.join("\n")
39
- end
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
- private
51
+ private
42
52
 
43
- def default_value
44
- @initial_value
45
- end
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
- def body_lines
48
- text_area.render.lines(chomp: true).map { |line| " #{line}" }
49
- end
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
- def text_area
52
- TextArea.new(
53
- value: value.to_s,
54
- placeholder: @placeholder,
55
- width: @width,
56
- height: @height,
57
- cursor: field_state[:cursor],
58
- offset: field_state[:offset],
59
- preferred_column: field_state[:preferred_column]
60
- )
61
- end
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
- def field_state
64
- state[:fields][name]
65
- end
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 Presentation
5
- module Components
6
- class Form < Component
7
- attr_reader :fields, :state
8
-
9
- def initialize(fields:, state: nil, theme: nil)
10
- super(theme: theme)
11
- @fields = fields
12
- @state = normalize_state(state || {})
13
- bind_fields
14
- clamp_focus
15
- end
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
- def handle_key(event)
18
- key = Charming.key_of(event)
19
- return :cancelled if key == :escape
20
- return submit if submit_shortcut?(event)
21
- return move_focus(tab_direction(event)) if key == :tab
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
- result = handle_current_field(event)
24
- return result if result
32
+ result = handle_current_field(event)
33
+ return result if result
25
34
 
26
- advance_or_submit if key == :enter
27
- end
35
+ advance_or_submit if key == :enter
36
+ end
28
37
 
29
- def values
30
- state[:values]
31
- end
38
+ # Returns a hash of `{field_name => value}` for the current field values.
39
+ def values
40
+ state[:values]
41
+ end
32
42
 
33
- def render
34
- fields.each_with_index.map do |field, index|
35
- field.render(active: index == state[:focus_index])
36
- end.join("\n")
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
- private
50
+ private
40
51
 
41
- def normalize_state(value)
42
- value[:values] ||= {}
43
- value[:fields] ||= {}
44
- value[:errors] ||= {}
45
- value[:focus_index] ||= first_focusable_index || 0
46
- value
47
- end
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
- def bind_fields
50
- fields.each { |field| field.bind(state) }
51
- end
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
- def handle_current_field(event)
54
- current_field&.handle_key(event)
55
- end
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
- def tab_direction(event)
58
- return -1 if event.respond_to?(:shift) && event.shift
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
- +1
61
- end
76
+ +1
77
+ end
62
78
 
63
- def submit_shortcut?(event)
64
- Charming.key_of(event) == :s && event.respond_to?(:ctrl) && event.ctrl
65
- end
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
- def advance_or_submit
68
- return submit if last_focusable?
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
- move_focus(+1)
71
- end
88
+ move_focus(+1)
89
+ end
72
90
 
73
- def submit
74
- state[:errors] = validation_errors
75
- focus_first_error unless state[:errors].empty?
76
- return :handled unless state[:errors].empty?
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
- [:submitted, values.dup]
79
- end
98
+ [:submitted, values.dup]
99
+ end
80
100
 
81
- def validation_errors
82
- fields.each_with_object({}) do |field, errors|
83
- messages = field.validate
84
- errors[field.name] = messages unless messages.empty?
85
- end
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
- def focus_first_error
89
- invalid = fields.index { |field| field.focusable? && state[:errors].key?(field.name) }
90
- state[:focus_index] = invalid if invalid
91
- end
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
- def current_field
94
- fields[state[:focus_index]]
95
- end
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
- def move_focus(direction)
98
- indices = focusable_indices
99
- return nil if indices.empty?
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
- current = indices.index(state[:focus_index]) || 0
102
- state[:focus_index] = indices[(current + direction) % indices.length]
103
- :handled
104
- end
125
+ current = indices.index(state[:focus_index]) || 0
126
+ state[:focus_index] = indices[(current + direction) % indices.length]
127
+ :handled
128
+ end
105
129
 
106
- def last_focusable?
107
- focusable_indices.last == state[:focus_index]
108
- end
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
- def focusable_indices
111
- @focusable_indices ||= fields.each_index.select { |index| fields[index].focusable? }
112
- end
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
- def first_focusable_index
115
- fields.each_index.find { |index| fields[index].focusable? }
116
- end
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
- def clamp_focus
119
- return if focusable_indices.empty?
120
- return if focusable_indices.include?(state[:focus_index])
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
- state[:focus_index] = focusable_indices.first
123
- end
150
+ state[:focus_index] = focusable_indices.first
124
151
  end
125
152
  end
126
153
  end