1pub 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
41pub const TITLE: &str = "profile.title";
43pub const CURRENT: &str = "profile.current";
45pub const ADD: &str = "profile.add";
47pub const NAME_BOX: &str = "profile.name_box";
49pub const DELETE_QUESTION: &str = "profile.question.delete";
51pub const KEEP_QUESTION: &str = "profile.question.keep";
53pub const NAME_TAKEN_MESSAGE: &str = "profile.message.name_taken";
55pub const TOO_MANY_MESSAGE: &str = "profile.message.too_many";
58pub const NOT_SAVED_MESSAGE: &str = "profile.message.not_saved";
60pub const HELP_LIST: &str = "profile.help.list";
62pub const HELP_ADD: &str = "profile.help.add";
64pub const HELP_NONE: &str = "profile.help.none";
67pub const HELP_ACTIONS: &str = "profile.help.actions";
69pub const HELP_QUESTION: &str = "profile.help.question";
71
72pub const NAME_RULES: NameRules = NameRules {
75 max_chars: MAX_NAME_CHARS,
76 digits: true,
77};
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RowKind {
82 Profile(ProfileId),
84 Add,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ProfileAction {
91 Switch,
93 Rename,
95 Delete,
97}
98
99impl ProfileAction {
100 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum DeleteChoice {
113 Yes,
115 No,
117}
118
119impl DeleteChoice {
120 pub const ALL: [DeleteChoice; 2] = [DeleteChoice::Yes, DeleteChoice::No];
122
123 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 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
141fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum NamePurpose {
153 Add,
155 Rename(ProfileId),
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161enum NameInput {
162 Typing(NameBox),
164 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#[derive(Debug, Clone, PartialEq, Eq)]
179struct Naming {
180 purpose: NamePurpose,
181 original: String,
183 input: NameInput,
184 leaving: Option<Ask<LeaveChoice>>,
186}
187
188impl Naming {
189 fn unkept(&self) -> bool {
192 finished(self.input.name()).is_some_and(|name| name != self.original)
193 }
194}
195
196pub type Paint<G> = fn(&Ctx<G>, &ProfilesView, &mut GlyphBuffer);
199
200pub type AfterAdd<G> = fn(&mut Ctx<G>) -> Box<dyn Screen<G>>;
203
204pub struct ProfilesScreen<G: GameData> {
207 paint: Paint<G>,
209 after_add: Option<AfterAdd<G>>,
211 rows: ListCursor,
214 actions: Option<Menu<ProfileAction>>,
216 naming: Option<Naming>,
218 deleting: Option<(ProfileId, Ask<DeleteChoice>)>,
221 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 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 pub const NAME: &'static str = "profiles";
241
242 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 #[must_use]
263 pub fn painted_by(mut self, paint: Paint<G>) -> Self {
264 self.paint = paint;
265 self
266 }
267
268 #[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 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 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 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 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 fn can_add(ctx: &Ctx<G>) -> bool {
316 ctx.profiles().all().len() < MAX_PROFILES
317 }
318
319 pub fn naming(&self) -> Option<NamePurpose> {
321 self.naming.as_ref().map(|n| n.purpose)
322 }
323
324 pub fn typed(&self) -> Option<&str> {
326 self.naming.as_ref().map(|n| n.input.name())
327 }
328
329 pub fn message(&self) -> Option<&str> {
331 self.message
332 }
333
334 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 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 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 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 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 fn switch(ctx: &mut Ctx<G>, id: ProfileId) -> Transition<G> {
477 let _ = ctx.switch_profile(id);
480 Transition::Pop
481 }
482
483 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 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 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 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 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 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 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 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
644fn profile_name<G: GameData>(ctx: &Ctx<G>, id: ProfileId) -> String {
646 let profile = ctx.profiles().get(id);
647 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 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 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 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 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 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;