Purpose
The chatview2 widget renders a channel's message history as a virtualized,
scrollable chat log with typed interactive messages, replacing the current
bin/app chatview. This spec defines its observable behavior: scene API,
buffer semantics, scrolling, storage, loading, filtering, resource
management, and feature parity with the current implementation.
ADDED Requirements
Requirement: Single chat screen with channel retargeting
The chatview SHALL support rebinding to a different channel's message
store at runtime via a set_channel method. On exit from a channel it
SHALL release that channel's in-memory buffer; on entry it SHALL reload
the target channel's messages through the background loading pipeline.
Message-type sub-nodes and their signal wirings SHALL remain attached and
functional across channel switches.
Scenario: Switching channels clears and reloads
- WHEN
set_channel is called with a channel that has stored history
- THEN the previously displayed messages are no longer rendered and
the target channel's newest messages load in the background
Scenario: Signal wirings survive channel switches
- WHEN the UI has subscribed to a message-type sub-node signal and the
channel is switched
- THEN the subscription remains active and receives signals from
messages of the newly bound channel
Scenario: Entering a channel with no history
- WHEN
set_channel targets a channel with an empty store
- THEN the view renders empty and remains interactive
Requirement: Scroll position restore on re-entry
The chatview SHALL remember, per channel, where the user left off and
restore that position on re-entry. The remembered state SHALL identify the
message being viewed (anchor id plus pixel offset) so restoration is stable
when messages arrive while the channel is not open. A user who left the
channel at the bottom SHALL return to the bottom.
Scenario: Re-entry restores the same content
- WHEN the user exits a channel while scrolled into history and new
messages arrive before re-entry
- THEN the restored view shows the same anchor message at the same
offset within the viewport
Scenario: Re-entry at the bottom
- WHEN the user exits a channel while at the live bottom
- THEN re-entry restores the bottom position and newly arrived
messages are visible
Scenario: Anchor no longer available
- WHEN the anchored message cannot be found on re-entry
- THEN the scroll position is clamped to a valid position without
crashing or blocking
Requirement: Render resources released when out of view
The chatview SHALL release render resources (meshes, glyphs, textures,
layouts) for messages that leave the visible region, using a soft window
plus LRU budget rather than strict window-bound eviction. Scrolling
through a large history SHALL NOT cause unbounded growth of memory or GPU
resource usage. Render-scoped async tasks SHALL be cancelled when their
message is released.
Scenario: Long scroll does not accumulate resources
- WHEN the user scrolls through many screens of history
- THEN resources held for messages far outside the viewport are
released, and total resource usage stays bounded
Requirement: Buffer-size independent interaction
Geometry queries (total content height, the set of messages visible at a
scroll position, the position of a given message) and per-frame scrolling
work SHALL NOT scale with the number of buffered messages. UI interaction
responsiveness SHALL NOT degrade as the buffer grows.
Scenario: Scrolling a large buffer costs like a small one
- WHEN the same viewport is scrolled by the same delta with a small
and then a very large loaded buffer
- THEN per-frame cost and responsiveness are comparable
Requirement: Scroll semantics in pixels from the bottom
The scroll position SHALL be measured in pixels from the live bottom of
the content (0 = bottom, increasing = further up in history) and
SHALL be clamped to the valid range. The position SHALL be internal
view state, not a settable scene property: externally, the view SHALL
expose a scroll_to_bottom method and an at-bottom indication that
distinguishes "at the live bottom" from "scrolled into history".
Scenario: Scroll zero pins to live bottom
- WHEN the view is at the bottom and a new message arrives
- THEN the view stays at the bottom and the new message is visible
Scenario: Clamping
- WHEN a gesture or animation requests a scroll position beyond the
valid range
- THEN the resulting position is clamped and no error occurs
Scenario: Scroll to bottom
- WHEN
scroll_to_bottom is invoked (e.g. the down-arrow button)
- THEN any in-flight motion stops and the view returns to the live
bottom; the at-bottom indication reflects the position
Requirement: Direct-drag scrolling
Touch-drag scrolling, delivered by the app gesture subsystem as a drag
lifecycle (DragStart/DragMove/DragEnd), SHALL move content 1:1
with the pointer in pixels, without animation or smoothing on top. The
slop dead-zone before the drag starts and the move delivery cadence
SHALL come from the gesture session, not the view. Starting a drag —
or touching down over the view — SHALL cancel any in-flight glide or
scroll animation.
Scenario: Finger tracking is pixel-exact
- WHEN the finger moves up by N pixels during a drag
- THEN the content scrolls exactly N pixels, delivered at the
gesture session's move cadence, with no acceleration or smoothing
added by the view
Scenario: Slop dead-zone precedes scroll
- WHEN a touch travels less than the session's touch slop
- THEN no scroll occurs and the touch remains eligible for
tap/long-press recognition
Scenario: Grabbing stops motion
- WHEN a touch begins while an animated scroll or glide is in progress
- THEN the animation/glide stops immediately and the drag takes over
Requirement: Animated page scrolling for wheel and keys
Mouse wheel ticks and PageUp/PageDown SHALL scroll half a page, animated
with easing. Repeated ticks while an animation is in flight SHALL
retarget/coalesce the animation rather than accumulate velocity.
Scenario: Single wheel tick
- WHEN the mouse wheel is scrolled one tick
- THEN the view animates half a page in the wheel direction
Scenario: Repeated ticks coalesce
- WHEN several wheel ticks occur in quick succession
- THEN the animation target extends by half a page per tick and the
motion remains smooth, without a velocity runaway
Requirement: Flick inertia
Releasing a drag with sufficient velocity SHALL produce an inertial glide
that decays over time and stops within the clamped range. Release
velocity SHALL be taken from the gesture session's DragEnd velocity
(the view does not sample its own). Touching down over the view during a
glide SHALL stop it, including before the touch travels past the touch
slop.
Scenario: Flick decays and stops
- WHEN the finger is released with upward velocity
- THEN the content glides in the same direction, decaying, and comes
to rest at or within the valid scroll range
Scenario: Touchdown stops a glide
- WHEN the user touches the view while an inertial glide is in motion
- THEN the glide stops immediately, even if the touch never travels
past the touch slop
Requirement: Scroll compensation for height changes
When a message below the viewport bottom changes height, the scroll
position SHALL be adjusted by the height delta so the viewed content stays
stable, unless scroll is 0 (bottom pinned). Height changes inside the
viewport SHALL grow or shrink the content around the current view without
jumping. Total height and the maximum scroll SHALL reflect height changes
immediately.
Scenario: Image loads below the reading position
- WHEN the user is scrolled into history and a message below the
viewport grows as its image loads
- THEN the viewed content does not move
Scenario: Expansion inside the viewport
- WHEN the user expands a collapsed message that is on screen
- THEN the message expands in place without the surrounding content
jumping out of view
Requirement: Message type sub-nodes
Each message type SHALL be represented by exactly one sub-node of the
chatview, exposing type-specific styling properties, signals whose
payloads identify the message (msg id) plus type-specific data, and
methods. Message lifecycle operations defined by each type's own
semantics (e.g. inserting messages of that type, file status updates)
SHALL be methods and signals of the type's sub-node; the chatview node
SHALL expose only view-wide methods and signals (channel switching,
filtering, selection). Sub-nodes SHALL NOT be created or destroyed when
the buffer changes (channel switch, load, eviction). Registering a new
message type SHALL NOT require modifying existing types.
Scenario: Nick click emits an identified signal
- WHEN the user clicks a nick inside a privmsg
- THEN the privmsg type sub-node emits a signal carrying the msg id
and nick, which the UI can use (e.g. inserting the nick into the chat
editor)
Scenario: Lifecycle operations go through type nodes
- WHEN a privmsg is inserted, confirmed, or a file status changes
- THEN the operation is invoked as a method on the corresponding
type sub-node (privmsg insert/confirm, filemsg status), not on the
chatview node
Scenario: New message type is additive
- WHEN a new message type is registered with its sub-node and payload
decoder
- THEN existing types and stored messages continue to work unchanged
Requirement: Debug introspection and deletion
The chatview SHALL provide methods to support live testing:
enumerating the ids (with timestamps) of currently loaded messages in
display order, and deleting a loaded message by id. Deletion SHALL
remove the message from the buffer (updating ordering, geometry, and
rendered state correctly) and from the channel's storage. These methods
are testing affordances, not user-facing features.
Scenario: Enumerate loaded messages
- WHEN the id-enumeration method is called
- THEN it returns the ids and timestamps of all currently loaded
messages in display order
Scenario: Delete by id updates everything
- WHEN a loaded message is deleted by id via the deletion method
- THEN it disappears from the view, geometry (total height, scroll
range) updates correctly, and it is absent after the channel is
re-entered
Requirement: Styling inheritance and regen
Styling properties shared across message types (e.g. font size, line
height, timestamp styling, selection color) SHALL be defined once on the
chatview node. A type sub-node SHALL only define properties specific to
it and MAY override an inherited property by defining its own. Message
types SHALL receive live property handles when created; changing a styling
property SHALL cause affected messages' rendered state to be rebuilt
(regen) with re-measured heights.
Scenario: Font size change re-renders everything
- WHEN the chatview font size property changes
- THEN all rendered messages are re-laid-out at the new size, heights
are re-measured, and the scroll position remains valid (compensated)
Scenario: Type-specific override
- WHEN a type sub-node defines its own value for an otherwise
inherited property
- THEN messages of that type render using the override while other
types use the inherited value
Requirement: Async message content updates
Message types MAY run async tasks that update their own persistent data
(e.g. download progress, decoded image buffers) and then rebuild their
rendered state, including height. Tasks tied to rendering SHALL be
cancelled on release. Tasks that must outlive eviction (e.g. background
downloads) SHALL be hosted on the type sub-node, keyed by msg id or
content address so duplicates are not spawned, and surviving tasks SHALL
keep updating state; re-materializing a message SHALL attach to running
tasks or current state rather than restart from scratch.
Scenario: Download continues while evicted
- WHEN a file message's download task is running and the message
scrolls out of view
- THEN the download continues, and scrolling back shows current
progress without a duplicate task
Scenario: Content update changes height
- WHEN an image finishes loading and replaces a progress placeholder
- THEN the message re-renders at its new height and scroll
compensation keeps the view stable per the height-change rules
Requirement: Random insertion at any timestamp
Messages SHALL be insertable at any timestamp position (including
backfill of older messages during sync) with the view updating correctly.
Buffer ordering SHALL use the (timestamp, msg_id) composite key.
Inserting a message whose (timestamp, msg_id) already exists SHALL be
ignored (deduplication).
Scenario: Backfilled message appears in order
- WHEN an older message arrives while the user views history that
includes its timestamp position
- THEN it appears at the correct chronological position without
duplicating or displacing other messages
Scenario: Same-millisecond messages coexist
- WHEN two messages share a timestamp but have different msg ids
- THEN both are stored, ordered, and rendered
Scenario: Duplicate insert is ignored
- WHEN the same (timestamp, msg_id) is inserted twice
- THEN the second insert has no visible effect
Requirement: Persisted message storage format
Messages SHALL be persisted in the per-channel kvdb tree with key
(timestamp big-endian, msg_id) and value [type_id][type-owned
bytes]; the type decides how to interpret its bytes. Unconfirmed
messages SHALL be persisted with a confirmed flag (type-owned payload
state), and their later confirmation SHALL update the existing entry in
place rather than create a duplicate. The format is a clean break from
the previous chatview: values it cannot decode are corrupt data and
SHALL fail explicitly (panic) rather than be skipped or misread.
Scenario: Unconfirmed then confirmed
- WHEN a message is sent unconfirmed and later confirmed via the
privmsg type node's confirm method
- THEN exactly one stored entry exists, rendered with confirmed
styling, and it survives app restart
Scenario: Corrupt entry fails explicitly
- WHEN a stored value carries an unknown type id or undecodable
payload
- THEN loading fails loudly with an explicit error identifying the
entry, never a silent skip
Requirement: Background loading pipeline
All message loading — channel entry, live receive, scrolling toward
history, and filter changes — SHALL happen in an async background pipeline
that never blocks UI interaction. The loader SHALL maintain coverage of
the visible region plus a preload margin, waking on demand.
Scenario: Entering a large channel is non-blocking
- WHEN
set_channel targets a channel with a large history
- THEN the UI is immediately interactive while messages stream in
Requirement: Runtime message filter
A filter callback SHALL be settable and replaceable at runtime. The filter
SHALL decide which stored messages enter the buffer during loading;
filtered-out messages SHALL still be persisted. Changing the filter SHALL
rebuild the visible set through the background pipeline.
Scenario: Filter narrows the view
- WHEN a filter that excludes some messages is set and the buffer
reloads
- THEN excluded messages are not rendered but remain in storage
Scenario: Filter replaced at runtime
- WHEN the filter callback is swapped
- THEN the view rebuilds in the background using the new filter
Requirement: Privmsg rendering parity
Privmsgs SHALL render with the current chatview's feature set: nick
coloring (stable per nick), CTCP ACTION rendering, NOTICE styling with
reduced font size, unconfirmed gray styling, timestamps, URL detection
with colored/backgrounded spans, URL click/tap opening, and URL
right-click or long-press copying with the "copied link" toast overlay.
Scenario: URL interaction parity
- WHEN a message containing a URL is clicked, or long-pressed on
touch
- THEN the URL opens (click) or is copied with the toast overlay
(long-press/right-click), matching current behavior
Requirement: Selection across message types
Any displayed message, regardless of type, SHALL be selectable (mouse
click toggle, Tap toggle, drag sweep, long-press entering selection
mode with subsequent drag extending the selection, tap toggling in
selection mode). copy_select
SHALL copy the selected messages' text in display order joined by
newlines, where each selected message contributes copy text defined by
its type; a type MAY contribute nothing. unselect SHALL clear all
selection. select_changed SHALL fire on transitions between having
and not having any selection.
Scenario: Mixed-type selection copies per-type text
- WHEN a privmsg, a file message, and a date separator are all
selected and
copy_select is invoked
- THEN the clipboard contains each message's type-defined copy
text (privmsg its rendered line, file message its file URL, date
separator its date label) joined by newlines in display order, and
selection is cleared
Scenario: Every type toggles
- WHEN a date separator line is clicked
- THEN it becomes selected and shows the selection highlight,
unlike the current chatview where separators are unselectable
Scenario: Long-press selects and drag extends
- WHEN a long-press lands on a message line and the finger then
drags without lifting
- THEN the pressed line becomes selected, selection mode is entered,
and the drag extends the selection instead of scrolling
Scenario: Selection transitions signal
- WHEN the first line becomes selected, or the last selection is
cleared
- THEN
select_changed fires with true and false respectively
Requirement: Expandable message height
Long messages SHALL be capped to a default height with an affordance to
expand to full height; expansion changes the message height and follows
the height-change scroll rules. In v1 the privmsg body is plain text with
nicks and URLs; the payload format and APIs SHALL accommodate a richer
span/block body (quotes, styling, code, math) added later without
breaking stored messages or the buffer/scroll machinery.
Scenario: Long message collapses and expands
- WHEN a message taller than the cap is rendered and the user toggles
expansion
- THEN the message renders capped, then expands in place following
the height-change rules, and collapsing restores the capped height
Requirement: File message parity
fud file messages SHALL be derived from privmsg text containing fud URLs,
render the file status lifecycle (initializing, idle, downloading with
progress, downloaded, error), request downloads on click/tap via a signal,
update rendering when status changes, and display downloaded images
scaled to fit width and height bounds.
Scenario: File download lifecycle
- WHEN a file message is tapped while idle and the download progresses
- THEN a download request signal fires, status renders with progress,
and on completion the image (or completed state) is displayed
Requirement: Reflow on viewport changes
When the viewport width or window scale changes, the chatview SHALL
re-wrap affected messages, re-measure heights, and keep the user's
reading position stable: the anchored content remains in view after
reflow, and a bottom-pinned view stays bottom-pinned. Height-only
viewport changes (e.g. the input editor growing) SHALL NOT trigger
re-wrapping. Reflow SHALL complete without losing messages.
Scenario: Width change keeps reading position
- WHEN the window is resized while the user is reading history
- THEN messages re-wrap at the new width and the message under the
viewport anchor remains in view at the same offset
Scenario: Bottom stays bottom across reflow
- WHEN a reflow occurs while the view is pinned at the live bottom
- THEN the view remains pinned at the bottom
Scenario: Height-only change does not rewrap
- WHEN the chat editor grows and only the viewport height changes
- THEN no re-wrapping occurs and the scroll position remains valid
Requirement: Date separators and derived messages
Date separator messages SHALL be derived from the stored messages' dates,
never persisted, and inserted into the display order at day boundaries.
Scenario: Day boundary renders a separator
- WHEN consecutive messages span midnight
- THEN a date separator renders between them showing the new date
Requirement: i18n support
Message types SHALL accept the i18n translation fish and use it for
translatable user-facing strings (e.g. file status labels).
Scenario: File status string is translated
- WHEN the active language differs and a file message shows "tap to
download"
- THEN the string is rendered translated
Requirement: Keyboard scrolling
PageUp/PageDown (and wheel equivalents) SHALL scroll via the animated page
scrolling behavior, and keyboard input SHALL be consumable so it does not
leak to other UI elements while interacting with the chatview.
Scenario: PageUp animates half a page
- WHEN PageUp is pressed
- THEN the view animates half a page up and the key event is consumed
Requirement: Gesture subsystem integration
The chatview SHALL receive touch input exclusively through the app
gesture subsystem: it SHALL declare its accepted gestures via
gesture_set (tap, long-press, vertical drag), pass an exact
gesture_hit_test matching its rect, and consume the GestureAction
stream via handle_gesture. It SHALL NOT implement its own recognition
thresholds, long-press timers, or velocity sampling; touch slop, tap
bounds, long-press firing, move throttling, and release velocity SHALL
come from the session. Interactive layers floating over the chatview
(e.g. the scroll-to-bottom arrow) SHALL carry a higher node priority
than the chatview so the session's priority-ordered target resolution
delivers their gestures to them.
Scenario: Overlaying arrow receives its taps
- WHEN the scroll-to-bottom arrow is visible over the chatview and
tapped
- THEN the arrow receives the tap (triggering scroll-to-bottom) and
the chatview does not treat it as selection or content activation
Scenario: No local recognition
- WHEN a touch stream is delivered to the chatview
- THEN slop gating, tap/long-press discrimination, long-press
timing, and release-velocity sampling are those of the gesture
session; the chatview applies only scroll physics and content
dispatch