|
|
@@ -0,0 +1,1164 @@
|
|
|
+## Context
|
|
|
+
|
|
|
+The spec delta in `specs/chatview/spec.md` is the behavioral contract,
|
|
|
+and this design document is the complete requirements record from the
|
|
|
+redesign exploration — every agreed requirement lives in this change
|
|
|
+(spec or design); nothing outside it is normative. The current
|
|
|
+implementation (`src/ui/chatview/`) is the functional spec:
|
|
|
+`MessageBuffer` holds a `Vec<Message>` newest-first with
|
|
|
+cached parley layouts and mesh caches; `adjust_scroll`→
|
|
|
+`calc_total_height` walks the entire buffer per scroll tick; `gen_meshes`
|
|
|
+clones instructions for every message from newest to the viewport top
|
|
|
+every frame; mesh caches are only cleared on rect/scale/epoch change
|
|
|
+(never on scroll); `chat::make()` builds a full screen per channel and
|
|
|
+switching toggles `is_visible`. Wheel/flick share one `speed: AtomicF32`
|
|
|
+decayed by a 10ms loop.
|
|
|
+
|
|
|
+Scene/property system facts the design relies on: `Property*::wrap`
|
|
|
+returns a live handle to a property object on a node (cross-node wrapping
|
|
|
+already exists — `window_scale` from `/window`); nodes track parents;
|
|
|
+nodes carry signals (`register`/`trigger`), method-call subscriptions,
|
|
|
+`OnModify`, and task lists; `Pimpl` UIObjects implement `draw` and
|
|
|
+`handle_*` input.
|
|
|
+
|
|
|
+## Goals / Non-Goals
|
|
|
+
|
|
|
+**Goals:**
|
|
|
+
|
|
|
+- Ground-up `src/ui/chatview2/` with one module per concern, replacing
|
|
|
+ `src/ui/chatview/` at parity.
|
|
|
+- Geometry operations independent of buffer size; bounded render
|
|
|
+ resources; non-blocking loading.
|
|
|
+- Scene API: chatview2 node + one stable sub-node per message type, ids
|
|
|
+ in signal/method payloads, property inheritance with a single `regen`
|
|
|
+ verb for styling and content changes.
|
|
|
+- Keep kvdb storage with a type-tagged value format; persist unconfirmed
|
|
|
+ messages.
|
|
|
+
|
|
|
+**Non-Goals:**
|
|
|
+
|
|
|
+- Rich span/block privmsg body (quotes, code, math) — v1 is plain text +
|
|
|
+ nicks + URLs + cap/expand; APIs shaped for later.
|
|
|
+- New message types beyond current parity set (stickers, forms, latex,
|
|
|
+ all-view, first-contact notices) — the registry makes them additive.
|
|
|
+- Layout sidecar (persisted height cache) — deferred until re-entry on
|
|
|
+ big histories measures poorly.
|
|
|
+- Hard buffer cap, strict window-bound eviction, full anchor-based scroll
|
|
|
+ core — simplest-thing-first; upgrade paths noted below.
|
|
|
+- Any change outside `bin/app`.
|
|
|
+
|
|
|
+**Future message content** (out of v1, but the registry, property
|
|
|
+inheritance, payload format, and height-change machinery are shaped so
|
|
|
+each lands as a new message type without reworking buffer or scroll):
|
|
|
+animated stickers, forms, emojis, code blocks, latex, status messages,
|
|
|
+images and rich media, green-text quotes, multi-line text, clickable
|
|
|
+nicks/media, network messages, all-view (merged-channel view showing
|
|
|
+channel labels), nick-highlight on mention, first-time-message and
|
|
|
+rename notices, show/hide timestamps, click-to-notify items.
|
|
|
+
|
|
|
+## Decisions
|
|
|
+
|
|
|
+### Module layout
|
|
|
+
|
|
|
+```
|
|
|
+src/util/fenwick.rs generic Fenwick tree (see Fenwick section)
|
|
|
+src/ui/chatview2/
|
|
|
+├── mod.rs ChatView2 node: properties, methods, signals, input
|
|
|
+│ dispatch, draw assembly. Orchestrates; owns nothing heavy.
|
|
|
+├── buffer.rs MsgBuffer: ordering, dedup, geometry. Pure data.
|
|
|
+├── scroll.rs ScrollController: gestures, animation, clamping,
|
|
|
+│ compensation, save/restore.
|
|
|
+├── loader.rs Loader: kvdb → filter → buffer, coverage maintenance.
|
|
|
+└── msg/
|
|
|
+ ├── mod.rs MessageType contract + registry
|
|
|
+ ├── privmsg.rs privmsg type node + instances
|
|
|
+ ├── filemsg.rs fud file type node + instances
|
|
|
+ └── datemsg.rs date separator type node (derived, never stored)
|
|
|
+```
|
|
|
+
|
|
|
+Main structures and basic APIs per file (sketches, not final signatures):
|
|
|
+
|
|
|
+`mod.rs` — the scene node and its public surface:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct ChatView2 { /* node, renderer, redraw, executor, buffer,
|
|
|
+ scroll controller, loader handles */ }
|
|
|
+
|
|
|
+impl ChatView2 {
|
|
|
+ pub async fn new(node, kv_db, window_scale, renderer, redraw, sg_root,
|
|
|
+ i18n_fish, ex) -> Pimpl
|
|
|
+}
|
|
|
+
|
|
|
+// Properties (rect, shared styling, is_at_bottom: bool — arrow visibility;
|
|
|
+// scroll position itself is internal to the scroll controller).
|
|
|
+// Methods (called via call_method) — view-wide concerns only:
|
|
|
+// set_channel(channel), set_filter, copy_select, unselect,
|
|
|
+// scroll_to_bottom
|
|
|
+// Testing methods (live testing, e.g. driven from the python gui api):
|
|
|
+// get_line_ids() -> [(ts, id), …] of loaded messages in
|
|
|
+// display order
|
|
|
+// delete_line(id) -> drop from buffer + channel kv tree
|
|
|
+// Signals — view-wide concerns only:
|
|
|
+// select_changed(bool)
|
|
|
+// Message lifecycle methods and message-derived signals live on the
|
|
|
+// type nodes, since each type defines its own semantics: privmsg
|
|
|
+// exposes insert_line/insert_unconf_line/confirm plus url/nick
|
|
|
+// interaction signals; filemsg exposes set_file_status plus its file
|
|
|
+// signals.
|
|
|
+// UIObject: draw() assembles only the visible window; handle_mouse_*,
|
|
|
+// handle_touch, handle_key_down dispatch to materialized instances
|
|
|
+// through the type registry.
|
|
|
+```
|
|
|
+
|
|
|
+`buffer.rs` — ordering + geometry, no rendering:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct MsgBuffer {
|
|
|
+ /// Loaded message records
|
|
|
+ records: SlotMap<SlotKey, MsgRecord>,
|
|
|
+ /// Arena slots in display order (newest first, by (ts, msg_id))
|
|
|
+ order: Vec<SlotKey>,
|
|
|
+ /// Cumulative heights over `order`
|
|
|
+ fenwick: Fenwick,
|
|
|
+ /// msg_id -> arena slot
|
|
|
+ index: HashMap<MessageId, SlotKey>,
|
|
|
+}
|
|
|
+
|
|
|
+pub struct MsgRecord {
|
|
|
+ pub ts: Timestamp,
|
|
|
+ pub id: MessageId,
|
|
|
+ /// Message type; its repr(u8) discriminant is the wire tag
|
|
|
+ pub msg_type: MsgType,
|
|
|
+ /// Type-owned payload; per-type state like `confirmed` lives here,
|
|
|
+ /// not on the record
|
|
|
+ pub payload: Vec<u8>,
|
|
|
+ /// Last height reported by the owning type node
|
|
|
+ pub height: f32,
|
|
|
+}
|
|
|
+
|
|
|
+impl MsgBuffer {
|
|
|
+ /// Insert at any (ts, id) position; false if already loaded (dedup)
|
|
|
+ pub fn insert(&mut self, rec: MsgRecord) -> bool
|
|
|
+ /// Remove a loaded record; false if unknown (debug deletion,
|
|
|
+ /// structural edit — batched Fenwick rebuild)
|
|
|
+ pub fn remove(&mut self, id: &MessageId) -> bool
|
|
|
+ /// Update a height; returns the delta for scroll compensation
|
|
|
+ pub fn set_height(&mut self, id: &MessageId, h: f32) -> Option<f32>
|
|
|
+ /// Total px of loaded content
|
|
|
+ pub fn total_height(&self) -> f32
|
|
|
+ /// Display-order range intersecting [scroll, scroll + view_h)
|
|
|
+ pub fn visible_range(&self, scroll: f32, view_h: f32) -> Range<usize>
|
|
|
+ /// Px from content bottom up to the top of msg `id`
|
|
|
+ pub fn pos_of(&self, id: &MessageId) -> Option<f32>
|
|
|
+ /// Oldest loaded ts (loader resume point)
|
|
|
+ pub fn oldest_ts(&self) -> Option<Timestamp>
|
|
|
+ pub fn clear(&mut self)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`scroll.rs` — gestures, animation, anchoring policy:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub enum ScrollState {
|
|
|
+ Idle,
|
|
|
+ Drag { start_y: f32, scroll0: f32 },
|
|
|
+ Glide { velocity: f32 },
|
|
|
+ Anim { from: f32, to: f32, started: Instant },
|
|
|
+}
|
|
|
+
|
|
|
+pub struct ScrollController {
|
|
|
+ state: ScrollState,
|
|
|
+ /// Internal position: px from content bottom; 0 = live bottom.
|
|
|
+ /// NOT a scene property — see the scroll decision section.
|
|
|
+ scroll: f32,
|
|
|
+ /// total_height − view_h, maintained by the chatview
|
|
|
+ max_scroll: f32,
|
|
|
+}
|
|
|
+
|
|
|
+/// Serialized "what the user is looking at" (save/restore boundaries
|
|
|
+/// only): viewport-top message + dy below its top edge
|
|
|
+pub struct Anchor { pub msg: Option<MessageId>, pub dy: f32 }
|
|
|
+
|
|
|
+impl ScrollController {
|
|
|
+ /// Drag: 1:1, cancels Glide/Anim
|
|
|
+ pub fn drag_start(&mut self, y: f32)
|
|
|
+ pub fn drag_move(&mut self, y: f32) -> f32
|
|
|
+ pub fn drag_end(&mut self, velocity: f32)
|
|
|
+ /// Wheel/PageUp/PageDown: set or extend Anim target by half a page
|
|
|
+ pub fn page_tick(&mut self, dir: f32, page: f32)
|
|
|
+ /// The down-arrow: teleport to bottom, cancel all motion
|
|
|
+ pub fn scroll_to_bottom(&mut self)
|
|
|
+ /// Whether scroll == 0 (drives the is_at_bottom scene property)
|
|
|
+ pub fn is_at_bottom(&self) -> bool
|
|
|
+ /// Animator advance; applies the frame's scroll internally
|
|
|
+ pub fn tick(&mut self, now: Instant) -> Option<f32>
|
|
|
+ pub fn clamp(&self, scroll: f32) -> f32
|
|
|
+ /// Height-change compensation (see the scroll decision section)
|
|
|
+ pub fn compensate(&mut self, delta: f32, msg_below_viewport: bool)
|
|
|
+ /// Snapshot of the current view position; no persistence here
|
|
|
+ pub fn anchor(&self) -> Anchor
|
|
|
+ /// Resolve an anchor against pos_of(id); clamped fallback
|
|
|
+ pub fn restore(&mut self, anchor: &Anchor,
|
|
|
+ pos_of: impl Fn(&MessageId) -> Option<f32>) -> f32
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`loader.rs` — the single background pipeline:
|
|
|
+
|
|
|
+```rust
|
|
|
+/// Why the loader was woken. Reasons are advisory bookkeeping: the
|
|
|
+/// pump always just restores the coverage invariant, whatever the
|
|
|
+/// trigger — except ChannelSwitch, which clears the buffer first.
|
|
|
+/// The reason is recorded for trace logs and to let the pump skip
|
|
|
+/// work it knows is pointless (e.g. NearTop when already covered).
|
|
|
+pub enum Wakeup { ChannelSwitch, NearTop, Insert, FilterChange, RectChange }
|
|
|
+
|
|
|
+pub type FilterFn = Arc<SyncMutex<Box<dyn Fn(&MsgRecord) -> bool + Send>>>;
|
|
|
+
|
|
|
+pub struct Loader {
|
|
|
+ /// Sole kvdb accessor for this chatview
|
|
|
+ tree: Tree,
|
|
|
+ buffer: Arc<AsyncMutex<MsgBuffer>>,
|
|
|
+ filter: FilterFn,
|
|
|
+ /// Wakers call wake(reason); reasons accumulate in a bitset so
|
|
|
+ /// coalesced wakes are not lost while the pump is running
|
|
|
+ cv: CondVar,
|
|
|
+ pending: AtomicU8,
|
|
|
+}
|
|
|
+
|
|
|
+impl Loader {
|
|
|
+ /// Wake the pump, recording the reason (bits coalesce)
|
|
|
+ pub fn wake(&self, reason: Wakeup)
|
|
|
+ /// Bind a channel tree; refill newest→older until coverage met
|
|
|
+ pub async fn bind(&mut self, tree: Tree)
|
|
|
+ /// Coverage pump: take pending reasons, ChannelSwitch clears the
|
|
|
+ /// buffer, then load newest→older until viewport + margin is
|
|
|
+ /// covered; apply filter, batch structural edits into one Fenwick
|
|
|
+ /// rebuild per batch
|
|
|
+ async fn pump(&mut self, viewport: Range<f32>, margin: f32)
|
|
|
+ /// Decode kvdb entry -> record; unknown type id panics (corrupt)
|
|
|
+ fn decode_entry(key: &[u8], val: &[u8]) -> MsgRecord
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Wakers: the chatview calls `wake(ChannelSwitch)` from `set_channel`;
|
|
|
+the draw/scroll path calls `wake(NearTop)` when the viewport approaches
|
|
|
+the top of loaded coverage; the type nodes' insert methods call
|
|
|
+`wake(Insert)`; `set_filter` and rect changes call their own reasons.
|
|
|
+The loop itself is the plain condvar wait → pump → reset shown in the
|
|
|
+loader decision section.
|
|
|
+
|
|
|
+`msg/mod.rs` — the type contract and registry:
|
|
|
+
|
|
|
+```rust
|
|
|
+/// All message types, hardcoded — a fixed enum, no factories.
|
|
|
+/// The wire tag IS the discriminant (repr(u8)): encode with
|
|
|
+/// `msg_type as u8`.
|
|
|
+#[repr(u8)]
|
|
|
+pub enum MsgType {
|
|
|
+ PrivMsg = 0,
|
|
|
+ FileMsg = 1,
|
|
|
+ DateMsg = 2,
|
|
|
+}
|
|
|
+
|
|
|
+/// Decode a wire tag. Unknown tags are corrupt data and panic —
|
|
|
+/// errors are always explicit, never a silent skip.
|
|
|
+pub fn msg_type_from_u8(t: u8) -> MsgType {
|
|
|
+ match t {
|
|
|
+ 0 => MsgType::PrivMsg,
|
|
|
+ 1 => MsgType::FileMsg,
|
|
|
+ 2 => MsgType::DateMsg,
|
|
|
+ _ => panic!("unknown msg type tag {t}"),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+pub trait MessageType {
|
|
|
+ type Instance;
|
|
|
+ fn msg_type(&self) -> MsgType;
|
|
|
+ /// new() + regen(): build an instance from a record + live props
|
|
|
+ fn materialize(&mut self, rec: &MsgRecord) -> &mut Self::Instance
|
|
|
+ /// Drop render state, cancel render-scoped tasks (LRU budget)
|
|
|
+ fn release(&mut self, id: &MessageId)
|
|
|
+ /// Rebuild rendered state from live props + current data
|
|
|
+ fn regen(&mut self, id: &MessageId)
|
|
|
+ fn height(&self, id: &MessageId) -> Option<f32>
|
|
|
+ fn draw(&mut self, id: &MessageId, rect: &Rectangle, renderer: &Renderer)
|
|
|
+ -> Vec<DrawInstruction>
|
|
|
+ /// Hit dispatch (urls, nicks, buttons) in msg-local coordinates
|
|
|
+ fn hit_test(&mut self, id: &MessageId, pos: Point) -> Option<Hit>
|
|
|
+ /// Clipboard contribution when selected (None = nothing copied)
|
|
|
+ fn copy_text(&mut self, id: &MessageId) -> Option<String>
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Each type file pairs one scene node (properties/signals/methods, stable
|
|
|
+across channels) with its per-id instances. All three implement the
|
|
|
+`MessageType` trait from `msg/mod.rs`; the sketches below show only the
|
|
|
+type-specific surface.
|
|
|
+
|
|
|
+`msg/privmsg.rs` — the type with insertion semantics:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct PrivMsgNode {
|
|
|
+ /// Scene node handle; properties: nick_colors, url_text_color,
|
|
|
+ /// url_bg_color, url_bg_border_*, action_text_color, cap_max_height
|
|
|
+ /* node, renderer, executor handles */
|
|
|
+ instances: HashMap<MessageId, PrivMsg>,
|
|
|
+}
|
|
|
+
|
|
|
+pub struct PrivMsg {
|
|
|
+ /// nick, text, is_action, is_notice, confirmed, expanded
|
|
|
+ pub data: PrivData,
|
|
|
+ /// Live handles resolved at new() (inherited or type-local)
|
|
|
+ pub props: PrivProps,
|
|
|
+ /// Txt layout, url click rects, cached draw instrs, height
|
|
|
+ pub rendered: PrivRendered,
|
|
|
+}
|
|
|
+
|
|
|
+// Methods: insert_line, insert_unconf_line (persist via the loader,
|
|
|
+// buffer insert, materialize if visible), confirm(id) —
|
|
|
+// mark an unconfirmed message confirmed (rewrite payload in
|
|
|
+// the kv tree, update data, regen for styling)
|
|
|
+// Signals: nick_clicked(id, nick), url_clicked(id, url)
|
|
|
+```
|
|
|
+
|
|
|
+`msg/filemsg.rs` — status lifecycle + eviction-surviving tasks:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct FileMsgNode {
|
|
|
+ /// Properties: max_height, margins, box styling, glow
|
|
|
+ /* node, renderer, executor handles */
|
|
|
+ instances: HashMap<MessageId, FileMsg>,
|
|
|
+ /// Content-scoped tasks keyed by file url; survive release,
|
|
|
+ /// dedup re-materialization, drain on stop()
|
|
|
+ tasks: HashMap<Url, Task<()>>,
|
|
|
+}
|
|
|
+
|
|
|
+pub struct FileMsg {
|
|
|
+ /// file_url, status, imgbuf (Arc<SyncMutex<Option<…>>>)
|
|
|
+ pub data: FileData,
|
|
|
+ pub props: FileProps,
|
|
|
+ /// Status box / image meshes, active_rect, height
|
|
|
+ pub rendered: FileRendered,
|
|
|
+}
|
|
|
+
|
|
|
+// Method: set_file_status(url, status)
|
|
|
+// Signals: fileurl_detected(url), download_request(id, url),
|
|
|
+// status_changed(id)
|
|
|
+```
|
|
|
+
|
|
|
+`msg/datemsg.rs` — derived day separators (see next section):
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct DateMsgNode {
|
|
|
+ /// Properties: color (font/size inherited from the chatview)
|
|
|
+ /* node, renderer, executor handles */
|
|
|
+ instances: HashMap<MessageId, DateMsg>,
|
|
|
+}
|
|
|
+
|
|
|
+pub struct DateMsg {
|
|
|
+ /// Local-midnight ts of the labeled day (from the record payload)
|
|
|
+ pub data: DateData,
|
|
|
+ pub props: DateProps,
|
|
|
+ /// Rendered label line, height = line_height
|
|
|
+ pub rendered: DateRendered,
|
|
|
+}
|
|
|
+
|
|
|
+// No methods, no signals. Never persisted. Copy text: the date label.
|
|
|
+```
|
|
|
+
|
|
|
+A question about the system maps to exactly one file (buffer↔ordering/
|
|
|
+geometry, scroll↔gestures/animation, loader↔persistence/coverage,
|
|
|
+msg/↔rendering/interaction).
|
|
|
+
|
|
|
+### Derived records: how datemsg interleaves
|
|
|
+
|
|
|
+Date separators occupy vertical space, so they must participate in
|
|
|
+geometry exactly like any other message — but they have no storage and
|
|
|
+no network identity. The model: separators are **derived records owned
|
|
|
+by the buffer**, materialized through the ordinary `datemsg` type. A
|
|
|
+separator for day D gets a synthetic composite key
|
|
|
+`(ts = local_midnight(D), id = [0; 32])`, and its payload is the
|
|
|
+midnight timestamp. Because every message of day D has
|
|
|
+`ts >= midnight(D)`, and every message of any older day has
|
|
|
+`ts < midnight(D)`, the ordinary `(ts, msg_id)` ordering places the
|
|
|
+separator **exactly at the boundary of D's day-run** — no special
|
|
|
+positioning logic exists anywhere; the zero id only breaks the
|
|
|
+exact-midnight tie, sorting the separator older than a message stamped
|
|
|
+at precisely midnight.
|
|
|
+
|
|
|
+```
|
|
|
+display order (bottom of screen = newest)
|
|
|
+───────────────────────────────────────────
|
|
|
+ privmsg 10:02 Aug 30 ┐
|
|
|
+ privmsg 09:58 Aug 30 ┘ day-run D₂
|
|
|
+ ◆ "Sun 30 Aug 2026" key (midnight Aug 30, 0)
|
|
|
+ filemsg 23:41 Aug 29 ┐
|
|
|
+ privmsg 23:12 Aug 29 │ day-run D₁
|
|
|
+ privmsg 01:03 Aug 29 ┘
|
|
|
+ ◆ "Sat 29 Aug 2026" key (midnight Aug 29, 0)
|
|
|
+ …older / still loading…
|
|
|
+```
|
|
|
+
|
|
|
+Buffer invariant, maintained on every structural change: **for every
|
|
|
+maximal same-day run in the loaded order, exactly one separator record
|
|
|
+keyed to that day exists.**
|
|
|
+
|
|
|
+```rust
|
|
|
+// Inside every structural batch (insert / remove / reload):
|
|
|
+fn sync_separators(&mut self, batch: &[MsgRecord]) {
|
|
|
+ // A record starting a new day-run gets its separator next to it
|
|
|
+ for rec in day_run_heads(batch) {
|
|
|
+ if !self.has_separator(rec.day()) {
|
|
|
+ self.insert_derived(separator_for(rec.day()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // A day-run that became empty leaves an orphan separator behind
|
|
|
+ for day in orphaned_days(batch) {
|
|
|
+ self.remove_derived(separator_of(day));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+fn separator_for(day: NaiveDate) -> MsgRecord {
|
|
|
+ let midnight = local_midnight_ts(day);
|
|
|
+ MsgRecord {
|
|
|
+ ts: midnight,
|
|
|
+ id: MessageId([0; 32]), // zero id sorts older at the
|
|
|
+ // exact-midnight tie
|
|
|
+ msg_type: MsgType::DateMsg,
|
|
|
+ payload: encode(&midnight),
|
|
|
+ ..
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+The maintenance paths:
|
|
|
+
|
|
|
+- **Load (loader)**: `derive_separators` runs per record as the batch
|
|
|
+ is collected (see the loader sketch); records and separators enter
|
|
|
+ the buffer together, one Fenwick rebuild covering both. The topmost
|
|
|
+ separator exists because loading stops mid-history; when an older
|
|
|
+ day's messages load, its separator keys naturally to its own
|
|
|
+ midnight — the previous separator needs no move.
|
|
|
+- **Live insert (privmsg insert_line)**: `sync_separators` over the
|
|
|
+ single-record batch; a record starting a new day-run inserts its
|
|
|
+ separator next to it.
|
|
|
+- **Removal (delete_line)**: if the last message of a day-run is
|
|
|
+ removed, the run disappears and the orphan separator is removed in
|
|
|
+ the same batch. This is the path the live-testing deletion exercises.
|
|
|
+- **Filter/reload**: separators re-derive from whatever records the
|
|
|
+ filter admits; they are never filtered themselves.
|
|
|
+
|
|
|
+Separators count toward `total_height`/`visible_range` like any record,
|
|
|
+are hit-tested/materialized through the registry like any type, are
|
|
|
+selectable like any line (contributing their date label to copy), and
|
|
|
+are never written to the kv tree.
|
|
|
+
|
|
|
+### Buffer: arena + order index + Fenwick tree
|
|
|
+
|
|
|
+Records live in a slotmap arena (`{ts, msg_id, msg_type, payload,
|
|
|
+height}`); a `Vec<u32>` of arena slots ordered by `(ts, msg_id)`
|
|
|
+is the display order; a Fenwick tree over heights in that order answers
|
|
|
+`total_height`, `visible_range(scroll)`, and `pos_of(id)`. The per-frame
|
|
|
+paths use only those queries:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Draw path — cost is O(log n) + O(visible), never O(buffer):
|
|
|
+let total = buffer.total_height();
|
|
|
+let max_scroll = (total - view_h).max(0.);
|
|
|
+let scroll = controller.clamp(requested, max_scroll);
|
|
|
+
|
|
|
+for idx in buffer.visible_range(scroll, view_h) {
|
|
|
+ let rec = buffer.record_at(idx);
|
|
|
+ let node = type_node(rec.msg_type);
|
|
|
+ node.materialize_if_needed(rec);
|
|
|
+ instrs.extend(node.draw(&rec.id, &rect, &renderer));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Height changes and newest-position inserts are O(log n) point updates;
|
|
|
+structural edits that move display positions (mid-array backfill
|
|
|
+inserts, removals) are applied by the loader in batches with a single
|
|
|
+O(n) rebuild per batch (see the Fenwick section):
|
|
|
+
|
|
|
+```rust
|
|
|
+// Live arrival (hot path): newest end, no positions move
|
|
|
+buffer.insert(rec); // order.push + fenwick.push
|
|
|
+
|
|
|
+// Backfill batch (loader): positions shift — one rebuild covers it
|
|
|
+buffer.insert_batch(batch); // merge into order, then
|
|
|
+ // fenwick.rebuild(heights)
|
|
|
+```
|
|
|
+
|
|
|
+Rejected alternatives: per-message scene nodes (rejected by requirement
|
|
|
+— one node per type); plain `Vec<Message>` with linear scans (the
|
|
|
+current design and the O(n) hot paths this change exists to remove);
|
|
|
+uniform row quantization (heights are inherently variable — images,
|
|
|
+expansion — so a fixed row unit is a lie while the expensive parts
|
|
|
+remain parley layout and draw submission).
|
|
|
+
|
|
|
+Heights come from the owning type node when a message is materialized
|
|
|
+(wrapped at the current width) — the buffer never lays out text:
|
|
|
+
|
|
|
+```rust
|
|
|
+// materialize() measures; the height flows back into geometry
|
|
|
+let h = node.materialize(rec)?.height;
|
|
|
+let delta = buffer.set_height(&rec.id, h); // fenwick.add(idx, h − old)
|
|
|
+```
|
|
|
+
|
|
|
+First layout of a message is the only remaining
|
|
|
+linear-in-loaded-messages cost; it happens inside the loader,
|
|
|
+incrementally. Full re-layout on resize is O(loaded) parley runs —
|
|
|
+accepted; resize is rare and buffers are bounded by eviction pressure.
|
|
|
+
|
|
|
+### Fenwick tree: what it offers and why
|
|
|
+
|
|
|
+Every geometric question the chatview asks is a **prefix-sum question
|
|
|
+over mutable heights**: total content height, which messages fall inside
|
|
|
+`[scroll, scroll + view_h)`, and where message M sits. The summands
|
|
|
+change constantly — inserts at any timestamp, async height changes
|
|
|
+(image loads, expansion), filter rebuilds. A Fenwick tree (binary
|
|
|
+indexed tree) is the minimal structure that answers both directions at
|
|
|
+O(log n): a flat `Vec<f32>` laid parallel to the order index where entry
|
|
|
+`i` holds the partial sum of a bit-aligned block of heights ending at
|
|
|
+`i`. Walking bit patterns instead of scanning messages gives:
|
|
|
+
|
|
|
+- `prefix(i)` — cumulative height of messages `0..i` (from the live
|
|
|
+ bottom upward): O(log n). `total_height` is one call.
|
|
|
+- `lower_bound(px)` — descend the tree to find the message containing
|
|
|
+ pixel distance `px` from the bottom, never touching per-message
|
|
|
+ heights: O(log n). The visible range at a scroll position is two
|
|
|
+ calls (`scroll`, `scroll + view_h`); `pos_of(id)` is its inverse.
|
|
|
+- `add(idx, δ)` — height change `h → h'` (`δ = h' − h`) and end-appends:
|
|
|
+ O(log n), touching ~log n floats. Structural edits that *move*
|
|
|
+ display positions (mid-array backfill inserts, removals) are not
|
|
|
+ point-updates — the Fenwick is rebuilt from the order index in one
|
|
|
+ O(n) pass per loader batch, keeping the structural cost off the
|
|
|
+ frame path (µs at 10k records, under the loader's lock, amortized
|
|
|
+ across the whole batch).
|
|
|
+
|
|
|
+This is what makes the buffer-size-independence requirement structural
|
|
|
+rather than hopeful: the per-frame costs of scrolling (clamp, visible
|
|
|
+range, draw window) are O(log n) + O(visible), independent of how much
|
|
|
+history is loaded, and height changes from async content updates are
|
|
|
+cheap point-adds.
|
|
|
+
|
|
|
+The tree lives in `src/util/fenwick.rs` as a plain reusable structure
|
|
|
+(it knows nothing about messages); `buffer.rs` owns the invariant that
|
|
|
+the Fenwick mirrors the order index. Basic design and API:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct Fenwick {
|
|
|
+ /// Partial sums, 1-indexed; node i covers the (i & -i) items
|
|
|
+ /// ending at i
|
|
|
+ tree: Vec<f32>,
|
|
|
+ /// Number of items
|
|
|
+ len: usize,
|
|
|
+}
|
|
|
+
|
|
|
+impl Fenwick {
|
|
|
+ /// Build from display-order values (O(n))
|
|
|
+ pub fn new(vals: &[f32]) -> Self
|
|
|
+ /// Append an item (O(log n)) — live-arrival hot path
|
|
|
+ pub fn push(&mut self, val: f32)
|
|
|
+ /// Current value at position idx (O(log n))
|
|
|
+ pub fn get(&self, idx: usize) -> f32
|
|
|
+ /// val += delta at idx (O(log n)) — height changes
|
|
|
+ pub fn add(&mut self, idx: usize, delta: f32)
|
|
|
+ /// Overwrite the value at idx (O(log n))
|
|
|
+ pub fn set(&mut self, idx: usize, val: f32)
|
|
|
+ /// Sum of [0, idx) (O(log n)) — total_height, pos_of
|
|
|
+ pub fn prefix(&self, idx: usize) -> f32
|
|
|
+ /// Sum of [from, to) (O(log n))
|
|
|
+ pub fn range(&self, from: usize, to: usize) -> f32
|
|
|
+ /// First idx whose cumulative sum exceeds `target` (O(log n)) —
|
|
|
+ /// px-from-bottom position → message lookup
|
|
|
+ pub fn lower_bound(&self, target: f32) -> usize
|
|
|
+ /// Full rebuild from new values (O(n)) — structural batches
|
|
|
+ pub fn rebuild(&mut self, vals: &[f32])
|
|
|
+ pub fn len(&self) -> usize
|
|
|
+ pub fn is_empty(&self) -> bool
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Internals — the classic implicit layout: no node objects, no pointers,
|
|
|
+one flat `Vec<f32>` where `tree[i]` sums the `i & -i` items ending at
|
|
|
+`i`:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Sum of items [0, i): walk downward, stripping the low bit
|
|
|
+fn prefix(&self, mut i: usize) -> f32 {
|
|
|
+ let mut sum = 0.;
|
|
|
+ while i > 0 {
|
|
|
+ sum += self.tree[i];
|
|
|
+ i -= i & i.wrapping_neg();
|
|
|
+ }
|
|
|
+ sum
|
|
|
+}
|
|
|
+
|
|
|
+// Apply a delta at item i: walk upward through every covering node
|
|
|
+fn add(&mut self, mut i: usize, delta: f32) {
|
|
|
+ i += 1;
|
|
|
+ while i <= self.len {
|
|
|
+ self.tree[i] += delta;
|
|
|
+ i += i & i.wrapping_neg();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// lower_bound(target): descend top-down through the implicit tree,
|
|
|
+// accumulating node sums, landing on the item containing `target`
|
|
|
+// without ever touching per-message heights.
|
|
|
+// get(idx) = prefix(idx + 1) - prefix(idx)
|
|
|
+```
|
|
|
+
|
|
|
+`src/util/fenwick.rs` carries randomized unit tests comparing
|
|
|
+every operation against brute-force sums over a reference `Vec<f32>`.
|
|
|
+
|
|
|
+Alternatives considered:
|
|
|
+
|
|
|
+- **Precomputed flat prefix-sum array** — O(1) queries but O(n)
|
|
|
+ recompute on every insert/height change; per-frame recompute is
|
|
|
+ exactly the current `calc_total_height` failure mode this change
|
|
|
+ removes.
|
|
|
+- **Segment tree** — same asymptotics as Fenwick, but a recursive node
|
|
|
+ structure with more code and worse cache behavior. It only pays off
|
|
|
+ for range assignments or min/max queries (e.g. "first visible
|
|
|
+ message with property X"), which this design doesn't need; if such a
|
|
|
+ query appears later, Fenwick swaps out behind `buffer.rs`'s API.
|
|
|
+- **Skiplist over absolute px positions** (an idea sketched in the old code
|
|
|
+ comments) — pointer-chasing per level, worse constants, and it
|
|
|
+ indexes absolute positions that every below-insertion invalidates;
|
|
|
+ sums compose better than absolute positions under mutation.
|
|
|
+- **Quantized uniform rows** — rejected in the buffer section:
|
|
|
+ variable heights are the point (images, expansion), so a fixed row
|
|
|
+ unit is a lie that still needs this machinery for accuracy.
|
|
|
+
|
|
|
+Costs/limits accepted: float summation means `prefix` can drift by
|
|
|
+rounding as deltas accumulate. Heights are bounded (~1e5–1e6 px total)
|
|
|
+and update counts per session are far below f32 precision limits
|
|
|
+(~7 significant digits), so drift stays sub-pixel; the buffer's
|
|
|
+randomized unit tests compare Fenwick results against exact brute-force
|
|
|
+sums, and a cheap full rebuild (O(n) adds) is available as a
|
|
|
+periodic/manual re-anchoring tool if drift is ever observed.
|
|
|
+
|
|
|
+### Scroll: internal pixels-from-bottom, exposed minimally
|
|
|
+
|
|
|
+Scroll position is **internal controller state**, not a scene property:
|
|
|
+a plain `f32`, pixels from content bottom (0 = live bottom), backed by
|
|
|
+O(log n) geometry. Rationale: compensation and animations mutate it
|
|
|
+constantly (per height change, per animation frame); as a scene
|
|
|
+property every mutation would fire property events, atomic guards, and
|
|
|
+subscriber notifications — overhead plus ordering hazards, for no
|
|
|
+consumer that needs it. The only external consumers are satisfied by:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Scene surface for scrolling (everything else is internal):
|
|
|
+// method: scroll_to_bottom() // the down-arrow button
|
|
|
+// property: is_at_bottom: bool // arrow visibility; the chatview
|
|
|
+// // sets it when scroll hits 0
|
|
|
+```
|
|
|
+
|
|
|
+The controller state machine replaces the old shared `speed` scalar:
|
|
|
+
|
|
|
+```
|
|
|
+State ::= Idle
|
|
|
+ | Drag { grab_y, scroll0 } 1:1, no animation
|
|
|
+ | Glide { velocity } exponential decay
|
|
|
+ | Anim { from, to, t0, ease } wheel/PageUp/PageDown
|
|
|
+```
|
|
|
+
|
|
|
+Inputs are intents; each intent names the state it produces, so
|
|
|
+gestures never smear into a shared scalar:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Grabbing: 1:1 tracking, cancels any in-flight motion
|
|
|
+pub fn drag_start(&mut self, y: f32) {
|
|
|
+ self.state = ScrollState::Drag { start_y: y, scroll0: self.scroll };
|
|
|
+}
|
|
|
+
|
|
|
+// Wheel/PageUp/PageDown: retarget the animation — never add velocity.
|
|
|
+// Repeated ticks extend `to` from the current target (coalescing).
|
|
|
+pub fn page_tick(&mut self, dir: f32, page: f32) {
|
|
|
+ let base = match self.state {
|
|
|
+ ScrollState::Anim { to, .. } => to,
|
|
|
+ _ => self.scroll,
|
|
|
+ };
|
|
|
+ let to = self.clamp(base + dir * page);
|
|
|
+ self.state =
|
|
|
+ ScrollState::Anim { from: self.scroll, to, started: Instant::now() };
|
|
|
+}
|
|
|
+
|
|
|
+// Flick: hand the sampled velocity to a decaying glide
|
|
|
+pub fn drag_end(&mut self, velocity: f32) {
|
|
|
+ self.state = ScrollState::Glide { velocity };
|
|
|
+}
|
|
|
+
|
|
|
+// The down-arrow: teleport to bottom, cancel all motion
|
|
|
+pub fn scroll_to_bottom(&mut self) {
|
|
|
+ self.state = ScrollState::Idle;
|
|
|
+ self.scroll = 0.;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+One animator task drives Glide/Anim on a deadline cadence (computed
|
|
|
+from the easing curve) instead of the fixed 10ms tick, writing
|
|
|
+`self.scroll` directly and triggering redraw. Bottom clamps hard at 0;
|
|
|
+top clamps at loaded content (loader extends coverage as the viewport
|
|
|
+nears the top of the loaded region).
|
|
|
+
|
|
|
+`compensate()` is the single entry point for the height-change rule —
|
|
|
+the chatview calls it after `buffer.set_height` reports a delta:
|
|
|
+
|
|
|
+```rust
|
|
|
+/// Height-change compensation. When a message entirely below the
|
|
|
+/// viewport bottom changes height by `delta`, the content the user is
|
|
|
+/// looking at must not move: since scroll measures from the content
|
|
|
+/// bottom, keeping the same content in view means adding `delta`.
|
|
|
+/// At scroll == 0 (bottom pinned) there is nothing to hold — the
|
|
|
+/// content grows upward and the view auto-follows, so no adjustment.
|
|
|
+/// Changes overlapping or above the viewport are visible growth by
|
|
|
+/// design (image expanding in place) and are also left alone.
|
|
|
+impl ScrollController {
|
|
|
+ pub fn compensate(&mut self, delta: f32, msg_below_viewport: bool) {
|
|
|
+ if msg_below_viewport && self.scroll > 0. {
|
|
|
+ self.scroll += delta;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Caller side (after a regen reported a new height):
|
|
|
+let top = buffer.pos_of(&id); // measured before update
|
|
|
+if let Some(delta) = buffer.set_height(&id, new_h) {
|
|
|
+ controller.compensate(delta, top <= controller.scroll());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+This one rule covers live arrival while reading history (stable),
|
|
|
+pinned bottom (auto-follow), and in-viewport expansion (grows in
|
|
|
+place). Deferred alternative: anchor-based core (`(msg_id, dy)` as
|
|
|
+ground truth) — more robust to mass reflow but more machinery; upgrade
|
|
|
+path is to keep the controller API and swap the internal representation.
|
|
|
+
|
|
|
+#### Anchors: what they are and how inserts affect them
|
|
|
+
|
|
|
+An `Anchor` is the serialization of "what the user is looking at",
|
|
|
+used only at save/restore boundaries (channel exit/entry, reflow) —
|
|
|
+runtime stability is `compensate()`'s job, never the anchor's:
|
|
|
+
|
|
|
+```rust
|
|
|
+pub struct Anchor {
|
|
|
+ /// Message whose top edge is at or above the viewport top edge —
|
|
|
+ /// the oldest visible message. None = bottom (scroll == 0).
|
|
|
+ pub msg: Option<MessageId>,
|
|
|
+ /// dy = (scroll + view_h) − pos_of(msg): how far the viewport top
|
|
|
+ /// sits below the anchor message's top, in px. 0 = the message's
|
|
|
+ /// top is exactly at the viewport top; larger = it is further
|
|
|
+ /// down (partially scrolled past).
|
|
|
+ pub dy: f32,
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Anchoring at the **viewport top** message makes the anchor immune to
|
|
|
+inserts by construction:
|
|
|
+
|
|
|
+- **Inserts below (newer messages arriving, live bottom growing)**:
|
|
|
+ `pos_of(anchor)` shifts, but the anchor is expressed relative to the
|
|
|
+ message itself — restore recomputes from `pos_of`, so the same
|
|
|
+ content reappears at the same place.
|
|
|
+- **Inserts above (older messages backfilled during sync)**: same —
|
|
|
+ the anchor names content, not a position relative to the bottom; whatever
|
|
|
+ shifted above it does not move it.
|
|
|
+- **The anchor message disappearing** (deleted): restore clamps to a
|
|
|
+ valid scroll (nearest valid position) — an explicit, logged
|
|
|
+ fallback, never a silent jump.
|
|
|
+
|
|
|
+```rust
|
|
|
+// Snapshot — cheap, no persistence (the chatview's per-channel state
|
|
|
+// map persists it on channel exit):
|
|
|
+pub fn anchor(&self) -> Anchor { /* per the definition above */ }
|
|
|
+
|
|
|
+// Resolve — after the loader's coverage reaches the anchor:
|
|
|
+// scroll = pos_of(msg) + dy − view_h, clamped
|
|
|
+```
|
|
|
+
|
|
|
+The method is `anchor()` (a snapshot), not `save_anchor()` — nothing
|
|
|
+is persisted by the call itself; persistence is the chatview storing
|
|
|
+the snapshot in its per-channel state map on exit.
|
|
|
+
|
|
|
+### Reflow: width, scale, and styling changes
|
|
|
+
|
|
|
+Wrapping depends on viewport width, so any width change (window
|
|
|
+resize/rotation), window_scale change, or styling change (font size,
|
|
|
+timestamp width, cap height) invalidates the rendered state of every
|
|
|
+loaded message of the affected types. Height-only viewport changes
|
|
|
+(e.g. the chat editor growing) do not re-wrap — they only recompute
|
|
|
+coverage and max scroll, and re-clamp.
|
|
|
+
|
|
|
+Reflow reuses the anchor machinery from channel restore:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Width/scale/styling change → anchor protocol, NOT compensation
|
|
|
+fn reflow(&mut self, atom: &mut PropertyAtomicGuard) {
|
|
|
+ let anchor = self.controller.anchor(); // 1. before invalidating
|
|
|
+ for node in self.affected_types() { // 2. drop rendered state
|
|
|
+ node.drop_rendered_all();
|
|
|
+ }
|
|
|
+ let heights = self.remeasure_all(); // 3. re-wrap, visible
|
|
|
+ self.buffer.rebuild_heights(&heights); // first; one O(n)
|
|
|
+ // Fenwick rebuild
|
|
|
+ self.controller.restore(&anchor, &pos_of); // 4. pos_of recomputed
|
|
|
+ self.redraw.trigger();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Step 3 in v1 is synchronous for all loaded messages — the O(loaded)
|
|
|
+parley-run hitch the buffer section accepts; laying out the visible
|
|
|
+window first keeps the frame correct. The same content stays under the
|
|
|
+viewport across the reflow; a bottom-pinned view stays bottom-pinned.
|
|
|
+
|
|
|
+The height-change compensation rule is deliberately NOT used for
|
|
|
+reflow: it covers incremental async changes (one image loading, one
|
|
|
+message expanding) where a single below-viewport delta keeps the view
|
|
|
+stable. Mass rewrapping changes heights both above and below the
|
|
|
+viewport, where only anchor restore is correct. If the synchronous
|
|
|
+pass ever measures poorly on large buffers, the staged variant
|
|
|
+(visible-first, loader corrects the remainder with stale heights in
|
|
|
+the Fenwick until re-measured) is the upgrade path — same anchor
|
|
|
+protocol, incremental invalidation.
|
|
|
+
|
|
|
+### Loader: single background pipeline
|
|
|
+
|
|
|
+One async task owns all kvdb access per chatview. Coverage invariant:
|
|
|
+the loaded region always includes the live bottom and extends far
|
|
|
+enough above the viewport to cover `viewport + preload margin`:
|
|
|
+
|
|
|
+```rust
|
|
|
+// The only kvdb reader/writer for this chatview
|
|
|
+async fn run(mut self) {
|
|
|
+ loop {
|
|
|
+ self.cv.wait().await;
|
|
|
+ self.pump().await; // restores the coverage invariant
|
|
|
+ self.cv.reset();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async fn pump(&mut self) {
|
|
|
+ let covered = self.buffer.total_height();
|
|
|
+ if covered >= self.scroll + self.view_h + self.margin {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Iterate newest→older from the oldest loaded ts, decode,
|
|
|
+ // apply the filter, derive separators, stop once covered:
|
|
|
+ let mut batch = vec![];
|
|
|
+ for entry in self.tree.range(..oldest_key).rev() {
|
|
|
+ let rec = decode_entry(entry); // panics on corrupt
|
|
|
+ if !(self.filter)(&rec) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ batch.extend(derive_separators(&rec)); // day-run boundary
|
|
|
+ batch.push(rec);
|
|
|
+ if batch_height(&batch) >= shortfall {
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ self.buffer.insert_batch(batch); // one Fenwick rebuild
|
|
|
+ if batch_touches_viewport {
|
|
|
+ self.redraw.trigger()
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Wake-ups: `set_channel`, scroll change nearing the top of coverage,
|
|
|
+message insert, filter change, rect change. Insert of a live message
|
|
|
+writes to kvdb and hands the record to the buffer directly (still
|
|
|
+under the loader's lock ordering). Filter application happens here —
|
|
|
+kvdb → filter → buffer — so filter swap is just a coverage rebuild
|
|
|
+through the same path; filtered messages remain stored.
|
|
|
+
|
|
|
+### Type nodes: data / props / rendered, rebuilt by regen
|
|
|
+
|
|
|
+One sub-node per message type, created once with the chatview, stable
|
|
|
+across channel switches. Per-id message instances owned by the type node:
|
|
|
+
|
|
|
+```
|
|
|
+msg instance = data record payload + self-owned mutable state
|
|
|
+ (file status, image buffer, …) behind interior
|
|
|
+ mutability (Arc<SyncMutex<…>>)
|
|
|
+ props live property handles received in new()
|
|
|
+ rendered layouts, meshes, textures, hit rects, measured
|
|
|
+ height — a pure cache of (data, props)
|
|
|
+```
|
|
|
+
|
|
|
+`regen()` re-reads live props + current data and rebuilds rendered
|
|
|
+state and height. Three triggers, one path:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Trigger 1: a styling property changed → regen all of the type
|
|
|
+fn on_styling_change(&self) {
|
|
|
+ for id in self.instance_ids() {
|
|
|
+ self.regen(&id);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Trigger 2: an async task updated data (image decoded, status) → one
|
|
|
+fn on_data_update(&self, id: &MessageId) {
|
|
|
+ self.regen(id);
|
|
|
+}
|
|
|
+
|
|
|
+// Trigger 3 (materialize after eviction) calls the same verb via new()
|
|
|
+fn regen(&mut self, id: &MessageId) {
|
|
|
+ let data = self.instance(id).data(); // current data
|
|
|
+ let rendered = self.render(&data); // live prop handles,
|
|
|
+ // re-layout, re-measure
|
|
|
+ self.instance_mut(id).rendered = rendered;
|
|
|
+ self.report_height(id, rendered.height); // → buffer + compensation
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Property inheritance is resolved at `new()` time: the type node's own
|
|
|
+property if it defines one, else the chatview's — the same
|
|
|
+`PropertyPtr`, so change notification arrives via existing
|
|
|
+subscriptions and the handler is just "regen".
|
|
|
+
|
|
|
+Task classes: render-scoped (spawn on materialize, cancel on release —
|
|
|
+animations, progressive decode) and content-scoped (hosted on the type
|
|
|
+node, surviving eviction and optionally channel switches for
|
|
|
+content-addressed work like fud downloads):
|
|
|
+
|
|
|
+```rust
|
|
|
+// Content-scoped hosting: keyed by content address, dedups
|
|
|
+// re-materialization, survives release, drains on stop()
|
|
|
+fn ensure_download(&mut self, url: &Url) {
|
|
|
+ self.tasks.entry(url.clone()).or_insert_with(|| {
|
|
|
+ self.ex.spawn(async move { download(url).await })
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// materialize(id) → ensure_download(&url): attach, never duplicate
|
|
|
+// release(id) → render-scoped tasks cancelled; downloads continue
|
|
|
+// stop() → all tasks dropped
|
|
|
+```
|
|
|
+
|
|
|
+For fud the plugin remains the true task host — the node-hosted task
|
|
|
+may simply be a status subscription feeding the data layer.
|
|
|
+
|
|
|
+### Testability: unit coverage of major codepaths
|
|
|
+
|
|
|
+Most major codepaths MUST be unit-testable without a GPU or a window.
|
|
|
+The structural rule that makes this true: **renderer-dependent work
|
|
|
+(mesh/texture allocation) stays at the draw edge; everything else is
|
|
|
+CPU-only.** Concretely:
|
|
|
+
|
|
|
+```rust
|
|
|
+// parley layout needs only fonts (data/font), not a GPU context:
|
|
|
+let layout = text::make_layout2(text, color, font_size, lineheight,
|
|
|
+ window_scale, Some(width), &[], &fg_ranges, align, wrap);
|
|
|
+let height = layout.height(); // measurable in a unit test
|
|
|
+
|
|
|
+// so the msg instance's `rendered` splits into:
|
|
|
+// testable: txt layout, measured height, url hit rects, copy text
|
|
|
+// renderer- mesh/texture caches (verified visually + via trace
|
|
|
+// bound: logs, not by unit test)
|
|
|
+```
|
|
|
+
|
|
|
+Cache *behavior* is bookkeeping, not GPU state, and is unit-tested:
|
|
|
+layout reused across draws until width/styling/data changes, regen
|
|
|
+re-measures and reports, materialize-after-release rebuilds state, LRU
|
|
|
+eviction order under budget pressure. The loader reads the kv tree
|
|
|
+through its tree handle, so unit tests build fixture trees in-test.
|
|
|
+The eviction policy is a pure
|
|
|
+budget component tested standalone. Unit-test surface by phase:
|
|
|
+fenwick ops (1), ordering/dedup/removal (2), geometry + compensation
|
|
|
+(3), codec round-trip + corrupt-entry panic (4), scroll transitions/
|
|
|
+anchors (5), type
|
|
|
+framework cache lifecycle (8), privmsg layout cache + invalidation
|
|
|
+(9), mixed-type copy ordering (11), separator sync/orphans (12), LRU
|
|
|
+policy (14), cap/expand measurement (15). Trace logs and netdebug
|
|
|
+verify the live integration of exactly these paths; they never
|
|
|
+substitute for the unit tests.
|
|
|
+
|
|
|
+### Selection: view-wide state, per-type copy text
|
|
|
+
|
|
|
+Every line is selectable regardless of type (the old chatview exempted
|
|
|
+date separators — dropped). Selection state and rendering live on the
|
|
|
+chatview; what a selected line *contributes to the clipboard* is
|
|
|
+defined by its type.
|
|
|
+
|
|
|
+```rust
|
|
|
+// ChatView2 (mod.rs) owns the state:
|
|
|
+// selected: HashSet<MessageId>
|
|
|
+// Highlight is chatview-drawn: a filled rect of the message's extent
|
|
|
+// (geometry comes from the buffer) in hi_bg_color, drawn behind the
|
|
|
+// type's instructions — so selecting never invalidates a type's
|
|
|
+// rendered cache and types stay ignorant of selection.
|
|
|
+
|
|
|
+// Toggling: hit-test the y position against the buffer window
|
|
|
+async fn select_line(&self, y: f32) {
|
|
|
+ if let Some(rec) = self.buffer.hit(y, self.scroll, self.rect) {
|
|
|
+ self.selected.insert(rec.id.clone());
|
|
|
+ self.redraw.trigger();
|
|
|
+ self.notify_select_changed().await;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Copying: display order, per-type contribution, join by newlines
|
|
|
+fn copy_text(&self) -> String {
|
|
|
+ self.buffer
|
|
|
+ .iter_display_order() // newest→older
|
|
|
+ .filter(|rec| self.selected.contains(&rec.id))
|
|
|
+ .filter_map(|rec| self.type_node(rec.msg_type).copy_text(&rec.id))
|
|
|
+ .collect::<Vec<_>>()
|
|
|
+ .join("\n")
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Per-type copy text: privmsg contributes its rendered line
|
|
|
+(`<nick> text`, action/notice variants as displayed); filemsg
|
|
|
+contributes its file URL; datemsg contributes its date label; future
|
|
|
+types decide for themselves (a type MAY contribute nothing). Selection
|
|
|
+gestures (click toggle, drag sweep, selection-mode taps), `unselect`,
|
|
|
+and the `select_changed` transition signal are unchanged from the
|
|
|
+current chatview and operate uniformly over all types.
|
|
|
+
|
|
|
+### Wire format
|
|
|
+
|
|
|
+kv key: 8-byte BE timestamp + 32-byte msg id (composite key is
|
|
|
+required — same-millisecond collisions are real and derived filemsgs
|
|
|
+intentionally share their source privmsg's timestamp). Value is
|
|
|
+`[u8 tag][type-owned bytes]` (the tag is the `MsgType` discriminant);
|
|
|
+rest. The privmsg payload after the tag is `nick, text` plus a
|
|
|
+`confirmed` flag (confirmed is privmsg-owned state, living in the
|
|
|
+payload — not record state).
|
|
|
+
|
|
|
+**No backward compatibility.** The old chatview's untagged values are
|
|
|
+not readable by chatview2 and no legacy decode path exists. Corrupt or
|
|
|
+unknown data is an explicit, loud failure — decoding an unknown type
|
|
|
+id panics; errors are never silently swallowed:
|
|
|
+
|
|
|
+```rust
|
|
|
+// key: [u64 BE ts][32-byte msg id]
|
|
|
+// value: [u8 tag][type-owned bytes] (tag = MsgType discriminant)
|
|
|
+//
|
|
|
+// privmsg (MsgType::PrivMsg):
|
|
|
+// [nick: String][text: String][confirmed: bool]
|
|
|
+
|
|
|
+fn decode_value(val: &[u8], ts: Timestamp, id: MessageId) -> MsgRecord {
|
|
|
+ let (&tag, rest) = val.split_first().expect("empty value");
|
|
|
+ let msg_type = msg_type_from_u8(tag); // panics on unknown
|
|
|
+ let payload = msg_type.decode_payload(rest).expect("bad payload");
|
|
|
+ MsgRecord { ts, id, msg_type, payload, height: 0. }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Old channel trees therefore cannot be opened by chatview2; the
|
|
|
+migration is a clean break (see Migration Plan), not a decode concern.
|
|
|
+Storage engine decision: **kvdb stays.** The access pattern is ordered
|
|
|
+range scans over `(ts, msg_id)` — a B-tree — and storage I/O is a
|
|
|
+negligible fraction of load cost (layout/wrapping dominates by orders
|
|
|
+of magnitude), so switching engines cannot meaningfully improve
|
|
|
+performance. Turso/libsql would add a heavy dependency and a SQL
|
|
|
+mapping for zero gain on this pattern (its strengths — SQL queries,
|
|
|
+replication, remote access — are unused here), and adding a dependency
|
|
|
+is a supply-chain decision requiring human review regardless.
|
|
|
+
|
|
|
+### Channel switching and app integration
|
|
|
+
|
|
|
+`set_channel(name)` looks up the channel's tree from an internal
|
|
|
+registry (kvdb handle given at construction), saves the outgoing
|
|
|
+channel's scroll anchor, releases the buffer, and lets the loader
|
|
|
+refill:
|
|
|
+
|
|
|
+```rust
|
|
|
+// Single method; the caller already knows, so no signal
|
|
|
+async fn set_channel(&mut self, channel: String) {
|
|
|
+ let anchor = self.controller.anchor();
|
|
|
+ self.channel_state.insert(self.current_channel.clone(), anchor);
|
|
|
+ self.buffer.lock().await.clear(); // release in-memory state
|
|
|
+ let tree = self.tree_registry.tree(&channel);
|
|
|
+ self.loader.bind(tree).await; // refill in the bg
|
|
|
+ // Scroll restore resolves when coverage reaches the saved anchor
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`src/app/schema/chat.rs` is reworked to build the screen once; the
|
|
|
+per-channel `chat::make()` loop in `schema/mod.rs` disappears; the
|
|
|
+channel label and relay paths in `main.rs` retarget the single chatview
|
|
|
+via `set_channel`. Unread highlighting in the menu keys off
|
|
|
+message-received events carrying the channel instead of per-screen
|
|
|
+layers.
|
|
|
+
|
|
|
+## Risks / Trade-offs
|
|
|
+
|
|
|
+- [Fenwick/bookkeeping bugs corrupt geometry] → buffer unit tests over
|
|
|
+ insert/remove/height-change sequences with random operations and
|
|
|
+ invariant checks (prefix sums vs brute force).
|
|
|
+- [Panic on corrupt db data takes down the app] → intentional:
|
|
|
+ corrupt/unknown entries are explicit failures, never silently
|
|
|
+ skipped; the panic message names the channel tree, key, and type id
|
|
|
+ so the offending entry can be found and removed.
|
|
|
+- [Compensation rule degrades under mass reflow (resize/regen)] → mass
|
|
|
+ reflow never uses the compensation rule; it follows the anchor
|
|
|
+ snapshot/restore protocol (see Reflow section). If synchronous
|
|
|
+ rewrap hitches on large buffers, stage it via the loader
|
|
|
+ (visible-first, stale heights corrected incrementally).
|
|
|
+- [Deep scroll restore loads+measures everything newest→anchor] →
|
|
|
+ background-only, content streams in; if it measures poorly, build the
|
|
|
+ deferred layout sidecar.
|
|
|
+- [Per-type node `HashMap<id, …>` grows without pressure] → same LRU
|
|
|
+ budget question as eviction; instrument before tuning (non-strict by
|
|
|
+ decision).
|
|
|
+- [Two chatviews during migration window (old screens + chatview2)] →
|
|
|
+ switchover is a single cutover task in the sequence; old module deleted
|
|
|
+ in the same change once parity tests pass.
|
|
|
+
|
|
|
+## Migration Plan
|
|
|
+
|
|
|
+1. Land chatview2 alongside the old module (unused) — no behavior change.
|
|
|
+2. Cutover `schema/chat.rs` + relays to the single screen; delete
|
|
|
+ `src/ui/chatview/`.
|
|
|
+3. Storage is a clean break, deliberately: chatview2 uses new per-channel
|
|
|
+ trees (versioned names, e.g. `{channel}__chat_tree_v2`), so old trees
|
|
|
+ are ignored rather than misread — history is rebuilt by a darkirc
|
|
|
+ rescan of the DAG. Rollback before cutover is trivial (old path and
|
|
|
+ old trees untouched); after cutover, rollback = revert the cutover
|
|
|
+ commit (new trees are simply ignored by the old code).
|
|
|
+
|
|
|
+## Development Protocol
|
|
|
+
|
|
|
+tasks.md is sequenced as 19 phases, each ending at a **logically atomic
|
|
|
+commit point**. A phase closes only through its gate: the phase's
|
|
|
+verification runs and its evidence is checked, the owner performs a
|
|
|
+thorough code review (amendments and clarifications are applied to the
|
|
|
+code before proceeding), and only then is the phase committed. Work on
|
|
|
+the next phase does not begin before the gate passes. Verification
|
|
|
+tooling available at every phase:
|
|
|
+
|
|
|
+- **Unit tests** (`cargo test` in `bin/app`) for pure components —
|
|
|
+ fenwick, buffer, codec, scroll controller.
|
|
|
+- **netdebug** (zeromq scene backend, enabled by default in `make dev`
|
|
|
+ features): drive and inspect the live scene from a CLI — `CallMethod`
|
|
|
+ (`insert_line`, `set_channel`, `get_line_ids`, `delete_line`),
|
|
|
+ `GetPropertyValue` (e.g. `is_at_bottom`), node/property introspection, and
|
|
|
+ signal subscriptions over the pub socket.
|
|
|
+- **Trace logs** (`ui::chatview2*` log targets with filelog): assert
|
|
|
+ internal behavior — loader batches, Fenwick rebuilds, layout cache
|
|
|
+ hit/miss, materialize/release lifecycle, scroll state transitions.
|
|
|
+- **Visual inspection** on the schema-chatview dev screen, compared
|
|
|
+ against the old chatview where parity applies.
|
|
|
+
|
|
|
+The dev screen — `src/app/schema/test_chatview.rs`, selected by the
|
|
|
+`schema-test-chatview` cargo feature (following the existing
|
|
|
+`schema-test-*` convention and its one-schema-at-a-time compile guard)
|
|
|
+— lands in phase 6 and carries all interactive verification until the
|
|
|
+cutover phases rework the real chat screen.
|
|
|
+
|
|
|
+## Open Questions
|
|
|
+
|
|
|
+- Eviction budget shape (bytes vs entry count, per-type vs global) —
|
|
|
+ tuning detail, decided during implementation.
|
|
|
+- `set_channel` argument is the channel name with an internal tree
|
|
|
+ registry (assumed in design); if the schema layer prefers passing tree
|
|
|
+ handles, it is a constructor-signature change only.
|
|
|
+- Exact signal payload encodings (field order in the encoded data vec) —
|
|
|
+ pinned down per signal while writing the msg nodes.
|