Skip to main content

shuyi_ui/dialogue/text_box/
glyph.rs

1//! The glyph look of a dialogue's text box: a single-line panel with the
2//! speaker's name on its border, the revealed text (narration dim and
3//! centred), the replies of a choice with the focused one marked, the
4//! question before skipping a scene, and, while a page waits, the key that
5//! goes on and a blinking `▼`. While the replies are up the box grows
6//! upward to list them under the line being answered.
7
8use super::{TextView, WaitView};
9use crate::ctx::{Ctx, GameData};
10use crate::frame::color::{EngineColor, Palette};
11use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
12use crate::prelude::*;
13use crate::widgets::menu::MenuView;
14
15/// Cells between the log's key hint and the key that goes on.
16const LOG_HINT_GAP: i32 = 3;
17
18/// Where narration (lines nobody says) sits in a text box. More ways may
19/// come (ADR-0039).
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum NarrationAlign {
23    /// Across the middle of the box; a lone line in the middle row too.
24    #[default]
25    Centred,
26    /// From [`BoxPlace::text_x`] like speech; a lone line on the first row.
27    Left,
28}
29
30/// Where a text box goes on the console. Build it with [`BoxPlace::new`]:
31/// a struct literal doesn't compile outside this crate, so a new field
32/// never breaks a game (ADR-0039).
33///
34/// ```compile_fail
35/// use shuyi_ui::dialogue::text_box::glyph::{BoxPlace, NarrationAlign};
36/// use shuyi_ui::frame::glyph_buffer::Rect;
37///
38/// const PLACE: BoxPlace = BoxPlace {
39///     frame: Rect::new(0, 18, 48, 6),
40///     text_x: 2,
41///     text_y: 19,
42///     narration: NarrationAlign::Centred,
43/// };
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub struct BoxPlace {
48    /// The box, border included, when it holds one page.
49    pub frame: Rect,
50    /// The column the text starts at.
51    pub text_x: i32,
52    /// The row of the text's first line.
53    pub text_y: i32,
54    /// Where narration sits: [`NarrationAlign::Centred`] or from `text_x`.
55    pub narration: NarrationAlign,
56}
57
58impl BoxPlace {
59    /// A box at `frame` whose text starts at column `text_x` on row
60    /// `text_y`, with narration [`Centred`](NarrationAlign::Centred)
61    /// ([`with_narration`](Self::with_narration) changes it).
62    #[must_use]
63    pub const fn new(frame: Rect, text_x: i32, text_y: i32) -> Self {
64        Self {
65            frame,
66            text_x,
67            text_y,
68            narration: NarrationAlign::Centred,
69        }
70    }
71
72    /// The place with narration set as `narration` says: usable in a
73    /// `const` item.
74    #[must_use]
75    pub const fn with_narration(self, narration: NarrationAlign) -> Self {
76        Self { narration, ..self }
77    }
78}
79
80/// The box `text` is painted in at `place`, and its first text row. While
81/// the replies are up it grows upward (its bottom stays put) to fit the
82/// line being answered, a blank row and one row per reply.
83pub fn box_rect(place: BoxPlace, text: &TextView) -> (Rect, i32) {
84    let frame = place.frame;
85    let Some(choices) = &text.choices else {
86        return (frame, place.text_y);
87    };
88    let question = text.lines.len();
89    let gap = usize::from(question > 0);
90    let rows = i32::try_from(question + gap + choices.items.len()).unwrap_or(0);
91    // The last content row is the one the ▼ uses, free during a choice.
92    let bottom = frame.y + frame.h - 1;
93    let text_y = (bottom - rows).min(place.text_y);
94    let top = text_y - (place.text_y - frame.y);
95    (Rect::new(frame.x, top, frame.w, bottom + 1 - top), text_y)
96}
97
98/// Paints `text` in a box at `place`, with `speaker`'s name on its border
99/// (none for narration). The replies' labels are in the player's language
100/// ([`Ctx::label`]).
101pub fn paint<G: GameData>(
102    ctx: &Ctx<G>,
103    buf: &mut GlyphBuffer,
104    place: BoxPlace,
105    text: &TextView,
106    speaker: Option<&str>,
107) {
108    let palette = &ctx.palette;
109    let c = |e| palette.engine(e);
110    let bg = c(EngineColor::Panel);
111    let (frame, text_y) = box_rect(place, text);
112    paint_frame(palette, buf, speaker, frame);
113
114    if let Some(skip) = &text.skip {
115        let (x, y) = (place.text_x, place.text_y);
116        buf.print(x, y, &skip.question, c(EngineColor::Text), bg);
117        buf.print(x, y + 1, &skip.answers, c(EngineColor::TextDim), bg);
118        return;
119    }
120
121    // Narration isn't centred vertically above replies, which follow
122    // right after.
123    let alone = text.choices.is_none();
124    paint_lines(palette, buf, place, text, text_y, alone);
125    if let Some(choices) = &text.choices {
126        let gap = i32::from(!text.lines.is_empty());
127        let first = text_y + i32::try_from(text.lines.len()).unwrap_or(0) + gap;
128        paint_choices(ctx, buf, place.text_x, choices, first);
129    }
130    if let Some(waiting) = &text.waiting {
131        paint_waiting(palette, buf, place.frame, waiting);
132    }
133}
134
135/// The box `frame`, empty, with `speaker`'s name on its border.
136fn paint_frame(palette: &Palette, buf: &mut GlyphBuffer, speaker: Option<&str>, frame: Rect) {
137    let c = |e| palette.engine(e);
138    let bg = c(EngineColor::Panel);
139    buf.fill_rect(frame, Cell::new(' ', c(EngineColor::Text), bg));
140    buf.draw_box(frame, BoxStyle::Single, c(EngineColor::Border), bg);
141    if let Some(speaker) = speaker {
142        let name = format!(" {speaker} ");
143        let fg = c(EngineColor::Highlight);
144        buf.print(frame.x + 3, frame.y, &name, fg, bg);
145    }
146}
147
148/// The revealed part of the page from row `text_y`. Narration is dim and,
149/// unless `place.narration` is `Left`, centred; vertically too when `alone`
150/// (nothing follows it in the box): one line goes in the middle of the
151/// three rows (two or three fill them from the top).
152fn paint_lines(
153    palette: &Palette,
154    buf: &mut GlyphBuffer,
155    place: BoxPlace,
156    text: &TextView,
157    text_y: i32,
158    alone: bool,
159) {
160    let c = |e| palette.engine(e);
161    let bg = c(EngineColor::Panel);
162    let narration = text.narration;
163    let fg = if narration {
164        c(EngineColor::TextDim)
165    } else {
166        c(EngineColor::Text)
167    };
168    let centred = narration && place.narration == NarrationAlign::Centred;
169    let top = text_y + i32::from(centred && text.lines.len() == 1 && alone);
170    for ((y, line), visible) in (top..).zip(&text.lines).zip(text.revealed()) {
171        let x = if centred {
172            let len = buf.text_cells(line);
173            place.frame.x + (place.frame.w - len) / 2
174        } else {
175            place.text_x
176        };
177        buf.print(x, y, &visible, fg, bg);
178    }
179}
180
181/// The replies from row `first` at column `x`, the focused one marked and
182/// bright.
183fn paint_choices<G: GameData>(
184    ctx: &Ctx<G>,
185    buf: &mut GlyphBuffer,
186    x: i32,
187    choices: &MenuView,
188    first: i32,
189) {
190    let c = |e| ctx.palette.engine(e);
191    let bg = c(EngineColor::Panel);
192    for (y, item) in (first..).zip(&choices.items) {
193        let (marker, fg) = if item.focused {
194            ("> ", c(EngineColor::Highlight))
195        } else {
196            ("  ", c(EngineColor::Text))
197        };
198        let label = ctx.label(&item.label);
199        buf.print(x, y, &format!("{marker}{label}"), fg, bg);
200    }
201}
202
203/// At the right of the box's last row: the log's hint, the key that goes
204/// on, and the `▼` while it is lit.
205fn paint_waiting(palette: &Palette, buf: &mut GlyphBuffer, frame: Rect, waiting: &WaitView) {
206    let c = |e| palette.engine(e);
207    let bg = c(EngineColor::Panel);
208    let right = frame.x + frame.w - 4;
209    let bottom = frame.y + frame.h - 2;
210    let key = &waiting.key;
211    let w = buf.text_cells(key);
212    buf.print(right - 1 - w, bottom, key, c(EngineColor::TextDim), bg);
213    // The log's key, if it has one, before it.
214    if let Some(hint) = &waiting.log_hint {
215        let hint_w = buf.text_cells(hint);
216        let x = right - 1 - w - LOG_HINT_GAP - hint_w;
217        buf.print(x, bottom, hint, c(EngineColor::TextDim), bg);
218    }
219    if waiting.marker {
220        buf.print(right, bottom, "▼", c(EngineColor::Highlight), bg);
221    }
222}
223
224#[cfg(test)]
225mod tests;