oriel_iced/column.rs
1//! Provided column renderers — public examples of the [`Column`] render seam
2//! plus one bespoke rich renderer (a mini bar + label). Each
3//! satisfies the same core trait; a user who outgrows them drops to the raw
4//! trait and loses nothing.
5//!
6//! All produce `Element<'static, Message>` (owned, so no borrow escapes a cell),
7//! with the default iced `Theme`/`Renderer`.
8//!
9//! # Fonts
10//!
11//! The provided renderers are deliberately **not** font-knobbed: they bake
12//! the default face at 13 px inside a pixel-calibrated optical text box
13//! (see the constants below) — changing the size would invalidate the
14//! calibration. Headers use the exported [`HEADER_FONT`] (semibold). To
15//! render grid text in your own font or size, implement
16//! [`Column`] yourself — `header`/`cell` return arbitrary elements, and
17//! these renderers are the copyable reference (no privileged internals).
18
19use std::marker::PhantomData;
20
21use iced::alignment::{Horizontal, Vertical};
22use iced::widget::text::Wrapping;
23use iced::widget::{container, progress_bar, row, text};
24use iced::{Element, Font, Length, Padding};
25use oriel_core::columns::{Column, ColumnId, ColumnSizing};
26
27/// Optical text box for body cells, calibrated by on-screen measurement of
28/// the default font at
29/// size 13: the natural line box is 16.9 px with the ink top 4.45 px inside
30/// it, so *any* symmetric centering lands the ink on a half pixel (soft,
31/// one-sided edges against row dividers). Cells therefore wrap their text in
32/// a fixed ODD-height box with a fractional top offset that puts the 9 px
33/// digit/cap ink at exactly 5 px from the box top (5 + 9 + 5 = 19). Centered
34/// in the grid's odd default row height (25), the ink lands on integers —
35/// 8 px above and below. Parity chain: odd ink → odd box → odd row.
36const TEXT_BOX_HEIGHT: f32 = 19.0;
37const TEXT_BOX_TOP: f32 = 0.55;
38
39/// The provided renderers' header font — semibold, so headers read as chrome
40/// rather than data. Bespoke columns are free to match (or not).
41pub const HEADER_FONT: Font = Font {
42 weight: iced::font::Weight::Semibold,
43 ..Font::DEFAULT
44};
45
46/// A text column — the common provided renderer. `value` reads just the field it
47/// needs from the row (the per-value default in practice). `.numeric()`
48/// right-aligns; format precision lives in the `value` closure.
49pub struct TextColumn<Row, Message> {
50 id: ColumnId,
51 title: String,
52 value: Box<dyn Fn(&Row) -> String>,
53 footer: Option<Box<dyn Fn() -> String>>,
54 sizing: ColumnSizing,
55 align: Horizontal,
56 sortable: bool,
57 _message: PhantomData<fn() -> Message>,
58}
59
60impl<Row, Message> TextColumn<Row, Message> {
61 /// A left-aligned text column. `sizing` accepts a bare width (resizable
62 /// with derived bounds — the default shape) or an explicit
63 /// [`ColumnSizing`]. Sortable by default (ag-grid parity);
64 /// opt out with [`sortable(false)`](Self::sortable).
65 pub fn new(
66 id: ColumnId,
67 title: impl Into<String>,
68 sizing: impl Into<ColumnSizing>,
69 value: impl Fn(&Row) -> String + 'static,
70 ) -> Self {
71 Self {
72 id,
73 title: title.into(),
74 value: Box::new(value),
75 footer: None,
76 sizing: sizing.into(),
77 align: Horizontal::Left,
78 sortable: true,
79 _message: PhantomData,
80 }
81 }
82
83 /// Adds a footer (aggregate/total) cell: `footer` is called at render
84 /// time, so capture whatever app state holds the totals (the clock
85 /// pattern) and the strip stays live.
86 #[must_use]
87 pub fn footer(mut self, footer: impl Fn() -> String + 'static) -> Self {
88 self.footer = Some(Box::new(footer));
89 self
90 }
91
92 /// Right-aligns the cell text — the numeric variant.
93 #[must_use]
94 pub fn numeric(mut self) -> Self {
95 self.align = Horizontal::Right;
96 self
97 }
98
99 /// Sets whether the header is clickable to sort by this column (default:
100 /// `true`). A sort only *executes* if the store resolves this column's
101 /// field (e.g. `VecStore::with_fields`) — opt out where it can't or where
102 /// sorting makes no sense (an append-only trace, where arrival order
103 /// IS the meaning).
104 #[must_use]
105 pub fn sortable(mut self, sortable: bool) -> Self {
106 self.sortable = sortable;
107 self
108 }
109}
110
111impl<Row, Message: 'static> Column for TextColumn<Row, Message> {
112 type Element = Element<'static, Message>;
113 type Row = Row;
114
115 fn id(&self) -> ColumnId {
116 self.id
117 }
118 fn sizing(&self) -> ColumnSizing {
119 self.sizing
120 }
121 fn sortable(&self) -> bool {
122 self.sortable
123 }
124 fn header(&self) -> Self::Element {
125 text(self.title.clone()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None).into()
126 }
127 fn cell(&self, row: &Row) -> Self::Element {
128 // Left-aligned cells shrink to their content — which is also what
129 // makes them *measurable* for auto-size (`Length::Fill` has no
130 // natural width; a filled cell auto-sizes to the policy maximum).
131 // Right/center alignment needs Fill to have room to align within.
132 // Either way, the text sits in the calibrated optical box (see
133 // `TEXT_BOX_HEIGHT`) so its ink centers on integer pixels.
134 // Never word-wrap: a too-narrow cell CLIPS (market grid behavior) —
135 // wrapped lines inside the fixed-height optical box read as broken.
136 let content = text((self.value)(row)).size(13.0).wrapping(Wrapping::None);
137 let boxed = if self.align == Horizontal::Left {
138 container(content)
139 } else {
140 container(content.width(Length::Fill).align_x(self.align)).width(Length::Fill)
141 };
142 boxed
143 .height(Length::Fixed(TEXT_BOX_HEIGHT))
144 .padding(Padding { top: TEXT_BOX_TOP, right: 0.0, bottom: 0.0, left: 0.0 })
145 .into()
146 }
147 fn footer(&self) -> Option<Self::Element> {
148 let footer = self.footer.as_ref()?;
149 let content = text(footer()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None);
150 Some(if self.align == Horizontal::Left {
151 content.into()
152 } else {
153 content.width(Length::Fill).align_x(self.align).into()
154 })
155 }
156 fn measure(&self, row: &Row) -> Option<Self::Element> {
157 // Right/center cells wrap their text in fill-the-cell containers
158 // (needed to have room to align within) — those have no natural
159 // width, so measurement uses the bare text instead.
160 Some(text((self.value)(row)).size(13.0).wrapping(Wrapping::None).into())
161 }
162 fn measure_footer(&self) -> Option<Self::Element> {
163 // Same rule as `measure`, in the footer's semibold face (weight
164 // changes ink width).
165 let footer = self.footer.as_ref()?;
166 Some(text(footer()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None).into())
167 }
168}
169
170/// A bespoke rich renderer: a mini horizontal bar plus a numeric label — the
171/// utilization-bar column. Demonstrates the opaque-element design
172/// carrying a non-text cell without the core knowing.
173pub struct BarColumn<Row, Message> {
174 id: ColumnId,
175 title: String,
176 value: Box<dyn Fn(&Row) -> f32>,
177 footer: Option<Box<dyn Fn() -> f32>>,
178 max: f32,
179 sizing: ColumnSizing,
180 sortable: bool,
181 _message: PhantomData<fn() -> Message>,
182}
183
184impl<Row, Message> BarColumn<Row, Message> {
185 /// A bar column showing `value(row)` against `max`. Sizing and
186 /// sortability default as on [`TextColumn::new`].
187 pub fn new(
188 id: ColumnId,
189 title: impl Into<String>,
190 sizing: impl Into<ColumnSizing>,
191 max: f32,
192 value: impl Fn(&Row) -> f32 + 'static,
193 ) -> Self {
194 Self {
195 id,
196 title: title.into(),
197 value: Box::new(value),
198 footer: None,
199 max: max.max(f32::MIN_POSITIVE),
200 sizing: sizing.into(),
201 sortable: true,
202 _message: PhantomData,
203 }
204 }
205
206 /// Adds a footer (aggregate/total) bar, rendered like a cell against the
207 /// same `max`; `footer` is called at render time (capture app totals).
208 #[must_use]
209 pub fn footer(mut self, footer: impl Fn() -> f32 + 'static) -> Self {
210 self.footer = Some(Box::new(footer));
211 self
212 }
213
214 /// Sets whether the header is clickable to sort by this column (default:
215 /// `true` — see [`TextColumn::sortable`]).
216 #[must_use]
217 pub fn sortable(mut self, sortable: bool) -> Self {
218 self.sortable = sortable;
219 self
220 }
221}
222
223impl<Row, Message: 'static> Column for BarColumn<Row, Message> {
224 type Element = Element<'static, Message>;
225 type Row = Row;
226
227 fn id(&self) -> ColumnId {
228 self.id
229 }
230 fn sizing(&self) -> ColumnSizing {
231 self.sizing
232 }
233 fn sortable(&self) -> bool {
234 self.sortable
235 }
236 fn header(&self) -> Self::Element {
237 text(self.title.clone()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None).into()
238 }
239 fn cell(&self, row: &Row) -> Self::Element {
240 let value = (self.value)(row);
241 Self::bar(value, self.max)
242 }
243 fn footer(&self) -> Option<Self::Element> {
244 let footer = self.footer.as_ref()?;
245 Some(Self::bar(footer(), self.max))
246 }
247 fn measure(&self, _row: &Row) -> Option<Self::Element> {
248 // A bar fills whatever width it is given — no natural width;
249 // auto-size leaves the column alone.
250 None
251 }
252 fn measure_footer(&self) -> Option<Self::Element> {
253 // The footer is a bar too — nothing to measure.
254 None
255 }
256}
257
258impl<Row, Message: 'static> BarColumn<Row, Message> {
259 /// The bar + numeric label used by both cells and the footer.
260 fn bar(value: f32, max: f32) -> Element<'static, Message> {
261 row![
262 progress_bar(0.0..=max, value)
263 .girth(Length::Fixed(10.0))
264 .length(Length::Fill),
265 text(format!("{value:.0}")).size(12.0).wrapping(Wrapping::None),
266 ]
267 .spacing(6)
268 .align_y(Vertical::Center)
269 .into()
270 }
271}