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
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- class Controller
5
- # Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
6
- # routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
7
- #
8
- # Sidebar/content focus is driven entirely by the controller's Focus object. Controllers
9
- # that want Tab-driven sidebar navigation declare `focus_ring :sidebar, :content` (generated
10
- # apps do); without those slots in the ring, `focus_sidebar`/`focus_content` are no-ops.
11
- module SidebarNavigation
4
+ # Shell::Sidebar is the opt-in app-shell sidebar: route listing, sidebar/content focus
5
+ # split, j/k/enter navigation, and mouse clicks on route rows. Generated apps include it
6
+ # in ApplicationController when generated with the sidebar layout
7
+ # (`include Charming::Shell::Sidebar`). Controllers that want Tab-driven sidebar
8
+ # navigation declare `focus_ring :sidebar, :content`.
9
+ module Shell
10
+ module Sidebar
12
11
  # Moves focus to the sidebar slot and remembers the highlighted route.
13
12
  def focus_sidebar
14
13
  focus.focus(:sidebar)
@@ -37,7 +36,8 @@ module Charming
37
36
  end
38
37
 
39
38
  # Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
40
- # active route when the session index is unset.
39
+ # active route when the session index is unset. Stored in the session: the sidebar cursor
40
+ # is app-global — it should keep its position as the user moves between screens.
41
41
  def sidebar_index
42
42
  session[:sidebar_index] || current_route_index
43
43
  end
@@ -52,7 +52,7 @@ module Charming
52
52
  def current_route?(candidate)
53
53
  return candidate.controller_class == self.class && candidate.action == :show unless route
54
54
 
55
- candidate.path == route.path &&
55
+ candidate.name == route.name &&
56
56
  candidate.controller_class == route.controller_class &&
57
57
  candidate.action == route.action
58
58
  end
@@ -139,7 +139,7 @@ module Charming
139
139
  route = sidebar_routes[sidebar_index]
140
140
  slot = content_slot
141
141
  focus.focus(slot) if slot
142
- route ? navigate_to(route.path) : render_default_action
142
+ route ? navigate(route.name) : render_default_action
143
143
  end
144
144
  end
145
145
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Tasks
5
+ # Context is the object a task block receives as its argument. It carries the
6
+ # task's inputs (the `with:` hash given to `run_task`, deep-frozen at submit time)
7
+ # and the progress reporter:
8
+ #
9
+ # run_task(:search, with: {query: query.value}) do |ctx|
10
+ # results = Api.search(ctx[:query])
11
+ # ctx.report(1, of: 1)
12
+ # results
13
+ # end
14
+ #
15
+ # Task blocks receive data in via `with:` and return data out as the block value.
16
+ # They touch nothing else: the `on_task` handler on the loop thread is the only
17
+ # place task results become state.
18
+ class Context
19
+ def initialize(data, progress)
20
+ @data = data
21
+ @progress = progress
22
+ end
23
+
24
+ # Returns the input value for *key* from the `with:` hash.
25
+ def [](key)
26
+ @data[key]
27
+ end
28
+
29
+ # Reports progress to the matching `on_task_progress` handler on the loop thread.
30
+ def report(current, of: nil, message: nil)
31
+ @progress.report(current, of: of, message: message)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -18,27 +18,28 @@ module Charming
18
18
  # end
19
19
  #
20
20
  # it "quits on q" do
21
- # expect(press(ctrl_class: HomeController, key: "q")).to be_quit
21
+ # expect(press(ctrl, "q")).to be_quit
22
22
  # end
23
23
  # end
24
24
  #
25
25
  # Helpers:
26
- # - `build_controller(klass, app:, screen:, route:)` — controller instance wired to an app
26
+ # - `build_controller(klass, app:, screen:, route:, params:)` — controller instance wired to an app
27
27
  # - `key_event("ctrl+p")` — build a KeyEvent from a human-readable string
28
- # - `press(controller_or_class, "down")` — dispatch a key press, returns the Response
29
- # - `press_sequence(klass, ["down", "down", "enter"], app:)` — dispatch several presses
28
+ # - `press(controller, "down")` — dispatch a key press at the instance, returns the Response
29
+ # - `press_sequence(controller, ["down", "down", "enter"])` — dispatch several presses at one instance
30
30
  #
31
31
  # RSpec matchers (when RSpec is loaded):
32
32
  # - `expect(response).to render_text("...")` / `render_match(/.../)`
33
33
  # - `expect(response).to be_quit` / `be_navigate` (predicate matchers on Response)
34
- # - `expect(response).to navigate_to("/path")`
34
+ # - `expect(response).to navigate_to(:projects)`
35
35
  module TestHelper
36
- # Builds a controller instance with sensible test defaults: a fresh Application,
37
- # an 80x24 screen, and no event.
38
- def build_controller(controller_class, app: nil, screen: nil, route: nil, event: nil)
36
+ # Builds a controller instance with sensible test defaults: a fresh Application and
37
+ # an 80x24 screen. Runs the screen_entered lifecycle hook, mirroring the Runtime's
38
+ # persistent-controller lifecycle.
39
+ def build_controller(controller_class, app: nil, screen: nil, route: nil, params: {})
39
40
  app ||= Charming::Application.new
40
41
  screen ||= Charming::Screen.new(width: 80, height: 24)
41
- controller_class.new(application: app, event: event, screen: screen, route: route)
42
+ controller_class.new(application: app, params: params, screen: screen, route: route).tap(&:screen_entered)
42
43
  end
43
44
 
44
45
  # Builds a KeyEvent from a human-readable string like "q", "down", "ctrl+p",
@@ -57,18 +58,16 @@ module Charming
57
58
  )
58
59
  end
59
60
 
60
- # Dispatches a single key press against *controller_class* and returns the Response.
61
- # Pass `app:` to share session state across presses.
62
- def press(controller_class, key, app:, screen: nil, route: nil)
63
- controller = build_controller(controller_class, app: app, screen: screen, route: route, event: key_event(key))
64
- controller.dispatch_key
61
+ # Dispatches a single key press at *controller* (an instance from build_controller)
62
+ # and returns the Response. Accepts a human-readable string or a ready KeyEvent.
63
+ def press(controller, key)
64
+ controller.dispatch_key(key.is_a?(String) ? key_event(key) : key)
65
65
  end
66
66
 
67
- # Dispatches each key in *keys* in order against fresh controller instances sharing
68
- # *app*'s session (mirroring the runtime's controller-per-event model). Returns the
69
- # last Response.
70
- def press_sequence(controller_class, keys, app:, screen: nil, route: nil)
71
- keys.map { |key| press(controller_class, key, app: app, screen: screen, route: route) }.last
67
+ # Dispatches each key in *keys* in order at the same controller instance (mirroring
68
+ # the runtime's persistent-controller model). Returns the last Response.
69
+ def press_sequence(controller, keys)
70
+ keys.map { |key| press(controller, key) }.last
72
71
  end
73
72
 
74
73
  # Builds a MemoryBackend pre-seeded with KeyEvents parsed from *keys*, ready to be
@@ -77,6 +76,24 @@ module Charming
77
76
  events = keys.map { |key| key.is_a?(String) ? key_event(key) : key }
78
77
  Charming::Internal::Terminal::MemoryBackend.new(events: events, width: width, height: height)
79
78
  end
79
+
80
+ # Renders a view class standalone — no controller needed — and returns a hash with
81
+ # the painted :frame plus the layout's registration data (:focus_slots,
82
+ # :mouse_targets). Rendering is pure: registrations only commit when a response
83
+ # paints, so unit-testing a view means asserting on these artifacts.
84
+ #
85
+ # result = render_view(Home::ShowView, screen: Charming::Screen.new(width: 40, height: 10))
86
+ # expect(result[:focus_slots]).to eq([:entries])
87
+ def render_view(view_class, **assigns)
88
+ view = view_class.new(**assigns)
89
+ frame = view.render
90
+ artifacts = view.render_artifacts
91
+ {
92
+ frame: frame,
93
+ focus_slots: artifacts.last&.focus_slots || [],
94
+ mouse_targets: artifacts.flat_map(&:mouse_targets)
95
+ }
96
+ end
80
97
  end
81
98
  end
82
99
 
@@ -108,13 +125,16 @@ if defined?(RSpec)
108
125
  end
109
126
  end
110
127
 
111
- RSpec::Matchers.define :navigate_to do |expected_path|
128
+ RSpec::Matchers.define :navigate_to do |expected_name, **expected_params|
112
129
  match do |response|
113
- response.respond_to?(:navigate?) && response.navigate? && response.path == expected_path
130
+ next false unless response.respond_to?(:navigate?) && response.navigate?
131
+ next false unless response.name == expected_name.to_sym
132
+
133
+ expected_params.empty? || response.params == expected_params
114
134
  end
115
135
 
116
136
  failure_message do |response|
117
- "expected a navigation response to #{expected_path.inspect}, got: #{response.inspect}"
137
+ "expected a navigation response to #{expected_name.inspect}, got: #{response.inspect}"
118
138
  end
119
139
  end
120
140
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when a component emits a result (`[:submitted, value]`, `[:selected, value]`,
5
+ # or `:cancelled`) and the controller has neither an `on_submit`/`on_select`/`on_cancel`
6
+ # registration nor a legacy `<slot>_<event>` hook for it. Raised in development and test;
7
+ # production logs a warning and falls back to the default render instead.
8
+ class UnhandledComponentEvent < Error; end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Raised when a rendered layout names a focusable pane, or an `on_*` registration
5
+ # references a slot, that nothing declares — not a `slot` declaration, not a
6
+ # `focus_ring` entry, not a same-named method. Raised in development and test;
7
+ # production logs a warning. The message names the fix and lists declared slots.
8
+ class UnknownSlot < Error; end
9
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -7,7 +7,7 @@ module Charming
7
7
  module Welcome
8
8
  # The fallback route the Runtime uses when the application has no routes.
9
9
  def self.route
10
- Router::Route.new(path: "/", controller_class: Controller, action: :show, title: "Welcome", params: {})
10
+ Router::Route.new(name: :root, controller_class: Controller, action: :show, title: "Welcome", params: {})
11
11
  end
12
12
  end
13
13
  end
data/lib/charming.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/inflector"
4
- require "active_support/string_inquirer"
5
3
  require "logger"
6
4
  require "zeitwerk"
7
5
 
@@ -26,15 +24,15 @@ module Charming
26
24
  # Base error class for all Charming-specific exceptions (used by templates, generators, runtime, etc.).
27
25
  class Error < StandardError; end
28
26
 
29
- # The current environment, read from CHARMING_ENV (default "development"). Returns a
30
- # StringInquirer so callers can write `Charming.env.test?` / `Charming.env.production?`.
27
+ # The current environment, read from CHARMING_ENV (default "development"). Returns an
28
+ # EnvInquirer so callers can write `Charming.env.test?` / `Charming.env.production?`.
31
29
  def self.env
32
- @env ||= ActiveSupport::StringInquirer.new(ENV["CHARMING_ENV"] || "development")
30
+ @env ||= Internal::EnvInquirer.new(ENV["CHARMING_ENV"] || "development")
33
31
  end
34
32
 
35
33
  # Overrides the environment (used by tests and the CLI).
36
34
  def self.env=(value)
37
- @env = value.nil? ? nil : ActiveSupport::StringInquirer.new(value.to_s)
35
+ @env = value.nil? ? nil : Internal::EnvInquirer.new(value.to_s)
38
36
  end
39
37
 
40
38
  # Entry point for running a Charming application. Instantiates a Runtime for *application* and starts
@@ -84,6 +82,16 @@ module Charming
84
82
 
85
83
  :"#{(ordered_modifiers + [key]).join("+")}"
86
84
  end
85
+
86
+ # Emits a deprecation warning once per process per *category*, prefixed
87
+ # "[charming deprecation]". Warnings go to stderr.
88
+ def self.deprecate(message, category:)
89
+ @deprecation_emitted ||= {}
90
+ return if @deprecation_emitted[category]
91
+
92
+ @deprecation_emitted[category] = true
93
+ warn("[charming deprecation] #{message}")
94
+ end
87
95
  end
88
96
 
89
97
  Charming::Templates.register ".tui.erb", Charming::Templates::ErbHandler
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: charming
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - pando
@@ -29,26 +29,6 @@ dependencies:
29
29
  - - ">="
30
30
  - !ruby/object:Gem::Version
31
31
  version: 8.1.2
32
- - !ruby/object:Gem::Dependency
33
- name: activesupport
34
- requirement: !ruby/object:Gem::Requirement
35
- requirements:
36
- - - "~>"
37
- - !ruby/object:Gem::Version
38
- version: '8.1'
39
- - - ">="
40
- - !ruby/object:Gem::Version
41
- version: 8.1.2
42
- type: :runtime
43
- prerelease: false
44
- version_requirements: !ruby/object:Gem::Requirement
45
- requirements:
46
- - - "~>"
47
- - !ruby/object:Gem::Version
48
- version: '8.1'
49
- - - ">="
50
- - !ruby/object:Gem::Version
51
- version: 8.1.2
52
32
  - !ruby/object:Gem::Dependency
53
33
  name: commonmarker
54
34
  requirement: !ruby/object:Gem::Requirement
@@ -91,48 +71,6 @@ dependencies:
91
71
  - - "~>"
92
72
  - !ruby/object:Gem::Version
93
73
  version: '5.0'
94
- - !ruby/object:Gem::Dependency
95
- name: tty-cursor
96
- requirement: !ruby/object:Gem::Requirement
97
- requirements:
98
- - - "~>"
99
- - !ruby/object:Gem::Version
100
- version: '0.7'
101
- type: :runtime
102
- prerelease: false
103
- version_requirements: !ruby/object:Gem::Requirement
104
- requirements:
105
- - - "~>"
106
- - !ruby/object:Gem::Version
107
- version: '0.7'
108
- - !ruby/object:Gem::Dependency
109
- name: zeitwerk
110
- requirement: !ruby/object:Gem::Requirement
111
- requirements:
112
- - - "~>"
113
- - !ruby/object:Gem::Version
114
- version: '2.6'
115
- type: :runtime
116
- prerelease: false
117
- version_requirements: !ruby/object:Gem::Requirement
118
- requirements:
119
- - - "~>"
120
- - !ruby/object:Gem::Version
121
- version: '2.6'
122
- - !ruby/object:Gem::Dependency
123
- name: tty-table
124
- requirement: !ruby/object:Gem::Requirement
125
- requirements:
126
- - - "~>"
127
- - !ruby/object:Gem::Version
128
- version: '0.12'
129
- type: :runtime
130
- prerelease: false
131
- version_requirements: !ruby/object:Gem::Requirement
132
- requirements:
133
- - - "~>"
134
- - !ruby/object:Gem::Version
135
- version: '0.12'
136
74
  - !ruby/object:Gem::Dependency
137
75
  name: tty-reader
138
76
  requirement: !ruby/object:Gem::Requirement
@@ -148,21 +86,21 @@ dependencies:
148
86
  - !ruby/object:Gem::Version
149
87
  version: '0.9'
150
88
  - !ruby/object:Gem::Dependency
151
- name: tty-screen
89
+ name: unicode-display_width
152
90
  requirement: !ruby/object:Gem::Requirement
153
91
  requirements:
154
92
  - - "~>"
155
93
  - !ruby/object:Gem::Version
156
- version: '0.8'
94
+ version: '2.6'
157
95
  type: :runtime
158
96
  prerelease: false
159
97
  version_requirements: !ruby/object:Gem::Requirement
160
98
  requirements:
161
99
  - - "~>"
162
100
  - !ruby/object:Gem::Version
163
- version: '0.8'
101
+ version: '2.6'
164
102
  - !ruby/object:Gem::Dependency
165
- name: unicode-display_width
103
+ name: zeitwerk
166
104
  requirement: !ruby/object:Gem::Requirement
167
105
  requirements:
168
106
  - - "~>"
@@ -196,18 +134,18 @@ files:
196
134
  - lib/charming/controller.rb
197
135
  - lib/charming/controller/action_hooks.rb
198
136
  - lib/charming/controller/class_methods.rb
199
- - lib/charming/controller/command_palette.rb
200
- - lib/charming/controller/component_dispatching.rb
137
+ - lib/charming/controller/component_dispatch.rb
201
138
  - lib/charming/controller/dispatching.rb
202
139
  - lib/charming/controller/focus.rb
203
140
  - lib/charming/controller/focus_management.rb
204
141
  - lib/charming/controller/key_dispatch.rb
205
142
  - lib/charming/controller/rendering.rb
206
143
  - lib/charming/controller/session_state.rb
207
- - lib/charming/controller/sidebar_navigation.rb
208
144
  - lib/charming/controller/terminal.rb
209
145
  - lib/charming/controller/timers.rb
146
+ - lib/charming/cross_thread_access.rb
210
147
  - lib/charming/database/commands.rb
148
+ - lib/charming/double_render_error.rb
211
149
  - lib/charming/escape.rb
212
150
  - lib/charming/events/focus_event.rb
213
151
  - lib/charming/events/key_event.rb
@@ -268,14 +206,20 @@ files:
268
206
  - lib/charming/image/source.rb
269
207
  - lib/charming/image/terminal.rb
270
208
  - lib/charming/image/transmit.rb
209
+ - lib/charming/internal/deep_freeze.rb
210
+ - lib/charming/internal/env_inquirer.rb
271
211
  - lib/charming/internal/event_loop.rb
212
+ - lib/charming/internal/inflections.rb
272
213
  - lib/charming/internal/renderer/differential.rb
273
214
  - lib/charming/internal/renderer/full_repaint.rb
215
+ - lib/charming/internal/session_guard.rb
274
216
  - lib/charming/internal/terminal/adapter.rb
217
+ - lib/charming/internal/terminal/cursor.rb
275
218
  - lib/charming/internal/terminal/key_normalizer.rb
276
219
  - lib/charming/internal/terminal/memory_backend.rb
277
220
  - lib/charming/internal/terminal/modified_key_parser.rb
278
221
  - lib/charming/internal/terminal/mouse_parser.rb
222
+ - lib/charming/internal/terminal/size.rb
279
223
  - lib/charming/internal/terminal/tty_backend.rb
280
224
  - lib/charming/internal/timer_control.rb
281
225
  - lib/charming/presentation/component.rb
@@ -309,6 +253,7 @@ files:
309
253
  - lib/charming/presentation/components/multi_select_list.rb
310
254
  - lib/charming/presentation/components/paginator.rb
311
255
  - lib/charming/presentation/components/progressbar.rb
256
+ - lib/charming/presentation/components/result.rb
312
257
  - lib/charming/presentation/components/sparkline.rb
313
258
  - lib/charming/presentation/components/spinner.rb
314
259
  - lib/charming/presentation/components/status_bar.rb
@@ -369,17 +314,23 @@ files:
369
314
  - lib/charming/presentation/ui/width.rb
370
315
  - lib/charming/presentation/view.rb
371
316
  - lib/charming/projectile.rb
317
+ - lib/charming/render_artifacts.rb
372
318
  - lib/charming/response.rb
373
319
  - lib/charming/router.rb
374
320
  - lib/charming/runtime.rb
375
321
  - lib/charming/screen.rb
322
+ - lib/charming/shell/palette.rb
323
+ - lib/charming/shell/sidebar.rb
376
324
  - lib/charming/spring.rb
377
325
  - lib/charming/tasks/cancelled.rb
326
+ - lib/charming/tasks/context.rb
378
327
  - lib/charming/tasks/inline_executor.rb
379
328
  - lib/charming/tasks/progress.rb
380
329
  - lib/charming/tasks/task.rb
381
330
  - lib/charming/tasks/threaded_executor.rb
382
331
  - lib/charming/test_helper.rb
332
+ - lib/charming/unhandled_component_event.rb
333
+ - lib/charming/unknown_slot.rb
383
334
  - lib/charming/version.rb
384
335
  - lib/charming/welcome.rb
385
336
  - lib/charming/welcome/controller.rb
@@ -1,125 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- class Controller
5
- # Component-dispatch helpers mixed into Controller. Forwards key events to the currently
6
- # focused component (the slot returned by `focus.current`) and translates component return
7
- # values into controller hook calls (e.g., `slot_submitted`, `slot_selected`, `slot_cancelled`).
8
- module ComponentDispatching
9
- private
10
-
11
- # Sends the current key event to the focused component (if it responds to `handle_key`).
12
- # Returns `:handled` after dispatching, or nil when no component is focused.
13
- def dispatch_to_focused_component
14
- slot = focus.current
15
- return nil unless slot && respond_to?(slot, true)
16
-
17
- component = send(slot)
18
- return nil unless component.respond_to?(:handle_key)
19
-
20
- result = component.handle_key(event)
21
- return nil if result.nil?
22
-
23
- dispatch_component_result(slot, result)
24
- :handled
25
- end
26
-
27
- # Translates a component `handle_key` *result* into a controller hook call. `:cancelled`
28
- # triggers `<slot>_cancelled`, `[:submitted, value]` triggers `<slot>_submitted(value)`,
29
- # `[:selected, value]` triggers `<slot>_selected(value)`. Falls back to a default render
30
- # when no matching hook exists.
31
- def dispatch_component_result(slot, result)
32
- action, arguments = component_result_action(slot, result)
33
- action ? send(action, *arguments) : render_default_action
34
- render_default_action unless response
35
- end
36
-
37
- # Resolves which controller hook (if any) corresponds to the *result* from a component.
38
- def component_result_action(slot, result)
39
- case result
40
- when :cancelled
41
- component_action(slot, :cancelled)
42
- when Array
43
- component_array_action(slot, result)
44
- end
45
- end
46
-
47
- # Handles array-shaped component results, currently `[:submitted, value]` and `[:selected, value]`.
48
- def component_array_action(slot, result)
49
- event_name, value = result
50
- return component_action(slot, :submitted, value) if event_name == :submitted
51
- return component_action(slot, :selected, value) if event_name == :selected
52
-
53
- nil
54
- end
55
-
56
- # Returns `[action, arguments]` for the `<slot>_<suffix>` controller hook if defined, or nil.
57
- def component_action(slot, suffix, *arguments)
58
- action = :"#{slot}_#{suffix}"
59
- return unless respond_to?(action, true)
60
-
61
- [action, arguments]
62
- end
63
-
64
- # Handles Tab/Shift+Tab by cycling through the focus ring. Returns :handled after rendering.
65
- def dispatch_tab_traversal
66
- return nil unless key_name == :tab
67
- return nil if focus.ring.empty?
68
-
69
- focus.cycle(event.shift ? -1 : +1)
70
- render_default_action
71
- :handled
72
- end
73
-
74
- # Hit-tests the current mouse event against named layout panes from the latest render.
75
- # Clicks move focus to matching slots; components in clicked panes receive local coordinates.
76
- def dispatch_component_mouse
77
- target = mouse_target_for_event
78
- return nil unless target
79
-
80
- slot = target.fetch(:name)
81
- previous_focus = focus.current
82
- focus.focus(slot) if focusable_click?(slot)
83
-
84
- result = dispatch_mouse_to_target_component(slot, target)
85
- return response if result.nil? && previous_focus == focus.current
86
-
87
- result ? dispatch_component_result(slot, result) : render_default_action
88
- response
89
- end
90
-
91
- def mouse_target_for_event
92
- mouse_targets.rfind { |target| target.fetch(:rect).cover?(event.x, event.y) }
93
- end
94
-
95
- def focusable_click?(slot)
96
- event.respond_to?(:click?) && event.click? && focus.ring.include?(slot)
97
- end
98
-
99
- def dispatch_mouse_to_target_component(slot, target)
100
- return nil unless respond_to?(slot, true)
101
-
102
- component = send(slot)
103
- return nil unless component.respond_to?(:handle_mouse)
104
-
105
- local_event = local_mouse_event(target.fetch(:inner_rect))
106
- return nil unless local_event
107
-
108
- component.handle_mouse(local_event)
109
- end
110
-
111
- def local_mouse_event(rect)
112
- return nil unless rect.cover?(event.x, event.y)
113
-
114
- Events::MouseEvent.new(
115
- button: event.button,
116
- x: event.x - rect.x,
117
- y: event.y - rect.y,
118
- ctrl: event.ctrl,
119
- alt: event.alt,
120
- shift: event.shift
121
- )
122
- end
123
- end
124
- end
125
- end