Skip to main content

shuyi_ui/screens/key_bindings/
glyph.rs

1//! The glyph look of the Key bindings screen: paints a [`KeyBindingsView`]
2//! as a panel of text rows, a column per slot, with the focused slot as a
3//! bar. Everything about the look lives here (where things go, the box
4//! characters, the colours); nothing here decides what the screen shows or
5//! does. Another look is another `paint` of the same view.
6//!
7//! It is laid out for a 100 × 32 console (`PANEL`) and fits another
8//! (`Layout`): the panel keeps its 2-cell margin and reaches down to the
9//! row over the message. On a narrower panel the label column is only as
10//! wide as the longest label, with its keys that can't change if the slots
11//! keep `SLOT_MIN_W` cells for a name (else those keys are cut), and the
12//! slot columns share the rest. Every slot is still shown: a key name too
13//! long for its column is cut, its last cell a `…`, and the not-mapped
14//! note, with no room after the slots, is written over them (they are all
15//! empty when it shows).
16//!
17//! Rows that don't fit the panel scroll: the list moves up
18//! just enough to keep the focused row (or Restore defaults) on the panel,
19//! and a `▲` or `▼` on the panel's right border says rows are hidden above
20//! or below.
21
22use super::Side;
23use super::view::{ChoicesView, KeyBindingsView, RowView, SlotView, SwitchView};
24use crate::ctx::{Ctx, GameData};
25use crate::frame::color::EngineColor;
26use crate::frame::draw::PanelLook;
27use crate::frame::glyph_buffer::{BoxStyle, Cell, GlyphBuffer, Rect};
28use crate::prelude::*;
29use crate::screens::{cut, print_centred};
30use crate::widgets::ask;
31use shuyi_assets::font::CellWidths;
32use shuyi_assets::lang::text_width;
33
34/// The panel, in cells, on a 100 × 32 console: at most this wide, with a
35/// margin of `PANEL.x` cells either side, and down to the row over the
36/// message ([`Layout`]).
37const PANEL: Rect = Rect::new(2, 1, 96, 29);
38/// Column of the action labels, from the panel's left edge.
39const LABEL_DX: i32 = 4;
40/// Column of the title and the group headings, from the panel's left edge:
41/// two cells left of the labels.
42const HEADING_DX: i32 = 2;
43/// Cells from the labels' column to the first slot's highlight bar, on a
44/// panel as wide as [`PANEL`].
45const LABEL_W: i32 = 23;
46/// Rows under the panel: the message's and the help line's.
47const BELOW_PANEL: i32 = 2;
48/// Row of the keyboard / controller switch.
49const SWITCH_Y: i32 = PANEL.y + 1;
50/// Row of the slot columns' headings, right under the switch; the first
51/// group's heading is on the next row.
52const COLUMNS_Y: i32 = PANEL.y + 2;
53/// Cells a slot's key name may take on a panel as wide as [`PANEL`] (the
54/// longest chord name fits).
55const SLOT_W: usize = 15;
56/// Columns from one slot to the next on a panel as wide as [`PANEL`]: the
57/// name, a cell before it and two after it.
58const SLOT_PITCH: i32 = 18;
59/// Cells a slot's name keeps on a narrower panel before the label column
60/// gives up room for the keys that can't change ("Backspace" fits).
61const SLOT_MIN_W: usize = 9;
62/// What an empty slot shows.
63const EMPTY_SLOT: &str = "·";
64/// The box of the choices, in cells: a line each and the border.
65const CHOICE_W: i32 = 12;
66const CHOICE_H: i32 = 4;
67
68/// Where things go across a console of a given size.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70struct Layout {
71    /// The panel: [`PANEL`] on a 100 × 32 console.
72    panel: Rect,
73    /// Column of the action labels.
74    label_x: i32,
75    /// Column of the first slot's highlight bar; its text starts one cell
76    /// in.
77    slots_x: i32,
78    /// Columns from one slot to the next.
79    pitch: i32,
80    /// Cells a slot's key name may take.
81    name_w: usize,
82}
83
84impl Layout {
85    /// The layout of `view` on a console of `width` × `height` cells: the
86    /// panel as wide as the console allows up to [`PANEL`]'s width,
87    /// centred; on a narrower panel, the label column as wide as the
88    /// longest label (measured with `widths`) and the slots sharing the
89    /// rest.
90    fn new<A: Copy + PartialEq>(
91        view: &KeyBindingsView<A>,
92        width: u16,
93        height: u16,
94        widths: &CellWidths,
95    ) -> Self {
96        let (width, height) = (i32::from(width), i32::from(height));
97        let w = PANEL.w.min(width - 2 * PANEL.x).max(0);
98        let h = (height - PANEL.y - BELOW_PANEL).max(0);
99        let panel = Rect::new((width - w) / 2, PANEL.y, w, h);
100        let label_x = panel.x + LABEL_DX;
101        if w >= PANEL.w {
102            return Self {
103                panel,
104                label_x,
105                slots_x: label_x + LABEL_W,
106                pitch: SLOT_PITCH,
107                name_w: SLOT_W,
108            };
109        }
110        let narrow = |with_fixed| {
111            let slots_x = label_x + LABEL_W.min(widest_label(view, with_fixed, widths) + 2);
112            let slots = to_i32(view.columns.len().max(1));
113            let pitch = SLOT_PITCH.min((right(panel) - slots_x) / slots).max(0);
114            Self {
115                panel,
116                label_x,
117                slots_x,
118                pitch,
119                name_w: to_usize(pitch - SLOT_PITCH + to_i32(SLOT_W)),
120            }
121        };
122        let with_fixed = narrow(true);
123        if with_fixed.name_w >= SLOT_MIN_W {
124            with_fixed
125        } else {
126            narrow(false)
127        }
128    }
129
130    /// Left edge of slot `i`'s highlight bar.
131    fn slot_x(&self, i: usize) -> i32 {
132        self.slots_x + to_i32(i) * self.pitch
133    }
134
135    /// Column of the title and the group headings.
136    fn heading_x(&self) -> i32 {
137        self.panel.x + HEADING_DX
138    }
139
140    /// Row of the message, right under the panel and over the help line.
141    fn message_row(&self) -> i32 {
142        self.panel.y + self.panel.h
143    }
144
145    /// Column of the panel's right border.
146    fn right(&self) -> i32 {
147        right(self.panel)
148    }
149}
150
151/// Column of `panel`'s right border.
152fn right(panel: Rect) -> i32 {
153    panel.x + panel.w - 1
154}
155
156/// `n` as an `i32`, the largest one if it is larger.
157fn to_i32(n: usize) -> i32 {
158    i32::try_from(n).unwrap_or(i32::MAX)
159}
160
161/// `n` as a `usize`, 0 if it is below 0.
162fn to_usize(n: i32) -> usize {
163    usize::try_from(n).unwrap_or(0)
164}
165
166/// The text of a row's keys that can't change, after its label.
167fn fixed_text<A>(row: &RowView<A>) -> String {
168    format!("+ {}", row.fixed.join("/"))
169}
170
171/// Cells the widest action label takes; `with_fixed`, with two cells and
172/// its keys that can't change, if it has any; measured with `widths`.
173fn widest_label<A: Copy + PartialEq>(
174    view: &KeyBindingsView<A>,
175    with_fixed: bool,
176    widths: &CellWidths,
177) -> i32 {
178    let width = |row: &RowView<A>| {
179        let label = text_width(&row.label, widths);
180        if row.fixed.is_empty() || !with_fixed {
181            label
182        } else {
183            label + 2 + text_width(&fixed_text(row), widths)
184        }
185    };
186    to_i32(view.rows().map(width).max().unwrap_or(0))
187}
188
189/// The console rows of what is listed under the columns' headings: each
190/// group's heading, each action, and Restore defaults.
191#[derive(Debug, PartialEq, Eq)]
192struct Rows {
193    /// Row of each group's heading.
194    headings: Vec<i32>,
195    /// Row of each action, in the order of [`KeyBindingsView::rows`].
196    actions: Vec<i32>,
197    /// Row of Restore defaults.
198    restore: i32,
199}
200
201/// Places the groups one under the other: a heading, then its actions; a
202/// blank row between groups and before Restore defaults.
203fn place<A>(view: &KeyBindingsView<A>) -> Rows {
204    let mut y = COLUMNS_Y + 1;
205    let (mut headings, mut actions) = (Vec::new(), Vec::new());
206    for (i, group) in view.groups.iter().enumerate() {
207        if i > 0 {
208            y += 1;
209        }
210        headings.push(y);
211        y += 1;
212        for _ in &group.rows {
213            actions.push(y);
214            y += 1;
215        }
216    }
217    Rows {
218        headings,
219        actions,
220        restore: y + 1,
221    }
222}
223
224/// How far the rows are moved up so the focused one shows, and whether
225/// rows are hidden above or below the panel.
226#[derive(Debug, PartialEq, Eq)]
227struct Scroll {
228    /// Rows the list is moved up by.
229    by: i32,
230    /// Whether rows are hidden above the first one shown.
231    above: bool,
232    /// Whether rows are hidden below the last one shown.
233    below: bool,
234}
235
236/// The first console row the list may use: under the columns' headings.
237const FIRST_ROW: i32 = COLUMNS_Y + 1;
238
239/// The scroll of `placed` (`view`'s rows) on a panel whose last inner row
240/// is `last`: none while Restore defaults fits; else just enough that the
241/// focused action (or Restore defaults) is on row `last` or above.
242fn scroll<A: Copy + PartialEq>(view: &KeyBindingsView<A>, placed: &Rows, last: i32) -> Scroll {
243    let end = placed.restore;
244    if end <= last {
245        return Scroll {
246            by: 0,
247            above: false,
248            below: false,
249        };
250    }
251    let focused = view
252        .rows()
253        .zip(&placed.actions)
254        .find_map(|(row, &y)| row.focused().then_some(y))
255        .or_else(|| view.restore.focused.then_some(end));
256    // The focus is never below Restore defaults, so this never scrolls
257    // past it.
258    let by = focused.map_or(0, |y| y - last).max(0);
259    Scroll {
260        by,
261        above: by > 0,
262        below: end - by > last,
263    }
264}
265
266/// `placed` moved up by `scroll`, its rows outside `FIRST_ROW..=last`
267/// marked with `None`.
268fn shown(
269    placed: &Rows,
270    scroll: &Scroll,
271    last: i32,
272) -> (Vec<Option<i32>>, Vec<Option<i32>>, Option<i32>) {
273    let on = |y: i32| Some(y - scroll.by).filter(|y| (FIRST_ROW..=last).contains(y));
274    (
275        placed.headings.iter().map(|&y| on(y)).collect(),
276        placed.actions.iter().map(|&y| on(y)).collect(),
277        on(placed.restore),
278    )
279}
280
281/// Paints `view` over the whole of `buf`.
282pub fn paint<G: GameData>(ctx: &Ctx<G>, view: &KeyBindingsView<G::Action>, buf: &mut GlyphBuffer) {
283    let c = |e| ctx.palette.engine(e);
284    let (black, bg) = (c(EngineColor::Background), c(EngineColor::Panel));
285    let (text, dim) = (c(EngineColor::Text), c(EngineColor::TextDim));
286    let bar = c(EngineColor::Focus);
287    let at = Layout::new(view, buf.width(), buf.height(), buf.widths());
288    buf.fill_rect(buf.bounds(), Cell::new(' ', text, black));
289    let look = PanelLook::new(BoxStyle::Single, text, bg)
290        .border(c(EngineColor::Border))
291        .title(c(EngineColor::Highlight));
292    buf.panel(at.panel, &look, Some(&view.title));
293    if let Some(switch) = &view.switch {
294        paint_switch(ctx, buf, switch, at.label_x);
295    }
296
297    for (j, heading) in view.columns.iter().enumerate() {
298        // Over the gap after the slot too, a cell short of the next one.
299        let heading = cut(heading, to_usize(at.pitch - 2), buf.widths());
300        buf.print(at.slot_x(j) + 1, COLUMNS_Y, &heading, dim, bg);
301    }
302    let placed = place(view);
303    let last = at.panel.y + at.panel.h - 2;
304    let scrolled = scroll(view, &placed, last);
305    let (headings, actions, restore) = shown(&placed, &scrolled, last);
306    let mut rows = view.rows().zip(&actions);
307    for (group, heading_y) in view.groups.iter().zip(&headings) {
308        if let Some(y) = *heading_y {
309            buf.print(at.heading_x(), y, &group.heading, bar, bg);
310        }
311        for _ in &group.rows {
312            if let Some((row, &Some(y))) = rows.next() {
313                paint_row(ctx, buf, view, row, &at, y);
314            }
315        }
316    }
317    match restore {
318        Some(y) if view.restore.focused => {
319            let label = format!(" {} ", view.restore.label);
320            buf.print(at.label_x - 1, y, &label, bg, bar);
321        }
322        Some(y) => {
323            buf.print(at.label_x, y, &view.restore.label, text, bg);
324        }
325        None => {}
326    }
327    let border = c(EngineColor::Border);
328    if scrolled.above {
329        buf.print(at.right(), FIRST_ROW, "▲", border, bg);
330    }
331    if scrolled.below {
332        buf.print(at.right(), last, "▼", border, bg);
333    }
334
335    if let Some(message) = &view.message {
336        let row = at.message_row();
337        print_centred(buf, row, message, c(EngineColor::Warning), black);
338    }
339    if let Some(question) = &view.question {
340        ask::glyph::paint(&ctx.palette, question, buf);
341    }
342    if let Some(choices) = &view.choices {
343        let focused_y = view
344            .rows()
345            .zip(&actions)
346            .find_map(|(row, &y)| y.filter(|_| row.focused()));
347        if let Some(y) = focused_y {
348            paint_choices(ctx, buf, choices, &at, y);
349        }
350    }
351    let bottom = i32::from(buf.height()) - 1;
352    print_centred(buf, bottom, &view.help, dim, black);
353}
354
355/// Paints the keyboard / controller switch from column `x`: the shown side
356/// highlighted (as a bar while the focus is on the switch), the other dim.
357fn paint_switch<G: GameData>(ctx: &Ctx<G>, buf: &mut GlyphBuffer, switch: &SwitchView, x: i32) {
358    let c = |e| ctx.palette.engine(e);
359    let (bg, bar) = (c(EngineColor::Panel), c(EngineColor::Focus));
360    let mut x = x;
361    for (side, name) in [
362        (Side::Keyboard, switch.keyboard.as_str()),
363        (Side::Controller, switch.controller.as_str()),
364    ] {
365        if side != switch.shown {
366            buf.print(x, SWITCH_Y, name, c(EngineColor::TextDim), bg);
367        } else if switch.focused {
368            buf.print(x - 1, SWITCH_Y, &format!(" {name} "), bg, bar);
369        } else {
370            buf.print(x, SWITCH_Y, name, c(EngineColor::Highlight), bg);
371        }
372        x += buf.text_cells(name) + 4;
373    }
374}
375
376/// Paints `row` on console row `y`: its label (a highlight colour if the
377/// cursor is on it, white if it just lost its key), the keys it has that
378/// can't change, its slots (the one the cursor is on a bar) and, if
379/// nothing is in any, the note.
380fn paint_row<G: GameData>(
381    ctx: &Ctx<G>,
382    buf: &mut GlyphBuffer,
383    view: &KeyBindingsView<G::Action>,
384    row: &RowView<G::Action>,
385    at: &Layout,
386    y: i32,
387) {
388    let c = |e| ctx.palette.engine(e);
389    let (bg, text, dim) = (
390        c(EngineColor::Panel),
391        c(EngineColor::Text),
392        c(EngineColor::TextDim),
393    );
394    let label_fg = if row.focused() {
395        c(EngineColor::Highlight)
396    } else if row.lost_key {
397        c(EngineColor::Bright)
398    } else {
399        text
400    };
401    // The label and the fixed keys end a cell before the first slot.
402    let room = to_usize(at.slots_x - at.label_x - 1);
403    let label_w = buf.print(
404        at.label_x,
405        y,
406        &cut(&row.label, room, buf.widths()),
407        label_fg,
408        bg,
409    );
410    if !row.fixed.is_empty() {
411        let used = usize::from(label_w) + 2;
412        let fixed = cut(&fixed_text(row), room.saturating_sub(used), buf.widths());
413        buf.print(at.label_x + i32::from(label_w) + 2, y, &fixed, dim, bg);
414    }
415    let note_x = row.unmapped.then(|| note_x(view, row, at, buf.widths()));
416    for (j, slot) in row.slots.iter().enumerate() {
417        let x = at.slot_x(j);
418        if row.focus == Some(j) {
419            let shown = match slot {
420                SlotView::Capturing(prompt) => prompt.as_str(),
421                SlotView::Bound(name) => name.as_str(),
422                SlotView::Empty => "",
423            };
424            let w = at.name_w;
425            let shown = cut(shown, w, buf.widths());
426            let pad = w.saturating_sub(text_width(&shown, buf.widths()));
427            let bar = format!(" {shown}{}", " ".repeat(pad));
428            buf.print(x, y, &bar, bg, c(EngineColor::Focus));
429        } else if let SlotView::Bound(name) = slot {
430            buf.print(x + 1, y, &cut(name, at.name_w, buf.widths()), text, bg);
431        } else if note_x.is_none_or(|n| n == after_slots(row, at)) {
432            buf.print(x + 1, y, EMPTY_SLOT, dim, bg);
433        }
434    }
435    if let Some(x) = note_x {
436        let fg = if row.blocks_leaving {
437            c(EngineColor::Warning)
438        } else {
439            dim
440        };
441        let note = cut(&view.not_mapped, to_usize(at.right() - x), buf.widths());
442        buf.print(x, y, &note, fg, bg);
443    }
444}
445
446/// The column of `row`'s not-mapped note: a cell after its slots if it fits
447/// there before the panel's right border; else over its slots (all empty),
448/// a cell into the first, or after the bar of the slot the cursor is on if
449/// it would cover the note. Its length is measured with `widths`.
450fn note_x<A>(view: &KeyBindingsView<A>, row: &RowView<A>, at: &Layout, widths: &CellWidths) -> i32 {
451    let len = to_i32(text_width(&view.not_mapped, widths));
452    let after = after_slots(row, at);
453    if after + len <= at.right() {
454        return after;
455    }
456    let first = at.slots_x + 1;
457    match row.focus {
458        Some(j) if at.slot_x(j) < first + len => at.slot_x(j) + at.pitch - 1,
459        _ => first,
460    }
461}
462
463/// The column a cell after `row`'s last slot.
464fn after_slots<A>(row: &RowView<A>, at: &Layout) -> i32 {
465    at.slot_x(row.slots.len()) + 1
466}
467
468/// Paints `choices` in a double-bordered box under the slot on console row
469/// `slot_y` (over it on the last rows, where it wouldn't fit; left of it at
470/// the panel's right edge), the focused line as a bar.
471fn paint_choices<G: GameData>(
472    ctx: &Ctx<G>,
473    buf: &mut GlyphBuffer,
474    choices: &ChoicesView,
475    at: &Layout,
476    slot_y: i32,
477) {
478    let c = |e| ctx.palette.engine(e);
479    let (bg, bar) = (c(EngineColor::Panel), c(EngineColor::Focus));
480    let below = slot_y + 1;
481    let y = if below + CHOICE_H < at.panel.y + at.panel.h {
482        below
483    } else {
484        slot_y - CHOICE_H
485    };
486    let x = at.slot_x(choices.slot).min(at.right() - CHOICE_W);
487    let rect = Rect::new(x, y, CHOICE_W, CHOICE_H);
488    let look = PanelLook::new(BoxStyle::Double, c(EngineColor::Text), bg).border(bar);
489    buf.panel(rect, &look, None);
490    let inner = usize::try_from(CHOICE_W - 3).unwrap_or(0);
491    for (i, choice) in (0..).zip(&choices.lines) {
492        let (x, y) = (rect.x + 1, rect.y + 1 + i);
493        if usize::try_from(i).is_ok_and(|i| i == choices.focused) {
494            buf.print(x, y, &format!(" {choice:<inner$}"), bg, bar);
495        } else {
496            buf.print(x + 1, y, choice, c(EngineColor::Text), bg);
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests;