Skip to main content

flatland_play_loop/
actions.rs

1//! Input action and overlay menu dispatch for any [`GameClient`] session.
2
3use flatland_client_lib::{
4    AutoNavigator, ClientKeyBindings, GameClient, PlayConnection, RotationEditorMode,
5};
6use flatland_client_ui::{
7    next_custom_preset, preset_deletable, InputAction, UiKeyCode, UiKeyEvent, UiKeyEventKind,
8    EDITOR_ABILITIES,
9};
10
11pub struct ActionCtx<'a> {
12    pub block_active: &'a mut bool,
13    pub auto_nav: &'a mut Option<AutoNavigator>,
14}
15
16/// Returns true if the key was consumed by a menu handler.
17pub async fn dispatch_menu_key<S: PlayConnection>(
18    client: &mut GameClient<S>,
19    key: UiKeyEvent,
20) -> bool {
21    if key.kind != UiKeyEventKind::Press {
22        return false;
23    }
24
25    if client.state.show_inventory_menu {
26        if client.state.show_rename_prompt {
27            match key.code {
28                UiKeyCode::Esc => client.cancel_rename_prompt(),
29                UiKeyCode::Enter => {
30                    if let Err(err) = client.confirm_rename_prompt().await {
31                        client.state.push_log(format!("Rename: {err}"));
32                    }
33                }
34                UiKeyCode::Backspace => {
35                    client.state.rename_buffer.pop();
36                }
37                UiKeyCode::Char(c) => {
38                    if client.state.rename_buffer.len() < 32 {
39                        client.state.rename_buffer.push(c);
40                    }
41                }
42                _ => {}
43            }
44        } else if client.state.show_destroy_picker {
45            match key.code {
46                UiKeyCode::Esc => {
47                    if client.state.destroy_confirm_pending {
48                        client.cancel_destroy_confirm();
49                    } else {
50                        client.close_destroy_picker();
51                    }
52                }
53                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
54                    if !client.state.destroy_confirm_pending {
55                        client.destroy_picker_adjust_quantity(-1);
56                    }
57                }
58                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
59                    if !client.state.destroy_confirm_pending {
60                        client.destroy_picker_adjust_quantity(1);
61                    }
62                }
63                UiKeyCode::Char('a') => {
64                    if !client.state.destroy_confirm_pending {
65                        client.destroy_picker_set_quantity_max();
66                    }
67                }
68                UiKeyCode::Enter => {
69                    if let Err(err) = client.activate_inventory_selection().await {
70                        client.state.push_log(format!("Destroy failed: {err}"));
71                    }
72                }
73                _ => {}
74            }
75        } else if client.state.show_move_picker {
76            match key.code {
77                UiKeyCode::Esc => client.close_move_picker(),
78                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
79                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
80                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
81                    client.move_picker_adjust_quantity(-1);
82                }
83                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
84                    client.move_picker_adjust_quantity(1);
85                }
86                UiKeyCode::Char('a') => client.move_picker_set_quantity_max(),
87                UiKeyCode::Enter => {
88                    if let Err(err) = client.activate_inventory_selection().await {
89                        client.state.push_log(format!("Move failed: {err}"));
90                    }
91                }
92                _ => {}
93            }
94        } else {
95            match key.code {
96                UiKeyCode::Char('b') => client.close_inventory_menu(),
97                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
98                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
99                UiKeyCode::Enter => {
100                    if let Err(err) = client.activate_inventory_selection().await {
101                        client.state.push_log(format!("{err}"));
102                    }
103                }
104                UiKeyCode::Char('m') => {
105                    if let Err(err) = client.open_move_picker() {
106                        client.state.push_log(format!("{err}"));
107                    }
108                }
109                UiKeyCode::Char('n') => {
110                    if let Err(err) = client.open_rename_prompt() {
111                        client.state.push_log(format!("{err}"));
112                    }
113                }
114                UiKeyCode::Char('d') => {
115                    if let Err(err) = client.drop_selected().await {
116                        client.state.push_log(format!("Drop: {err}"));
117                    }
118                }
119                UiKeyCode::Char('x') => {
120                    if let Err(err) = client.open_destroy_picker() {
121                        client.state.push_log(format!("Destroy: {err}"));
122                    }
123                }
124                UiKeyCode::Char('l') => {
125                    if let Err(err) = client.toggle_chest_lock_for_selection().await {
126                        client.state.push_log(format!("Lock: {err}"));
127                    }
128                }
129                UiKeyCode::Char('u') => {
130                    if let Err(err) = client.unequip_mainhand().await {
131                        client.state.push_log(format!("Unequip: {err}"));
132                    }
133                }
134                _ => {}
135            }
136        }
137        return true;
138    }
139
140    if client.state.show_keychain_menu {
141        match key.code {
142            UiKeyCode::Esc | UiKeyCode::Char(',') => client.close_keychain_menu(),
143            UiKeyCode::Up | UiKeyCode::Char('w') | UiKeyCode::Char('k') => {
144                client.keychain_menu_move(-1);
145            }
146            UiKeyCode::Down | UiKeyCode::Char('s') | UiKeyCode::Char('j') => {
147                client.keychain_menu_move(1);
148            }
149            UiKeyCode::Enter => {
150                if let Err(err) = client.activate_keychain_selection().await {
151                    client.state.push_log(format!("Keychain: {err}"));
152                }
153            }
154            _ => {}
155        }
156        return true;
157    }
158
159    if client.state.show_craft_menu {
160        match key.code {
161            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
162                client.close_craft_menu()
163            }
164            UiKeyCode::Up | UiKeyCode::Char('k') => client.craft_menu_move(-1),
165            UiKeyCode::Down | UiKeyCode::Char('j') => client.craft_menu_move(1),
166            UiKeyCode::Char('-') => client.craft_batch_adjust_quantity(-1),
167            UiKeyCode::Char('=') => client.craft_batch_adjust_quantity(1),
168            UiKeyCode::Char('a') => client.craft_batch_set_max(),
169            UiKeyCode::Enter => {
170                if let Err(err) = client.craft_menu_selection().await {
171                    client.state.push_log(format!("Craft failed: {err}"));
172                }
173            }
174            _ => {}
175        }
176        return true;
177    }
178
179    if client.state.show_npc_chat {
180        match key.code {
181            UiKeyCode::Esc => {
182                if let Err(err) = client.npc_talk_close().await {
183                    client.state.push_log(format!("Talk close failed: {err}"));
184                }
185            }
186            UiKeyCode::Enter => {
187                if let Err(err) = client.npc_talk_send().await {
188                    client.state.push_log(format!("Talk failed: {err}"));
189                }
190            }
191            UiKeyCode::Backspace => {
192                if let Some(chat) = client.state.npc_chat.as_mut() {
193                    chat.input.pop();
194                }
195            }
196            UiKeyCode::Char(c) => {
197                if let Some(chat) = client.state.npc_chat.as_mut() {
198                    if !chat.pending && chat.input.len() < 300 {
199                        chat.input.push(c);
200                    }
201                }
202            }
203            _ => {}
204        }
205        return true;
206    }
207
208    if client.state.show_npc_verb_menu {
209        match key.code {
210            UiKeyCode::Esc => {
211                client.state.show_npc_verb_menu = false;
212                client.state.npc_verb_target = None;
213            }
214            UiKeyCode::Up | UiKeyCode::Char('k') => {
215                if client.state.npc_verb_index > 0 {
216                    client.state.npc_verb_index -= 1;
217                }
218            }
219            UiKeyCode::Down | UiKeyCode::Char('j') => {
220                let max = client.npc_verb_options().len().saturating_sub(1);
221                if client.state.npc_verb_index < max {
222                    client.state.npc_verb_index += 1;
223                }
224            }
225            UiKeyCode::Enter => {
226                if let Err(err) = client.confirm_npc_verb().await {
227                    client.state.push_log(format!("Interact failed: {err}"));
228                }
229            }
230            _ => {}
231        }
232        return true;
233    }
234
235    if client.state.show_shop_menu {
236        match key.code {
237            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
238                client.close_shop_menu()
239            }
240            UiKeyCode::Tab => client.shop_tab_toggle(),
241            UiKeyCode::Up | UiKeyCode::Char('k') => client.shop_menu_move(-1),
242            UiKeyCode::Down | UiKeyCode::Char('j') => client.shop_menu_move(1),
243            UiKeyCode::Char('-') => client.shop_quantity_adjust(-1),
244            UiKeyCode::Char('=') => client.shop_quantity_adjust(1),
245            UiKeyCode::Char('a') => client.shop_quantity_set_max(),
246            UiKeyCode::Enter => {
247                if let Err(err) = client.shop_confirm().await {
248                    client.state.push_log(format!("Trade failed: {err}"));
249                }
250            }
251            _ => {}
252        }
253        return true;
254    }
255
256    if client.state.show_quest_offer {
257        match key.code {
258            UiKeyCode::Esc => client.quest_offer_decline(),
259            UiKeyCode::Enter => {
260                if let Err(err) = client.quest_offer_accept().await {
261                    client.state.push_log(format!("Quest: {err}"));
262                }
263            }
264            _ => {}
265        }
266        return true;
267    }
268
269    if client.state.show_quest_menu {
270        match key.code {
271            UiKeyCode::Esc => {
272                if client.state.quest_withdraw_confirm {
273                    client.state.quest_withdraw_confirm = false;
274                } else {
275                    client.state.show_quest_menu = false;
276                }
277            }
278            UiKeyCode::Up | UiKeyCode::Char('k') => client.quest_menu_move(-1),
279            UiKeyCode::Down | UiKeyCode::Char('j') => client.quest_menu_move(1),
280            UiKeyCode::Char('x') => client.quest_request_withdraw(),
281            UiKeyCode::Enter => {
282                if let Err(err) = client.quest_confirm_action().await {
283                    client.state.push_log(format!("Quest: {err}"));
284                }
285            }
286            _ => {}
287        }
288        return true;
289    }
290
291    false
292}
293
294pub async fn dispatch_action<S: PlayConnection>(
295    client: &mut GameClient<S>,
296    keys: &ClientKeyBindings,
297    action: InputAction,
298    ctx: &mut ActionCtx<'_>,
299) {
300    match action {
301        InputAction::Harvest => {
302            if let Err(err) = client.harvest_nearest().await {
303                client.state.push_log(format!("Harvest failed: {err}"));
304            }
305        }
306        InputAction::Pickup => {
307            if client.pickup_nearest().await.is_err() {
308                if let Err(err) = client.pickup_nearest_container().await {
309                    client.state.push_log(format!("Pickup: {err}"));
310                }
311            }
312        }
313        InputAction::Craft => client.open_craft_menu(),
314        InputAction::Interact | InputAction::UseWorld => {
315            if let Err(err) = client.use_nearest().await {
316                client.state.push_log(format!("Use: {err}"));
317            }
318        }
319        InputAction::TestDamage => {
320            if let Err(err) = client.test_damage(25.0).await {
321                client.state.push_log(format!("Damage failed: {err}"));
322            }
323        }
324        InputAction::CycleCombatTarget { reverse } => {
325            if let Err(err) = client.cycle_combat_target(reverse).await {
326                client.state.push_log(format!("T1 target: {err}"));
327            }
328        }
329        InputAction::CycleCombatTargetT2 { reverse } => {
330            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
331                client.state.push_log(format!("T2 target: {err}"));
332            }
333        }
334        InputAction::AdvanceRotationT1 => {
335            if let Err(err) = client.advance_rotation(1).await {
336                client.state.push_log(format!("T1 step: {err}"));
337            }
338        }
339        InputAction::AdvanceRotationT2 => {
340            if let Err(err) = client.advance_rotation(2).await {
341                client.state.push_log(format!("T2 step: {err}"));
342            }
343        }
344        InputAction::ToggleAutoT1 => {
345            if let Err(err) = client.toggle_auto_attack_slot(1).await {
346                client.state.push_log(format!("T1 auto: {err}"));
347            }
348        }
349        InputAction::ToggleAutoT2 => {
350            if let Err(err) = client.toggle_auto_attack_slot(2).await {
351                client.state.push_log(format!("T2 auto: {err}"));
352            }
353        }
354        InputAction::ToggleLoadout => {
355            client.state.show_rotation_editor = false;
356            client.state.rotation_editor.reset();
357            client.state.show_loadout_menu = !client.state.show_loadout_menu;
358            if client.state.show_loadout_menu {
359                *ctx.auto_nav = None;
360                let _ = client.stop().await;
361            }
362        }
363        InputAction::ToggleRotationEditor => {
364            client.state.show_loadout_menu = false;
365            if client.state.show_rotation_editor {
366                client.state.show_rotation_editor = false;
367                client.state.rotation_editor.reset();
368            } else {
369                client.state.show_rotation_editor = true;
370                client.state.rotation_editor.reset();
371                let max = client.state.rotation_presets.len();
372                if max > 0 {
373                    client.state.rotation_editor.list_index =
374                        client.state.rotation_editor.list_index.min(max - 1);
375                }
376            }
377            if client.state.show_rotation_editor {
378                *ctx.auto_nav = None;
379                let _ = client.stop().await;
380            }
381        }
382        InputAction::LoadoutMenuUp => {
383            if client.state.show_loadout_menu && client.state.loadout_menu_index > 0 {
384                client.state.loadout_menu_index -= 1;
385            }
386        }
387        InputAction::LoadoutMenuDown => {
388            if client.state.show_loadout_menu {
389                let max = client.state.rotation_presets.len();
390                if max > 0 {
391                    client.state.loadout_menu_index =
392                        (client.state.loadout_menu_index + 1).min(max - 1);
393                }
394            }
395        }
396        InputAction::LoadoutAssignT1 => {
397            let preset_id = {
398                let idx = client.state.loadout_menu_index;
399                client.state.rotation_presets.get(idx).map(|p| p.id.clone())
400            };
401            if let Some(preset_id) = preset_id {
402                if let Err(err) = client.assign_slot_preset(1, &preset_id).await {
403                    client.state.push_log(format!("Loadout: {err}"));
404                }
405            }
406        }
407        InputAction::LoadoutAssignT2 => {
408            let preset_id = {
409                let idx = client.state.loadout_menu_index;
410                client.state.rotation_presets.get(idx).map(|p| p.id.clone())
411            };
412            if let Some(preset_id) = preset_id {
413                if let Err(err) = client.assign_slot_preset(2, &preset_id).await {
414                    client.state.push_log(format!("Loadout: {err}"));
415                }
416            }
417        }
418        InputAction::CloseOverlay => {
419            client.state.show_loadout_menu = false;
420            client.state.show_rotation_editor = false;
421            client.state.rotation_editor.reset();
422        }
423        InputAction::ClearCombatTarget => {
424            if let Err(err) = client.clear_combat_target().await {
425                client.state.push_log(format!("Target: {err}"));
426            }
427        }
428        InputAction::ClearCombatTargetT2 => {
429            if let Err(err) = client.clear_combat_target_slot(2).await {
430                client.state.push_log(format!("T2 target: {err}"));
431            }
432        }
433        InputAction::Dodge => {
434            *ctx.auto_nav = None;
435            if let Err(err) = client.dodge().await {
436                client.state.push_log(format!("Dodge: {err}"));
437            }
438        }
439        InputAction::Lunge => {
440            *ctx.auto_nav = None;
441            let _ = client.stop().await;
442            if let Err(err) = client.lunge().await {
443                client.state.push_log(format!("Lunge: {err}"));
444            }
445        }
446        InputAction::DirectionalJump { forward, strafe } => {
447            *ctx.auto_nav = None;
448            let _ = client.stop().await;
449            if let Err(err) = client.directional_jump(forward, strafe).await {
450                client.state.push_log(format!("Jump: {err}"));
451            }
452        }
453        InputAction::CastHotbar { slot } => match keys.hotbar_ability(slot) {
454            Some(ability_id) => {
455                let ability_id = ability_id.to_string();
456                if let Err(err) = client.cast_hotbar_ability(&ability_id).await {
457                    client.state.push_log(format!("Hotbar {slot}: {err}"));
458                }
459            }
460            None => {
461                client.state.push_log(format!(
462                    "Hotbar {slot} unbound — set in client-settings.yaml"
463                ));
464            }
465        },
466        InputAction::ToggleBlock => {
467            *ctx.block_active = !*ctx.block_active;
468            if let Err(err) = client.set_block(*ctx.block_active).await {
469                client.state.push_log(format!("Block: {err}"));
470                *ctx.block_active = false;
471            }
472        }
473        InputAction::ToggleStats => client.toggle_stats(),
474        InputAction::ToggleInventory => client.toggle_inventory_menu(),
475        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
476        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
477        InputAction::QuestMenuUp => client.quest_menu_move(-1),
478        InputAction::QuestMenuDown => client.quest_menu_move(1),
479        InputAction::QuestWithdraw => client.quest_request_withdraw(),
480        InputAction::RotationEditorBack => {
481            let _ = client.back_on_esc();
482        }
483        InputAction::RotationEditorListUp
484        | InputAction::RotationEditorListDown
485        | InputAction::RotationEditorEdit
486        | InputAction::RotationEditorNew
487        | InputAction::RotationEditorDelete
488        | InputAction::RotationEditorAddAbility
489        | InputAction::RotationEditorRemoveAbility
490        | InputAction::RotationEditorMoveAbilityUp
491        | InputAction::RotationEditorMoveAbilityDown
492        | InputAction::RotationEditorAbilityUp
493        | InputAction::RotationEditorAbilityDown
494        | InputAction::RotationEditorPickerUp
495        | InputAction::RotationEditorPickerDown
496        | InputAction::RotationEditorPickAbility
497        | InputAction::RotationEditorRename
498        | InputAction::RotationEditorConfirmLabel
499        | InputAction::RotationEditorLabelBackspace
500        | InputAction::RotationEditorLabelChar(_)
501        | InputAction::RotationEditorSave => {
502            handle_rotation_editor_action(client, action).await;
503        }
504        InputAction::None
505        | InputAction::Quit
506        | InputAction::ToggleHelp
507        | InputAction::CycleHudView
508        | InputAction::StartChat { .. }
509        | InputAction::SubmitChat
510        | InputAction::CancelChat
511        | InputAction::ToggleSprintMode
512        | InputAction::ToggleMapTarget
513        | InputAction::ConfirmMapTarget
514        | InputAction::CancelMapTarget
515        | InputAction::MapTargetNudge { .. }
516        | InputAction::CancelAutoNav
517        | InputAction::StopMovement => {}
518    }
519}
520
521async fn handle_rotation_editor_action<S: PlayConnection>(
522    client: &mut GameClient<S>,
523    action: InputAction,
524) {
525    match action {
526        InputAction::RotationEditorListUp => {
527            if client.state.rotation_editor.list_index > 0 {
528                client.state.rotation_editor.list_index -= 1;
529            }
530        }
531        InputAction::RotationEditorListDown => {
532            let max = client.state.rotation_presets.len();
533            if max > 0 {
534                client.state.rotation_editor.list_index =
535                    (client.state.rotation_editor.list_index + 1).min(max - 1);
536            }
537        }
538        InputAction::RotationEditorEdit => {
539            let idx = client.state.rotation_editor.list_index;
540            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
541                client.state.rotation_editor.draft = Some(preset);
542                client.state.rotation_editor.ability_index = 0;
543                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
544            }
545        }
546        InputAction::RotationEditorNew => {
547            let preset = next_custom_preset(&client.state.rotation_presets);
548            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
549            client.state.rotation_editor.draft = Some(preset);
550            client.state.rotation_editor.ability_index = 0;
551            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
552        }
553        InputAction::RotationEditorDelete => {
554            let idx = client.state.rotation_editor.list_index;
555            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
556            if let Some(id) = preset_id {
557                if preset_deletable(&id) {
558                    if let Err(err) = client.delete_rotation_preset(&id).await {
559                        client.state.push_log(format!("Delete: {err}"));
560                    } else {
561                        let max = client.state.rotation_presets.len();
562                        client.state.rotation_editor.list_index = if max == 0 {
563                            0
564                        } else {
565                            client.state.rotation_editor.list_index.min(max - 1)
566                        };
567                    }
568                } else {
569                    client.state.push_log("Cannot delete built-in preset");
570                }
571            }
572        }
573        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
574            RotationEditorMode::EditLabel => {
575                client.state.rotation_editor.label_buffer.clear();
576                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
577            }
578            RotationEditorMode::PickAbility => {
579                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
580            }
581            RotationEditorMode::EditSequence => {
582                client.state.rotation_editor.draft = None;
583                client.state.rotation_editor.mode = RotationEditorMode::List;
584            }
585            RotationEditorMode::List => {}
586        },
587        InputAction::RotationEditorAddAbility => {
588            client.state.rotation_editor.picker_index = 0;
589            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
590        }
591        InputAction::RotationEditorRemoveAbility => {
592            let idx = client.state.rotation_editor.ability_index;
593            if let Some(draft) = &mut client.state.rotation_editor.draft {
594                if idx < draft.abilities.len() {
595                    draft.abilities.remove(idx);
596                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
597                        && client.state.rotation_editor.ability_index > 0
598                    {
599                        client.state.rotation_editor.ability_index -= 1;
600                    }
601                }
602            }
603        }
604        InputAction::RotationEditorMoveAbilityUp => {
605            let i = client.state.rotation_editor.ability_index;
606            if let Some(draft) = &mut client.state.rotation_editor.draft {
607                if i > 0 && i < draft.abilities.len() {
608                    draft.abilities.swap(i, i - 1);
609                    client.state.rotation_editor.ability_index -= 1;
610                }
611            }
612        }
613        InputAction::RotationEditorMoveAbilityDown => {
614            let i = client.state.rotation_editor.ability_index;
615            if let Some(draft) = &mut client.state.rotation_editor.draft {
616                if i + 1 < draft.abilities.len() {
617                    draft.abilities.swap(i, i + 1);
618                    client.state.rotation_editor.ability_index += 1;
619                }
620            }
621        }
622        InputAction::RotationEditorAbilityUp => {
623            if client.state.rotation_editor.ability_index > 0 {
624                client.state.rotation_editor.ability_index -= 1;
625            }
626        }
627        InputAction::RotationEditorAbilityDown => {
628            if let Some(draft) = &client.state.rotation_editor.draft {
629                if !draft.abilities.is_empty() {
630                    client.state.rotation_editor.ability_index =
631                        (client.state.rotation_editor.ability_index + 1)
632                            .min(draft.abilities.len() - 1);
633                }
634            }
635        }
636        InputAction::RotationEditorPickerUp => {
637            if client.state.rotation_editor.picker_index > 0 {
638                client.state.rotation_editor.picker_index -= 1;
639            }
640        }
641        InputAction::RotationEditorPickerDown => {
642            if !EDITOR_ABILITIES.is_empty() {
643                client.state.rotation_editor.picker_index =
644                    (client.state.rotation_editor.picker_index + 1).min(EDITOR_ABILITIES.len() - 1);
645            }
646        }
647        InputAction::RotationEditorPickAbility => {
648            let pick = client.state.rotation_editor.picker_index;
649            if let Some(ability) = EDITOR_ABILITIES.get(pick) {
650                if let Some(draft) = &mut client.state.rotation_editor.draft {
651                    draft.abilities.push((*ability).to_string());
652                    client.state.rotation_editor.ability_index =
653                        draft.abilities.len().saturating_sub(1);
654                }
655                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
656            }
657        }
658        InputAction::RotationEditorRename => {
659            if let Some(draft) = &client.state.rotation_editor.draft {
660                client.state.rotation_editor.label_buffer = draft.label.clone();
661                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
662            }
663        }
664        InputAction::RotationEditorConfirmLabel => {
665            let label = client.state.rotation_editor.label_buffer.trim().to_string();
666            if label.is_empty() {
667                client.state.push_log("Label cannot be empty");
668            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
669                draft.label = label;
670                client.state.rotation_editor.label_buffer.clear();
671                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
672            }
673        }
674        InputAction::RotationEditorLabelBackspace => {
675            client.state.rotation_editor.label_buffer.pop();
676        }
677        InputAction::RotationEditorLabelChar(c) => {
678            if client.state.rotation_editor.label_buffer.len() < 32 {
679                client.state.rotation_editor.label_buffer.push(c);
680            }
681        }
682        InputAction::RotationEditorSave => {
683            let draft = match client.state.rotation_editor.draft.clone() {
684                Some(d) => d,
685                None => return,
686            };
687            if draft.abilities.is_empty() {
688                client.state.push_log("Rotation needs at least one ability");
689                return;
690            }
691            let saved_id = draft.id.clone();
692            if let Err(err) = client.upsert_rotation_preset(draft).await {
693                client.state.push_log(format!("Save: {err}"));
694            } else {
695                client.state.rotation_editor.mode = RotationEditorMode::List;
696                client.state.rotation_editor.draft = None;
697                if let Some(i) = client
698                    .state
699                    .rotation_presets
700                    .iter()
701                    .position(|p| p.id == saved_id)
702                {
703                    client.state.rotation_editor.list_index = i;
704                }
705            }
706        }
707        _ => {}
708    }
709}