rotulus_layout/lib.rs
1//! The layout engine behind the Rotulus chat view.
2//!
3//! Everything between "a message arrives" and "the view knows what
4//! pixels to put where", with no GTK, no GLib and no Pango, so all of it
5//! runs under `cargo test` on display-less CI. The widget that consumes
6//! it is `rotulus`; see docs/design.md.
7//!
8//! ```text
9//! Message ──parse──▶ ParsedText (text + Spans)
10//! │ │
11//! │ layout_message ──▶ LayoutCache (height, LineBoxes)
12//! ▼ │
13//! ChatBuffer ◀────────────────┘
14//! │ rows + HeightIndex + ScrollAnchor
15//! ▼
16//! view: "which rows are visible, and how tall is everything"
17//! ```
18//!
19//! # What this replaces, and why
20//!
21//! HexChat's xtext widget lays out on a uniform grid: every row is
22//! `fontsize × subline_count`, the scroll adjustment's unit is fractional
23//! *text lines*, and hit-testing is `y / fontsize`. That assumption is
24//! wired through the whole widget, and every feature added to it late
25//! had to work around it — inline media reserves
26//! `ceil(image_height / fontsize)` blank text rows and paints a texture
27//! across them, which is why selection drags through empty space and the
28//! marker line draws above an image rather than across it.
29//!
30//! Four things here are deliberate departures:
31//!
32//! - **Parse once.** Styling is resolved at append into [`span::Span`]s.
33//! xtext re-ran its escape state machine over every visible byte on
34//! every render pass *and* separately built a run list at append that
35//! the render path then ignored.
36//! - **Pixels, not lines.** [`index::HeightIndex`] gives O(log n)
37//! pixel↔row in both directions over genuinely variable heights.
38//! - **Anchor, not offset.** [`anchor::ScrollAnchor`] pins the viewport
39//! to a row. Resize, zoom, history backfill, scrollback trim and a
40//! late-arriving image decode then all preserve reading position for
41//! free, rather than each needing its own compensation.
42//! - **Lazy layout.** A width or font change invalidates without
43//! recomputing, so resize costs O(visible). xtext's
44//! `gtk_xtext_calc_lines` re-wraps the entire scrollback.
45//!
46//! # Measurement
47//!
48//! [`measure::TextMeasure`] is the seam. The view supplies a Pango
49//! implementation; tests supply [`measure::FixedMeasure`], where every
50//! character is N pixels wide, so wrap assertions are exact rather than
51//! font-dependent. The trait's unit of work is a *run*, never a
52//! character — xtext measured per character through a full Pango round
53//! trip each time, and the trait shape makes repeating that impossible.
54//!
55//! # Input vocabularies
56//!
57//! Style arrives as runs from the application, already resolved, so the
58//! engine decodes no in-band escapes and nothing a remote user sends can
59//! restyle a row. [`markdown`] is the one vocabulary it parses itself: an
60//! inline subset, rendered on display and transmitted literally.
61//! [`linkify`] finds URLs, against a scheme list the application owns.
62
63#![forbid(unsafe_code)]
64#![warn(missing_debug_implementations)]
65
66pub mod anchor;
67pub mod buffer;
68pub mod index;
69pub mod linkify;
70pub mod markdown;
71pub mod measure;
72pub mod message;
73pub mod search;
74pub mod select;
75pub mod span;
76pub mod wrap;
77
78#[cfg(test)]
79mod tests;
80
81pub use anchor::{Gravity, ScrollAnchor};
82pub use buffer::{ChatBuffer, MIN_INDENT};
83pub use index::HeightIndex;
84pub use linkify::{Linkifier, DEFAULT_SCHEMES};
85pub use markdown::{scan_delims, SourceSpan};
86pub use measure::{FixedMeasure, FontMetrics, TextMeasure};
87pub use message::{
88 Block, Blocks, GroupKey, ImageSize, LoadMoreDirection, Message, MessageFlags, MessageId,
89 MessageKind, Speaker,
90};
91pub use search::{find_all, Match, SearchState};
92pub use select::{Caret, RowSelection, Selection};
93pub use span::{Attrs, ColorRef, Link, LinkId, ParsedText, Span, Style};
94pub use wrap::{AvatarBox, LayoutCache, LayoutGeneration, LayoutParams, LineBox, LineSource};