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
@@ -54,16 +54,25 @@ module Charming
54
54
  clamp_position
55
55
  end
56
56
 
57
- # Handles key events. Returns `[:selected, item]` on Enter when an item is selected;
58
- # otherwise delegates to the KeyboardHandler for navigation keys.
57
+ # Replaces the source items (e.g. after the underlying data changed) and
58
+ # reclamps the selection. Lets a memoized list stay fresh: keep the component
59
+ # in an ivar so its selection survives, then assign `list.items = rows`
60
+ # before each render.
61
+ def items=(new_items)
62
+ @source_items = new_items
63
+ clamp_position
64
+ end
65
+
66
+ # Handles key events. Returns `Result.selected(item)` on Enter when an item is
67
+ # selected; otherwise delegates to the KeyboardHandler for navigation keys.
59
68
  def handle_key(event)
60
- return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
69
+ return Result.selected(selected_item) if Charming.key_of(event) == :enter && selected_item
61
70
 
62
71
  super
63
72
  end
64
73
 
65
74
  # Handles mouse events: a click within the visible window selects the clicked row.
66
- # Returns :handled on a successful click, nil otherwise.
75
+ # Returns Result.handled on a successful click, nil otherwise.
67
76
  def handle_mouse(event)
68
77
  return nil unless @height
69
78
  return nil unless event.respond_to?(:click?) && event.click?
@@ -73,7 +82,7 @@ module Charming
73
82
 
74
83
  @selected_index = viewport_start + clicked
75
84
  clamp_position
76
- :handled
85
+ Result.handled
77
86
  end
78
87
 
79
88
  # Returns the currently selected item, or nil when the list is empty.
@@ -28,8 +28,9 @@ module Charming
28
28
  @style = style
29
29
  end
30
30
 
31
- # Scrolls the body when it is taller than max_body_height. Returns :handled when the
32
- # key moved the viewport, nil otherwise (so callers can route unconsumed keys).
31
+ # Scrolls the body when it is taller than max_body_height. Returns the Viewport's
32
+ # Result (Result.handled when the key scrolled), or nil when the modal is not
33
+ # scrollable (so callers can route unconsumed keys).
33
34
  def handle_key(event)
34
35
  return nil unless scrollable?
35
36
 
@@ -5,9 +5,9 @@ module Charming
5
5
  # MultiSelectList is a List variant where Space toggles per-item checkmarks and
6
6
  # Enter submits the checked set. Renders `[x]` / `[ ]` prefixes.
7
7
  #
8
- # `handle_key` returns `[:submitted, [item, ...]]` on Enter, :handled for toggles
9
- # and navigation, nil otherwise. *max_selections* optionally caps how many items
10
- # can be checked at once.
8
+ # `handle_key` returns `Result.submitted([item, ...])` on Enter, Result.handled for
9
+ # toggles and navigation, nil otherwise. *max_selections* optionally caps how many
10
+ # items can be checked at once.
11
11
  class MultiSelectList < List
12
12
  # The set of selected (checked) item indices.
13
13
  attr_reader :selected_indices
@@ -20,11 +20,18 @@ module Charming
20
20
  @max_selections = max_selections
21
21
  end
22
22
 
23
+ # Replaces the items, reclamps the highlight (List#items=), and drops checks
24
+ # that fall outside the new list.
25
+ def items=(new_items)
26
+ super
27
+ @selected_indices = selected_indices.select { |index| index < items.length }
28
+ end
29
+
23
30
  # Space toggles the highlighted item, Enter submits the checked items.
24
31
  def handle_key(event)
25
32
  case Charming.key_of(event)
26
33
  when :space then toggle_current
27
- when :enter then [:submitted, selected_items]
34
+ when :enter then Result.submitted(selected_items)
28
35
  else
29
36
  # Bypass List#handle_key (its Enter means single-select); use its navigation.
30
37
  keyboard_navigation(event)
@@ -52,11 +59,11 @@ module Charming
52
59
  if selected_indices.include?(index)
53
60
  selected_indices.delete(index)
54
61
  else
55
- return :handled if @max_selections && selected_indices.length >= @max_selections
62
+ return Result.handled if @max_selections && selected_indices.length >= @max_selections
56
63
 
57
64
  selected_indices << index
58
65
  end
59
- :handled
66
+ Result.handled
60
67
  end
61
68
 
62
69
  # Navigation via the KeyboardHandler key actions (up/down/home/end and keymap aliases).
@@ -66,7 +73,7 @@ module Charming
66
73
  return nil unless action
67
74
 
68
75
  send(action)
69
- :handled
76
+ Result.handled
70
77
  end
71
78
 
72
79
  # One row: checkbox, then the labeled item; highlighted row in selected style.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Result is what an interactive component returns from `handle_key`, `handle_mouse`,
6
+ # and `handle_paste`. `kind` is the event (:handled, :submitted, :selected,
7
+ # :cancelled, or :changed — changed is reserved for a future `on_change` DSL);
8
+ # `value` carries the payload for submit/select/change. The legacy return forms
9
+ # (:handled, :cancelled, [:submitted, value], [:selected, value]) still work —
10
+ # the dispatch collaborator normalizes them to Results with `Result.normalize`.
11
+ Result = Data.define(:kind, :value) do
12
+ class << self
13
+ # The component consumed the event; nothing to dispatch.
14
+ def handled = new(kind: :handled, value: nil)
15
+
16
+ # The component submitted a value (Enter on the last field, Ctrl+S).
17
+ def submitted(value) = new(kind: :submitted, value: value)
18
+
19
+ # The component selected an item (Enter/click on a row).
20
+ def selected(value) = new(kind: :selected, value: value)
21
+
22
+ # The component was dismissed (Escape).
23
+ def cancelled = new(kind: :cancelled, value: nil)
24
+
25
+ # Reserved: the component's value changed. No controller DSL consumes it yet.
26
+ def changed(value) = new(kind: :changed, value: value)
27
+
28
+ # Normalizes a legacy handle_* return value to a Result. nil and unknown
29
+ # values pass through.
30
+ def normalize(value)
31
+ case value
32
+ when Result then value
33
+ when :handled then handled
34
+ when :cancelled then cancelled
35
+ when Array then normalize_array(value)
36
+ else value
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # Normalizes the legacy two-element array forms, [:submitted, v] and
43
+ # [:selected, v]. Unknown arrays pass through unchanged.
44
+ def normalize_array(value)
45
+ event, payload = value
46
+ return submitted(payload) if event == :submitted
47
+ return selected(payload) if event == :selected
48
+
49
+ value
50
+ end
51
+ end
52
+
53
+ # Kind predicates for the dispatch pipeline and the shell.
54
+ def handled? = kind == :handled
55
+ def submitted? = kind == :submitted
56
+ def selected? = kind == :selected
57
+ def cancelled? = kind == :cancelled
58
+ def changed? = kind == :changed
59
+ end
60
+ end
61
+ end
@@ -8,8 +8,8 @@ module Charming
8
8
  #
9
9
  # TabBar.new(tabs: ["Files", "Search", "Git"], selected_index: 0)
10
10
  #
11
- # `handle_key` returns `[:selected, index]` on Enter, `:handled` for navigation keys,
12
- # and nil otherwise.
11
+ # `handle_key` returns `Result.selected(index)` on Enter, Result.handled for
12
+ # navigation keys, and nil otherwise.
13
13
  class TabBar < Component
14
14
  include KeyboardHandler
15
15
 
@@ -24,6 +24,14 @@ module Charming
24
24
  # The tab labels and the index of the active tab.
25
25
  attr_reader :tabs, :selected_index
26
26
 
27
+ # Replaces the tab labels and reclamps the active tab. Lets a memoized tab bar
28
+ # stay fresh: keep the component in a slot and assign `bar.tabs = names` before
29
+ # each render.
30
+ def tabs=(new_tabs)
31
+ @tabs = Array(new_tabs).map(&:to_s)
32
+ @selected_index = @tabs.empty? ? 0 : selected_index.clamp(0, @tabs.length - 1)
33
+ end
34
+
27
35
  # *tabs* is the array of tab labels. *selected_index* is the active tab (default 0).
28
36
  # *separator* spaces the tabs apart.
29
37
  def initialize(tabs:, selected_index: 0, separator: " ", keymap: :vim, theme: nil)
@@ -34,15 +42,15 @@ module Charming
34
42
  @keymap = keymap
35
43
  end
36
44
 
37
- # Returns `[:selected, index]` on Enter; navigation keys move the active tab.
45
+ # Returns `Result.selected(index)` on Enter; navigation keys move the active tab.
38
46
  def handle_key(event)
39
47
  return nil if tabs.empty?
40
- return [:selected, selected_index] if Charming.key_of(event) == :enter
48
+ return Result.selected(selected_index) if Charming.key_of(event) == :enter
41
49
 
42
50
  super
43
51
  end
44
52
 
45
- # Selects the clicked tab. Returns :handled when a tab was hit, nil otherwise.
53
+ # Selects the clicked tab. Returns Result.handled when a tab was hit, nil otherwise.
46
54
  def handle_mouse(event)
47
55
  return nil if tabs.empty?
48
56
  return nil unless event.respond_to?(:click?) && event.click?
@@ -51,7 +59,7 @@ module Charming
51
59
  return nil unless index
52
60
 
53
61
  @selected_index = index
54
- :handled
62
+ Result.handled
55
63
  end
56
64
 
57
65
  # Renders the tabs on one row, the active tab in the selected style.
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-table"
4
-
5
3
  module Charming
6
4
  module Components
7
5
  # Table renders tabular data with a header row, a selected row highlight, and keyboard
8
- # navigation. Mouse clicks within the body area also select rows. The table is rendered
9
- # via tty-table and the selected row is overlaid with reverse-video ANSI styling.
6
+ # navigation. Mouse clicks within the body area also select rows. The grid is drawn
7
+ # with Charming's own width/border machinery (square border chars plus junctions);
8
+ # the selected row is overlaid with reverse-video ANSI styling.
10
9
  class Table < Component
11
10
  include KeyboardHandler
12
11
 
@@ -42,20 +41,20 @@ module Charming
42
41
  @height = height
43
42
  end
44
43
 
45
- # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
46
- # KeyboardHandler for navigation keys.
44
+ # Handles key events. Returns `Result.selected(row)` on Enter; otherwise delegates
45
+ # to the KeyboardHandler for navigation keys.
47
46
  def handle_key(event)
48
47
  return nil if rows.empty?
49
48
 
50
49
  case Charming.key_of(event)
51
- when :enter then [:selected, selected_row]
50
+ when :enter then Result.selected(selected_row)
52
51
  else super
53
52
  end
54
53
  end
55
54
 
56
55
  # Handles mouse events: a click within the body area selects the clicked row
57
56
  # (relative to the visible window when a height is set).
58
- # Returns :handled on a successful click.
57
+ # Returns Result.handled on a successful click.
59
58
  def handle_mouse(event)
60
59
  return nil if rows.empty?
61
60
  return nil unless event.respond_to?(:click?) && event.click?
@@ -64,7 +63,7 @@ module Charming
64
63
  return nil if clicked.negative? || clicked >= visible_row_count
65
64
 
66
65
  @selected_index = viewport_start + clicked
67
- :handled
66
+ Result.handled
68
67
  end
69
68
 
70
69
  # Returns the currently selected row, or nil when the table is empty.
@@ -72,6 +71,15 @@ module Charming
72
71
  rows[selected_index]
73
72
  end
74
73
 
74
+ # Replaces the body rows (e.g. after the underlying data changed) and
75
+ # reclamps the selection. Lets a memoized table stay fresh: keep the component
76
+ # in an ivar so its selection survives, then assign `table.rows = rows`
77
+ # before each render.
78
+ def rows=(new_rows)
79
+ @rows = Array(new_rows)
80
+ @selected_index = clamp_index(selected_index)
81
+ end
82
+
75
83
  # Sorts the body rows by *column* (a header label or 0-based index).
76
84
  # Numeric-looking cells compare numerically; everything else as strings.
77
85
  # The sorted column is marked ▲/▼ in the rendered header. Returns self.
@@ -94,16 +102,57 @@ module Charming
94
102
  def render
95
103
  return "(empty table)" if header.empty? && rows.empty?
96
104
 
97
- normalized = rows.map { |row| normalize_row(row) }
98
- lines = TTY::Table.new(header: sort_marked_header, rows: normalized)
99
- .render(:unicode)
100
- .lines(chomp: true)
101
-
102
- compact_layout(lines)
105
+ body = rows.map { |row| normalize_row(row) }
106
+ columns = [header.length, *body.map(&:length)].max
107
+ widths = column_widths(body, columns)
108
+ lines = [border_line(widths, grid.top_left, grid_top_junction, grid.top_right)]
109
+ lines << grid_line(marked_header_row(columns), widths) unless header.empty?
110
+ window = body[viewport_start, visible_row_count] || []
111
+ window.each_with_index do |cells, index|
112
+ line = grid_line(cells, widths)
113
+ lines << (((viewport_start + index) == selected_index) ? theme.selected.render(line) : line)
114
+ end
115
+ lines << border_line(widths, grid.bottom_left, grid_bottom_junction, grid.bottom_right)
116
+ lines.join("\n")
103
117
  end
104
118
 
105
119
  private
106
120
 
121
+ # The square border set the grid is drawn with.
122
+ def grid
123
+ UI::Border.fetch(:square)
124
+ end
125
+
126
+ # Column junction characters for the grid's top and bottom borders.
127
+ def grid_top_junction = "┬"
128
+
129
+ def grid_bottom_junction = "┴"
130
+
131
+ # The display width of each column: the widest cell or header label, measured
132
+ # with ANSI-aware display width so wide characters pad correctly.
133
+ def column_widths(body, columns)
134
+ lines = header.empty? ? body : [sort_marked_header, *body]
135
+ Array.new(columns) do |column|
136
+ UI::Width.widest(lines.map { |cells| cells[column].to_s })
137
+ end
138
+ end
139
+
140
+ # The header row padded out to *columns* cells (short rows render empty cells).
141
+ def marked_header_row(columns)
142
+ sort_marked_header + Array.new([columns - header.length, 0].max, "")
143
+ end
144
+
145
+ # One body/header line: cells padded to column width, joined by verticals.
146
+ def grid_line(cells, widths)
147
+ padded = widths.each_with_index.map { |width, index| UI::Width.pad_to(cells[index].to_s, width) }
148
+ "#{grid.vertical}#{padded.join(grid.vertical)}#{grid.vertical}"
149
+ end
150
+
151
+ # A top or bottom border line across the column widths.
152
+ def border_line(widths, left, junction, right)
153
+ "#{left}#{widths.map { |width| grid.horizontal * width }.join(junction)}#{right}"
154
+ end
155
+
107
156
  # Coerces a *row* (Hash / String / Array) into a flat cell array matching the header.
108
157
  # Excess cells are merged into the last column with a space separator.
109
158
  def normalize_row(row)
@@ -119,23 +168,6 @@ module Charming
119
168
  kept + [merged]
120
169
  end
121
170
 
122
- # Applies the selected-row highlight, windows the body to the configured height,
123
- # and trims unused body rows below the actual row count.
124
- def compact_layout(lines)
125
- return lines.join("\n") if lines.length < 4
126
-
127
- top, header_line, _separator, *rest = lines
128
- body = rest.first(rows.length)
129
- bottom = rest[rows.length]
130
-
131
- window = body[viewport_start, visible_row_count] || []
132
- highlighted = window.each_with_index.map do |line, index|
133
- ((viewport_start + index) == selected_index) ? theme.selected.render(line) : line
134
- end
135
-
136
- [top, header_line, *highlighted, bottom].compact.join("\n")
137
- end
138
-
139
171
  # The top body row of the visible window (0 when no height is set), keeping the
140
172
  # selection in view.
141
173
  def viewport_start
@@ -37,12 +37,12 @@ module Charming
37
37
  true
38
38
  end
39
39
 
40
- # Routes key events to the appropriate cursor/text mutation. Returns :handled when the
41
- # event was consumed, nil otherwise.
40
+ # Routes key events to the appropriate cursor/text mutation. Returns Result.handled
41
+ # when the event was consumed, nil otherwise.
42
42
  def handle_key(event)
43
43
  key = Charming.key_of(event)
44
- return :handled if newline_event?(event) && insert("\n")
45
- return :handled if character_event?(event) && insert(event.char)
44
+ return Result.handled if newline_event?(event) && insert("\n")
45
+ return Result.handled if character_event?(event) && insert(event.char)
46
46
 
47
47
  case key
48
48
  when :left then move_left
@@ -58,15 +58,15 @@ module Charming
58
58
  else return nil
59
59
  end
60
60
 
61
- :handled
61
+ Result.handled
62
62
  end
63
63
 
64
64
  # Inserts pasted text at the cursor. Newlines are preserved; other control
65
- # characters (and CRLF carriage returns) are stripped. Returns :handled.
65
+ # characters (and CRLF carriage returns) are stripped. Returns Result.handled.
66
66
  def handle_paste(event)
67
67
  sanitized = event.text.to_s.tr("\r", "").gsub(/[^[:print:]\n]/, "")
68
68
  insert(sanitized) unless sanitized.empty?
69
- :handled
69
+ Result.handled
70
70
  end
71
71
 
72
72
  # Renders the visible portion of the text buffer (scrolled to `offset`), with each
@@ -51,26 +51,26 @@ module Charming
51
51
  end
52
52
 
53
53
  # Handles key events. Inserts printable characters, submits on Enter
54
- # (returning `[:submitted, value]` so a focused slot dispatches
54
+ # (returning `Result.submitted(value)` so a focused slot dispatches
55
55
  # `<slot>_submitted(value)`), recalls history on up/down (when enabled),
56
56
  # otherwise dispatches via KEY_ACTIONS.
57
- # Returns :handled or `[:submitted, value]` when the event was consumed, nil otherwise.
57
+ # Returns Result.handled or Result.submitted(value) when the event was consumed, nil otherwise.
58
58
  def handle_key(event)
59
- return :handled if character_event?(event) && insert(event.char)
59
+ return Result.handled if character_event?(event) && insert(event.char)
60
60
 
61
61
  key = Charming.key_of(event)
62
- return [:submitted, value] if key == :enter
63
- return :handled if history_event(key)
62
+ return Result.submitted(value) if key == :enter
63
+ return Result.handled if history_event(key)
64
64
 
65
65
  super
66
66
  end
67
67
 
68
68
  # Inserts pasted text at the cursor (newlines and control characters are
69
- # stripped — this is a single-line input). Returns :handled.
69
+ # stripped — this is a single-line input). Returns Result.handled.
70
70
  def handle_paste(event)
71
71
  sanitized = event.text.to_s.gsub(/[[:cntrl:]]/, "")
72
72
  insert(sanitized) unless sanitized.empty?
73
- :handled
73
+ Result.handled
74
74
  end
75
75
 
76
76
  # Renders the value with a cursor marker. When *width* was given at construction, the
@@ -6,8 +6,8 @@ module Charming
6
6
  # hashes: `{label: "src", children: [...], expanded: true}` — `children` and
7
7
  # `expanded` are optional. Navigation: up/down move the cursor through *visible*
8
8
  # nodes, right expands, left collapses (or jumps to the parent), Enter returns
9
- # `[:selected, node]` for leaves and toggles branches. Mouse clicks move the cursor
10
- # and toggle branches.
9
+ # `Result.selected(node)` for leaves and toggles branches. Mouse clicks move the
10
+ # cursor and toggle branches.
11
11
  class Tree < Component
12
12
  include KeyboardHandler
13
13
 
@@ -23,6 +23,15 @@ module Charming
23
23
  # The root node list and the cursor index into the visible-node list.
24
24
  attr_reader :nodes, :cursor_index
25
25
 
26
+ # Replaces the root nodes (e.g. after the underlying data changed) and reclamps
27
+ # the cursor. Expansion state lives in the node hashes, so fresh nodes render
28
+ # with the expansion they carry. Lets a memoized tree stay fresh: keep the
29
+ # component in a slot and assign `tree.nodes = rows` before each render.
30
+ def nodes=(new_nodes)
31
+ @nodes = new_nodes
32
+ clamp_cursor
33
+ end
34
+
26
35
  # *nodes* is the array of root node hashes (mutated in place to track expansion).
27
36
  # *height* optionally constrains the visible window.
28
37
  def initialize(nodes:, cursor_index: 0, height: nil, keymap: :vim, theme: nil)
@@ -34,7 +43,7 @@ module Charming
34
43
  clamp_cursor
35
44
  end
36
45
 
37
- # Enter selects a leaf (`[:selected, node]`) or toggles a branch. Navigation keys
46
+ # Enter selects a leaf (`Result.selected(node)`) or toggles a branch. Navigation keys
38
47
  # are handled by KeyboardHandler.
39
48
  def handle_key(event)
40
49
  node = current_node
@@ -54,7 +63,7 @@ module Charming
54
63
  @cursor_index = clicked
55
64
  node = current_node
56
65
  toggle(node) if branch?(node)
57
- :handled
66
+ Result.handled
58
67
  end
59
68
 
60
69
  # The node under the cursor (a node hash), or nil for an empty tree.
@@ -104,10 +113,10 @@ module Charming
104
113
  end
105
114
 
106
115
  def select_or_toggle(node)
107
- return [:selected, node] unless branch?(node)
116
+ return Result.selected(node) unless branch?(node)
108
117
 
109
118
  toggle(node)
110
- :handled
119
+ Result.handled
111
120
  end
112
121
 
113
122
  def toggle(node)
@@ -62,14 +62,14 @@ module Charming
62
62
  end
63
63
 
64
64
  # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
65
- # visible row to the clicked position. Returns :handled on success.
65
+ # visible row to the clicked position. Returns Result.handled on success.
66
66
  def handle_mouse(event)
67
67
  return nil unless height
68
68
 
69
69
  if event.scroll?
70
70
  scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
71
71
  position.move_to(offset + scroll_delta, bounds)
72
- return :handled
72
+ return Result.handled
73
73
  end
74
74
 
75
75
  return nil unless event.click?
@@ -78,7 +78,7 @@ module Charming
78
78
  return nil if clicked_row < 0 || clicked_row >= viewport_height
79
79
 
80
80
  position.move_to(offset + clicked_row, bounds)
81
- :handled
81
+ Result.handled
82
82
  end
83
83
 
84
84
  private
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
4
+
3
5
  module Charming
4
6
  module Layout
5
7
  # Pane is a leaf layout node: a single rectangle with optional border, padding, and
@@ -7,9 +9,11 @@ module Charming
7
9
  # view's context). Panes with a `name` and `behavior.focus: true` are registered as
8
10
  # focusable slots in the controller's focus ring.
9
11
  class Pane
12
+ extend Forwardable
13
+
10
14
  attr_reader :name
11
- delegate :width, :height, :grow,
12
- :min_width, :max_width, :min_height, :max_height, to: :geometry
15
+ def_delegators :geometry, :width, :height, :grow,
16
+ :min_width, :max_width, :min_height, :max_height
13
17
 
14
18
  # *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
15
19
  # is the view used for instance_exec when the block is given. *geometry*, *style*, and
@@ -49,6 +49,13 @@ module Charming
49
49
  end
50
50
  end
51
51
 
52
+ # Returns the rendered frame plus registration data (focusable pane names, mouse
53
+ # targets) as RenderArtifacts — the pure-render counterpart to #render that views
54
+ # use so dispatch, not rendering, commits registrations.
55
+ def render_with_artifacts
56
+ RenderArtifacts.new(frame: render, focus_slots: focusable_names, mouse_targets: mouse_targets)
57
+ end
58
+
52
59
  private
53
60
 
54
61
  # The screen, background style, the single child, and the list of overlays.