charming 0.1.2 → 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/lib/charming/application.rb +3 -3
- data/lib/charming/controller/class_methods.rb +2 -2
- data/lib/charming/controller/command_palette.rb +2 -2
- data/lib/charming/controller/rendering.rb +2 -2
- data/lib/charming/controller/session_state.rb +1 -1
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/templates/app/application.template +1 -1
- data/lib/charming/generators/templates/app/layout.template +3 -6
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/generators/templates/component/component.rb.template +1 -1
- data/lib/charming/generators/templates/screen/view.rb.template +1 -1
- data/lib/charming/generators/templates/view/view.rb.template +1 -1
- data/lib/charming/internal/renderer/differential.rb +13 -5
- data/lib/charming/internal/terminal/tty_backend.rb +22 -2
- 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 -49
- data/lib/charming/presentation/components/form/builder.rb +52 -54
- data/lib/charming/presentation/components/form/confirm.rb +49 -51
- data/lib/charming/presentation/components/form/field.rb +94 -96
- data/lib/charming/presentation/components/form/input.rb +53 -55
- data/lib/charming/presentation/components/form/note.rb +27 -29
- data/lib/charming/presentation/components/form/select.rb +84 -86
- data/lib/charming/presentation/components/form/textarea.rb +67 -69
- data/lib/charming/presentation/components/form.rb +120 -122
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -125
- data/lib/charming/presentation/components/markdown.rb +21 -23
- data/lib/charming/presentation/components/modal.rb +46 -48
- data/lib/charming/presentation/components/progressbar.rb +51 -53
- data/lib/charming/presentation/components/spinner.rb +40 -42
- data/lib/charming/presentation/components/table.rb +109 -111
- data/lib/charming/presentation/components/text_area.rb +219 -221
- data/lib/charming/presentation/components/text_input.rb +120 -122
- data/lib/charming/presentation/components/viewport.rb +218 -220
- data/lib/charming/presentation/layout/builder.rb +64 -66
- data/lib/charming/presentation/layout/overlay.rb +48 -50
- data/lib/charming/presentation/layout/pane.rb +122 -118
- data/lib/charming/presentation/layout/rect.rb +14 -16
- data/lib/charming/presentation/layout/screen_layout.rb +40 -42
- data/lib/charming/presentation/layout/split.rb +101 -103
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +94 -96
- data/lib/charming/presentation/markdown/inline_renderers.rb +52 -54
- data/lib/charming/presentation/markdown/render_context.rb +12 -14
- data/lib/charming/presentation/markdown/renderer.rb +84 -86
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +57 -59
- data/lib/charming/presentation/markdown.rb +4 -6
- data/lib/charming/presentation/template_view.rb +22 -24
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -49
- data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
- data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +37 -39
- data/lib/charming/presentation/ui/canvas.rb +59 -61
- data/lib/charming/presentation/ui/style.rb +173 -175
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +69 -71
- data/lib/charming/presentation/view.rb +103 -105
- data/lib/charming/runtime.rb +23 -10
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +3 -2
- metadata +2 -1
|
@@ -1,266 +1,264 @@
|
|
|
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
|
-
end
|
|
4
|
+
module Components
|
|
5
|
+
# TextArea is a multi-line text editor component. Supports character insertion (with
|
|
6
|
+
# newline insertion via Shift+Enter or Ctrl+J), cursor movement (left/right/up/down,
|
|
7
|
+
# home/end, page up/down), deletion (backspace/delete), and scrolling for long buffers.
|
|
8
|
+
# Vertical movement preserves a "preferred column" so left/right navigation feels stable.
|
|
9
|
+
class TextArea < Component
|
|
10
|
+
# The current text value, cursor byte offset, top-visible row offset, and remembered
|
|
11
|
+
# column for vertical navigation, respectively.
|
|
12
|
+
attr_reader :value, :cursor, :offset, :preferred_column
|
|
13
|
+
|
|
14
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty. *width* and
|
|
15
|
+
# *height* constrain the rendered output. *cursor* defaults to the end of the value.
|
|
16
|
+
# *offset* is the top-visible row. *preferred_column* is the column to resume at on
|
|
17
|
+
# vertical movement (defaults to the current column on first use).
|
|
18
|
+
def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
|
|
19
|
+
super()
|
|
20
|
+
@value = value.dup
|
|
21
|
+
@placeholder = placeholder
|
|
22
|
+
@width = width
|
|
23
|
+
@height = height
|
|
24
|
+
@cursor = cursor || @value.length
|
|
25
|
+
@offset = offset
|
|
26
|
+
@preferred_column = preferred_column
|
|
27
|
+
clamp_position
|
|
28
|
+
ensure_cursor_visible
|
|
29
|
+
end
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
31
|
+
# Routes key events to the appropriate cursor/text mutation. Returns :handled when the
|
|
32
|
+
# event was consumed, nil otherwise.
|
|
33
|
+
def handle_key(event)
|
|
34
|
+
key = Charming.key_of(event)
|
|
35
|
+
return :handled if newline_event?(event) && insert("\n")
|
|
36
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
37
|
+
|
|
38
|
+
case key
|
|
39
|
+
when :left then move_left
|
|
40
|
+
when :right then move_right
|
|
41
|
+
when :up then move_up
|
|
42
|
+
when :down then move_down
|
|
43
|
+
when :home then move_home
|
|
44
|
+
when :end then move_end
|
|
45
|
+
when :backspace then delete_before_cursor
|
|
46
|
+
when :delete then delete_at_cursor
|
|
47
|
+
when :page_up then page_up
|
|
48
|
+
when :page_down then page_down
|
|
49
|
+
else return nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
:handled
|
|
53
|
+
end
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
# Renders the visible portion of the text buffer (scrolled to `offset`), with each
|
|
56
|
+
# visible line either clipped to `width` or padded to it.
|
|
57
|
+
def render
|
|
58
|
+
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
59
|
+
end
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
private
|
|
63
62
|
|
|
64
|
-
|
|
63
|
+
attr_reader :placeholder, :width, :height
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
# True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
|
|
66
|
+
def newline_event?(event)
|
|
67
|
+
key = Charming.key_of(event)
|
|
68
|
+
return true if key == :enter && event.respond_to?(:shift) && event.shift
|
|
69
|
+
return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
false
|
|
72
|
+
end
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
# True when *event* carries a single printable character.
|
|
75
|
+
def character_event?(event)
|
|
76
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
77
|
+
end
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
# True when *char* is not a control character.
|
|
80
|
+
def printable?(char)
|
|
81
|
+
!char.match?(/[[:cntrl:]]/)
|
|
82
|
+
end
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
# Inserts *text* at the cursor, advances the cursor by its length, resets the preferred
|
|
85
|
+
# column, and ensures the cursor remains visible.
|
|
86
|
+
def insert(text)
|
|
87
|
+
@value = value[0...cursor].to_s + text + value[cursor..].to_s
|
|
88
|
+
@cursor += text.length
|
|
89
|
+
reset_preferred_column
|
|
90
|
+
ensure_cursor_visible
|
|
91
|
+
end
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
# Moves the cursor one character left.
|
|
94
|
+
def move_left
|
|
95
|
+
@cursor -= 1 if cursor.positive?
|
|
96
|
+
reset_preferred_column
|
|
97
|
+
ensure_cursor_visible
|
|
98
|
+
end
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
# Moves the cursor one character right.
|
|
101
|
+
def move_right
|
|
102
|
+
@cursor += 1 if cursor < value.length
|
|
103
|
+
reset_preferred_column
|
|
104
|
+
ensure_cursor_visible
|
|
105
|
+
end
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
# Moves the cursor up one line while preserving the preferred column.
|
|
108
|
+
def move_up
|
|
109
|
+
move_vertical(-1)
|
|
110
|
+
end
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
# Moves the cursor down one line while preserving the preferred column.
|
|
113
|
+
def move_down
|
|
114
|
+
move_vertical(+1)
|
|
115
|
+
end
|
|
117
116
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
# Moves the cursor to the start of the current line.
|
|
118
|
+
def move_home
|
|
119
|
+
row, = cursor_position
|
|
120
|
+
@cursor = line_start(row)
|
|
121
|
+
reset_preferred_column
|
|
122
|
+
ensure_cursor_visible
|
|
123
|
+
end
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
# Moves the cursor to the end of the current line.
|
|
126
|
+
def move_end
|
|
127
|
+
row, = cursor_position
|
|
128
|
+
@cursor = line_start(row) + line_length(row)
|
|
129
|
+
reset_preferred_column
|
|
130
|
+
ensure_cursor_visible
|
|
131
|
+
end
|
|
133
132
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
# Deletes the character before the cursor (backspace behavior).
|
|
134
|
+
def delete_before_cursor
|
|
135
|
+
return if cursor.zero?
|
|
137
136
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
@value = value[0...(cursor - 1)].to_s + value[cursor..].to_s
|
|
138
|
+
@cursor -= 1
|
|
139
|
+
reset_preferred_column
|
|
140
|
+
ensure_cursor_visible
|
|
141
|
+
end
|
|
143
142
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
# Deletes the character at the cursor (delete-key behavior).
|
|
144
|
+
def delete_at_cursor
|
|
145
|
+
return if cursor >= value.length
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
@value = value[0...cursor].to_s + value[(cursor + 1)..].to_s
|
|
148
|
+
reset_preferred_column
|
|
149
|
+
ensure_cursor_visible
|
|
150
|
+
end
|
|
152
151
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
152
|
+
# Scrolls the buffer up by one viewport height.
|
|
153
|
+
def page_up
|
|
154
|
+
@offset -= viewport_height
|
|
155
|
+
clamp_offset
|
|
156
|
+
end
|
|
158
157
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
# Scrolls the buffer down by one viewport height.
|
|
159
|
+
def page_down
|
|
160
|
+
@offset += viewport_height
|
|
161
|
+
clamp_offset
|
|
162
|
+
end
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
164
|
+
# Moves the cursor vertically by *delta* rows. Stays within the line count and uses
|
|
165
|
+
# `preferred_column` so up/down movement feels stable on short lines.
|
|
166
|
+
def move_vertical(delta)
|
|
167
|
+
row, column = cursor_position
|
|
168
|
+
target_row = (row + delta).clamp(0, lines.length - 1)
|
|
169
|
+
@preferred_column ||= column
|
|
170
|
+
@cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
|
|
171
|
+
ensure_cursor_visible
|
|
172
|
+
end
|
|
174
173
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
174
|
+
# Sets the preferred column to the current column (called when horizontal movement happens).
|
|
175
|
+
def reset_preferred_column
|
|
176
|
+
@preferred_column = cursor_position.last
|
|
177
|
+
end
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
179
|
+
# Returns the cursor's current position as `[row, column]`, where row is the zero-based
|
|
180
|
+
# line index and column is the character offset within that line.
|
|
181
|
+
def cursor_position
|
|
182
|
+
before = value[0...cursor].to_s
|
|
183
|
+
row = before.count("\n")
|
|
184
|
+
last_newline = before.rindex("\n")
|
|
185
|
+
column = last_newline ? before.length - last_newline - 1 : before.length
|
|
186
|
+
[row, column]
|
|
187
|
+
end
|
|
189
188
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
# Returns the byte offset where line *row* begins in the value.
|
|
190
|
+
def line_start(row)
|
|
191
|
+
lines.first(row).sum(&:length) + row
|
|
192
|
+
end
|
|
194
193
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
# Returns the character length of the line at *row* (empty string when row is past the end).
|
|
195
|
+
def line_length(row)
|
|
196
|
+
lines.fetch(row, "").length
|
|
197
|
+
end
|
|
199
198
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
# Splits the value into an array of lines (preserving trailing empty lines).
|
|
200
|
+
def lines
|
|
201
|
+
value.empty? ? [""] : value.split("\n", -1)
|
|
202
|
+
end
|
|
204
203
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
# Returns the rendered lines (with cursor marker inserted) before viewport slicing.
|
|
205
|
+
def rendered_lines
|
|
206
|
+
return [cursor_marker + placeholder] if value.empty?
|
|
208
207
|
|
|
209
|
-
|
|
210
|
-
|
|
208
|
+
(value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
|
|
209
|
+
end
|
|
211
210
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
211
|
+
# Returns the lines that should be visible in the current viewport, padded to *height*
|
|
212
|
+
# with empty strings when the buffer is shorter.
|
|
213
|
+
def visible_lines
|
|
214
|
+
ensure_cursor_visible
|
|
215
|
+
rendered = rendered_lines.slice(offset, viewport_height) || []
|
|
216
|
+
return rendered unless height
|
|
218
217
|
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
rendered + Array.new([height - rendered.length, 0].max, "")
|
|
219
|
+
end
|
|
221
220
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
# Renders a single line, clipping to *width* and padding with spaces.
|
|
222
|
+
def render_line(line)
|
|
223
|
+
return line unless width
|
|
225
224
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
clipped = UI.visible_slice(line, 0, width)
|
|
226
|
+
clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
|
|
227
|
+
end
|
|
229
228
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
229
|
+
# Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
|
|
230
|
+
# one row at a time when needed.
|
|
231
|
+
def ensure_cursor_visible
|
|
232
|
+
row, = cursor_position
|
|
233
|
+
@offset = row if row < offset
|
|
234
|
+
@offset = row - viewport_height + 1 if row >= offset + viewport_height
|
|
235
|
+
clamp_offset
|
|
236
|
+
end
|
|
238
237
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
238
|
+
# Clamps the cursor and offset to valid bounds.
|
|
239
|
+
def clamp_position
|
|
240
|
+
@cursor = cursor.clamp(0, value.length)
|
|
241
|
+
clamp_offset
|
|
242
|
+
end
|
|
244
243
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
244
|
+
# Clamps the offset to the valid range `[0, max_offset]`.
|
|
245
|
+
def clamp_offset
|
|
246
|
+
@offset = offset.clamp(0, max_offset)
|
|
247
|
+
end
|
|
249
248
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
249
|
+
# Returns the maximum allowed offset (so the bottom of the buffer is reachable).
|
|
250
|
+
def max_offset
|
|
251
|
+
[lines.length - viewport_height, 0].max
|
|
252
|
+
end
|
|
254
253
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
# Returns the visible row count (the configured *height* or the buffer's line count).
|
|
255
|
+
def viewport_height
|
|
256
|
+
height || lines.length
|
|
257
|
+
end
|
|
259
258
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
end
|
|
259
|
+
# The literal character used to mark the cursor position in `rendered_lines`.
|
|
260
|
+
def cursor_marker
|
|
261
|
+
"|"
|
|
264
262
|
end
|
|
265
263
|
end
|
|
266
264
|
end
|