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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /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