tuile 0.13.0 → 0.14.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
@@ -4,7 +4,7 @@ module Tuile
4
4
  class Component
5
5
  # A borderless, tinted, non-focusable floating selection list — the dropdown
6
6
  # a *driver* drops open, drives by forwarding movement keys, and commits a
7
- # pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
7
+ # pick from: an {Overlay} wrapping a {List} that never takes focus, so
8
8
  # focus stays on the driver while the caller refills the rows, moves the
9
9
  # highlight, and reads the pick.
10
10
  #
@@ -34,7 +34,7 @@ module Tuile
34
34
  # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
35
35
  #
36
36
  # UI-thread-confined, like every component (see {Screen}).
37
- class ListDropdown < Popup
37
+ class ListDropdown < Overlay
38
38
  # The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
39
39
  # while focus stays on it, and a mouse click selects an item without
40
40
  # stealing focus — so a driving text input never loses its caret
@@ -64,7 +64,7 @@ module Tuile
64
64
  @list = Menu.new
65
65
  @list.cursor = List::Cursor.new
66
66
  @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
67
- super(content: @list, modal: false)
67
+ super(content: @list)
68
68
  self.bg_color = Theme.ref(:input_bg_color)
69
69
  end
70
70
 
@@ -124,7 +124,16 @@ module Tuile
124
124
  # Vertical flips but horizontal slides because covering the driver would
125
125
  # hide what is being chosen, while sharing its columns is the point.
126
126
  #
127
- # @param anchor [Rect] the driver's rect; the dropdown never covers it.
127
+ # **`anchor` is the region actually occupied, and may be taller than one
128
+ # row** — "beneath" means the row *after* it, so a multi-row driver (a
129
+ # {Component::TextArea} carrying an autocomplete menu) is cleared entirely
130
+ # rather than overdrawn from its second row down. A widget that paints one
131
+ # row but may be *assigned* more height passes its face, not its rect:
132
+ # {ComboBox} and {Select} both do, since a {Window} content slot hands them
133
+ # the full inner height.
134
+ #
135
+ # @param anchor [Rect] the region the driver occupies, of any height; the
136
+ # dropdown never covers it.
128
137
  # @param rows [Integer] how many rows there are to show — the content
129
138
  # count, not the height: more than fits turns the scrollbar on. `0`
130
139
  # collapses the dropdown to an empty rect (drivers close instead).
@@ -136,20 +145,20 @@ module Tuile
136
145
  # @return [void]
137
146
  def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
138
147
  desired = [rows, max_rows].min
139
- below = screen.size.height - (anchor.top + 1)
148
+ beneath = anchor.top + anchor.height
149
+ below = screen.size.height - beneath
140
150
  above = anchor.top
141
151
  if desired <= below
142
- top = anchor.top + 1
152
+ top = beneath
143
153
  height = desired
144
154
  elsif above >= below
145
155
  height = [desired, above].min
146
156
  top = anchor.top - height
147
157
  else
148
158
  height = below
149
- top = anchor.top + 1
159
+ top = beneath
150
160
  end
151
161
  width = [width, screen.size.width].min
152
- self.size = Size.new(width, height)
153
162
  self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
154
163
  # After the geometry: the setter rebuilds the list's padded rows against
155
164
  # the width it can see, and the gutter takes a column off it.
@@ -182,7 +191,7 @@ module Tuile
182
191
  # @param width [Integer] the panel's width in columns, clamped to the
183
192
  # screen. **Required, with no default:** `anchor.width` is the *parent's*
184
193
  # width and would be meaningless here, so the caller measures (see
185
- # `DECISIONS.md` `D-select` on why the width policy stays with the
194
+ # `DECISIONS.md` `D_select` on why the width policy stays with the
186
195
  # driver).
187
196
  # @param max_rows [Integer] rows shown before the list scrolls.
188
197
  # @return [void]
@@ -197,7 +206,6 @@ module Tuile
197
206
  end
198
207
  left = left.clamp(0, [screen.size.width - width, 0].max)
199
208
  top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
200
- self.size = Size.new(width, height)
201
209
  self.rect = Rect.new(left, top, width, height)
202
210
  # After the geometry, as in {#anchor_to}: the setter rebuilds the list's
203
211
  # padded rows against the width it can see.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A {TextView} purpose-built for log output: auto-scroll is on, the
6
+ # scrollbar is visible, and lines arrive via {#log} — from any thread.
7
+ # Point a logger at a {LogTextView::IO} to route its lines here:
8
+ #
9
+ # view = Tuile::Component::LogTextView.new
10
+ # logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
11
+ # logger.info("started") # appears in the view, from any thread
12
+ #
13
+ # Any logger that writes formatted lines to an IO works the same way —
14
+ # for example `TTY::Logger` configured with the `:console` handler and
15
+ # `output: LogTextView::IO.new(view)`.
16
+ #
17
+ # Add it to a layout as-is for a frameless log pane, or use {LogWindow}
18
+ # for the framed assembly. A {TextView} rather than a {List}: long lines
19
+ # (stacktraces, wide log records) word-wrap rather than ellipsize — a
20
+ # truncated log line hides the very detail you opened the log to read.
21
+ class LogTextView < TextView
22
+ def initialize
23
+ super
24
+ self.auto_scroll = true
25
+ self.scrollbar_visibility = :visible
26
+ end
27
+
28
+ # Appends the given line to the log. Safe to call from any thread —
29
+ # the one exception to the UI-thread confinement every component
30
+ # carries: the append is marshalled through `event_queue.submit`, so
31
+ # it lands once a loop drains the queue (deferred before the first
32
+ # loop; silently dropped after the loop has returned).
33
+ # @param string [String, nil] the line (or multiple lines) to log;
34
+ # `nil` is a no-op.
35
+ # @return [void]
36
+ def log(string)
37
+ return if string.nil?
38
+
39
+ screen.event_queue.submit { add_line(string) }
40
+ end
41
+
42
+ # IO-shaped adapter that forwards each log line to its sink's `#log`.
43
+ # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
44
+ # call `output.puts`, e.g. `TTY::Logger`).
45
+ class IO
46
+ # @param sink [LogTextView, LogWindow] anything responding to `#log`.
47
+ def initialize(sink)
48
+ @sink = sink
49
+ end
50
+
51
+ # @param string [String]
52
+ # @return [void]
53
+ def write(string)
54
+ @sink.log(string.chomp)
55
+ end
56
+
57
+ # @param string [String]
58
+ # @return [void]
59
+ def puts(string)
60
+ @sink.log(string)
61
+ end
62
+
63
+ # Stdlib `Logger` only treats an object as an IO target when it
64
+ # responds to both {#write} and {#close}; otherwise it tries to
65
+ # interpret it as a filename. This is a no-op.
66
+ # @return [void]
67
+ def close; end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,65 +2,30 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
6
- # log lines into this window:
5
+ # A {Window} framing a {LogTextView} — the framed log pane. All the log
6
+ # behavior lives on the view (see {LogTextView} for the adapter and the
7
+ # wrap rationale); the window adds the border, caption and delegation:
7
8
  #
8
9
  # log_window = Tuile::Component::LogWindow.new
9
10
  # logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
10
- #
11
- # Any logger that writes formatted lines to an IO works the same way —
12
- # for example `TTY::Logger` configured with the `:console` handler and
13
- # `output: LogWindow::IO.new(window)`.
14
11
  class LogWindow < Window
12
+ # Alias of {LogTextView::IO}, which accepts the window itself as its sink.
13
+ # @return [Class]
14
+ IO = LogTextView::IO
15
+
15
16
  # @param caption [String]
16
17
  def initialize(caption = "Log")
17
18
  super
18
- view = Component::TextView.new
19
- # Word-wrap long lines (stacktraces, wide log records) rather than
20
- # ellipsizing them as a {List} would — a truncated log line hides the
21
- # very detail you opened the log to read.
22
- view.auto_scroll = true
23
- self.content = view
24
- self.scrollbar = true
19
+ self.content = LogTextView.new
25
20
  end
26
21
 
27
- # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
28
- # @param string [String, nil] the line (or multiple lines) to log.
22
+ # Appends the given line to the log, from any thread — delegates to
23
+ # {LogTextView#log}.
24
+ # @param string [String, nil] the line (or multiple lines) to log;
25
+ # `nil` is a no-op.
29
26
  # @return [void]
30
27
  def log(string)
31
- return if string.nil?
32
-
33
- screen.event_queue.submit do
34
- content.add_line(string)
35
- end
36
- end
37
-
38
- # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
39
- # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
40
- # call `output.puts`, e.g. `TTY::Logger`).
41
- class IO
42
- # @param window [LogWindow]
43
- def initialize(window)
44
- @window = window
45
- end
46
-
47
- # @param string [String]
48
- # @return [void]
49
- def write(string)
50
- @window.log(string.chomp)
51
- end
52
-
53
- # @param string [String]
54
- # @return [void]
55
- def puts(string)
56
- @window.log(string)
57
- end
58
-
59
- # Stdlib `Logger` only treats an object as an IO target when it
60
- # responds to both {#write} and {#close}; otherwise it tries to
61
- # interpret it as a filename. This is a no-op.
62
- # @return [void]
63
- def close; end
28
+ content.log(string)
64
29
  end
65
30
  end
66
31
  end
@@ -19,7 +19,7 @@ module Tuile
19
19
  #
20
20
  # Widths are measured here, per level — the panel is as wide as the level's
21
21
  # widest label — because {ListDropdown} deliberately measures nothing
22
- # itself (`DECISIONS.md` `D-select`).
22
+ # itself (`DECISIONS.md` `D_select`).
23
23
  #
24
24
  # == Implementation details
25
25
  # While open it consumes **everything** except the two keys that mean
@@ -33,7 +33,7 @@ module Tuile
33
33
  # The affordance painted on a row that opens a submenu. U+25B8 rather
34
34
  # than the obvious `▶`: like {Component::Select}'s `▾` it is East-Asian
35
35
  # **Neutral**, so it measures one column even under ambiguous-as-wide and
36
- # stays outside `D-ambiguous-width`'s bet, where `▶` and `▼` are
36
+ # stays outside `D_ambiguous_width`'s bet, where `▶` and `▼` are
37
37
  # Ambiguous and would need an ASCII opt-in.
38
38
  # @return [String]
39
39
  SUBMENU_ARROW = "▸"
@@ -179,7 +179,7 @@ module Tuile
179
179
  drop.on_cursor_changed = ->(_index, _child) { truncate(level + 1) }
180
180
  # The cascade's own record of what is open is reconciled from the
181
181
  # popup's own closure, not maintained alongside it: an outside click
182
- # closes panels behind our back ({Popup#close_on_outside_click?}), and
182
+ # closes panels behind our back ({Overlay#close_on_outside_click?}), and
183
183
  # a level left in `@levels` after its panel is gone would have `depth`,
184
184
  # `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
185
185
  # because the notice also arrives from `truncate` (which has already
@@ -43,7 +43,7 @@ module Tuile
43
43
  # {Item} handles are minted by {#add_item} and nest via the *same* method, so
44
44
  # depth is unlimited. There is no removal, no reordering and no dynamic
45
45
  # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
46
- # `D-menu-bar`.
46
+ # `D_menu_bar`.
47
47
  #
48
48
  # == Sizing
49
49
  # Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
@@ -244,11 +244,11 @@ module Tuile
244
244
  # tail — or on a lower row, when the rect is taller than one — opens
245
245
  # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
246
246
  # ungated by geometry.
247
- # @return [Rect]
247
+ # @return [Size]
248
248
  def extent
249
- return Rect.new(rect.left, rect.top, 0, 1) if rect.empty?
249
+ return Size.new(0, 1) if rect.empty?
250
250
 
251
- Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1)
251
+ Size.new([painted_width - @left_column, rect.width].min, 1)
252
252
  end
253
253
 
254
254
  # @param new_rect [Rect]
@@ -470,7 +470,7 @@ module Tuile
470
470
  # @return [Integer, nil] the index of the item painted at `point`; `nil`
471
471
  # for the blank tail or a row the strip doesn't paint.
472
472
  def index_at(point)
473
- return nil unless extent.contains?(point)
473
+ return nil unless extent_rect.contains?(point)
474
474
 
475
475
  column = point.x - rect.left + @left_column
476
476
  segments.index { |_item, start, width| column >= start && column < start + width }
@@ -28,22 +28,23 @@ module Tuile
28
28
  # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
29
29
  # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
30
30
  # rows and is then ellipsized, and entries past the height cap wait unpainted.
31
- # `DECISIONS.md` `D-notification` has why each of those is what it is.
31
+ # `DECISIONS.md` `D_notification` has why each of those is what it is.
32
32
  #
33
33
  # Three things it deliberately doesn't do:
34
34
  #
35
- # - **Take focus, or receive keys.** A non-modal popup sits off the
36
- # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
37
- # `q`/ESC arrives here. A left click *on the box* dismisses
38
- # ({#handle_mouse}); an app wanting a key registers a global shortcut and
39
- # calls {#close}. A click *elsewhere* does not — this is the one popup
40
- # with {Popup#close_on_outside_click?} false, since a toast is timed and
41
- # an unrelated click is not about it.
35
+ # - **Take focus, or receive keys.** An {Overlay} sits off the
36
+ # key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
37
+ # all. A left click *on the box* dismisses ({#handle_mouse}); an app
38
+ # wanting a key registers a global shortcut and calls {Overlay#close}. A
39
+ # click *elsewhere* does not — this is the one overlay with
40
+ # {Overlay#close_on_outside_click?} false, since a toast is timed and an
41
+ # unrelated click is not about it.
42
42
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
43
43
  # the message is added — a toast lives seconds, so there is no
44
44
  # {Component#on_theme_changed} rebuild.
45
- # - **Take a size.** {#size=} raises; the messages decide.
46
- class Notification < Popup
45
+ # - **Take a size.** An {Overlay} has no declared box; the messages decide
46
+ # this one's, in {#reposition}.
47
+ class Notification < Overlay
47
48
  # Most messages held at once, counting both the painted ones and any
48
49
  # waiting for room. Chosen from reading time rather than geometry: the
49
50
  # drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
@@ -116,26 +117,17 @@ module Tuile
116
117
  private_class_method :new
117
118
 
118
119
  def initialize
119
- # Built before `super`, because Popup#initialize assigns the content and
120
- # calls #reposition, and our override reads every one of these.
120
+ # Built before `super`, because Overlay#initialize assigns the content
121
+ # and our #reposition override reads every one of these.
121
122
  @messages = []
122
123
  @high_water = 0
123
124
  @ticker = nil
124
125
  @view = TextView.new
125
126
  @window = Window.new
126
127
  @window.content = @view
127
- super(content: @window, modal: false, close_on_outside_click: false)
128
+ super(content: @window, close_on_outside_click: false)
128
129
  end
129
130
 
130
- # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
131
- # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
132
- # nobody — every keystroke would go dead until the user pressed Tab.
133
- # @return [Boolean] false.
134
- def focusable? = false
135
-
136
- # @return [Boolean] false — see {#focusable?}.
137
- def tab_stop? = false
138
-
139
131
  # Appends a message, dropping it (with a {Tuile.logger} warning) once
140
132
  # {MAX_MESSAGES} are held. Public so a caller holding the instance can
141
133
  # append without repeating {show}'s lookup.
@@ -166,7 +158,7 @@ module Tuile
166
158
 
167
159
  # Recomputes the box from its messages and re-anchors it to the screen's
168
160
  # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
169
- # {Popup#reposition} would have kept the stale left column of a *derived*
161
+ # {Overlay#reposition} would have kept the stale left column of a *derived*
170
162
  # position (off-screen entirely if the terminal narrowed).
171
163
  #
172
164
  # Rebuilds the {TextView}'s text too, and every mutation routes through
@@ -183,27 +175,17 @@ module Tuile
183
175
  width = box_width
184
176
  rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
185
177
  height = [rows.size + 2, cap_height].min
186
- @size = Size.new(width, height)
187
178
  @view.text = join_rows(rows)
188
179
  self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
189
180
  end
190
181
 
191
- # A notification is sized by its messages, so this always raises. Failing
192
- # loudly beats accepting a size the next {#reposition} would discard.
193
- # @param _new_size [Size, Fraction]
194
- # @raise [Tuile::Error] always.
195
- # @return [void]
196
- def size=(_new_size)
197
- raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
198
- end
199
-
200
182
  # A left click dismisses the whole box, every message with it. Other buttons
201
183
  # are consumed and inert — including the scroll wheel, which would otherwise
202
184
  # nuke the box on a stray spin.
203
185
  #
204
186
  # Deliberately *replaces* rather than augments: neither `super` nor
205
187
  # {HasContent#handle_mouse} may run, since both end at a
206
- # `screen.focused = …` inside this subtree (see {#focusable?}).
188
+ # `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
207
189
  # @param event [MouseEvent]
208
190
  # @return [void]
209
191
  def handle_mouse(event)
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A component mounted on the {Screen}'s overlay stack rather than in the
6
+ # tiled tree: it floats above the content at a rect the caller assigns, and
7
+ # has an open/close lifecycle instead of a parent that lays it out.
8
+ #
9
+ # overlay = Component::Overlay.new(content: Component::Label.new("saved"))
10
+ # overlay.rect = Rect.new(10, 4, 20, 1) # you place it — nothing else does
11
+ # overlay.open # mounts it on the Screen
12
+ # overlay.close
13
+ #
14
+ # That is the whole of it — floating, plus the lifecycle, {#on_close},
15
+ # outside-click dismissal and {#owner}. {Component::Popup} is the subclass
16
+ # that adds a declared size, self-centering, focus and key handling.
17
+ #
18
+ # The wrapped content fills the overlay's whole {#rect}; for a frame and a
19
+ # caption, wrap a {Component::Window} and let it draw its own border.
20
+ #
21
+ # == Implementation details
22
+ #
23
+ # **{#focusable?} and {#modal?} move together — flip both or neither.** The
24
+ # defaults here are inert (`false`, `false`): a bare overlay floats without
25
+ # disturbing focus or key dispatch. {Component::Popup} flips both. What must
26
+ # not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
27
+ # scopes delivery to the topmost modal popup or else the tiled content, so
28
+ # such an overlay would hold focus outside the key scope, where delivery
29
+ # reaches nobody and *every* keystroke goes dead until Tab recovers. A
30
+ # non-modal overlay is therefore driven from its owner's key handler
31
+ # ({Component::Select} forwarding to its dropdown) instead of claiming focus.
32
+ #
33
+ # **A derived position needs a {#reposition} override.** The default is a
34
+ # no-op: the rect is whatever the caller last assigned. An overlay whose
35
+ # position is computed — from the screen, or from an anchor — must recompute
36
+ # it there, or it keeps a stale rect after a SIGWINCH and sits off-screen
37
+ # entirely if the terminal narrowed. Closing on resize is equally legal
38
+ # ({Component::MenuBar} drops its cascade from `rect=` rather than walking
39
+ # every level to re-anchor it).
40
+ #
41
+ # UI-thread-confined, like every component (see {Screen}).
42
+ class Overlay < Component
43
+ include Component::HasContent
44
+
45
+ # @param content [Component, nil] initial content; can be set later via
46
+ # {#content=}. It fills the overlay's {#rect} and does not determine it.
47
+ # @param close_on_outside_click [Boolean] true (default) to dismiss on a
48
+ # left click that misses this overlay. See {#close_on_outside_click?}.
49
+ def initialize(content: nil, close_on_outside_click: true)
50
+ super()
51
+ @close_on_outside_click = close_on_outside_click
52
+ @owner = nil
53
+ @on_close = nil
54
+ @content = nil
55
+ self.content = content unless content.nil?
56
+ end
57
+
58
+ # @return [Boolean] false — a bare overlay scopes no keys and blocks no
59
+ # clicks. {Component::Popup} overrides it, together with {#focusable?}.
60
+ def modal? = false
61
+
62
+ # @return [Boolean] false — a bare overlay leaves focus where it was. See
63
+ # the class docs: override it only together with {#modal?}.
64
+ def focusable? = false
65
+
66
+ # @return [Boolean] false — Tab never lands on the overlay wrapper itself
67
+ # (its content may still carry stops).
68
+ def tab_stop? = false
69
+
70
+ # Whether a left click outside this overlay closes it (default true). The
71
+ # pane does the closing — {ScreenPane#handle_mouse} snapshots the open
72
+ # overlays *before* routing the click and closes the dismissable ones
73
+ # *after*, so a widget that toggles its own overlay from a click on its
74
+ # face (a {Component::Select}, a {Component::MenuBar} title) still toggles
75
+ # correctly: the delivered click closes the overlay and the dismissal then
76
+ # no-ops on it, rather than closing and reopening it. Only `:left`
77
+ # dismisses; scroll and right clicks never do.
78
+ #
79
+ # **"Outside" spans the {#owner} chain, not just this rect.** A click
80
+ # counts as inside this overlay when it lands in its rect *or* in any
81
+ # overlay that belongs to it — so a dialog is not dismissed by a click on a
82
+ # dropdown its own field opened, and a menu cascade is not dismissed by a
83
+ # click on one of its deeper panels. Overlays with no owner relationship
84
+ # are independent: clicking one dismisses the other, which is what a
85
+ # window-like overlay should do. One that must survive unrelated clicks
86
+ # entirely ({Component::Notification}) sets this false.
87
+ #
88
+ # Every dismissable overlay closes, not just the topmost, and stacking
89
+ # order plays no part: a {Component::MenuBar} cascade must vanish whole on
90
+ # one click on the background, not peel one panel per click.
91
+ # @return [Boolean]
92
+ def close_on_outside_click? = @close_on_outside_click
93
+
94
+ # @return [Boolean] see {#close_on_outside_click?}.
95
+ attr_writer :close_on_outside_click
96
+
97
+ # The component this overlay is *part of*, or `nil` (the default) when it
98
+ # is an overlay in its own right. It exists for outside-click dismissal: a
99
+ # click inside this overlay also counts as inside whatever overlay encloses
100
+ # its owner, so the host is not dismissed by a click on a panel it put
101
+ # there. See {#close_on_outside_click?}.
102
+ #
103
+ # Set it to the *driver* — {Component::ComboBox} hands its dropdown `self`
104
+ # — rather than to the enclosing overlay: the driver knows what it is,
105
+ # while the overlay above it is a tree relationship the pane resolves at
106
+ # click time (so it cannot go stale). Any {Component} is accepted, and an
107
+ # `Overlay` resolves to itself, which is how a
108
+ # {Component::MenuBar::Cascade} chains each panel to the one it dropped out
109
+ # of.
110
+ # @return [Component, nil]
111
+ attr_accessor :owner
112
+
113
+ # A callback taking no arguments, fired once this overlay has left the
114
+ # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
115
+ # an outside click, or teardown via {Screen#close}. That unconditionality
116
+ # is the point, so it hangs off {#on_detached} rather than {#close}; a
117
+ # driver keeping its own record of open overlays reconciles it here and
118
+ # cannot drift ({Component::MenuBar::Cascade} is the worked example).
119
+ #
120
+ # It fires *after* the overlay is detached, so {#open?} is already false
121
+ # and the usual {Component#on_detached} caveats apply: release state, don't
122
+ # inspect the tree, keep it trivial (it may run while the pane is mid-way
123
+ # through closing a batch of overlays, and a raise propagates).
124
+ # @return [Proc, nil]
125
+ attr_accessor :on_close
126
+
127
+ # Reassigns the overlay's rect, escalating to a full scene repaint when an
128
+ # open overlay shrinks or moves so its new rect no longer covers the cells
129
+ # it previously painted. An overlay overdraws the scene without clipping
130
+ # and nothing clears underneath it, so {Screen#repaint}'s overlay-only fast
131
+ # path would repaint into the new rect and leave the vacated cells showing
132
+ # stale content. When the new rect fully covers the old one (the overlay
133
+ # only grew), the fast path is correct and the full repaint is skipped.
134
+ # @param new_rect [Rect]
135
+ # @return [void]
136
+ def rect=(new_rect)
137
+ old_rect = rect
138
+ super
139
+ screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
140
+ end
141
+
142
+ # Mounts this overlay on the {Screen}.
143
+ #
144
+ # overlay = Component::Overlay.new(content: label).open # construct and mount
145
+ #
146
+ # There is deliberately no class-level `Overlay.open` factory — see
147
+ # `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
148
+ # one-liner above available without one.
149
+ # @return [self]
150
+ def open
151
+ reposition
152
+ screen.add_popup(self)
153
+ self
154
+ end
155
+
156
+ # Removes this overlay from the {Screen}. No-op if not currently open.
157
+ # @return [void]
158
+ def close
159
+ screen.remove_popup(self)
160
+ end
161
+
162
+ # @return [Boolean] true if this overlay is currently mounted on the screen.
163
+ def open?
164
+ screen.has_popup?(self)
165
+ end
166
+
167
+ # Recomputes this overlay's own rect (not its content's layout). A no-op
168
+ # here — the rect is whatever the caller assigned — and the hook a subclass
169
+ # with a *derived* position overrides; see the class docs. Called on
170
+ # {#open} and by the screen's layout pass, so an override tracks SIGWINCH.
171
+ # @return [void]
172
+ def reposition; end
173
+
174
+ # Fires {#on_close}. A subclass overriding this **must** call `super`, or
175
+ # the overlay's driver never hears that it closed.
176
+ # @return [void]
177
+ def on_detached
178
+ @on_close&.call
179
+ end
180
+
181
+ protected
182
+
183
+ # Content fills the overlay's full rect — an Overlay has no border to
184
+ # subtract.
185
+ # @param content [Component]
186
+ # @return [void]
187
+ def layout(content)
188
+ content.rect = rect
189
+ end
190
+ end
191
+ end
192
+ end