Phosphor::View(S, M)
Owns the registry of mounted widgets, the focus stack, and subscription routing.
Screen delegates all widget lifecycle, focus management, and per-widget
rendering to View.
Generic over S, the same application state type parameter Screen(S, M)
uses — a View only ever holds Widget(S, M) values for the one S its
owning Screen(S, M) was built with. Also generic over M, the app's own
Msg type — #dispatch collects M values, not the framework's built-in
Phosphor::Msg specifically.
Constants
Constructors
Instance methods
Returns the smallest non-nil Widget#animation_interval across every
currently mounted widget, or nil if none of them animate.
Queried fresh each time rather than cached — mounting, unmounting, or
a screen transition changes the set of widgets from one render to the
next. App#run calls this after each draw to decide how long its
main loop can block on event_ch.receive before it needs to wake up
and synthesize a TickEvent itself, replacing a manually-mounted
TickPort for widgets that declare their own interval.
Written as map + select(Time::Span) rather than compact_map —
when @widgets is empty (no widget ever mounted on this View),
compact_map(&.animation_interval).min? fails to compile: Crystal
can't infer Array(NoReturn)#min?'s block return type. select
against the concrete type sidesteps that inference entirely.
Empties the zone registry. Called once at the start of every render
pass (see Renderer#draw), immediately before Screen#render runs,
so each frame's zones reflect only what that frame actually drew.
Routes event to the right widget(s) and collects the resulting Msg values.
MouseEvents are routed by position: every mounted widget whose hit_test
contains the point receives the event, regardless of focus. A TouchEvent
is routed by the registered Zone at its coordinates — see #dispatch_touch
— rather than hit_test, since a zone can cover a sub-region of a widget's
own frame instead of always matching it exactly; it also reaches any widget
subscribed to TouchEvent via #subscribe_event. A PasteEvent goes to
the focused widget via Cmd::InsertString, not #on_event — see
#dispatch_paste. All other events use focus-based routing — the focused
widget is consulted first, then any subscribed non-focused widgets whose
SubRules match, then (for DataEvent/TickEvent) any widget registered
via #subscribe_data/#subscribe_tick. A Msg value is appended at most
once even if multiple widgets return the same value.
Makes id the active (focused) widget.
Raises if id is not mounted. If id was already in the focus stack it is moved to the top rather than duplicated.
Registers widget under id.
Raises if id is already mounted — duplicate IDs are a programming error.
Sets widget.view and widget.mount_id to self/id, so the widget's
own Widget#register_zone convenience method has somewhere to forward
to and its own mount id to pass along as a zone's widget_id.
Returns the total height needed to show every mounted widget's
content at the given width without scrolling: the sum of every
non-nil Widget#preferred_height(width) across @widgets, or nil
if none of them declare a preference.
Terminal#auto_resize calls this (via App#run) to size an Inline
session's block — see App.run(mode: RenderMode::Inline, height: Terminal::AUTO_HEIGHT). Written as map + select(Int32) rather
than compact_map, same reason as #animation_interval above: it
sidesteps a block-return-type inference failure when @widgets is
empty.
Registers a named, hit-testable region for touch dispatch, under id, registered by the widget mounted as widget_id.
Called by Widget#register_zone (or directly) during render, once a
widget knows the Frame it just drew into. Zones accumulate across a
single render pass — #clear_zones empties the list at the start of
each one, so stale zones from a previous frame never linger. See
#zone_at for how a TouchEvent's coordinates resolve back to a zone,
and #dispatch_touch for how that zone routes to a mounted widget —
trying id first and falling back to widget_id.
Syncs and renders the widget registered under id into buf within frame.
Calls widget.sync(state) first so Props reflect the app's current
state, then calls widget.render(frame, buf) to write cells. The
Screen chooses which frame each widget receives; View does not
decide layout.
Raises if id is not mounted.
Subscribes the widget at id to receive events matching rule, regardless of focus.
Multiple rules can be registered for the same widget; the widget receives the event if any one of its rules matches.
Registers the widget at id to receive every DataEvent, regardless of
which widget currently holds focus. See #subscribe_event.
Registers the widget at id to receive any event where
event.is_a?(event_class), regardless of which widget currently holds
focus.
Unlike #subscribe, this matches by event type rather than a SubRule
— use it for widgets (e.g. a status bar or log) that need to observe
every event of a given class a Port produces, not just events routed
to whichever widget is focused. #subscribe_data and #subscribe_tick
are convenience wrappers over this for the two built-in event types.
Registering the same id for the same event_class more than once is
a no-op.
Registers the widget at id to receive every TickEvent, regardless of
which widget currently holds focus.
View#animation_interval already aggregates the shortest declared
animation_interval across every mounted widget, so more than one
widget can be animating at once even though only the focused widget
would otherwise receive the synthesized TickEvent. Subscribe any
non-focused widget that needs to keep animating here. See
#subscribe_event.
A one-line summary of currently active subscriptions, for the debug
overlay — "none", or each subscribed widget id with its rule count
(e.g. "sidebar (2), log (1)"). Format is not stable — for display
only, never parse this.
Animates the widget registered under id from its currently
rendered state to whatever the block leaves it in, using
transition — the single-widget analog of App#play_transition's
whole-screen swap. Screen#transition_widget is the usual entry
point; this is the layer that does the actual work.
Captures the widget's current render into from_buf (a Buffer
sized to frame, via #render_widget), yields so the caller can
change whatever the widget's next render depends on, then captures
the result into to_buf. Each wraps in a TransitionWidgetScreen
— see its own doc comment for why — so transition.frames can
animate between them exactly as it would two real screens.
from_buf/to_buf are filled with renderer's background style
before the widget renders into them — the same reason Renderer#draw
fills a real screen's Buffer before Screen#render runs (see its
own doc comment): a cell the widget's #render never touches (an
empty area below short content) would otherwise keep Buffer.new's
own blank default (Style.new, no color) instead of an explicit
background, and show the terminal's own default color once flushed.
This has to happen here, not only via step_buf's own per-step fill
below, because every real Transition (Slide, Fade, Push)
composites each step from from_buf/to_buf with full coverage —
step_buf's own fill is what a step's paint Proc paints over, not
what a real Transition ever preserves underneath it (confirmed
against Transition::Slide#paint_step's own Buffer#copy_region
calls, which always cover the whole frame between the two).
Every step Transition#frames yields paints into its own
frame-sized Buffer; Buffer#copy_region composites just that
into buf at frame's position relative to buf's own frame —
the only part of buf this method ever writes. Everything else
buf already holds (the rest of the screen, painted by the caller
before this runs) is left untouched. Paced at 1.second / 60
between steps, the same cadence App#play_transition uses.
to_buf is composited into buf once more, unconditionally, after
playback — a Transition's own last generated step (or, for
NoTransition, no step at all) isn't guaranteed to land on the
exact final render (see e.g. Transition::Fade's own doc comment
on why its last step still mixes in some from_buf cells).
App#play_transition gets away without this because the next
real Screen#render call repaints the whole new screen from
scratch; nothing does that for a single widget here, so this method
guarantees the final state directly instead.
Every step is also flushed live to terminal via
Renderer#draw_frame, right after it's composited into buf — so
playback is actually visible as it plays, not just as a jump straight
to the final state. Paced at 1.second / 60 between steps, the same
cadence App#play_transition uses for whole-screen transitions.
renderer.invalidate runs once more after playback completes — buf
now holds content this method painted directly rather than through
the normal Screen#render → Renderer#draw path, so the next real
draw must treat it as a full redraw rather than diffing against
whatever it cached before. This is on top of (not a replacement for)
the per-step invalidation #draw_frame already does for each of its
own writes.
Raises if id is not mounted, same as #render_widget.
Removes the widget registered under id and cleans up focus state.
If the unmounted widget held focus, it is removed from the focus stack and the previously focused widget automatically becomes active again.
Returns the topmost Zone containing (x, y), or nil if none does.
Searches @zones in reverse registration order — a widget rendered
(and therefore registered) later is drawn on top, so it should win an
overlap, matching BubbleZone's own "last registered wins" behavior.