spec.md 23 KB

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.

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