charming 0.3.0 → 0.4.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/charming/application.rb +91 -14
  4. data/lib/charming/application_state.rb +23 -0
  5. data/lib/charming/cli.rb +2 -2
  6. data/lib/charming/controller/action_hooks.rb +5 -1
  7. data/lib/charming/controller/class_methods.rb +66 -15
  8. data/lib/charming/controller/component_dispatch.rb +163 -0
  9. data/lib/charming/controller/dispatching.rb +1 -2
  10. data/lib/charming/controller/focus_management.rb +67 -1
  11. data/lib/charming/controller/key_dispatch.rb +7 -7
  12. data/lib/charming/controller/rendering.rb +22 -6
  13. data/lib/charming/controller/session_state.rb +63 -22
  14. data/lib/charming/controller.rb +247 -59
  15. data/lib/charming/cross_thread_access.rb +9 -0
  16. data/lib/charming/double_render_error.rb +8 -0
  17. data/lib/charming/generators/layout_generator.rb +4 -1
  18. data/lib/charming/generators/migration_generator.rb +1 -1
  19. data/lib/charming/generators/model_generator.rb +2 -2
  20. data/lib/charming/generators/name.rb +1 -1
  21. data/lib/charming/generators/screen_generator.rb +2 -2
  22. data/lib/charming/generators/view_generator.rb +1 -1
  23. data/lib/charming/internal/deep_freeze.rb +23 -0
  24. data/lib/charming/internal/env_inquirer.rb +22 -0
  25. data/lib/charming/internal/inflections.rb +93 -0
  26. data/lib/charming/internal/session_guard.rb +27 -0
  27. data/lib/charming/internal/terminal/cursor.rb +29 -0
  28. data/lib/charming/internal/terminal/size.rb +47 -0
  29. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  30. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  31. data/lib/charming/presentation/components/command_palette.rb +11 -9
  32. data/lib/charming/presentation/components/filepicker.rb +4 -4
  33. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  34. data/lib/charming/presentation/components/form/field.rb +1 -1
  35. data/lib/charming/presentation/components/form/input.rb +3 -3
  36. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  37. data/lib/charming/presentation/components/form/select.rb +3 -3
  38. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  39. data/lib/charming/presentation/components/form.rb +6 -6
  40. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  41. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  42. data/lib/charming/presentation/components/list.rb +14 -5
  43. data/lib/charming/presentation/components/modal.rb +3 -2
  44. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  45. data/lib/charming/presentation/components/result.rb +61 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  47. data/lib/charming/presentation/components/table.rb +64 -32
  48. data/lib/charming/presentation/components/text_area.rb +7 -7
  49. data/lib/charming/presentation/components/text_input.rb +7 -7
  50. data/lib/charming/presentation/components/tree.rb +15 -6
  51. data/lib/charming/presentation/components/viewport.rb +3 -3
  52. data/lib/charming/presentation/layout/pane.rb +6 -2
  53. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  54. data/lib/charming/presentation/view.rb +35 -29
  55. data/lib/charming/render_artifacts.rb +24 -0
  56. data/lib/charming/response.rb +19 -8
  57. data/lib/charming/router.rb +50 -68
  58. data/lib/charming/runtime.rb +42 -26
  59. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  60. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  61. data/lib/charming/tasks/context.rb +35 -0
  62. data/lib/charming/test_helper.rb +42 -22
  63. data/lib/charming/unhandled_component_event.rb +9 -0
  64. data/lib/charming/unknown_slot.rb +9 -0
  65. data/lib/charming/version.rb +1 -1
  66. data/lib/charming/welcome.rb +1 -1
  67. data/lib/charming.rb +14 -6
  68. metadata +21 -70
  69. data/lib/charming/controller/component_dispatching.rb +0 -125
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
42
42
  def action_class_name
43
- ActiveSupport::Inflector.camelize(action)
43
+ Internal::Inflections.camelize(action)
44
44
  end
45
45
  end
46
46
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ # DeepFreeze returns a deep-frozen copy of a value: strings, arrays, hashes, and
6
+ # sets are duplicated (recursively) and frozen; numbers, symbols, and nil pass
7
+ # through as-is (already immutable); anything else (IO, models, components) passes
8
+ # through unfrozen — it has no sane freeze semantics across a thread boundary.
9
+ # The caller's originals are never frozen.
10
+ module DeepFreeze
11
+ # Returns a deep-frozen copy of *value* per the rules above.
12
+ def self.call(value)
13
+ case value
14
+ when Hash then value.to_h { |key, element| [call(key), call(element)] }.freeze
15
+ when Array then value.map { |element| call(element) }.freeze
16
+ when Set then value.map { |element| call(element) }.to_set.freeze
17
+ when String then value.frozen? ? value : value.dup.freeze
18
+ else value
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ # A String that answers predicates about its own value:
6
+ # `EnvInquirer.new("development").development?` → true. Replaces
7
+ # ActiveSupport::StringInquirer for `Charming.env`.
8
+ class EnvInquirer < String
9
+ private
10
+
11
+ def method_missing(name, *)
12
+ return self == name.to_s.delete_suffix("?") if name.end_with?("?")
13
+
14
+ super
15
+ end
16
+
17
+ def respond_to_missing?(name, include_private = false)
18
+ name.end_with?("?") || super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ # String inflection helpers covering exactly what Charming needs, with
6
+ # ActiveSupport-compatible semantics for the inputs Charming produces
7
+ # (snake_case identifiers and "A::B" constant paths). Replaces the former
8
+ # ActiveSupport::Inflector dependency. `pluralize` implements a deliberate
9
+ # subset of English rules — enough for conventional resource names — not
10
+ # ActiveSupport's full inflection table.
11
+ module Inflections
12
+ module_function
13
+
14
+ # "weather_report" → "WeatherReport"; "admin/users" → "Admin::Users".
15
+ def camelize(term)
16
+ string = term.to_s.sub(/\A[a-z\d]*/, &:capitalize)
17
+ string.gsub(%r{(?:_|(/))([a-z\d]*)}i) { "#{Regexp.last_match(1) && "::"}#{Regexp.last_match(2).capitalize}" }
18
+ end
19
+
20
+ # "HomeController" → "home_controller"; "MyApp::Home" → "my_app/home";
21
+ # acronym runs get a boundary before a capitalized word ("HTMLTidy" → "html_tidy").
22
+ def underscore(camel_cased_word)
23
+ word = camel_cased_word.to_s.gsub("::", "/")
24
+ word.gsub!(/([A-Z\d]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) { "#{Regexp.last_match(1) || Regexp.last_match(2)}_" }
25
+ word.tr!("-", "_")
26
+ word.downcase!
27
+ word
28
+ end
29
+
30
+ # "MyApp::HomeController" → "HomeController".
31
+ def demodulize(path)
32
+ path.to_s.split("::").last
33
+ end
34
+
35
+ # "MyApp::Application" → "MyApp"; "Application" → "".
36
+ def deconstantize(path)
37
+ path.to_s[0, path.to_s.rindex("::") || 0]
38
+ end
39
+
40
+ # "Charming::Router" → Charming::Router. Raises NameError on a miss.
41
+ def constantize(name)
42
+ Object.const_get(name)
43
+ end
44
+
45
+ # "user_name" → "User name"; "author_id" → "Author". Assumes snake_case input.
46
+ def humanize(lower_case_and_underscored_word)
47
+ result = lower_case_and_underscored_word.to_s.sub(/_id\z/, "").tr("_", " ")
48
+ result.sub(/\A\w/, &:upcase)
49
+ end
50
+
51
+ # "category" → "categories"; "person" → "people". A subset of English
52
+ # rules covering conventional resource names; exotic words may need the
53
+ # generated migration renamed by hand.
54
+ def pluralize(word)
55
+ result = word.to_s.dup
56
+ return result if UNCOUNTABLE.include?(result)
57
+
58
+ irregular = IRREGULAR_FORMS[result.split("_").last]
59
+ return result.sub(/[^_]+\z/, irregular) if irregular
60
+
61
+ PLURAL_RULES.each do |pattern, replacement|
62
+ return result.sub(pattern, replacement) if result.match?(pattern)
63
+ end
64
+ result
65
+ end
66
+
67
+ IRREGULAR_FORMS = {
68
+ "child" => "children",
69
+ "man" => "men",
70
+ "mouse" => "mice",
71
+ "person" => "people",
72
+ "sex" => "sexes",
73
+ "woman" => "women"
74
+ }.freeze
75
+
76
+ UNCOUNTABLE = %w[equipment information money rice series sheep species].freeze
77
+
78
+ # Ordered: the first matching rule wins. Mirrors the head of
79
+ # ActiveSupport's plural rule list for the inputs generators produce.
80
+ PLURAL_RULES = [
81
+ [/(quiz)\z/, '\1zes'],
82
+ [/(matr|vert|ind)(ix|ex)\z/, '\1ices'],
83
+ [/(x|ch|ss|sh)\z/, '\1es'],
84
+ [/([^aeiouy]|qu)y\z/, '\1ies'],
85
+ [/sis\z/, "ses"],
86
+ [/([ti])um\z/, '\1a'],
87
+ [/(buffal|tomat|potat|her)o\z/, '\1oes'],
88
+ [/s\z/, "s"],
89
+ [/\z/, "s"]
90
+ ].freeze
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Charming
6
+ module Internal
7
+ # SessionGuard wraps the application session hash and asserts every access happens
8
+ # on the controller's loop thread. Controller#session returns this wrapper in
9
+ # development and test; production gets the raw hash (a warning is logged instead
10
+ # of a raise).
11
+ class SessionGuard < SimpleDelegator
12
+ def initialize(session, controller)
13
+ super(session)
14
+ @controller = controller
15
+ end
16
+
17
+ def method_missing(name, *args, &block)
18
+ @controller.assert_loop_thread!(:session)
19
+ super
20
+ end
21
+
22
+ def respond_to_missing?(name, include_private = false)
23
+ __getobj__.respond_to?(name, include_private) || super
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # Cursor emits the ANSI escape sequences for cursor visibility, screen clearing,
7
+ # and positioning. Replaces tty-cursor (a stateless escape-string generator) with
8
+ # the four sequences TTYBackend actually uses, asserted byte-for-byte in
9
+ # spec/internal/terminal/cursor_spec.rb.
10
+ module Cursor
11
+ module_function
12
+
13
+ # Shows the terminal cursor (DECTCEM set).
14
+ def show = "\e[?25h"
15
+
16
+ # Hides the terminal cursor (DECTCEM reset).
17
+ def hide = "\e[?25l"
18
+
19
+ # Clears the whole screen (ED 2).
20
+ def clear_screen = "\e[2J"
21
+
22
+ # Moves the cursor to zero-based *column*/*row* (CUP is one-based row;column).
23
+ def move_to(column, row)
24
+ "\e[#{row + 1};#{column + 1}H"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # Size detects terminal dimensions without tty-screen: IO#winsize on the first
7
+ # reporting IO (a TTY), then ENV["COLUMNS"]/ENV["LINES"], then the 80x24 default.
8
+ module Size
9
+ DEFAULT_SIZE = [80, 24].freeze
10
+
11
+ module_function
12
+
13
+ # Returns [width, height] for the first of *ios* that reports a winsize,
14
+ # falling back to the environment and then the default.
15
+ def measure(*ios, env: ENV)
16
+ ios.each do |io|
17
+ size = winsize(io)
18
+ return size if size
19
+ end
20
+ env_size(env) || DEFAULT_SIZE
21
+ end
22
+
23
+ # IO#winsize reports [rows, columns]; size is [width, height]. Nil for IOs
24
+ # without a size (StringIO, pipes, closed streams).
25
+ def winsize(io)
26
+ return nil unless io.respond_to?(:winsize)
27
+
28
+ rows, columns = io.winsize
29
+ return nil if rows.to_i.zero? || columns.to_i.zero?
30
+
31
+ [columns, rows]
32
+ rescue SystemCallError, IOError
33
+ nil
34
+ end
35
+
36
+ # The COLUMNS/LINES environment size, or nil when either is unset or zero.
37
+ def env_size(env)
38
+ columns = env["COLUMNS"].to_i
39
+ lines = env["LINES"].to_i
40
+ return nil if columns.zero? || lines.zero?
41
+
42
+ [columns, lines]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-cursor"
3
+ require "io/console"
4
4
  require "tty-reader"
5
- require "tty-screen"
6
5
 
7
6
  module Charming
8
7
  module Internal
9
8
  module Terminal
10
9
  # TTYBackend is the production terminal backend. It reads key and mouse events from
11
10
  # a TTY::Reader, normalizes them via KeyNormalizer and MouseParser, and writes output
12
- # frames using TTY::Cursor and TTY::Screen. It also installs SIGWINCH and SIGINFO
13
- # handlers so the runtime can react to terminal resize and focus changes.
11
+ # frames using Terminal::Cursor sequences and IO#winsize for dimensions. It also
12
+ # installs SIGWINCH and SIGINFO handlers so the runtime can react to terminal resize
13
+ # and focus changes.
14
14
  class TTYBackend
15
15
  include Adapter
16
16
 
@@ -37,8 +37,9 @@ module Charming
37
37
 
38
38
  # *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
39
39
  # tests can inject IO objects. *reader* is a TTY::Reader instance (created from
40
- # *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
41
- def initialize(input: $stdin, output: $stdout, reader: nil, cursor: TTY::Cursor)
40
+ # *input*/*output* when nil). *cursor* is the Terminal::Cursor module used for
41
+ # cursor control sequences.
42
+ def initialize(input: $stdin, output: $stdout, reader: nil, cursor: Cursor)
42
43
  @input = input
43
44
  @output = output
44
45
  @reader = reader || TTY::Reader.new(input: input, output: output)
@@ -274,8 +275,9 @@ module Charming
274
275
  write_control(@cursor.move_to(column - 1, row - 1))
275
276
  end
276
277
 
277
- # Returns the current terminal dimensions as [width, height] via TTY::Screen.
278
- def size = [TTY::Screen.width, TTY::Screen.height]
278
+ # Returns the current terminal dimensions as [width, height]: IO#winsize on the
279
+ # output/input streams, then COLUMNS/LINES, then 80x24.
280
+ def size = Size.measure(@output, @input)
279
281
 
280
282
  private
281
283
 
@@ -9,8 +9,8 @@ module Charming
9
9
  #
10
10
  # Autocomplete.new(suggestions: ["ruby", "rails", "rspec"], value: "r")
11
11
  #
12
- # `handle_key` returns `[:submitted, value]` on Enter, `:cancelled` on Escape,
13
- # `:handled` for consumed keys, nil otherwise.
12
+ # `handle_key` returns `Result.submitted(value)` on Enter, `Result.cancelled` on
13
+ # Escape, `Result.handled` for consumed keys, nil otherwise.
14
14
  class Autocomplete < Component
15
15
  DEFAULT_MAX_SUGGESTIONS = 6
16
16
 
@@ -29,6 +29,14 @@ module Charming
29
29
  clamp_selection
30
30
  end
31
31
 
32
+ # Replaces the suggestion list and reclamps the selection against the filtered
33
+ # list. Lets a memoized autocomplete stay fresh: keep the component in a slot
34
+ # and assign `combo.suggestions = names` before each render.
35
+ def suggestions=(new_suggestions)
36
+ @suggestions = Array(new_suggestions).map(&:to_s)
37
+ clamp_selection
38
+ end
39
+
32
40
  # The typed text.
33
41
  def value
34
42
  @input.value
@@ -56,8 +64,8 @@ module Charming
56
64
  # edits the text (resetting the highlight).
57
65
  def handle_key(event)
58
66
  case Charming.key_of(event)
59
- when :escape then :cancelled
60
- when :enter then [:submitted, submission_value]
67
+ when :escape then Result.cancelled
68
+ when :enter then Result.submitted(submission_value)
61
69
  when :up then move_selection(-1)
62
70
  when :down then move_selection(+1)
63
71
  else
@@ -100,10 +108,10 @@ module Charming
100
108
 
101
109
  def move_selection(delta)
102
110
  count = filtered_suggestions.length
103
- return :handled if count.zero?
111
+ return Result.handled if count.zero?
104
112
 
105
113
  @selected_index = (selected_index + delta).clamp(0, count - 1)
106
- :handled
114
+ Result.handled
107
115
  end
108
116
 
109
117
  def clamp_selection
@@ -5,7 +5,8 @@ module Charming
5
5
  # CommandPalette renders a fuzzy-searchable command picker UI. It wraps a TextInput for search
6
6
  # input and a List for result display, dispatching key events between them. Users type to filter
7
7
  # the registered commands by label match, navigate with up/down/home/end keys (delegated to List),
8
- # confirm a selection with Enter (returns [:selected, command]), or cancel with Escape (returns :cancelled).
8
+ # confirm a selection with Enter (returns Result.selected(command)), or cancel with Escape (returns
9
+ # Result.cancelled).
9
10
  # State is serializable as a hash of value/cursor/selected_index for session persistence.
10
11
  class CommandPalette < Component
11
12
  Command = Data.define(:label, :value)
@@ -49,14 +50,14 @@ module Charming
49
50
  end
50
51
 
51
52
  # Handles key events by routing them to the appropriate sub-component: Escape kills the
52
- # palette returning :cancelled; up/down/home/end keys go to the List selection handler
53
+ # palette returning Result.cancelled; up/down/home/end keys go to the List selection handler
53
54
  # via handle_list_key; all other keys (including typed characters) are passed to the TextInput
54
55
  # which manages cursor position and input filtering. If a list key match fails, falls through
55
- # to the TextInput handler. Returns nil/nil if no handler consumed the event, or :cancelled when
56
- # Escape is pressed.
56
+ # to the TextInput handler. Returns nil if no handler consumed the event, or Result.cancelled
57
+ # when Escape is pressed.
57
58
  def handle_key(event)
58
59
  key = Charming.key_of(event)
59
- return :cancelled if key == :escape
60
+ return Result.cancelled if key == :escape
60
61
 
61
62
  return handle_list_key(event) if list_key?(key)
62
63
 
@@ -75,17 +76,18 @@ module Charming
75
76
  attr_reader :height, :list
76
77
 
77
78
  # Delegates key handling entirely to the internal List widget, which manages up/down/home/end selection.
78
- # Returns whatever the List's handle_key returns (typically nil or the symbol from the subclass).
79
+ # Returns whatever the List's handle_key returns (a Result or nil).
79
80
  def handle_list_key(event)
80
81
  list.handle_key(event)
81
82
  end
82
83
 
83
84
  # Passes the key event to the TextInput for cursor position and search text management.
84
- # If the input returns :handled, rebuilds the List so that filtering is re-evaluated against
85
- # the new input value. Returns nil/nil if no handler consumed the event.
85
+ # If the input returns Result.handled, rebuilds the List so that filtering is re-evaluated
86
+ # against the new input value. Returns the input's Result, or nil when it did not consume
87
+ # the event.
86
88
  def handle_input_key(event)
87
89
  result = input.handle_key(event)
88
- @list = build_list if result == :handled
90
+ @list = build_list if result&.handled?
89
91
  result
90
92
  end
91
93
 
@@ -3,7 +3,7 @@
3
3
  module Charming
4
4
  module Components
5
5
  # Filepicker is a directory browser built on List. Enter descends into the
6
- # highlighted directory or returns `[:selected, absolute_path]` for a file;
6
+ # highlighted directory or returns `Result.selected(absolute_path)` for a file;
7
7
  # Backspace (or the "../" entry) goes up, never above the configured root.
8
8
  # Dotfiles are hidden until `toggle_hidden`.
9
9
  class Filepicker < Component
@@ -63,14 +63,14 @@ module Charming
63
63
  return ascend if entry == PARENT_ENTRY
64
64
  return descend(entry.delete_suffix("/")) if entry.end_with?("/")
65
65
 
66
- [:selected, File.join(current_dir, entry)]
66
+ Result.selected(File.join(current_dir, entry))
67
67
  end
68
68
 
69
69
  # Enters *name* under the current directory.
70
70
  def descend(name)
71
71
  @current_dir = File.join(current_dir, name)
72
72
  rebuild_list
73
- :handled
73
+ Result.handled
74
74
  end
75
75
 
76
76
  # Moves to the parent directory, unless already at the root.
@@ -79,7 +79,7 @@ module Charming
79
79
 
80
80
  @current_dir = File.dirname(current_dir)
81
81
  rebuild_list
82
- :handled
82
+ Result.handled
83
83
  end
84
84
 
85
85
  # Builds a fresh List over the current directory's entries.
@@ -16,6 +16,7 @@ module Charming
16
16
 
17
17
  # Handles the standard confirm keys: space toggles, y/right sets to true, n/left
18
18
  # sets to false, and a space character (when the event exposes `char`) also toggles.
19
+ # Returns Result.handled when consumed, nil otherwise.
19
20
  def handle_key(event)
20
21
  case Charming.key_of(event)
21
22
  when :space
@@ -29,7 +30,7 @@ module Charming
29
30
 
30
31
  toggle
31
32
  end
32
- :handled
33
+ Result.handled
33
34
  end
34
35
 
35
36
  # Returns ["must be accepted"] when required and the value is not true, otherwise
@@ -116,7 +116,7 @@ module Charming
116
116
 
117
117
  # Converts a snake_case symbol/string to a humanized "Capitalized" string.
118
118
  def humanize(value)
119
- ActiveSupport::Inflector.humanize(value)
119
+ Internal::Inflections.humanize(value)
120
120
  end
121
121
  end
122
122
  end
@@ -25,7 +25,7 @@ module Charming
25
25
  end
26
26
 
27
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.
28
+ # back into the form state. Returns Result.handled when the event was consumed.
29
29
  def handle_key(event)
30
30
  forward_to_input(:handle_key, event)
31
31
  end
@@ -41,11 +41,11 @@ module Charming
41
41
  # the event, persists the resulting value and cursor into the form state.
42
42
  def forward_to_input(message, event)
43
43
  text_input = input
44
- return nil unless text_input.public_send(message, event) == :handled
44
+ return nil unless text_input.public_send(message, event)&.handled?
45
45
 
46
46
  state[:values][name] = text_input.value
47
47
  field_state[:cursor] = text_input.cursor
48
- :handled
48
+ Result.handled
49
49
  end
50
50
 
51
51
  # The default value for a freshly-bound field is the *value* passed at construction.
@@ -27,16 +27,15 @@ module Charming
27
27
  end
28
28
 
29
29
  # Forwards key events to the underlying MultiSelectList, syncing the checked
30
- # set and highlight cursor back into the field state. Returns :handled when
31
- # consumed; Enter (the list's submit) is left unconsumed for the Form.
30
+ # set and highlight cursor back into the field state. Returns Result.handled when
31
+ # consumed; Enter (the list's Result.submitted) is left unconsumed for the Form.
32
32
  def handle_key(event)
33
33
  widget = list
34
34
  result = widget.handle_key(event)
35
- return nil if result.is_a?(Array)
36
- return nil unless result == :handled
35
+ return nil unless result&.handled?
37
36
 
38
37
  save_selection(widget)
39
- :handled
38
+ Result.handled
40
39
  end
41
40
 
42
41
  private
@@ -24,14 +24,14 @@ module Charming
24
24
  end
25
25
 
26
26
  # Forwards key events to the underlying List, syncing the chosen option index back
27
- # into the field state. Returns :handled when consumed.
27
+ # into the field state. Returns Result.handled when consumed.
28
28
  def handle_key(event)
29
29
  selection = list
30
30
  result = selection.handle_key(event)
31
- return nil unless result == :handled
31
+ return nil unless result&.handled?
32
32
 
33
33
  save_selection(selection.selected_index)
34
- :handled
34
+ Result.handled
35
35
  end
36
36
 
37
37
  private
@@ -27,7 +27,7 @@ module Charming
27
27
  end
28
28
 
29
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.
30
+ # and preferred column back into the form state. Returns Result.handled when consumed.
31
31
  def handle_key(event)
32
32
  forward_to_text_area(:handle_key, event)
33
33
  end
@@ -51,13 +51,13 @@ module Charming
51
51
  # event, persists the value, cursor, offset, and preferred column into form state.
52
52
  def forward_to_text_area(message, event)
53
53
  area = text_area
54
- return nil unless area.public_send(message, event) == :handled
54
+ return nil unless area.public_send(message, event)&.handled?
55
55
 
56
56
  state[:values][name] = area.value
57
57
  field_state[:cursor] = area.cursor
58
58
  field_state[:offset] = area.offset
59
59
  field_state[:preferred_column] = area.preferred_column
60
- :handled
60
+ Result.handled
61
61
  end
62
62
 
63
63
  # The default value for a freshly-bound field is the *value* passed at construction.
@@ -28,7 +28,7 @@ module Charming
28
28
  # or submits, and unhandled keys are passed to the focused field.
29
29
  def handle_key(event)
30
30
  key = Charming.key_of(event)
31
- return :cancelled if key == :escape
31
+ return Result.cancelled if key == :escape
32
32
  return submit if submit_shortcut?(event)
33
33
  return move_focus(tab_direction(event)) if key == :tab
34
34
 
@@ -103,14 +103,14 @@ module Charming
103
103
  move_focus(+1)
104
104
  end
105
105
 
106
- # Validates all fields, focuses the first invalid one, and returns [:submitted, values]
107
- # when there are no errors.
106
+ # Validates all fields, focuses the first invalid one, and returns
107
+ # Result.submitted(values) when there are no errors.
108
108
  def submit
109
109
  state[:errors] = validation_errors
110
110
  focus_first_error unless state[:errors].empty?
111
- return :handled unless state[:errors].empty?
111
+ return Result.handled unless state[:errors].empty?
112
112
 
113
- [:submitted, values.dup]
113
+ Result.submitted(values.dup)
114
114
  end
115
115
 
116
116
  # Runs each field's validator and collects per-field error messages.
@@ -139,7 +139,7 @@ module Charming
139
139
 
140
140
  current = indices.index(state[:focus_index]) || 0
141
141
  state[:focus_index] = indices[(current + direction) % indices.length]
142
- :handled
142
+ Result.handled
143
143
  end
144
144
 
145
145
  # True when the current focus index is the last focusable field.
@@ -11,7 +11,7 @@ module Charming
11
11
  #
12
12
  # HelpOverlay.new(bindings: {"q" => "Quit", "ctrl+p" => "Command palette"})
13
13
  #
14
- # Any key dismisses it (`handle_key` returns :cancelled).
14
+ # Any key dismisses it (`handle_key` returns Result.cancelled).
15
15
  class HelpOverlay < Component
16
16
  DEFAULT_TITLE = "Keyboard Shortcuts"
17
17
  DEFAULT_WIDTH = 44
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # Any key dismisses the overlay.
42
42
  def handle_key(_event)
43
- :cancelled
43
+ Result.cancelled
44
44
  end
45
45
 
46
46
  # Renders the bindings table inside a titled modal.
@@ -6,8 +6,8 @@ module Charming
6
6
  # to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
7
7
  # a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
8
8
  # +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
9
- # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns :handled if an
10
- # action was found. Returns nil (via :handled being truthy or not) when no matching key exists.
9
+ # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns Result.handled
10
+ # if an action was found. Returns nil when no matching key exists.
11
11
  module KeyboardHandler
12
12
  VIM_KEYMAP = {
13
13
  up: :k,
@@ -22,7 +22,7 @@ module Charming
22
22
  return unless action
23
23
 
24
24
  send(action)
25
- :handled
25
+ Result.handled
26
26
  end
27
27
 
28
28
  private