charming 0.2.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +41 -3
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- metadata +63 -50
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Tree renders a collapsible hierarchy (file explorers, nested data). Nodes are
|
|
6
|
+
# hashes: `{label: "src", children: [...], expanded: true}` — `children` and
|
|
7
|
+
# `expanded` are optional. Navigation: up/down move the cursor through *visible*
|
|
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.
|
|
11
|
+
class Tree < Component
|
|
12
|
+
include KeyboardHandler
|
|
13
|
+
|
|
14
|
+
KEY_ACTIONS = {
|
|
15
|
+
up: :move_up,
|
|
16
|
+
down: :move_down,
|
|
17
|
+
left: :collapse_or_parent,
|
|
18
|
+
right: :expand,
|
|
19
|
+
home: :move_home,
|
|
20
|
+
end: :move_end
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# The root node list and the cursor index into the visible-node list.
|
|
24
|
+
attr_reader :nodes, :cursor_index
|
|
25
|
+
|
|
26
|
+
# *nodes* is the array of root node hashes (mutated in place to track expansion).
|
|
27
|
+
# *height* optionally constrains the visible window.
|
|
28
|
+
def initialize(nodes:, cursor_index: 0, height: nil, keymap: :vim, theme: nil)
|
|
29
|
+
super(theme: theme)
|
|
30
|
+
@nodes = nodes
|
|
31
|
+
@cursor_index = cursor_index
|
|
32
|
+
@height = height
|
|
33
|
+
@keymap = keymap
|
|
34
|
+
clamp_cursor
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Enter selects a leaf (`[:selected, node]`) or toggles a branch. Navigation keys
|
|
38
|
+
# are handled by KeyboardHandler.
|
|
39
|
+
def handle_key(event)
|
|
40
|
+
node = current_node
|
|
41
|
+
return nil unless node
|
|
42
|
+
return select_or_toggle(node) if Charming.key_of(event) == :enter
|
|
43
|
+
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A click moves the cursor to the clicked row; clicking a branch toggles it.
|
|
48
|
+
def handle_mouse(event)
|
|
49
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
50
|
+
|
|
51
|
+
clicked = viewport_start + event.y
|
|
52
|
+
return nil if clicked >= visible_nodes.length || event.y.negative?
|
|
53
|
+
|
|
54
|
+
@cursor_index = clicked
|
|
55
|
+
node = current_node
|
|
56
|
+
toggle(node) if branch?(node)
|
|
57
|
+
:handled
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The node under the cursor (a node hash), or nil for an empty tree.
|
|
61
|
+
def current_node
|
|
62
|
+
visible_nodes[cursor_index]&.fetch(:node)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Renders the visible window of the flattened tree.
|
|
66
|
+
def render
|
|
67
|
+
window = visible_nodes[viewport_start, viewport_height] || []
|
|
68
|
+
window.each_with_index.map do |entry, index|
|
|
69
|
+
render_node(entry, viewport_start + index)
|
|
70
|
+
end.join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Flattens the tree into visible rows: `{node:, depth:}` entries, skipping
|
|
76
|
+
# children of collapsed branches.
|
|
77
|
+
def visible_nodes
|
|
78
|
+
flatten(nodes, 0)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def flatten(list, depth)
|
|
82
|
+
Array(list).flat_map do |node|
|
|
83
|
+
entry = [{node: node, depth: depth}]
|
|
84
|
+
entry += flatten(node[:children], depth + 1) if branch?(node) && node[:expanded]
|
|
85
|
+
entry
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Renders one row: indentation, expansion marker, label; cursor row uses the
|
|
90
|
+
# selected style.
|
|
91
|
+
def render_node(entry, index)
|
|
92
|
+
node = entry.fetch(:node)
|
|
93
|
+
marker = if branch?(node)
|
|
94
|
+
node[:expanded] ? "▾ " : "▸ "
|
|
95
|
+
else
|
|
96
|
+
" "
|
|
97
|
+
end
|
|
98
|
+
line = "#{" " * entry.fetch(:depth)}#{marker}#{node[:label]}"
|
|
99
|
+
(index == cursor_index) ? theme.selected.render(line) : line
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def branch?(node)
|
|
103
|
+
node && node[:children] && !node[:children].empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def select_or_toggle(node)
|
|
107
|
+
return [:selected, node] unless branch?(node)
|
|
108
|
+
|
|
109
|
+
toggle(node)
|
|
110
|
+
:handled
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def toggle(node)
|
|
114
|
+
node[:expanded] = !node[:expanded]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def expand
|
|
118
|
+
node = current_node
|
|
119
|
+
node[:expanded] = true if branch?(node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Collapses an expanded branch, or moves the cursor to the parent of a leaf or
|
|
123
|
+
# collapsed node.
|
|
124
|
+
def collapse_or_parent
|
|
125
|
+
node = current_node
|
|
126
|
+
if branch?(node) && node[:expanded]
|
|
127
|
+
node[:expanded] = false
|
|
128
|
+
else
|
|
129
|
+
parent = parent_index(cursor_index)
|
|
130
|
+
@cursor_index = parent if parent
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Index of the nearest row above with a smaller depth (the parent), or nil.
|
|
135
|
+
def parent_index(index)
|
|
136
|
+
rows = visible_nodes
|
|
137
|
+
depth = rows[index]&.fetch(:depth)
|
|
138
|
+
return nil unless depth&.positive?
|
|
139
|
+
|
|
140
|
+
(index - 1).downto(0).find { |candidate| rows[candidate].fetch(:depth) < depth }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def move_up
|
|
144
|
+
@cursor_index -= 1 if cursor_index.positive?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def move_down
|
|
148
|
+
@cursor_index += 1 if cursor_index < visible_nodes.length - 1
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def move_home
|
|
152
|
+
@cursor_index = 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def move_end
|
|
156
|
+
@cursor_index = [visible_nodes.length - 1, 0].max
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Top row of the visible window, keeping the cursor in view.
|
|
160
|
+
def viewport_start
|
|
161
|
+
return 0 unless @height
|
|
162
|
+
|
|
163
|
+
Layout.selected_window_start(selected_index: cursor_index, item_count: visible_nodes.length, window_size: @height)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def viewport_height
|
|
167
|
+
@height || visible_nodes.length
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def clamp_cursor
|
|
171
|
+
max = [visible_nodes.length - 1, 0].max
|
|
172
|
+
@cursor_index = cursor_index.clamp(0, max)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
class Viewport
|
|
6
|
+
# ContentLines normalizes viewport content into display lines.
|
|
7
|
+
class ContentLines
|
|
8
|
+
def initialize(content:, width:, wrap:)
|
|
9
|
+
@content = content
|
|
10
|
+
@window_width = width
|
|
11
|
+
@wrap = wrap
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def lines
|
|
15
|
+
return wrapped_lines if wrap?
|
|
16
|
+
|
|
17
|
+
rendered_content.lines(chomp: true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def display_width
|
|
21
|
+
UI::Width.widest(lines)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :content, :window_width, :wrap
|
|
27
|
+
|
|
28
|
+
def wrapped_lines
|
|
29
|
+
rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def wrap_line(line)
|
|
33
|
+
line_width = UI::Width.measure(line)
|
|
34
|
+
return [""] if line_width.zero?
|
|
35
|
+
|
|
36
|
+
wrap_slices(line, line_width)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wrap_slices(line, line_width)
|
|
40
|
+
(0...line_width).step(window_width).map do |start_column|
|
|
41
|
+
UI.visible_slice(line, start_column, window_width)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rendered_content
|
|
46
|
+
content.respond_to?(:render) ? content.render.to_s : content.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wrap?
|
|
50
|
+
wrap && window_width&.positive?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Components
|
|
7
|
+
class Viewport
|
|
8
|
+
# LineWindow renders one content line inside the viewport's horizontal window.
|
|
9
|
+
class LineWindow
|
|
10
|
+
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
11
|
+
|
|
12
|
+
def initialize(width:, column:, wrap:)
|
|
13
|
+
@width = width
|
|
14
|
+
@column = column
|
|
15
|
+
@wrap = wrap
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(line)
|
|
19
|
+
return line unless width
|
|
20
|
+
return pad_line(line, width) if wrap
|
|
21
|
+
|
|
22
|
+
pad_line(clip_line(line), width)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :width, :column, :wrap
|
|
28
|
+
|
|
29
|
+
def clip_line(line)
|
|
30
|
+
clipped = clip_tokens(line.to_s)
|
|
31
|
+
needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clip_tokens(line)
|
|
35
|
+
state = {cursor: 0, output: +""}
|
|
36
|
+
line.scan(/#{ANSI_PATTERN}|./mo) do |token|
|
|
37
|
+
ansi?(token) ? append_ansi(state, token) : append_character(state, token)
|
|
38
|
+
end
|
|
39
|
+
state.fetch(:output)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def append_ansi(state, token)
|
|
43
|
+
state.fetch(:output) << token
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def append_character(state, char)
|
|
47
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
48
|
+
cursor = state.fetch(:cursor)
|
|
49
|
+
state.fetch(:output) << char if visible?(cursor, char_width)
|
|
50
|
+
state[:cursor] = cursor + char_width
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def visible?(cursor, char_width)
|
|
54
|
+
cursor >= column && cursor + char_width <= column + width
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def needs_reset?(value)
|
|
58
|
+
value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def pad_line(line, target_width)
|
|
62
|
+
UI::Width.pad_to(line, target_width)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ansi?(token)
|
|
66
|
+
token.match?(ANSI_PATTERN)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
class Viewport
|
|
6
|
+
# Position owns the viewport's mutable row and column offsets.
|
|
7
|
+
class Position
|
|
8
|
+
attr_reader :offset, :column
|
|
9
|
+
|
|
10
|
+
def initialize(offset:, column:)
|
|
11
|
+
@offset = offset
|
|
12
|
+
@column = column
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def scroll_up(bounds)
|
|
16
|
+
@offset -= 1
|
|
17
|
+
clamp(bounds)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scroll_down(bounds)
|
|
21
|
+
@offset += 1
|
|
22
|
+
clamp(bounds)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def page_up(page_size, bounds)
|
|
26
|
+
@offset -= page_size
|
|
27
|
+
clamp(bounds)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def page_down(page_size, bounds)
|
|
31
|
+
@offset += page_size
|
|
32
|
+
clamp(bounds)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def home
|
|
36
|
+
@offset = 0
|
|
37
|
+
@column = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def end_at(bounds)
|
|
41
|
+
@offset = bounds.fetch(:max_offset)
|
|
42
|
+
@column = bounds.fetch(:max_column)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def scroll_left(bounds)
|
|
46
|
+
@column -= 1
|
|
47
|
+
clamp(bounds)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scroll_right(bounds)
|
|
51
|
+
@column += 1
|
|
52
|
+
clamp(bounds)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def move_to(row, bounds)
|
|
56
|
+
@offset = row
|
|
57
|
+
clamp(bounds)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clamp(bounds)
|
|
61
|
+
@offset = offset.clamp(0, bounds.fetch(:max_offset))
|
|
62
|
+
@column = column.clamp(0, bounds.fetch(:max_column))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "unicode/display_width"
|
|
4
|
-
|
|
5
3
|
module Charming
|
|
6
4
|
module Components
|
|
7
5
|
# Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
|
|
@@ -12,9 +10,6 @@ module Charming
|
|
|
12
10
|
class Viewport < Component
|
|
13
11
|
include KeyboardHandler
|
|
14
12
|
|
|
15
|
-
# Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
|
|
16
|
-
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
17
|
-
|
|
18
13
|
# Maps scroll keys to the instance methods that perform them via KeyboardHandler.
|
|
19
14
|
KEY_ACTIONS = {
|
|
20
15
|
up: :scroll_up,
|
|
@@ -27,9 +22,6 @@ module Charming
|
|
|
27
22
|
right: :scroll_right
|
|
28
23
|
}.freeze
|
|
29
24
|
|
|
30
|
-
# The current top-visible row and left-visible column, respectively.
|
|
31
|
-
attr_reader :offset, :column
|
|
32
|
-
|
|
33
25
|
# *content* may be a string, an array of lines, or any object responding to `render`.
|
|
34
26
|
# *width* and *height* constrain the visible window; *offset* is the top-visible row
|
|
35
27
|
# and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
|
|
@@ -38,11 +30,10 @@ module Charming
|
|
|
38
30
|
@content = content
|
|
39
31
|
@width = width
|
|
40
32
|
@height = height
|
|
41
|
-
@
|
|
42
|
-
@column = column
|
|
33
|
+
@position = Position.new(offset: offset, column: column)
|
|
43
34
|
@wrap = wrap
|
|
44
35
|
@keymap = keymap
|
|
45
|
-
|
|
36
|
+
position.clamp(bounds)
|
|
46
37
|
end
|
|
47
38
|
|
|
48
39
|
# Renders the visible window of content as a multi-line string.
|
|
@@ -50,6 +41,16 @@ module Charming
|
|
|
50
41
|
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
51
42
|
end
|
|
52
43
|
|
|
44
|
+
# The current top-visible row.
|
|
45
|
+
def offset
|
|
46
|
+
@position.offset
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The current left-visible column.
|
|
50
|
+
def column
|
|
51
|
+
@position.column
|
|
52
|
+
end
|
|
53
|
+
|
|
53
54
|
# Handles mouse events: scroll wheel adjusts the row offset, click moves the top
|
|
54
55
|
# visible row to the clicked position. Returns :handled on success.
|
|
55
56
|
def handle_mouse(event)
|
|
@@ -57,77 +58,61 @@ module Charming
|
|
|
57
58
|
|
|
58
59
|
if event.scroll?
|
|
59
60
|
scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
|
|
60
|
-
|
|
61
|
-
clamp_position
|
|
61
|
+
position.move_to(offset + scroll_delta, bounds)
|
|
62
62
|
return :handled
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
return nil unless event.click?
|
|
66
66
|
|
|
67
67
|
clicked_row = event.y
|
|
68
|
-
return nil if clicked_row <
|
|
68
|
+
return nil if clicked_row < 0 || clicked_row >= viewport_height
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
clamp_position
|
|
70
|
+
position.move_to(offset + clicked_row, bounds)
|
|
72
71
|
:handled
|
|
73
72
|
end
|
|
74
73
|
|
|
75
74
|
private
|
|
76
75
|
|
|
77
|
-
attr_reader :content, :width, :height
|
|
76
|
+
attr_reader :content, :width, :height, :position
|
|
78
77
|
|
|
79
78
|
# Scrolls the viewport up by one row.
|
|
80
79
|
def scroll_up
|
|
81
|
-
|
|
82
|
-
clamp_position
|
|
80
|
+
position.scroll_up(bounds)
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
# Scrolls the viewport down by one row.
|
|
86
84
|
def scroll_down
|
|
87
|
-
|
|
88
|
-
clamp_position
|
|
85
|
+
position.scroll_down(bounds)
|
|
89
86
|
end
|
|
90
87
|
|
|
91
88
|
# Scrolls up by one viewport page.
|
|
92
89
|
def page_up
|
|
93
|
-
|
|
94
|
-
clamp_position
|
|
90
|
+
position.page_up(page_size, bounds)
|
|
95
91
|
end
|
|
96
92
|
|
|
97
93
|
# Scrolls down by one viewport page.
|
|
98
94
|
def page_down
|
|
99
|
-
|
|
100
|
-
clamp_position
|
|
95
|
+
position.page_down(page_size, bounds)
|
|
101
96
|
end
|
|
102
97
|
|
|
103
98
|
# Scrolls to the top-left of the content.
|
|
104
99
|
def scroll_home
|
|
105
|
-
|
|
106
|
-
@column = 0
|
|
100
|
+
position.home
|
|
107
101
|
end
|
|
108
102
|
|
|
109
103
|
# Scrolls to the bottom-right of the content.
|
|
110
104
|
def scroll_end
|
|
111
|
-
|
|
112
|
-
@column = max_column
|
|
105
|
+
position.end_at(bounds)
|
|
113
106
|
end
|
|
114
107
|
|
|
115
108
|
# Scrolls one column left.
|
|
116
109
|
def scroll_left
|
|
117
|
-
|
|
118
|
-
clamp_position
|
|
110
|
+
position.scroll_left(bounds)
|
|
119
111
|
end
|
|
120
112
|
|
|
121
113
|
# Scrolls one column right.
|
|
122
114
|
def scroll_right
|
|
123
|
-
|
|
124
|
-
clamp_position
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Clamps both the row offset and the column to their valid ranges.
|
|
128
|
-
def clamp_position
|
|
129
|
-
@offset = offset.clamp(0, max_offset)
|
|
130
|
-
@column = column.clamp(0, max_column)
|
|
115
|
+
position.scroll_right(bounds)
|
|
131
116
|
end
|
|
132
117
|
|
|
133
118
|
# Returns the slice of content lines visible in the current viewport, padded to *height*.
|
|
@@ -141,89 +126,12 @@ module Charming
|
|
|
141
126
|
# Renders a single line according to the configured width and wrap mode: clips to the
|
|
142
127
|
# visible column window when not wrapping, otherwise wraps the line to the width.
|
|
143
128
|
def render_line(line)
|
|
144
|
-
|
|
145
|
-
return pad_line(line, width) if wrap?
|
|
146
|
-
|
|
147
|
-
pad_line(clip_line(line), width)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# Clips *line* to the visible column window while preserving active ANSI styling.
|
|
151
|
-
def clip_line(line)
|
|
152
|
-
clipped = clip_tokens(line.to_s)
|
|
153
|
-
needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Walks *line* token-by-token, copying ANSI escapes through and emitting only the
|
|
157
|
-
# characters that fall inside the visible column window.
|
|
158
|
-
def clip_tokens(line)
|
|
159
|
-
state = {cursor: 0, output: +""}
|
|
160
|
-
line.scan(/#{ANSI_PATTERN}|./mo) do |token|
|
|
161
|
-
ansi?(token) ? append_ansi(state, token) : append_character(state, token)
|
|
162
|
-
end
|
|
163
|
-
state.fetch(:output)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
# Appends an ANSI escape token to the output buffer unchanged.
|
|
167
|
-
def append_ansi(state, token)
|
|
168
|
-
state.fetch(:output) << token
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Appends a single character token to the output buffer when it falls inside the
|
|
172
|
-
# visible column window, advancing the visual cursor.
|
|
173
|
-
def append_character(state, char)
|
|
174
|
-
char_width = Unicode::DisplayWidth.of(char)
|
|
175
|
-
cursor = state.fetch(:cursor)
|
|
176
|
-
state.fetch(:output) << char if visible?(cursor, char_width)
|
|
177
|
-
state[:cursor] = cursor + char_width
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# True when the character at *cursor* (with the given display *char_width*) is within
|
|
181
|
-
# the visible column window.
|
|
182
|
-
def visible?(cursor, char_width)
|
|
183
|
-
cursor >= column && cursor + char_width <= column + width
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# True when *value* contains ANSI codes but does not end with a reset — needed because
|
|
187
|
-
# the clip may truncate styling in the middle of a styled run.
|
|
188
|
-
def needs_reset?(value)
|
|
189
|
-
value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
|
|
193
|
-
def pad_line(line, target_width)
|
|
194
|
-
line + (" " * [target_width - UI::Width.measure(line), 0].max)
|
|
129
|
+
line_window.render(line)
|
|
195
130
|
end
|
|
196
131
|
|
|
197
132
|
# Returns the content lines, wrapped to *width* when wrap is enabled.
|
|
198
133
|
def content_lines
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
rendered_content.lines(chomp: true)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
# Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
|
|
205
|
-
def wrapped_content_lines
|
|
206
|
-
rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# Wraps a single *line* into chunks of *width* display columns.
|
|
210
|
-
def wrap_line(line)
|
|
211
|
-
line_width = UI::Width.measure(line)
|
|
212
|
-
return [""] if line_width.zero?
|
|
213
|
-
|
|
214
|
-
start_column = 0
|
|
215
|
-
out = []
|
|
216
|
-
while start_column < line_width
|
|
217
|
-
out << UI.visible_slice(line, start_column, width)
|
|
218
|
-
start_column += width
|
|
219
|
-
end
|
|
220
|
-
out
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# Returns the rendered content string, calling `render.to_s` on the content object when
|
|
224
|
-
# it responds to render.
|
|
225
|
-
def rendered_content
|
|
226
|
-
content.respond_to?(:render) ? content.render.to_s : content.to_s
|
|
134
|
+
content_source.lines
|
|
227
135
|
end
|
|
228
136
|
|
|
229
137
|
# Returns the visible row count (the configured *height* or the content's line count).
|
|
@@ -253,18 +161,25 @@ module Charming
|
|
|
253
161
|
|
|
254
162
|
# Returns the maximum display width across all content lines.
|
|
255
163
|
def content_width
|
|
256
|
-
|
|
164
|
+
content_source.display_width
|
|
257
165
|
end
|
|
258
166
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
token.match?(ANSI_PATTERN)
|
|
167
|
+
def bounds
|
|
168
|
+
{max_offset: max_offset, max_column: max_column}
|
|
262
169
|
end
|
|
263
170
|
|
|
264
171
|
# True when soft-wrapping is enabled and a positive width is configured.
|
|
265
172
|
def wrap?
|
|
266
173
|
@wrap && width&.positive?
|
|
267
174
|
end
|
|
175
|
+
|
|
176
|
+
def line_window
|
|
177
|
+
LineWindow.new(width: width, column: column, wrap: wrap?)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def content_source
|
|
181
|
+
ContentLines.new(content: content, width: width, wrap: @wrap)
|
|
182
|
+
end
|
|
268
183
|
end
|
|
269
184
|
end
|
|
270
185
|
end
|
|
@@ -41,7 +41,10 @@ module Charming
|
|
|
41
41
|
def pane(name = nil, content = nil, **options, &block)
|
|
42
42
|
node = Pane.new(
|
|
43
43
|
name: name, content: content, block: block, view: view,
|
|
44
|
-
geometry: PaneGeometry.build(**options.slice(
|
|
44
|
+
geometry: PaneGeometry.build(**options.slice(
|
|
45
|
+
:width, :height, :grow, :border, :padding,
|
|
46
|
+
:min_width, :max_width, :min_height, :max_height
|
|
47
|
+
)),
|
|
45
48
|
style: PaneStyle.build(**options.slice(:style, :focused_style)),
|
|
46
49
|
behavior: PaneBehavior.build(**options.slice(:focus, :scroll, :clip, :wrap))
|
|
47
50
|
)
|
|
@@ -8,13 +8,14 @@ module Charming
|
|
|
8
8
|
# with an outer *style*.
|
|
9
9
|
class Overlay
|
|
10
10
|
# The vertical and horizontal offset (cell count or `:center`) of the overlay
|
|
11
|
-
# within the parent canvas.
|
|
12
|
-
attr_reader :top, :left
|
|
11
|
+
# within the parent canvas, and its stacking order (higher paints later/on top).
|
|
12
|
+
attr_reader :top, :left, :z_index
|
|
13
13
|
|
|
14
14
|
# *content* (or a *block*) provides the body. *top*/*left* default to :center.
|
|
15
15
|
# *width*/*height* fix the overlay's dimensions; when unset, the content's natural
|
|
16
|
-
# size is used. *style* wraps the rendered content in a UI::Style.
|
|
17
|
-
|
|
16
|
+
# size is used. *style* wraps the rendered content in a UI::Style. *z_index*
|
|
17
|
+
# controls stacking: higher values composite on top (ties keep registration order).
|
|
18
|
+
def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil, z_index: 0)
|
|
18
19
|
@content = content
|
|
19
20
|
@block = block
|
|
20
21
|
@view = view
|
|
@@ -23,6 +24,7 @@ module Charming
|
|
|
23
24
|
@width = width
|
|
24
25
|
@height = height
|
|
25
26
|
@style = style
|
|
27
|
+
@z_index = z_index
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
# Renders the overlay's content; when *width* or *height* is set, places the rendered
|
|
@@ -8,7 +8,8 @@ module Charming
|
|
|
8
8
|
# focusable slots in the controller's focus ring.
|
|
9
9
|
class Pane
|
|
10
10
|
attr_reader :name
|
|
11
|
-
delegate :width, :height, :grow,
|
|
11
|
+
delegate :width, :height, :grow,
|
|
12
|
+
:min_width, :max_width, :min_height, :max_height, to: :geometry
|
|
12
13
|
|
|
13
14
|
# *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
|
|
14
15
|
# is the view used for instance_exec when the block is given. *geometry*, *style*, and
|