Skip to main content

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}