charming 0.1.1 → 0.1.3
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 +14 -3
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +110 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +25 -2
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +65 -115
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -36
- data/lib/charming/presentation/components/form/builder.rb +52 -40
- data/lib/charming/presentation/components/form/confirm.rb +50 -39
- data/lib/charming/presentation/components/form/field.rb +94 -71
- data/lib/charming/presentation/components/form/input.rb +61 -49
- data/lib/charming/presentation/components/form/note.rb +27 -20
- data/lib/charming/presentation/components/form/select.rb +84 -63
- data/lib/charming/presentation/components/form/textarea.rb +67 -53
- data/lib/charming/presentation/components/form.rb +120 -93
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -97
- data/lib/charming/presentation/components/markdown.rb +21 -17
- data/lib/charming/presentation/components/modal.rb +55 -43
- data/lib/charming/presentation/components/progressbar.rb +61 -50
- data/lib/charming/presentation/components/spinner.rb +35 -27
- data/lib/charming/presentation/components/table.rb +108 -85
- data/lib/charming/presentation/components/text_area.rb +219 -173
- data/lib/charming/presentation/components/text_input.rb +120 -98
- data/lib/charming/presentation/components/viewport.rb +218 -168
- data/lib/charming/presentation/layout/builder.rb +84 -0
- data/lib/charming/presentation/layout/overlay.rb +55 -0
- data/lib/charming/presentation/layout/pane.rb +149 -0
- data/lib/charming/presentation/layout/rect.rb +21 -0
- data/lib/charming/presentation/layout/screen_layout.rb +58 -0
- data/lib/charming/presentation/layout/split.rb +132 -0
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
- data/lib/charming/presentation/markdown/render_context.rb +20 -0
- data/lib/charming/presentation/markdown/renderer.rb +82 -174
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
- data/lib/charming/presentation/markdown.rb +4 -3
- data/lib/charming/presentation/template_view.rb +22 -17
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -32
- data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +56 -0
- data/lib/charming/presentation/ui/canvas.rb +80 -0
- data/lib/charming/presentation/ui/style.rb +170 -205
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +70 -213
- data/lib/charming/presentation/view.rb +107 -92
- data/lib/charming/runtime.rb +25 -10
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +16 -2
- metadata +60 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
|
@@ -1,104 +1,126 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
4
|
+
module Components
|
|
5
|
+
# TextInput is a single-line text editor component. Supports printable character insertion,
|
|
6
|
+
# cursor movement (left/right/home/end), and deletion (backspace/delete). The component
|
|
7
|
+
# exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
|
|
8
|
+
# is given, the rendered output is padded to that width via a UI::Style.
|
|
9
|
+
class TextInput < Component
|
|
10
|
+
include KeyboardHandler
|
|
11
|
+
|
|
12
|
+
# Maps editing keys (left/right/home/end/backspace/delete) to the instance
|
|
13
|
+
# methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
|
|
14
|
+
# maps to a method (e.g., :move_left) that adjusts cursor position or text content.
|
|
15
|
+
KEY_ACTIONS = {
|
|
16
|
+
left: :move_left,
|
|
17
|
+
right: :move_right,
|
|
18
|
+
home: :move_home,
|
|
19
|
+
end: :move_end,
|
|
20
|
+
backspace: :delete_before_cursor,
|
|
21
|
+
delete: :delete_at_cursor
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# The current input string and the byte offset of the cursor within it.
|
|
25
|
+
attr_reader :value, :cursor
|
|
26
|
+
|
|
27
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
28
|
+
# *width* optionally constrains the rendered output width; *cursor* defaults to the end.
|
|
29
|
+
def initialize(value: "", placeholder: "", width: nil, cursor: nil)
|
|
30
|
+
super()
|
|
31
|
+
@value = value.dup
|
|
32
|
+
@placeholder = placeholder
|
|
33
|
+
@width = width
|
|
34
|
+
@cursor = cursor || @value.length
|
|
35
|
+
clamp_position
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
|
|
39
|
+
# Returns :handled when the event was consumed, nil otherwise.
|
|
40
|
+
def handle_key(event)
|
|
41
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Renders the value with a cursor marker. When *width* was given at construction, the
|
|
47
|
+
# output is padded to that width via the configured style.
|
|
48
|
+
def render
|
|
49
|
+
rendered = render_value
|
|
50
|
+
@width ? style.width(@width).render(rendered) : rendered
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
attr_reader :placeholder
|
|
56
|
+
|
|
57
|
+
# True when *event* carries a single printable character that should be inserted.
|
|
58
|
+
def character_event?(event)
|
|
59
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# True when *char* is not a control character (and therefore safe to insert).
|
|
63
|
+
def printable?(char)
|
|
64
|
+
!char.match?(/[[:cntrl:]]/)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Inserts *char* at the cursor and advances the cursor by its byte length.
|
|
68
|
+
def insert(char)
|
|
69
|
+
@value = value[0...cursor] + char + value[cursor..]
|
|
70
|
+
@cursor += char.length
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Moves the cursor one position left, when possible.
|
|
74
|
+
def move_left
|
|
75
|
+
@cursor -= 1 if cursor.positive?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Moves the cursor one position right, when possible.
|
|
79
|
+
def move_right
|
|
80
|
+
@cursor += 1 if cursor < value.length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Moves the cursor to the start of the value.
|
|
84
|
+
def move_home
|
|
85
|
+
@cursor = 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Moves the cursor to the end of the value.
|
|
89
|
+
def move_end
|
|
90
|
+
@cursor = value.length
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Deletes the character before the cursor (backspace behavior).
|
|
94
|
+
def delete_before_cursor
|
|
95
|
+
return if cursor.zero?
|
|
96
|
+
|
|
97
|
+
@value = value[0...(cursor - 1)] + value[cursor..]
|
|
98
|
+
@cursor -= 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Deletes the character at the cursor (delete-key behavior).
|
|
102
|
+
def delete_at_cursor
|
|
103
|
+
return if cursor >= value.length
|
|
104
|
+
|
|
105
|
+
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Renders the value with a "|" cursor marker at the current position. When the value is
|
|
109
|
+
# empty, the placeholder is rendered instead, preceded by the cursor marker.
|
|
110
|
+
def render_value
|
|
111
|
+
return cursor_marker + placeholder if value.empty?
|
|
112
|
+
|
|
113
|
+
value[0...cursor] + cursor_marker + value[cursor..]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The literal character used to mark the cursor position in `render`.
|
|
117
|
+
def cursor_marker
|
|
118
|
+
"|"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Clamps the cursor to the valid range `[0, value.length]`.
|
|
122
|
+
def clamp_position
|
|
123
|
+
@cursor = cursor.clamp(0, value.length)
|
|
102
124
|
end
|
|
103
125
|
end
|
|
104
126
|
end
|
|
@@ -3,217 +3,267 @@
|
|
|
3
3
|
require "unicode/display_width"
|
|
4
4
|
|
|
5
5
|
module Charming
|
|
6
|
-
module
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
6
|
+
module Components
|
|
7
|
+
# Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
|
|
8
|
+
# (up/down/left/right, page up/down, home/end) and mouse interactions (scroll wheel and
|
|
9
|
+
# click-to-position). Lines are clipped with ANSI awareness via `UI::ANSISlicer` so styled
|
|
10
|
+
# text is preserved across horizontal scrolls. When `wrap:` is true, long lines are wrapped
|
|
11
|
+
# to the configured *width* before scrolling.
|
|
12
|
+
class Viewport < Component
|
|
13
|
+
include KeyboardHandler
|
|
14
|
+
|
|
15
|
+
# Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
|
|
16
|
+
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
17
|
+
|
|
18
|
+
# Maps scroll keys to the instance methods that perform them via KeyboardHandler.
|
|
19
|
+
KEY_ACTIONS = {
|
|
20
|
+
up: :scroll_up,
|
|
21
|
+
down: :scroll_down,
|
|
22
|
+
page_up: :page_up,
|
|
23
|
+
page_down: :page_down,
|
|
24
|
+
home: :scroll_home,
|
|
25
|
+
end: :scroll_end,
|
|
26
|
+
left: :scroll_left,
|
|
27
|
+
right: :scroll_right
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# The current top-visible row and left-visible column, respectively.
|
|
31
|
+
attr_reader :offset, :column
|
|
32
|
+
|
|
33
|
+
# *content* may be a string, an array of lines, or any object responding to `render`.
|
|
34
|
+
# *width* and *height* constrain the visible window; *offset* is the top-visible row
|
|
35
|
+
# and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
|
|
36
|
+
def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
|
|
37
|
+
super()
|
|
38
|
+
@content = content
|
|
39
|
+
@width = width
|
|
40
|
+
@height = height
|
|
41
|
+
@offset = offset
|
|
42
|
+
@column = column
|
|
43
|
+
@wrap = wrap
|
|
44
|
+
@keymap = keymap
|
|
45
|
+
clamp_position
|
|
46
|
+
end
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
# Renders the visible window of content as a multi-line string.
|
|
49
|
+
def render
|
|
50
|
+
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
51
|
+
end
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
# Handles mouse events: scroll wheel adjusts the row offset, click moves the top
|
|
54
|
+
# visible row to the clicked position. Returns :handled on success.
|
|
55
|
+
def handle_mouse(event)
|
|
56
|
+
return nil unless height
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
if event.scroll?
|
|
59
|
+
scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
|
|
60
|
+
@offset += scroll_delta
|
|
61
|
+
clamp_position
|
|
62
|
+
return :handled
|
|
63
|
+
end
|
|
50
64
|
|
|
51
|
-
|
|
65
|
+
return nil unless event.click?
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
clicked_row = event.y
|
|
68
|
+
return nil if clicked_row < offset || clicked_row >= offset + viewport_height
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
@offset = clicked_row
|
|
71
|
+
clamp_position
|
|
72
|
+
:handled
|
|
73
|
+
end
|
|
60
74
|
|
|
61
|
-
|
|
75
|
+
private
|
|
62
76
|
|
|
63
|
-
|
|
77
|
+
attr_reader :content, :width, :height
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
# Scrolls the viewport up by one row.
|
|
80
|
+
def scroll_up
|
|
81
|
+
@offset -= 1
|
|
82
|
+
clamp_position
|
|
83
|
+
end
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
# Scrolls the viewport down by one row.
|
|
86
|
+
def scroll_down
|
|
87
|
+
@offset += 1
|
|
88
|
+
clamp_position
|
|
89
|
+
end
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
# Scrolls up by one viewport page.
|
|
92
|
+
def page_up
|
|
93
|
+
@offset -= page_size
|
|
94
|
+
clamp_position
|
|
95
|
+
end
|
|
79
96
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
# Scrolls down by one viewport page.
|
|
98
|
+
def page_down
|
|
99
|
+
@offset += page_size
|
|
100
|
+
clamp_position
|
|
101
|
+
end
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
# Scrolls to the top-left of the content.
|
|
104
|
+
def scroll_home
|
|
105
|
+
@offset = 0
|
|
106
|
+
@column = 0
|
|
107
|
+
end
|
|
89
108
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
# Scrolls to the bottom-right of the content.
|
|
110
|
+
def scroll_end
|
|
111
|
+
@offset = max_offset
|
|
112
|
+
@column = max_column
|
|
113
|
+
end
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
# Scrolls one column left.
|
|
116
|
+
def scroll_left
|
|
117
|
+
@column -= 1
|
|
118
|
+
clamp_position
|
|
119
|
+
end
|
|
99
120
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
# Scrolls one column right.
|
|
122
|
+
def scroll_right
|
|
123
|
+
@column += 1
|
|
124
|
+
clamp_position
|
|
125
|
+
end
|
|
104
126
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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)
|
|
131
|
+
end
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
# Returns the slice of content lines visible in the current viewport, padded to *height*.
|
|
134
|
+
def visible_lines
|
|
135
|
+
lines = content_lines.slice(offset, viewport_height) || []
|
|
136
|
+
return lines unless height
|
|
113
137
|
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
lines + Array.new([height - lines.length, 0].max, "")
|
|
139
|
+
end
|
|
116
140
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
# Renders a single line according to the configured width and wrap mode: clips to the
|
|
142
|
+
# visible column window when not wrapping, otherwise wraps the line to the width.
|
|
143
|
+
def render_line(line)
|
|
144
|
+
return line unless width
|
|
145
|
+
return pad_line(line, width) if wrap?
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
pad_line(clip_line(line), width)
|
|
148
|
+
end
|
|
123
149
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
128
155
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
state
|
|
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)
|
|
135
162
|
end
|
|
163
|
+
state.fetch(:output)
|
|
164
|
+
end
|
|
136
165
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
# Appends an ANSI escape token to the output buffer unchanged.
|
|
167
|
+
def append_ansi(state, token)
|
|
168
|
+
state.fetch(:output) << token
|
|
169
|
+
end
|
|
140
170
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
179
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
185
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
155
191
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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)
|
|
195
|
+
end
|
|
159
196
|
|
|
160
|
-
|
|
161
|
-
|
|
197
|
+
# Returns the content lines, wrapped to *width* when wrap is enabled.
|
|
198
|
+
def content_lines
|
|
199
|
+
return wrapped_content_lines if wrap?
|
|
162
200
|
|
|
163
|
-
|
|
164
|
-
|
|
201
|
+
rendered_content.lines(chomp: true)
|
|
202
|
+
end
|
|
165
203
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
169
208
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
start_column = 0
|
|
175
|
-
out = []
|
|
176
|
-
while start_column < line_width
|
|
177
|
-
out << UI.visible_slice(line, start_column, width)
|
|
178
|
-
start_column += width
|
|
179
|
-
end
|
|
180
|
-
out
|
|
181
|
-
end
|
|
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?
|
|
182
213
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
185
219
|
end
|
|
220
|
+
out
|
|
221
|
+
end
|
|
186
222
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
227
|
+
end
|
|
190
228
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
229
|
+
# Returns the visible row count (the configured *height* or the content's line count).
|
|
230
|
+
def viewport_height
|
|
231
|
+
height || content_lines.length
|
|
232
|
+
end
|
|
194
233
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
234
|
+
# Returns the number of rows to advance on a page up/down: at least 1, otherwise the
|
|
235
|
+
# viewport height.
|
|
236
|
+
def page_size
|
|
237
|
+
[viewport_height, 1].max
|
|
238
|
+
end
|
|
198
239
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
240
|
+
# Returns the maximum allowed row offset (so the bottom of the content is reachable).
|
|
241
|
+
def max_offset
|
|
242
|
+
[content_lines.length - viewport_height, 0].max
|
|
243
|
+
end
|
|
202
244
|
|
|
203
|
-
|
|
204
|
-
|
|
245
|
+
# Returns the maximum allowed column offset. Returns 0 when wrapping is enabled or
|
|
246
|
+
# when no width is configured.
|
|
247
|
+
def max_column
|
|
248
|
+
return 0 if wrap?
|
|
249
|
+
return 0 unless width
|
|
205
250
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
end
|
|
251
|
+
[content_width - width, 0].max
|
|
252
|
+
end
|
|
209
253
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
254
|
+
# Returns the maximum display width across all content lines.
|
|
255
|
+
def content_width
|
|
256
|
+
content_lines.map { |line| UI::Width.measure(line) }.max || 0
|
|
257
|
+
end
|
|
213
258
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
259
|
+
# True when *token* is an ANSI escape sequence.
|
|
260
|
+
def ansi?(token)
|
|
261
|
+
token.match?(ANSI_PATTERN)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# True when soft-wrapping is enabled and a positive width is configured.
|
|
265
|
+
def wrap?
|
|
266
|
+
@wrap && width&.positive?
|
|
217
267
|
end
|
|
218
268
|
end
|
|
219
269
|
end
|