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.
- checksums.yaml +4 -4
- data/README.md +3 -1
- data/lib/charming/application.rb +98 -14
- data/lib/charming/application_state.rb +23 -0
- data/lib/charming/cli.rb +2 -2
- data/lib/charming/controller/action_hooks.rb +5 -1
- data/lib/charming/controller/class_methods.rb +79 -17
- data/lib/charming/controller/component_dispatch.rb +163 -0
- data/lib/charming/controller/dispatching.rb +1 -2
- data/lib/charming/controller/focus_management.rb +67 -1
- data/lib/charming/controller/key_dispatch.rb +7 -7
- data/lib/charming/controller/rendering.rb +22 -6
- data/lib/charming/controller/session_state.rb +63 -22
- data/lib/charming/controller/timers.rb +25 -0
- data/lib/charming/controller.rb +253 -60
- data/lib/charming/cross_thread_access.rb +9 -0
- data/lib/charming/double_render_error.rb +8 -0
- data/lib/charming/generators/layout_generator.rb +4 -1
- data/lib/charming/generators/migration_generator.rb +1 -1
- data/lib/charming/generators/model_generator.rb +2 -2
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/screen_generator.rb +2 -2
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/deep_freeze.rb +23 -0
- data/lib/charming/internal/env_inquirer.rb +22 -0
- data/lib/charming/internal/event_loop.rb +25 -2
- data/lib/charming/internal/inflections.rb +93 -0
- data/lib/charming/internal/session_guard.rb +27 -0
- data/lib/charming/internal/terminal/cursor.rb +29 -0
- data/lib/charming/internal/terminal/size.rb +47 -0
- data/lib/charming/internal/terminal/tty_backend.rb +10 -8
- data/lib/charming/internal/timer_control.rb +48 -0
- data/lib/charming/presentation/components/autocomplete.rb +14 -6
- data/lib/charming/presentation/components/command_palette.rb +11 -9
- data/lib/charming/presentation/components/filepicker.rb +4 -4
- data/lib/charming/presentation/components/form/confirm.rb +2 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/form/input.rb +3 -3
- data/lib/charming/presentation/components/form/multiselect.rb +4 -5
- data/lib/charming/presentation/components/form/select.rb +3 -3
- data/lib/charming/presentation/components/form/textarea.rb +3 -3
- data/lib/charming/presentation/components/form.rb +6 -6
- data/lib/charming/presentation/components/help_overlay.rb +2 -2
- data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
- data/lib/charming/presentation/components/list.rb +14 -5
- data/lib/charming/presentation/components/modal.rb +3 -2
- data/lib/charming/presentation/components/multi_select_list.rb +14 -7
- data/lib/charming/presentation/components/result.rb +61 -0
- data/lib/charming/presentation/components/tab_bar.rb +14 -6
- data/lib/charming/presentation/components/table.rb +64 -32
- data/lib/charming/presentation/components/text_area.rb +7 -7
- data/lib/charming/presentation/components/text_input.rb +7 -7
- data/lib/charming/presentation/components/tree.rb +15 -6
- data/lib/charming/presentation/components/viewport.rb +3 -3
- data/lib/charming/presentation/layout/pane.rb +6 -2
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/view.rb +35 -29
- data/lib/charming/projectile.rb +64 -0
- data/lib/charming/render_artifacts.rb +24 -0
- data/lib/charming/response.rb +19 -8
- data/lib/charming/router.rb +50 -68
- data/lib/charming/runtime.rb +54 -28
- data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
- data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
- data/lib/charming/spring.rb +126 -0
- data/lib/charming/tasks/context.rb +35 -0
- data/lib/charming/test_helper.rb +42 -22
- data/lib/charming/unhandled_component_event.rb +9 -0
- data/lib/charming/unknown_slot.rb +9 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome.rb +1 -1
- data/lib/charming.rb +20 -6
- metadata +25 -70
- 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
|
|
31
|
-
# consumed; Enter (the list's
|
|
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
|
|
36
|
-
return nil unless result == :handled
|
|
35
|
+
return nil unless result&.handled?
|
|
37
36
|
|
|
38
37
|
save_selection(widget)
|
|
39
|
-
|
|
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
|
|
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
|
|
31
|
+
return nil unless result&.handled?
|
|
32
32
|
|
|
33
33
|
save_selection(selection.selected_index)
|
|
34
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
111
|
+
return Result.handled unless state[:errors].empty?
|
|
112
112
|
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
10
|
-
# action was found. Returns nil
|
|
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
|
-
|
|
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
|
-
#
|
|
58
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
32
|
-
#
|
|
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 `
|
|
9
|
-
# and navigation, nil otherwise. *max_selections* optionally caps how many
|
|
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
|
|
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
|
|
62
|
+
return Result.handled if @max_selections && selected_indices.length >= @max_selections
|
|
56
63
|
|
|
57
64
|
selected_indices << index
|
|
58
65
|
end
|
|
59
|
-
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
9
|
-
#
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
45
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
57
|
+
# Returns Result.handled or Result.submitted(value) when the event was consumed, nil otherwise.
|
|
58
58
|
def handle_key(event)
|
|
59
|
-
return
|
|
59
|
+
return Result.handled if character_event?(event) && insert(event.char)
|
|
60
60
|
|
|
61
61
|
key = Charming.key_of(event)
|
|
62
|
-
return
|
|
63
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
# `
|
|
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 (`
|
|
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
|
-
|
|
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
|
|
116
|
+
return Result.selected(node) unless branch?(node)
|
|
108
117
|
|
|
109
118
|
toggle(node)
|
|
110
|
-
|
|
119
|
+
Result.handled
|
|
111
120
|
end
|
|
112
121
|
|
|
113
122
|
def toggle(node)
|