tuile 0.3.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.
@@ -11,36 +11,14 @@ module Tuile
11
11
  # The caret is a logical index in `0..text.length`. The hardware cursor is
12
12
  # positioned by {Screen} after each repaint cycle when this component is
13
13
  # focused; see {Component#cursor_position}.
14
- class TextField < Component
14
+ class TextField < TextInput
15
15
  def initialize
16
16
  super
17
- @text = +""
18
- @caret = 0
19
- @on_escape = nil
20
- @on_change = nil
21
17
  @on_key_up = nil
22
18
  @on_key_down = nil
23
19
  @on_enter = nil
24
20
  end
25
21
 
26
- # @return [String] current text contents.
27
- attr_reader :text
28
-
29
- # @return [Integer] caret index in `0..text.length`.
30
- attr_reader :caret
31
-
32
- # Optional callback fired when ESC is pressed. When set, ESC is consumed
33
- # by the field; when nil, ESC falls through to the parent (default
34
- # behavior).
35
- # @return [Proc, Method, nil] no-arg callable, or nil.
36
- attr_accessor :on_escape
37
-
38
- # Optional callback fired whenever {#text} changes. Receives the new text
39
- # as a single argument. Not fired by {#caret=} (text unchanged) and not
40
- # fired when a setter is a no-op.
41
- # @return [Proc, Method, nil] one-arg callable, or nil.
42
- attr_accessor :on_change
43
-
44
22
  # Optional callback fired when the UP arrow key is pressed. When set, UP
45
23
  # is consumed by the field; when nil, UP falls through to the parent
46
24
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -61,60 +39,50 @@ module Tuile
61
39
  # @return [Proc, Method, nil] no-arg callable, or nil.
62
40
  attr_accessor :on_enter
63
41
 
64
- # Sets the text. Truncates to fit if longer than `rect.width - 1`. Caret
65
- # is clamped to the new text length.
66
- # @param new_text [String]
67
- def text=(new_text)
68
- new_text = new_text.to_s
69
- new_text = new_text[0, max_text_length] if new_text.length > max_text_length
70
- return if @text == new_text
42
+ # @return [Point, nil]
43
+ def cursor_position
44
+ return nil unless rect.width.positive?
71
45
 
72
- @text = +new_text
73
- @caret = @caret.clamp(0, @text.length)
74
- invalidate
75
- @on_change&.call(@text)
46
+ Point.new(rect.left + @caret, rect.top)
76
47
  end
77
48
 
78
- # Sets the caret position. Clamped to `0..text.length`.
79
- # @param new_caret [Integer]
80
- def caret=(new_caret)
81
- new_caret = new_caret.clamp(0, @text.length)
82
- return if @caret == new_caret
49
+ # @param event [MouseEvent]
50
+ # @return [void]
51
+ def handle_mouse(event)
52
+ super
53
+ return unless event.button == :left && rect.contains?(event.point)
83
54
 
84
- @caret = new_caret
85
- invalidate
55
+ self.caret = (event.x - rect.left).clamp(0, @text.length)
86
56
  end
87
57
 
88
- def focusable? = true
58
+ # @return [void]
59
+ def repaint
60
+ return if rect.empty?
89
61
 
90
- def tab_stop? = true
62
+ bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
63
+ padded = @text + (" " * (rect.width - @text.length))
64
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
65
+ end
91
66
 
92
- # @return [Point, nil]
93
- def cursor_position
94
- return nil unless rect.width.positive?
67
+ protected
95
68
 
96
- Point.new(rect.left + @caret, rect.top)
69
+ # Truncate to fit `rect.width - 1` — single-line fields can't grow past
70
+ # their width.
71
+ # @param new_text [String]
72
+ # @return [String]
73
+ def preprocess_text(new_text)
74
+ new_text = new_text.to_s
75
+ new_text.length > max_text_length ? new_text[0, max_text_length] : new_text
97
76
  end
98
77
 
99
78
  # @param key [String]
100
79
  # @return [Boolean]
101
- def handle_key(key)
102
- return false unless active?
103
- return true if super
104
-
80
+ def handle_text_input_key(key)
105
81
  case key
106
- when Keys::LEFT_ARROW then self.caret = @caret - 1
107
- when Keys::RIGHT_ARROW then self.caret = @caret + 1
108
- when Keys::CTRL_LEFT_ARROW then self.caret = word_left
109
- when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
110
82
  when *Keys::HOMES then self.caret = 0
111
83
  when *Keys::ENDS_ then self.caret = @text.length
112
84
  when *Keys::BACKSPACES then delete_before_caret
113
85
  when Keys::DELETE then delete_at_caret
114
- when Keys::ESC
115
- return false if @on_escape.nil?
116
-
117
- @on_escape.call
118
86
  when Keys::UP_ARROW
119
87
  return false if @on_key_up.nil?
120
88
 
@@ -128,48 +96,13 @@ module Tuile
128
96
 
129
97
  @on_enter.call
130
98
  else
131
- return insert(key) if printable?(key)
99
+ return insert(key) if Keys.printable?(key)
132
100
 
133
- return false
101
+ return super
134
102
  end
135
103
  true
136
104
  end
137
105
 
138
- # @param event [MouseEvent]
139
- # @return [void]
140
- def handle_mouse(event)
141
- super
142
- return unless event.button == :left && rect.contains?(event.point)
143
-
144
- self.caret = (event.x - rect.left).clamp(0, @text.length)
145
- end
146
-
147
- # 256-color SGR for the focused-button highlight (matches what
148
- # `Rainbow(...).bg(:darkslategray)` emits, which is what
149
- # {Component::Button#repaint} uses for its focused state).
150
- # @return [String]
151
- ACTIVE_BG_SGR = "\e[48;5;59m"
152
- # 256-color SGR for the unfocused field's "well": index 238 sits in
153
- # the grayscale ramp (~#444444), bright enough to stand out against
154
- # non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
155
- # backgrounds sit in the #1d–#2d range), and still distinctly darker
156
- # than the active highlight at index 59 (~#5f5f5f). Rainbow's
157
- # RGB-to-256 mapping snaps everything dark to palette index 16
158
- # (terminal black), so we emit the escape directly to reach the ramp.
159
- # @return [String]
160
- INACTIVE_BG_SGR = "\e[48;5;238m"
161
-
162
- # @return [void]
163
- def repaint
164
- return if rect.empty?
165
-
166
- bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
167
- padded = @text + (" " * (rect.width - @text.length))
168
- screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
169
- end
170
-
171
- protected
172
-
173
106
  # @return [void]
174
107
  def on_width_changed
175
108
  super
@@ -191,61 +124,11 @@ module Tuile
191
124
  def insert(char)
192
125
  return false if @text.length >= max_text_length
193
126
 
194
- @text = @text.dup.insert(@caret, char)
127
+ new_text = @text.dup.insert(@caret, char)
195
128
  @caret += 1
196
- invalidate
197
- @on_change&.call(@text)
129
+ self.text = new_text
198
130
  true
199
131
  end
200
-
201
- # @return [void]
202
- def delete_before_caret
203
- return if @caret.zero?
204
-
205
- @text = @text.dup
206
- @text.slice!(@caret - 1)
207
- @caret -= 1
208
- invalidate
209
- @on_change&.call(@text)
210
- end
211
-
212
- # @return [void]
213
- def delete_at_caret
214
- return if @caret >= @text.length
215
-
216
- @text = @text.dup
217
- @text.slice!(@caret)
218
- invalidate
219
- @on_change&.call(@text)
220
- end
221
-
222
- # @param key [String]
223
- # @return [Boolean]
224
- def printable?(key)
225
- key.length == 1 && key.ord >= 0x20 && key.ord < 0x7f
226
- end
227
-
228
- # Caret target for ctrl+left: skip whitespace going left, then a run of
229
- # non-whitespace. Lands at the beginning of the current word, or the
230
- # beginning of the previous word if already there.
231
- # @return [Integer]
232
- def word_left
233
- c = @caret
234
- c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
235
- c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
236
- c
237
- end
238
-
239
- # Caret target for ctrl+right: skip non-whitespace going right, then a
240
- # run of whitespace. Lands at the beginning of the next word, or at the
241
- # end of the text if no further word exists.
242
- # @return [Integer]
243
- def word_right
244
- c = @caret
245
- c += 1 while c < @text.length && !@text[c].match?(/\s/)
246
- c += 1 while c < @text.length && @text[c].match?(/\s/)
247
- c
248
- end
249
132
  end
250
133
  end
251
134
  end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Abstract base for editable text components ({TextField}, {TextArea}).
6
+ #
7
+ # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
8
+ # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
9
+ # that single-line and multi-line inputs both need: ESC handling,
10
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
11
+ # `focusable?`/`tab_stop?` flags.
12
+ #
13
+ # Subclasses implement the layout-specific pieces ({#cursor_position},
14
+ # {#repaint}) and add their own keys (HOME/END, ENTER, UP/DOWN,
15
+ # printable insertion) by overriding the protected
16
+ # {#handle_text_input_key} hook — `super` falls through to the common
17
+ # navigation handling.
18
+ #
19
+ # The mutation pipeline is a template method: {#text=} and {#caret=}
20
+ # detect no-ops, mutate state, fire {#on_change}, and invalidate.
21
+ # Subclasses inject their own behavior via two protected hooks:
22
+ #
23
+ # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
24
+ # fit `rect.width - 1`).
25
+ # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
26
+ # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
27
+ # keep the caret visible).
28
+ class TextInput < Component
29
+ def initialize
30
+ super
31
+ @text = +""
32
+ @caret = 0
33
+ @on_change = nil
34
+ @on_escape = method(:default_on_escape)
35
+ end
36
+
37
+ # @return [String] current text contents.
38
+ attr_reader :text
39
+
40
+ # @return [Boolean] true iff {#text} is the empty string.
41
+ def empty? = @text.empty?
42
+
43
+ # @return [Integer] caret index in `0..text.length`.
44
+ attr_reader :caret
45
+
46
+ # Optional callback fired whenever {#text} changes. Receives the new text
47
+ # as a single argument. Not fired by {#caret=} (text unchanged) and not
48
+ # fired when a setter is a no-op.
49
+ # @return [Proc, Method, nil] one-arg callable, or nil.
50
+ attr_accessor :on_change
51
+
52
+ # Callback fired when ESC is pressed. Defaults to a closure that clears
53
+ # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
54
+ # of bubbling to the parent — and, in particular, instead of reaching the
55
+ # screen's default ESC-to-quit handler. Set to nil to let ESC fall through
56
+ # to the parent again; set to any other callable to replace the default.
57
+ # @return [Proc, Method, nil] no-arg callable, or nil.
58
+ attr_accessor :on_escape
59
+
60
+ def focusable? = true
61
+
62
+ def tab_stop? = true
63
+
64
+ # Sets the text. Runs {#preprocess_text} first (subclasses may filter or
65
+ # truncate). Caret is clamped to the new text length. Fires {#on_change}
66
+ # only on a real change.
67
+ # @param new_text [String]
68
+ def text=(new_text)
69
+ new_text = preprocess_text(new_text)
70
+ return if @text == new_text
71
+
72
+ @text = +new_text
73
+ @caret = @caret.clamp(0, @text.length)
74
+ on_text_mutated
75
+ invalidate
76
+ @on_change&.call(@text)
77
+ end
78
+
79
+ # Sets the caret position. Clamped to `0..text.length`. Fires
80
+ # {#on_caret_mutated} hook for subclasses (e.g. {TextArea} scrolls).
81
+ # @param new_caret [Integer]
82
+ def caret=(new_caret)
83
+ new_caret = new_caret.clamp(0, @text.length)
84
+ return if @caret == new_caret
85
+
86
+ @caret = new_caret
87
+ on_caret_mutated
88
+ invalidate
89
+ end
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
+ # Handles a key. Returns false when the component is inactive. Otherwise
107
+ # first runs the {Component#handle_key} shortcut search via `super`, then
108
+ # delegates to {#handle_text_input_key}.
109
+ # @param key [String]
110
+ # @return [Boolean]
111
+ def handle_key(key)
112
+ return false unless active?
113
+ return true if super
114
+
115
+ handle_text_input_key(key)
116
+ end
117
+
118
+ protected
119
+
120
+ # Input filter for {#text=}. Subclasses override to truncate or reject
121
+ # invalid input. Default coerces to String.
122
+ # @param new_text [String]
123
+ # @return [String] possibly transformed text.
124
+ def preprocess_text(new_text) = new_text.to_s
125
+
126
+ # Hook called after {#text} has been mutated, before invalidation /
127
+ # {#on_change}. Default no-op. Subclasses use this to invalidate caches
128
+ # ({TextArea}'s wrap cache) and update derived state.
129
+ # @return [void]
130
+ def on_text_mutated; end
131
+
132
+ # Hook called after {#caret} has been mutated, before invalidation.
133
+ # Default no-op. Subclasses use this to keep the caret visible
134
+ # ({TextArea}'s vertical scroll).
135
+ # @return [void]
136
+ def on_caret_mutated; end
137
+
138
+ # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
139
+ # that have identical semantics in single-line and multi-line inputs:
140
+ # LEFT/RIGHT arrows, CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
141
+ # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
142
+ # DELETE, printable insertion) and call `super` to fall back to the
143
+ # common navigation handling.
144
+ # @param key [String]
145
+ # @return [Boolean] true if the key was handled.
146
+ def handle_text_input_key(key)
147
+ case key
148
+ when Keys::LEFT_ARROW then self.caret = @caret - 1
149
+ when Keys::RIGHT_ARROW then self.caret = @caret + 1
150
+ when Keys::CTRL_LEFT_ARROW then self.caret = word_left
151
+ when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
152
+ when Keys::ESC
153
+ return false if @on_escape.nil?
154
+
155
+ @on_escape.call
156
+ else
157
+ return false
158
+ end
159
+ true
160
+ end
161
+
162
+ # @return [void]
163
+ def delete_before_caret
164
+ return if @caret.zero?
165
+
166
+ new_text = @text.dup
167
+ new_text.slice!(@caret - 1)
168
+ @caret -= 1
169
+ self.text = new_text
170
+ end
171
+
172
+ # @return [void]
173
+ def delete_at_caret
174
+ return if @caret >= @text.length
175
+
176
+ new_text = @text.dup
177
+ new_text.slice!(@caret)
178
+ self.text = new_text
179
+ end
180
+
181
+ private
182
+
183
+ # Default {#on_escape} action: clear focus. Component deactivates; user
184
+ # can re-focus by clicking or tabbing back in.
185
+ # @return [void]
186
+ def default_on_escape
187
+ screen.focused = nil
188
+ end
189
+
190
+ # Caret target for ctrl+left: skip whitespace going left, then a run of
191
+ # non-whitespace. Lands at the beginning of the current word, or the
192
+ # beginning of the previous word if already there.
193
+ # @return [Integer]
194
+ def word_left
195
+ c = @caret
196
+ c -= 1 while c.positive? && @text[c - 1].match?(/\s/)
197
+ c -= 1 while c.positive? && !@text[c - 1].match?(/\s/)
198
+ c
199
+ end
200
+
201
+ # Caret target for ctrl+right: skip non-whitespace going right, then a
202
+ # run of whitespace. Lands at the beginning of the next word, or at the
203
+ # end of the text if no further word exists.
204
+ # @return [Integer]
205
+ def word_right
206
+ c = @caret
207
+ c += 1 while c < @text.length && !@text[c].match?(/\s/)
208
+ c += 1 while c < @text.length && @text[c].match?(/\s/)
209
+ c
210
+ end
211
+ end
212
+ end
213
+ end
@@ -12,9 +12,19 @@ module Tuile
12
12
  # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
13
13
  # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
14
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.
15
+ # always returns the {StyledString}.
16
+ #
17
+ # For incremental updates pick the right primitive: {#append} (aliased
18
+ # as `<<`) is verbatim and stream-friendly — chunks are concatenated
19
+ # straight onto the buffer, with embedded `\n` becoming hard breaks.
20
+ # {#add_line} is the "log entry" convenience — it starts the content on
21
+ # a fresh line by inserting a leading `\n` when the buffer is non-empty.
22
+ # {#remove_last_n_lines} pops hard lines back off the tail — the
23
+ # inverse of building up a region with {#append} / {#add_line}, so a
24
+ # caller streaming reformattable content (e.g. partially-rendered
25
+ # Markdown that may need to retract its last paragraph) can replace
26
+ # the tail without rewriting the whole text. Turn on {#auto_scroll}
27
+ # to keep the latest content in view.
18
28
  #
19
29
  # TextView is meant to be the content of a {Window} — focus indication and
20
30
  # keyboard-hint surfacing rely on the surrounding window chrome.
@@ -76,35 +86,117 @@ module Tuile
76
86
  invalidate
77
87
  end
78
88
 
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=}.
89
+ # @return [Boolean] true iff {#text} is empty (no hard lines).
90
+ def empty? = @hard_lines.empty?
91
+
92
+ # Appends `str` verbatim. Embedded `\n` characters become hard line
93
+ # breaks; otherwise the text is concatenated onto the current last
94
+ # hard line. Designed for streaming use (e.g. an LLM chat window
95
+ # receiving partial messages — feed each chunk straight in). Accepts
96
+ # the same input forms as {#text=}; empty/`nil` input is a no-op.
97
+ #
98
+ # For the "add an entry on a new line" pattern use {#add_line}.
83
99
  #
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.
100
+ # Cost is O(appended + width-of-current-last-hard-line) — the
101
+ # previously last hard line is re-wrapped (because the extension may
102
+ # cause it to wrap differently), any additional hard lines created by
103
+ # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
104
+ # and rebuilt on demand.
88
105
  # @param str [String, StyledString, nil]
89
106
  # @return [void]
90
107
  def append(str)
91
108
  screen.check_locked
92
109
  appended = StyledString.parse(str)
93
- if @hard_lines.empty?
94
- self.text = appended
95
- return
110
+ return if appended.empty?
111
+
112
+ new_segments = appended.lines
113
+ width = wrap_width
114
+
115
+ if empty?
116
+ new_segments.each do |hl|
117
+ @hard_lines << hl
118
+ append_physical_lines(hl, width)
119
+ end
120
+ else
121
+ extension = new_segments.first
122
+ unless extension.empty?
123
+ old_last = @hard_lines.pop
124
+ drop_physical_rows_for(old_last, width)
125
+ extended = old_last + extension
126
+ @hard_lines << extended
127
+ append_physical_lines(extended, width)
128
+ end
129
+ new_segments[1..].each do |hl|
130
+ @hard_lines << hl
131
+ append_physical_lines(hl, width)
132
+ end
96
133
  end
97
134
 
98
- new_hard_lines = appended.lines
99
135
  @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
- )
136
+ @content_size = compute_content_size
137
+ update_top_line_if_auto_scroll
138
+ invalidate
139
+ end
140
+
141
+ # Verbatim append, returning `self` for chainability (`view << a << b`).
142
+ # @param str [String, StyledString, nil]
143
+ # @return [self]
144
+ def <<(str)
145
+ append(str)
146
+ self
147
+ end
148
+
149
+ # Appends `str` as a new entry: starts a fresh hard line first (when
150
+ # the buffer is non-empty) and then appends `str`. Equivalent to
151
+ # `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
152
+ # empty one. `nil` and `""` produce a blank entry on a non-empty
153
+ # buffer and a no-op on an empty buffer (matches the old `append`
154
+ # semantics for "log line" callers).
155
+ # @param str [String, StyledString, nil]
156
+ # @return [void]
157
+ def add_line(str)
158
+ parsed = StyledString.parse(str)
159
+ if empty?
160
+ append(parsed)
161
+ else
162
+ append(StyledString.plain("\n") + parsed)
163
+ end
164
+ end
165
+
166
+ # Drops the last `n` hard lines from the buffer. The inverse of
167
+ # building up a tail region with {#append} / {#add_line}: a caller
168
+ # streaming partially-rendered content whose tail must occasionally
169
+ # be retracted (e.g. Markdown-to-ANSI where a new token reformats
170
+ # the table being built) can call `remove_last_n_lines(k)` followed
171
+ # by `append(new_tail)` to replace the damaged region in place.
172
+ #
173
+ # `n == 0` and the empty-buffer case are no-ops (no invalidation).
174
+ # `n >= hard-line count` empties the buffer.
175
+ #
176
+ # Operates on **hard lines** (the `\n`-delimited entries the
177
+ # buffer stores), not on wrapped physical rows — same granularity
178
+ # as {#add_line}. Cost is O(rendered-rows of the popped lines).
179
+ # @param n [Integer] number of hard lines to drop; must be >= 0.
180
+ # @raise [TypeError] if `n` isn't an `Integer`.
181
+ # @raise [ArgumentError] if `n` is negative.
182
+ # @return [void]
183
+ def remove_last_n_lines(n)
184
+ raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
185
+ raise ArgumentError, "n must not be negative, got #{n}" if n.negative?
186
+
187
+ screen.check_locked
188
+ return if n.zero? || empty?
189
+
106
190
  width = wrap_width
107
- new_hard_lines.each { |hl| append_physical_lines(hl, width) }
191
+ to_drop = [n, @hard_lines.size].min
192
+ to_drop.times do
193
+ popped = @hard_lines.pop
194
+ drop_physical_rows_for(popped, width)
195
+ end
196
+
197
+ @text = nil
198
+ @content_size = compute_content_size
199
+ @top_line = top_line_max if @top_line > top_line_max
108
200
  update_top_line_if_auto_scroll
109
201
  invalidate
110
202
  end
@@ -255,6 +347,19 @@ module Tuile
255
347
  end
256
348
  end
257
349
 
350
+ # Pops from {@physical_lines} the rows that `hard_line` previously
351
+ # contributed (the inverse of {#append_physical_lines} for the same
352
+ # input). Used by {#append} when extending the last hard line: its
353
+ # old wrapped rows are dropped, then the extended hard line is
354
+ # re-wrapped and appended.
355
+ # @param hard_line [StyledString]
356
+ # @param width [Integer]
357
+ # @return [void]
358
+ def drop_physical_rows_for(hard_line, width)
359
+ count = hard_line.empty? || width <= 0 ? 1 : hard_line.wrap(width).size
360
+ count.times { @physical_lines.pop }
361
+ end
362
+
258
363
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
259
364
  # default-styled `"\n"` between hard lines. Called from the {#text}
260
365
  # reader when the cache is cold. Cost is O(total spans).
@@ -8,8 +8,9 @@ module Tuile
8
8
  #
9
9
  # The window's `content` is unset by default; assign one via {#content=}.
10
10
  #
11
- # Window is considered invisible if {#rect} is empty or one of left/top is
12
- # negative. The window won't draw when invisible.
11
+ # Window is considered invisible if {#rect} is empty. The window won't
12
+ # draw when invisible. (Repaint of detached windows is short-circuited
13
+ # by {Component#invalidate}; subclasses don't need to re-check.)
13
14
  class Window < Component
14
15
  include Component::HasContent
15
16
 
@@ -125,12 +126,6 @@ module Tuile
125
126
  Size.new(inner_w + 2, inner_h + 2)
126
127
  end
127
128
 
128
- # @return [Boolean] true if {#rect} is off screen and the window won't
129
- # paint.
130
- def visible?
131
- !@rect.empty? && !@rect.top.negative? && !@rect.left.negative?
132
- end
133
-
134
129
  # Fully repaints the window: both frame and contents.
135
130
  #
136
131
  # Window deliberately paints over its entire rect (border around the
@@ -143,7 +138,7 @@ module Tuile
143
138
  # cycle.
144
139
  # @return [void]
145
140
  def repaint
146
- return unless visible?
141
+ return if rect.empty?
147
142
 
148
143
  super
149
144
  repaint_border
@@ -168,7 +163,7 @@ module Tuile
168
163
  # Paints the window border.
169
164
  # @return [void]
170
165
  def repaint_border
171
- return unless visible?
166
+ return if rect.empty?
172
167
 
173
168
  frame = build_frame(frame_caption)
174
169
  frame = Rainbow(frame).green if active?