tuile 0.2.0 → 0.3.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.
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A read-only viewer for prose: chunks of formatted text that scroll
6
+ # vertically. Shape-wise a hybrid between {Label} (string-shaped content
7
+ # via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
8
+ #
9
+ # Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
10
+ # lines wider than the viewport are word-wrapped via {StyledString#wrap}
11
+ # (style spans are preserved across wrap boundaries — unlike the older
12
+ # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
13
+ # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
14
+ # so embedded ANSI is honored) or a {StyledString} directly; {#text}
15
+ # always returns the {StyledString}. Use {#append} for incremental "log
16
+ # line" style updates; turn on {#auto_scroll} to keep the latest content
17
+ # in view.
18
+ #
19
+ # TextView is meant to be the content of a {Window} — focus indication and
20
+ # keyboard-hint surfacing rely on the surrounding window chrome.
21
+ class TextView < Component
22
+ def initialize
23
+ super
24
+ # `@hard_lines` is the logical model — one entry per `\n`-delimited
25
+ # line of the original text, width-independent. `@physical_lines` is
26
+ # the rendered view — each hard line word-wrapped to `wrap_width`
27
+ # and padded with trailing blanks, so painting a row is a lookup.
28
+ # Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
29
+ # extends both.
30
+ @hard_lines = []
31
+ @physical_lines = []
32
+ @text = StyledString::EMPTY
33
+ @content_size = Size::ZERO
34
+ @blank_line = StyledString::EMPTY
35
+ @top_line = 0
36
+ @auto_scroll = false
37
+ @scrollbar_visibility = :gone
38
+ end
39
+
40
+ # @return [StyledString] the current text. Defaults to an empty
41
+ # {StyledString}. Internally the text is stored as an array of hard
42
+ # lines so {#append} can stay O(appended) instead of re-scanning the
43
+ # whole buffer; the joined {StyledString} returned here is
44
+ # reconstructed on first read after a mutation and cached, so
45
+ # repeated reads are O(1) but the first read after {#append} pays
46
+ # O(total spans).
47
+ def text
48
+ @text ||= build_text
49
+ end
50
+
51
+ # @return [Integer] index of the first visible physical line.
52
+ attr_reader :top_line
53
+
54
+ # @return [Symbol] `:gone` or `:visible`.
55
+ attr_reader :scrollbar_visibility
56
+
57
+ # @return [Boolean] if true, mutating the text scrolls the viewport so
58
+ # the last line stays in view. Default `false`.
59
+ attr_reader :auto_scroll
60
+
61
+ # Replaces the text. Embedded `\n` characters become hard line breaks.
62
+ # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
63
+ # honored); a `StyledString` is used as-is; `nil` is coerced to an
64
+ # empty {StyledString}.
65
+ # @param value [String, StyledString, nil]
66
+ # @return [void]
67
+ def text=(value)
68
+ new_text = StyledString.parse(value)
69
+ return if text == new_text
70
+
71
+ @text = new_text
72
+ @hard_lines = new_text.empty? ? [] : new_text.lines
73
+ @content_size = compute_content_size
74
+ rewrap
75
+ update_top_line_if_auto_scroll
76
+ invalidate
77
+ end
78
+
79
+ # Appends `str` as a new physical line. If the current text is empty,
80
+ # behaves like `text = str`; otherwise prepends a newline so the new
81
+ # content lands on a fresh line. Accepts the same input forms as
82
+ # {#text=}.
83
+ #
84
+ # Cost is O(appended) rather than O(total) — the existing wrapped
85
+ # buffer is reused, only the new hard line(s) are wrapped and padded,
86
+ # and `@content_size` is updated incrementally. The cached
87
+ # {#text} is invalidated and rebuilt on demand.
88
+ # @param str [String, StyledString, nil]
89
+ # @return [void]
90
+ def append(str)
91
+ screen.check_locked
92
+ appended = StyledString.parse(str)
93
+ if @hard_lines.empty?
94
+ self.text = appended
95
+ return
96
+ end
97
+
98
+ new_hard_lines = appended.lines
99
+ @text = nil
100
+ @hard_lines.concat(new_hard_lines)
101
+ new_width = new_hard_lines.map(&:display_width).max || 0
102
+ @content_size = Size.new(
103
+ [@content_size.width, new_width].max,
104
+ @content_size.height + new_hard_lines.size
105
+ )
106
+ width = wrap_width
107
+ new_hard_lines.each { |hl| append_physical_lines(hl, width) }
108
+ update_top_line_if_auto_scroll
109
+ invalidate
110
+ end
111
+
112
+ # Clears the text. Equivalent to `text = ""`.
113
+ # @return [void]
114
+ def clear
115
+ self.text = StyledString::EMPTY
116
+ end
117
+
118
+ # @param new_top_line [Integer] 0 or greater. Not clamped against the
119
+ # number of lines (matches {List#top_line=}).
120
+ # @return [void]
121
+ def top_line=(new_top_line)
122
+ raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
123
+ raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
124
+ return if @top_line == new_top_line
125
+
126
+ @top_line = new_top_line
127
+ invalidate
128
+ end
129
+
130
+ # @param value [Symbol] `:gone` or `:visible`.
131
+ # @return [void]
132
+ def scrollbar_visibility=(value)
133
+ raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
134
+ return if @scrollbar_visibility == value
135
+
136
+ @scrollbar_visibility = value
137
+ rewrap
138
+ invalidate
139
+ end
140
+
141
+ # Sets `auto_scroll`. If true, immediately scrolls to the bottom.
142
+ # @param value [Boolean]
143
+ # @return [void]
144
+ def auto_scroll=(value)
145
+ @auto_scroll = value ? true : false
146
+ update_top_line_if_auto_scroll
147
+ end
148
+
149
+ def focusable? = true
150
+
151
+ def tab_stop? = true
152
+
153
+ # @return [Size] longest hard-line's display width × number of hard
154
+ # lines. Reported on the *unwrapped* text — wrap-aware sizing would
155
+ # be circular (width depends on width). Empty text returns
156
+ # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
157
+ # {#append}, so reads are O(1).
158
+ attr_reader :content_size
159
+
160
+ # @param key [String]
161
+ # @return [Boolean]
162
+ def handle_key(key)
163
+ return false unless active?
164
+ return true if super
165
+
166
+ case key
167
+ when *Keys::DOWN_ARROWS then move_top_line_by(1)
168
+ when *Keys::UP_ARROWS then move_top_line_by(-1)
169
+ when Keys::PAGE_DOWN then move_top_line_by(viewport_lines)
170
+ when Keys::PAGE_UP then move_top_line_by(-viewport_lines)
171
+ when Keys::CTRL_D then move_top_line_by(viewport_lines / 2)
172
+ when Keys::CTRL_U then move_top_line_by(-viewport_lines / 2)
173
+ when *Keys::HOMES, "g" then move_top_line_to(0)
174
+ when *Keys::ENDS_, "G" then move_top_line_to(top_line_max)
175
+ else return false
176
+ end
177
+ true
178
+ end
179
+
180
+ # @param event [MouseEvent]
181
+ # @return [void]
182
+ def handle_mouse(event)
183
+ super
184
+ case event.button
185
+ when :scroll_down then move_top_line_by(4)
186
+ when :scroll_up then move_top_line_by(-4)
187
+ end
188
+ end
189
+
190
+ # Paints the text into {#rect}.
191
+ #
192
+ # Skips the {Component#repaint} default's auto-clear: every row is
193
+ # painted explicitly (with padded blanks past the last line), so the
194
+ # "fully draw over your rect" contract is met without an upfront wipe.
195
+ # @return [void]
196
+ def repaint
197
+ return if rect.empty?
198
+
199
+ scrollbar = if scrollbar_visible?
200
+ VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
201
+ end
202
+ (0...rect.height).each do |row|
203
+ line = paintable_line(row + @top_line, row, scrollbar)
204
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
205
+ end
206
+ end
207
+
208
+ protected
209
+
210
+ # Rewraps the text on width changes. Wrap width depends on
211
+ # {#rect}`.width` and the scrollbar gutter, both of which trigger
212
+ # this hook.
213
+ # @return [void]
214
+ def on_width_changed
215
+ super
216
+ rewrap
217
+ end
218
+
219
+ private
220
+
221
+ # @return [Integer] number of visible lines.
222
+ def viewport_lines = rect.height
223
+
224
+ # @return [Integer] the max value of {#top_line} for scroll-key clamping.
225
+ def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
226
+
227
+ # Recomputes {@physical_lines} for the current text and wrap width,
228
+ # pre-padding every line to `wrap_width` so {#paintable_line} is just
229
+ # a lookup + optional scrollbar-char append at paint time (and the
230
+ # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
231
+ # memoization, so re-painting on scroll is near-free). Clamps
232
+ # {@top_line} if the new line count puts it out of range.
233
+ # @return [void]
234
+ def rewrap
235
+ width = wrap_width
236
+ @blank_line = pad_to(StyledString::EMPTY, width)
237
+ @physical_lines = []
238
+ @hard_lines.each { |hl| append_physical_lines(hl, width) }
239
+ @top_line = top_line_max if @top_line > top_line_max
240
+ end
241
+
242
+ # Wraps `hard_line` at `width` and appends the padded physical lines
243
+ # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
244
+ # and degenerate `width <= 0` both emit a single {@blank_line} row,
245
+ # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
246
+ # would have produced for those cases.
247
+ # @param hard_line [StyledString] one hard-broken line (no embedded `"\n"`).
248
+ # @param width [Integer]
249
+ # @return [void]
250
+ def append_physical_lines(hard_line, width)
251
+ if hard_line.empty? || width <= 0
252
+ @physical_lines << @blank_line
253
+ else
254
+ hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
255
+ end
256
+ end
257
+
258
+ # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
259
+ # default-styled `"\n"` between hard lines. Called from the {#text}
260
+ # reader when the cache is cold. Cost is O(total spans).
261
+ # @return [StyledString]
262
+ def build_text
263
+ return StyledString::EMPTY if @hard_lines.empty?
264
+ return @hard_lines.first if @hard_lines.size == 1
265
+
266
+ newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
267
+ spans = []
268
+ @hard_lines.each_with_index do |hl, i|
269
+ spans << newline if i.positive?
270
+ spans.concat(hl.spans)
271
+ end
272
+ StyledString.new(spans)
273
+ end
274
+
275
+ # @return [Size] {#content_size} computed from {@hard_lines}.
276
+ def compute_content_size
277
+ return Size::ZERO if @hard_lines.empty?
278
+
279
+ Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
280
+ end
281
+
282
+ # @return [Integer] column width available for wrapped text — viewport
283
+ # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
284
+ # width is non-positive, which yields a degenerate "no wrap" result.
285
+ def wrap_width
286
+ return 0 if rect.width <= 0
287
+
288
+ rect.width - (scrollbar_visible? ? 1 : 0)
289
+ end
290
+
291
+ # @param delta [Integer] negative scrolls up, positive scrolls down.
292
+ # @return [void]
293
+ def move_top_line_by(delta)
294
+ move_top_line_to(@top_line + delta)
295
+ end
296
+
297
+ # @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
298
+ # @return [void]
299
+ def move_top_line_to(target)
300
+ clamped = target.clamp(0, top_line_max)
301
+ self.top_line = clamped unless @top_line == clamped
302
+ end
303
+
304
+ # @return [void]
305
+ def update_top_line_if_auto_scroll
306
+ return unless @auto_scroll
307
+
308
+ target = (@physical_lines.size - viewport_lines).clamp(0, nil)
309
+ self.top_line = target if @top_line != target
310
+ end
311
+
312
+ # @return [Boolean]
313
+ def scrollbar_visible?
314
+ return false if rect.empty?
315
+
316
+ @scrollbar_visibility == :visible
317
+ end
318
+
319
+ # Pads `line` with trailing default-styled spaces out to `width` display
320
+ # columns. Callers rely on {StyledString#wrap} having already
321
+ # constrained the line to `<= width`, so no truncation is performed.
322
+ # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
323
+ # `wrap_width == 0` case (rect.width == 1 with scrollbar).
324
+ # @param line [StyledString]
325
+ # @param width [Integer]
326
+ # @return [StyledString]
327
+ def pad_to(line, width)
328
+ return StyledString::EMPTY if width <= 0
329
+
330
+ diff = width - line.display_width
331
+ return line if diff <= 0
332
+
333
+ line + StyledString.plain(" " * diff)
334
+ end
335
+
336
+ # @param index [Integer] 0-based index into `@physical_lines`.
337
+ # @param row_in_viewport [Integer] 0-based row within the viewport.
338
+ # @param scrollbar [VerticalScrollBar, nil]
339
+ # @return [String] paintable ANSI-encoded line exactly `rect.width`
340
+ # columns wide. Body lines come pre-padded from {#rewrap}, so this
341
+ # reduces to a memoized {StyledString#to_ansi} read plus an
342
+ # ASCII-string concat of the scrollbar glyph when one is present.
343
+ def paintable_line(index, row_in_viewport, scrollbar)
344
+ line = @physical_lines[index] || @blank_line
345
+ return line.to_ansi unless scrollbar
346
+
347
+ line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
348
+ end
349
+ end
350
+ end
351
+ end
@@ -90,9 +90,9 @@ module Tuile
90
90
  # @param value [Boolean]
91
91
  # @return [void]
92
92
  def scrollbar=(value)
93
- unless content.is_a?(Component::List)
93
+ unless content.respond_to?(:scrollbar_visibility=)
94
94
  raise Tuile::Error,
95
- "scrollbar= requires a Component::List as content, got #{content.inspect}"
95
+ "scrollbar= requires a content component that supports scrollbar_visibility=, got #{content.inspect}"
96
96
  end
97
97
 
98
98
  content.scrollbar_visibility = value ? :visible : :gone