charming 0.2.3 → 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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/lib/charming/application.rb +98 -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 +79 -17
  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/timers.rb +25 -0
  15. data/lib/charming/controller.rb +253 -60
  16. data/lib/charming/cross_thread_access.rb +9 -0
  17. data/lib/charming/double_render_error.rb +8 -0
  18. data/lib/charming/generators/layout_generator.rb +4 -1
  19. data/lib/charming/generators/migration_generator.rb +1 -1
  20. data/lib/charming/generators/model_generator.rb +2 -2
  21. data/lib/charming/generators/name.rb +1 -1
  22. data/lib/charming/generators/screen_generator.rb +2 -2
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/deep_freeze.rb +23 -0
  25. data/lib/charming/internal/env_inquirer.rb +22 -0
  26. data/lib/charming/internal/event_loop.rb +25 -2
  27. data/lib/charming/internal/inflections.rb +93 -0
  28. data/lib/charming/internal/session_guard.rb +27 -0
  29. data/lib/charming/internal/terminal/cursor.rb +29 -0
  30. data/lib/charming/internal/terminal/size.rb +47 -0
  31. data/lib/charming/internal/terminal/tty_backend.rb +10 -8
  32. data/lib/charming/internal/timer_control.rb +48 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +14 -6
  34. data/lib/charming/presentation/components/command_palette.rb +11 -9
  35. data/lib/charming/presentation/components/filepicker.rb +4 -4
  36. data/lib/charming/presentation/components/form/confirm.rb +2 -1
  37. data/lib/charming/presentation/components/form/field.rb +1 -1
  38. data/lib/charming/presentation/components/form/input.rb +3 -3
  39. data/lib/charming/presentation/components/form/multiselect.rb +4 -5
  40. data/lib/charming/presentation/components/form/select.rb +3 -3
  41. data/lib/charming/presentation/components/form/textarea.rb +3 -3
  42. data/lib/charming/presentation/components/form.rb +6 -6
  43. data/lib/charming/presentation/components/help_overlay.rb +2 -2
  44. data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
  45. data/lib/charming/presentation/components/list.rb +14 -5
  46. data/lib/charming/presentation/components/modal.rb +3 -2
  47. data/lib/charming/presentation/components/multi_select_list.rb +14 -7
  48. data/lib/charming/presentation/components/result.rb +61 -0
  49. data/lib/charming/presentation/components/tab_bar.rb +14 -6
  50. data/lib/charming/presentation/components/table.rb +64 -32
  51. data/lib/charming/presentation/components/text_area.rb +7 -7
  52. data/lib/charming/presentation/components/text_input.rb +7 -7
  53. data/lib/charming/presentation/components/tree.rb +15 -6
  54. data/lib/charming/presentation/components/viewport.rb +3 -3
  55. data/lib/charming/presentation/layout/pane.rb +6 -2
  56. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  57. data/lib/charming/presentation/view.rb +35 -29
  58. data/lib/charming/projectile.rb +64 -0
  59. data/lib/charming/render_artifacts.rb +24 -0
  60. data/lib/charming/response.rb +19 -8
  61. data/lib/charming/router.rb +50 -68
  62. data/lib/charming/runtime.rb +54 -28
  63. data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
  64. data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
  65. data/lib/charming/spring.rb +126 -0
  66. data/lib/charming/tasks/context.rb +35 -0
  67. data/lib/charming/test_helper.rb +42 -22
  68. data/lib/charming/unhandled_component_event.rb +9 -0
  69. data/lib/charming/unknown_slot.rb +9 -0
  70. data/lib/charming/version.rb +1 -1
  71. data/lib/charming/welcome.rb +1 -1
  72. data/lib/charming.rb +20 -6
  73. metadata +25 -70
  74. data/lib/charming/controller/component_dispatching.rb +0 -125
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when a controller sets the response twice in one dispatch — render, navigate,
5
+ # and quit each assign the response, and a second assignment would silently discard the
6
+ # first. The message names the action, the response already set, and the one attempted.
7
+ class DoubleRenderError < Error; end
8
+ end
@@ -99,12 +99,15 @@ module Charming
99
99
 
100
100
  def controller_chrome
101
101
  class_body_block(<<~RUBY)
102
+ include Charming::Shell::Sidebar
103
+ include Charming::Shell::Palette
104
+
102
105
  focus_ring :sidebar, :content
103
106
 
104
107
  key "ctrl+p", :open_command_palette, scope: :global
105
108
 
106
109
  command "Home" do
107
- navigate_to "/"
110
+ navigate :root
108
111
  end
109
112
 
110
113
  command "Theme", :open_theme_palette
@@ -60,7 +60,7 @@ module Charming
60
60
 
61
61
  # The CamelCase migration class name derived from the snake_case migration name.
62
62
  def migration_class_name
63
- ActiveSupport::Inflector.camelize(name.snake_name)
63
+ Internal::Inflections.camelize(name.snake_name)
64
64
  end
65
65
 
66
66
  # Builds the `change` method body based on the migration name convention.
@@ -103,12 +103,12 @@ module Charming
103
103
 
104
104
  # The pluralized table name (e.g., "user" → "users", "category" → "categories").
105
105
  def table_name
106
- ActiveSupport::Inflector.pluralize(name.snake_name)
106
+ Internal::Inflections.pluralize(name.snake_name)
107
107
  end
108
108
 
109
109
  # The CamelCase migration class name (e.g., "users" → "Users").
110
110
  def table_class_name
111
- ActiveSupport::Inflector.camelize(table_name)
111
+ Internal::Inflections.camelize(table_name)
112
112
  end
113
113
 
114
114
  # A migration timestamp in ActiveRecord's filename format, bumped past any
@@ -21,7 +21,7 @@ module Charming
21
21
 
22
22
  # The CamelCase class name (e.g., "user" → "User").
23
23
  def class_name
24
- ActiveSupport::Inflector.camelize(snake_name)
24
+ Internal::Inflections.camelize(snake_name)
25
25
  end
26
26
 
27
27
  # The controller class name (e.g., "user" → "UserController").
@@ -141,14 +141,14 @@ module Charming
141
141
 
142
142
  # Inserts a `screen` route into `config/routes.rb`, idempotently.
143
143
  def insert_route
144
- route = %( screen "/#{name.snake_name}", to: "#{name.snake_name}#show", title: "#{name.class_name}")
144
+ route = %( screen :#{name.snake_name}, "#{name.snake_name}#show", title: "#{name.class_name}")
145
145
  insert_before_end(route_path, route, "route", "end")
146
146
  end
147
147
 
148
148
  # Inserts a `command` block into `ApplicationController`, idempotently.
149
149
  def insert_command
150
150
  command = %( command "#{name.class_name}" do
151
- navigate_to "/#{name.snake_name}"
151
+ navigate :#{name.snake_name}
152
152
  end)
153
153
  insert_before_end(application_controller_path, command, "command", " end")
154
154
  end
@@ -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
@@ -51,6 +51,24 @@ module Charming
51
51
  @timers = build_timers(timer_bindings)
52
52
  end
53
53
 
54
+ # Schedules *binding* one interval from now. Idempotent: starting a timer
55
+ # that is already running keeps its current deadline (no phase reset).
56
+ def start_timer(binding)
57
+ return if timer_running?(binding.name)
58
+
59
+ @timers << {binding: binding, next_at: clock_now + binding.interval}
60
+ end
61
+
62
+ # Removes the named timer from the schedule. Idempotent for unknown names.
63
+ def stop_timer(name)
64
+ @timers.reject! { |timer| timer.fetch(:binding).name == name }
65
+ end
66
+
67
+ # True while the named timer is scheduled.
68
+ def timer_running?(name)
69
+ @timers.any? { |timer| timer.fetch(:binding).name == name }
70
+ end
71
+
54
72
  private
55
73
 
56
74
  # The next due event in priority order: task results, then timers, then input.
@@ -74,13 +92,18 @@ module Charming
74
92
  end
75
93
 
76
94
  # Returns a TimerEvent for the first due timer and advances its next fire
77
- # time. Returns nil if no timers are ready or registered.
95
+ # time. Returns nil if no timers are ready or registered. Rescheduling is
96
+ # accumulator-based so fire times stay on the interval grid (no drift);
97
+ # after a stall the deadline snaps forward, skipping missed ticks instead
98
+ # of firing a catch-up burst.
78
99
  def next_timer_event
79
100
  timer = due_timer
80
101
  return unless timer
81
102
 
82
103
  now = clock_now
83
- timer[:next_at] = now + timer.fetch(:binding).interval
104
+ interval = timer.fetch(:binding).interval
105
+ timer[:next_at] += interval
106
+ timer[:next_at] = now + interval if timer[:next_at] <= now
84
107
  Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
85
108
  end
86
109
 
@@ -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
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ # TimerControl is the runtime-injected surface that lets ephemeral controllers
6
+ # start and stop the EventLoop's scheduled timers by name (the same pattern as
7
+ # Application#task_executor). *bindings* is a callable returning the current
8
+ # route's timer bindings, so navigation needs no re-wiring.
9
+ class TimerControl
10
+ def initialize(event_loop:, bindings:)
11
+ @event_loop = event_loop
12
+ @bindings = bindings
13
+ end
14
+
15
+ # Schedules the named declared timer on the event loop.
16
+ def start(name)
17
+ binding = @bindings.call[name.to_sym]
18
+ raise ArgumentError, "unknown timer #{name.to_sym.inspect}" unless binding
19
+
20
+ @event_loop.start_timer(binding)
21
+ end
22
+
23
+ # Unschedules the named timer.
24
+ def stop(name)
25
+ @event_loop.stop_timer(name.to_sym)
26
+ end
27
+
28
+ # True while the named timer is scheduled.
29
+ def running?(name)
30
+ @event_loop.timer_running?(name.to_sym)
31
+ end
32
+
33
+ # Null is the default control for applications running outside a Runtime
34
+ # (unit specs, console): starts and stops are no-ops.
35
+ class Null
36
+ def start(name)
37
+ end
38
+
39
+ def stop(name)
40
+ end
41
+
42
+ def running?(name)
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -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.