Class: Tuile::Component::Notification
- Defined in:
- lib/tuile/component/notification.rb,
sig/tuile.rbs
Overview
A transient message in the screen's top-right corner — the TTY toast:
Component::Notification.show("Saved")
Component::Notification.show("Disk full", color: Theme.ref(:error))
┌─────────┐ ← flush: row 0, right edge at the last column
│Saved │ ← oldest on top, retires in 3 s
│Disk full│ ← then this one, 3 s after that
└─────────┘
Notification.show is the only entry point (new is private): it finds the live notification and appends to it, so a burst stacks as entries in one box instead of opening five overlapping ones.
One repeating ticker retires the oldest entry every DISPLAY_SECONDS
and closes the box when the last one goes — five messages raised together
appear at once and drain over fifteen seconds. A message arriving mid-cycle
waits its turn and does not restart the clock, so the bottom entry of a
full box is visible for about N × DISPLAY_SECONDS. Past MAX_MESSAGES a
message is dropped and reported to Tuile.logger; an app notifying faster
than that wants a LogWindow.
The box is flush to the corner, at most WIDTH_FRACTION of the screen wide
(floor MIN_CAP_WIDTH) and HEIGHT_FRACTION tall, and grows but never
shrinks while it lives; a long message wraps to MAX_ROWS_PER_MESSAGE
rows and is then ellipsized, and entries past the height cap wait unpainted.
design/decisions.md D_notification has why each of those is what it is.
Three things it deliberately doesn't do:
- Take focus, or receive keys. An Overlay sits off the key-dispatch scope (ScreenPane#handle_key?), so no key arrives here at all. A left click on the box dismisses (#handle_mouse_down?); an app wanting a key registers a global shortcut and calls Overlay#close. A click elsewhere does not — this is the one overlay with Overlay#close_on_outside_click? false, since a toast is timed and an unrelated click is not about it.
- Follow a theme flip. A
Theme::Refcolor:is resolved once, when the message is added — a toast lives seconds, so there is no #handle_theme_changed rebuild. - Take a size. The messages decide the box, in #declared_size_in.
Defined Under Namespace
Classes: View
Constant Summary collapse
- MAX_MESSAGES =
Most messages held at once, counting both the painted ones and any waiting for room. Chosen from reading time rather than geometry: the drain rate is one message per DISPLAY_SECONDS, so the queue length is a duration, and 5 × 3 s is about the longest a corner box should own the screen — and about as many short lines as anyone reads.
5- MAX_ROWS_PER_MESSAGE =
Rows a single message may occupy before it is ellipsized.
3- DISPLAY_SECONDS =
Seconds between retirements — how long the oldest message is held.
3.0- WIDTH_FRACTION =
Fraction of the screen width the box may not exceed (see MIN_CAP_WIDTH).
0.4- HEIGHT_FRACTION =
Fraction of the screen height the box may not exceed.
0.4- MIN_CAP_WIDTH =
Floor under the width cap, so 40 % of an 80-column terminal doesn't ellipsize every message down to five words.
34- SPACE =
Separator for re-joining wrapped rows before ellipsizing.
StyledString.parse(" ")
- ROW_BREAK =
Hard-line separator handed to TextView#text=.
StyledString.parse("\n")
Instance Attribute Summary
Attributes inherited from Overlay
#close_on_outside_click, #owner
Attributes included from HasContent
Class Method Summary collapse
-
.show(text, color: nil) ⇒ Notification?
Shows
textin the corner, creating the box if none is open and appending to it if one is.
Instance Method Summary collapse
-
#add_message(text, color: nil) ⇒ void
Appends a message, dropping it (with a Tuile.logger warning) once MAX_MESSAGES are held.
-
#box_width(screen_size) ⇒ Integer
Grow-only: the high-water mark is kept in desired columns and the cap is applied here, last.
-
#build_message(text, color) ⇒ StyledString
@param
text. -
#cap_height(screen_size) ⇒ Integer
@param
screen_size. -
#cap_width(screen_size) ⇒ Integer
@param
screen_size. -
#declared_size_in(screen_size) ⇒ Size
The box its messages need: as wide as the widest message held so far and as tall as they wrap to at that width, each capped by the screen.
-
#default_placement ⇒ Overlay::TopRight
@return — a notification opens in the corner.
- #handle_attached ⇒ void
- #handle_detached ⇒ void
-
#handle_mouse_down?(event) ⇒ Boolean
A left press dismisses the whole box, every message with it.
-
#handle_mouse_scroll?(_event) ⇒ Boolean
Claimed and inert: a stray wheel spin over the toast must neither nuke it nor reach whatever it covers.
-
#initialize ⇒ Notification
constructor
A new instance of Notification.
-
#join_rows(rows) ⇒ StyledString
Joins pre-wrapped rows into one StyledString with
\nseparators, so TextView takes them as hard lines and its own wrap is a no-op over them (each row already fits the width it will be painted at). -
#natural_width(message) ⇒ Integer
Columns the message would like, ignoring wrapping — the widest of its hard lines, not the sum of its spans (which would add every line together for a message carrying
\n). -
#relayout ⇒ void
Wraps the messages to the width the pane gave the box — the wrap the height in #declared_size_in was measured with.
-
#restack ⇒ void
A message came or went: the text is re-wrapped here, and the box is re-measured by the pane.
-
#retire_oldest ⇒ void
Retires the oldest message, closing the box when it was the last.
-
#sync_ticker ⇒ void
Syncs the retirement clock from the invariant "something to retire, and on screen" — the sole writer of
@ticker. -
#wrap_message(message, width) ⇒ ::Array[StyledString]
Wraps one message to
widthcolumns, capped at MAX_ROWS_PER_MESSAGE rows. -
#wrapped_rows(width) ⇒ ::Array[StyledString]
@param
width— the box width, border included.
Methods inherited from Overlay
#close, #close_on_outside_click?, #focusable?, #handle_focus, #handle_rect_changed, #modal?, #on_close, #open, #open?, #placement, #placement=, #reposition, #tab_stop?, #visible=
Methods included from HasContent
Constructor Details
#initialize ⇒ Notification
Returns a new instance of Notification.
118 119 120 121 122 123 124 125 126 |
# File 'lib/tuile/component/notification.rb', line 118 def initialize @messages = [] @high_water = 0 @ticker = nil @view = View.new @window = Window.new @window.content = @view super(content: @window, close_on_outside_click: false) end |
Class Method Details
.show(text, color: nil) ⇒ Notification?
Shows text in the corner, creating the box if none is open and
appending to it if one is.
@param text — the message. A String is parsed via StyledString.parse, so embedded ANSI is honored. nil and the empty string are no-ops (nothing is shown, nothing is created).
@param color — applied to every span of the message via StyledString#with_fg. A Theme::Ref is resolved against the current theme now — see the class docs on theme following. nil leaves the message's own colors alone.
@return — the live notification, or nil when text
was empty.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/tuile/component/notification.rb', line 101 def self.show(text, color: nil) Screen.instance.check_locked return nil if StyledString.parse(text).empty? live = Screen.instance.pane.popups.find { _1.is_a?(Notification) } return live.tap { _1.(text, color: color) } unless live.nil? # Message first, so the box is sized before it is mounted: opening an # empty 0×0 popup and then growing it would paint a frame of nothing. new.tap do |notification| notification.(text, color: color) notification.open end end |
Instance Method Details
#add_message(text, color: nil) ⇒ void
This method returns an undefined value.
Appends a message, dropping it (with a Tuile.logger warning) once MAX_MESSAGES are held. Public so a caller holding the instance can append without repeating show's lookup.
@param text — see show. Empty is a no-op.
@param color — see show.
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/tuile/component/notification.rb', line 136 def (text, color: nil) # Explicit rather than inherited-through-invalidate: this appends to # @messages before anything repaints, so a wrong-thread call has to fail # before the message is recorded, not after. screen.check_locked = (text, color) return if .empty? if @messages.size >= MAX_MESSAGES Tuile.logger.warn("Notification: dropping #{.to_s.inspect}, " \ "#{MAX_MESSAGES} messages already queued") return end @messages << @high_water = [@high_water, natural_width()].max restack sync_ticker end |
#box_width(screen_size) ⇒ Integer
Grow-only: the high-water mark is kept in desired columns and the cap is applied here, last. Storing the clamped value instead would let a SIGWINCH that narrows the terminal ratchet the box permanently down to the narrow cap, with nothing to restore it when the terminal widens.
@param screen_size
284 |
# File 'lib/tuile/component/notification.rb', line 284 def box_width(screen_size) = [@high_water + 2, cap_width(screen_size)].min |
#build_message(text, color) ⇒ StyledString
@param text
@param color
264 265 266 267 268 269 |
# File 'lib/tuile/component/notification.rb', line 264 def (text, color) = StyledString.parse(text) return if color.nil? || .empty? .with_fg(color.is_a?(Theme::Ref) ? color.resolve(screen.theme) : color) end |
#cap_height(screen_size) ⇒ Integer
@param screen_size
@return — at least 3: two border rows plus one row of message.
294 295 296 |
# File 'lib/tuile/component/notification.rb', line 294 def cap_height(screen_size) [[(screen_size.height * HEIGHT_FRACTION).to_i, 3].max, screen_size.height].min end |
#cap_width(screen_size) ⇒ Integer
@param screen_size
288 289 290 |
# File 'lib/tuile/component/notification.rb', line 288 def cap_width(screen_size) [[(screen_size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen_size.width].min end |
#declared_size_in(screen_size) ⇒ Size
The box its messages need: as wide as the widest message held so far and as tall as they wrap to at that width, each capped by the screen.
@param screen_size
163 164 165 166 167 168 |
# File 'lib/tuile/component/notification.rb', line 163 def declared_size_in(screen_size) return Size.new(0, 0) if @messages.empty? width = box_width(screen_size) Size.new(width, [wrapped_rows(width).size + 2, cap_height(screen_size)].min) end |
#default_placement ⇒ Overlay::TopRight
@return — a notification opens in the corner.
157 |
# File 'lib/tuile/component/notification.rb', line 157 def default_placement = TopRight[] |
#handle_attached ⇒ void
This method returns an undefined value.
198 199 200 201 |
# File 'lib/tuile/component/notification.rb', line 198 def handle_attached super sync_ticker end |
#handle_detached ⇒ void
This method returns an undefined value.
204 205 206 207 |
# File 'lib/tuile/component/notification.rb', line 204 def handle_detached super sync_ticker end |
#handle_mouse_down?(event) ⇒ Boolean
A left press dismisses the whole box, every message with it. Every other press is claimed and inert, so nothing beneath the toast acts on it.
@param event
174 175 176 177 |
# File 'lib/tuile/component/notification.rb', line 174 def handle_mouse_down?(event) close if event. == :left true end |
#handle_mouse_scroll?(_event) ⇒ Boolean
Claimed and inert: a stray wheel spin over the toast must neither nuke it nor reach whatever it covers.
@param _event
183 |
# File 'lib/tuile/component/notification.rb', line 183 def handle_mouse_scroll?(_event) = true |
#join_rows(rows) ⇒ StyledString
Joins pre-wrapped rows into one StyledString with \n separators, so
TextView takes them as hard lines and its own wrap is a no-op over
them (each row already fits the width it will be painted at).
@param rows
322 323 324 325 326 |
# File 'lib/tuile/component/notification.rb', line 322 def join_rows(rows) return StyledString::EMPTY if rows.empty? rows.inject { |joined, row| joined + ROW_BREAK + row } end |
#natural_width(message) ⇒ Integer
Columns the message would like, ignoring wrapping — the widest of its
hard lines, not the sum of its spans (which would add every line
together for a message carrying \n).
@param message
276 |
# File 'lib/tuile/component/notification.rb', line 276 def natural_width() = .lines.map(&:display_width).max || 0 |
#relayout ⇒ void
This method returns an undefined value.
Wraps the messages to the width the pane gave the box — the wrap the height in #declared_size_in was measured with.
214 215 216 217 |
# File 'lib/tuile/component/notification.rb', line 214 def relayout super @view.text = join_rows(wrapped_rows(rect.width)) if rect.width > 2 end |
#restack ⇒ void
This method returns an undefined value.
A message came or went: the text is re-wrapped here, and the box is re-measured by the pane.
224 225 226 227 |
# File 'lib/tuile/component/notification.rb', line 224 def restack invalidate_layout reposition end |
#retire_oldest ⇒ void
This method returns an undefined value.
Retires the oldest message, closing the box when it was the last. Runs on the event-loop thread, from the ticker.
236 237 238 239 240 |
# File 'lib/tuile/component/notification.rb', line 236 def retire_oldest @messages.shift @messages.empty? ? close : restack sync_ticker end |
#sync_ticker ⇒ void
This method returns an undefined value.
Syncs the retirement clock from the invariant "something to retire, and on
screen" — the sole writer of @ticker. Four sites change whether it is
wanted (append, a retirement that empties the queue, Overlay#close, detach),
which is the 2×2 a start-in-#handle_attached / cancel-in-#handle_detached pair
gets half wrong. The early return is also what keeps an append from
restarting the clock and extending the oldest message's life.
249 250 251 252 253 254 255 256 257 258 259 |
# File 'lib/tuile/component/notification.rb', line 249 def sync_ticker want = attached? && !@messages.empty? return if want == !@ticker.nil? if want @ticker = screen.event_queue.tick(DISPLAY_SECONDS) { retire_oldest } else @ticker.cancel @ticker = nil end end |
#wrap_message(message, width) ⇒ ::Array[StyledString]
Wraps one message to width columns, capped at MAX_ROWS_PER_MESSAGE
rows.
The overflow is ellipsized from the joined remainder, not by
ellipsizing the last kept row: that row usually already fits width, so
StyledString#ellipsize would be a no-op and the message would be
truncated with no … to say so.
@param message
@param width
308 309 310 311 312 313 314 315 |
# File 'lib/tuile/component/notification.rb', line 308 def (, width) rows = .wrap(width) return rows if rows.size <= MAX_ROWS_PER_MESSAGE kept = rows.take(MAX_ROWS_PER_MESSAGE - 1) rest = rows[(MAX_ROWS_PER_MESSAGE - 1)..].inject { |joined, row| joined + SPACE + row } kept + [rest.ellipsize(width)] end |
#wrapped_rows(width) ⇒ ::Array[StyledString]
@param width — the box width, border included.
@return — every message, wrapped inside the border.
231 |
# File 'lib/tuile/component/notification.rb', line 231 def wrapped_rows(width) = @messages.flat_map { || (, width - 2) } |