tuile 0.1.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
@@ -132,13 +132,21 @@ module Tuile
132
132
  end
133
133
 
134
134
  # Fully repaints the window: both frame and contents.
135
+ #
136
+ # Window deliberately paints over its entire rect (border around the
137
+ # edge, content/footer over the interior), so we don't need the
138
+ # {Component#repaint} default's auto-clear — but we do still want its
139
+ # "re-invalidate children" effect, since the border overpaints
140
+ # whatever the content/footer drew on the perimeter. Calling super
141
+ # handles both: the auto-clear is harmless (we re-paint over it), and
142
+ # the invalidation queues content + footer for repaint in the same
143
+ # cycle.
135
144
  # @return [void]
136
145
  def repaint
146
+ return unless visible?
147
+
137
148
  super
138
149
  repaint_border
139
- # Border paints over content: invalidate the content to have it
140
- # repainted.
141
- content&.invalidate
142
150
  end
143
151
 
144
152
  # @param key [String, nil]
@@ -44,14 +44,36 @@ module Tuile
44
44
  screen.focused = self
45
45
  end
46
46
 
47
- # Repaints the component. Default implementation does nothing.
47
+ # Repaints the component.
48
48
  #
49
- # The component must fully draw over {#rect}, and must not draw outside of
50
- # {#rect}.
49
+ # The default does the bookkeeping that almost every component would
50
+ # otherwise have to remember: it clears the background and re-invalidates
51
+ # any direct children whose rects leave gaps in {#rect}. Concretely:
51
52
  #
52
- # Tip: use {#clear_background} to clear component background before painting.
53
+ # - Leaf (no children): always clears, so subclasses can paint their
54
+ # content directly without an explicit `clear_background` call.
55
+ # - Container with children that fully tile {#rect}: skipped — the
56
+ # children themselves will repaint and cover everything.
57
+ # - Container with gappy children (e.g. a form layout where widgets
58
+ # don't tile): clears, then invalidates the children so they re-paint
59
+ # on top of the cleared background. This is what makes mixed
60
+ # field/button forms safe without each container learning a custom
61
+ # damage-tracking pass.
62
+ #
63
+ # Subclasses that paint their entire rect themselves (e.g. {Window}'s
64
+ # border draws over the area the default would clear; {Component::List}
65
+ # explicitly paints every row) may skip super and take full
66
+ # responsibility for {#rect}. Everything else should call super.
67
+ #
68
+ # A component must not draw outside of {#rect}.
53
69
  # @return [void]
54
- def repaint; end
70
+ def repaint
71
+ return if rect.empty? || rect.left.negative? || rect.top.negative?
72
+ return if children.any? && children_tile_rect?
73
+
74
+ clear_background
75
+ children.each { |c| screen.invalidate(c) }
76
+ end
55
77
 
56
78
  # Called when a character is pressed on the keyboard.
57
79
  #
@@ -123,9 +145,22 @@ module Tuile
123
145
  # Independent from {#active?}: every component carries the active flag, but
124
146
  # only focusable ones can become a focus target that puts themselves and
125
147
  # their ancestors on the active chain.
148
+ #
149
+ # See also {#tab_stop?}: focusable controls _can_ receive focus (via click
150
+ # or programmatic assignment), but only tab stops participate in Tab /
151
+ # Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
152
+ # (so a click on chrome lands focus) but are not tab stops.
126
153
  # @return [Boolean] true if this component can be focused.
127
154
  def focusable? = false
128
155
 
156
+ # Whether this component participates in Tab / Shift+Tab focus cycling.
157
+ # `false` by default. Only true on components that accept direct user
158
+ # input (e.g. {TextField}, {List}, {Component::Button}). Implies
159
+ # {#focusable?} — Screen will skip non-focusable tab stops, but in
160
+ # practice every override should keep the two consistent.
161
+ # @return [Boolean] true if Tab / Shift+Tab should land on this component.
162
+ def tab_stop? = false
163
+
129
164
  # @return [Component, nil] the parent component or nil if the component has
130
165
  # no parent.
131
166
  attr_reader :parent
@@ -221,6 +256,19 @@ module Tuile
221
256
  screen.invalidate(self)
222
257
  end
223
258
 
259
+ # Whether direct children fully tile {#rect}. Used by the default
260
+ # {#repaint} to decide whether the framework needs to wipe gaps.
261
+ #
262
+ # Approximated by area: sum of (non-empty) child areas vs the parent's
263
+ # area. Cheap, and correct as long as siblings don't overlap each other
264
+ # — which Tuile already requires (no clipping in the tiled tree).
265
+ # Children with empty rects contribute zero, since they paint nothing.
266
+ # @return [Boolean]
267
+ def children_tile_rect?
268
+ total = children.sum { |c| c.rect.empty? ? 0 : c.rect.width * c.rect.height }
269
+ total >= rect.width * rect.height
270
+ end
271
+
224
272
  # Clears the background: prints spaces into all characters occupied by the
225
273
  # component's rect.
226
274
  # @return [void]
@@ -70,7 +70,20 @@ module Tuile
70
70
  event_loop(&)
71
71
  ensure
72
72
  Signal.trap("WINCH", "SYSTEM_DEFAULT")
73
- @key_thread&.kill
73
+ if @key_thread
74
+ # Kill returns immediately, but the key thread is typically
75
+ # blocked inside $stdin.getch with a termios snapshot saved in
76
+ # io-console's C-level ensure. If we let it run to completion
77
+ # *after* the outer $stdin.raw block has exited (e.g. when an
78
+ # exception is escaping run_event_loop), the late tcsetattr
79
+ # restores raw mode and leaves the terminal with ONLCR off —
80
+ # the stack trace then prints as one un-wrapped soft line.
81
+ # Joining here forces the restore to happen while we're still
82
+ # nested inside $stdin.raw, so raw's own restoration is the
83
+ # final write and the terminal lands in cooked mode.
84
+ @key_thread.kill
85
+ @key_thread.join
86
+ end
74
87
  @queue.clear
75
88
  end
76
89
  end
data/lib/tuile/keys.rb CHANGED
@@ -18,17 +18,31 @@ module Tuile
18
18
  # @return [String]
19
19
  RIGHT_ARROW = "\e[C"
20
20
  # @return [String]
21
+ CTRL_LEFT_ARROW = "\e[1;5D"
22
+ # @return [String]
23
+ CTRL_RIGHT_ARROW = "\e[1;5C"
24
+ # @return [String]
21
25
  ESC = "\e"
22
26
  # @return [String]
23
27
  HOME = "\e[H"
24
28
  # @return [String]
25
29
  END_ = "\e[F"
30
+ # Home-key sequences. xterm-style (`HOME`) is the modern default, but the
31
+ # Linux console, rxvt, and tmux/screen in their default configuration emit
32
+ # the VT220-style `\e[1~` instead. Components that handle Home should
33
+ # match against this array so users see consistent behavior regardless of
34
+ # which sequence their terminal emits.
35
+ # @return [Array<String>]
36
+ HOMES = [HOME, "\e[1~"].freeze
37
+ # End-key sequences. See {HOMES} for why two are recognized.
38
+ # @return [Array<String>]
39
+ ENDS_ = [END_, "\e[4~"].freeze
26
40
  # @return [String]
27
41
  PAGE_UP = "\e[5~"
28
42
  # @return [String]
29
43
  PAGE_DOWN = "\e[6~"
30
44
  # @return [String]
31
- BACKSPACE = "\u007f"
45
+ BACKSPACE = ""
32
46
  # @return [String]
33
47
  DELETE = "\e[3~"
34
48
  # @return [String]
@@ -36,11 +50,17 @@ module Tuile
36
50
  # @return [Array<String>]
37
51
  BACKSPACES = [BACKSPACE, CTRL_H].freeze
38
52
  # @return [String]
39
- CTRL_U = "\u0015"
53
+ CTRL_U = ""
54
+ # @return [String]
55
+ CTRL_D = ""
56
+ # @return [String]
57
+ ENTER = "
40
58
  # @return [String]
41
- CTRL_D = "\u0004"
59
+ TAB = "\t"
60
+ # The terminal sequence emitted by Shift+Tab in xterm-style terminals
61
+ # (CSI Z). Used by {Screen} for reverse focus traversal.
42
62
  # @return [String]
43
- ENTER = "\u000d"
63
+ SHIFT_TAB = "\e[Z"
44
64
 
45
65
  # Grabs a key from stdin and returns it. Blocks until the key is obtained.
46
66
  # Reads a full ESC key sequence; see constants above for some values returned