Class: Tuile::Component::Notification

Inherits:
Overlay
  • Object
show all
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:

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.

Returns:

  • (Integer)
5
MAX_ROWS_PER_MESSAGE =

Rows a single message may occupy before it is ellipsized.

Returns:

  • (Integer)
3
DISPLAY_SECONDS =

Seconds between retirements — how long the oldest message is held.

Returns:

  • (Float)
3.0
WIDTH_FRACTION =

Fraction of the screen width the box may not exceed (see MIN_CAP_WIDTH).

Returns:

  • (Float)
0.4
HEIGHT_FRACTION =

Fraction of the screen height the box may not exceed.

Returns:

  • (Float)
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.

Returns:

  • (Integer)
34
SPACE =

Separator for re-joining wrapped rows before ellipsizing.

Returns:

StyledString.parse(" ")
ROW_BREAK =

Hard-line separator handed to TextView#text=.

Returns:

StyledString.parse("\n")

Instance Attribute Summary

Attributes inherited from Overlay

#close_on_outside_click, #owner

Attributes included from HasContent

#content

Class Method Summary collapse

Instance Method Summary collapse

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

#handle_focus

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.

Parameters:

Returns:



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.add_message(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.add_message(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.

Parameters:



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 add_message(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
  message = build_message(text, color)
  return if message.empty?

  if @messages.size >= MAX_MESSAGES
    Tuile.logger.warn("Notification: dropping #{message.to_s.inspect}, " \
                      "#{MAX_MESSAGES} messages already queued")
    return
  end

  @messages << message
  @high_water = [@high_water, natural_width(message)].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

Parameters:

  • screen_size (Size)

Returns:

  • (Integer)


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

Parameters:

Returns:



264
265
266
267
268
269
# File 'lib/tuile/component/notification.rb', line 264

def build_message(text, color)
  message = StyledString.parse(text)
  return message if color.nil? || message.empty?

  message.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.

Parameters:

  • screen_size (Size)

Returns:

  • (Integer)


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

Parameters:

  • screen_size (Size)

Returns:

  • (Integer)


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

Parameters:

  • screen_size (Size)

Returns:



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.

Returns:



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

Parameters:

Returns:

  • (Boolean)


174
175
176
177
# File 'lib/tuile/component/notification.rb', line 174

def handle_mouse_down?(event)
  close if event.button == :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

Parameters:

Returns:

  • (Boolean)


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

Parameters:

Returns:



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

Parameters:

Returns:

  • (Integer)


276
# File 'lib/tuile/component/notification.rb', line 276

def natural_width(message) = message.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

Parameters:

Returns:



308
309
310
311
312
313
314
315
# File 'lib/tuile/component/notification.rb', line 308

def wrap_message(message, width)
  rows = message.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.

Parameters:

  • width (Integer)

Returns:



231
# File 'lib/tuile/component/notification.rb', line 231

def wrapped_rows(width) = @messages.flat_map { |message| wrap_message(message, width - 2) }