Skip to main content

shuyi_ui/screens/
profiles.rs

1//! A player profiles screen: the profiles, the one in use marked, then a
2//! row that adds one. It lets the people who share a computer each keep
3//! their own settings and saves under a name, on the [`Ctx`] profile
4//! functions ([`Ctx::add_profile`] and the rest).
5//!
6//! Confirm on a profile opens what can be done to it ([`ProfileAction`]):
7//! use it, rename it, delete it (asking first, focus on No; never the only
8//! profile). Adding asks for a name and uses the new profile; it starts
9//! from the default options. Then the screen closes, or opens what the
10//! game gives ([`ProfilesScreen::opening_after_add`]: its first-launch
11//! setup, say). A name is typed on the keyboard or spelled on the letter
12//! grid with a controller ([`crate::widgets::name_entry`]), up to
13//! [`MAX_NAME_CHARS`], not another profile's. Cancel in a name box with a
14//! name typed and not kept asks whether to keep it, throw it away or go
15//! back, focus on going back.
16//!
17//! **The look is a skin.** This module says what the screen shows as a
18//! [`ProfilesView`] ([`ProfilesScreen::view`]); [`glyph::paint`] draws it,
19//! unless the game gives another painter ([`ProfilesScreen::painted_by`]).
20//! Tests of what happened read the view or the profiles.
21
22pub mod glyph;
23pub mod view;
24
25use crate::prelude::*;
26use core::fmt;
27pub use view::{ActionsView, ProfilesView, RowView};
28
29use shuyi_assets::keymap::{ActionSet, UiAction};
30
31use super::options::LeaveChoice;
32use super::text_keys_help;
33use crate::audio::MenuSound;
34use crate::ctx::{Ctx, GameData};
35use crate::frame::glyph_buffer::GlyphBuffer;
36use crate::profiles::{MAX_NAME_CHARS, MAX_PROFILES, ProfileError, ProfileId};
37use crate::screen::{FrameInput, Screen, Transition};
38use crate::widgets::name_entry::{self, NameBox, NameEntry, NameEvent, NameRules, finished};
39use crate::widgets::{Ask, ListCursor, Menu, MenuEvent, MenuItem};
40
41/// Text key ([`Ctx::text`]) of the screen title.
42pub const TITLE: &str = "profile.title";
43/// Text key of the words marking the profile in use.
44pub const CURRENT: &str = "profile.current";
45/// Text key of the row that adds a profile.
46pub const ADD: &str = "profile.add";
47/// Text key of the name box's title.
48pub const NAME_BOX: &str = "profile.name_box";
49/// Text key of the question asked before deleting, with `{name}`.
50pub const DELETE_QUESTION: &str = "profile.question.delete";
51/// Text key of the question asked on backing out of a name box.
52pub const KEEP_QUESTION: &str = "profile.question.keep";
53/// Text key of the message when the name is another profile's.
54pub const NAME_TAKEN_MESSAGE: &str = "profile.message.name_taken";
55/// Text key of the message when there are the most profiles already, with
56/// `{n}`.
57pub const TOO_MANY_MESSAGE: &str = "profile.message.too_many";
58/// Text key of the message if the profiles couldn't be saved.
59pub const NOT_SAVED_MESSAGE: &str = "profile.message.not_saved";
60/// Text key of the help line on a profile.
61pub const HELP_LIST: &str = "profile.help.list";
62/// Text key of the help line on the row that adds one.
63pub const HELP_ADD: &str = "profile.help.add";
64/// Text key of the help line on the row that adds one, with the most
65/// profiles already.
66pub const HELP_NONE: &str = "profile.help.none";
67/// Text key of the help line while a profile's actions are open.
68pub const HELP_ACTIONS: &str = "profile.help.actions";
69/// Text key of the help line while a question is open.
70pub const HELP_QUESTION: &str = "profile.help.question";
71
72/// What a profile's name may hold: letters and digits (the engine names a
73/// profile `Player 2`), up to [`MAX_NAME_CHARS`].
74pub const NAME_RULES: NameRules = NameRules {
75    max_chars: MAX_NAME_CHARS,
76    digits: true,
77};
78
79/// One row of the screen.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RowKind {
82    /// A profile.
83    Profile(ProfileId),
84    /// Adds a profile.
85    Add,
86}
87
88/// What can be done to a profile.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ProfileAction {
91    /// Use it (not offered for the one in use).
92    Switch,
93    /// Rename it.
94    Rename,
95    /// Delete it (not offered for the only profile).
96    Delete,
97}
98
99impl ProfileAction {
100    /// The text key of its name.
101    pub const fn key(self) -> &'static str {
102        match self {
103            ProfileAction::Switch => "profile.action.switch",
104            ProfileAction::Rename => "profile.action.rename",
105            ProfileAction::Delete => "profile.action.delete",
106        }
107    }
108}
109
110/// An answer to the question asked before deleting.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DeleteChoice {
113    /// Delete it.
114    Yes,
115    /// Don't.
116    No,
117}
118
119impl DeleteChoice {
120    /// Every answer, in the order shown.
121    pub const ALL: [DeleteChoice; 2] = [DeleteChoice::Yes, DeleteChoice::No];
122
123    /// The text key of its name.
124    pub const fn key(self) -> &'static str {
125        match self {
126            DeleteChoice::Yes => "profile.answer.yes",
127            DeleteChoice::No => "profile.answer.no",
128        }
129    }
130
131    /// The question asked before deleting ([`DELETE_QUESTION`]): focused
132    /// on No, which Cancel answers too.
133    pub fn ask() -> Ask<DeleteChoice> {
134        let answers = DeleteChoice::ALL.map(|choice| (choice, choice.key()));
135        Ask::new(DELETE_QUESTION, answers)
136            .focused_on(DeleteChoice::No)
137            .cancelled_by(DeleteChoice::No)
138    }
139}
140
141/// The question asked on backing out of a name box with a name typed
142/// ([`KEEP_QUESTION`]): focused on going back, which Cancel answers too.
143fn keep_question() -> Ask<LeaveChoice> {
144    let answers = LeaveChoice::ALL.map(|choice| (choice, choice.key()));
145    Ask::new(KEEP_QUESTION, answers)
146        .focused_on(LeaveChoice::Back)
147        .cancelled_by(LeaveChoice::Back)
148}
149
150/// What a name box is for.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum NamePurpose {
153    /// A new profile.
154    Add,
155    /// A new name for this profile.
156    Rename(ProfileId),
157}
158
159/// Where the name is typed.
160#[derive(Debug, Clone, PartialEq, Eq)]
161enum NameInput {
162    /// On the keyboard.
163    Typing(NameBox),
164    /// On the letter grid (a controller).
165    Grid(NameEntry),
166}
167
168impl NameInput {
169    fn name(&self) -> &str {
170        match self {
171            NameInput::Typing(b) => b.name(),
172            NameInput::Grid(e) => e.name(),
173        }
174    }
175}
176
177/// An open name box.
178#[derive(Debug, Clone, PartialEq, Eq)]
179struct Naming {
180    purpose: NamePurpose,
181    /// The name it opened with: the profile's, or nothing for a new one.
182    original: String,
183    input: NameInput,
184    /// The question asked on backing out, while it is open.
185    leaving: Option<Ask<LeaveChoice>>,
186}
187
188impl Naming {
189    /// Whether a name is typed that isn't kept: not blank, and not the one
190    /// the box opened with.
191    fn unkept(&self) -> bool {
192        finished(self.input.name()).is_some_and(|name| name != self.original)
193    }
194}
195
196/// What paints the screen's view: [`glyph::paint`], or the game's own
197/// ([`ProfilesScreen::painted_by`]).
198pub type Paint<G> = fn(&Ctx<G>, &ProfilesView, &mut GlyphBuffer);
199
200/// What opens once a profile is added and in use, in the screen's place
201/// ([`ProfilesScreen::opening_after_add`]).
202pub type AfterAdd<G> = fn(&mut Ctx<G>) -> Box<dyn Screen<G>>;
203
204/// The profiles, the one in use marked, and a row to add one. Cancel
205/// closes it.
206pub struct ProfilesScreen<G: GameData> {
207    /// What paints the view.
208    paint: Paint<G>,
209    /// What opens once a profile is added, if anything.
210    after_add: Option<AfterAdd<G>>,
211    /// The focused row: a profile's place in the list, or the list's
212    /// length for the row that adds one.
213    rows: ListCursor,
214    /// The focused profile's actions, while open.
215    actions: Option<Menu<ProfileAction>>,
216    /// The name box, while open.
217    naming: Option<Naming>,
218    /// The profile asked about and the question, while the question asked
219    /// before deleting is open.
220    deleting: Option<(ProfileId, Ask<DeleteChoice>)>,
221    /// The text key of the message under the panel, if any.
222    message: Option<&'static str>,
223}
224
225impl<G: GameData> fmt::Debug for ProfilesScreen<G> {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        // check-text: not player text, the type name for Debug
228        f.debug_struct("ProfilesScreen")
229            .field("rows", &self.rows)
230            .field("actions", &self.actions)
231            .field("naming", &self.naming)
232            .field("deleting", &self.deleting)
233            .field("message", &self.message)
234            .finish_non_exhaustive()
235    }
236}
237
238impl<G: GameData> ProfilesScreen<G> {
239    /// Name reported by [`Screen::name`].
240    pub const NAME: &'static str = "profiles";
241
242    /// The screen focused on the profile in use.
243    pub fn new(ctx: &Ctx<G>) -> Self {
244        let profiles = ctx.profiles();
245        let current = profiles
246            .all()
247            .iter()
248            .position(|p| p.id == profiles.current());
249        Self {
250            paint: glyph::paint,
251            after_add: None,
252            rows: ListCursor::new(Self::rows(ctx).len()).at(current.unwrap_or(0)),
253            actions: None,
254            naming: None,
255            deleting: None,
256            message: None,
257        }
258    }
259
260    /// The same screen with its view painted by `paint` instead of
261    /// [`glyph::paint`]: a game's look for a device of its own.
262    #[must_use]
263    pub fn painted_by(mut self, paint: Paint<G>) -> Self {
264        self.paint = paint;
265        self
266    }
267
268    /// The same screen opening the screen `open` makes in its place once a
269    /// profile is added (and in use), instead of closing: the game's
270    /// first-launch setup for the new profile, say.
271    #[must_use]
272    pub fn opening_after_add(mut self, open: AfterAdd<G>) -> Self {
273        self.after_add = Some(open);
274        self
275    }
276
277    /// Every row, top to bottom.
278    pub fn rows(ctx: &Ctx<G>) -> Vec<RowKind> {
279        let profiles = ctx.profiles().all().iter();
280        let mut rows: Vec<RowKind> = profiles.map(|p| RowKind::Profile(p.id)).collect();
281        rows.push(RowKind::Add);
282        rows
283    }
284
285    /// The focused row.
286    pub fn focused(&self, ctx: &Ctx<G>) -> RowKind {
287        let rows = Self::rows(ctx);
288        rows.get(self.rows.focus()).copied().unwrap_or(RowKind::Add)
289    }
290
291    /// The focused profile, if a profile row is focused.
292    fn focused_profile(&self, ctx: &Ctx<G>) -> Option<ProfileId> {
293        match self.focused(ctx) {
294            RowKind::Profile(id) => Some(id),
295            RowKind::Add => None,
296        }
297    }
298
299    /// What can be done to `id`: use it unless it is in use, rename it,
300    /// delete it unless it is the only one.
301    pub fn actions_for(ctx: &Ctx<G>, id: ProfileId) -> Vec<ProfileAction> {
302        let profiles = ctx.profiles();
303        let mut actions = Vec::new();
304        if profiles.current() != id {
305            actions.push(ProfileAction::Switch);
306        }
307        actions.push(ProfileAction::Rename);
308        if profiles.all().len() > 1 {
309            actions.push(ProfileAction::Delete);
310        }
311        actions
312    }
313
314    /// Whether another profile may be added.
315    fn can_add(ctx: &Ctx<G>) -> bool {
316        ctx.profiles().all().len() < MAX_PROFILES
317    }
318
319    /// What the name box is for, while it is open.
320    pub fn naming(&self) -> Option<NamePurpose> {
321        self.naming.as_ref().map(|n| n.purpose)
322    }
323
324    /// The name typed so far, while the name box is open.
325    pub fn typed(&self) -> Option<&str> {
326        self.naming.as_ref().map(|n| n.input.name())
327    }
328
329    /// The text key of the message under the panel, if any.
330    pub fn message(&self) -> Option<&str> {
331        self.message
332    }
333
334    /// The screen as it is now, as plain data for a skin to paint
335    /// ([`glyph::paint`]).
336    pub fn view(&self, ctx: &Ctx<G>) -> ProfilesView {
337        let profiles = ctx.profiles();
338        let can_add = Self::can_add(ctx);
339        let focus = self.rows.focus();
340        let rows = Self::rows(ctx).into_iter().enumerate().map(|(i, kind)| {
341            let focused = i == focus;
342            match kind {
343                RowKind::Profile(id) => RowView {
344                    kind,
345                    label: profile_name(ctx, id),
346                    current: id == profiles.current(),
347                    focused,
348                    enabled: true,
349                },
350                RowKind::Add => RowView {
351                    kind,
352                    label: ctx.text(ADD).to_owned(),
353                    current: false,
354                    focused,
355                    enabled: can_add,
356                },
357            }
358        });
359        let actions = self.actions.as_ref().zip(self.focused_profile(ctx));
360        let title = ctx.text(NAME_BOX);
361        let (typing, grid) = match self.naming.as_ref().map(|n| &n.input) {
362            Some(NameInput::Typing(b)) => (Some(b.view(ctx, title)), None),
363            Some(NameInput::Grid(e)) => (None, Some(e.view(ctx, title))),
364            None => (None, None),
365        };
366        let leaving = self.naming.as_ref().and_then(|n| n.leaving.as_ref());
367        ProfilesView {
368            title: ctx.text(TITLE).to_owned(),
369            rows: rows.collect(),
370            current_mark: ctx.text(CURRENT).to_owned(),
371            actions: actions.map(|(menu, id)| ActionsView {
372                title: profile_name(ctx, id),
373                menu: menu.view(),
374            }),
375            typing,
376            grid,
377            leaving: leaving.map(|ask| ask.view(ctx)),
378            deleting: self.deleting.as_ref().map(|(id, ask)| {
379                let name = profile_name(ctx, *id);
380                ask.view_with_keys(ctx, ctx.help_keys(), &[("name", &name)])
381            }),
382            message: self.message.map(|key| match key {
383                TOO_MANY_MESSAGE => ctx.text_with(key, &[("n", &MAX_PROFILES)]),
384                key => ctx.text(key).to_owned(),
385            }),
386            help: self.help(ctx),
387        }
388    }
389
390    /// The bottom help line, naming the keys of the active keymap (or the
391    /// text box's own keys while typing).
392    pub fn help(&self, ctx: &Ctx<G>) -> String {
393        let key = match &self.naming {
394            Some(n) if n.leaving.is_some() => HELP_QUESTION,
395            Some(Naming {
396                input: NameInput::Typing(_),
397                ..
398            }) => return text_keys_help(ctx),
399            Some(_) => name_entry::HELP_GRID,
400            None if self.deleting.is_some() => HELP_QUESTION,
401            None if self.actions.is_some() => HELP_ACTIONS,
402            None => match self.focused(ctx) {
403                RowKind::Add if Self::can_add(ctx) => HELP_ADD,
404                RowKind::Add => HELP_NONE,
405                RowKind::Profile(_) => HELP_LIST,
406            },
407        };
408        ctx.text_with(key, &[])
409    }
410
411    /// Opens the name box for `purpose`, starting from `name`: the letter
412    /// grid if a controller button opened it (`pad`), else the typing box.
413    /// It plays nothing: the key that opened it did.
414    fn open_name(&mut self, purpose: NamePurpose, name: &str, pad: bool) {
415        let input = if pad {
416            NameInput::Grid(NameEntry::new(name, NAME_RULES))
417        } else {
418            NameInput::Typing(NameBox::new(name, NAME_RULES))
419        };
420        self.naming = Some(Naming {
421            purpose,
422            original: name.to_owned(),
423            input,
424            leaving: None,
425        });
426    }
427
428    /// Confirm on the list (`pad`: with a controller button).
429    fn confirm(&mut self, ctx: &mut Ctx<G>, pad: bool) {
430        match self.focused(ctx) {
431            RowKind::Profile(id) => {
432                let items = Self::actions_for(ctx, id)
433                    .into_iter()
434                    .map(|action| MenuItem::keyed(action.key()).with_value(action));
435                self.actions = Some(Menu::new(items.collect()));
436                ctx.audio.menu(MenuSound::Select);
437            }
438            RowKind::Add if Self::can_add(ctx) => {
439                self.open_name(NamePurpose::Add, "", pad);
440                ctx.audio.menu(MenuSound::Select);
441            }
442            RowKind::Add => {
443                self.message = Some(TOO_MANY_MESSAGE);
444                ctx.audio.menu(MenuSound::Denied);
445            }
446        }
447    }
448
449    /// An action while the focused profile's actions are open.
450    fn act(&mut self, ctx: &mut Ctx<G>, action: G::Action, pad: bool) -> Transition<G> {
451        let id = self.focused_profile(ctx);
452        let (Some(menu), Some(id)) = (&mut self.actions, id) else {
453            self.actions = None;
454            return Transition::None;
455        };
456        match menu.handle_with_sound(action, &mut ctx.audio) {
457            None => {}
458            Some(MenuEvent::Cancelled) => self.actions = None,
459            Some(MenuEvent::Chosen(chosen)) => {
460                self.actions = None;
461                match chosen {
462                    ProfileAction::Switch => return Self::switch(ctx, id),
463                    ProfileAction::Rename => {
464                        let name = profile_name(ctx, id);
465                        self.open_name(NamePurpose::Rename(id), &name, pad);
466                    }
467                    ProfileAction::Delete => self.deleting = Some((id, DeleteChoice::ask())),
468                }
469            }
470        }
471        Transition::None
472    }
473
474    /// Uses the profile `id` and closes the screen: what is under it then
475    /// shows its options and saves.
476    fn switch(ctx: &mut Ctx<G>, id: ProfileId) -> Transition<G> {
477        // The switch happens even if the list can't be saved (the next
478        // launch then opens on the profile used before).
479        let _ = ctx.switch_profile(id);
480        Transition::Pop
481    }
482
483    /// The message for `error`, refused by the profiles.
484    fn refused(&mut self, ctx: &mut Ctx<G>, error: &ProfileError) {
485        self.message = Some(match error {
486            ProfileError::NameTaken => NAME_TAKEN_MESSAGE,
487            ProfileError::TooMany => TOO_MANY_MESSAGE,
488            _ => NOT_SAVED_MESSAGE,
489        });
490        ctx.audio.menu(MenuSound::Denied);
491    }
492
493    /// Keeps the name typed: adds the profile (then uses it and closes, or
494    /// opens what the game gives) or renames one. A name the profiles
495    /// refuse leaves the box open with a message.
496    fn keep_name(&mut self, ctx: &mut Ctx<G>) -> Transition<G> {
497        let Some(naming) = &self.naming else {
498            return Transition::None;
499        };
500        let Some(name) = finished(naming.input.name()) else {
501            ctx.audio.menu(MenuSound::Denied);
502            return Transition::None;
503        };
504        match naming.purpose {
505            NamePurpose::Rename(id) => {
506                if let Err(e) = ctx.rename_profile(id, &name) {
507                    self.refused(ctx, &e);
508                    return Transition::None;
509                }
510            }
511            NamePurpose::Add => match ctx.add_profile(&name) {
512                Ok(id) => {
513                    self.naming = None;
514                    self.message = None;
515                    let _ = ctx.switch_profile(id);
516                    return match self.after_add {
517                        Some(open) => Transition::Replace(open(ctx)),
518                        None => Transition::Pop,
519                    };
520                }
521                Err(e) => {
522                    self.refused(ctx, &e);
523                    return Transition::None;
524                }
525            },
526        }
527        self.naming = None;
528        self.message = None;
529        Transition::None
530    }
531
532    /// The name box asked to close (Cancel): with a name typed and not
533    /// kept it asks first, focused on going back; otherwise it closes.
534    fn cancel_name(&mut self, ctx: &mut Ctx<G>) {
535        let Some(naming) = &mut self.naming else {
536            return;
537        };
538        if naming.unkept() {
539            naming.leaving = Some(keep_question());
540            ctx.audio.menu(MenuSound::Select);
541        } else {
542            self.naming = None;
543            self.message = None;
544            ctx.audio.menu(MenuSound::Cancel);
545        }
546    }
547
548    /// One frame of the name box (no question open).
549    fn type_name(&mut self, ctx: &mut Ctx<G>, input: &FrameInput<G::Action>) -> Transition<G> {
550        let Some(naming) = &mut self.naming else {
551            return Transition::None;
552        };
553        let event = match &mut naming.input {
554            NameInput::Typing(b) => b.handle(input, ctx),
555            NameInput::Grid(e) => {
556                let mut event = NameEvent::Open;
557                for &action in &input.actions {
558                    event = e.handle(action, ctx);
559                    if event != NameEvent::Open {
560                        break;
561                    }
562                }
563                event
564            }
565        };
566        match event {
567            NameEvent::Open => Transition::None,
568            NameEvent::Done => self.keep_name(ctx),
569            NameEvent::Cancel => {
570                self.cancel_name(ctx);
571                Transition::None
572            }
573        }
574    }
575
576    /// An action while the question asked on backing out of the name box
577    /// is open: the cursor moves between the answers, Confirm picks one,
578    /// Cancel goes back to the box. Returns the transition and whether the
579    /// question closed.
580    fn answer_leave(&mut self, ctx: &mut Ctx<G>, action: G::Action) -> (Transition<G>, bool) {
581        let Some(ask) = self.naming.as_mut().and_then(|n| n.leaving.as_mut()) else {
582            return (Transition::None, true);
583        };
584        let Some(choice) = ask.handle(action, &mut ctx.audio) else {
585            return (Transition::None, false);
586        };
587        if let Some(naming) = &mut self.naming {
588            naming.leaving = None;
589        }
590        match choice {
591            LeaveChoice::Keep => (self.keep_name(ctx), true),
592            LeaveChoice::Discard => {
593                self.naming = None;
594                self.message = None;
595                ctx.audio.menu(MenuSound::Cancel);
596                (Transition::None, true)
597            }
598            LeaveChoice::Back => {
599                ctx.audio.menu(MenuSound::Cancel);
600                (Transition::None, true)
601            }
602        }
603    }
604
605    /// An action while the question asked before deleting is open. Returns
606    /// whether the question closed.
607    fn answer_delete(&mut self, ctx: &mut Ctx<G>, action: G::Action) -> bool {
608        let Some((id, ask)) = &mut self.deleting else {
609            return true;
610        };
611        let id = *id;
612        let Some(choice) = ask.handle(action, &mut ctx.audio) else {
613            return false;
614        };
615        self.deleting = None;
616        if choice == DeleteChoice::No {
617            ctx.audio.menu(MenuSound::Cancel);
618            return true;
619        }
620        match ctx.delete_profile(id) {
621            Ok(()) => ctx.audio.menu(MenuSound::Select),
622            Err(e) => self.refused(ctx, &e),
623        }
624        // A row fewer: the focus stays in the list. (The cursor learns the
625        // new number of rows on the next move.)
626        let profiles = ctx.profiles().all().len();
627        self.rows = self
628            .rows
629            .clone()
630            .at(self.rows.focus().min(profiles.saturating_sub(1)));
631        true
632    }
633
634    /// Moves the focus one row up or down, wrapping.
635    fn move_row(&mut self, ctx: &mut Ctx<G>, down: bool) {
636        self.rows.set_len(Self::rows(ctx).len());
637        if self.rows.step(down, true, |_| true) {
638            ctx.audio.menu(MenuSound::Move);
639        }
640        self.message = None;
641    }
642}
643
644/// The name the player gave the profile `id` (empty if there is none).
645fn profile_name<G: GameData>(ctx: &Ctx<G>, id: ProfileId) -> String {
646    let profile = ctx.profiles().get(id);
647    // check-text: not a data name (the player typed it)
648    profile.map(|p| p.name.clone()).unwrap_or_default()
649}
650
651impl<G: GameData> Screen<G> for ProfilesScreen<G> {
652    fn name(&self) -> &'static str {
653        Self::NAME
654    }
655
656    /// Its rows, its menus and questions, and the name grid.
657    fn actions(&self) -> Option<Vec<G::Action>> {
658        Some(crate::input::ui_actions(crate::input::NAVIGATION))
659    }
660
661    fn update(&mut self, ctx: &mut Ctx<G>, input: &FrameInput<G::Action>) -> Transition<G> {
662        let typing = self.naming.as_ref().is_some_and(|n| n.leaving.is_none());
663        if typing {
664            // The game's keys do nothing while the box is open (on the
665            // grid they are its cursor).
666            return self.type_name(ctx, input);
667        }
668        for &action in &input.actions {
669            if self.naming.is_some() {
670                let (transition, closed) = self.answer_leave(ctx, action);
671                if closed {
672                    // The answer ends this frame's keys.
673                    return transition;
674                }
675                continue;
676            }
677            if self.deleting.is_some() {
678                if self.answer_delete(ctx, action) {
679                    break;
680                }
681                continue;
682            }
683            if self.actions.is_some() {
684                let transition = self.act(ctx, action, input.pad_pressed());
685                if !matches!(transition, Transition::None) {
686                    return transition;
687                }
688                if self.actions.is_none() {
689                    // The key that chose isn't the next box's.
690                    break;
691                }
692                continue;
693            }
694            match action.ui() {
695                Some(UiAction::Up) => self.move_row(ctx, false),
696                Some(UiAction::Down) => self.move_row(ctx, true),
697                Some(UiAction::Confirm) => {
698                    self.confirm(ctx, input.pad_pressed());
699                    // The key that opened something isn't its first.
700                    if self.actions.is_some() || self.naming.is_some() {
701                        break;
702                    }
703                }
704                Some(UiAction::Cancel) => {
705                    ctx.audio.menu(MenuSound::Cancel);
706                    return Transition::Pop;
707                }
708                _ => {}
709            }
710        }
711        Transition::None
712    }
713
714    fn draw(&self, ctx: &Ctx<G>, buf: &mut GlyphBuffer) {
715        (self.paint)(ctx, &self.view(ctx), buf);
716    }
717
718    fn as_any(&self) -> Option<&dyn core::any::Any> {
719        Some(self)
720    }
721}
722
723#[cfg(test)]
724mod tests;