tuile 0.2.0 → 0.4.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.
@@ -2,20 +2,31 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A scrollable list of String items with cursor support.
5
+ # A scrollable list of items with cursor support.
6
6
  #
7
- # Items are lines painted directly into the component's {#rect}. Lines are
8
- # automatically clipped horizontally. Vertical scrolling is supported via
9
- # {#top_line}; the list can also automatically scroll to the bottom if
10
- # {#auto_scroll} is enabled.
7
+ # Items are modeled as {StyledString}s and painted directly into the
8
+ # component's {#rect}. Lines wider than the viewport are ellipsized via
9
+ # {StyledString#ellipsize} (span styles are preserved across the cut
10
+ # unlike the older ANSI-as-bytes truncation, color does *not* get
11
+ # dropped on the surviving characters). Vertical scrolling is supported
12
+ # via {#top_line}; the list can also automatically scroll to the bottom
13
+ # if {#auto_scroll} is enabled.
11
14
  #
12
15
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
13
- # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
14
- # automatically.
16
+ # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
17
+ # list automatically. The cursor highlight overlays a dark background
18
+ # while preserving each span's foreground color.
15
19
  class List < Component
20
+ # 256-color SGR index for the cursor-row background highlight. Matches
21
+ # what `Rainbow(...).bg(:darkslategray)` emits.
22
+ # @return [Integer]
23
+ CURSOR_BG = 59
24
+
16
25
  def initialize
17
26
  super
18
27
  @lines = []
28
+ @padded_lines = []
29
+ @blank_padded = StyledString::EMPTY
19
30
  @auto_scroll = false
20
31
  @top_line = 0
21
32
  @cursor = Cursor::None.new
@@ -28,19 +39,19 @@ module Tuile
28
39
 
29
40
  # @return [Proc, nil] callback fired when an item is chosen — by pressing
30
41
  # Enter on the cursor's item, or by left-clicking an item. Called as
31
- # `proc.call(index, line)` with the chosen 0-based index and its line.
32
- # Never fires when the cursor's position is outside the content (e.g.
33
- # {Cursor::None}, or empty content).
42
+ # `proc.call(index, line)` with the chosen 0-based index and its
43
+ # {StyledString} line. Never fires when the cursor's position is
44
+ # outside the content (e.g. {Cursor::None}, or empty content).
34
45
  attr_accessor :on_item_chosen
35
46
 
36
47
  # @return [Proc, nil] callback fired when the `(index, line)` tuple under
37
48
  # the cursor changes. Called as `proc.call(index, line)` where `line`
38
- # is `nil` when the cursor is off-content ({Cursor::None}, empty list,
39
- # or `index` past the last line). Fires on cursor moves (key, mouse,
40
- # search), on {#cursor=}, and on {#lines=}/{#add_lines} when the line
41
- # at the cursor's index changes (or its in-range/out-of-range status
42
- # flips). Useful for keeping a details pane in sync with the
43
- # highlighted row.
49
+ # is the {StyledString} at the cursor, or `nil` when the cursor is
50
+ # off-content ({Cursor::None}, empty list, or `index` past the last
51
+ # line). Fires on cursor moves (key, mouse, search), on {#cursor=},
52
+ # and on {#lines=}/{#add_lines} when the line at the cursor's index
53
+ # changes (or its in-range/out-of-range status flips). Useful for
54
+ # keeping a details pane in sync with the highlighted row.
44
55
  attr_accessor :on_cursor_changed
45
56
 
46
57
  # @return [Boolean] if true and a line is added or new content is set,
@@ -77,6 +88,7 @@ module Tuile
77
88
  return if @scrollbar_visibility == value
78
89
 
79
90
  @scrollbar_visibility = value
91
+ rebuild_padded_lines
80
92
  invalidate
81
93
  end
82
94
 
@@ -109,16 +121,22 @@ module Tuile
109
121
  invalidate
110
122
  end
111
123
 
112
- # Sets new lines. Each entry is coerced via `#to_s`, split on `\n` into
113
- # separate lines, and trailing whitespace stripped symmetric with
114
- # {#add_lines}, so the stored `@lines` is always `Array<String>`.
115
- # @param lines [Array] new lines. Entries need only respond to `#to_s`.
124
+ # Sets new lines. Each entry is coerced into a {StyledString} (a
125
+ # `String` is parsed via {StyledString.parse}, so embedded ANSI is
126
+ # honored; a {StyledString} is used as-is; anything else is stringified
127
+ # via `#to_s` first), then split on `\n` into separate lines via
128
+ # {StyledString#lines}, with trailing empty pieces dropped and trailing
129
+ # ASCII whitespace stripped — symmetric with {#add_lines}, so the
130
+ # stored `@lines` is always `Array<StyledString>`.
131
+ # @param lines [Array] entries are `String`, `StyledString`, or anything
132
+ # that responds to `#to_s`.
116
133
  # @return [void]
117
134
  def lines=(lines)
118
135
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
119
136
 
120
- @lines = lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
137
+ @lines = parse_input_lines(lines)
121
138
  @content_size = nil
139
+ rebuild_padded_lines
122
140
  update_top_line_if_auto_scroll
123
141
  notify_cursor_changed
124
142
  invalidate
@@ -132,9 +150,11 @@ module Tuile
132
150
  # end
133
151
  # ```
134
152
  # @yield [buffer]
135
- # @yieldparam buffer [Array<String>] mutable buffer to push lines into.
153
+ # @yieldparam buffer [Array] mutable buffer to push lines into. Each
154
+ # entry is parsed the same way as the items passed to {#lines=}.
136
155
  # @yieldreturn [void]
137
- # @return [Array<String>] current lines (when called without a block).
156
+ # @return [Array<StyledString>] current lines (when called without a
157
+ # block).
138
158
  def lines
139
159
  return @lines unless block_given?
140
160
 
@@ -144,21 +164,25 @@ module Tuile
144
164
  end
145
165
 
146
166
  # Adds a line.
147
- # @param line [String]
167
+ # @param line [String, StyledString, #to_s]
148
168
  # @return [void]
149
169
  def add_line(line)
170
+ raise ArgumentError, "line is nil" if line.nil?
150
171
  add_lines [line]
151
172
  end
152
173
 
153
- # Appends given lines. Each entry is coerced via `#to_s`, split on `\n`
154
- # into separate lines, and trailing whitespace stripped symmetric with
155
- # {#lines=}.
156
- # @param lines [Array] entries need only respond to `#to_s`.
174
+ # Appends given lines. Each entry is parsed the same way as in
175
+ # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
176
+ # empty pieces dropped and trailing ASCII whitespace stripped.
177
+ # @param lines [Array] entries are `String`, `StyledString`, or anything
178
+ # that responds to `#to_s`.
157
179
  # @return [void]
158
180
  def add_lines(lines)
159
181
  screen.check_locked
160
- @lines += lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
182
+ new_lines = parse_input_lines(lines)
183
+ @lines += new_lines
161
184
  @content_size = nil
185
+ @padded_lines += new_lines.map { |line| pad_to_row(line) }
162
186
  update_top_line_if_auto_scroll
163
187
  notify_cursor_changed
164
188
  invalidate
@@ -167,8 +191,8 @@ module Tuile
167
191
  # @return [Size]
168
192
  def content_size
169
193
  @content_size ||= begin
170
- content_width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
171
- width = @lines.empty? ? 0 : content_width + 2
194
+ content_w = @lines.map(&:display_width).max || 0
195
+ width = @lines.empty? ? 0 : content_w + 2
172
196
  Size.new(width, @lines.size)
173
197
  end
174
198
  end
@@ -206,6 +230,8 @@ module Tuile
206
230
  # Moves the cursor to the next line whose text contains `query`
207
231
  # (case-insensitive substring match). Search wraps around the end of the
208
232
  # list. Only lines reachable by the current {#cursor} are considered.
233
+ # Matching uses the line's plain text — span styles do not affect the
234
+ # match.
209
235
  #
210
236
  # @param query [String] substring to match. Empty query never matches.
211
237
  # @param include_current [Boolean] when true, the current cursor position
@@ -250,21 +276,19 @@ module Tuile
250
276
  # Paints the list items into {#rect}.
251
277
  #
252
278
  # Skips the {Component#repaint} default's auto-clear: every row of
253
- # {#rect} is painted below (with padded content past the last item),
279
+ # {#rect} is painted below (with blank padding past the last item),
254
280
  # so the parent contract — "fully draw over your rect" — is met
255
281
  # without an upfront wipe.
256
282
  # @return [void]
257
283
  def repaint
258
284
  return if rect.empty?
259
285
 
260
- width = rect.width
261
286
  scrollbar = if scrollbar_visible?
262
287
  VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
263
288
  end
264
- (0..(rect.height - 1)).each do |line_no|
265
- line_index = line_no + @top_line
266
- line = paintable_line(line_index, line_no, width, scrollbar)
267
- screen.print TTY::Cursor.move_to(rect.left, line_no + rect.top), line
289
+ (0...rect.height).each do |row|
290
+ line = paintable_line(row + @top_line, row, scrollbar)
291
+ screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
268
292
  end
269
293
  end
270
294
 
@@ -303,6 +327,16 @@ module Tuile
303
327
  def candidate_positions(_line_count)
304
328
  []
305
329
  end
330
+
331
+ # Overridden so all movement funnels — base {Cursor#go_to_last},
332
+ # {Cursor#go_to_first}, etc., which all call {#go} — become safe
333
+ # no-ops on a disabled cursor. The instance is frozen, so a default
334
+ # mutating {#go} would raise.
335
+ # @param _new_position [Integer]
336
+ # @return [Boolean] always false.
337
+ def go(_new_position)
338
+ false
339
+ end
306
340
  end
307
341
 
308
342
  # @return [Integer] 0-based line index of the current cursor position.
@@ -361,6 +395,15 @@ module Tuile
361
395
  true
362
396
  end
363
397
 
398
+ # Moves the cursor to the last reachable position. For base {Cursor},
399
+ # the last line; {Limited} clamps to the last allowed position; {None}
400
+ # is a no-op.
401
+ # @param line_count [Integer] number of lines in the list.
402
+ # @return [Boolean] true if the position changed.
403
+ def go_to_last(line_count)
404
+ go(line_count - 1)
405
+ end
406
+
364
407
  protected
365
408
 
366
409
  # @param lines [Integer]
@@ -381,12 +424,6 @@ module Tuile
381
424
  go(0)
382
425
  end
383
426
 
384
- # @param line_count [Integer]
385
- # @return [Boolean]
386
- def go_to_last(line_count)
387
- go(line_count - 1)
388
- end
389
-
390
427
  # Cursor which can only land on specific allowed lines.
391
428
  class Limited < Cursor
392
429
  # @param positions [Array<Integer>] allowed positions. Must not be
@@ -419,6 +456,12 @@ module Tuile
419
456
  @positions.select { it < line_count }
420
457
  end
421
458
 
459
+ # @param _line_count [Integer]
460
+ # @return [Boolean]
461
+ def go_to_last(_line_count)
462
+ go(@positions.last)
463
+ end
464
+
422
465
  protected
423
466
 
424
467
  # @param lines [Integer]
@@ -444,17 +487,64 @@ module Tuile
444
487
  def go_to_first
445
488
  go(@positions.first)
446
489
  end
447
-
448
- # @param _line_count [Integer]
449
- # @return [Boolean]
450
- def go_to_last(_line_count)
451
- go(@positions.last)
452
- end
453
490
  end
454
491
  end
455
492
 
493
+ protected
494
+
495
+ # Rebuilds pre-padded lines when the wrap width changes. The wrap width
496
+ # depends on {#rect}`.width` and the scrollbar gutter, both of which
497
+ # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
498
+ # appended while the rect was empty (e.g. a {Popup}-wrapped list got
499
+ # `add_line` calls before the popup was opened), the auto-scroll update
500
+ # was skipped because there was no viewport — re-run it now that there
501
+ # is one, so the list snaps to the bottom on first paint.
502
+ # @return [void]
503
+ def on_width_changed
504
+ super
505
+ rebuild_padded_lines
506
+ update_top_line_if_auto_scroll
507
+ end
508
+
456
509
  private
457
510
 
511
+ # Coerces and flattens a list of input entries into trimmed
512
+ # {StyledString} lines. Each entry becomes a {StyledString} (String
513
+ # via {StyledString.parse}, StyledString passed through, anything else
514
+ # via `#to_s`), then split on `\n` via {StyledString#lines} — with
515
+ # trailing empty pieces dropped (matching `String#split("\n")`'s
516
+ # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
517
+ # whitespace stripped on each resulting line.
518
+ # @param entries [Array]
519
+ # @return [Array<StyledString>]
520
+ def parse_input_lines(entries)
521
+ entries.flat_map { |entry| split_to_lines(entry) }
522
+ end
523
+
524
+ # @param entry [Object]
525
+ # @return [Array<StyledString>]
526
+ def split_to_lines(entry)
527
+ styled = entry.is_a?(StyledString) ? entry : StyledString.parse(entry.to_s)
528
+ parts = styled.lines
529
+ parts.pop while parts.last && parts.last.empty?
530
+ parts.map { |line| rstrip_styled(line) }
531
+ end
532
+
533
+ # Returns `line` with trailing ASCII whitespace (space/tab) dropped,
534
+ # preserving span styles on the surviving prefix. Whitespace chars are
535
+ # all single-column ASCII, so byte-count delta equals column-count
536
+ # delta and {StyledString#slice} can do the cut.
537
+ # @param line [StyledString]
538
+ # @return [StyledString]
539
+ def rstrip_styled(line)
540
+ plain = line.to_s
541
+ trailing = plain.length - plain.rstrip.length
542
+ return line if trailing.zero?
543
+ return StyledString::EMPTY if trailing == plain.length
544
+
545
+ line.slice(0, line.display_width - trailing)
546
+ end
547
+
458
548
  # @return [Boolean] true if the cursor sits on a real content line.
459
549
  def cursor_on_item?
460
550
  pos = @cursor.position
@@ -469,8 +559,9 @@ module Tuile
469
559
  @on_item_chosen&.call(pos, @lines[pos])
470
560
  end
471
561
 
472
- # @return [Array((Integer, String, nil))] `[position, line_at_position]`,
473
- # with `line` nil when the cursor is off-content.
562
+ # @return [Array((Integer, StyledString, nil))]
563
+ # `[position, line_at_position]`, with `line` nil when the cursor is
564
+ # off-content.
474
565
  def cursor_state
475
566
  pos = @cursor.position
476
567
  line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
@@ -500,7 +591,7 @@ module Tuile
500
591
 
501
592
  ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
502
593
  query_lc = query.downcase
503
- match = ordered.find { |idx| Rainbow.uncolor(@lines[idx]).downcase.include?(query_lc) }
594
+ match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
504
595
  return false unless match
505
596
 
506
597
  @cursor.go(match)
@@ -566,10 +657,20 @@ module Tuile
566
657
  invalidate
567
658
  end
568
659
 
569
- # If auto-scrolling, recalculate the top line.
660
+ # If auto-scrolling, recalculate the top line and snap the cursor to the
661
+ # last reachable position. Without the cursor snap the viewport gets
662
+ # yanked back to wherever the cursor sat on the next arrow press,
663
+ # negating the auto-scroll. Skipped when {#rect} is empty: without a
664
+ # viewport the "lines minus viewport" formula yields `@lines.size`,
665
+ # which would leave `top_line` past the last item once a real rect
666
+ # arrives. {#on_width_changed} re-runs this hook when the rect grows so
667
+ # the snap-to-bottom intent is preserved.
570
668
  # @return [void]
571
669
  def update_top_line_if_auto_scroll
572
670
  return unless @auto_scroll
671
+ return if rect.empty?
672
+
673
+ notify_cursor_changed if @cursor.go_to_last(@lines.size)
573
674
 
574
675
  new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
575
676
  return unless @top_line != new_top_line
@@ -584,42 +685,56 @@ module Tuile
584
685
  @scrollbar_visibility == :visible
585
686
  end
586
687
 
587
- # Trims string exactly to `width` columns.
588
- # @param str [String]
589
- # @param width [Integer]
590
- # @return [String]
591
- def trim_to(str, width)
592
- return " " * width if str.empty?
688
+ # @return [Integer] column width available for line content (rect width
689
+ # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
690
+ # is non-positive.
691
+ def content_width
692
+ return 0 if rect.width <= 0
693
+
694
+ rect.width - (scrollbar_visible? ? 1 : 0)
695
+ end
593
696
 
594
- truncated_line = Truncate.truncate(str, length: width)
595
- return truncated_line unless truncated_line == str
697
+ # Recomputes {@padded_lines} for the current rect width and scrollbar
698
+ # visibility. Each line is ellipsized to fit and pre-padded with
699
+ # single-space gutters on each side, so {#paintable_line} only has to
700
+ # apply the cursor highlight (if any) and append the scrollbar glyph.
701
+ # @return [void]
702
+ def rebuild_padded_lines
703
+ @padded_lines = @lines.map { |line| pad_to_row(line) }
704
+ @blank_padded = pad_to_row(StyledString::EMPTY)
705
+ end
596
706
 
597
- length = Unicode::DisplayWidth.of(Rainbow.uncolor(str))
598
- str += " " * (width - length) if length < width
599
- str
707
+ # Pads `line` to one full row of the viewport (scrollbar gutter
708
+ # excluded). Lines wider than the content area are ellipsized via
709
+ # {StyledString#ellipsize} (span styles survive the cut); shorter
710
+ # lines are padded with default-styled spaces.
711
+ # @param line [StyledString]
712
+ # @return [StyledString] exactly {#content_width} display columns wide
713
+ # (or {StyledString::EMPTY} when content_width is non-positive).
714
+ def pad_to_row(line)
715
+ cw = content_width
716
+ return StyledString::EMPTY if cw <= 0
717
+ return StyledString.plain(" " * cw) if cw < 2
718
+
719
+ text_width = cw - 2
720
+ body = line.ellipsize(text_width)
721
+ fill = cw - 2 - body.display_width
722
+ StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
600
723
  end
601
724
 
602
725
  # @param index [Integer] 0-based index into {#lines}.
603
726
  # @param row_in_viewport [Integer] 0-based row within the viewport.
604
- # @param width [Integer] number of columns the line should occupy.
605
- # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil if
606
- # not shown.
607
- # @return [String] paintable line exactly `width` columns wide;
608
- # highlighted if cursor is here.
609
- def paintable_line(index, row_in_viewport, width, scrollbar)
610
- content_width = scrollbar ? width - 1 : width
611
- line = @lines[index] || ""
612
- line = trim_to(line, content_width - 2)
613
- line = " #{line} "
727
+ # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
728
+ # if not shown.
729
+ # @return [String] paintable ANSI-encoded line exactly `rect.width`
730
+ # columns wide; highlighted if cursor is here.
731
+ def paintable_line(index, row_in_viewport, scrollbar)
732
+ base = index < @lines.size ? @padded_lines[index] : @blank_padded
614
733
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
615
- line = if is_cursor
616
- Rainbow(Rainbow.uncolor(line)).bg(:darkslategray)
617
- else
618
- line
619
- end
620
- return line unless scrollbar
621
-
622
- line + scrollbar.scrollbar_char(row_in_viewport)
734
+ styled = is_cursor ? base.with_bg(CURSOR_BG) : base
735
+ out = styled.to_ansi
736
+ out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
737
+ out
623
738
  end
624
739
  end
625
740
  end
@@ -23,6 +23,16 @@ module Tuile
23
23
  self.scrollbar = true
24
24
  end
25
25
 
26
+ # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
27
+ # @param string [String, nil] the line (or multiple lines) to log.
28
+ # @return [void]
29
+ def log(string)
30
+ return if string.nil?
31
+ screen.event_queue.submit do
32
+ content.add_line(string)
33
+ end
34
+ end
35
+
26
36
  # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
27
37
  # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
28
38
  # call `output.puts`, e.g. `TTY::Logger`).
@@ -35,17 +45,13 @@ module Tuile
35
45
  # @param string [String]
36
46
  # @return [void]
37
47
  def write(string)
38
- @window.screen.event_queue.submit do
39
- @window.content.add_line(string.chomp)
40
- end
48
+ @window.log(string.chomp)
41
49
  end
42
50
 
43
51
  # @param string [String]
44
52
  # @return [void]
45
53
  def puts(string)
46
- @window.screen.event_queue.submit do
47
- @window.content.add_line(string)
48
- end
54
+ @window.log(string)
49
55
  end
50
56
 
51
57
  # Stdlib `Logger` only treats an object as an IO target when it
@@ -30,17 +30,17 @@ module Tuile
30
30
  def initialize(content: nil)
31
31
  super()
32
32
  @content = nil
33
- # Off-screen sentinel until the content sets a real size and the popup
34
- # is centered on open.
35
- @rect = Rect.new(-1, -1, 0, 0)
36
33
  self.content = content unless content.nil?
37
34
  end
38
35
 
39
36
  def focusable? = true
40
37
 
41
- # Mounts this popup on the {Screen}.
38
+ # Mounts this popup on the {Screen}. Recomputes the popup's size from
39
+ # the current content first, so reopening a popup whose content has
40
+ # grown or shrunk while closed picks up the new size.
42
41
  # @return [void]
43
42
  def open
43
+ update_rect unless @content.nil?
44
44
  screen.add_popup(self)
45
45
  end
46
46
 
@@ -119,7 +119,7 @@ module Tuile
119
119
  def update_rect
120
120
  size = @content.content_size.clamp_height(max_height)
121
121
  size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
122
- self.rect = Rect.new(-1, -1, size.width, size.height)
122
+ self.rect = Rect.new(0, 0, size.width, size.height)
123
123
  center if open?
124
124
  end
125
125
  end