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,42 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- class EmptyState < Component
7
- def initialize(message: "Nothing to show.", loading: false, loading_message: "Loading...", error: nil, error_message: nil, help: nil, theme: nil)
8
- super(theme: theme)
9
- @message = message
10
- @loading = loading
11
- @loading_message = loading_message
12
- @error = error
13
- @error_message = error_message
14
- @help = help
15
- end
16
-
17
- def render
18
- return loading_state if @loading
19
- return error_state if error?
20
-
21
- text @message, style: theme.muted
22
- end
23
-
24
- private
25
-
26
- def loading_state
27
- text @loading_message, style: theme.muted
28
- end
29
-
30
- def error_state
31
- lines = [text(@error_message || @error.to_s, style: theme.warn)]
32
- lines << text(@help, style: theme.muted) if @help.to_s.strip != ""
33
-
34
- column(*lines)
35
- end
36
-
37
- def error?
38
- @error.to_s.strip != "" || @error_message.to_s.strip != ""
39
- end
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 Presentation
5
- module Components
6
- class Form
7
- class Builder
8
- attr_reader :fields, :theme
9
-
10
- def initialize(theme: nil)
11
- @theme = theme
12
- @fields = []
13
- end
14
-
15
- def input(name, **options)
16
- fields << Input.new(name, **field_options(options))
17
- end
18
-
19
- def textarea(name, **options)
20
- fields << Textarea.new(name, **field_options(options))
21
- end
22
-
23
- def select(name, **options)
24
- fields << Select.new(name, **field_options(options))
25
- end
26
-
27
- def confirm(name, **options)
28
- fields << Confirm.new(name, **field_options(options))
29
- end
30
-
31
- def note(text, **options)
32
- fields << Note.new(text, **field_options(options))
33
- end
34
-
35
- def build(state:, theme: nil)
36
- Components::Form.new(fields: fields, state: state, theme: theme || self.theme)
37
- end
38
-
39
- private
40
-
41
- def field_options(options)
42
- {theme: theme}.merge(options)
43
- end
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 Presentation
5
- module Components
6
- class Form
7
- class Confirm < Field
8
- def initialize(name, value: false, **options)
9
- super(name, **options)
10
- @initial_value = value
11
- end
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
- def handle_key(event)
14
- case Charming.key_of(event)
15
- when :space
16
- toggle
17
- when :y, :right
18
- state[:values][name] = true
19
- when :n, :left
20
- state[:values][name] = false
21
- else
22
- return nil unless event.respond_to?(:char) && event.char == " "
23
-
24
- toggle
25
- end
26
- :handled
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
- def validate
30
- return ["must be accepted"] if required? && value != true
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
- super
33
- end
40
+ super
41
+ end
34
42
 
35
- private
43
+ private
36
44
 
37
- def default_value
38
- @initial_value
39
- end
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
- def render_control
42
- "#{checked_marker} #{label}"
43
- end
50
+ # Renders "[x] Label" or "[ ] Label" depending on the current value.
51
+ def render_control
52
+ "#{checked_marker} #{label}"
53
+ end
44
54
 
45
- def checked_marker
46
- value ? "[x]" : "[ ]"
47
- end
55
+ # Returns the checkbox marker string.
56
+ def checked_marker
57
+ value ? "[x]" : "[ ]"
58
+ end
48
59
 
49
- def toggle
50
- state[:values][name] = !value
51
- end
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 Presentation
5
- module Components
6
- class Form
7
- class Field < Component
8
- attr_reader :name, :label, :help, :state
9
-
10
- def initialize(name, label: nil, required: false, validate: nil, help: nil, theme: nil)
11
- super(theme: theme)
12
- @name = name.to_sym
13
- @label = label || humanize(name)
14
- @required = required
15
- @validator = validate
16
- @help = help
17
- end
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
- def bind(state)
20
- @state = state
21
- state[:fields][name] ||= {}
22
- state[:values][name] = default_value unless state[:values].key?(name)
23
- end
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
- def focusable?
26
- true
27
- end
34
+ # Subclasses that participate in Tab/Enter navigation return true. Default is true.
35
+ def focusable?
36
+ true
37
+ end
28
38
 
29
- def handle_key(_event)
30
- nil
31
- end
39
+ # Default key handler returns nil (no key handling). Subclasses override.
40
+ def handle_key(_event)
41
+ nil
42
+ end
32
43
 
33
- def render(active: false)
34
- line = "#{active ? ">" : " "} #{render_control}"
35
- line = theme.selected.render(line) if active
36
- [line, help_line, *error_lines].compact.join("\n")
37
- end
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
- def validate
40
- messages = []
41
- messages << "is required" if required? && blank?(value)
42
- messages.concat(validator_messages) if @validator
43
- messages
44
- end
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
- def value
47
- state[:values][name]
48
- end
61
+ # The current value of this field from the bound state.
62
+ def value
63
+ state[:values][name]
64
+ end
49
65
 
50
- private
66
+ private
51
67
 
52
- def default_value
53
- nil
54
- end
68
+ # The default value assigned to a freshly-bound field. Subclasses override.
69
+ def default_value
70
+ nil
71
+ end
55
72
 
56
- def render_control
57
- "#{label}: #{value}"
58
- end
73
+ # Renders the control portion (label + value). Default: "Label: <value>".
74
+ def render_control
75
+ "#{label}: #{value}"
76
+ end
59
77
 
60
- def required?
61
- @required
62
- end
78
+ # True when the field was declared with `required: true`.
79
+ def required?
80
+ @required
81
+ end
63
82
 
64
- def blank?(value)
65
- return true if value.nil?
66
- return value.strip.empty? if value.is_a?(String)
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
- value.respond_to?(:empty?) && value.empty?
69
- end
88
+ value.respond_to?(:empty?) && value.empty?
89
+ end
70
90
 
71
- def validator_messages
72
- result = @validator.call(value)
73
- case result
74
- when nil, true then []
75
- when false then ["is invalid"]
76
- when Array then result
77
- else [result.to_s]
78
- end
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
- def help_line
82
- " #{theme.muted.render(help)}" if help
83
- end
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
- def error_lines
86
- Array(state[:errors][name]).map { |message| " #{theme.warn.render(message)}" }
87
- end
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
- def humanize(value)
90
- value.to_s.tr("_", " ").capitalize
91
- end
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 Presentation
5
- module Components
6
- class Form
7
- class Input < Field
8
- def initialize(name, value: "", placeholder: "", width: nil, **options)
9
- super(name, **options)
10
- @initial_value = value
11
- @placeholder = placeholder
12
- @width = width
13
- end
14
-
15
- def bind(state)
16
- super
17
- state[:values][name] = @initial_value if state[:values][name].nil?
18
- field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
19
- end
20
-
21
- def handle_key(event)
22
- text_input = input
23
- result = text_input.handle_key(event)
24
- return nil unless result == :handled
25
-
26
- state[:values][name] = text_input.value
27
- field_state[:cursor] = text_input.cursor
28
- :handled
29
- end
30
-
31
- private
32
-
33
- def default_value
34
- @initial_value
35
- end
36
-
37
- def render_control
38
- "#{label}: #{input.render}"
39
- end
40
-
41
- def input
42
- TextInput.new(
43
- value: value.to_s,
44
- placeholder: @placeholder,
45
- width: @width,
46
- cursor: field_state[:cursor]
47
- )
48
- end
49
-
50
- def field_state
51
- state[:fields][name]
52
- end
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