class

Phosphor::View(S, M)

Inherits Reference < Object

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

Log = ::Log.for("phosphor.view")

Constructors

Instance methods

active_id

Returns the id of the currently focused widget, or nil if nothing is focused.

Source
animation_interval

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.

Source
clear_zones

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.

Source
dispatch(event : Event) : Array(M)

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.

Source
focus(id : Symbol) : Void

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.

Source
mount(id : Symbol, widget : Widget(S, M)) : Void

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.

Source
preferred_height(width : Int32) : Int32 | Nil

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.

Source
register_zone(id : String, widget_id : Symbol, frame : Frame) : Void

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.

Source
render_widget(id : Symbol, state : S, frame : Frame, buf : Buffer) : Void

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.

Source
subscribe(id : Symbol, rule : SubRule) : Void

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.

Source
subscribe_data(id : Symbol) : Void

Registers the widget at id to receive every DataEvent, regardless of which widget currently holds focus. See #subscribe_event.

Source
subscribe_event(id : Symbol, event_class : Event.class) : Void

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.

Source
subscribe_tick(id : Symbol) : Void

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.

Source
subscription_summary

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.

Source
transition_widget(id : Symbol, transition : Transition(S, M), renderer : Renderer, terminal : Terminal, frame : Frame, buf : Buffer, state : S, &) : Void

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#renderRenderer#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.

Source
unmount(id : Symbol) : Void

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.

Source
zone_at(x : Int32, y : Int32) : Zone | Nil

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.

Source