tuile 0.7.0 → 0.8.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,10 +2,20 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A modal overlay that wraps any {Component} as its content. Popup itself
6
- # paints nothing — it's a transparent host that handles modality
7
- # ({#open} / {#close} / {#open?}, ESC/q to close), centers itself on the
8
- # screen, and auto-sizes to the wrapped content.
5
+ # An overlay that wraps any {Component} as its content. Popup itself
6
+ # paints nothing — it's a transparent host that handles its lifecycle
7
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
8
+ # wrapped content.
9
+ #
10
+ # Modal by default: it centers on the screen, grabs focus, eats keys, and
11
+ # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
12
+ # that floats above the content (still painted on top, still auto-sized)
13
+ # without taking focus or capturing input — the caller positions it (via
14
+ # {#rect=}) and drives it from app code. That is the building block for an
15
+ # autocomplete/slash-command list anchored to a {Component::TextField} or
16
+ # {Component::TextArea} caret: typing keeps focus (and the cursor) in the
17
+ # input, an {Component::TextInput#on_change} listener refills the list, and
18
+ # an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
9
19
  #
10
20
  # The wrapped content fills the popup's full {#rect}; if you want a frame
11
21
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -27,12 +37,19 @@ module Tuile
27
37
 
28
38
  # @param content [Component, nil] initial content; can be set later via
29
39
  # {#content=}. When provided here, the popup auto-sizes to fit.
30
- def initialize(content: nil)
40
+ # @param modal [Boolean] true (default) for a centered, focus-grabbing,
41
+ # input-capturing modal; false for a non-modal overlay the caller
42
+ # positions and drives (see the class docs).
43
+ def initialize(content: nil, modal: true)
31
44
  super()
45
+ @modal = modal
32
46
  @content = nil
33
47
  self.content = content unless content.nil?
34
48
  end
35
49
 
50
+ # @return [Boolean] whether this popup is modal. See {#initialize}.
51
+ def modal? = @modal
52
+
36
53
  def focusable? = true
37
54
 
38
55
  # Reassigns the popup's rect, escalating to a full scene repaint when an
@@ -85,9 +102,20 @@ module Tuile
85
102
  self.rect = rect.centered(screen.size)
86
103
  end
87
104
 
88
- # @return [Integer] max height the popup will grow to fit its content,
89
- # defaults to 12. Override in a subclass to allow taller popups.
90
- def max_height = 12
105
+ # @return [Integer] max height the popup will grow to fit its content.
106
+ # Defers to the content's {Component#popup_max_height} advice when it
107
+ # gives one, else defaults to 12. Override in a subclass to allow
108
+ # taller popups regardless of content.
109
+ def max_height = @content&.popup_max_height || 12
110
+
111
+ # @return [Integer] min height the popup occupies even when its content
112
+ # is shorter. Defers to the content's {Component#popup_min_height}
113
+ # advice when it gives one, else defaults to 0 (size purely to
114
+ # content) — so a {Component::LogWindow} stays readable while only a
115
+ # few lines are in without callers wiring up a subclass. Override in a
116
+ # subclass to keep any popup from collapsing to a couple of rows.
117
+ # Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
118
+ def min_height = @content&.popup_min_height || 0
91
119
 
92
120
  # Sets the popup's content and auto-sizes the popup to fit.
93
121
  # @param new_content [Component, nil]
@@ -114,11 +142,12 @@ module Tuile
114
142
  child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
115
143
  end
116
144
 
145
+ # `q` and ESC close the popup. The popup sits on the focus chain of
146
+ # whatever it wraps, so the key reaches here by bubbling up from the
147
+ # focused content after that content declined to handle it.
117
148
  # @param key [String]
118
149
  # @return [Boolean] true if the key was handled.
119
150
  def handle_key(key)
120
- return true if super
121
-
122
151
  if [Keys::ESC, "q"].include?(key)
123
152
  close
124
153
  true
@@ -147,10 +176,16 @@ module Tuile
147
176
  # {#rect=}'s shrink/move detection fire a full repaint on every resize.
148
177
  # @return [void]
149
178
  def update_rect
179
+ ceiling = screen.size.height * 4 / 5
150
180
  size = @content.content_size.clamp_height(max_height)
151
- size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
152
- r = Rect.new(0, 0, size.width, size.height)
153
- r = r.centered(screen.size) if open?
181
+ size = size.clamp(Size.new(screen.size.width * 4 / 5, ceiling))
182
+ floor = min_height.clamp(0, ceiling)
183
+ size = Size.new(size.width, floor) if size.height < floor
184
+ # A non-modal overlay is positioned by the caller, so an open one keeps
185
+ # its current top-left when its content resizes; a modal popup recenters.
186
+ origin = open? && !modal? ? Point.new(rect.left, rect.top) : Point.new(0, 0)
187
+ r = Rect.new(origin.x, origin.y, size.width, size.height)
188
+ r = r.centered(screen.size) if open? && modal?
154
189
  self.rect = r
155
190
  end
156
191
  end
@@ -80,7 +80,7 @@ module Tuile
80
80
  chunk = @text[r[:start], r[:length]] || ""
81
81
  chunk + (" " * (rect.width - r[:length]))
82
82
  end
83
- screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), background(line)
83
+ screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
84
84
  end
85
85
  end
86
86
 
@@ -60,7 +60,7 @@ module Tuile
60
60
  return if rect.empty?
61
61
 
62
62
  padded = @text + (" " * (rect.width - @text.length))
63
- screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
63
+ screen.buffer.set_line(rect.left, rect.top, background(padded))
64
64
  end
65
65
 
66
66
  protected
@@ -31,6 +31,7 @@ module Tuile
31
31
  @text = +""
32
32
  @caret = 0
33
33
  @on_change = nil
34
+ @on_key = nil
34
35
  @on_escape = method(:default_on_escape)
35
36
  end
36
37
 
@@ -49,6 +50,20 @@ module Tuile
49
50
  # @return [Proc, Method, nil] one-arg callable, or nil.
50
51
  attr_accessor :on_change
51
52
 
53
+ # Optional interceptor consulted before the input's own key handling.
54
+ # Receives the pressed key; return a truthy value to consume it (the
55
+ # input then ignores that key), falsy to let normal editing proceed.
56
+ #
57
+ # The keyboard analog of {#on_change}: it lets app code layer behavior
58
+ # onto an input without subclassing. The motivating case is an
59
+ # autocomplete / slash-command overlay (a non-modal {Component::Popup}):
60
+ # while it is open the interceptor claims Up/Down/Enter/ESC and forwards
61
+ # them to the overlay's list, but lets ordinary characters fall through
62
+ # so typing keeps editing the field (and {#on_change} keeps refilling the
63
+ # list).
64
+ # @return [Proc, Method, nil] one-arg callable, or nil.
65
+ attr_accessor :on_key
66
+
52
67
  # Callback fired when ESC is pressed. Defaults to a closure that clears
53
68
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
54
69
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -88,14 +103,15 @@ module Tuile
88
103
  invalidate
89
104
  end
90
105
 
91
- # Handles a key. Returns false when the component is inactive. Otherwise
92
- # first runs the {Component#handle_key} shortcut search via `super`, then
93
- # delegates to {#handle_text_input_key}.
106
+ # Handles a key. An {#on_key} interceptor (if set) gets first refusal —
107
+ # a truthy return consumes the key otherwise it delegates to
108
+ # {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
109
+ # keys here when this input is on the focus chain, so there is no
110
+ # {#active?} gate.
94
111
  # @param key [String]
95
112
  # @return [Boolean]
96
113
  def handle_key(key)
97
- return false unless active?
98
- return true if super
114
+ return true if @on_key&.call(key)
99
115
 
100
116
  handle_text_input_key(key)
101
117
  end
@@ -103,13 +119,13 @@ module Tuile
103
119
  protected
104
120
 
105
121
  # 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 —
122
+ # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
123
+ # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
108
124
  # visibly a field either way, distinctly highlighted when active.
109
125
  # @param text [String]
110
- # @return [String] ANSI-rendered text.
126
+ # @return [StyledString] text on the field's background well.
111
127
  def background(text)
112
- active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
128
+ StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
113
129
  end
114
130
 
115
131
  # Input filter for {#text=}. Subclasses override to truncate or reject
@@ -437,7 +437,7 @@ module Tuile
437
437
  end
438
438
  (0...rect.height).each do |row|
439
439
  line = paintable_line(row + @top_line, row, scrollbar)
440
- screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
440
+ screen.buffer.set_line(rect.left, rect.top + row, line)
441
441
  end
442
442
  end
443
443
 
@@ -904,15 +904,14 @@ module Tuile
904
904
  # @param index [Integer] 0-based index into `@physical_lines`.
905
905
  # @param row_in_viewport [Integer] 0-based row within the viewport.
906
906
  # @param scrollbar [VerticalScrollBar, nil]
907
- # @return [String] paintable ANSI-encoded line exactly `rect.width`
908
- # columns wide. Body lines come pre-padded from {#rewrap}, so this
909
- # reduces to a memoized {StyledString#to_ansi} read plus an
910
- # ASCII-string concat of the scrollbar glyph when one is present.
907
+ # @return [StyledString] paintable line exactly `rect.width` columns wide.
908
+ # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
909
+ # plus a concat of the scrollbar glyph when one is present.
911
910
  def paintable_line(index, row_in_viewport, scrollbar)
912
911
  line = @physical_lines[index] || @blank_line
913
- return line.to_ansi unless scrollbar
912
+ return line unless scrollbar
914
913
 
915
- line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
914
+ line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
916
915
  end
917
916
 
918
917
  # A logical section of a {TextView}'s text — a contiguous run of
@@ -83,14 +83,6 @@ module Tuile
83
83
  @footer.nil? ? super : super + [@footer]
84
84
  end
85
85
 
86
- # @param key [String]
87
- # @return [Boolean]
88
- def handle_key(key)
89
- return @footer.handle_key(key) if @footer&.active?
90
-
91
- super
92
- end
93
-
94
86
  # @param event [MouseEvent]
95
87
  # @return [void]
96
88
  def handle_mouse(event)
@@ -197,14 +189,31 @@ module Tuile
197
189
  content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
198
190
  end
199
191
 
200
- # Paints the window border.
192
+ # Paints the window border into the {Screen#buffer}. Title is clipped to
193
+ # the inner width so the box never overflows {#rect}; when the window is
194
+ # active the whole border is drawn in {Theme#active_border_color}.
201
195
  # @return [void]
202
196
  def repaint_border
203
197
  return if rect.empty?
204
198
 
205
- frame = build_frame(frame_caption)
206
- frame = screen.theme.active_border(frame) if active?
207
- screen.print frame
199
+ w = rect.width
200
+ h = rect.height
201
+ top = rect.top
202
+ left = rect.left
203
+ inner_w = [w - 2, 0].max
204
+ title = frame_caption.to_s
205
+ title = title[0, inner_w] if title.length > inner_w
206
+ dashes = "─" * (inner_w - title.length)
207
+
208
+ fg = active? ? screen.theme.active_border_color : nil
209
+ bar = StyledString::Style.new(fg: fg)
210
+ buf = screen.buffer
211
+ buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
212
+ (1..(h - 2)).each do |dy|
213
+ buf.set_char(left, top + dy, "│", bar)
214
+ buf.set_char(left + w - 1, top + dy, "│", bar)
215
+ end
216
+ buf.set_line(left, top + h - 1, StyledString.styled("└#{"─" * inner_w}┘", fg: fg)) if h >= 2
208
217
  end
209
218
 
210
219
  # The caption text as it appears in the rendered border, including the
@@ -215,32 +224,6 @@ module Tuile
215
224
  key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
216
225
  end
217
226
 
218
- # Builds the border as a single string with embedded cursor-positioning
219
- # escapes, mirroring the layout {TTY::Box.frame} used to produce. Title
220
- # is clipped to fit the inner width so the box never overflows {#rect}.
221
- # @param caption [String]
222
- # @return [String]
223
- def build_frame(caption)
224
- w = @rect.width
225
- h = @rect.height
226
- top = @rect.top
227
- left = @rect.left
228
- inner_w = [w - 2, 0].max
229
-
230
- title = caption.to_s
231
- title = title[0, inner_w] if title.length > inner_w
232
- dashes = "─" * (inner_w - title.length)
233
-
234
- out = +""
235
- out << TTY::Cursor.move_to(left, top) << "┌#{title}#{dashes}┐"
236
- (1..(h - 2)).each do |dy|
237
- out << TTY::Cursor.move_to(left, top + dy) << "│"
238
- out << TTY::Cursor.move_to(left + w - 1, top + dy) << "│"
239
- end
240
- out << TTY::Cursor.move_to(left, top + h - 1) << "└#{"─" * inner_w}┘" if h >= 2
241
- out
242
- end
243
-
244
227
  private
245
228
 
246
229
  # Recomputes the window's natural size: content's natural size (or the
@@ -81,27 +81,22 @@ module Tuile
81
81
  children.each { |c| screen.invalidate(c) }
82
82
  end
83
83
 
84
- # Called when a character is pressed on the keyboard.
84
+ # Called when a character is pressed on the keyboard. The default does
85
+ # nothing and reports the key as unhandled; input components
86
+ # ({Component::TextField}, {Component::List}, {Component::Button}, …)
87
+ # override it to act on keys they care about.
85
88
  #
86
- # Also called for inactive components. Inactive component should just return
87
- # false.
88
- #
89
- # Default implementation searches for a component with {#key_shortcut} and
90
- # focuses it. The shortcut search is suppressed while the focused component
91
- # owns the hardware cursor (e.g. a {Component::TextField} the user is
92
- # typing into) so that hotkeys don't steal printable keys from the editor.
93
- # @param key [String] a key.
89
+ # Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
90
+ # anywhere in the active scope is captured first (suppressed while a
91
+ # cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
92
+ # and bubbles up its ancestor chain until some component handles it. A
93
+ # component therefore only ever receives keys when it is on the focus chain
94
+ # or when app code hands it a key directly — so it acts on the key alone
95
+ # and must never gate on its own {#active?} state.
96
+ # @param _key [String] a key.
94
97
  # @return [Boolean] true if the key was handled, false if not.
95
- def handle_key(key)
96
- return false unless screen.cursor_position.nil?
97
-
98
- c = find_shortcut_component(key)
99
- if !c.nil?
100
- screen.focused = c
101
- true
102
- else
103
- false
104
- end
98
+ def handle_key(_key)
99
+ false
105
100
  end
106
101
 
107
102
  # A global keyboard shortcut. When pressed, will focus this component.
@@ -293,6 +288,20 @@ module Tuile
293
288
  # topmost popup. Empty by default; override to advertise shortcuts.
294
289
  def keyboard_hint = ""
295
290
 
291
+ # Advice to a wrapping {Component::Popup} on the minimum height this
292
+ # component prefers to occupy when shown in a popup. `nil` (the default)
293
+ # means no preference — the popup uses its own {Component::Popup#min_height}.
294
+ # Override in a content component that should not collapse to a couple of
295
+ # rows when sparse (e.g. {Component::LogWindow}).
296
+ # @return [Integer, nil]
297
+ def popup_min_height = nil
298
+
299
+ # Advice to a wrapping {Component::Popup} on the maximum height this
300
+ # component may grow to when shown in a popup. `nil` (the default) means
301
+ # no preference — the popup uses its own {Component::Popup#max_height}.
302
+ # @return [Integer, nil]
303
+ def popup_max_height = nil
304
+
296
305
  protected
297
306
 
298
307
  # @param parent [Component, nil]
@@ -354,12 +363,7 @@ module Tuile
354
363
  # component's rect.
355
364
  # @return [void]
356
365
  def clear_background
357
- return if rect.empty?
358
-
359
- spaces = " " * rect.width
360
- (rect.top..(rect.top + rect.height - 1)).each do |row|
361
- screen.print TTY::Cursor.move_to(rect.left, row), spaces
362
- end
366
+ screen.buffer.fill(rect)
363
367
  end
364
368
  end
365
369
  end
@@ -25,10 +25,14 @@ module Tuile
25
25
  super
26
26
  @event_queue = FakeEventQueue.new
27
27
  @size = Size.new(160, 50)
28
+ @buffer.resize(@size) # super sized it to the test runner's TTY
28
29
  @prints = []
29
30
  end
30
31
 
31
- # @return [Array<String>] whatever {#print} printed so far.
32
+ # @return [Array<String>] whatever {#print} / {#emit} produced so far.
33
+ # Component painting lands in {#buffer}, not here — assert on
34
+ # {Buffer#row_text} / {Buffer#row_ansi} / {Buffer#cell} for content, and
35
+ # on `prints` for cursor and housekeeping escapes.
32
36
  attr_reader :prints
33
37
 
34
38
  # @return [void]
@@ -46,6 +50,15 @@ module Tuile
46
50
  @prints += args
47
51
  end
48
52
 
53
+ # Captures the assembled repaint frame instead of writing to the test
54
+ # runner's TTY. Lands in {#prints} so cursor/sync escapes can be asserted;
55
+ # painted content is read from {#buffer}.
56
+ # @param str [String]
57
+ # @return [void]
58
+ def emit(str)
59
+ @prints << str
60
+ end
61
+
49
62
  # @param component [Component] the component to check.
50
63
  # @return [Boolean]
51
64
  def invalidated?(component) = @invalidated.include?(component)