tuile 0.4.0 → 0.6.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.
@@ -14,14 +14,9 @@ module Tuile
14
14
  #
15
15
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
16
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.
17
+ # list automatically. The cursor highlight overlays
18
+ # {Theme#active_bg_color} while preserving each span's foreground color.
19
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
-
25
20
  def initialize
26
21
  super
27
22
  @lines = []
@@ -135,11 +130,11 @@ module Tuile
135
130
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
136
131
 
137
132
  @lines = parse_input_lines(lines)
138
- @content_size = nil
139
133
  rebuild_padded_lines
140
134
  update_top_line_if_auto_scroll
141
135
  notify_cursor_changed
142
136
  invalidate
137
+ self.content_size = compute_content_size
143
138
  end
144
139
 
145
140
  # Without a block, returns the current lines. With a block, fully
@@ -181,20 +176,11 @@ module Tuile
181
176
  screen.check_locked
182
177
  new_lines = parse_input_lines(lines)
183
178
  @lines += new_lines
184
- @content_size = nil
185
179
  @padded_lines += new_lines.map { |line| pad_to_row(line) }
186
180
  update_top_line_if_auto_scroll
187
181
  notify_cursor_changed
188
182
  invalidate
189
- end
190
-
191
- # @return [Size]
192
- def content_size
193
- @content_size ||= begin
194
- content_w = @lines.map(&:display_width).max || 0
195
- width = @lines.empty? ? 0 : content_w + 2
196
- Size.new(width, @lines.size)
197
- end
183
+ grow_content_size(new_lines)
198
184
  end
199
185
 
200
186
  def focusable? = true
@@ -508,6 +494,30 @@ module Tuile
508
494
 
509
495
  private
510
496
 
497
+ # Natural size from scratch: longest line's display width plus the two
498
+ # single-space gutters {#pad_to_row} adds, × line count. An empty list
499
+ # is {Size::ZERO} (no gutters for no content).
500
+ # @return [Size]
501
+ def compute_content_size
502
+ content_w = @lines.map(&:display_width).max || 0
503
+ width = @lines.empty? ? 0 : content_w + 2
504
+ Size.new(width, @lines.size)
505
+ end
506
+
507
+ # Incremental {#content_size} update for appends: folds just the
508
+ # appended lines into the running maximum, keeping {#add_lines}
509
+ # O(appended) instead of re-scanning the whole list (LogWindow appends
510
+ # a line per log statement).
511
+ # @param appended [Array<StyledString>] the just-appended lines
512
+ # (already concatenated onto {@lines}).
513
+ # @return [void]
514
+ def grow_content_size(appended)
515
+ return if appended.empty?
516
+
517
+ appended_w = appended.map(&:display_width).max + 2
518
+ self.content_size = Size.new([content_size.width, appended_w].max, @lines.size)
519
+ end
520
+
511
521
  # Coerces and flattens a list of input entries into trimmed
512
522
  # {StyledString} lines. Each entry becomes a {StyledString} (String
513
523
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -731,7 +741,7 @@ module Tuile
731
741
  def paintable_line(index, row_in_viewport, scrollbar)
732
742
  base = index < @lines.size ? @padded_lines[index] : @blank_padded
733
743
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
734
- styled = is_cursor ? base.with_bg(CURSOR_BG) : base
744
+ styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
735
745
  out = styled.to_ansi
736
746
  out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
737
747
  out
@@ -37,7 +37,7 @@ module Tuile
37
37
  @options = options.map { Option.new(it[0], it[1]) }
38
38
  @block = block
39
39
  list = Component::List.new
40
- list.lines = @options.map { "#{it.key} #{Rainbow(it.caption).cadetblue}" }
40
+ list.lines = @options.map { "#{it.key} #{screen.theme.hint(it.caption)}" }
41
41
  list.cursor = Component::List::Cursor.new
42
42
  list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
43
43
  self.content = list
@@ -65,7 +65,7 @@ module Tuile
65
65
 
66
66
  # @return [String]
67
67
  def keyboard_hint
68
- @options.map { "#{it.key} #{Rainbow(it.caption).cadetblue}" }.join(" ")
68
+ @options.map { "#{it.key} #{screen.theme.hint(it.caption)}" }.join(" ")
69
69
  end
70
70
 
71
71
  # Opens a picker as a popup. Picking an option fires `block`, then
@@ -81,10 +81,20 @@ module Tuile
81
81
  update_rect unless new_content.nil?
82
82
  end
83
83
 
84
+ # Re-sizes (and recenters, when open) whenever the wrapped content's
85
+ # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
86
+ # `add_line`, or a nested {Window} whose own content grew (the window
87
+ # recomputes its {Component#content_size} and the change bubbles here).
88
+ # @param _child [Component]
89
+ # @return [void]
90
+ def on_child_content_size_changed(_child)
91
+ update_rect
92
+ end
93
+
84
94
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
85
95
  # @return [String]
86
96
  def keyboard_hint
87
- prefix = "q #{Rainbow("Close").cadetblue}"
97
+ prefix = "q #{screen.theme.hint("Close")}"
88
98
  child_hint = @content&.keyboard_hint.to_s
89
99
  child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
90
100
  end
@@ -70,7 +70,6 @@ module Tuile
70
70
  def repaint
71
71
  return if rect.empty?
72
72
 
73
- bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
74
73
  rows = display_rows
75
74
  (0...rect.height).each do |screen_row|
76
75
  row_idx = screen_row + @top_display_row
@@ -81,7 +80,7 @@ module Tuile
81
80
  chunk = @text[r[:start], r[:length]] || ""
82
81
  chunk + (" " * (rect.width - r[:length]))
83
82
  end
84
- screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, Ansi::RESET
83
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), background(line)
85
84
  end
86
85
  end
87
86
 
@@ -59,9 +59,8 @@ module Tuile
59
59
  def repaint
60
60
  return if rect.empty?
61
61
 
62
- bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
63
62
  padded = @text + (" " * (rect.width - @text.length))
64
- screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
63
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
65
64
  end
66
65
 
67
66
  protected
@@ -88,21 +88,6 @@ module Tuile
88
88
  invalidate
89
89
  end
90
90
 
91
- # 256-color SGR for the focused-button highlight (matches what
92
- # `Rainbow(...).bg(:darkslategray)` emits, which is what
93
- # {Component::Button#repaint} uses for its focused state).
94
- # @return [String]
95
- ACTIVE_BG_SGR = "\e[48;5;59m"
96
- # 256-color SGR for the unfocused field's "well": index 238 sits in
97
- # the grayscale ramp (~#444444), bright enough to stand out against
98
- # non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
99
- # backgrounds sit in the #1d–#2d range), and still distinctly darker
100
- # than the active highlight at index 59 (~#5f5f5f). Rainbow's
101
- # RGB-to-256 mapping snaps everything dark to palette index 16
102
- # (terminal black), so we emit the escape directly to reach the ramp.
103
- # @return [String]
104
- INACTIVE_BG_SGR = "\e[48;5;238m"
105
-
106
91
  # Handles a key. Returns false when the component is inactive. Otherwise
107
92
  # first runs the {Component#handle_key} shortcut search via `super`, then
108
93
  # delegates to {#handle_text_input_key}.
@@ -117,6 +102,16 @@ module Tuile
117
102
 
118
103
  protected
119
104
 
105
+ # Renders `text` on the field's background well, looked up from the
106
+ # current {Screen#theme} at paint time: {Theme#active_bg} when this
107
+ # input is on the active (focus) chain, {Theme#input_bg} otherwise —
108
+ # visibly a field either way, distinctly highlighted when active.
109
+ # @param text [String]
110
+ # @return [String] ANSI-rendered text.
111
+ def background(text)
112
+ active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
113
+ end
114
+
120
115
  # Input filter for {#text=}. Subclasses override to truncate or reject
121
116
  # invalid input. Default coerces to String.
122
117
  # @param new_text [String]