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
@@ -27,16 +27,15 @@ module Charming
27
27
  end
28
28
 
29
29
  # Forwards key events to the underlying MultiSelectList, syncing the checked
30
- # set and highlight cursor back into the field state. Returns :handled when
31
- # consumed; Enter (the list's submit) is left unconsumed for the Form.
30
+ # set and highlight cursor back into the field state. Returns Result.handled when
31
+ # consumed; Enter (the list's Result.submitted) is left unconsumed for the Form.
32
32
  def handle_key(event)
33
33
  widget = list
34
34
  result = widget.handle_key(event)
35
- return nil if result.is_a?(Array)
36
- return nil unless result == :handled
35
+ return nil unless result&.handled?
37
36
 
38
37
  save_selection(widget)
39
- :handled
38
+ Result.handled
40
39
  end
41
40
 
42
41
  private
@@ -24,14 +24,14 @@ module Charming
24
24
  end
25
25
 
26
26
  # Forwards key events to the underlying List, syncing the chosen option index back
27
- # into the field state. Returns :handled when consumed.
27
+ # into the field state. Returns Result.handled when consumed.
28
28
  def handle_key(event)
29
29
  selection = list
30
30
  result = selection.handle_key(event)
31
- return nil unless result == :handled
31
+ return nil unless result&.handled?
32
32
 
33
33
  save_selection(selection.selected_index)
34
- :handled
34
+ Result.handled
35
35
  end
36
36
 
37
37
  private
@@ -27,7 +27,7 @@ module Charming
27
27
  end
28
28
 
29
29
  # Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
30
- # and preferred column back into the form state. Returns :handled when consumed.
30
+ # and preferred column back into the form state. Returns Result.handled when consumed.
31
31
  def handle_key(event)
32
32
  forward_to_text_area(:handle_key, event)
33
33
  end
@@ -51,13 +51,13 @@ module Charming
51
51
  # event, persists the value, cursor, offset, and preferred column into form state.
52
52
  def forward_to_text_area(message, event)
53
53
  area = text_area
54
- return nil unless area.public_send(message, event) == :handled
54
+ return nil unless area.public_send(message, event)&.handled?
55
55
 
56
56
  state[:values][name] = area.value
57
57
  field_state[:cursor] = area.cursor
58
58
  field_state[:offset] = area.offset
59
59
  field_state[:preferred_column] = area.preferred_column
60
- :handled
60
+ Result.handled
61
61
  end
62
62
 
63
63
  # The default value for a freshly-bound field is the *value* passed at construction.
@@ -28,7 +28,7 @@ module Charming
28
28
  # or submits, and unhandled keys are passed to the focused field.
29
29
  def handle_key(event)
30
30
  key = Charming.key_of(event)
31
- return :cancelled if key == :escape
31
+ return Result.cancelled if key == :escape
32
32
  return submit if submit_shortcut?(event)
33
33
  return move_focus(tab_direction(event)) if key == :tab
34
34
 
@@ -103,14 +103,14 @@ module Charming
103
103
  move_focus(+1)
104
104
  end
105
105
 
106
- # Validates all fields, focuses the first invalid one, and returns [:submitted, values]
107
- # when there are no errors.
106
+ # Validates all fields, focuses the first invalid one, and returns
107
+ # Result.submitted(values) when there are no errors.
108
108
  def submit
109
109
  state[:errors] = validation_errors
110
110
  focus_first_error unless state[:errors].empty?
111
- return :handled unless state[:errors].empty?
111
+ return Result.handled unless state[:errors].empty?
112
112
 
113
- [:submitted, values.dup]
113
+ Result.submitted(values.dup)
114
114
  end
115
115
 
116
116
  # Runs each field's validator and collects per-field error messages.
@@ -139,7 +139,7 @@ module Charming
139
139
 
140
140
  current = indices.index(state[:focus_index]) || 0
141
141
  state[:focus_index] = indices[(current + direction) % indices.length]
142
- :handled
142
+ Result.handled
143
143
  end
144
144
 
145
145
  # True when the current focus index is the last focusable field.
@@ -11,7 +11,7 @@ module Charming
11
11
  #
12
12
  # HelpOverlay.new(bindings: {"q" => "Quit", "ctrl+p" => "Command palette"})
13
13
  #
14
- # Any key dismisses it (`handle_key` returns :cancelled).
14
+ # Any key dismisses it (`handle_key` returns Result.cancelled).
15
15
  class HelpOverlay < Component
16
16
  DEFAULT_TITLE = "Keyboard Shortcuts"
17
17
  DEFAULT_WIDTH = 44
@@ -40,7 +40,7 @@ module Charming
40
40
 
41
41
  # Any key dismisses the overlay.
42
42
  def handle_key(_event)
43
- :cancelled
43
+ Result.cancelled
44
44
  end
45
45
 
46
46
  # Renders the bindings table inside a titled modal.
@@ -6,8 +6,8 @@ module Charming
6
6
  # to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
7
7
  # a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
8
8
  # +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
9
- # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns :handled if an
10
- # action was found. Returns nil (via :handled being truthy or not) when no matching key exists.
9
+ # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns Result.handled
10
+ # if an action was found. Returns nil when no matching key exists.
11
11
  module KeyboardHandler
12
12
  VIM_KEYMAP = {
13
13
  up: :k,
@@ -22,7 +22,7 @@ module Charming
22
22
  return unless action
23
23
 
24
24
  send(action)
25
- :handled
25
+ Result.handled
26
26
  end
27
27
 
28
28
  private
@@ -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)