charming 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # DSL for declaring controller-level event bindings and configuration: keys, commands,
6
+ # timers, task handlers, the auto-rendered action, layout wrapper, and focus ring.
7
+ # Mixed into Controller as class methods; also exposed for tests and shared base controllers.
8
+ module ClassMethods
9
+ # Binds a key press to a controller action. *name* is the normalized key symbol (e.g., "up",
10
+ # "q", "ctrl+c"). *scope* is :content (default) for content-pane keys or :global for app-wide
11
+ # shortcuts that fire regardless of focus. Raises ArgumentError for any other scope.
12
+ def key(name, action, scope: :content)
13
+ normalized_scope = validate_key_scope(scope)
14
+ key_name = name.to_sym
15
+ key_bindings[key_name] = action
16
+ key_binding_scopes[key_name] = normalized_scope
17
+ end
18
+
19
+ # Adds a CommandPalette entry with the given *label*. *action* is a method name to send on
20
+ # the controller, or a block to instance_exec when selected.
21
+ def command(label, action = nil, &block)
22
+ command_bindings << Presentation::Components::CommandPalette::Command.new(label: label, value: block || action)
23
+ end
24
+
25
+ # Declares a timer that fires every *every* seconds and dispatches *action* on the controller.
26
+ # The runtime builds a TimerEvent and routes it to the active controller's dispatch_timer.
27
+ def timer(name, every:, action:)
28
+ timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
29
+ end
30
+
31
+ # Declares a task handler for async work submitted via `run_task(:name)`. When the task emits
32
+ # a TaskEvent with the matching name, the runtime dispatches *action* on the controller.
33
+ def on_task(name, action:)
34
+ task_bindings[name.to_sym] = TaskBinding.new(name: name.to_sym, action: action)
35
+ end
36
+
37
+ # Sets the action that the controller should auto-render after a non-rendering action runs.
38
+ # Defaults to :show when unset.
39
+ def auto_render(action = :show)
40
+ @auto_render_action = action.to_sym
41
+ end
42
+
43
+ # Returns the configured auto-render action, walking the superclass chain when undefined locally.
44
+ def auto_render_action
45
+ return @auto_render_action if instance_variable_defined?(:@auto_render_action)
46
+ return superclass.auto_render_action if superclass.respond_to?(:auto_render_action)
47
+
48
+ nil
49
+ end
50
+
51
+ # Sets or returns the controller's layout. Pass a layout class (instantiated per request),
52
+ # a String/Symbol template name (resolved through Presentation::Templates), or `false` to
53
+ # disable inherited layout wrapping. Called with no arguments returns the resolved layout.
54
+ def layout(layout_class = :__charming_layout_reader__)
55
+ return resolved_layout if layout_class == :__charming_layout_reader__
56
+
57
+ @layout = layout_class
58
+ end
59
+
60
+ # Hash of registered key bindings (symbol key name => action method name), inherited from
61
+ # superclass controllers.
62
+ def key_bindings
63
+ @key_bindings ||= superclass.respond_to?(:key_bindings) ? superclass.key_bindings.dup : {}
64
+ end
65
+
66
+ # Hash of key scopes paralleling `key_bindings` (symbol key name => :content or :global).
67
+ def key_binding_scopes
68
+ @key_binding_scopes ||= superclass.respond_to?(:key_binding_scopes) ? superclass.key_binding_scopes.dup : {}
69
+ end
70
+
71
+ # Defines the named focus slots cycled by Tab/Shift+Tab traversal.
72
+ def focus_ring(*slots)
73
+ @focus_ring_slots = slots
74
+ end
75
+
76
+ # Returns the focus ring slots, inherited from superclass when undefined.
77
+ def focus_ring_slots
78
+ @focus_ring_slots ||= superclass.respond_to?(:focus_ring_slots) ? superclass.focus_ring_slots.dup : []
79
+ end
80
+
81
+ # Array of registered command palette entries, inherited from superclass when undefined.
82
+ def command_bindings
83
+ @command_bindings ||= superclass.respond_to?(:command_bindings) ? superclass.command_bindings.dup : []
84
+ end
85
+
86
+ # Hash of timer name => TimerBinding, inherited from superclass when undefined.
87
+ def timer_bindings
88
+ @timer_bindings ||= superclass.respond_to?(:timer_bindings) ? superclass.timer_bindings.dup : {}
89
+ end
90
+
91
+ # Hash of task name => TaskBinding, inherited from superclass when undefined.
92
+ def task_bindings
93
+ @task_bindings ||= superclass.respond_to?(:task_bindings) ? superclass.task_bindings.dup : {}
94
+ end
95
+
96
+ private
97
+
98
+ # Validates that *scope* is :content or :global; otherwise raises ArgumentError.
99
+ def validate_key_scope(scope)
100
+ normalized_scope = scope.to_sym
101
+ return normalized_scope if %i[content global].include?(normalized_scope)
102
+
103
+ raise ArgumentError, "unknown key scope: #{scope.inspect}"
104
+ end
105
+
106
+ # Walks the superclass chain to find a configured layout, returning nil if none is set.
107
+ def resolved_layout
108
+ return @layout if instance_variable_defined?(:@layout)
109
+ return superclass.layout if superclass.respond_to?(:layout)
110
+
111
+ nil
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Command palette helpers mixed into Controller. Opens/closes the palette, builds the
6
+ # palette from registered command bindings or theme list, and routes key/mouse events
7
+ # through it. Supports both the standard command palette (:commands) and the theme picker
8
+ # (:themes) via a discriminated `session[:command_palette]` state hash.
9
+ module CommandPalette
10
+ # Opens the command palette populated with the controller's `command_bindings`. Pushes
11
+ # a focus scope so subsequent keys are routed to the palette.
12
+ def open_command_palette
13
+ session[:command_palette] = command_palette_state(:commands)
14
+ focus.push_scope([:command_palette], origin: :command_palette)
15
+ render_default_action
16
+ end
17
+
18
+ # Closes the command palette, pops its focus scope, and renders the current action.
19
+ def close_command_palette
20
+ session.delete(:command_palette)
21
+ pop_command_palette_scope
22
+ render_default_action
23
+ end
24
+
25
+ # True when either the command palette or theme picker is currently open.
26
+ def command_palette_open?
27
+ session.key?(:command_palette)
28
+ end
29
+
30
+ # Returns the active CommandPalette component, or nil when the palette is closed.
31
+ def command_palette
32
+ build_command_palette_from_state(session[:command_palette]) if command_palette_open?
33
+ end
34
+
35
+ private
36
+
37
+ # Routes the current key event to the open palette. Cancels on Escape, performs the
38
+ # selected command on Enter, otherwise persists the palette's state and re-renders.
39
+ def dispatch_command_palette_key
40
+ palette = command_palette
41
+ result = palette.handle_key(event)
42
+
43
+ if result == :cancelled
44
+ close_command_palette
45
+ elsif selected_command?(result)
46
+ perform_command(result.last)
47
+ else
48
+ save_command_palette_state(palette)
49
+ render_default_action unless response
50
+ end
51
+
52
+ response
53
+ end
54
+
55
+ # Mouse dispatch for the command palette. Reserved for future use; returns nil.
56
+ def dispatch_command_palette_mouse
57
+ nil
58
+ end
59
+
60
+ # Builds a CommandPalette component from the persisted palette *state* hash, dispatching
61
+ # to command-list or theme-list construction based on the state's `:type`.
62
+ def build_command_palette_from_state(state)
63
+ case state.fetch(:type)
64
+ when :commands
65
+ build_command_palette_with_state(self.class.command_bindings, state, height: 6)
66
+ when :themes
67
+ build_command_palette_with_state(theme_commands, state, placeholder: "Search themes", height: 10)
68
+ end
69
+ end
70
+
71
+ # Constructs the CommandPalette widget with a *commands* list and persisted *state* hash.
72
+ def build_command_palette_with_state(commands, state, placeholder: "Search commands", height: nil)
73
+ Presentation::Components::CommandPalette.new(
74
+ commands: commands,
75
+ placeholder: placeholder,
76
+ height: height,
77
+ value: state.fetch(:value),
78
+ cursor: state.fetch(:cursor),
79
+ selected_index: state.fetch(:selected_index),
80
+ theme: theme
81
+ )
82
+ end
83
+
84
+ # Initial palette state hash used when opening either palette type.
85
+ def command_palette_state(type)
86
+ {type: type, value: "", cursor: 0, selected_index: 0}
87
+ end
88
+
89
+ # Merges the in-memory palette's state back into the session hash so the search query,
90
+ # cursor, and selected index survive across renders.
91
+ def save_command_palette_state(palette)
92
+ session[:command_palette] = session.fetch(:command_palette).merge(palette.state)
93
+ end
94
+
95
+ # True when a component result is the `[:selected, command]` array shape.
96
+ def selected_command?(result)
97
+ result.is_a?(Array) && result.first == :selected
98
+ end
99
+
100
+ # Invokes the value (proc, lambda, or method symbol) of the selected *command*, then
101
+ # closes the palette unless the command was :quit or the user has re-opened it.
102
+ def perform_command(command)
103
+ current_palette_state = session[:command_palette]
104
+ pop_command_palette_scope
105
+ perform_command_value(command.value)
106
+ if command.value != :quit && session[:command_palette].equal?(current_palette_state)
107
+ session.delete(:command_palette)
108
+ end
109
+ render_default_action unless response&.navigate? || response&.quit?
110
+ end
111
+
112
+ # Returns the theme-switching commands used by the theme picker palette.
113
+ def theme_commands
114
+ application.class.themes.keys.map do |name|
115
+ Presentation::Components::CommandPalette::Command.new(label: theme_label(name), value: -> { use_theme(name) })
116
+ end
117
+ end
118
+
119
+ # Converts a theme name symbol (e.g., :dracula_dark) to a human-readable label ("Dracula Dark").
120
+ def theme_label(name)
121
+ name.to_s.tr("_", "-").split("-").map(&:capitalize).join(" ")
122
+ end
123
+
124
+ # Pops focus scopes while the top of the stack is the command palette.
125
+ def pop_command_palette_scope
126
+ focus.pop_scope while focus.ring == [:command_palette]
127
+ end
128
+
129
+ # Invokes a palette command *value* — a proc gets instance_exec'd on self, a symbol gets sent.
130
+ def perform_command_value(value)
131
+ value.respond_to?(:call) ? instance_exec(&value) : send(value)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,81 @@
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
+ # Default mouse dispatch hook: subclasses/components may override by including their own
75
+ # mouse logic via the controller's `dispatch_component_mouse` override.
76
+ def dispatch_component_mouse
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Key-dispatch helpers mixed into Controller. Resolves the current event's key symbol and
6
+ # looks up bindings by scope (content vs. global) before they are sent to controller actions.
7
+ module Dispatching
8
+ private
9
+
10
+ # Returns the normalized key symbol for the current controller event.
11
+ def key_name
12
+ Charming.key_of(event)
13
+ end
14
+
15
+ # Calls the auto-render action if one is configured. No-op when the action method is undefined.
16
+ def render_default_action
17
+ action = self.class.auto_render_action || :show
18
+ public_send(action) if respond_to?(action)
19
+ end
20
+
21
+ # True when an explicit auto-render action is configured and the just-completed *action* is
22
+ # not itself the auto-render action (to avoid infinite loops).
23
+ def auto_render_after?(action)
24
+ auto_render_action = self.class.auto_render_action
25
+ auto_render_action && action.to_sym != auto_render_action
26
+ end
27
+
28
+ # Returns the action method bound to the current key at :global scope, or nil if none.
29
+ def global_key_action
30
+ key_action_for_scope(:global)
31
+ end
32
+
33
+ # Returns the action method bound to the current key at :content scope, or nil if the
34
+ # content scope is not active (e.g., sidebar has focus).
35
+ def content_key_action
36
+ return nil unless content_key_scope_active?
37
+
38
+ key_action_for_scope(:content)
39
+ end
40
+
41
+ # Returns false when the controller declared a content focus ring slot and the sidebar
42
+ # is currently focused. Otherwise true (the default behavior for non-declarative controllers).
43
+ def content_key_scope_active?
44
+ return content_focused? if focus_ring_slot?(:content)
45
+
46
+ true
47
+ end
48
+
49
+ # Looks up the current key in the class bindings and returns the action only if its
50
+ # registered scope matches *scope*. Returns nil otherwise.
51
+ def key_action_for_scope(scope)
52
+ action = self.class.key_bindings[key_name]
53
+ return nil unless action
54
+ return nil unless self.class.key_binding_scopes.fetch(key_name, :content) == scope
55
+
56
+ action
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Focus helpers mixed into Controller: lazily-allocated per-controller Focus object and
6
+ # predicates for `focused?(:slot)` checks from views. The Focus object is keyed by controller
7
+ # class name in the session, so it survives across controller dispatches for the same class.
8
+ module FocusManagement
9
+ # Returns the per-controller Focus object, defining the focus ring from class-level DSL
10
+ # declarations on first access.
11
+ def focus
12
+ @focus ||= Focus.for(session, self.class).tap do |f|
13
+ f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
14
+ end
15
+ end
16
+
17
+ # Returns true when the named *slot* is the currently focused slot in this controller's focus ring.
18
+ def focused?(slot)
19
+ focus.focused?(slot)
20
+ end
21
+
22
+ private
23
+
24
+ # True when the controller class declared *slot* as part of its focus_ring DSL.
25
+ def focus_ring_slot?(slot)
26
+ self.class.focus_ring_slots.include?(slot)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Rendering pipeline mixed into Controller. Resolves view classes, template paths, layouts,
6
+ # and assigns. Most helpers are private — only `render`/`render_view`/`render_template` are
7
+ # part of the public controller API and live in `controller.rb` itself.
8
+ module Rendering
9
+ private
10
+
11
+ # Returns the string body for *body* — if it responds to `render` (e.g., a View or Component),
12
+ # delegates to that; otherwise calls `to_s`.
13
+ def render_body(body)
14
+ body.respond_to?(:render) ? body.render.to_s : body.to_s
15
+ end
16
+
17
+ # Wraps *body* (a string) in the controller's configured layout, if any. When no layout is set
18
+ # the body is returned as-is.
19
+ def render_with_layout(body)
20
+ rendered = render_body(body)
21
+ layout = self.class.layout
22
+ return rendered unless layout
23
+
24
+ render_body(layout_body(layout, body, rendered))
25
+ end
26
+
27
+ # Builds the layout wrapper for *body* / *rendered* content. String/Symbol layouts are
28
+ # resolved as templates; other values are treated as layout view classes.
29
+ def layout_body(layout, body, rendered)
30
+ assigns = layout_assigns(body, rendered)
31
+ return template_body(layout, **assigns) if layout.is_a?(String) || layout.is_a?(Symbol)
32
+
33
+ layout.new(**assigns)
34
+ end
35
+
36
+ # Returns a view object for *name* — a conventional view class when one exists under the
37
+ # application namespace, otherwise a TemplateView rendered from `app/views`.
38
+ def view_body(name, **assigns)
39
+ view_class = conventional_view_class(name)
40
+ return view_class.new(**template_assigns(assigns)) if view_class
41
+
42
+ template_body(name, **assigns)
43
+ end
44
+
45
+ # Builds the assigns hash passed to layout view constructors: view's own assigns, plus
46
+ # `content:`, `screen:`, `controller:`, and `theme:`.
47
+ def layout_assigns(body, rendered)
48
+ view_assigns(body).merge(content: rendered, screen: screen, controller: self, theme: theme)
49
+ end
50
+
51
+ # Returns the assigns hash from *body* (a View), or an empty hash when *body* doesn't expose them.
52
+ def view_assigns(body)
53
+ body.respond_to?(:layout_assigns) ? body.layout_assigns : {}
54
+ end
55
+
56
+ # Resolves a template by *name* and returns a TemplateView bound to the application's namespace.
57
+ def template_body(name, **assigns)
58
+ Presentation::TemplateView.new(template: resolve_template(name), namespace: template_namespace, **template_assigns(assigns))
59
+ end
60
+
61
+ # Looks up the template file under `app/views` relative to the application root.
62
+ def resolve_template(name)
63
+ Presentation::Templates.resolve(name, root: application.class.root)
64
+ end
65
+
66
+ # Returns the assigns hash passed to templates: `screen:`, `controller:`, `theme:` plus user *assigns*.
67
+ def template_assigns(assigns)
68
+ {screen: screen, controller: self, theme: theme}.merge(assigns)
69
+ end
70
+
71
+ # Returns the application namespace constant (e.g., `MyApp`) used for view-class lookup,
72
+ # or nil when the application has no namespace.
73
+ def template_namespace
74
+ namespace_name = application.class.namespace
75
+ return nil if namespace_name.to_s.empty?
76
+
77
+ Object.const_get(namespace_name)
78
+ end
79
+
80
+ # Returns the conventional view class for *name* (e.g., `MyApp::Home::ShowView`) when defined
81
+ # under the application namespace. Returns nil when no such class exists.
82
+ def conventional_view_class(name)
83
+ namespace = template_namespace
84
+ return unless namespace
85
+
86
+ constant_path = conventional_view_constant_path(name)
87
+ constant_path.reduce(namespace) do |scope, constant_name|
88
+ break unless scope.const_defined?(constant_name, false)
89
+
90
+ scope.const_get(constant_name, false)
91
+ end
92
+ end
93
+
94
+ # Builds the constant lookup path (array of strings) for a conventional view class from *name*.
95
+ # Splits "home/show" → ["Home", "ShowView"].
96
+ def conventional_view_constant_path(name)
97
+ parts = name.to_s.split("/")
98
+ action = parts.pop
99
+ parts.map { |part| camelize(part) } + ["#{camelize(action)}View"]
100
+ end
101
+
102
+ # Converts a snake_case string to CamelCase. Used to build conventional view constant names.
103
+ def camelize(value)
104
+ value.to_s.split("_").map(&:capitalize).join
105
+ end
106
+
107
+ # Returns the default template path for a given *action* (e.g., "home/show" for HomeController#show).
108
+ def default_template_name(action)
109
+ "#{controller_template_path}/#{action}"
110
+ end
111
+
112
+ # Returns the underscored controller path (e.g., "home" for HomeController) used for view lookup.
113
+ def controller_template_path
114
+ underscore(self.class.name.split("::").last.delete_suffix("Controller"))
115
+ end
116
+
117
+ # Converts CamelCase to snake_case.
118
+ def underscore(value)
119
+ value
120
+ .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
121
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
122
+ .tr("-", "_")
123
+ .downcase
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Session-state helpers mixed into Controller: accessing the application session hash, lazy
6
+ # state-object lookup by name/class, form builder invocation, and async task submission.
7
+ module SessionState
8
+ # Returns the application session hash for this controller. All persistent state (focus,
9
+ # sidebar index, command palette, user state objects) lives here.
10
+ def session
11
+ application.session
12
+ end
13
+
14
+ # Returns the named session-backed state object, creating it on first access. *name* is a
15
+ # symbol key under `session[:states]`. *state_class* is an ApplicationState subclass whose
16
+ # constructor receives *attributes* on first creation. Subsequent calls return the same object.
17
+ def state(name, state_class, **attributes)
18
+ session[:states] ||= {}
19
+ session[:states][name.to_sym] ||= state_class.new(**attributes)
20
+ end
21
+
22
+ # Builds a Form component scoped to the named form slot in `session[:forms]`. The block is
23
+ # evaluated against a Form::Builder (or invoked with the builder as its argument for arity-1 blocks)
24
+ # and returns a Form component pre-bound to the per-form mutable state hash.
25
+ def form(name, &block)
26
+ session[:forms] ||= {}
27
+ form_state = session[:forms][name.to_sym] ||= {}
28
+ builder = Presentation::Components::Form::Builder.new(theme: theme)
29
+ block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
30
+ builder.build(state: form_state, theme: theme)
31
+ end
32
+
33
+ # Submits a background task with the given *name*. The block is executed by the configured
34
+ # task executor; its return value (or any raised exception) is delivered to the controller
35
+ # as a TaskEvent dispatched to the matching `on_task` handler.
36
+ def run_task(name, &block)
37
+ application.task_executor.submit(name, &block)
38
+ end
39
+ end
40
+ end
41
+ end