Skip to main content

densa_grid/
layout.rs

1use densa_text::{GlyphMap, LineMetrics};
2use std::borrow::Cow;
3
4/// Horizontal alignment of text within a column.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6#[non_exhaustive]
7pub enum Align {
8    /// Flush with the column's left edge (labels).
9    Left,
10    /// Flush with the column's right edge (numbers).
11    Right,
12}
13
14/// A grid column: header title, width in characters, alignment. Made with
15/// [`Column::new`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct Column {
19    /// Header text.
20    pub title: Cow<'static, str>,
21    /// Width in characters of the font's digit advance.
22    pub chars: u32,
23    /// Text alignment.
24    pub align: Align,
25}
26
27impl Column {
28    /// A column titled `title`, `chars` digits wide.
29    pub fn new(title: impl Into<Cow<'static, str>>, chars: u32, align: Align) -> Self {
30        Self {
31            title: title.into(),
32            chars,
33            align,
34        }
35    }
36}
37
38/// Blank characters between columns.
39const GAP_CHARS: f32 = 2.0;
40/// Smallest margin around the grid in pixels.
41const MIN_PADDING: u32 = 8;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44struct ColumnGeometry {
45    x: u32,
46    width: u32,
47    chars: u32,
48}
49
50/// Pixel geometry of a grid, derived from its columns and the font's metrics.
51///
52/// Column edges and baselines are whole pixels, so text is positioned identically every
53/// frame and glyphs stay crisp. All sizes are physical pixels.
54#[derive(Debug, Clone, PartialEq)]
55pub struct GridLayout {
56    rows: u32,
57    row_height: u32,
58    padding: u32,
59    gap: u32,
60    char_width: f32,
61    /// Row top to baseline.
62    baseline: u32,
63    columns: Box<[ColumnGeometry]>,
64    width: u32,
65}
66
67impl GridLayout {
68    /// Layout for `rows` data rows (plus a header) of `columns`, drawn with `glyphs`.
69    pub fn new(columns: &[Column], rows: u32, glyphs: &mut GlyphMap) -> Self {
70        let char_width = glyphs.get('0').map_or(glyphs.px() * 0.5, |g| g.advance);
71        let metrics: LineMetrics = glyphs.line_metrics();
72        let (ascent, line_height) = (metrics.ascent, metrics.line_height());
73        // Lossless: font sizes give row heights and gaps of a few dozen pixels.
74        let row_height = line_height.ceil().max(1.0) as u32;
75        let baseline = ((row_height as f32 - line_height) / 2.0 + ascent).round() as u32;
76        let gap = (GAP_CHARS * char_width).ceil() as u32;
77        // Cell backgrounds extend half a gap past their column; keep that on screen.
78        let padding = MIN_PADDING.max(gap.div_ceil(2));
79
80        let mut x = padding;
81        let columns: Box<[ColumnGeometry]> = columns
82            .iter()
83            .map(|c| {
84                let width = (c.chars as f32 * char_width).ceil() as u32;
85                let geometry = ColumnGeometry {
86                    x,
87                    width,
88                    chars: c.chars,
89                };
90                // Saturating: absurd column widths clamp instead of wrapping.
91                x = x.saturating_add(width).saturating_add(gap);
92                geometry
93            })
94            .collect();
95        let content = columns
96            .last()
97            .map_or(0, |c| c.x.saturating_add(c.width) - padding);
98        Self {
99            rows,
100            row_height,
101            padding,
102            gap,
103            char_width,
104            baseline,
105            columns,
106            width: content.saturating_add(2 * padding),
107        }
108    }
109
110    /// Data rows (the header is extra).
111    pub fn rows(&self) -> u32 {
112        self.rows
113    }
114
115    /// Number of columns.
116    pub fn column_count(&self) -> usize {
117        self.columns.len()
118    }
119
120    /// Row pitch in pixels.
121    pub fn row_height(&self) -> u32 {
122        self.row_height
123    }
124
125    /// Margin around the grid in pixels.
126    pub fn padding(&self) -> u32 {
127        self.padding
128    }
129
130    /// Space between columns in pixels.
131    pub fn gap(&self) -> u32 {
132        self.gap
133    }
134
135    /// Advance of the font's `0`: the unit column widths are given in.
136    pub fn char_width(&self) -> f32 {
137        self.char_width
138    }
139
140    /// Total grid width in pixels, padding included.
141    pub fn width(&self) -> u32 {
142        self.width
143    }
144
145    /// Total grid height in pixels: header plus data rows, padding included.
146    pub fn height(&self) -> u32 {
147        self.padding * 2 + (self.rows + 1) * self.row_height
148    }
149
150    /// Left edge of column `index`.
151    ///
152    /// # Panics
153    /// If `index` is not a column.
154    pub fn column_x(&self, index: usize) -> u32 {
155        self.columns[index].x
156    }
157
158    /// Width of column `index` in pixels.
159    ///
160    /// # Panics
161    /// If `index` is not a column.
162    pub fn column_width(&self, index: usize) -> u32 {
163        self.columns[index].width
164    }
165
166    /// Width of column `index` in characters.
167    ///
168    /// # Panics
169    /// If `index` is not a column.
170    pub fn column_chars(&self, index: usize) -> u32 {
171        self.columns[index].chars
172    }
173
174    /// Top edge of grid row `row` (0 is the header).
175    pub fn row_y(&self, row: u32) -> u32 {
176        self.padding + row * self.row_height
177    }
178
179    /// Baseline of grid row `row` (0 is the header), a whole pixel.
180    pub fn baseline_y(&self, row: u32) -> f32 {
181        (self.row_y(row) + self.baseline) as f32
182    }
183
184    /// Instances needed for a full frame in the worst case: the header bar, and per row a
185    /// stripe, a background per cell and a glyph per character of every column.
186    pub fn max_instances(&self) -> usize {
187        let per_row: usize = self
188            .columns
189            .iter()
190            .map(|c| c.chars as usize + 1)
191            .sum::<usize>()
192            + 1;
193        (self.rows as usize + 1)
194            .saturating_mul(per_row)
195            .saturating_add(1)
196    }
197}