1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! Feed content model: the public block vocabulary (`FeedBlock`,
//! `CustomBlock`) and the item builder (`FeedItem`) — split from
//! `feed.rs` for the file-size discipline (child module of `feed`,
//! the `feed_typeset.rs` pattern).
//!
//! ## The block-vocabulary note (backlog 0102, coordinates 0660/0280)
//!
//! `FeedBlock` shipped EXHAUSTIVE in 0.2.x, so new block kinds cannot
//! land as public variants without a major bump (`cargo semver-checks`
//! `enum_variant_added`). The vocabulary therefore grows in two places:
//! the crate-private [`ItemBlock`] enum is the REAL block vocabulary
//! (typesetting matches on it), and `FeedItem` grows constructors
//! (`rich`, `rich_block`, `rich_lines`) that mint the new kinds.
//! `FeedBlock` values convert losslessly via `From`. The 0.3 budget
//! (docs/backlog/planned/0002) records the fold-back: `FeedBlock`
//! gains `#[non_exhaustive]` + the `Rich` variant, and 0660/0280's
//! block kinds land additively after it.
//!
//! OWNER: CONTENT (app-widgets wave).
use std::rc::Rc;
use crate::base::Rect;
use crate::render::RichText;
use crate::ui::StyledCanvas;
/// One rich block of a feed item.
pub enum FeedBlock {
/// Plain text, wrapped verbatim (log lines, tool output). No
/// markdown parsing.
Text(String),
/// Markdown source — the DOC vocabulary
/// ([`md::parse_doc`](crate::render::md::parse_doc)): core blocks
/// plus GFM tables, in-flow images (lazy mosaic), task lists and
/// strikethrough.
Markdown(String),
/// A fenced code block: highlighted like a markdown fence.
Code {
/// Language label. Routes the lexer like a markdown fence:
/// `"diff"`/`"patch"` tint through the diff mapping
/// (`code::diff_token_color`); every other label renders with
/// the C-like lexer.
lang: String,
/// Verbatim source.
source: String,
},
/// App-drawn block: an honest height-at-width callback plus a draw
/// closure over the block's solved sub-rect. The draw MUST NOT
/// mutate the owning `FeedState` (it runs during paint).
Custom(CustomBlock),
}
/// Shared draw closure (custom blocks are drawn from cloned handles so
/// user paint code runs outside the feed-state borrow).
pub(super) type SharedDrawFn = Rc<dyn Fn(&mut dyn StyledCanvas, Rect)>;
/// The custom-draw escape hatch (badges, tool cards, charts).
pub struct CustomBlock {
pub(super) height: Box<dyn Fn(i32) -> i32>,
pub(super) draw: SharedDrawFn,
}
impl CustomBlock {
/// `height(width) -> rows` must be honest (windowing trusts it);
/// `draw(canvas, rect)` paints inside the block's rect.
pub fn new(
height: impl Fn(i32) -> i32 + 'static,
draw: impl Fn(&mut dyn StyledCanvas, Rect) + 'static,
) -> CustomBlock {
CustomBlock {
height: Box::new(height),
draw: Rc::new(draw),
}
}
}
/// Width-aware row cap on a ROW-BASED block (first-app/0283): applied
/// POST-TYPESET at the width the engine typesets at, because the row
/// count only exists after the wrap — a consumer cannot precompute it.
/// Text/Rich cap their wrapped lines; Markdown/Code cap the rows the
/// whole block typesets to (the count a reader sees, not source
/// lines). `Custom` is the one kind with no cap: it declares its own
/// height and paints its own rect, so the feed has no rows to count.
/// Both fields fill through the [`FeedItem::max_rows`] /
/// [`FeedItem::overflow_marker`] builders; a marker without a cap is
/// inert by construction (nothing overflows).
pub(super) struct RowCap {
/// Total typeset rows the block may occupy, MARKER ROW INCLUDED
/// (so extent/windowing arithmetic stays exact). Clamped ≥ 1.
pub(super) max_rows: Option<usize>,
/// Marker wording override: `hidden_line_count -> text`. Default:
/// "… (+K more lines)".
pub(super) marker: Option<Rc<dyn Fn(usize) -> String>>,
}
impl RowCap {
fn empty() -> RowCap {
RowCap {
max_rows: None,
marker: None,
}
}
}
/// The crate-private block vocabulary the typesetter matches on — the
/// public `FeedBlock` plus the post-0.2 kinds (module doc above). Kept
/// FLAT (no nesting of `FeedBlock`) so `typeset_static` reads like the
/// eventual 0.3 public enum.
pub(super) enum ItemBlock {
Text {
text: String,
/// Optional row cap (first-app/0283); `None` = unbounded, the
/// pre-cap behavior byte-for-byte.
cap: Option<RowCap>,
},
Markdown {
src: String,
/// Optional row cap, same contract as `Text` — over the rows
/// the WHOLE block typesets to, since a markdown source line
/// and a rendered row are not the same thing.
cap: Option<RowCap>,
},
Code {
lang: String,
source: String,
/// Optional row cap, same contract as `Markdown`.
cap: Option<RowCap>,
},
Custom(CustomBlock),
/// Span-model lines (backlog 0102): typeset through the SAME
/// span-preserving wrap + row walk as everything else. Replace-on-
/// update like `Text` (streaming spans are out of scope by design).
Rich {
text: RichText,
/// Optional row cap, same contract as `Text`.
cap: Option<RowCap>,
},
}
impl From<FeedBlock> for ItemBlock {
fn from(b: FeedBlock) -> ItemBlock {
match b {
FeedBlock::Text(s) => ItemBlock::Text { text: s, cap: None },
FeedBlock::Markdown(s) => ItemBlock::Markdown { src: s, cap: None },
FeedBlock::Code { lang, source } => ItemBlock::Code {
lang,
source,
cap: None,
},
FeedBlock::Custom(c) => ItemBlock::Custom(c),
}
}
}
/// One feed item: a small block list. Items are IDENTITIES (keyed);
/// see [`super::FeedState::push`].
pub struct FeedItem {
pub(super) blocks: Vec<ItemBlock>,
}
impl FeedItem {
pub fn new() -> FeedItem {
FeedItem { blocks: Vec::new() }
}
/// Single markdown-block item (the common chat message). Parses
/// the DOC vocabulary: tables, images, task lists and
/// strikethrough render alongside the core blocks.
pub fn markdown(src: impl Into<String>) -> FeedItem {
FeedItem::new().block(FeedBlock::Markdown(src.into()))
}
/// Single plain-text item (the common log line).
pub fn text(s: impl Into<String>) -> FeedItem {
FeedItem::new().block(FeedBlock::Text(s.into()))
}
/// Single code-fence item.
pub fn code(lang: impl Into<String>, source: impl Into<String>) -> FeedItem {
FeedItem::new().block(FeedBlock::Code {
lang: lang.into(),
source: source.into(),
})
}
/// Single rich-text item (backlog 0102): multi-ink spans per line
/// without a custom block — severity-tinted log lines, chat
/// headers, status rows. Lines wrap at item width through the
/// span-preserving wrap (`render::RichText::wrap`), and span
/// styles stay PATCHES: `fg: None` spans inherit the item's theme
/// ink at draw time, explicit inks are resolved `Rgba` and render
/// verbatim (rebuild the item to retint on theme switch — the
/// widget-wide token posture). Rich items are replace-on-update
/// like `text`; for token streaming use [`super::FeedState::push_stream`].
pub fn rich(text: RichText) -> FeedItem {
FeedItem::new().rich_block(text)
}
/// Single rich-text item from pre-built lines — the common "one
/// styled line" construction without spelling `RichText`:
///
/// ```ignore
/// FeedItem::rich_lines(vec![RichLine::from_spans(vec![
/// Span::new("ERROR ", Style::new().fg(t.error)),
/// Span::plain("disk full"),
/// ])])
/// ```
pub fn rich_lines(lines: Vec<crate::render::RichLine>) -> FeedItem {
FeedItem::rich(RichText::from_lines(lines))
}
/// Append a rich-text block (builder form of [`FeedItem::rich`],
/// composable with [`FeedItem::block`] in any order).
pub fn rich_block(mut self, text: RichText) -> FeedItem {
self.blocks.push(ItemBlock::Rich { text, cap: None });
self
}
pub fn block(mut self, b: FeedBlock) -> FeedItem {
self.blocks.push(b.into());
self
}
/// Cap the most recently appended block at `rows` typeset rows
/// (first-app/0283) — the transcript-preview idiom. The cap is
/// WIDTH-AWARE: it applies POST-TYPESET at the width the engine
/// typesets at, where the row count actually exists. Content that
/// occupies at most `rows` rows renders unchanged; overflow shows
/// the first `rows - 1` rows and spends the last row on an
/// honest marker ("… (+K more lines)", `text_muted` ink, K = the
/// hidden row count — it changes with width). A capped
/// block is therefore never taller than `rows` and never hides
/// content silently; extent/windowing count the marker row.
/// `rows` clamps to ≥ 1 (a zero-row cap over hidden content would
/// vanish it without a trace). Streaming items are unaffected
/// (caps live on static blocks only).
///
/// **Markdown and Code cap RENDERED rows, not source lines** — the
/// only count a reader can see, and the only one the extent
/// arithmetic agrees with. A cut lands wherever row `rows - 1`
/// falls, which for a long document can be inside a table or a
/// fence: the rows above it render exactly as they would
/// uncapped, and the marker names what is missing.
///
/// Chain per block: `.block(a).max_rows(3).block(b).max_rows(8)`.
/// Debug builds assert when the last block is `Custom` — it
/// declares its own height and paints its own rect, so the feed
/// has no rows to cap; release ignores it.
pub fn max_rows(mut self, rows: usize) -> FeedItem {
let slot = self.last_cap();
debug_assert!(
slot.is_some(),
"FeedItem::max_rows cannot cap a Custom block (it owns its own height)"
);
if let Some(cap) = slot {
cap.get_or_insert_with(RowCap::empty).max_rows = Some(rows.max(1));
}
self
}
/// Override the overflow-marker wording of the most recently
/// appended block: `hidden_row_count -> text` (e.g.
/// `|k| format!("… (+{k} more — full text in the ledger)")`).
/// Renders in `text_muted`, one row, clipped at the item width.
/// Inert without [`FeedItem::max_rows`] on the same block.
pub fn overflow_marker(mut self, marker: impl Fn(usize) -> String + 'static) -> FeedItem {
let slot = self.last_cap();
debug_assert!(
slot.is_some(),
"FeedItem::overflow_marker cannot mark a Custom block (it owns its own height)"
);
if let Some(cap) = slot {
cap.get_or_insert_with(RowCap::empty).marker = Some(Rc::new(marker));
}
self
}
/// The cap slot of the last block, when that block kind carries one
/// — every row-based kind does; `Custom` is the sole exception.
fn last_cap(&mut self) -> Option<&mut Option<RowCap>> {
match self.blocks.last_mut() {
Some(ItemBlock::Text { cap, .. })
| Some(ItemBlock::Rich { cap, .. })
| Some(ItemBlock::Markdown { cap, .. })
| Some(ItemBlock::Code { cap, .. }) => Some(cap),
_ => None,
}
}
}
impl Default for FeedItem {
fn default() -> Self {
FeedItem::new()
}
}