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::Enter => {
904                if let Err(err) = client.confirm_worker_take_picker().await {
905                    client.state.push_log(format!("Take: {err}"));
906                }
907            }
908            _ => {}
909        }
910        return true;
911    }
912
913    if client.state.show_worker_teach_picker {
914        match key.code {
915            UiKeyCode::Esc => client.close_worker_teach_picker(),
916            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_teach_picker_move(-1),
917            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_teach_picker_move(1),
918            UiKeyCode::Enter => {
919                if let Err(err) = client.confirm_worker_teach_picker().await {
920                    client.state.push_log(format!("Teach: {err}"));
921                }
922            }
923            _ => {}
924        }
925        return true;
926    }
927
928    if client.state.show_workers_menu {
929        match key.code {
930            UiKeyCode::Esc => client.state.show_workers_menu = false,
931            UiKeyCode::Up | UiKeyCode::Char('k') => client.workers_menu_move(-1),
932            UiKeyCode::Down | UiKeyCode::Char('j') => client.workers_menu_move(1),
933            UiKeyCode::PageUp => client.workers_menu_page(-1),
934            UiKeyCode::PageDown => client.workers_menu_page(1),
935            UiKeyCode::Char('d') => {
936                if let Err(err) = client.workers_dismiss_selected().await {
937                    client.state.push_log(format!("Worker: {err}"));
938                }
939            }
940            UiKeyCode::Char('r') => {
941                if let Err(err) = client.hire_worker_laborer().await {
942                    client.state.push_log(format!("Hire: {err}"));
943                }
944            }
945            UiKeyCode::Char('e') => {
946                if let Err(err) = client.open_worker_route_editor_for_selected() {
947                    client.state.push_log(format!("Route: {err}"));
948                }
949            }
950            UiKeyCode::Char('n') => {
951                if let Err(err) = client.open_worker_rename() {
952                    client.state.push_log(format!("Rename: {err}"));
953                }
954            }
955            UiKeyCode::Char('g') => {
956                if let Err(err) = client.open_worker_give_picker() {
957                    client.state.push_log(format!("Give: {err}"));
958                }
959            }
960            UiKeyCode::Char('i') => {
961                if let Err(err) = client.open_worker_take_picker() {
962                    client.state.push_log(format!("Take: {err}"));
963                }
964            }
965            UiKeyCode::Char('t') => {
966                if let Err(err) = client.open_worker_teach_picker() {
967                    client.state.push_log(format!("Teach: {err}"));
968                }
969            }
970            UiKeyCode::Char('c') => client.toggle_workers_menu_compact(),
971            UiKeyCode::Enter => {
972                if let Err(err) = client.workers_confirm_action().await {
973                    client.state.push_log(format!("Worker: {err}"));
974                }
975            }
976            _ => {}
977        }
978        return true;
979    }
980
981    false
982}
983
984pub async fn dispatch_action<S: PlayConnection>(
985    client: &mut GameClient<S>,
986    _keys: &ClientKeyBindings,
987    action: InputAction,
988    ctx: &mut ActionCtx<'_>,
989) {
990    match action {
991        InputAction::StartChat { whisper } => {
992            if whisper {
993                // Stone contacts: pick inside CHAT column (no separate popup).
994                client.state.whisper_pouch_ui.open = false;
995                client.state.social_chat.picking_stone = true;
996                client.state.social_chat.stone_pick_index = 0;
997                client.state.social_chat.input_focused = false;
998                client.state.social_chat.push_system(
999                    "Whisper stones — ↑↓ select · Enter · Esc cancel",
1000                );
1001            } else {
1002                client.state.social_chat.focus_nearby();
1003            }
1004        }
1005        InputAction::Harvest => {
1006            if let Err(err) = client.harvest_nearest().await {
1007                client.state.push_log(format!("Harvest failed: {err}"));
1008            }
1009        }
1010        InputAction::Pickup => {
1011            if client.pickup_nearest().await.is_err() {
1012                if let Err(err) = client.pickup_nearest_container().await {
1013                    client.state.push_log(format!("Pickup: {err}"));
1014                }
1015            }
1016        }
1017        InputAction::Craft => client.open_craft_menu(),
1018        InputAction::Interact | InputAction::UseWorld => {
1019            if let Err(err) = client.use_nearest().await {
1020                client.state.push_log(format!("Use: {err}"));
1021            }
1022        }
1023        InputAction::TestDamage => {
1024            if let Err(err) = client.test_damage(25.0).await {
1025                client.state.push_log(format!("Damage failed: {err}"));
1026            }
1027        }
1028        InputAction::CycleCombatTarget { reverse } => {
1029            if let Err(err) = client.cycle_combat_target(reverse).await {
1030                client.state.push_log(format!("T1 target: {err}"));
1031            }
1032        }
1033        InputAction::CycleCombatTargetT2 { reverse } => {
1034            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
1035                client.state.push_log(format!("T2 target: {err}"));
1036            }
1037        }
1038        InputAction::AdvanceRotationT1 => {
1039            if let Err(err) = client.advance_rotation(1).await {
1040                client.state.push_log(format!("T1 step: {err}"));
1041            }
1042        }
1043        InputAction::AdvanceRotationT2 => {
1044            if let Err(err) = client.advance_rotation(2).await {
1045                client.state.push_log(format!("T2 step: {err}"));
1046            }
1047        }
1048        InputAction::ToggleAutoT1 => {
1049            if let Err(err) = client.toggle_auto_attack_slot(1).await {
1050                client.state.push_log(format!("T1 auto: {err}"));
1051            }
1052        }
1053        InputAction::ToggleAutoT2 => {
1054            if let Err(err) = client.toggle_auto_attack_slot(2).await {
1055                client.state.push_log(format!("T2 auto: {err}"));
1056            }
1057        }
1058        InputAction::ToggleLoadout => {
1059            client.state.show_rotation_editor = false;
1060            client.state.rotation_editor.reset();
1061            client.state.show_loadout_menu = !client.state.show_loadout_menu;
1062            if client.state.show_loadout_menu {
1063                client.state.loadout_focus_presets = true;
1064                client.state.loadout_hotbar_slot = client.state.loadout_hotbar_slot.clamp(1, 9);
1065                let ability_len = client.state.loadout_hotbar_choices().len();
1066                if ability_len > 0 {
1067                    client.state.loadout_ability_index =
1068                        client.state.loadout_ability_index.min(ability_len - 1);
1069                } else {
1070                    client.state.loadout_ability_index = 0;
1071                }
1072                let preset_len = client.state.rotation_presets.len();
1073                if preset_len > 0 {
1074                    client.state.loadout_menu_index =
1075                        client.state.loadout_menu_index.min(preset_len - 1);
1076                } else {
1077                    client.state.loadout_menu_index = 0;
1078                }
1079                *ctx.auto_nav = None;
1080                let _ = client.stop().await;
1081            }
1082        }
1083        InputAction::ToggleRotationEditor => {
1084            client.state.show_loadout_menu = false;
1085            if client.state.show_rotation_editor {
1086                client.state.show_rotation_editor = false;
1087                client.state.rotation_editor.reset();
1088            } else {
1089                client.state.show_rotation_editor = true;
1090                client.state.rotation_editor.reset();
1091                let max = client.state.rotation_presets.len();
1092                if max > 0 {
1093                    client.state.rotation_editor.list_index =
1094                        client.state.rotation_editor.list_index.min(max - 1);
1095                }
1096            }
1097            if client.state.show_rotation_editor {
1098                *ctx.auto_nav = None;
1099                let _ = client.stop().await;
1100            }
1101        }
1102        InputAction::LoadoutMenuUp => {
1103            if !client.state.show_loadout_menu {
1104                return;
1105            }
1106            if client.state.loadout_focus_presets {
1107                if client.state.loadout_menu_index > 0 {
1108                    client.state.loadout_menu_index -= 1;
1109                }
1110            } else if client.state.loadout_ability_index > 0 {
1111                client.state.loadout_ability_index -= 1;
1112            }
1113        }
1114        InputAction::LoadoutMenuDown => {
1115            if !client.state.show_loadout_menu {
1116                return;
1117            }
1118            if client.state.loadout_focus_presets {
1119                let max = client.state.rotation_presets.len();
1120                if max > 0 {
1121                    client.state.loadout_menu_index =
1122                        (client.state.loadout_menu_index + 1).min(max - 1);
1123                }
1124            } else {
1125                let max = client.state.loadout_hotbar_choices().len();
1126                if max > 0 {
1127                    client.state.loadout_ability_index =
1128                        (client.state.loadout_ability_index + 1).min(max - 1);
1129                }
1130            }
1131        }
1132        InputAction::LoadoutHotbarPrev => {
1133            if client.state.show_loadout_menu {
1134                let slot = client.state.loadout_hotbar_slot;
1135                client.state.loadout_hotbar_slot = if slot <= 1 { 9 } else { slot - 1 };
1136            }
1137        }
1138        InputAction::LoadoutHotbarNext => {
1139            if client.state.show_loadout_menu {
1140                let slot = client.state.loadout_hotbar_slot;
1141                client.state.loadout_hotbar_slot = if slot >= 9 { 1 } else { slot + 1 };
1142            }
1143        }
1144        InputAction::LoadoutToggleFocus => {
1145            if client.state.show_loadout_menu {
1146                client.state.loadout_focus_presets = !client.state.loadout_focus_presets;
1147            }
1148        }
1149        InputAction::LoadoutBindHotbar => {
1150            if !client.state.show_loadout_menu {
1151                return;
1152            }
1153            let slot = client.state.loadout_hotbar_slot;
1154            let binding = {
1155                let choices = client.state.loadout_hotbar_choices();
1156                choices
1157                    .get(client.state.loadout_ability_index)
1158                    .map(|c| c.binding.clone())
1159            };
1160            if let Some(binding) = binding {
1161                if let Err(err) = client.set_hotbar_slot(slot, Some(&binding)).await {
1162                    client.state.push_log(format!("Hotbar: {err}"));
1163                }
1164            } else {
1165                client.state.push_log("No ability/consumable selected to bind");
1166            }
1167        }
1168        InputAction::LoadoutClearHotbar => {
1169            if !client.state.show_loadout_menu {
1170                return;
1171            }
1172            let slot = client.state.loadout_hotbar_slot;
1173            if let Err(err) = client.set_hotbar_slot(slot, None).await {
1174                client.state.push_log(format!("Hotbar: {err}"));
1175            }
1176        }
1177        InputAction::LoadoutAssignT1 => {
1178            if !client.state.show_loadout_menu {
1179                return;
1180            }
1181            let preset = {
1182                let idx = client.state.loadout_menu_index;
1183                client.state.rotation_presets.get(idx).cloned()
1184            };
1185            if let Some(preset) = preset {
1186                let already = client
1187                    .state
1188                    .combat_slots
1189                    .iter()
1190                    .find(|s| s.slot_index == 1)
1191                    .and_then(|s| s.preset_id.as_deref())
1192                    == Some(preset.id.as_str());
1193                if already {
1194                    client.state.push_log(format!(
1195                        "T1 already uses {} (equip weapons from inventory — Weapon auto tracks mainhand)",
1196                        preset.label
1197                    ));
1198                } else if let Err(err) = client.assign_slot_preset(1, &preset.id).await {
1199                    client.state.push_log(format!("Loadout: {err}"));
1200                } else {
1201                    client
1202                        .state
1203                        .push_log(format!("T1 ← {}", preset.label));
1204                }
1205            }
1206        }
1207        InputAction::LoadoutAssignT2 => {
1208            if !client.state.show_loadout_menu {
1209                return;
1210            }
1211            let preset = {
1212                let idx = client.state.loadout_menu_index;
1213                client.state.rotation_presets.get(idx).cloned()
1214            };
1215            if let Some(preset) = preset {
1216                let already = client
1217                    .state
1218                    .combat_slots
1219                    .iter()
1220                    .find(|s| s.slot_index == 2)
1221                    .and_then(|s| s.preset_id.as_deref())
1222                    == Some(preset.id.as_str());
1223                if already {
1224                    client
1225                        .state
1226                        .push_log(format!("T2 already uses {}", preset.label));
1227                } else if let Err(err) = client.assign_slot_preset(2, &preset.id).await {
1228                    client.state.push_log(format!("Loadout: {err}"));
1229                } else {
1230                    client
1231                        .state
1232                        .push_log(format!("T2 ← {}", preset.label));
1233                }
1234            }
1235        }
1236        InputAction::CloseOverlay => {
1237            client.state.show_loadout_menu = false;
1238            client.state.show_rotation_editor = false;
1239            client.state.rotation_editor.reset();
1240        }
1241        InputAction::ClearCombatTarget => {
1242            if let Err(err) = client.clear_combat_target().await {
1243                client.state.push_log(format!("Target: {err}"));
1244            }
1245        }
1246        InputAction::ClearCombatTargetT2 => {
1247            if let Err(err) = client.clear_combat_target_slot(2).await {
1248                client.state.push_log(format!("T2 target: {err}"));
1249            }
1250        }
1251        InputAction::Dodge => {
1252            *ctx.auto_nav = None;
1253            if let Err(err) = client.dodge().await {
1254                client.state.push_log(format!("Dodge: {err}"));
1255            }
1256        }
1257        InputAction::Lunge => {
1258            *ctx.auto_nav = None;
1259            let _ = client.stop().await;
1260            if let Err(err) = client.lunge().await {
1261                client.state.push_log(format!("Lunge: {err}"));
1262            }
1263        }
1264        InputAction::DirectionalJump { forward, strafe } => {
1265            *ctx.auto_nav = None;
1266            let _ = client.stop().await;
1267            if let Err(err) = client.directional_jump(forward, strafe).await {
1268                client.state.push_log(format!("Jump: {err}"));
1269            }
1270        }
1271        InputAction::CastHotbar { slot } => {
1272            if let Err(err) = client.cast_hotbar_ability(slot).await {
1273                client.state.push_log(format!("Hotbar {slot}: {err}"));
1274            }
1275        }
1276        InputAction::ToggleBlock => {
1277            *ctx.block_active = !*ctx.block_active;
1278            if let Err(err) = client.set_block(*ctx.block_active).await {
1279                client.state.push_log(format!("Block: {err}"));
1280                *ctx.block_active = false;
1281            }
1282        }
1283        InputAction::ToggleStats => client.toggle_stats(),
1284        InputAction::ToggleEquip => client.toggle_equip_menu(),
1285        InputAction::CycleCharacterSheetTab => client.cycle_character_sheet_tab(),
1286        InputAction::LedgerPeriodDigit(c) => client.set_ledger_period_digit(c),
1287        InputAction::ToggleInventory => client.toggle_inventory_menu(),
1288        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
1289        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
1290        InputAction::ToggleWorkersMenu => client.toggle_workers_menu(),
1291        InputAction::QuestMenuUp => client.quest_menu_move(-1),
1292        InputAction::QuestMenuDown => client.quest_menu_move(1),
1293        InputAction::QuestWithdraw => client.quest_request_withdraw(),
1294        InputAction::RotationEditorBack => {
1295            let _ = client.back_on_esc();
1296        }
1297        InputAction::RotationEditorListUp
1298        | InputAction::RotationEditorListDown
1299        | InputAction::RotationEditorEdit
1300        | InputAction::RotationEditorNew
1301        | InputAction::RotationEditorDelete
1302        | InputAction::RotationEditorAddAbility
1303        | InputAction::RotationEditorRemoveAbility
1304        | InputAction::RotationEditorMoveAbilityUp
1305        | InputAction::RotationEditorMoveAbilityDown
1306        | InputAction::RotationEditorAbilityUp
1307        | InputAction::RotationEditorAbilityDown
1308        | InputAction::RotationEditorPickerUp
1309        | InputAction::RotationEditorPickerDown
1310        | InputAction::RotationEditorPickAbility
1311        | InputAction::RotationEditorRename
1312        | InputAction::RotationEditorConfirmLabel
1313        | InputAction::RotationEditorLabelBackspace
1314        | InputAction::RotationEditorLabelChar(_)
1315        | InputAction::RotationEditorSave => {
1316            handle_rotation_editor_action(client, action).await;
1317        }
1318        InputAction::None
1319        | InputAction::Quit
1320        | InputAction::ToggleHelp
1321        | InputAction::CycleHudView
1322        | InputAction::SubmitChat
1323        | InputAction::CancelChat
1324        | InputAction::ToggleSprintMode
1325        | InputAction::ToggleMapTarget
1326        | InputAction::ConfirmMapTarget
1327        | InputAction::CancelMapTarget
1328        | InputAction::MapTargetNudge { .. }
1329        | InputAction::CancelAutoNav
1330        | InputAction::StopMovement => {}
1331        InputAction::ToggleHudLog => {
1332            client.state.hud_log_hidden = !client.state.hud_log_hidden;
1333            let mut cfg = flatland_client_lib::ClientConfig::load();
1334            let _ = cfg.save_hud_log_hidden(client.state.hud_log_hidden);
1335            if client.state.hud_log_hidden {
1336                client.state.push_log("LOG hidden — press ' to show");
1337            } else {
1338                client.state.push_log("LOG shown — press ' to hide");
1339            }
1340        }
1341    }
1342}
1343
1344async fn handle_rotation_editor_action<S: PlayConnection>(
1345    client: &mut GameClient<S>,
1346    action: InputAction,
1347) {
1348    match action {
1349        InputAction::RotationEditorListUp => {
1350            if client.state.rotation_editor.list_index > 0 {
1351                client.state.rotation_editor.list_index -= 1;
1352            }
1353        }
1354        InputAction::RotationEditorListDown => {
1355            let max = client.state.rotation_presets.len();
1356            if max > 0 {
1357                client.state.rotation_editor.list_index =
1358                    (client.state.rotation_editor.list_index + 1).min(max - 1);
1359            }
1360        }
1361        InputAction::RotationEditorEdit => {
1362            let idx = client.state.rotation_editor.list_index;
1363            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
1364                client.state.rotation_editor.draft = Some(preset);
1365                client.state.rotation_editor.ability_index = 0;
1366                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1367            }
1368        }
1369        InputAction::RotationEditorNew => {
1370            let preset = next_custom_preset(&client.state.rotation_presets);
1371            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
1372            client.state.rotation_editor.draft = Some(preset);
1373            client.state.rotation_editor.ability_index = 0;
1374            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1375        }
1376        InputAction::RotationEditorDelete => {
1377            let idx = client.state.rotation_editor.list_index;
1378            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
1379            if let Some(id) = preset_id {
1380                if preset_deletable(&id) {
1381                    if let Err(err) = client.delete_rotation_preset(&id).await {
1382                        client.state.push_log(format!("Delete: {err}"));
1383                    } else {
1384                        let max = client.state.rotation_presets.len();
1385                        client.state.rotation_editor.list_index = if max == 0 {
1386                            0
1387                        } else {
1388                            client.state.rotation_editor.list_index.min(max - 1)
1389                        };
1390                    }
1391                } else {
1392                    client.state.push_log("Cannot delete built-in preset");
1393                }
1394            }
1395        }
1396        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
1397            RotationEditorMode::EditLabel => {
1398                client.state.rotation_editor.label_buffer.clear();
1399                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1400            }
1401            RotationEditorMode::PickAbility => {
1402                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1403            }
1404            RotationEditorMode::EditSequence => {
1405                client.state.rotation_editor.draft = None;
1406                client.state.rotation_editor.mode = RotationEditorMode::List;
1407            }
1408            RotationEditorMode::List => {}
1409        },
1410        InputAction::RotationEditorAddAbility => {
1411            client.state.rotation_editor.picker_index = 0;
1412            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
1413        }
1414        InputAction::RotationEditorRemoveAbility => {
1415            let idx = client.state.rotation_editor.ability_index;
1416            if let Some(draft) = &mut client.state.rotation_editor.draft {
1417                if idx < draft.abilities.len() {
1418                    draft.abilities.remove(idx);
1419                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
1420                        && client.state.rotation_editor.ability_index > 0
1421                    {
1422                        client.state.rotation_editor.ability_index -= 1;
1423                    }
1424                }
1425            }
1426        }
1427        InputAction::RotationEditorMoveAbilityUp => {
1428            let i = client.state.rotation_editor.ability_index;
1429            if let Some(draft) = &mut client.state.rotation_editor.draft {
1430                if i > 0 && i < draft.abilities.len() {
1431                    draft.abilities.swap(i, i - 1);
1432                    client.state.rotation_editor.ability_index -= 1;
1433                }
1434            }
1435        }
1436        InputAction::RotationEditorMoveAbilityDown => {
1437            let i = client.state.rotation_editor.ability_index;
1438            if let Some(draft) = &mut client.state.rotation_editor.draft {
1439                if i + 1 < draft.abilities.len() {
1440                    draft.abilities.swap(i, i + 1);
1441                    client.state.rotation_editor.ability_index += 1;
1442                }
1443            }
1444        }
1445        InputAction::RotationEditorAbilityUp => {
1446            if client.state.rotation_editor.ability_index > 0 {
1447                client.state.rotation_editor.ability_index -= 1;
1448            }
1449        }
1450        InputAction::RotationEditorAbilityDown => {
1451            if let Some(draft) = &client.state.rotation_editor.draft {
1452                if !draft.abilities.is_empty() {
1453                    client.state.rotation_editor.ability_index =
1454                        (client.state.rotation_editor.ability_index + 1)
1455                            .min(draft.abilities.len() - 1);
1456                }
1457            }
1458        }
1459        InputAction::RotationEditorPickerUp => {
1460            if client.state.rotation_editor.picker_index > 0 {
1461                client.state.rotation_editor.picker_index -= 1;
1462            }
1463        }
1464        InputAction::RotationEditorPickerDown => {
1465            let choices = editor_ability_choices(
1466                &client.state.known_abilities,
1467                &client.state.weapon_ability_id,
1468            );
1469            if !choices.is_empty() {
1470                client.state.rotation_editor.picker_index =
1471                    (client.state.rotation_editor.picker_index + 1).min(choices.len() - 1);
1472            }
1473        }
1474        InputAction::RotationEditorPickAbility => {
1475            let choices = editor_ability_choices(
1476                &client.state.known_abilities,
1477                &client.state.weapon_ability_id,
1478            );
1479            let pick = client.state.rotation_editor.picker_index;
1480            if let Some(ability) = choices.get(pick) {
1481                if let Some(draft) = &mut client.state.rotation_editor.draft {
1482                    let max = client.state.max_abilities_per_rotation.max(1) as usize;
1483                    if draft.abilities.len() >= max {
1484                        client
1485                            .state
1486                            .push_log(format!("Rotation full (max {max} from INT+WIS)"));
1487                    } else {
1488                        draft.abilities.push(ability.clone());
1489                        client.state.rotation_editor.ability_index =
1490                            draft.abilities.len().saturating_sub(1);
1491                    }
1492                }
1493                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1494            }
1495        }
1496        InputAction::RotationEditorRename => {
1497            if let Some(draft) = &client.state.rotation_editor.draft {
1498                client.state.rotation_editor.label_buffer = draft.label.clone();
1499                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
1500            }
1501        }
1502        InputAction::RotationEditorConfirmLabel => {
1503            let label = client.state.rotation_editor.label_buffer.trim().to_string();
1504            if label.is_empty() {
1505                client.state.push_log("Label cannot be empty");
1506            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
1507                draft.label = label;
1508                client.state.rotation_editor.label_buffer.clear();
1509                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1510            }
1511        }
1512        InputAction::RotationEditorLabelBackspace => {
1513            client.state.rotation_editor.label_buffer.pop();
1514        }
1515        InputAction::RotationEditorLabelChar(c) => {
1516            if client.state.rotation_editor.label_buffer.len() < 32 {
1517                client.state.rotation_editor.label_buffer.push(c);
1518            }
1519        }
1520        InputAction::RotationEditorSave => {
1521            let draft = match client.state.rotation_editor.draft.clone() {
1522                Some(d) => d,
1523                None => return,
1524            };
1525            if draft.abilities.is_empty() {
1526                client.state.push_log("Rotation needs at least one ability");
1527                return;
1528            }
1529            let saved_id = draft.id.clone();
1530            if let Err(err) = client.upsert_rotation_preset(draft).await {
1531                client.state.push_log(format!("Save: {err}"));
1532            } else {
1533                client.state.rotation_editor.mode = RotationEditorMode::List;
1534                client.state.rotation_editor.draft = None;
1535                if let Some(i) = client
1536                    .state
1537                    .rotation_presets
1538                    .iter()
1539                    .position(|p| p.id == saved_id)
1540                {
1541                    client.state.rotation_editor.list_index = i;
1542                }
1543            }
1544        }
1545        _ => {}
1546    }
1547}