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    editor_ability_choices, next_custom_preset, preset_deletable, InputAction, UiKeyCode,
8    UiKeyEvent, UiKeyEventKind,
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    // Chat typing owns the keyboard — no gameplay binds while focused.
26    if client.state.social_chat.input_focused {
27        match key.code {
28            UiKeyCode::Esc => client.state.social_chat.unfocus(),
29            UiKeyCode::Enter => {
30                if let Err(err) = client.submit_social_chat_buffer().await {
31                    client.state.push_log(format!("Chat failed: {err}"));
32                }
33            }
34            UiKeyCode::Tab => client.state.social_chat.toggle_mode_speak_whisper(),
35            UiKeyCode::Backspace => {
36                client.state.social_chat.buffer.pop();
37            }
38            UiKeyCode::Char(c) => {
39                // Accept printable text; ignore lone control leftovers.
40                if !c.is_control()
41                    && client.state.social_chat.buffer.len()
42                        < flatland_client_lib::SocialChatState::MAX_BUF
43                {
44                    client.state.social_chat.buffer.push(c);
45                }
46            }
47            _ => {}
48        }
49        return true;
50    }
51
52    if client.state.show_worker_rename {
53        match key.code {
54            UiKeyCode::Esc => client.cancel_worker_rename(),
55            UiKeyCode::Enter => {
56                if let Err(err) = client.confirm_worker_rename().await {
57                    client.state.push_log(format!("Rename: {err}"));
58                }
59            }
60            UiKeyCode::Backspace => {
61                client.state.rename_buffer.pop();
62            }
63            UiKeyCode::Char(c) => {
64                if client.state.rename_buffer.chars().count() < 32 {
65                    client.state.rename_buffer.push(c);
66                }
67            }
68            _ => {}
69        }
70        return true;
71    }
72
73    if client.state.show_equip_menu {
74        match key.code {
75            UiKeyCode::Char('p') | UiKeyCode::Esc => {
76                client.toggle_equip_menu();
77            }
78            UiKeyCode::Up | UiKeyCode::Char('k') => {
79                if client.state.equip_menu_index > 0 {
80                    client.state.equip_menu_index -= 1;
81                }
82            }
83            UiKeyCode::Down | UiKeyCode::Char('j') => {
84                let n = flatland_client_lib::equip_paperdoll_rows(&client.state).len();
85                if n > 0 {
86                    client.state.equip_menu_index = (client.state.equip_menu_index + 1).min(n - 1);
87                }
88            }
89            UiKeyCode::PageUp => {
90                let n = flatland_client_lib::equip_paperdoll_rows(&client.state).len();
91                client.state.equip_menu_index =
92                    flatland_client_lib::page_list_index(client.state.equip_menu_index, -1, n);
93            }
94            UiKeyCode::PageDown => {
95                let n = flatland_client_lib::equip_paperdoll_rows(&client.state).len();
96                client.state.equip_menu_index =
97                    flatland_client_lib::page_list_index(client.state.equip_menu_index, 1, n);
98            }
99            UiKeyCode::Enter => {
100                if let Err(err) = client.activate_equip_selection().await {
101                    client.state.push_log(format!("Equip: {err}"));
102                }
103            }
104            _ => {}
105        }
106        return true;
107    }
108
109    if client.state.show_inventory_menu {
110        if client.state.show_rename_prompt {
111            match key.code {
112                UiKeyCode::Esc => client.cancel_rename_prompt(),
113                UiKeyCode::Enter => {
114                    if let Err(err) = client.confirm_rename_prompt().await {
115                        client.state.push_log(format!("Rename: {err}"));
116                    }
117                }
118                UiKeyCode::Backspace => {
119                    client.state.rename_buffer.pop();
120                }
121                UiKeyCode::Char(c) => {
122                    if client.state.rename_buffer.len() < 32 {
123                        client.state.rename_buffer.push(c);
124                    }
125                }
126                _ => {}
127            }
128        } else if client.state.show_destroy_picker {
129            match key.code {
130                UiKeyCode::Esc => {
131                    if client.state.destroy_confirm_pending {
132                        client.cancel_destroy_confirm();
133                    } else {
134                        client.close_destroy_picker();
135                    }
136                }
137                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
138                    if !client.state.destroy_confirm_pending {
139                        client.destroy_picker_adjust_quantity(-1);
140                    }
141                }
142                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
143                    if !client.state.destroy_confirm_pending {
144                        client.destroy_picker_adjust_quantity(1);
145                    }
146                }
147                UiKeyCode::Char('a') => {
148                    if !client.state.destroy_confirm_pending {
149                        client.destroy_picker_set_quantity_max();
150                    }
151                }
152                UiKeyCode::Enter => {
153                    if let Err(err) = client.activate_inventory_selection().await {
154                        client.state.push_log(format!("Destroy failed: {err}"));
155                    }
156                }
157                _ => {}
158            }
159        } else if client.state.show_grant_picker {
160            let filter_focused = client
161                .state
162                .grant_picker
163                .as_ref()
164                .map(|p| p.filter_focused)
165                .unwrap_or(false);
166            match key.code {
167                UiKeyCode::Esc => {
168                    if !client.clear_or_blur_inventory_filter() {
169                        client.close_grant_picker();
170                    }
171                }
172                UiKeyCode::PageUp => client.inventory_menu_page(-1),
173                UiKeyCode::PageDown => client.inventory_menu_page(1),
174                UiKeyCode::Char('/') if !filter_focused => client.focus_inventory_filter(),
175                UiKeyCode::Backspace if filter_focused => client.inventory_filter_backspace(),
176                UiKeyCode::Char(c) if filter_focused => client.append_inventory_filter_char(c),
177                UiKeyCode::Up | UiKeyCode::Char('k') if !filter_focused => {
178                    client.inventory_menu_move(-1)
179                }
180                UiKeyCode::Down | UiKeyCode::Char('j') if !filter_focused => {
181                    client.inventory_menu_move(1)
182                }
183                UiKeyCode::Enter if !filter_focused => {
184                    if let Err(err) = client.activate_inventory_selection().await {
185                        client.state.push_log(format!("Grant failed: {err}"));
186                    }
187                }
188                _ => {}
189            }
190        } else if client.state.show_move_picker {
191            let filter_focused = client
192                .state
193                .move_picker
194                .as_ref()
195                .map(|p| p.filter_focused)
196                .unwrap_or(false);
197            match key.code {
198                UiKeyCode::Esc => {
199                    if !client.clear_or_blur_inventory_filter() {
200                        client.close_move_picker();
201                    }
202                }
203                UiKeyCode::PageUp => client.inventory_menu_page(-1),
204                UiKeyCode::PageDown => client.inventory_menu_page(1),
205                UiKeyCode::Char('/') if !filter_focused => client.focus_inventory_filter(),
206                UiKeyCode::Backspace if filter_focused => client.inventory_filter_backspace(),
207                UiKeyCode::Char(c) if filter_focused => client.append_inventory_filter_char(c),
208                UiKeyCode::Up | UiKeyCode::Char('k') if !filter_focused => {
209                    client.inventory_menu_move(-1)
210                }
211                UiKeyCode::Down | UiKeyCode::Char('j') if !filter_focused => {
212                    client.inventory_menu_move(1)
213                }
214                UiKeyCode::Char('[') | UiKeyCode::Char('-') if !filter_focused => {
215                    client.move_picker_adjust_quantity(-1);
216                }
217                UiKeyCode::Char(']') | UiKeyCode::Char('=') if !filter_focused => {
218                    client.move_picker_adjust_quantity(1);
219                }
220                UiKeyCode::Char('a') if !filter_focused => client.move_picker_set_quantity_max(),
221                UiKeyCode::Enter if !filter_focused => {
222                    if let Err(err) = client.activate_inventory_selection().await {
223                        client.state.push_log(format!("Move failed: {err}"));
224                    }
225                }
226                _ => {}
227            }
228        } else if client.state.inventory_filter_focused {
229            match key.code {
230                UiKeyCode::Esc => {
231                    let _ = client.clear_or_blur_inventory_filter();
232                }
233                UiKeyCode::Enter => {
234                    client.state.inventory_filter_focused = false;
235                }
236                UiKeyCode::Backspace => client.inventory_filter_backspace(),
237                UiKeyCode::Char(c) => client.append_inventory_filter_char(c),
238                _ => {}
239            }
240        } else {
241            match key.code {
242                UiKeyCode::Esc => {
243                    if !client.clear_or_blur_inventory_filter() {
244                        client.close_inventory_menu();
245                    }
246                }
247                UiKeyCode::Char('b') => client.close_inventory_menu(),
248                UiKeyCode::Tab => client.cycle_inventory_tab(true),
249                UiKeyCode::BackTab => client.cycle_inventory_tab(false),
250                UiKeyCode::PageUp => client.inventory_menu_page(-1),
251                UiKeyCode::PageDown => client.inventory_menu_page(1),
252                UiKeyCode::Char('/') => client.focus_inventory_filter(),
253                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
254                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
255                UiKeyCode::Enter => {
256                    if let Err(err) = client.activate_inventory_selection().await {
257                        client.state.push_log(format!("{err}"));
258                    }
259                }
260                UiKeyCode::Char('m') => {
261                    if let Err(err) = client.open_move_picker() {
262                        client.state.push_log(format!("{err}"));
263                    }
264                }
265                UiKeyCode::Char('e') => {
266                    if let Err(err) = client.use_selected_consumable().await {
267                        client.state.push_log(format!("Use: {err}"));
268                    }
269                }
270                UiKeyCode::Char('n') => {
271                    if let Err(err) = client.open_rename_prompt() {
272                        client.state.push_log(format!("{err}"));
273                    }
274                }
275                UiKeyCode::Char('d') => {
276                    if let Err(err) = client.drop_selected().await {
277                        client.state.push_log(format!("Drop: {err}"));
278                    }
279                }
280                UiKeyCode::Char('x') => {
281                    if let Err(err) = client.open_destroy_picker() {
282                        client.state.push_log(format!("Destroy: {err}"));
283                    }
284                }
285                UiKeyCode::Char('l') => {
286                    if let Err(err) = client.toggle_chest_lock_for_selection().await {
287                        client.state.push_log(format!("Lock: {err}"));
288                    }
289                }
290                UiKeyCode::Char('g') => {
291                    if let Err(err) = client.give_selected_inventory_to_worker().await {
292                        client.state.push_log(format!("Give: {err}"));
293                    }
294                }
295                UiKeyCode::Char('u') => {
296                    if let Err(err) = client.unequip_mainhand().await {
297                        client.state.push_log(format!("Unequip: {err}"));
298                    }
299                }
300                _ => {}
301            }
302        }
303        return true;
304    }
305
306    if client.state.show_keychain_menu {
307        match key.code {
308            UiKeyCode::Esc | UiKeyCode::Char(',') => client.close_keychain_menu(),
309            UiKeyCode::Up | UiKeyCode::Char('w') | UiKeyCode::Char('k') => {
310                client.keychain_menu_move(-1);
311            }
312            UiKeyCode::Down | UiKeyCode::Char('s') | UiKeyCode::Char('j') => {
313                client.keychain_menu_move(1);
314            }
315            UiKeyCode::PageUp => client.keychain_menu_page(-1),
316            UiKeyCode::PageDown => client.keychain_menu_page(1),
317            UiKeyCode::Enter => {
318                if let Err(err) = client.activate_keychain_selection().await {
319                    client.state.push_log(format!("Keychain: {err}"));
320                }
321            }
322            _ => {}
323        }
324        return true;
325    }
326
327    if client.state.show_craft_menu {
328        match key.code {
329            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
330                client.close_craft_menu()
331            }
332            UiKeyCode::Up | UiKeyCode::Char('k') => client.craft_menu_move(-1),
333            UiKeyCode::Down | UiKeyCode::Char('j') => client.craft_menu_move(1),
334            UiKeyCode::PageUp => client.craft_menu_page(-1),
335            UiKeyCode::PageDown => client.craft_menu_page(1),
336            UiKeyCode::Char('-') => client.craft_batch_adjust_quantity(-1),
337            UiKeyCode::Char('=') => client.craft_batch_adjust_quantity(1),
338            UiKeyCode::Char('a') => client.craft_batch_set_max(),
339            UiKeyCode::Enter => {
340                if let Err(err) = client.craft_menu_selection().await {
341                    client.state.push_log(format!("Craft failed: {err}"));
342                }
343            }
344            _ => {}
345        }
346        return true;
347    }
348
349    if client.state.show_quest_offer {
350        match key.code {
351            UiKeyCode::Esc => client.quest_offer_decline(),
352            UiKeyCode::Enter => {
353                if let Err(err) = client.quest_offer_accept().await {
354                    client.state.push_log(format!("Quest: {err}"));
355                }
356            }
357            _ => {}
358        }
359        return true;
360    }
361
362    if client.state.show_npc_chat {
363        match key.code {
364            UiKeyCode::Esc => {
365                if let Err(err) = client.npc_talk_close().await {
366                    client.state.push_log(format!("Talk close failed: {err}"));
367                }
368            }
369            UiKeyCode::Enter => {
370                if let Err(err) = client.npc_talk_send().await {
371                    client.state.push_log(format!("Talk failed: {err}"));
372                }
373            }
374            UiKeyCode::Backspace => {
375                if let Some(chat) = client.state.npc_chat.as_mut() {
376                    chat.input.pop();
377                }
378            }
379            UiKeyCode::Char(c) => {
380                if let Some(chat) = client.state.npc_chat.as_mut() {
381                    if !chat.pending && chat.input.len() < 300 {
382                        chat.input.push(c);
383                    }
384                }
385            }
386            _ => {}
387        }
388        return true;
389    }
390
391    if client.state.show_npc_verb_menu {
392        match key.code {
393            UiKeyCode::Esc => {
394                client.state.show_npc_verb_menu = false;
395                client.state.npc_verb_target = None;
396            }
397            UiKeyCode::Up | UiKeyCode::Char('k') => {
398                if client.state.npc_verb_index > 0 {
399                    client.state.npc_verb_index -= 1;
400                }
401            }
402            UiKeyCode::Down | UiKeyCode::Char('j') => {
403                let max = client.npc_verb_options().len().saturating_sub(1);
404                if client.state.npc_verb_index < max {
405                    client.state.npc_verb_index += 1;
406                }
407            }
408            UiKeyCode::Enter => {
409                if let Err(err) = client.confirm_npc_verb().await {
410                    client.state.push_log(format!("Interact failed: {err}"));
411                }
412            }
413            _ => {}
414        }
415        return true;
416    }
417
418    // Trade request accept/decline — not while typing chat.
419    if client.state.social_chat.pending_trade.is_some()
420        && !client.state.social_chat.input_focused
421        && !client.state.social_chat.picking_stone
422        && matches!(key.code, UiKeyCode::Char('y' | 'Y' | 'n' | 'N'))
423    {
424        let accept = matches!(key.code, UiKeyCode::Char('y' | 'Y'));
425        if let Err(err) = client.respond_pending_trade(accept).await {
426            client.state.push_log(format!("Trade respond failed: {err}"));
427        }
428        return true;
429    }
430
431    if client.state.player_verbs.open {
432        let opts = flatland_client_lib::PlayerVerbState::options();
433        match key.code {
434            UiKeyCode::Esc => client.state.player_verbs.close(),
435            UiKeyCode::Up | UiKeyCode::Char('k') => {
436                if client.state.player_verbs.index > 0 {
437                    client.state.player_verbs.index -= 1;
438                }
439            }
440            UiKeyCode::Down | UiKeyCode::Char('j') => {
441                let max = opts.len().saturating_sub(1);
442                if client.state.player_verbs.index < max {
443                    client.state.player_verbs.index += 1;
444                }
445            }
446            UiKeyCode::Enter => {
447                if let Err(err) = client.confirm_player_verb().await {
448                    client.state.push_log(format!("Interact failed: {err}"));
449                }
450            }
451            _ => {}
452        }
453        return true;
454    }
455
456    // Whisper-stone contact picker lives in the CHAT column (`g`).
457    if client.state.social_chat.picking_stone {
458        let contacts =
459            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
460        match key.code {
461            UiKeyCode::Esc => {
462                client.state.social_chat.picking_stone = false;
463            }
464            UiKeyCode::Up | UiKeyCode::Char('k') => {
465                if client.state.social_chat.stone_pick_index > 0 {
466                    client.state.social_chat.stone_pick_index -= 1;
467                }
468            }
469            UiKeyCode::Down | UiKeyCode::Char('j') => {
470                let max = contacts.len().saturating_sub(1);
471                if client.state.social_chat.stone_pick_index < max {
472                    client.state.social_chat.stone_pick_index += 1;
473                }
474            }
475            UiKeyCode::Enter => {
476                if let Some(c) = contacts.get(client.state.social_chat.stone_pick_index) {
477                    if !c.blank {
478                        if let Some(peer) = client
479                            .state
480                            .entities
481                            .iter()
482                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
483                        {
484                            client
485                                .state
486                                .social_chat
487                                .focus_stone(peer.id, &c.peer_label);
488                        } else {
489                            client.state.social_chat.push_system(format!(
490                                "{} is not online in this region",
491                                c.peer_label
492                            ));
493                            client.state.social_chat.picking_stone = false;
494                        }
495                    }
496                }
497            }
498            _ => {}
499        }
500        return true;
501    }
502
503    if client.state.trade_ui.panel.is_some() {
504        // Quantity entry for presenting a stack.
505        if client.state.trade_ui.qty_entry.is_some() {
506            match key.code {
507                UiKeyCode::Esc => {
508                    client.state.trade_ui.qty_entry = None;
509                    client.state.trade_ui.picking_inventory = true;
510                }
511                UiKeyCode::Enter => {
512                    if let Err(err) = client.trade_confirm_qty_or_present().await {
513                        client.state.push_log(format!("Present failed: {err}"));
514                    }
515                }
516                UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
517                    client.state.trade_ui.adjust_qty(-1);
518                }
519                UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
520                    client.state.trade_ui.adjust_qty(1);
521                }
522                UiKeyCode::Char('a') | UiKeyCode::Char('A') => {
523                    client.state.trade_ui.set_qty_all();
524                }
525                UiKeyCode::Backspace => client.state.trade_ui.qty_backspace(),
526                UiKeyCode::Char(c) if c.is_ascii_digit() => {
527                    client.state.trade_ui.append_qty_digit(c);
528                }
529                _ => {}
530            }
531            return true;
532        }
533        match key.code {
534            UiKeyCode::Esc => {
535                if client.state.trade_ui.picking_inventory {
536                    client.state.trade_ui.picking_inventory = false;
537                } else if let Err(err) = client.trade_cancel().await {
538                    client.state.push_log(format!("Trade cancel failed: {err}"));
539                }
540            }
541            UiKeyCode::Char('r') => {
542                let ready = client
543                    .state
544                    .trade_ui
545                    .panel
546                    .as_ref()
547                    .map(|p| !p.i_ready)
548                    .unwrap_or(true);
549                if let Err(err) = client.trade_set_ready(ready).await {
550                    client.state.push_log(format!("Trade ready failed: {err}"));
551                }
552            }
553            UiKeyCode::Char('p') => {
554                client.state.trade_ui.picking_inventory = !client.state.trade_ui.picking_inventory;
555                client.state.trade_ui.qty_entry = None;
556            }
557            UiKeyCode::Up | UiKeyCode::Char('k') => {
558                if client.state.trade_ui.picking_inventory {
559                    if client.state.trade_ui.inventory_index > 0 {
560                        client.state.trade_ui.inventory_index -= 1;
561                    }
562                } else if client.state.trade_ui.select_index > 0 {
563                    client.state.trade_ui.select_index -= 1;
564                }
565            }
566            UiKeyCode::Down | UiKeyCode::Char('j') => {
567                if client.state.trade_ui.picking_inventory {
568                    let max = client.state.inventory_stacks.len().saturating_sub(1);
569                    if client.state.trade_ui.inventory_index < max {
570                        client.state.trade_ui.inventory_index += 1;
571                    }
572                }
573            }
574            UiKeyCode::Enter => {
575                if client.state.trade_ui.picking_inventory {
576                    if let Err(err) = client.trade_confirm_qty_or_present().await {
577                        client.state.push_log(format!("Present failed: {err}"));
578                    }
579                }
580            }
581            _ => {}
582        }
583        return true;
584    }
585
586    if client.state.whisper_pouch_ui.open {
587        let contacts =
588            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
589        match key.code {
590            UiKeyCode::Esc => client.state.whisper_pouch_ui.open = false,
591            UiKeyCode::Up | UiKeyCode::Char('k') => {
592                if client.state.whisper_pouch_ui.index > 0 {
593                    client.state.whisper_pouch_ui.index -= 1;
594                }
595            }
596            UiKeyCode::Down | UiKeyCode::Char('j') => {
597                let max = contacts.len().saturating_sub(1);
598                if client.state.whisper_pouch_ui.index < max {
599                    client.state.whisper_pouch_ui.index += 1;
600                }
601            }
602            UiKeyCode::Enter => {
603                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
604                    if !c.blank {
605                        // Resolve online peer by label match for stone chat.
606                        if let Some(peer) = client
607                            .state
608                            .entities
609                            .iter()
610                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
611                        {
612                            client
613                                .state
614                                .social_chat
615                                .open_stone(peer.id, &c.peer_label);
616                            client.state.whisper_pouch_ui.open = false;
617                        } else {
618                            client.state.push_log(format!(
619                                "{} is not online in this region",
620                                c.peer_label
621                            ));
622                        }
623                    }
624                }
625            }
626            UiKeyCode::Char('x') | UiKeyCode::Char('d') => {
627                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
628                    let id = c.instance_id;
629                    if let Err(err) = client.destroy_whisper_stone(id).await {
630                        client.state.push_log(format!("Destroy stone failed: {err}"));
631                    }
632                }
633            }
634            _ => {}
635        }
636        return true;
637    }
638
639    if client.state.bank_panel.is_some() {
640        match &client.state.bank_ui_mode {
641            flatland_client_lib::BankUiMode::Menu => match key.code {
642                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
643                    if let Err(err) = client.close_bank_panel().await {
644                        client.state.push_log(format!("Bank close failed: {err}"));
645                    }
646                }
647                UiKeyCode::Up | UiKeyCode::Char('k') => client.bank_menu_move(-1),
648                UiKeyCode::Down | UiKeyCode::Char('j') => client.bank_menu_move(1),
649                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
650                    if let Err(err) = client.confirm_bank_menu().await {
651                        client.state.push_log(format!("Bank action failed: {err}"));
652                    }
653                }
654                UiKeyCode::Char('d') => {
655                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::DepositAmount {
656                        input: String::new(),
657                    };
658                }
659                UiKeyCode::Char('w') => {
660                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::WithdrawAmount {
661                        input: String::new(),
662                    };
663                }
664                UiKeyCode::Char('t') => {
665                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::TransferName {
666                        input: String::new(),
667                    };
668                }
669                _ => {}
670            },
671            _ => match key.code {
672                UiKeyCode::Esc | UiKeyCode::Char('[') => client.bank_transfer_back(),
673                UiKeyCode::Enter => {
674                    if let Err(err) = client.confirm_bank_menu().await {
675                        client.state.push_log(format!("Bank action failed: {err}"));
676                    }
677                }
678                UiKeyCode::Backspace => client.bank_transfer_backspace(),
679                UiKeyCode::Char(c) => client.bank_transfer_append_char(c),
680                _ => {}
681            },
682        }
683        return true;
684    }
685
686    if client.state.storage_panel.is_some() {
687        match &client.state.storage_ui_mode {
688            flatland_client_lib::StorageUiMode::Menu => match key.code {
689                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
690                    if let Err(err) = client.close_storage_panel().await {
691                        client.state.push_log(format!("Storage close failed: {err}"));
692                    }
693                }
694                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_menu_move(-1),
695                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_menu_move(1),
696                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
697                    if let Err(err) = client.confirm_storage_menu().await {
698                        client.state.push_log(format!("Storage action failed: {err}"));
699                    }
700                }
701                UiKeyCode::Char('s') => {
702                    let opts = client.state.storage_store_options();
703                    if opts.is_empty() {
704                        client.state.push_log("Nothing loose to store.");
705                    } else {
706                        client.state.storage_ui_mode =
707                            flatland_client_lib::StorageUiMode::StorePick { index: 0 };
708                    }
709                }
710                UiKeyCode::Char('t') => {
711                    let opts = client.state.storage_vault_options();
712                    if opts.is_empty() {
713                        client.state.push_log("Vault is empty.");
714                    } else {
715                        client.state.storage_ui_mode =
716                            flatland_client_lib::StorageUiMode::TakePick { index: 0 };
717                    }
718                }
719                _ => {}
720            },
721            flatland_client_lib::StorageUiMode::StoreAmount { .. }
722            | flatland_client_lib::StorageUiMode::TakeAmount { .. }
723            | flatland_client_lib::StorageUiMode::ShipAmount { .. } => match key.code {
724                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
725                UiKeyCode::Enter => {
726                    if let Err(err) = client.confirm_storage_menu().await {
727                        client.state.push_log(format!("Storage action failed: {err}"));
728                    }
729                }
730                UiKeyCode::Backspace => client.storage_amount_backspace(),
731                UiKeyCode::Char(c) => client.storage_amount_append_char(c),
732                _ => {}
733            },
734            _ => match key.code {
735                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
736                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_pick_move(-1),
737                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_pick_move(1),
738                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
739                    if let Err(err) = client.confirm_storage_menu().await {
740                        client.state.push_log(format!("Storage action failed: {err}"));
741                    }
742                }
743                _ => {}
744            },
745        }
746        return true;
747    }
748
749    if client.state.show_shop_menu {
750        match key.code {
751            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
752                if let Err(err) = client.back_from_shop_menu().await {
753                    client.state.push_log(format!("Shop close failed: {err}"));
754                }
755            }
756            UiKeyCode::Tab => client.shop_tab_toggle(),
757            UiKeyCode::Up | UiKeyCode::Char('k') => client.shop_menu_move(-1),
758            UiKeyCode::Down | UiKeyCode::Char('j') => client.shop_menu_move(1),
759            UiKeyCode::PageUp => client.shop_menu_page(-1),
760            UiKeyCode::PageDown => client.shop_menu_page(1),
761            UiKeyCode::Char('-') => client.shop_quantity_adjust(-1),
762            UiKeyCode::Char('=') => client.shop_quantity_adjust(1),
763            UiKeyCode::Char('a') => client.shop_quantity_set_max(),
764            UiKeyCode::Enter => {
765                if let Err(err) = client.shop_confirm().await {
766                    client.state.push_log(format!("Trade failed: {err}"));
767                }
768            }
769            _ => {}
770        }
771        return true;
772    }
773
774    if client.state.show_quest_menu {
775        match key.code {
776            UiKeyCode::Esc => {
777                if client.state.quest_withdraw_confirm {
778                    client.state.quest_withdraw_confirm = false;
779                } else {
780                    client.state.show_quest_menu = false;
781                }
782            }
783            UiKeyCode::Up | UiKeyCode::Char('k') => client.quest_menu_move(-1),
784            UiKeyCode::Down | UiKeyCode::Char('j') => client.quest_menu_move(1),
785            UiKeyCode::PageUp => client.quest_menu_page(-1),
786            UiKeyCode::PageDown => client.quest_menu_page(1),
787            UiKeyCode::Char('x') => client.quest_request_withdraw(),
788            UiKeyCode::Enter => {
789                if let Err(err) = client.quest_confirm_action().await {
790                    client.state.push_log(format!("Quest: {err}"));
791                }
792            }
793            _ => {}
794        }
795        return true;
796    }
797
798    if client.state.worker_route_editor.is_some() {
799        let at_root = client.re_at_root_sheet();
800        let sheet_index = client.re_sheet_index();
801        if at_root {
802            // Root: the ordered stop list.
803            match key.code {
804                UiKeyCode::Esc => client.close_worker_route_editor(),
805                UiKeyCode::Char('s') => {
806                    if let Err(err) = client.worker_route_editor_save().await {
807                        client.state.push_log(format!("Route: {err}"));
808                    }
809                }
810                UiKeyCode::Down => {
811                    if key.modifiers.contains_control() {
812                        client.worker_route_editor_move_selected(1);
813                    } else {
814                        client.worker_route_editor_select(1);
815                    }
816                }
817                UiKeyCode::Up => {
818                    if key.modifiers.contains_control() {
819                        client.worker_route_editor_move_selected(-1);
820                    } else {
821                        client.worker_route_editor_select(-1);
822                    }
823                }
824                // Select: vim j/k. Reorder: Ctrl+j = earlier (up), Ctrl+k = later (down)
825                // — matches "k moves down" and keeps arrow+Ctrl natural.
826                UiKeyCode::Char('j') => {
827                    if key.modifiers.contains_control() {
828                        client.worker_route_editor_move_selected(-1);
829                    } else {
830                        client.worker_route_editor_select(1);
831                    }
832                }
833                UiKeyCode::Char('k') => {
834                    if key.modifiers.contains_control() {
835                        client.worker_route_editor_move_selected(1);
836                    } else {
837                        client.worker_route_editor_select(-1);
838                    }
839                }
840                UiKeyCode::Char('d') | UiKeyCode::Delete => {
841                    client.worker_route_editor_delete_selected()
842                }
843                UiKeyCode::Char('x') => client.worker_route_editor_clear_stops(),
844                UiKeyCode::Char('a') => client.re_open_add_menu(),
845                UiKeyCode::Char('l') => client.re_open_bed_picker(),
846                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
847                UiKeyCode::Enter => client.re_edit_selected_stop(),
848                _ => {}
849            }
850        } else {
851            // Inside a setup sheet: uniform picker keys.
852            match key.code {
853                UiKeyCode::Esc => client.re_sheet_back(),
854                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
855                UiKeyCode::Char('j') | UiKeyCode::Down => client.re_sheet_move(1),
856                UiKeyCode::Char('k') | UiKeyCode::Up => client.re_sheet_move(-1),
857                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
858                    client.re_sheet_row_activate(sheet_index)
859                }
860                UiKeyCode::Char('[') | UiKeyCode::Char('-') => client.re_sheet_adjust(-1),
861                UiKeyCode::Char(']') | UiKeyCode::Char('=') => client.re_sheet_adjust(1),
862                _ => {}
863            }
864        }
865        return true;
866    }
867
868    if client.state.show_worker_give_picker {
869        match key.code {
870            UiKeyCode::Esc => client.close_worker_give_picker(),
871            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_picker_move(-1),
872            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_picker_move(1),
873            UiKeyCode::Enter => {
874                if let Err(err) = client.confirm_worker_give_picker().await {
875                    client.state.push_log(format!("Give: {err}"));
876                }
877            }
878            _ => {}
879        }
880        return true;
881    }
882
883    if client.state.show_worker_give_target_picker {
884        match key.code {
885            UiKeyCode::Esc => client.close_worker_give_target_picker(),
886            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_target_picker_move(-1),
887            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_target_picker_move(1),
888            UiKeyCode::Enter => {
889                if let Err(err) = client.confirm_worker_give_target_picker().await {
890                    client.state.push_log(format!("Give: {err}"));
891                }
892            }
893            _ => {}
894        }
895        return true;
896    }
897
898    if client.state.show_worker_take_picker {
899        match key.code {
900            UiKeyCode::Esc => client.close_worker_take_picker(),
901            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_take_picker_move(-1),
902            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_take_picker_move(1),
903            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
904                client.worker_take_picker_adjust_quantity(-1)
905            }
906            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
907                client.worker_take_picker_adjust_quantity(1)
908            }
909            UiKeyCode::Char('a') => client.worker_take_picker_set_quantity_max(),
910            UiKeyCode::Enter => {
911                if let Err(err) = client.confirm_worker_take_picker().await {
912                    client.state.push_log(format!("Take: {err}"));
913                }
914            }
915            _ => {}
916        }
917        return true;
918    }
919
920    if client.state.show_worker_teach_picker {
921        match key.code {
922            UiKeyCode::Esc => client.close_worker_teach_picker(),
923            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_teach_picker_move(-1),
924            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_teach_picker_move(1),
925            UiKeyCode::Enter => {
926                if let Err(err) = client.confirm_worker_teach_picker().await {
927                    client.state.push_log(format!("Teach: {err}"));
928                }
929            }
930            _ => {}
931        }
932        return true;
933    }
934
935    if client.state.show_workers_menu {
936        match key.code {
937            UiKeyCode::Esc => client.state.show_workers_menu = false,
938            UiKeyCode::Up | UiKeyCode::Char('k') => client.workers_menu_move(-1),
939            UiKeyCode::Down | UiKeyCode::Char('j') => client.workers_menu_move(1),
940            UiKeyCode::PageUp => client.workers_menu_page(-1),
941            UiKeyCode::PageDown => client.workers_menu_page(1),
942            UiKeyCode::Char('d') => {
943                if let Err(err) = client.workers_dismiss_selected().await {
944                    client.state.push_log(format!("Worker: {err}"));
945                }
946            }
947            UiKeyCode::Char('r') => {
948                if let Err(err) = client.hire_worker_laborer().await {
949                    client.state.push_log(format!("Hire: {err}"));
950                }
951            }
952            UiKeyCode::Char('e') => {
953                if let Err(err) = client.open_worker_route_editor_for_selected() {
954                    client.state.push_log(format!("Route: {err}"));
955                }
956            }
957            UiKeyCode::Char('n') => {
958                if let Err(err) = client.open_worker_rename() {
959                    client.state.push_log(format!("Rename: {err}"));
960                }
961            }
962            UiKeyCode::Char('g') => {
963                if let Err(err) = client.open_worker_give_picker() {
964                    client.state.push_log(format!("Give: {err}"));
965                }
966            }
967            UiKeyCode::Char('i') => {
968                if let Err(err) = client.open_worker_take_picker() {
969                    client.state.push_log(format!("Take: {err}"));
970                }
971            }
972            UiKeyCode::Char('t') => {
973                if let Err(err) = client.open_worker_teach_picker() {
974                    client.state.push_log(format!("Teach: {err}"));
975                }
976            }
977            UiKeyCode::Char('c') => client.toggle_workers_menu_compact(),
978            UiKeyCode::Enter => {
979                if let Err(err) = client.workers_confirm_action().await {
980                    client.state.push_log(format!("Worker: {err}"));
981                }
982            }
983            _ => {}
984        }
985        return true;
986    }
987
988    false
989}
990
991pub async fn dispatch_action<S: PlayConnection>(
992    client: &mut GameClient<S>,
993    _keys: &ClientKeyBindings,
994    action: InputAction,
995    ctx: &mut ActionCtx<'_>,
996) {
997    match action {
998        InputAction::StartChat { whisper } => {
999            if whisper {
1000                // Stone contacts: pick inside CHAT column (no separate popup).
1001                client.state.whisper_pouch_ui.open = false;
1002                client.state.social_chat.picking_stone = true;
1003                client.state.social_chat.stone_pick_index = 0;
1004                client.state.social_chat.input_focused = false;
1005                client.state.social_chat.push_system(
1006                    "Whisper stones — ↑↓ select · Enter · Esc cancel",
1007                );
1008            } else {
1009                client.state.social_chat.focus_nearby();
1010            }
1011        }
1012        InputAction::Harvest => {
1013            if let Err(err) = client.harvest_nearest().await {
1014                client.state.push_log(format!("Harvest failed: {err}"));
1015            }
1016        }
1017        InputAction::Pickup => {
1018            if client.pickup_nearest().await.is_err() {
1019                if let Err(err) = client.pickup_nearest_container().await {
1020                    client.state.push_log(format!("Pickup: {err}"));
1021                }
1022            }
1023        }
1024        InputAction::Craft => client.open_craft_menu(),
1025        InputAction::Interact | InputAction::UseWorld => {
1026            if let Err(err) = client.use_nearest().await {
1027                client.state.push_log(format!("Use: {err}"));
1028            }
1029        }
1030        InputAction::TestDamage => {
1031            if let Err(err) = client.test_damage(25.0).await {
1032                client.state.push_log(format!("Damage failed: {err}"));
1033            }
1034        }
1035        InputAction::CycleCombatTarget { reverse } => {
1036            if let Err(err) = client.cycle_combat_target(reverse).await {
1037                client.state.push_log(format!("T1 target: {err}"));
1038            }
1039        }
1040        InputAction::CycleCombatTargetT2 { reverse } => {
1041            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
1042                client.state.push_log(format!("T2 target: {err}"));
1043            }
1044        }
1045        InputAction::AdvanceRotationT1 => {
1046            if let Err(err) = client.advance_rotation(1).await {
1047                client.state.push_log(format!("T1 step: {err}"));
1048            }
1049        }
1050        InputAction::AdvanceRotationT2 => {
1051            if let Err(err) = client.advance_rotation(2).await {
1052                client.state.push_log(format!("T2 step: {err}"));
1053            }
1054        }
1055        InputAction::ToggleAutoT1 => {
1056            if let Err(err) = client.toggle_auto_attack_slot(1).await {
1057                client.state.push_log(format!("T1 auto: {err}"));
1058            }
1059        }
1060        InputAction::ToggleAutoT2 => {
1061            if let Err(err) = client.toggle_auto_attack_slot(2).await {
1062                client.state.push_log(format!("T2 auto: {err}"));
1063            }
1064        }
1065        InputAction::ToggleLoadout => {
1066            client.state.show_rotation_editor = false;
1067            client.state.rotation_editor.reset();
1068            client.state.show_loadout_menu = !client.state.show_loadout_menu;
1069            if client.state.show_loadout_menu {
1070                client.state.loadout_focus_presets = true;
1071                client.state.loadout_hotbar_slot = client.state.loadout_hotbar_slot.clamp(1, 9);
1072                let ability_len = client.state.loadout_hotbar_choices().len();
1073                if ability_len > 0 {
1074                    client.state.loadout_ability_index =
1075                        client.state.loadout_ability_index.min(ability_len - 1);
1076                } else {
1077                    client.state.loadout_ability_index = 0;
1078                }
1079                let preset_len = client.state.rotation_presets.len();
1080                if preset_len > 0 {
1081                    client.state.loadout_menu_index =
1082                        client.state.loadout_menu_index.min(preset_len - 1);
1083                } else {
1084                    client.state.loadout_menu_index = 0;
1085                }
1086                *ctx.auto_nav = None;
1087                let _ = client.stop().await;
1088            }
1089        }
1090        InputAction::ToggleRotationEditor => {
1091            client.state.show_loadout_menu = false;
1092            if client.state.show_rotation_editor {
1093                client.state.show_rotation_editor = false;
1094                client.state.rotation_editor.reset();
1095            } else {
1096                client.state.show_rotation_editor = true;
1097                client.state.rotation_editor.reset();
1098                let max = client.state.rotation_presets.len();
1099                if max > 0 {
1100                    client.state.rotation_editor.list_index =
1101                        client.state.rotation_editor.list_index.min(max - 1);
1102                }
1103            }
1104            if client.state.show_rotation_editor {
1105                *ctx.auto_nav = None;
1106                let _ = client.stop().await;
1107            }
1108        }
1109        InputAction::LoadoutMenuUp => {
1110            if !client.state.show_loadout_menu {
1111                return;
1112            }
1113            if client.state.loadout_focus_presets {
1114                if client.state.loadout_menu_index > 0 {
1115                    client.state.loadout_menu_index -= 1;
1116                }
1117            } else if client.state.loadout_ability_index > 0 {
1118                client.state.loadout_ability_index -= 1;
1119            }
1120        }
1121        InputAction::LoadoutMenuDown => {
1122            if !client.state.show_loadout_menu {
1123                return;
1124            }
1125            if client.state.loadout_focus_presets {
1126                let max = client.state.rotation_presets.len();
1127                if max > 0 {
1128                    client.state.loadout_menu_index =
1129                        (client.state.loadout_menu_index + 1).min(max - 1);
1130                }
1131            } else {
1132                let max = client.state.loadout_hotbar_choices().len();
1133                if max > 0 {
1134                    client.state.loadout_ability_index =
1135                        (client.state.loadout_ability_index + 1).min(max - 1);
1136                }
1137            }
1138        }
1139        InputAction::LoadoutHotbarPrev => {
1140            if client.state.show_loadout_menu {
1141                let slot = client.state.loadout_hotbar_slot;
1142                client.state.loadout_hotbar_slot = if slot <= 1 { 9 } else { slot - 1 };
1143            }
1144        }
1145        InputAction::LoadoutHotbarNext => {
1146            if client.state.show_loadout_menu {
1147                let slot = client.state.loadout_hotbar_slot;
1148                client.state.loadout_hotbar_slot = if slot >= 9 { 1 } else { slot + 1 };
1149            }
1150        }
1151        InputAction::LoadoutToggleFocus => {
1152            if client.state.show_loadout_menu {
1153                client.state.loadout_focus_presets = !client.state.loadout_focus_presets;
1154            }
1155        }
1156        InputAction::LoadoutBindHotbar => {
1157            if !client.state.show_loadout_menu {
1158                return;
1159            }
1160            let slot = client.state.loadout_hotbar_slot;
1161            let binding = {
1162                let choices = client.state.loadout_hotbar_choices();
1163                choices
1164                    .get(client.state.loadout_ability_index)
1165                    .map(|c| c.binding.clone())
1166            };
1167            if let Some(binding) = binding {
1168                if let Err(err) = client.set_hotbar_slot(slot, Some(&binding)).await {
1169                    client.state.push_log(format!("Hotbar: {err}"));
1170                }
1171            } else {
1172                client.state.push_log("No ability/consumable selected to bind");
1173            }
1174        }
1175        InputAction::LoadoutClearHotbar => {
1176            if !client.state.show_loadout_menu {
1177                return;
1178            }
1179            let slot = client.state.loadout_hotbar_slot;
1180            if let Err(err) = client.set_hotbar_slot(slot, None).await {
1181                client.state.push_log(format!("Hotbar: {err}"));
1182            }
1183        }
1184        InputAction::LoadoutAssignT1 => {
1185            if !client.state.show_loadout_menu {
1186                return;
1187            }
1188            let preset = {
1189                let idx = client.state.loadout_menu_index;
1190                client.state.rotation_presets.get(idx).cloned()
1191            };
1192            if let Some(preset) = preset {
1193                let already = client
1194                    .state
1195                    .combat_slots
1196                    .iter()
1197                    .find(|s| s.slot_index == 1)
1198                    .and_then(|s| s.preset_id.as_deref())
1199                    == Some(preset.id.as_str());
1200                if already {
1201                    client.state.push_log(format!(
1202                        "T1 already uses {} (equip weapons from inventory — Weapon auto tracks mainhand)",
1203                        preset.label
1204                    ));
1205                } else if let Err(err) = client.assign_slot_preset(1, &preset.id).await {
1206                    client.state.push_log(format!("Loadout: {err}"));
1207                } else {
1208                    client
1209                        .state
1210                        .push_log(format!("T1 ← {}", preset.label));
1211                }
1212            }
1213        }
1214        InputAction::LoadoutAssignT2 => {
1215            if !client.state.show_loadout_menu {
1216                return;
1217            }
1218            let preset = {
1219                let idx = client.state.loadout_menu_index;
1220                client.state.rotation_presets.get(idx).cloned()
1221            };
1222            if let Some(preset) = preset {
1223                let already = client
1224                    .state
1225                    .combat_slots
1226                    .iter()
1227                    .find(|s| s.slot_index == 2)
1228                    .and_then(|s| s.preset_id.as_deref())
1229                    == Some(preset.id.as_str());
1230                if already {
1231                    client
1232                        .state
1233                        .push_log(format!("T2 already uses {}", preset.label));
1234                } else if let Err(err) = client.assign_slot_preset(2, &preset.id).await {
1235                    client.state.push_log(format!("Loadout: {err}"));
1236                } else {
1237                    client
1238                        .state
1239                        .push_log(format!("T2 ← {}", preset.label));
1240                }
1241            }
1242        }
1243        InputAction::CloseOverlay => {
1244            client.state.show_loadout_menu = false;
1245            client.state.show_rotation_editor = false;
1246            client.state.rotation_editor.reset();
1247        }
1248        InputAction::ClearCombatTarget => {
1249            if let Err(err) = client.clear_combat_target().await {
1250                client.state.push_log(format!("Target: {err}"));
1251            }
1252        }
1253        InputAction::ClearCombatTargetT2 => {
1254            if let Err(err) = client.clear_combat_target_slot(2).await {
1255                client.state.push_log(format!("T2 target: {err}"));
1256            }
1257        }
1258        InputAction::Dodge => {
1259            *ctx.auto_nav = None;
1260            if let Err(err) = client.dodge().await {
1261                client.state.push_log(format!("Dodge: {err}"));
1262            }
1263        }
1264        InputAction::Lunge => {
1265            *ctx.auto_nav = None;
1266            let _ = client.stop().await;
1267            if let Err(err) = client.lunge().await {
1268                client.state.push_log(format!("Lunge: {err}"));
1269            }
1270        }
1271        InputAction::DirectionalJump { forward, strafe } => {
1272            *ctx.auto_nav = None;
1273            let _ = client.stop().await;
1274            if let Err(err) = client.directional_jump(forward, strafe).await {
1275                client.state.push_log(format!("Jump: {err}"));
1276            }
1277        }
1278        InputAction::CastHotbar { slot } => {
1279            if let Err(err) = client.cast_hotbar_ability(slot).await {
1280                client.state.push_log(format!("Hotbar {slot}: {err}"));
1281            }
1282        }
1283        InputAction::ToggleBlock => {
1284            *ctx.block_active = !*ctx.block_active;
1285            if let Err(err) = client.set_block(*ctx.block_active).await {
1286                client.state.push_log(format!("Block: {err}"));
1287                *ctx.block_active = false;
1288            }
1289        }
1290        InputAction::ToggleStats => client.toggle_stats(),
1291        InputAction::ToggleEquip => client.toggle_equip_menu(),
1292        InputAction::CycleCharacterSheetTab => client.cycle_character_sheet_tab(),
1293        InputAction::LedgerPeriodDigit(c) => client.set_ledger_period_digit(c),
1294        InputAction::ToggleInventory => client.toggle_inventory_menu(),
1295        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
1296        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
1297        InputAction::ToggleWorkersMenu => client.toggle_workers_menu(),
1298        InputAction::QuestMenuUp => client.quest_menu_move(-1),
1299        InputAction::QuestMenuDown => client.quest_menu_move(1),
1300        InputAction::QuestWithdraw => client.quest_request_withdraw(),
1301        InputAction::RotationEditorBack => {
1302            let _ = client.back_on_esc();
1303        }
1304        InputAction::RotationEditorListUp
1305        | InputAction::RotationEditorListDown
1306        | InputAction::RotationEditorEdit
1307        | InputAction::RotationEditorNew
1308        | InputAction::RotationEditorDelete
1309        | InputAction::RotationEditorAddAbility
1310        | InputAction::RotationEditorRemoveAbility
1311        | InputAction::RotationEditorMoveAbilityUp
1312        | InputAction::RotationEditorMoveAbilityDown
1313        | InputAction::RotationEditorAbilityUp
1314        | InputAction::RotationEditorAbilityDown
1315        | InputAction::RotationEditorPickerUp
1316        | InputAction::RotationEditorPickerDown
1317        | InputAction::RotationEditorPickAbility
1318        | InputAction::RotationEditorRename
1319        | InputAction::RotationEditorConfirmLabel
1320        | InputAction::RotationEditorLabelBackspace
1321        | InputAction::RotationEditorLabelChar(_)
1322        | InputAction::RotationEditorSave => {
1323            handle_rotation_editor_action(client, action).await;
1324        }
1325        InputAction::None
1326        | InputAction::Quit
1327        | InputAction::ToggleHelp
1328        | InputAction::CycleHudView
1329        | InputAction::SubmitChat
1330        | InputAction::CancelChat
1331        | InputAction::ToggleSprintMode
1332        | InputAction::ToggleMapTarget
1333        | InputAction::ConfirmMapTarget
1334        | InputAction::CancelMapTarget
1335        | InputAction::MapTargetNudge { .. }
1336        | InputAction::CancelAutoNav
1337        | InputAction::StopMovement => {}
1338        InputAction::ToggleHudLog => {
1339            client.state.hud_log_hidden = !client.state.hud_log_hidden;
1340            let mut cfg = flatland_client_lib::ClientConfig::load();
1341            let _ = cfg.save_hud_log_hidden(client.state.hud_log_hidden);
1342            if client.state.hud_log_hidden {
1343                client.state.push_log("LOG hidden — press ' to show");
1344            } else {
1345                client.state.push_log("LOG shown — press ' to hide");
1346            }
1347        }
1348    }
1349}
1350
1351async fn handle_rotation_editor_action<S: PlayConnection>(
1352    client: &mut GameClient<S>,
1353    action: InputAction,
1354) {
1355    match action {
1356        InputAction::RotationEditorListUp => {
1357            if client.state.rotation_editor.list_index > 0 {
1358                client.state.rotation_editor.list_index -= 1;
1359            }
1360        }
1361        InputAction::RotationEditorListDown => {
1362            let max = client.state.rotation_presets.len();
1363            if max > 0 {
1364                client.state.rotation_editor.list_index =
1365                    (client.state.rotation_editor.list_index + 1).min(max - 1);
1366            }
1367        }
1368        InputAction::RotationEditorEdit => {
1369            let idx = client.state.rotation_editor.list_index;
1370            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
1371                client.state.rotation_editor.draft = Some(preset);
1372                client.state.rotation_editor.ability_index = 0;
1373                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1374            }
1375        }
1376        InputAction::RotationEditorNew => {
1377            let preset = next_custom_preset(&client.state.rotation_presets);
1378            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
1379            client.state.rotation_editor.draft = Some(preset);
1380            client.state.rotation_editor.ability_index = 0;
1381            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1382        }
1383        InputAction::RotationEditorDelete => {
1384            let idx = client.state.rotation_editor.list_index;
1385            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
1386            if let Some(id) = preset_id {
1387                if preset_deletable(&id) {
1388                    if let Err(err) = client.delete_rotation_preset(&id).await {
1389                        client.state.push_log(format!("Delete: {err}"));
1390                    } else {
1391                        let max = client.state.rotation_presets.len();
1392                        client.state.rotation_editor.list_index = if max == 0 {
1393                            0
1394                        } else {
1395                            client.state.rotation_editor.list_index.min(max - 1)
1396                        };
1397                    }
1398                } else {
1399                    client.state.push_log("Cannot delete built-in preset");
1400                }
1401            }
1402        }
1403        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
1404            RotationEditorMode::EditLabel => {
1405                client.state.rotation_editor.label_buffer.clear();
1406                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1407            }
1408            RotationEditorMode::PickAbility => {
1409                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1410            }
1411            RotationEditorMode::EditSequence => {
1412                client.state.rotation_editor.draft = None;
1413                client.state.rotation_editor.mode = RotationEditorMode::List;
1414            }
1415            RotationEditorMode::List => {}
1416        },
1417        InputAction::RotationEditorAddAbility => {
1418            client.state.rotation_editor.picker_index = 0;
1419            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
1420        }
1421        InputAction::RotationEditorRemoveAbility => {
1422            let idx = client.state.rotation_editor.ability_index;
1423            if let Some(draft) = &mut client.state.rotation_editor.draft {
1424                if idx < draft.abilities.len() {
1425                    draft.abilities.remove(idx);
1426                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
1427                        && client.state.rotation_editor.ability_index > 0
1428                    {
1429                        client.state.rotation_editor.ability_index -= 1;
1430                    }
1431                }
1432            }
1433        }
1434        InputAction::RotationEditorMoveAbilityUp => {
1435            let i = client.state.rotation_editor.ability_index;
1436            if let Some(draft) = &mut client.state.rotation_editor.draft {
1437                if i > 0 && i < draft.abilities.len() {
1438                    draft.abilities.swap(i, i - 1);
1439                    client.state.rotation_editor.ability_index -= 1;
1440                }
1441            }
1442        }
1443        InputAction::RotationEditorMoveAbilityDown => {
1444            let i = client.state.rotation_editor.ability_index;
1445            if let Some(draft) = &mut client.state.rotation_editor.draft {
1446                if i + 1 < draft.abilities.len() {
1447                    draft.abilities.swap(i, i + 1);
1448                    client.state.rotation_editor.ability_index += 1;
1449                }
1450            }
1451        }
1452        InputAction::RotationEditorAbilityUp => {
1453            if client.state.rotation_editor.ability_index > 0 {
1454                client.state.rotation_editor.ability_index -= 1;
1455            }
1456        }
1457        InputAction::RotationEditorAbilityDown => {
1458            if let Some(draft) = &client.state.rotation_editor.draft {
1459                if !draft.abilities.is_empty() {
1460                    client.state.rotation_editor.ability_index =
1461                        (client.state.rotation_editor.ability_index + 1)
1462                            .min(draft.abilities.len() - 1);
1463                }
1464            }
1465        }
1466        InputAction::RotationEditorPickerUp => {
1467            if client.state.rotation_editor.picker_index > 0 {
1468                client.state.rotation_editor.picker_index -= 1;
1469            }
1470        }
1471        InputAction::RotationEditorPickerDown => {
1472            let choices = editor_ability_choices(
1473                &client.state.known_abilities,
1474                &client.state.weapon_ability_id,
1475            );
1476            if !choices.is_empty() {
1477                client.state.rotation_editor.picker_index =
1478                    (client.state.rotation_editor.picker_index + 1).min(choices.len() - 1);
1479            }
1480        }
1481        InputAction::RotationEditorPickAbility => {
1482            let choices = editor_ability_choices(
1483                &client.state.known_abilities,
1484                &client.state.weapon_ability_id,
1485            );
1486            let pick = client.state.rotation_editor.picker_index;
1487            if let Some(ability) = choices.get(pick) {
1488                if let Some(draft) = &mut client.state.rotation_editor.draft {
1489                    let max = client.state.max_abilities_per_rotation.max(1) as usize;
1490                    if draft.abilities.len() >= max {
1491                        client
1492                            .state
1493                            .push_log(format!("Rotation full (max {max} from INT+WIS)"));
1494                    } else {
1495                        draft.abilities.push(ability.clone());
1496                        client.state.rotation_editor.ability_index =
1497                            draft.abilities.len().saturating_sub(1);
1498                    }
1499                }
1500                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1501            }
1502        }
1503        InputAction::RotationEditorRename => {
1504            if let Some(draft) = &client.state.rotation_editor.draft {
1505                client.state.rotation_editor.label_buffer = draft.label.clone();
1506                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
1507            }
1508        }
1509        InputAction::RotationEditorConfirmLabel => {
1510            let label = client.state.rotation_editor.label_buffer.trim().to_string();
1511            if label.is_empty() {
1512                client.state.push_log("Label cannot be empty");
1513            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
1514                draft.label = label;
1515                client.state.rotation_editor.label_buffer.clear();
1516                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1517            }
1518        }
1519        InputAction::RotationEditorLabelBackspace => {
1520            client.state.rotation_editor.label_buffer.pop();
1521        }
1522        InputAction::RotationEditorLabelChar(c) => {
1523            if client.state.rotation_editor.label_buffer.len() < 32 {
1524                client.state.rotation_editor.label_buffer.push(c);
1525            }
1526        }
1527        InputAction::RotationEditorSave => {
1528            let draft = match client.state.rotation_editor.draft.clone() {
1529                Some(d) => d,
1530                None => return,
1531            };
1532            if draft.abilities.is_empty() {
1533                client.state.push_log("Rotation needs at least one ability");
1534                return;
1535            }
1536            let saved_id = draft.id.clone();
1537            if let Err(err) = client.upsert_rotation_preset(draft).await {
1538                client.state.push_log(format!("Save: {err}"));
1539            } else {
1540                client.state.rotation_editor.mode = RotationEditorMode::List;
1541                client.state.rotation_editor.draft = None;
1542                if let Some(i) = client
1543                    .state
1544                    .rotation_presets
1545                    .iter()
1546                    .position(|p| p.id == saved_id)
1547                {
1548                    client.state.rotation_editor.list_index = i;
1549                }
1550            }
1551        }
1552        _ => {}
1553    }
1554}