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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list_dropdown.rb +18 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- 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:
|
|
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 <
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
148
|
+
beneath = anchor.top + anchor.height
|
|
149
|
+
below = screen.size.height - beneath
|
|
140
150
|
above = anchor.top
|
|
141
151
|
if desired <= below
|
|
142
|
-
top =
|
|
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 =
|
|
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` `
|
|
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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
|
28
|
-
#
|
|
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
|
-
|
|
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` `
|
|
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 `
|
|
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 ({
|
|
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
|
-
# `
|
|
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 [
|
|
247
|
+
# @return [Size]
|
|
248
248
|
def extent
|
|
249
|
-
return
|
|
249
|
+
return Size.new(0, 1) if rect.empty?
|
|
250
250
|
|
|
251
|
-
|
|
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
|
|
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` `
|
|
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.**
|
|
36
|
-
# key-dispatch scope ({ScreenPane#handle_key}), so
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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.** {
|
|
46
|
-
|
|
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
|
|
120
|
-
#
|
|
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,
|
|
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
|
-
# {
|
|
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
|