Skip to main content

shuyi_ui/frame/
draw.rs

1//! Drawing helpers on [`GlyphBuffer`] that skins repeat: a [`panel`] with
2//! a title set into its top border, a [`meter`] bar that shows how full
3//! something is, a line [`print_centred`] in a rectangle and a line
4//! [`print_cut`] to a width (with [`cut`], the text it prints).
5//!
6//! Each counts cells as [`GlyphBuffer::print`] does: one a `char`, two a
7//! wide one ([`GlyphBuffer::widths`]).
8//!
9//! [`panel`]: GlyphBuffer::panel
10//! [`meter`]: GlyphBuffer::meter
11//! [`print_centred`]: GlyphBuffer::print_centred
12//! [`print_cut`]: GlyphBuffer::print_cut
13
14use crate::prelude::*;
15
16use shuyi_assets::font::CellWidths;
17use shuyi_assets::lang::text_width;
18
19use crate::frame::color::Rgb;
20use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
21
22/// Cells of a panel's top border left of its title: the corner and one
23/// line.
24const TITLE_DX: i32 = 2;
25
26/// Cells of a panel's top border that a title never covers: a corner and
27/// a line on each side ([`TITLE_DX`] each), and a space each side of the
28/// title.
29const TITLE_FRAME: i32 = 6;
30
31/// `text` in at most `w` cells, measured with `widths`: cut, its last cell
32/// a `…`, if it is longer. A wide character that would reach that cell
33/// goes too.
34///
35/// ```
36/// use shuyi_assets::font::CellWidths;
37/// use shuyi_ui::frame::draw::cut;
38/// let narrow = &CellWidths::NARROW;
39/// assert_eq!(cut("Backspace", 5, narrow), "Back…");
40/// assert_eq!(cut("Enter", 5, narrow), "Enter");
41/// assert_eq!(cut("あいう", 5, &CellWidths::wide(['あ', 'い', 'う'])), "あい…");
42/// ```
43pub fn cut(text: &str, w: usize, widths: &CellWidths) -> String {
44    if text_width(text, widths) <= w {
45        return text.to_owned();
46    }
47    let room = w.saturating_sub(1);
48    let mut used = 0;
49    let kept: String = text
50        .chars()
51        .take_while(|&c| {
52            used += widths.of_char(c);
53            used <= room
54        })
55        .collect();
56    if w == 0 { kept } else { kept + "…" }
57}
58
59/// How many of a meter's `cells` are full when it shows `filled` of `of`.
60///
61/// The rule every meter shares: `cells × filled / of` rounded half up, but
62/// a meter that is not empty (`filled > 0`) shows at least one full cell
63/// and one that is not full (`filled < of`) at least one empty cell, so
64/// "almost dead" and "almost full" never look like "dead" and "full". With
65/// one cell, the rounding alone decides. `filled` past `of` counts as
66/// `of`; an `of` of 0 is an empty meter.
67///
68/// ```
69/// use shuyi_ui::frame::draw::meter_fill;
70/// assert_eq!(meter_fill(10, 1, 100), 1); // 0.1 would round to 0
71/// assert_eq!(meter_fill(10, 99, 100), 9); // 9.9 would round to 10
72/// assert_eq!(meter_fill(10, 25, 100), 3); // 2.5 rounds up
73/// ```
74pub fn meter_fill(cells: u32, filled: u32, of: u32) -> u32 {
75    if of == 0 {
76        return 0;
77    }
78    let (cells, filled, of) = (
79        u128::from(cells),
80        u128::from(filled.min(of)),
81        u128::from(of),
82    );
83    let rounded = (2 * cells * filled + of) / (2 * of);
84    let shown = if cells >= 2 && filled > 0 && filled < of {
85        rounded.clamp(1, cells - 1)
86    } else {
87        rounded
88    };
89    // At most `cells`, which came from a u32.
90    u32::try_from(shown).unwrap_or(u32::MAX)
91}
92
93/// How a [`GlyphBuffer::panel`] looks: its border's style and colour, its
94/// title's colour, and the text colour and background it is cleared to.
95#[derive(Copy, Clone, PartialEq, Eq, Debug)]
96pub struct PanelLook {
97    /// The border's lines.
98    pub style: BoxStyle,
99    /// The border's colour.
100    pub border: Rgb,
101    /// The title's colour.
102    pub title: Rgb,
103    /// The colour the inside is cleared to, for text printed keeping it.
104    pub text: Rgb,
105    /// The background, inside and under the border and the title.
106    pub bg: Rgb,
107}
108
109impl PanelLook {
110    /// A panel in `style` with everything in `fg` on `bg`.
111    pub const fn new(style: BoxStyle, fg: Rgb, bg: Rgb) -> Self {
112        Self {
113            style,
114            border: fg,
115            title: fg,
116            text: fg,
117            bg,
118        }
119    }
120
121    /// This look with its border in `border`.
122    #[must_use]
123    pub const fn border(self, border: Rgb) -> Self {
124        Self { border, ..self }
125    }
126
127    /// This look with its title in `title`.
128    #[must_use]
129    pub const fn title(self, title: Rgb) -> Self {
130        Self { title, ..self }
131    }
132}
133
134impl GlyphBuffer {
135    /// A panel: clears `rect` to `look.text` on `look.bg`, frames it in
136    /// `look.style` and `look.border`, and sets `title`, if any, into the
137    /// top border in `look.title`, two cells from the left with a space
138    /// each side of it. A title that doesn't fit is [`cut`]; the border
139    /// keeps a line and its corner on each side.
140    ///
141    /// ```
142    /// use shuyi_ui::frame::draw::PanelLook;
143    /// use shuyi_ui::frame::{BoxStyle, Cell, GlyphBuffer, Rgb};
144    /// let (white, black) = (Rgb::new(255, 255, 255), Rgb::new(0, 0, 0));
145    /// let look = PanelLook::new(BoxStyle::Single, white, black);
146    /// let mut buf = GlyphBuffer::new(12, 3, Cell::new(' ', white, black));
147    /// buf.panel(buf.bounds(), &look, Some("Shop"));
148    /// let top: String = (0..12).map(|x| buf.get(x, 0).unwrap().glyph).collect();
149    /// assert_eq!(top, "┌─ Shop ───┐");
150    /// ```
151    pub fn panel(&mut self, rect: Rect, look: &PanelLook, title: Option<&str>) {
152        self.fill_rect(rect, Cell::new(' ', look.text, look.bg));
153        self.draw_box(rect, look.style, look.border, look.bg);
154        let Some(title) = title else {
155            return;
156        };
157        let room = usize::try_from(rect.w.saturating_sub(TITLE_FRAME)).unwrap_or(0);
158        if room > 0 {
159            let shown = format!(" {} ", cut(title, room, self.widths()));
160            let x = rect.x.saturating_add(TITLE_DX);
161            self.print(x, rect.y, &shown, look.title, look.bg);
162        }
163    }
164
165    /// A meter: each row of `rect` is `rect.w` cells, the first
166    /// [`meter_fill`] of them `full` and the rest `empty`, showing `filled`
167    /// of `of`.
168    ///
169    /// ```
170    /// use shuyi_ui::frame::{Cell, GlyphBuffer, Rect, Rgb};
171    /// let (white, black) = (Rgb::new(255, 255, 255), Rgb::new(0, 0, 0));
172    /// let mut buf = GlyphBuffer::new(10, 1, Cell::new(' ', white, black));
173    /// let (full, empty) = (Cell::new('█', white, black), Cell::new('░', white, black));
174    /// buf.meter(buf.bounds(), 12, 20, full, empty);
175    /// let row: String = (0..10).map(|x| buf.get(x, 0).unwrap().glyph).collect();
176    /// assert_eq!(row, "██████░░░░");
177    /// ```
178    pub fn meter(&mut self, rect: Rect, filled: u32, of: u32, full: Cell, empty: Cell) {
179        if rect.is_empty() {
180            return;
181        }
182        let cells = u32::try_from(rect.w).unwrap_or(0);
183        let shown = i32::try_from(meter_fill(cells, filled, of)).unwrap_or(rect.w);
184        self.fill_rect(Rect::new(rect.x, rect.y, shown, rect.h), full);
185        let unfilled = Rect::new(rect.x.saturating_add(shown), rect.y, rect.w - shown, rect.h);
186        self.fill_rect(unfilled, empty);
187    }
188
189    /// Prints `text` in `fg` on the middle row of `rect` (the upper of two
190    /// middles), centred across it (a cell left of the middle when the
191    /// cells left over are odd) and [`cut`] to its width, keeping each
192    /// cell's background. Returns how many cells were written.
193    ///
194    /// ```
195    /// use shuyi_ui::frame::{Cell, GlyphBuffer, Rect, Rgb};
196    /// let (white, black) = (Rgb::new(255, 255, 255), Rgb::new(0, 0, 0));
197    /// let mut buf = GlyphBuffer::new(9, 1, Cell::new('.', white, black));
198    /// buf.print_centred(buf.bounds(), "Paused", white);
199    /// let row: String = (0..9).map(|x| buf.get(x, 0).unwrap().glyph).collect();
200    /// assert_eq!(row, ".Paused..");
201    /// ```
202    pub fn print_centred(&mut self, rect: Rect, text: &str, fg: Rgb) -> u16 {
203        if rect.is_empty() {
204            return 0;
205        }
206        let shown = cut(text, usize::try_from(rect.w).unwrap_or(0), self.widths());
207        let len = self.text_cells(&shown).min(rect.w);
208        let x = rect.x.saturating_add((rect.w - len) / 2);
209        let y = rect.y.saturating_add((rect.h - 1) / 2);
210        self.print_fg(x, y, &shown, fg)
211    }
212
213    /// Prints `text` in `fg` from `(x, y)` in at most `width` cells,
214    /// [`cut`] if it is longer, keeping each cell's background: a
215    /// [`print_fg`](Self::print_fg) of [`cut`]. Returns how many cells
216    /// were written.
217    ///
218    /// ```
219    /// use shuyi_ui::frame::{Cell, GlyphBuffer, Rgb};
220    /// let (white, black) = (Rgb::new(255, 255, 255), Rgb::new(0, 0, 0));
221    /// let mut buf = GlyphBuffer::new(8, 1, Cell::new('.', white, black));
222    /// buf.print_cut(0, 0, 5, "Backspace", white);
223    /// let row: String = (0..8).map(|x| buf.get(x, 0).unwrap().glyph).collect();
224    /// assert_eq!(row, "Back…...");
225    /// ```
226    pub fn print_cut(&mut self, x: i32, y: i32, width: usize, text: &str, fg: Rgb) -> u16 {
227        let shown = cut(text, width, self.widths());
228        self.print_fg(x, y, &shown, fg)
229    }
230}
231
232#[cfg(test)]
233mod tests;