oriel-iced 0.1.0

iced integrator for the oriel headless data grid: satisfies the data and render seams for iced 0.14.
Documentation
//! Provided column renderers — public examples of the [`Column`] render seam
//! plus one bespoke rich renderer (a mini bar + label). Each
//! satisfies the same core trait; a user who outgrows them drops to the raw
//! trait and loses nothing.
//!
//! All produce `Element<'static, Message>` (owned, so no borrow escapes a cell),
//! with the default iced `Theme`/`Renderer`.
//!
//! # Fonts
//!
//! The provided renderers are deliberately **not** font-knobbed: they bake
//! the default face at 13 px inside a pixel-calibrated optical text box
//! (see the constants below) — changing the size would invalidate the
//! calibration. Headers use the exported [`HEADER_FONT`] (semibold). To
//! render grid text in your own font or size, implement
//! [`Column`] yourself — `header`/`cell` return arbitrary elements, and
//! these renderers are the copyable reference (no privileged internals).

use std::marker::PhantomData;

use iced::alignment::{Horizontal, Vertical};
use iced::widget::text::Wrapping;
use iced::widget::{container, progress_bar, row, text};
use iced::{Element, Font, Length, Padding};
use oriel_core::columns::{Column, ColumnId, ColumnSizing};

/// Optical text box for body cells, calibrated by on-screen measurement of
/// the default font at
/// size 13: the natural line box is 16.9 px with the ink top 4.45 px inside
/// it, so *any* symmetric centering lands the ink on a half pixel (soft,
/// one-sided edges against row dividers). Cells therefore wrap their text in
/// a fixed ODD-height box with a fractional top offset that puts the 9 px
/// digit/cap ink at exactly 5 px from the box top (5 + 9 + 5 = 19). Centered
/// in the grid's odd default row height (25), the ink lands on integers —
/// 8 px above and below. Parity chain: odd ink → odd box → odd row.
const TEXT_BOX_HEIGHT: f32 = 19.0;
const TEXT_BOX_TOP: f32 = 0.55;

/// The provided renderers' header font — semibold, so headers read as chrome
/// rather than data. Bespoke columns are free to match (or not).
pub const HEADER_FONT: Font = Font {
    weight: iced::font::Weight::Semibold,
    ..Font::DEFAULT
};

/// A text column — the common provided renderer. `value` reads just the field it
/// needs from the row (the per-value default in practice). `.numeric()`
/// right-aligns; format precision lives in the `value` closure.
pub struct TextColumn<Row, Message> {
    id: ColumnId,
    title: String,
    value: Box<dyn Fn(&Row) -> String>,
    footer: Option<Box<dyn Fn() -> String>>,
    sizing: ColumnSizing,
    align: Horizontal,
    sortable: bool,
    _message: PhantomData<fn() -> Message>,
}

impl<Row, Message> TextColumn<Row, Message> {
    /// A left-aligned text column. `sizing` accepts a bare width (resizable
    /// with derived bounds — the default shape) or an explicit
    /// [`ColumnSizing`]. Sortable by default (ag-grid parity);
    /// opt out with [`sortable(false)`](Self::sortable).
    pub fn new(
        id: ColumnId,
        title: impl Into<String>,
        sizing: impl Into<ColumnSizing>,
        value: impl Fn(&Row) -> String + 'static,
    ) -> Self {
        Self {
            id,
            title: title.into(),
            value: Box::new(value),
            footer: None,
            sizing: sizing.into(),
            align: Horizontal::Left,
            sortable: true,
            _message: PhantomData,
        }
    }

    /// Adds a footer (aggregate/total) cell: `footer` is called at render
    /// time, so capture whatever app state holds the totals (the clock
    /// pattern) and the strip stays live.
    #[must_use]
    pub fn footer(mut self, footer: impl Fn() -> String + 'static) -> Self {
        self.footer = Some(Box::new(footer));
        self
    }

    /// Right-aligns the cell text — the numeric variant.
    #[must_use]
    pub fn numeric(mut self) -> Self {
        self.align = Horizontal::Right;
        self
    }

    /// Sets whether the header is clickable to sort by this column (default:
    /// `true`). A sort only *executes* if the store resolves this column's
    /// field (e.g. `VecStore::with_fields`) — opt out where it can't or where
    /// sorting makes no sense (an append-only trace, where arrival order
    /// IS the meaning).
    #[must_use]
    pub fn sortable(mut self, sortable: bool) -> Self {
        self.sortable = sortable;
        self
    }
}

impl<Row, Message: 'static> Column for TextColumn<Row, Message> {
    type Element = Element<'static, Message>;
    type Row = Row;

    fn id(&self) -> ColumnId {
        self.id
    }
    fn sizing(&self) -> ColumnSizing {
        self.sizing
    }
    fn sortable(&self) -> bool {
        self.sortable
    }
    fn header(&self) -> Self::Element {
        text(self.title.clone()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None).into()
    }
    fn cell(&self, row: &Row) -> Self::Element {
        // Left-aligned cells shrink to their content — which is also what
        // makes them *measurable* for auto-size (`Length::Fill` has no
        // natural width; a filled cell auto-sizes to the policy maximum).
        // Right/center alignment needs Fill to have room to align within.
        // Either way, the text sits in the calibrated optical box (see
        // `TEXT_BOX_HEIGHT`) so its ink centers on integer pixels.
        // Never word-wrap: a too-narrow cell CLIPS (market grid behavior) —
        // wrapped lines inside the fixed-height optical box read as broken.
        let content = text((self.value)(row)).size(13.0).wrapping(Wrapping::None);
        let boxed = if self.align == Horizontal::Left {
            container(content)
        } else {
            container(content.width(Length::Fill).align_x(self.align)).width(Length::Fill)
        };
        boxed
            .height(Length::Fixed(TEXT_BOX_HEIGHT))
            .padding(Padding { top: TEXT_BOX_TOP, right: 0.0, bottom: 0.0, left: 0.0 })
            .into()
    }
    fn footer(&self) -> Option<Self::Element> {
        let footer = self.footer.as_ref()?;
        let content = text(footer()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None);
        Some(if self.align == Horizontal::Left {
            content.into()
        } else {
            content.width(Length::Fill).align_x(self.align).into()
        })
    }
    fn measure(&self, row: &Row) -> Option<Self::Element> {
        // Right/center cells wrap their text in fill-the-cell containers
        // (needed to have room to align within) — those have no natural
        // width, so measurement uses the bare text instead.
        Some(text((self.value)(row)).size(13.0).wrapping(Wrapping::None).into())
    }
    fn measure_footer(&self) -> Option<Self::Element> {
        // Same rule as `measure`, in the footer's semibold face (weight
        // changes ink width).
        let footer = self.footer.as_ref()?;
        Some(text(footer()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None).into())
    }
}

/// A bespoke rich renderer: a mini horizontal bar plus a numeric label — the
/// utilization-bar column. Demonstrates the opaque-element design
/// carrying a non-text cell without the core knowing.
pub struct BarColumn<Row, Message> {
    id: ColumnId,
    title: String,
    value: Box<dyn Fn(&Row) -> f32>,
    footer: Option<Box<dyn Fn() -> f32>>,
    max: f32,
    sizing: ColumnSizing,
    sortable: bool,
    _message: PhantomData<fn() -> Message>,
}

impl<Row, Message> BarColumn<Row, Message> {
    /// A bar column showing `value(row)` against `max`. Sizing and
    /// sortability default as on [`TextColumn::new`].
    pub fn new(
        id: ColumnId,
        title: impl Into<String>,
        sizing: impl Into<ColumnSizing>,
        max: f32,
        value: impl Fn(&Row) -> f32 + 'static,
    ) -> Self {
        Self {
            id,
            title: title.into(),
            value: Box::new(value),
            footer: None,
            max: max.max(f32::MIN_POSITIVE),
            sizing: sizing.into(),
            sortable: true,
            _message: PhantomData,
        }
    }

    /// Adds a footer (aggregate/total) bar, rendered like a cell against the
    /// same `max`; `footer` is called at render time (capture app totals).
    #[must_use]
    pub fn footer(mut self, footer: impl Fn() -> f32 + 'static) -> Self {
        self.footer = Some(Box::new(footer));
        self
    }

    /// Sets whether the header is clickable to sort by this column (default:
    /// `true` — see [`TextColumn::sortable`]).
    #[must_use]
    pub fn sortable(mut self, sortable: bool) -> Self {
        self.sortable = sortable;
        self
    }
}

impl<Row, Message: 'static> Column for BarColumn<Row, Message> {
    type Element = Element<'static, Message>;
    type Row = Row;

    fn id(&self) -> ColumnId {
        self.id
    }
    fn sizing(&self) -> ColumnSizing {
        self.sizing
    }
    fn sortable(&self) -> bool {
        self.sortable
    }
    fn header(&self) -> Self::Element {
        text(self.title.clone()).size(13.0).font(HEADER_FONT).wrapping(Wrapping::None).into()
    }
    fn cell(&self, row: &Row) -> Self::Element {
        let value = (self.value)(row);
        Self::bar(value, self.max)
    }
    fn footer(&self) -> Option<Self::Element> {
        let footer = self.footer.as_ref()?;
        Some(Self::bar(footer(), self.max))
    }
    fn measure(&self, _row: &Row) -> Option<Self::Element> {
        // A bar fills whatever width it is given — no natural width;
        // auto-size leaves the column alone.
        None
    }
    fn measure_footer(&self) -> Option<Self::Element> {
        // The footer is a bar too — nothing to measure.
        None
    }
}

impl<Row, Message: 'static> BarColumn<Row, Message> {
    /// The bar + numeric label used by both cells and the footer.
    fn bar(value: f32, max: f32) -> Element<'static, Message> {
        row![
            progress_bar(0.0..=max, value)
                .girth(Length::Fixed(10.0))
                .length(Length::Fill),
            text(format!("{value:.0}")).size(12.0).wrapping(Wrapping::None),
        ]
        .spacing(6)
        .align_y(Vertical::Center)
        .into()
    }
}