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.
@@ -5,9 +5,11 @@ module Tuile
5
5
  # A layout doesn't paint anything by itself: its job is to position child
6
6
  # components.
7
7
  #
8
- # All children must completely cover the contents of a layout: that way,
9
- # the layout itself doesn't have to draw and no clipping algorithm is
10
- # necessary.
8
+ # Children that fully tile the layout's rect repaint themselves and
9
+ # cover everything; children that leave gaps (e.g. a form with widgets
10
+ # of varying widths) trigger {Component#repaint}'s default behavior —
11
+ # the background is cleared and children are re-invalidated so they
12
+ # paint over a clean surface.
11
13
  class Layout < Component
12
14
  def initialize
13
15
  super
@@ -17,6 +19,16 @@ module Tuile
17
19
  # @return [Array<Component>]
18
20
  def children = @children.to_a
19
21
 
22
+ # Layouts are focusable containers — like {Window} and {Popup}, they
23
+ # don't accept input themselves but they need to participate in the
24
+ # {HasContent} focus cascade so a Popup wrapping a Layout wrapping a
25
+ # {TextField} ends up focusing the field rather than parking focus on
26
+ # the popup. Layouts don't paint any visible chrome of their own
27
+ # (the auto-cleared background is just blank space), so this has no
28
+ # mouse-routing consequences — clicks on a gap area land back on the
29
+ # Layout itself and the on_focus cascade forwards to a tab stop.
30
+ def focusable? = true
31
+
20
32
  # Adds a child component to this layout.
21
33
  # @param child [Component, Array<Component>]
22
34
  # @return [void]
@@ -53,11 +65,6 @@ module Tuile
53
65
  Size.new(right - rect.left, bottom - rect.top)
54
66
  end
55
67
 
56
- # @return [void]
57
- def repaint
58
- clear_background if @children.empty?
59
- end
60
-
61
68
  # Dispatches the event to the child under the mouse cursor.
62
69
  # @param event [MouseEvent]
63
70
  # @return [void]
@@ -83,10 +90,20 @@ module Tuile
83
90
  # @return [void]
84
91
  def on_focus
85
92
  super
86
- # Let the content component receive focus, so that it can immediately
87
- # start responding to key presses.
88
- first_focusable = @children.find(&:focusable?)
89
- screen.focused = first_focusable unless first_focusable.nil?
93
+ # Forward focus to the first interactive widget in the subtree so the
94
+ # user can start typing / cursoring immediately. Prefer a {#tab_stop?}
95
+ # descendant (TextField, List, Button…) so we skip past intermediate
96
+ # containers like a {Window} or another {Layout}. Fall back to the
97
+ # first focusable direct child for the rare case where the layout has
98
+ # focusable but non-tab-stop children (e.g. an empty {Window}).
99
+ first_tab_stop = nil
100
+ on_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
101
+ if first_tab_stop
102
+ screen.focused = first_tab_stop
103
+ else
104
+ first_focusable = @children.find(&:focusable?)
105
+ screen.focused = first_focusable unless first_focusable.nil?
106
+ end
90
107
  end
91
108
 
92
109
  # Absolute layout. Extend this class, register any children, and
@@ -2,35 +2,58 @@
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
22
33
  @scrollbar_visibility = :gone
23
34
  @show_cursor_when_inactive = false
24
35
  @on_item_chosen = nil
36
+ @on_cursor_changed = nil
37
+ @last_cursor_state = cursor_state
25
38
  end
26
39
 
27
40
  # @return [Proc, nil] callback fired when an item is chosen — by pressing
28
41
  # Enter on the cursor's item, or by left-clicking an item. Called as
29
- # `proc.call(index, line)` with the chosen 0-based index and its line.
30
- # Never fires when the cursor's position is outside the content (e.g.
31
- # {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).
32
45
  attr_accessor :on_item_chosen
33
46
 
47
+ # @return [Proc, nil] callback fired when the `(index, line)` tuple under
48
+ # the cursor changes. Called as `proc.call(index, line)` where `line`
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.
55
+ attr_accessor :on_cursor_changed
56
+
34
57
  # @return [Boolean] if true and a line is added or new content is set,
35
58
  # auto-scrolls to the bottom.
36
59
  attr_reader :auto_scroll
@@ -65,6 +88,7 @@ module Tuile
65
88
  return if @scrollbar_visibility == value
66
89
 
67
90
  @scrollbar_visibility = value
91
+ rebuild_padded_lines
68
92
  invalidate
69
93
  end
70
94
 
@@ -83,6 +107,7 @@ module Tuile
83
107
  old_position = @cursor.position
84
108
  @cursor = cursor
85
109
  invalidate if old_position != cursor.position
110
+ notify_cursor_changed
86
111
  end
87
112
 
88
113
  # Sets the top line.
@@ -96,17 +121,24 @@ module Tuile
96
121
  invalidate
97
122
  end
98
123
 
99
- # Sets new lines. Each entry is coerced via `#to_s`, split on `\n` into
100
- # separate lines, and trailing whitespace stripped symmetric with
101
- # {#add_lines}, so the stored `@lines` is always `Array<String>`.
102
- # @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`.
103
133
  # @return [void]
104
134
  def lines=(lines)
105
135
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
106
136
 
107
- @lines = lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
137
+ @lines = parse_input_lines(lines)
108
138
  @content_size = nil
139
+ rebuild_padded_lines
109
140
  update_top_line_if_auto_scroll
141
+ notify_cursor_changed
110
142
  invalidate
111
143
  end
112
144
 
@@ -118,9 +150,11 @@ module Tuile
118
150
  # end
119
151
  # ```
120
152
  # @yield [buffer]
121
- # @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=}.
122
155
  # @yieldreturn [void]
123
- # @return [Array<String>] current lines (when called without a block).
156
+ # @return [Array<StyledString>] current lines (when called without a
157
+ # block).
124
158
  def lines
125
159
  return @lines unless block_given?
126
160
 
@@ -130,36 +164,42 @@ module Tuile
130
164
  end
131
165
 
132
166
  # Adds a line.
133
- # @param line [String]
167
+ # @param line [String, StyledString, #to_s]
134
168
  # @return [void]
135
169
  def add_line(line)
136
170
  add_lines [line]
137
171
  end
138
172
 
139
- # Appends given lines. Each entry is coerced via `#to_s`, split on `\n`
140
- # into separate lines, and trailing whitespace stripped symmetric with
141
- # {#lines=}.
142
- # @param lines [Array] entries need only respond to `#to_s`.
173
+ # Appends given lines. Each entry is parsed the same way as in
174
+ # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
175
+ # empty pieces dropped and trailing ASCII whitespace stripped.
176
+ # @param lines [Array] entries are `String`, `StyledString`, or anything
177
+ # that responds to `#to_s`.
143
178
  # @return [void]
144
179
  def add_lines(lines)
145
180
  screen.check_locked
146
- @lines += lines.flat_map { it.to_s.split("\n") }.map(&:rstrip)
181
+ new_lines = parse_input_lines(lines)
182
+ @lines += new_lines
147
183
  @content_size = nil
184
+ @padded_lines += new_lines.map { |line| pad_to_row(line) }
148
185
  update_top_line_if_auto_scroll
186
+ notify_cursor_changed
149
187
  invalidate
150
188
  end
151
189
 
152
190
  # @return [Size]
153
191
  def content_size
154
192
  @content_size ||= begin
155
- content_width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
156
- width = @lines.empty? ? 0 : content_width + 2
193
+ content_w = @lines.map(&:display_width).max || 0
194
+ width = @lines.empty? ? 0 : content_w + 2
157
195
  Size.new(width, @lines.size)
158
196
  end
159
197
  end
160
198
 
161
199
  def focusable? = true
162
200
 
201
+ def tab_stop? = true
202
+
163
203
  # @param key [String] a key.
164
204
  # @return [Boolean] true if the key was handled.
165
205
  def handle_key(key)
@@ -178,6 +218,7 @@ module Tuile
178
218
  true
179
219
  elsif @cursor.handle_key(key, @lines.size, viewport_lines)
180
220
  move_viewport_to_cursor
221
+ notify_cursor_changed
181
222
  invalidate
182
223
  true
183
224
  else
@@ -188,6 +229,8 @@ module Tuile
188
229
  # Moves the cursor to the next line whose text contains `query`
189
230
  # (case-insensitive substring match). Search wraps around the end of the
190
231
  # list. Only lines reachable by the current {#cursor} are considered.
232
+ # Matching uses the line's plain text — span styles do not affect the
233
+ # match.
191
234
  #
192
235
  # @param query [String] substring to match. Empty query never matches.
193
236
  # @param include_current [Boolean] when true, the current cursor position
@@ -222,6 +265,7 @@ module Tuile
222
265
  line = event.y - rect.top + top_line
223
266
  if @cursor.handle_mouse(line, event, @lines.size)
224
267
  move_viewport_to_cursor
268
+ notify_cursor_changed
225
269
  invalidate
226
270
  end
227
271
  fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
@@ -229,19 +273,21 @@ module Tuile
229
273
  end
230
274
 
231
275
  # Paints the list items into {#rect}.
276
+ #
277
+ # Skips the {Component#repaint} default's auto-clear: every row of
278
+ # {#rect} is painted below (with blank padding past the last item),
279
+ # so the parent contract — "fully draw over your rect" — is met
280
+ # without an upfront wipe.
232
281
  # @return [void]
233
282
  def repaint
234
- super
235
283
  return if rect.empty?
236
284
 
237
- width = rect.width
238
285
  scrollbar = if scrollbar_visible?
239
286
  VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
240
287
  end
241
- (0..(rect.height - 1)).each do |line_no|
242
- line_index = line_no + @top_line
243
- line = paintable_line(line_index, line_no, width, scrollbar)
244
- screen.print TTY::Cursor.move_to(rect.left, line_no + rect.top), line
288
+ (0...rect.height).each do |row|
289
+ line = paintable_line(row + @top_line, row, scrollbar)
290
+ screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
245
291
  end
246
292
  end
247
293
 
@@ -302,9 +348,9 @@ module Tuile
302
348
  go_down_by(1, line_count)
303
349
  when *Keys::UP_ARROWS
304
350
  go_up_by(1)
305
- when Keys::HOME
351
+ when *Keys::HOMES
306
352
  go_to_first
307
- when Keys::END_
353
+ when *Keys::ENDS_
308
354
  go_to_last(line_count)
309
355
  when Keys::CTRL_U
310
356
  go_up_by(viewport_lines / 2)
@@ -430,8 +476,56 @@ module Tuile
430
476
  end
431
477
  end
432
478
 
479
+ protected
480
+
481
+ # Rebuilds pre-padded lines when the wrap width changes. The wrap width
482
+ # depends on {#rect}`.width` and the scrollbar gutter, both of which
483
+ # trigger this hook.
484
+ # @return [void]
485
+ def on_width_changed
486
+ super
487
+ rebuild_padded_lines
488
+ end
489
+
433
490
  private
434
491
 
492
+ # Coerces and flattens a list of input entries into trimmed
493
+ # {StyledString} lines. Each entry becomes a {StyledString} (String
494
+ # via {StyledString.parse}, StyledString passed through, anything else
495
+ # via `#to_s`), then split on `\n` via {StyledString#lines} — with
496
+ # trailing empty pieces dropped (matching `String#split("\n")`'s
497
+ # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
498
+ # whitespace stripped on each resulting line.
499
+ # @param entries [Array]
500
+ # @return [Array<StyledString>]
501
+ def parse_input_lines(entries)
502
+ entries.flat_map { |entry| split_to_lines(entry) }
503
+ end
504
+
505
+ # @param entry [Object]
506
+ # @return [Array<StyledString>]
507
+ def split_to_lines(entry)
508
+ styled = entry.is_a?(StyledString) ? entry : StyledString.parse(entry.to_s)
509
+ parts = styled.lines
510
+ parts.pop while parts.last && parts.last.empty?
511
+ parts.map { |line| rstrip_styled(line) }
512
+ end
513
+
514
+ # Returns `line` with trailing ASCII whitespace (space/tab) dropped,
515
+ # preserving span styles on the surviving prefix. Whitespace chars are
516
+ # all single-column ASCII, so byte-count delta equals column-count
517
+ # delta and {StyledString#slice} can do the cut.
518
+ # @param line [StyledString]
519
+ # @return [StyledString]
520
+ def rstrip_styled(line)
521
+ plain = line.to_s
522
+ trailing = plain.length - plain.rstrip.length
523
+ return line if trailing.zero?
524
+ return StyledString::EMPTY if trailing == plain.length
525
+
526
+ line.slice(0, line.display_width - trailing)
527
+ end
528
+
435
529
  # @return [Boolean] true if the cursor sits on a real content line.
436
530
  def cursor_on_item?
437
531
  pos = @cursor.position
@@ -446,6 +540,26 @@ module Tuile
446
540
  @on_item_chosen&.call(pos, @lines[pos])
447
541
  end
448
542
 
543
+ # @return [Array((Integer, StyledString, nil))]
544
+ # `[position, line_at_position]`, with `line` nil when the cursor is
545
+ # off-content.
546
+ def cursor_state
547
+ pos = @cursor.position
548
+ line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
549
+ [pos, line]
550
+ end
551
+
552
+ # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
553
+ # fired state. Idempotent — safe to call after any mutation.
554
+ # @return [void]
555
+ def notify_cursor_changed
556
+ state = cursor_state
557
+ return if state == @last_cursor_state
558
+
559
+ @last_cursor_state = state
560
+ @on_cursor_changed&.call(*state)
561
+ end
562
+
449
563
  # @param query [String]
450
564
  # @param include_current [Boolean]
451
565
  # @param reverse [Boolean]
@@ -458,11 +572,12 @@ module Tuile
458
572
 
459
573
  ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
460
574
  query_lc = query.downcase
461
- match = ordered.find { |idx| Rainbow.uncolor(@lines[idx]).downcase.include?(query_lc) }
575
+ match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
462
576
  return false unless match
463
577
 
464
578
  @cursor.go(match)
465
579
  move_viewport_to_cursor
580
+ notify_cursor_changed
466
581
  invalidate
467
582
  true
468
583
  end
@@ -541,42 +656,56 @@ module Tuile
541
656
  @scrollbar_visibility == :visible
542
657
  end
543
658
 
544
- # Trims string exactly to `width` columns.
545
- # @param str [String]
546
- # @param width [Integer]
547
- # @return [String]
548
- def trim_to(str, width)
549
- return " " * width if str.empty?
659
+ # @return [Integer] column width available for line content (rect width
660
+ # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
661
+ # is non-positive.
662
+ def content_width
663
+ return 0 if rect.width <= 0
664
+
665
+ rect.width - (scrollbar_visible? ? 1 : 0)
666
+ end
550
667
 
551
- truncated_line = Strings::Truncation.truncate(str, length: width)
552
- return truncated_line unless truncated_line == str
668
+ # Recomputes {@padded_lines} for the current rect width and scrollbar
669
+ # visibility. Each line is ellipsized to fit and pre-padded with
670
+ # single-space gutters on each side, so {#paintable_line} only has to
671
+ # apply the cursor highlight (if any) and append the scrollbar glyph.
672
+ # @return [void]
673
+ def rebuild_padded_lines
674
+ @padded_lines = @lines.map { |line| pad_to_row(line) }
675
+ @blank_padded = pad_to_row(StyledString::EMPTY)
676
+ end
553
677
 
554
- length = Unicode::DisplayWidth.of(Rainbow.uncolor(str))
555
- str += " " * (width - length) if length < width
556
- str
678
+ # Pads `line` to one full row of the viewport (scrollbar gutter
679
+ # excluded). Lines wider than the content area are ellipsized via
680
+ # {StyledString#ellipsize} (span styles survive the cut); shorter
681
+ # lines are padded with default-styled spaces.
682
+ # @param line [StyledString]
683
+ # @return [StyledString] exactly {#content_width} display columns wide
684
+ # (or {StyledString::EMPTY} when content_width is non-positive).
685
+ def pad_to_row(line)
686
+ cw = content_width
687
+ return StyledString::EMPTY if cw <= 0
688
+ return StyledString.plain(" " * cw) if cw < 2
689
+
690
+ text_width = cw - 2
691
+ body = line.ellipsize(text_width)
692
+ fill = cw - 2 - body.display_width
693
+ StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
557
694
  end
558
695
 
559
696
  # @param index [Integer] 0-based index into {#lines}.
560
697
  # @param row_in_viewport [Integer] 0-based row within the viewport.
561
- # @param width [Integer] number of columns the line should occupy.
562
- # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil if
563
- # not shown.
564
- # @return [String] paintable line exactly `width` columns wide;
565
- # highlighted if cursor is here.
566
- def paintable_line(index, row_in_viewport, width, scrollbar)
567
- content_width = scrollbar ? width - 1 : width
568
- line = @lines[index] || ""
569
- line = trim_to(line, content_width - 2)
570
- line = " #{line} "
698
+ # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
699
+ # if not shown.
700
+ # @return [String] paintable ANSI-encoded line exactly `rect.width`
701
+ # columns wide; highlighted if cursor is here.
702
+ def paintable_line(index, row_in_viewport, scrollbar)
703
+ base = index < @lines.size ? @padded_lines[index] : @blank_padded
571
704
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
572
- line = if is_cursor
573
- Rainbow(Rainbow.uncolor(line)).bg(:darkslategray)
574
- else
575
- line
576
- end
577
- return line unless scrollbar
578
-
579
- line + scrollbar.scrollbar_char(row_in_viewport)
705
+ styled = is_cursor ? base.with_bg(CURSOR_BG) : base
706
+ out = styled.to_ansi
707
+ out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
708
+ out
580
709
  end
581
710
  end
582
711
  end