tuile 0.12.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
@@ -28,19 +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 dismisses ({#handle_mouse}); an app
38
- # wanting a key registers a global shortcut and calls {#close}.
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.
39
42
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
40
43
  # the message is added — a toast lives seconds, so there is no
41
44
  # {Component#on_theme_changed} rebuild.
42
- # - **Take a size.** {#size=} raises; the messages decide.
43
- 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
44
48
  # Most messages held at once, counting both the painted ones and any
45
49
  # waiting for room. Chosen from reading time rather than geometry: the
46
50
  # drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
@@ -113,32 +117,17 @@ module Tuile
113
117
  private_class_method :new
114
118
 
115
119
  def initialize
116
- # Built before `super`, because Popup#initialize assigns the content and
117
- # 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.
118
122
  @messages = []
119
123
  @high_water = 0
120
124
  @ticker = nil
121
125
  @view = TextView.new
122
126
  @window = Window.new
123
127
  @window.content = @view
124
- super(content: @window, modal: false)
128
+ super(content: @window, close_on_outside_click: false)
125
129
  end
126
130
 
127
- # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
128
- # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
129
- # nobody — every keystroke would go dead until the user pressed Tab.
130
- # @return [Boolean] false.
131
- def focusable? = false
132
-
133
- # @return [Boolean] false — see {#focusable?}.
134
- def tab_stop? = false
135
-
136
- # Empty: a non-modal popup never owns the status bar, and {Popup}'s
137
- # inherited `q Close` hint would be a lie here — no key ever reaches a
138
- # notification.
139
- # @return [String]
140
- def keyboard_hint = ""
141
-
142
131
  # Appends a message, dropping it (with a {Tuile.logger} warning) once
143
132
  # {MAX_MESSAGES} are held. Public so a caller holding the instance can
144
133
  # append without repeating {show}'s lookup.
@@ -169,7 +158,7 @@ module Tuile
169
158
 
170
159
  # Recomputes the box from its messages and re-anchors it to the screen's
171
160
  # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
172
- # {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*
173
162
  # position (off-screen entirely if the terminal narrowed).
174
163
  #
175
164
  # Rebuilds the {TextView}'s text too, and every mutation routes through
@@ -186,27 +175,17 @@ module Tuile
186
175
  width = box_width
187
176
  rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
188
177
  height = [rows.size + 2, cap_height].min
189
- @size = Size.new(width, height)
190
178
  @view.text = join_rows(rows)
191
179
  self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
192
180
  end
193
181
 
194
- # A notification is sized by its messages, so this always raises. Failing
195
- # loudly beats accepting a size the next {#reposition} would discard.
196
- # @param _new_size [Size, Fraction]
197
- # @raise [Tuile::Error] always.
198
- # @return [void]
199
- def size=(_new_size)
200
- raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
201
- end
202
-
203
182
  # A left click dismisses the whole box, every message with it. Other buttons
204
183
  # are consumed and inert — including the scroll wheel, which would otherwise
205
184
  # nuke the box on a stray spin.
206
185
  #
207
186
  # Deliberately *replaces* rather than augments: neither `super` nor
208
187
  # {HasContent#handle_mouse} may run, since both end at a
209
- # `screen.focused = …` inside this subtree (see {#focusable?}).
188
+ # `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
210
189
  # @param event [MouseEvent]
211
190
  # @return [void]
212
191
  def handle_mouse(event)
@@ -214,10 +193,16 @@ module Tuile
214
193
  end
215
194
 
216
195
  # @return [void]
217
- def on_attached = sync_ticker
196
+ def on_attached
197
+ super
198
+ sync_ticker
199
+ end
218
200
 
219
201
  # @return [void]
220
- def on_detached = sync_ticker
202
+ def on_detached
203
+ super
204
+ sync_ticker
205
+ end
221
206
 
222
207
  private
223
208
 
@@ -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
@@ -66,11 +66,6 @@ module Tuile
66
66
  end
67
67
  end
68
68
 
69
- # @return [String]
70
- def keyboard_hint
71
- @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
72
- end
73
-
74
69
  # Opens a picker as a popup. Picking an option fires `block`, then
75
70
  # closes the popup; ESC / `q` close without firing `block`.
76
71
  # @param caption [String]
@@ -2,143 +2,98 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
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 holds a top-down
8
- # {#size} the {Screen} applies.
9
- #
10
- # The popup does *not* size itself to its content. Its box is declared by
11
- # {#size} — a {Fraction} (resolved against the screen every layout pass, so
12
- # it tracks resize) or an absolute {Size} (clamped to the screen). The
13
- # default is {Fraction::HALF}: half the screen, centered. The wrapped
14
- # content then fills that box and handles its own overflow by wrapping and
15
- # scrolling, so use content that can — a {Component::TextView} or
16
- # {Component::TextArea} — for anything longer than fits. A
17
- # {Component::Label} only truncates.
18
- #
19
- # Modal by default: it centers on the screen, grabs focus, eats keys, and
20
- # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
21
- # that floats above the content without taking focus or capturing input —
22
- # the caller positions it (via {#rect=}), sizes it, and drives it from app
23
- # code. That's the building block for an autocomplete/slash-command list
24
- # anchored to a text field's caret: typing keeps focus in the input while
25
- # the caller refills and drives the overlay.
26
- #
27
- # The wrapped content fills the popup's full {#rect}; if you want a frame
28
- # and caption, wrap a {Component::Window} (or any subclass — including
29
- # {Component::LogWindow}) and let it draw its own border:
5
+ # A modal dialog: an {Component::Overlay} that centers itself on the screen,
6
+ # grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
7
+ # closes on ESC or `q`.
30
8
  #
31
9
  # window = Component::Window.new("Help")
32
10
  # window.content = Component::List.new.tap { _1.lines = lines }
33
11
  # Component::Popup.new(content: window).open
34
12
  #
35
13
  # Bare content also works (a {Component::Label}, a {Component::List}…), in
36
- # which case the popup is borderless.
14
+ # which case the popup is borderless. For a floating layer that does *not*
15
+ # take focus or capture input — an autocomplete list anchored to a field, a
16
+ # toast — use {Component::Overlay} directly.
17
+ #
18
+ # The popup does *not* size itself to its content. Its box is set by
19
+ # {#declared_size} — a {Fraction} (resolved against the screen every layout
20
+ # pass, so it tracks resize) or an absolute {Size} (clamped to the screen).
21
+ # The default is {Fraction::HALF}: half the screen, centered. The wrapped content
22
+ # then fills that box and handles its own overflow by wrapping and scrolling,
23
+ # so use content that can — a {Component::TextView} or
24
+ # {Component::TextArea} — for anything longer than fits. A
25
+ # {Component::Label} only truncates.
26
+ #
27
+ # == Implementation details
37
28
  #
38
29
  # `q` and ESC close the popup — handled here, at the top of the popup's own
39
30
  # subtree, so the key only arrives after every component on the focus chain
40
31
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
41
- # nested {Component::TextField} doesn't dismiss the popup: the field
42
- # consumes it first.
43
- class Popup < Component
44
- include Component::HasContent
45
-
32
+ # nested {Component::TextField} doesn't dismiss the popup: the field consumes
33
+ # it first.
34
+ #
35
+ # A left click *outside* the popup closes it too — see
36
+ # {Overlay#close_on_outside_click?} for the exact contract and
37
+ # {Overlay#on_close=} for the notice a driver hears when it happens.
38
+ #
39
+ # UI-thread-confined, like every component (see {Screen}).
40
+ class Popup < Overlay
46
41
  # @param content [Component, nil] initial content; can be set later via
47
42
  # {#content=}. The content fills the popup's {#rect}; it does not
48
43
  # determine the popup's size.
49
- # @param modal [Boolean] true (default) for a centered, focus-grabbing,
50
- # input-capturing modal; false for a non-modal overlay the caller
51
- # positions and drives (see the class docs).
52
- # @param size [Size, Fraction] the popup's size, applied top-down. A
53
- # {Fraction} is resolved against the screen each layout pass; a {Size}
44
+ # @param declared_size [Size, Fraction] the popup's box, applied top-down.
45
+ # A {Fraction} is resolved against the screen each layout pass; a {Size}
54
46
  # is clamped to the screen. Defaults to {Fraction::HALF}.
55
- def initialize(content: nil, modal: true, size: Fraction::HALF)
56
- super()
57
- @modal = modal
58
- @size = size
59
- @content = nil
60
- self.content = content unless content.nil?
47
+ # @param close_on_outside_click [Boolean] true (default) to dismiss on a
48
+ # left click that misses this popup. See
49
+ # {Overlay#close_on_outside_click?}.
50
+ def initialize(content: nil, declared_size: Fraction::HALF, close_on_outside_click: true)
51
+ super(content: content, close_on_outside_click: close_on_outside_click)
52
+ @declared_size = declared_size
61
53
  reposition
62
54
  end
63
55
 
64
- # @return [Size, Fraction] the popup's declared size. See {#size=}.
65
- attr_reader :size
56
+ # The box this popup asks for, *as set* — a {Fraction} comes back
57
+ # unresolved. {#rect} is the resolved answer. Named apart from `size`
58
+ # because a component's `size` is its `rect.size`, which this is not: it
59
+ # is an input re-read on every layout pass, not a report of the current
60
+ # geometry.
61
+ # @return [Size, Fraction] see {#declared_size=}.
62
+ attr_reader :declared_size
66
63
 
67
- # @return [Boolean] whether this popup is modal. See {#initialize}.
68
- def modal? = @modal
64
+ # @return [Boolean] true — a Popup scopes key dispatch, grabs focus on
65
+ # open, and blocks clicks on the content beneath it.
66
+ def modal? = true
69
67
 
68
+ # @return [Boolean] true — {ScreenPane#add_popup} focuses a popup on open,
69
+ # and focus repair falls back to it when its subtree has no tab stop.
70
70
  def focusable? = true
71
71
 
72
- # Sets the popup's size and repositions it. Accepts a {Fraction}
73
- # (resolved against the screen every layout pass, so it tracks resize) or
74
- # an absolute {Size} (clamped to the screen). This is **authoritative**,
75
- # not a preference: the screen applies exactly what you ask for (clamped),
72
+ # Sets the popup's box and repositions it. Accepts a {Fraction} (resolved
73
+ # against the screen every layout pass, so it tracks resize) or an
74
+ # absolute {Size} (clamped to the screen). This is **authoritative**, not
75
+ # a preference: the screen applies exactly what you ask for (clamped),
76
76
  # with no negotiation — a popup has no siblings to compete with.
77
77
  # @param new_size [Size, Fraction]
78
78
  # @return [void]
79
- def size=(new_size)
80
- @size = new_size
79
+ def declared_size=(new_size)
80
+ @declared_size = new_size
81
81
  reposition
82
82
  end
83
83
 
84
- # Reassigns the popup's rect, escalating to a full scene repaint when an
85
- # open popup shrinks or moves so its new rect no longer covers the cells
86
- # it previously painted. A popup overdraws the scene without clipping and
87
- # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
88
- # would repaint into the new rect and leave the vacated cells showing
89
- # stale content. When the new rect fully covers the old one (the popup
90
- # only grew), the fast path is correct and the full repaint is skipped.
91
- # @param new_rect [Rect]
92
- # @return [void]
93
- def rect=(new_rect)
94
- old_rect = rect
95
- super
96
- screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
97
- end
98
-
99
- # Mounts this popup on the {Screen}, re-resolving its {#size} against the
100
- # current screen first.
101
- #
102
- # popup = Component::Popup.new(content: window).open # construct and mount
84
+ # Re-resolves {#declared_size} against the current screen and recenters the popup
85
+ # *itself* (this is not laying out content — the popup's own rect). Called
86
+ # on {Overlay#open}, on {#declared_size=}, and by the screen's layout pass,
87
+ # so a {Fraction} tracks SIGWINCH.
103
88
  #
104
- # There is deliberately no class-level `Popup.open` factory — see
105
- # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
106
- # one-liner above available without one.
107
- # @return [self]
108
- def open
109
- reposition
110
- screen.add_popup(self)
111
- self
112
- end
113
-
114
- # Removes this popup from the {Screen}. No-op if not currently open.
115
- # @return [void]
116
- def close
117
- screen.remove_popup(self)
118
- end
119
-
120
- # @return [Boolean] true if this popup is currently mounted on the screen.
121
- def open?
122
- screen.has_popup?(self)
123
- end
124
-
125
- # Re-resolves {#size} against the current screen and repositions the popup
126
- # *itself* (this is not laying out content — the popup's own rect): a
127
- # modal popup recenters; a non-modal overlay keeps its caller-assigned
128
- # top-left (only its size follows the screen). Called on {#open}, on
129
- # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
130
- # SIGWINCH).
131
- #
132
- # The final rect is computed and assigned in one step rather than sizing
133
- # at the origin and then centering: the intermediate origin rect rarely
134
- # covers the previous one, which would make {#rect=}'s shrink/move
89
+ # The final rect is computed and assigned in one step rather than sizing at
90
+ # the origin and then centering: the intermediate origin rect rarely covers
91
+ # the previous one, which would make {Overlay#rect=}'s shrink/move
135
92
  # detection fire a full repaint on every resize.
136
93
  # @return [void]
137
94
  def reposition
138
- size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
139
- r = Rect.new(rect.left, rect.top, size.width, size.height)
140
- r = r.centered(screen.size) if modal?
141
- self.rect = r
95
+ size = @declared_size.is_a?(Fraction) ? @declared_size.resolve(screen.size) : @declared_size.clamp(screen.size)
96
+ self.rect = Rect.new(rect.left, rect.top, size.width, size.height).centered(screen.size)
142
97
  end
143
98
 
144
99
  # Recenters the popup on the screen, preserving its current width/height.
@@ -147,14 +102,6 @@ module Tuile
147
102
  self.rect = rect.centered(screen.size)
148
103
  end
149
104
 
150
- # Hint for the status bar: own "q Close" plus the wrapped content's hint.
151
- # @return [String]
152
- def keyboard_hint
153
- prefix = "q #{screen.theme.hint("Close")}"
154
- child_hint = @content&.keyboard_hint.to_s
155
- child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
156
- end
157
-
158
105
  # `q` and ESC close the popup. The popup sits on the focus chain of
159
106
  # whatever it wraps, so the key reaches here by bubbling up from the
160
107
  # focused content after that content declined to handle it.
@@ -168,15 +115,6 @@ module Tuile
168
115
  false
169
116
  end
170
117
  end
171
-
172
- protected
173
-
174
- # Content fills the popup's full rect — Popup has no border to subtract.
175
- # @param content [Component]
176
- # @return [void]
177
- def layout(content)
178
- content.rect = rect
179
- end
180
118
  end
181
119
  end
182
120
  end
@@ -38,7 +38,7 @@ module Tuile
38
38
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
39
39
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
40
40
  # the rendered length would vary with the fill level. Shipped anyway, per
41
- # `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
41
+ # `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
42
42
  # beats a third convention, and if that bet is ever reversed both swap
43
43
  # together.
44
44
  class ProgressBar < Component
@@ -42,7 +42,7 @@ module Tuile
42
42
  # Home/End are declined too, so they stay available app-wide.
43
43
  #
44
44
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
45
- # the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
45
+ # the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
46
46
  # need no prefix-disambiguation.
47
47
  #
48
48
  # == Implementation details
@@ -72,6 +72,9 @@ module Tuile
72
72
  @value = value
73
73
  @on_value_change = nil
74
74
  @overlay = ListDropdown.new
75
+ # Outside-click dismissal spans the owner chain, so a click on this
76
+ # select's dropdown must not dismiss a dialog the select sits in.
77
+ @overlay.owner = self
75
78
  @overlay.renderer = method(:label_for)
76
79
  @overlay.on_item_chosen = ->(_index, item) { commit(item) }
77
80
  end
@@ -108,7 +111,6 @@ module Tuile
108
111
  end
109
112
 
110
113
  # @return [String]
111
- def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
112
114
 
113
115
  # Re-anchors the (open) dropdown after a move or resize.
114
116
  # @param new_rect [Rect]
@@ -154,24 +156,32 @@ module Tuile
154
156
  end
155
157
  end
156
158
 
157
- # Toggles the dropdown on a left click anywhere in {#rect} — a field's
159
+ # The one row this Select paints — the full width, at the top of {#rect}.
160
+ # A single-slot container ({Component::Window}, {Component::Popup}) hands
161
+ # its content the whole inner rect, so a Select is routinely assigned more
162
+ # height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
163
+ # clicks in it, and the dropdown hangs under this rather than under the
164
+ # unused space.
165
+ # @return [Size]
166
+ def extent = Size.new(rect.width, 1)
167
+
168
+ # Toggles the dropdown on a left click anywhere in {#extent} — a field's
158
169
  # affordance is its whole row, as the well advertises; `super` runs first,
159
170
  # so the click also focuses.
160
171
  # @param event [MouseEvent]
161
172
  # @return [void]
162
173
  def handle_mouse(event)
163
174
  super
164
- return unless event.button == :left && rect.contains?(event.point)
175
+ return unless event.button == :left && extent_rect.contains?(event.point)
165
176
 
166
177
  @overlay.open? ? close_menu : open_menu
167
178
  end
168
179
 
169
180
  # @return [void]
170
181
  def repaint
182
+ super
171
183
  return if rect.empty?
172
184
 
173
- tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
174
- clear_background(tail) unless tail.empty?
175
185
  draw_text(rect.left, rect.top, face_row)
176
186
  end
177
187
 
@@ -218,7 +228,7 @@ module Tuile
218
228
  end
219
229
 
220
230
  # @return [void]
221
- def anchor = @overlay.anchor_to(rect, rows: @items.size, width: menu_width)
231
+ def anchor = @overlay.anchor_to(extent_rect, rows: @items.size, width: menu_width)
222
232
 
223
233
  # The dropdown's width: the widest label plus {List}'s two row gutters, plus
224
234
  # the scrollbar column when the rows can't all be shown at once — but never