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('-') => {
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::Char('0') => {
153                    if !client.state.destroy_confirm_pending {
154                        client.destroy_picker_set_quantity_min();
155                    }
156                }
157                UiKeyCode::Enter => {
158                    if let Err(err) = client.activate_inventory_selection().await {
159                        client.state.push_log(format!("Destroy failed: {err}"));
160                    }
161                }
162                _ => {}
163            }
164        } else if client.state.show_grant_picker {
165            let filter_focused = client
166                .state
167                .grant_picker
168                .as_ref()
169                .map(|p| p.filter_focused)
170                .unwrap_or(false);
171            match key.code {
172                UiKeyCode::Esc => {
173                    if !client.clear_or_blur_inventory_filter() {
174                        client.close_grant_picker();
175                    }
176                }
177                UiKeyCode::PageUp => client.inventory_menu_page(-1),
178                UiKeyCode::PageDown => client.inventory_menu_page(1),
179                UiKeyCode::Char('/') if !filter_focused => client.focus_inventory_filter(),
180                UiKeyCode::Backspace if filter_focused => client.inventory_filter_backspace(),
181                UiKeyCode::Char(c) if filter_focused => client.append_inventory_filter_char(c),
182                UiKeyCode::Up | UiKeyCode::Char('k') if !filter_focused => {
183                    client.inventory_menu_move(-1)
184                }
185                UiKeyCode::Down | UiKeyCode::Char('j') if !filter_focused => {
186                    client.inventory_menu_move(1)
187                }
188                UiKeyCode::Enter if !filter_focused => {
189                    if let Err(err) = client.activate_inventory_selection().await {
190                        client.state.push_log(format!("Grant failed: {err}"));
191                    }
192                }
193                _ => {}
194            }
195        } else if client.state.show_move_picker {
196            let filter_focused = client
197                .state
198                .move_picker
199                .as_ref()
200                .map(|p| p.filter_focused)
201                .unwrap_or(false);
202            match key.code {
203                UiKeyCode::Esc => {
204                    if !client.clear_or_blur_inventory_filter() {
205                        client.close_move_picker();
206                    }
207                }
208                UiKeyCode::PageUp => client.inventory_menu_page(-1),
209                UiKeyCode::PageDown => client.inventory_menu_page(1),
210                UiKeyCode::Char('/') if !filter_focused => client.focus_inventory_filter(),
211                UiKeyCode::Backspace if filter_focused => client.inventory_filter_backspace(),
212                UiKeyCode::Char(c) if filter_focused => client.append_inventory_filter_char(c),
213                UiKeyCode::Up | UiKeyCode::Char('k') if !filter_focused => {
214                    client.inventory_menu_move(-1)
215                }
216                UiKeyCode::Down | UiKeyCode::Char('j') if !filter_focused => {
217                    client.inventory_menu_move(1)
218                }
219                UiKeyCode::Char('-') if !filter_focused => {
220                    client.move_picker_adjust_quantity(-1);
221                }
222                UiKeyCode::Char('+') | UiKeyCode::Char('=') if !filter_focused => {
223                    client.move_picker_adjust_quantity(1);
224                }
225                UiKeyCode::Char('a') if !filter_focused => client.move_picker_set_quantity_max(),
226                UiKeyCode::Char('0') if !filter_focused => client.move_picker_set_quantity_min(),
227                UiKeyCode::Enter if !filter_focused => {
228                    if let Err(err) = client.activate_inventory_selection().await {
229                        client.state.push_log(format!("Move failed: {err}"));
230                    }
231                }
232                _ => {}
233            }
234        } else if client.state.inventory_filter_focused {
235            match key.code {
236                UiKeyCode::Esc => {
237                    let _ = client.clear_or_blur_inventory_filter();
238                }
239                UiKeyCode::Enter => {
240                    client.state.inventory_filter_focused = false;
241                }
242                UiKeyCode::Backspace => client.inventory_filter_backspace(),
243                UiKeyCode::Char(c) => client.append_inventory_filter_char(c),
244                _ => {}
245            }
246        } else {
247            match key.code {
248                UiKeyCode::Esc => {
249                    if !client.clear_or_blur_inventory_filter() {
250                        client.close_inventory_menu();
251                    }
252                }
253                UiKeyCode::Char('b') => client.close_inventory_menu(),
254                UiKeyCode::Tab => client.cycle_inventory_tab(true),
255                UiKeyCode::BackTab => client.cycle_inventory_tab(false),
256                UiKeyCode::PageUp => client.inventory_menu_page(-1),
257                UiKeyCode::PageDown => client.inventory_menu_page(1),
258                UiKeyCode::Char('/') => client.focus_inventory_filter(),
259                UiKeyCode::Up | UiKeyCode::Char('k') => client.inventory_menu_move(-1),
260                UiKeyCode::Down | UiKeyCode::Char('j') => client.inventory_menu_move(1),
261                UiKeyCode::Enter => {
262                    if let Err(err) = client.activate_inventory_selection().await {
263                        client.state.push_log(format!("{err}"));
264                    }
265                }
266                UiKeyCode::Char('m') => {
267                    if let Err(err) = client.open_move_picker() {
268                        client.state.push_log(format!("{err}"));
269                    }
270                }
271                UiKeyCode::Char('e') => {
272                    if let Err(err) = client.use_selected_consumable().await {
273                        client.state.push_log(format!("Use: {err}"));
274                    }
275                }
276                UiKeyCode::Char('n') => {
277                    if let Err(err) = client.open_rename_prompt() {
278                        client.state.push_log(format!("{err}"));
279                    }
280                }
281                UiKeyCode::Char('d') => {
282                    if let Err(err) = client.drop_selected().await {
283                        client.state.push_log(format!("Drop: {err}"));
284                    }
285                }
286                UiKeyCode::Char('x') => {
287                    if let Err(err) = client.open_destroy_picker() {
288                        client.state.push_log(format!("Destroy: {err}"));
289                    }
290                }
291                UiKeyCode::Char('l') => {
292                    if let Err(err) = client.toggle_chest_lock_for_selection().await {
293                        client.state.push_log(format!("Lock: {err}"));
294                    }
295                }
296                UiKeyCode::Char('g') => {
297                    if let Err(err) = client.give_selected_inventory_to_worker().await {
298                        client.state.push_log(format!("Give: {err}"));
299                    }
300                }
301                UiKeyCode::Char('u') => {
302                    if let Err(err) = client.unequip_mainhand().await {
303                        client.state.push_log(format!("Unequip: {err}"));
304                    }
305                }
306                _ => {}
307            }
308        }
309        return true;
310    }
311
312    if client.state.show_keychain_menu {
313        match key.code {
314            UiKeyCode::Esc | UiKeyCode::Char(',') => client.close_keychain_menu(),
315            UiKeyCode::Up | UiKeyCode::Char('w') | UiKeyCode::Char('k') => {
316                client.keychain_menu_move(-1);
317            }
318            UiKeyCode::Down | UiKeyCode::Char('s') | UiKeyCode::Char('j') => {
319                client.keychain_menu_move(1);
320            }
321            UiKeyCode::PageUp => client.keychain_menu_page(-1),
322            UiKeyCode::PageDown => client.keychain_menu_page(1),
323            UiKeyCode::Enter => {
324                if let Err(err) = client.activate_keychain_selection().await {
325                    client.state.push_log(format!("Keychain: {err}"));
326                }
327            }
328            _ => {}
329        }
330        return true;
331    }
332
333    if client.state.show_craft_menu {
334        match key.code {
335            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
336                client.close_craft_menu()
337            }
338            UiKeyCode::Up | UiKeyCode::Char('k') => client.craft_menu_move(-1),
339            UiKeyCode::Down | UiKeyCode::Char('j') => client.craft_menu_move(1),
340            UiKeyCode::PageUp => client.craft_menu_page(-1),
341            UiKeyCode::PageDown => client.craft_menu_page(1),
342            UiKeyCode::Char('-') => client.craft_batch_adjust_quantity(-1),
343            UiKeyCode::Char('+') | UiKeyCode::Char('=') => client.craft_batch_adjust_quantity(1),
344            UiKeyCode::Char('a') => client.craft_batch_set_max(),
345            UiKeyCode::Char('0') => client.craft_batch_set_min(),
346            UiKeyCode::Enter => {
347                if let Err(err) = client.craft_menu_selection().await {
348                    client.state.push_log(format!("Craft failed: {err}"));
349                }
350            }
351            _ => {}
352        }
353        return true;
354    }
355
356    if client.state.show_plot_build_menu {
357        let building = client
358            .state
359            .timed_channel
360            .as_ref()
361            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
362        match key.code {
363            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('B') => {
364                client.close_plot_build_menu()
365            }
366            UiKeyCode::Char('x') | UiKeyCode::Char('X') if building => {
367                if let Err(err) = client.plot_build_menu_cancel_build().await {
368                    client.state.push_log(format!("Build: {err}"));
369                }
370            }
371            UiKeyCode::Up | UiKeyCode::Char('k') if !building => client.plot_build_menu_move(-1),
372            UiKeyCode::Down | UiKeyCode::Char('j') if !building => client.plot_build_menu_move(1),
373            UiKeyCode::Tab if !building => client.plot_build_menu_toggle_focus(),
374            UiKeyCode::Enter if !building => {
375                if let Err(err) = client.plot_build_menu_confirm().await {
376                    client.state.push_log(format!("Build: {err}"));
377                }
378            }
379            _ => {}
380        }
381        return true;
382    }
383
384    if client.state.show_quest_offer {
385        match key.code {
386            UiKeyCode::Esc => client.quest_offer_decline(),
387            UiKeyCode::Enter => {
388                if let Err(err) = client.quest_offer_accept().await {
389                    client.state.push_log(format!("Quest: {err}"));
390                }
391            }
392            _ => {}
393        }
394        return true;
395    }
396
397    if client.state.show_npc_chat {
398        match key.code {
399            UiKeyCode::Esc => {
400                if let Err(err) = client.npc_talk_close().await {
401                    client.state.push_log(format!("Talk close failed: {err}"));
402                }
403            }
404            UiKeyCode::Enter => {
405                if let Err(err) = client.npc_talk_send().await {
406                    client.state.push_log(format!("Talk failed: {err}"));
407                }
408            }
409            UiKeyCode::Backspace => {
410                if let Some(chat) = client.state.npc_chat.as_mut() {
411                    chat.input.pop();
412                }
413            }
414            UiKeyCode::Char(c) => {
415                if let Some(chat) = client.state.npc_chat.as_mut() {
416                    if !chat.pending && chat.input.len() < 300 {
417                        chat.input.push(c);
418                    }
419                }
420            }
421            _ => {}
422        }
423        return true;
424    }
425
426    if client.state.show_npc_verb_menu {
427        match key.code {
428            UiKeyCode::Esc => {
429                client.state.show_npc_verb_menu = false;
430                client.state.npc_verb_target = None;
431            }
432            UiKeyCode::Up | UiKeyCode::Char('k') => {
433                if client.state.npc_verb_index > 0 {
434                    client.state.npc_verb_index -= 1;
435                }
436            }
437            UiKeyCode::Down | UiKeyCode::Char('j') => {
438                let max = client.npc_verb_options().len().saturating_sub(1);
439                if client.state.npc_verb_index < max {
440                    client.state.npc_verb_index += 1;
441                }
442            }
443            UiKeyCode::Enter => {
444                if let Err(err) = client.confirm_npc_verb().await {
445                    client.state.push_log(format!("Interact failed: {err}"));
446                }
447            }
448            _ => {}
449        }
450        return true;
451    }
452
453    // Trade request accept/decline — not while typing chat.
454    if client.state.social_chat.pending_trade.is_some()
455        && !client.state.social_chat.input_focused
456        && !client.state.social_chat.picking_stone
457        && matches!(key.code, UiKeyCode::Char('y' | 'Y' | 'n' | 'N'))
458    {
459        let accept = matches!(key.code, UiKeyCode::Char('y' | 'Y'));
460        if let Err(err) = client.respond_pending_trade(accept).await {
461            client.state.push_log(format!("Trade respond failed: {err}"));
462        }
463        return true;
464    }
465
466    if client.state.player_verbs.open {
467        let opts = flatland_client_lib::PlayerVerbState::options();
468        match key.code {
469            UiKeyCode::Esc => client.state.player_verbs.close(),
470            UiKeyCode::Up | UiKeyCode::Char('k') => {
471                if client.state.player_verbs.index > 0 {
472                    client.state.player_verbs.index -= 1;
473                }
474            }
475            UiKeyCode::Down | UiKeyCode::Char('j') => {
476                let max = opts.len().saturating_sub(1);
477                if client.state.player_verbs.index < max {
478                    client.state.player_verbs.index += 1;
479                }
480            }
481            UiKeyCode::Enter => {
482                if let Err(err) = client.confirm_player_verb().await {
483                    client.state.push_log(format!("Interact failed: {err}"));
484                }
485            }
486            _ => {}
487        }
488        return true;
489    }
490
491    // Whisper-stone contact picker lives in the CHAT column (`g`).
492    if client.state.social_chat.picking_stone {
493        let contacts =
494            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
495        match key.code {
496            UiKeyCode::Esc => {
497                client.state.social_chat.picking_stone = false;
498            }
499            UiKeyCode::Up | UiKeyCode::Char('k') => {
500                if client.state.social_chat.stone_pick_index > 0 {
501                    client.state.social_chat.stone_pick_index -= 1;
502                }
503            }
504            UiKeyCode::Down | UiKeyCode::Char('j') => {
505                let max = contacts.len().saturating_sub(1);
506                if client.state.social_chat.stone_pick_index < max {
507                    client.state.social_chat.stone_pick_index += 1;
508                }
509            }
510            UiKeyCode::Enter => {
511                if let Some(c) = contacts.get(client.state.social_chat.stone_pick_index) {
512                    if !c.blank {
513                        if let Some(peer) = client
514                            .state
515                            .entities
516                            .iter()
517                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
518                        {
519                            client
520                                .state
521                                .social_chat
522                                .focus_stone(peer.id, &c.peer_label);
523                        } else {
524                            client.state.social_chat.push_system(format!(
525                                "{} is not online in this region",
526                                c.peer_label
527                            ));
528                            client.state.social_chat.picking_stone = false;
529                        }
530                    }
531                }
532            }
533            _ => {}
534        }
535        return true;
536    }
537
538    if client.state.trade_ui.panel.is_some() {
539        // Quantity entry for presenting a stack.
540        if client.state.trade_ui.qty_entry.is_some() {
541            match key.code {
542                UiKeyCode::Esc => {
543                    client.state.trade_ui.qty_entry = None;
544                    client.state.trade_ui.picking_inventory = true;
545                }
546                UiKeyCode::Enter => {
547                    if let Err(err) = client.trade_confirm_qty_or_present().await {
548                        client.state.push_log(format!("Present failed: {err}"));
549                    }
550                }
551                UiKeyCode::Char('-') => {
552                    client.state.trade_ui.adjust_qty(-1);
553                }
554                UiKeyCode::Char('+') | UiKeyCode::Char('=') => {
555                    client.state.trade_ui.adjust_qty(1);
556                }
557                UiKeyCode::Char('a') | UiKeyCode::Char('A') => {
558                    client.state.trade_ui.set_qty_all();
559                }
560                UiKeyCode::Char('0') => {
561                    let typed_empty = client
562                        .state
563                        .trade_ui
564                        .qty_entry
565                        .as_ref()
566                        .map(|e| e.typed.is_empty())
567                        .unwrap_or(true);
568                    if typed_empty {
569                        client.state.trade_ui.set_qty_min();
570                    } else {
571                        client.state.trade_ui.append_qty_digit('0');
572                    }
573                }
574                UiKeyCode::Backspace => client.state.trade_ui.qty_backspace(),
575                UiKeyCode::Char(c) if c.is_ascii_digit() => {
576                    client.state.trade_ui.append_qty_digit(c);
577                }
578                _ => {}
579            }
580            return true;
581        }
582        match key.code {
583            UiKeyCode::Esc => {
584                if client.state.trade_ui.picking_inventory {
585                    client.state.trade_ui.picking_inventory = false;
586                } else if let Err(err) = client.trade_cancel().await {
587                    client.state.push_log(format!("Trade cancel failed: {err}"));
588                }
589            }
590            UiKeyCode::Char('r') => {
591                let ready = client
592                    .state
593                    .trade_ui
594                    .panel
595                    .as_ref()
596                    .map(|p| !p.i_ready)
597                    .unwrap_or(true);
598                if let Err(err) = client.trade_set_ready(ready).await {
599                    client.state.push_log(format!("Trade ready failed: {err}"));
600                }
601            }
602            UiKeyCode::Char('p') => {
603                client.state.trade_ui.picking_inventory = !client.state.trade_ui.picking_inventory;
604                client.state.trade_ui.qty_entry = None;
605                if client.state.trade_ui.picking_inventory {
606                    let max = client
607                        .state
608                        .trade_presentable_stacks()
609                        .len()
610                        .saturating_sub(1);
611                    client.state.trade_ui.inventory_index =
612                        client.state.trade_ui.inventory_index.min(max);
613                }
614            }
615            UiKeyCode::Up | UiKeyCode::Char('k') => {
616                if client.state.trade_ui.picking_inventory {
617                    if client.state.trade_ui.inventory_index > 0 {
618                        client.state.trade_ui.inventory_index -= 1;
619                    }
620                } else if client.state.trade_ui.select_index > 0 {
621                    client.state.trade_ui.select_index -= 1;
622                }
623            }
624            UiKeyCode::Down | UiKeyCode::Char('j') => {
625                if client.state.trade_ui.picking_inventory {
626                    let max = client
627                        .state
628                        .trade_presentable_stacks()
629                        .len()
630                        .saturating_sub(1);
631                    if client.state.trade_ui.inventory_index < max {
632                        client.state.trade_ui.inventory_index += 1;
633                    }
634                }
635            }
636            UiKeyCode::PageUp => {
637                if client.state.trade_ui.picking_inventory {
638                    let n = client.state.trade_presentable_stacks().len();
639                    client.state.trade_ui.inventory_index =
640                        flatland_client_lib::page_list_index(
641                            client.state.trade_ui.inventory_index,
642                            -1,
643                            n,
644                        );
645                }
646            }
647            UiKeyCode::PageDown => {
648                if client.state.trade_ui.picking_inventory {
649                    let n = client.state.trade_presentable_stacks().len();
650                    client.state.trade_ui.inventory_index =
651                        flatland_client_lib::page_list_index(
652                            client.state.trade_ui.inventory_index,
653                            1,
654                            n,
655                        );
656                }
657            }
658            UiKeyCode::Enter => {
659                if client.state.trade_ui.picking_inventory {
660                    if let Err(err) = client.trade_confirm_qty_or_present().await {
661                        client.state.push_log(format!("Present failed: {err}"));
662                    }
663                }
664            }
665            _ => {}
666        }
667        return true;
668    }
669
670    if client.state.whisper_pouch_ui.open {
671        let contacts =
672            flatland_client_lib::contacts_from_stacks(&client.state.whisper_pouch_stacks);
673        match key.code {
674            UiKeyCode::Esc => client.state.whisper_pouch_ui.open = false,
675            UiKeyCode::Up | UiKeyCode::Char('k') => {
676                if client.state.whisper_pouch_ui.index > 0 {
677                    client.state.whisper_pouch_ui.index -= 1;
678                }
679            }
680            UiKeyCode::Down | UiKeyCode::Char('j') => {
681                let max = contacts.len().saturating_sub(1);
682                if client.state.whisper_pouch_ui.index < max {
683                    client.state.whisper_pouch_ui.index += 1;
684                }
685            }
686            UiKeyCode::Enter => {
687                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
688                    if !c.blank {
689                        // Resolve online peer by label match for stone chat.
690                        if let Some(peer) = client
691                            .state
692                            .entities
693                            .iter()
694                            .find(|e| e.id != client.state.entity_id && e.label == c.peer_label)
695                        {
696                            client
697                                .state
698                                .social_chat
699                                .open_stone(peer.id, &c.peer_label);
700                            client.state.whisper_pouch_ui.open = false;
701                        } else {
702                            client.state.push_log(format!(
703                                "{} is not online in this region",
704                                c.peer_label
705                            ));
706                        }
707                    }
708                }
709            }
710            UiKeyCode::Char('x') | UiKeyCode::Char('d') => {
711                if let Some(c) = contacts.get(client.state.whisper_pouch_ui.index) {
712                    let id = c.instance_id;
713                    if let Err(err) = client.destroy_whisper_stone(id).await {
714                        client.state.push_log(format!("Destroy stone failed: {err}"));
715                    }
716                }
717            }
718            _ => {}
719        }
720        return true;
721    }
722
723    if client.state.bank_panel.is_some() {
724        match &client.state.bank_ui_mode {
725            flatland_client_lib::BankUiMode::Menu => match key.code {
726                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
727                    if let Err(err) = client.close_bank_panel().await {
728                        client.state.push_log(format!("Bank close failed: {err}"));
729                    }
730                }
731                UiKeyCode::Up | UiKeyCode::Char('k') => client.bank_menu_move(-1),
732                UiKeyCode::Down | UiKeyCode::Char('j') => client.bank_menu_move(1),
733                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
734                    if let Err(err) = client.confirm_bank_menu().await {
735                        client.state.push_log(format!("Bank action failed: {err}"));
736                    }
737                }
738                UiKeyCode::Char('d') => {
739                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::DepositAmount {
740                        input: String::new(),
741                    };
742                }
743                UiKeyCode::Char('w') => {
744                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::WithdrawAmount {
745                        input: String::new(),
746                    };
747                }
748                UiKeyCode::Char('t') => {
749                    client.state.bank_ui_mode = flatland_client_lib::BankUiMode::TransferName {
750                        input: String::new(),
751                    };
752                }
753                _ => {}
754            },
755            _ => match key.code {
756                UiKeyCode::Esc | UiKeyCode::Char('[') => client.bank_transfer_back(),
757                UiKeyCode::Enter => {
758                    if let Err(err) = client.confirm_bank_menu().await {
759                        client.state.push_log(format!("Bank action failed: {err}"));
760                    }
761                }
762                UiKeyCode::Backspace => client.bank_transfer_backspace(),
763                UiKeyCode::Char(c) => client.bank_transfer_append_char(c),
764                _ => {}
765            },
766        }
767        return true;
768    }
769
770    if client.state.storage_panel.is_some() {
771        match &client.state.storage_ui_mode {
772            flatland_client_lib::StorageUiMode::Menu => match key.code {
773                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
774                    if let Err(err) = client.close_storage_panel().await {
775                        client.state.push_log(format!("Storage close failed: {err}"));
776                    }
777                }
778                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_menu_move(-1),
779                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_menu_move(1),
780                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
781                    if let Err(err) = client.confirm_storage_menu().await {
782                        client.state.push_log(format!("Storage action failed: {err}"));
783                    }
784                }
785                UiKeyCode::Char('s') => {
786                    let opts = client.state.storage_store_options();
787                    if opts.is_empty() {
788                        client.state.push_log("Nothing loose to store.");
789                    } else {
790                        client.state.storage_ui_mode =
791                            flatland_client_lib::StorageUiMode::StorePick { index: 0 };
792                    }
793                }
794                UiKeyCode::Char('t') => {
795                    let opts = client.state.storage_vault_options();
796                    if opts.is_empty() {
797                        client.state.push_log("Vault is empty.");
798                    } else {
799                        client.state.storage_ui_mode =
800                            flatland_client_lib::StorageUiMode::TakePick { index: 0 };
801                    }
802                }
803                _ => {}
804            },
805            flatland_client_lib::StorageUiMode::StoreAmount { .. }
806            | flatland_client_lib::StorageUiMode::TakeAmount { .. }
807            | flatland_client_lib::StorageUiMode::ShipAmount { .. } => match key.code {
808                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
809                UiKeyCode::Enter => {
810                    if let Err(err) = client.confirm_storage_menu().await {
811                        client.state.push_log(format!("Storage action failed: {err}"));
812                    }
813                }
814                UiKeyCode::Backspace => client.storage_amount_backspace(),
815                UiKeyCode::Char(c) => client.storage_amount_append_char(c),
816                _ => {}
817            },
818            _ => match key.code {
819                UiKeyCode::Esc | UiKeyCode::Char('[') => client.storage_ui_back(),
820                UiKeyCode::Up | UiKeyCode::Char('k') => client.storage_pick_move(-1),
821                UiKeyCode::Down | UiKeyCode::Char('j') => client.storage_pick_move(1),
822                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
823                    if let Err(err) = client.confirm_storage_menu().await {
824                        client.state.push_log(format!("Storage action failed: {err}"));
825                    }
826                }
827                _ => {}
828            },
829        }
830        return true;
831    }
832
833    if client.state.market_panel.is_some() {
834        let filter_focused = client.state.market_filter_focused;
835        match &client.state.market_ui_mode {
836            flatland_client_lib::MarketUiMode::Browse => {
837                if filter_focused {
838                    match key.code {
839                        UiKeyCode::Esc => {
840                            let _ = client.clear_or_blur_market_filter();
841                        }
842                        UiKeyCode::Enter => {
843                            client.state.market_filter_focused = false;
844                        }
845                        UiKeyCode::Backspace => client.market_filter_backspace(),
846                        UiKeyCode::Char(c) => client.append_market_filter_char(c),
847                        _ => {}
848                    }
849                    return true;
850                }
851                match key.code {
852                UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('c') => {
853                    if client.state.market_buy_confirm.is_some() {
854                        client.state.market_buy_confirm = None;
855                    } else if client.clear_or_blur_market_filter() {
856                        // cleared search / category stays
857                    } else if let Err(err) = client.close_market_panel().await {
858                        client.state.push_log(format!("Market close failed: {err}"));
859                    }
860                }
861                UiKeyCode::Up | UiKeyCode::Char('k') => {
862                    if client.state.market_buy_confirm.is_none() {
863                        client.market_move_selection(-1);
864                    }
865                }
866                UiKeyCode::Down | UiKeyCode::Char('j') => {
867                    if client.state.market_buy_confirm.is_none() {
868                        client.market_move_selection(1);
869                    }
870                }
871                UiKeyCode::PageUp => {
872                    if client.state.market_buy_confirm.is_none() {
873                        client.market_page_selection(-1);
874                    }
875                }
876                UiKeyCode::PageDown => {
877                    if client.state.market_buy_confirm.is_none() {
878                        client.market_page_selection(1);
879                    }
880                }
881                UiKeyCode::Tab => {
882                    if client.state.market_buy_confirm.is_none() {
883                        client.market_cycle_category(1);
884                    }
885                }
886                UiKeyCode::Char('/') => client.focus_market_filter(),
887                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
888                    if let Err(err) = client.market_activate_selection().await {
889                        client.state.push_log(format!("Market action failed: {err}"));
890                    }
891                }
892                UiKeyCode::Char('l') => client.market_begin_list(),
893                _ => {}
894            }
895            }
896            flatland_client_lib::MarketUiMode::ListAmount { .. }
897            | flatland_client_lib::MarketUiMode::ListPrice { .. } => match key.code {
898                UiKeyCode::Esc | UiKeyCode::Char('[') => client.market_ui_back(),
899                UiKeyCode::Enter => {
900                    if let Err(err) = client.confirm_market_list_step().await {
901                        client.state.push_log(format!("Market list failed: {err}"));
902                    }
903                }
904                UiKeyCode::Backspace => client.market_list_amount_backspace(),
905                UiKeyCode::Char(c) => client.market_list_amount_append_char(c),
906                _ => {}
907            },
908            _ => {
909                if filter_focused {
910                    match key.code {
911                        UiKeyCode::Esc => {
912                            let _ = client.clear_or_blur_market_filter();
913                        }
914                        UiKeyCode::Enter => {
915                            client.state.market_filter_focused = false;
916                        }
917                        UiKeyCode::Backspace => client.market_filter_backspace(),
918                        UiKeyCode::Char(c) => client.append_market_filter_char(c),
919                        _ => {}
920                    }
921                    return true;
922                }
923                match key.code {
924                UiKeyCode::Esc | UiKeyCode::Char('[') => {
925                    if !client.clear_or_blur_market_filter() {
926                        client.market_ui_back();
927                    }
928                }
929                UiKeyCode::Up | UiKeyCode::Char('k') => client.market_list_move(-1),
930                UiKeyCode::Down | UiKeyCode::Char('j') => client.market_list_move(1),
931                UiKeyCode::PageUp => client.market_list_page(-1),
932                UiKeyCode::PageDown => client.market_list_page(1),
933                UiKeyCode::Tab => client.market_cycle_category(1),
934                UiKeyCode::Char('/') => client.focus_market_filter(),
935                UiKeyCode::Enter | UiKeyCode::Char(' ') => {
936                    if let Err(err) = client.confirm_market_list_step().await {
937                        client.state.push_log(format!("Market list failed: {err}"));
938                    }
939                }
940                _ => {}
941            }
942            }
943        }
944        return true;
945    }
946
947    if client.state.show_shop_menu {
948        match key.code {
949            UiKeyCode::Esc | UiKeyCode::Char('[') | UiKeyCode::Char('n') | UiKeyCode::Char('c') => {
950                if let Err(err) = client.back_from_shop_menu().await {
951                    client.state.push_log(format!("Shop close failed: {err}"));
952                }
953            }
954            UiKeyCode::Tab => client.shop_tab_toggle(),
955            UiKeyCode::Up | UiKeyCode::Char('k') => client.shop_menu_move(-1),
956            UiKeyCode::Down | UiKeyCode::Char('j') => client.shop_menu_move(1),
957            UiKeyCode::PageUp => client.shop_menu_page(-1),
958            UiKeyCode::PageDown => client.shop_menu_page(1),
959            UiKeyCode::Char('-') => client.shop_quantity_adjust(-1),
960            UiKeyCode::Char('+') | UiKeyCode::Char('=') => client.shop_quantity_adjust(1),
961            UiKeyCode::Char('a') => client.shop_quantity_set_max(),
962            UiKeyCode::Char('0') => client.shop_quantity_set_min(),
963            UiKeyCode::Enter => {
964                if let Err(err) = client.shop_confirm().await {
965                    client.state.push_log(format!("Trade failed: {err}"));
966                }
967            }
968            _ => {}
969        }
970        return true;
971    }
972
973    if client.state.show_quest_menu {
974        match key.code {
975            UiKeyCode::Esc => {
976                if client.state.quest_withdraw_confirm {
977                    client.state.quest_withdraw_confirm = false;
978                } else {
979                    client.state.show_quest_menu = false;
980                }
981            }
982            UiKeyCode::Up | UiKeyCode::Char('k') => client.quest_menu_move(-1),
983            UiKeyCode::Down | UiKeyCode::Char('j') => client.quest_menu_move(1),
984            UiKeyCode::PageUp => client.quest_menu_page(-1),
985            UiKeyCode::PageDown => client.quest_menu_page(1),
986            UiKeyCode::Char('x') => client.quest_request_withdraw(),
987            UiKeyCode::Enter => {
988                if let Err(err) = client.quest_confirm_action().await {
989                    client.state.push_log(format!("Quest: {err}"));
990                }
991            }
992            _ => {}
993        }
994        return true;
995    }
996
997    if client.state.worker_route_editor.is_some() {
998        let at_root = client.re_at_root_sheet();
999        if at_root {
1000            // Root: the ordered stop list.
1001            match key.code {
1002                UiKeyCode::Esc => client.close_worker_route_editor(),
1003                UiKeyCode::Char('s') => {
1004                    if let Err(err) = client.worker_route_editor_save().await {
1005                        client.state.push_log(format!("Route: {err}"));
1006                    }
1007                }
1008                UiKeyCode::Down => {
1009                    if key.modifiers.contains_control() {
1010                        client.worker_route_editor_move_selected(1);
1011                    } else {
1012                        client.worker_route_editor_select(1);
1013                    }
1014                }
1015                UiKeyCode::Up => {
1016                    if key.modifiers.contains_control() {
1017                        client.worker_route_editor_move_selected(-1);
1018                    } else {
1019                        client.worker_route_editor_select(-1);
1020                    }
1021                }
1022                // Select: vim j/k. Reorder: Ctrl+j = earlier (up), Ctrl+k = later (down)
1023                // — matches "k moves down" and keeps arrow+Ctrl natural.
1024                UiKeyCode::Char('j') => {
1025                    if key.modifiers.contains_control() {
1026                        client.worker_route_editor_move_selected(-1);
1027                    } else {
1028                        client.worker_route_editor_select(1);
1029                    }
1030                }
1031                UiKeyCode::Char('k') => {
1032                    if key.modifiers.contains_control() {
1033                        client.worker_route_editor_move_selected(1);
1034                    } else {
1035                        client.worker_route_editor_select(-1);
1036                    }
1037                }
1038                UiKeyCode::Char('d') | UiKeyCode::Delete => {
1039                    client.worker_route_editor_delete_selected()
1040                }
1041                UiKeyCode::Char('x') => client.worker_route_editor_clear_stops(),
1042                UiKeyCode::Char('a') => client.re_open_add_menu(),
1043                UiKeyCode::Char('l') => client.re_open_bed_picker(),
1044                UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
1045                UiKeyCode::Enter => client.re_edit_selected_stop(),
1046                _ => {}
1047            }
1048        } else {
1049            // Inside a setup sheet: uniform picker keys.
1050            let filter_focused = client
1051                .state
1052                .worker_route_editor
1053                .as_ref()
1054                .map(|ed| ed.sheet_filter_focused)
1055                .unwrap_or(false);
1056            let sheet_index = client.re_sheet_index();
1057
1058            if filter_focused {
1059                // Search mode: every key is filter text until Enter (no j/k/list binds).
1060                match key.code {
1061                    UiKeyCode::Esc => {
1062                        let _ = client.clear_or_blur_re_sheet_filter();
1063                    }
1064                    UiKeyCode::Enter => client.re_blur_sheet_filter_keep_text(),
1065                    UiKeyCode::Backspace => client.re_sheet_filter_backspace(),
1066                    UiKeyCode::Char(c) => client.re_append_sheet_filter_char(c),
1067                    _ => {}
1068                }
1069            } else {
1070                match key.code {
1071                    UiKeyCode::Esc => {
1072                        if !client.clear_or_blur_re_sheet_filter() {
1073                            client.re_sheet_back();
1074                        }
1075                    }
1076                    UiKeyCode::Char('z') => client.worker_route_editor_toggle_panel(),
1077                    UiKeyCode::PageUp => client.re_sheet_page(-1),
1078                    UiKeyCode::PageDown => client.re_sheet_page(1),
1079                    UiKeyCode::Char('/') => client.re_focus_sheet_filter(),
1080                    UiKeyCode::Char('j') | UiKeyCode::Down => client.re_sheet_move(1),
1081                    UiKeyCode::Char('k') | UiKeyCode::Up => client.re_sheet_move(-1),
1082                    UiKeyCode::Enter | UiKeyCode::Char(' ') => {
1083                        client.re_sheet_row_activate(sheet_index)
1084                    }
1085                    UiKeyCode::Char('-') => client.re_sheet_adjust(-1),
1086                    UiKeyCode::Char('+') | UiKeyCode::Char('=') => client.re_sheet_adjust(1),
1087                    _ => {}
1088                }
1089            }
1090        }
1091        return true;
1092    }
1093
1094    if client.state.show_plant_menu {
1095        match key.code {
1096            UiKeyCode::Esc => client.close_plant_menu(),
1097            UiKeyCode::Up | UiKeyCode::Char('k') => client.plant_menu_move(-1),
1098            UiKeyCode::Down | UiKeyCode::Char('j') => client.plant_menu_move(1),
1099            UiKeyCode::Char('-') => {
1100                client.plant_menu_adjust_quantity(-1)
1101            }
1102            UiKeyCode::Char('+') | UiKeyCode::Char('=') => {
1103                client.plant_menu_adjust_quantity(1)
1104            }
1105            UiKeyCode::Char('a') | UiKeyCode::Char('A') => client.plant_menu_set_quantity_max(),
1106            UiKeyCode::Char('0') => client.plant_menu_set_quantity_min(),
1107            UiKeyCode::Enter | UiKeyCode::Char('f') | UiKeyCode::Char('F') => {
1108                if let Err(err) = client.confirm_plant_menu().await {
1109                    client.state.push_log(format!("Plant: {err}"));
1110                }
1111            }
1112            _ => {}
1113        }
1114        return true;
1115    }
1116
1117    if client.state.show_farm_access {
1118        match key.code {
1119            UiKeyCode::Esc => client.close_farm_access_panel(),
1120            UiKeyCode::Up | UiKeyCode::Char('k') => client.farm_access_move(-1),
1121            UiKeyCode::Down | UiKeyCode::Char('j') => client.farm_access_move(1),
1122            UiKeyCode::Enter | UiKeyCode::Char(' ') => {
1123                if let Err(err) = client.farm_access_activate().await {
1124                    client.state.push_log(format!("Farm access: {err}"));
1125                }
1126            }
1127            UiKeyCode::Char('[') | UiKeyCode::Char('-') => {
1128                if let Err(err) = client.farm_access_adjust_discount(-100).await {
1129                    client.state.push_log(format!("Farm access: {err}"));
1130                }
1131            }
1132            UiKeyCode::Char(']') | UiKeyCode::Char('=') => {
1133                if let Err(err) = client.farm_access_adjust_discount(100).await {
1134                    client.state.push_log(format!("Farm access: {err}"));
1135                }
1136            }
1137            _ => {}
1138        }
1139        return true;
1140    }
1141
1142    if client.state.show_worker_give_picker {
1143        match key.code {
1144            UiKeyCode::Esc => client.close_worker_give_picker(),
1145            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_picker_move(-1),
1146            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_picker_move(1),
1147            UiKeyCode::Enter => {
1148                if let Err(err) = client.confirm_worker_give_picker().await {
1149                    client.state.push_log(format!("Give: {err}"));
1150                }
1151            }
1152            _ => {}
1153        }
1154        return true;
1155    }
1156
1157    if client.state.show_worker_give_target_picker {
1158        match key.code {
1159            UiKeyCode::Esc => client.close_worker_give_target_picker(),
1160            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_give_target_picker_move(-1),
1161            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_give_target_picker_move(1),
1162            UiKeyCode::Enter => {
1163                if let Err(err) = client.confirm_worker_give_target_picker().await {
1164                    client.state.push_log(format!("Give: {err}"));
1165                }
1166            }
1167            _ => {}
1168        }
1169        return true;
1170    }
1171
1172    if client.state.show_worker_take_picker {
1173        match key.code {
1174            UiKeyCode::Esc => client.close_worker_take_picker(),
1175            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_take_picker_move(-1),
1176            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_take_picker_move(1),
1177            UiKeyCode::Char('-') => {
1178                client.worker_take_picker_adjust_quantity(-1)
1179            }
1180            UiKeyCode::Char('+') | UiKeyCode::Char('=') => {
1181                client.worker_take_picker_adjust_quantity(1)
1182            }
1183            UiKeyCode::Char('a') => client.worker_take_picker_set_quantity_max(),
1184            UiKeyCode::Char('0') => client.worker_take_picker_set_quantity_min(),
1185            UiKeyCode::Enter => {
1186                if let Err(err) = client.confirm_worker_take_picker().await {
1187                    client.state.push_log(format!("Take: {err}"));
1188                }
1189            }
1190            _ => {}
1191        }
1192        return true;
1193    }
1194
1195    if client.state.show_worker_teach_picker {
1196        match key.code {
1197            UiKeyCode::Esc => client.close_worker_teach_picker(),
1198            UiKeyCode::Up | UiKeyCode::Char('k') => client.worker_teach_picker_move(-1),
1199            UiKeyCode::Down | UiKeyCode::Char('j') => client.worker_teach_picker_move(1),
1200            UiKeyCode::Enter => {
1201                if let Err(err) = client.confirm_worker_teach_picker().await {
1202                    client.state.push_log(format!("Teach: {err}"));
1203                }
1204            }
1205            _ => {}
1206        }
1207        return true;
1208    }
1209
1210    if client.state.show_workers_menu {
1211        match key.code {
1212            UiKeyCode::Esc => {
1213                if let Err(err) = client.close_workers_menu().await {
1214                    client.state.push_log(format!("Worker: {err}"));
1215                }
1216            }
1217            UiKeyCode::Up | UiKeyCode::Char('k') => client.workers_menu_move(-1),
1218            UiKeyCode::Down | UiKeyCode::Char('j') => client.workers_menu_move(1),
1219            UiKeyCode::PageUp => client.workers_menu_page(-1),
1220            UiKeyCode::PageDown => client.workers_menu_page(1),
1221            UiKeyCode::Char('d') => {
1222                if let Err(err) = client.workers_dismiss_selected().await {
1223                    client.state.push_log(format!("Worker: {err}"));
1224                }
1225            }
1226            UiKeyCode::Char('r') => {
1227                if let Err(err) = client.hire_worker_laborer().await {
1228                    client.state.push_log(format!("Hire: {err}"));
1229                }
1230            }
1231            UiKeyCode::Char('e') => {
1232                if let Err(err) = client.open_worker_route_editor_for_selected() {
1233                    client.state.push_log(format!("Route: {err}"));
1234                }
1235            }
1236            UiKeyCode::Char('n') => {
1237                if let Err(err) = client.open_worker_rename() {
1238                    client.state.push_log(format!("Rename: {err}"));
1239                }
1240            }
1241            UiKeyCode::Char('g') => {
1242                if let Err(err) = client.open_worker_give_picker() {
1243                    client.state.push_log(format!("Give: {err}"));
1244                }
1245            }
1246            UiKeyCode::Char('i') => {
1247                if let Err(err) = client.open_worker_take_picker() {
1248                    client.state.push_log(format!("Take: {err}"));
1249                }
1250            }
1251            UiKeyCode::Char('t') => {
1252                if let Err(err) = client.open_worker_teach_picker() {
1253                    client.state.push_log(format!("Teach: {err}"));
1254                }
1255            }
1256            UiKeyCode::Char('c') => client.toggle_workers_menu_compact(),
1257            UiKeyCode::Enter => {
1258                if let Err(err) = client.workers_confirm_action().await {
1259                    client.state.push_log(format!("Worker: {err}"));
1260                }
1261            }
1262            _ => {}
1263        }
1264        return true;
1265    }
1266
1267    false
1268}
1269
1270pub async fn dispatch_action<S: PlayConnection>(
1271    client: &mut GameClient<S>,
1272    _keys: &ClientKeyBindings,
1273    action: InputAction,
1274    ctx: &mut ActionCtx<'_>,
1275) {
1276    match action {
1277        InputAction::StartChat { whisper } => {
1278            if whisper {
1279                // Stone contacts: pick inside CHAT column (no separate popup).
1280                client.state.whisper_pouch_ui.open = false;
1281                client.state.social_chat.picking_stone = true;
1282                client.state.social_chat.stone_pick_index = 0;
1283                client.state.social_chat.input_focused = false;
1284                client.state.social_chat.push_system(
1285                    "Whisper stones — ↑↓ select · Enter · Esc cancel",
1286                );
1287            } else {
1288                client.state.social_chat.focus_nearby();
1289            }
1290        }
1291        InputAction::Harvest => {
1292            if let Err(err) = client.harvest_nearest().await {
1293                client.state.push_log(format!("Harvest failed: {err}"));
1294            }
1295        }
1296        InputAction::Pickup => {
1297            if client.pickup_nearest().await.is_err() {
1298                if let Err(err) = client.pickup_nearest_container().await {
1299                    client.state.push_log(format!("Pickup: {err}"));
1300                }
1301            }
1302        }
1303        InputAction::Craft => client.open_craft_menu(),
1304        InputAction::Interact | InputAction::UseWorld => {
1305            if let Err(err) = client.use_nearest().await {
1306                client.state.push_log(format!("Use: {err}"));
1307            }
1308        }
1309        InputAction::ClaimPlotBegin => {
1310            if let Err(err) = client.try_begin_claim_mode().await {
1311                client.state.push_log(format!("Claim: {err}"));
1312            }
1313        }
1314        InputAction::FarmCultivate => {
1315            if let Err(err) = client.farm_cultivate_underfoot().await {
1316                client.state.push_log(format!("Cultivate: {err}"));
1317            }
1318        }
1319        InputAction::FarmPlant => {
1320            if let Err(err) = client.farm_plant_underfoot().await {
1321                client.state.push_log(format!("Plant: {err}"));
1322            }
1323        }
1324        InputAction::PlotBuild => {
1325            if client.state.show_plot_build_menu {
1326                client.close_plot_build_menu();
1327            } else if let Err(err) = client.open_plot_build_menu() {
1328                client.state.push_log(format!("Build: {err}"));
1329            }
1330        }
1331        InputAction::DoorLockToggle => {
1332            if let Err(err) = client.toggle_nearby_door_lock().await {
1333                client.state.push_log(format!("Door: {err}"));
1334            }
1335        }
1336        InputAction::EnterBuildingDoor => {
1337            if let Err(err) = client.enter_nearby_open_door().await {
1338                client.state.push_log(format!("Enter: {err}"));
1339            }
1340        }
1341        InputAction::ExitBuildingDoor => {
1342            if let Err(err) = client.exit_nearby_building_door().await {
1343                client.state.push_log(format!("Exit: {err}"));
1344            }
1345        }
1346        InputAction::ClaimConfirm => {
1347            if let Err(err) = client.confirm_buy_plot().await {
1348                client.state.push_log(format!("Claim: {err}"));
1349            }
1350        }
1351        InputAction::ClaimCancel => {
1352            client.cancel_claim_mode();
1353        }
1354        InputAction::ClaimNudge { dw, dh } => {
1355            client.claim_nudge(dw, dh);
1356        }
1357        InputAction::ClaimMoveNudge { dx, dy } => {
1358            client.claim_move_nudge(dx, dy);
1359        }
1360        InputAction::ClaimPreset { w, h } => {
1361            client.claim_set_preset(w, h);
1362        }
1363        InputAction::RelocateBeginNearest => {
1364            if let Err(err) = client.try_begin_relocate_nearest() {
1365                client.state.push_log(format!("Relocate: {err}"));
1366            }
1367        }
1368        InputAction::RelocateConfirm => {
1369            if let Err(err) = client.confirm_relocate_container().await {
1370                client.state.push_log(format!("Relocate: {err}"));
1371            }
1372        }
1373        InputAction::RelocateCancel => {
1374            client.cancel_relocate_mode();
1375        }
1376        InputAction::RelocateNudge { dx, dy } => {
1377            client.relocate_nudge(dx, dy);
1378        }
1379        InputAction::TestDamage => {
1380            if let Err(err) = client.test_damage(25.0).await {
1381                client.state.push_log(format!("Damage failed: {err}"));
1382            }
1383        }
1384        InputAction::CycleCombatTarget { reverse } => {
1385            if let Err(err) = client.cycle_combat_target(reverse).await {
1386                client.state.push_log(format!("T1 target: {err}"));
1387            }
1388        }
1389        InputAction::CycleCombatTargetT2 { reverse } => {
1390            if let Err(err) = client.cycle_combat_target_slot(2, reverse).await {
1391                client.state.push_log(format!("T2 target: {err}"));
1392            }
1393        }
1394        InputAction::AdvanceRotationT1 => {
1395            if let Err(err) = client.advance_rotation(1).await {
1396                client.state.push_log(format!("T1 step: {err}"));
1397            }
1398        }
1399        InputAction::AdvanceRotationT2 => {
1400            if let Err(err) = client.advance_rotation(2).await {
1401                client.state.push_log(format!("T2 step: {err}"));
1402            }
1403        }
1404        InputAction::ToggleAutoT1 => {
1405            if let Err(err) = client.toggle_auto_attack_slot(1).await {
1406                client.state.push_log(format!("T1 auto: {err}"));
1407            }
1408        }
1409        InputAction::ToggleAutoT2 => {
1410            if let Err(err) = client.toggle_auto_attack_slot(2).await {
1411                client.state.push_log(format!("T2 auto: {err}"));
1412            }
1413        }
1414        InputAction::ToggleLoadout => {
1415            client.state.show_rotation_editor = false;
1416            client.state.rotation_editor.reset();
1417            client.state.show_loadout_menu = !client.state.show_loadout_menu;
1418            if client.state.show_loadout_menu {
1419                client.state.loadout_focus_presets = true;
1420                client.state.loadout_hotbar_slot = client.state.loadout_hotbar_slot.clamp(1, 9);
1421                let ability_len = client.state.loadout_hotbar_choices().len();
1422                if ability_len > 0 {
1423                    client.state.loadout_ability_index =
1424                        client.state.loadout_ability_index.min(ability_len - 1);
1425                } else {
1426                    client.state.loadout_ability_index = 0;
1427                }
1428                let preset_len = client.state.rotation_presets.len();
1429                if preset_len > 0 {
1430                    client.state.loadout_menu_index =
1431                        client.state.loadout_menu_index.min(preset_len - 1);
1432                } else {
1433                    client.state.loadout_menu_index = 0;
1434                }
1435                *ctx.auto_nav = None;
1436                let _ = client.stop().await;
1437            }
1438        }
1439        InputAction::ToggleRotationEditor => {
1440            client.state.show_loadout_menu = false;
1441            if client.state.show_rotation_editor {
1442                client.state.show_rotation_editor = false;
1443                client.state.rotation_editor.reset();
1444            } else {
1445                client.state.show_rotation_editor = true;
1446                client.state.rotation_editor.reset();
1447                let max = client.state.rotation_presets.len();
1448                if max > 0 {
1449                    client.state.rotation_editor.list_index =
1450                        client.state.rotation_editor.list_index.min(max - 1);
1451                }
1452            }
1453            if client.state.show_rotation_editor {
1454                *ctx.auto_nav = None;
1455                let _ = client.stop().await;
1456            }
1457        }
1458        InputAction::LoadoutMenuUp => {
1459            if !client.state.show_loadout_menu {
1460                return;
1461            }
1462            if client.state.loadout_focus_presets {
1463                if client.state.loadout_menu_index > 0 {
1464                    client.state.loadout_menu_index -= 1;
1465                }
1466            } else if client.state.loadout_ability_index > 0 {
1467                client.state.loadout_ability_index -= 1;
1468            }
1469        }
1470        InputAction::LoadoutMenuDown => {
1471            if !client.state.show_loadout_menu {
1472                return;
1473            }
1474            if client.state.loadout_focus_presets {
1475                let max = client.state.rotation_presets.len();
1476                if max > 0 {
1477                    client.state.loadout_menu_index =
1478                        (client.state.loadout_menu_index + 1).min(max - 1);
1479                }
1480            } else {
1481                let max = client.state.loadout_hotbar_choices().len();
1482                if max > 0 {
1483                    client.state.loadout_ability_index =
1484                        (client.state.loadout_ability_index + 1).min(max - 1);
1485                }
1486            }
1487        }
1488        InputAction::LoadoutHotbarPrev => {
1489            if client.state.show_loadout_menu {
1490                let slot = client.state.loadout_hotbar_slot;
1491                client.state.loadout_hotbar_slot = if slot <= 1 { 9 } else { slot - 1 };
1492            }
1493        }
1494        InputAction::LoadoutHotbarNext => {
1495            if client.state.show_loadout_menu {
1496                let slot = client.state.loadout_hotbar_slot;
1497                client.state.loadout_hotbar_slot = if slot >= 9 { 1 } else { slot + 1 };
1498            }
1499        }
1500        InputAction::LoadoutToggleFocus => {
1501            if client.state.show_loadout_menu {
1502                client.state.loadout_focus_presets = !client.state.loadout_focus_presets;
1503            }
1504        }
1505        InputAction::LoadoutBindHotbar => {
1506            if !client.state.show_loadout_menu {
1507                return;
1508            }
1509            let slot = client.state.loadout_hotbar_slot;
1510            let binding = {
1511                let choices = client.state.loadout_hotbar_choices();
1512                choices
1513                    .get(client.state.loadout_ability_index)
1514                    .map(|c| c.binding.clone())
1515            };
1516            if let Some(binding) = binding {
1517                if let Err(err) = client.set_hotbar_slot(slot, Some(&binding)).await {
1518                    client.state.push_log(format!("Hotbar: {err}"));
1519                }
1520            } else {
1521                client.state.push_log("No ability/consumable selected to bind");
1522            }
1523        }
1524        InputAction::LoadoutClearHotbar => {
1525            if !client.state.show_loadout_menu {
1526                return;
1527            }
1528            let slot = client.state.loadout_hotbar_slot;
1529            if let Err(err) = client.set_hotbar_slot(slot, None).await {
1530                client.state.push_log(format!("Hotbar: {err}"));
1531            }
1532        }
1533        InputAction::LoadoutAssignT1 => {
1534            if !client.state.show_loadout_menu {
1535                return;
1536            }
1537            let preset = {
1538                let idx = client.state.loadout_menu_index;
1539                client.state.rotation_presets.get(idx).cloned()
1540            };
1541            if let Some(preset) = preset {
1542                let already = client
1543                    .state
1544                    .combat_slots
1545                    .iter()
1546                    .find(|s| s.slot_index == 1)
1547                    .and_then(|s| s.preset_id.as_deref())
1548                    == Some(preset.id.as_str());
1549                if already {
1550                    client.state.push_log(format!(
1551                        "T1 already uses {} (equip weapons from inventory — Weapon auto tracks mainhand)",
1552                        preset.label
1553                    ));
1554                } else if let Err(err) = client.assign_slot_preset(1, &preset.id).await {
1555                    client.state.push_log(format!("Loadout: {err}"));
1556                } else {
1557                    client
1558                        .state
1559                        .push_log(format!("T1 ← {}", preset.label));
1560                }
1561            }
1562        }
1563        InputAction::LoadoutAssignT2 => {
1564            if !client.state.show_loadout_menu {
1565                return;
1566            }
1567            let preset = {
1568                let idx = client.state.loadout_menu_index;
1569                client.state.rotation_presets.get(idx).cloned()
1570            };
1571            if let Some(preset) = preset {
1572                let already = client
1573                    .state
1574                    .combat_slots
1575                    .iter()
1576                    .find(|s| s.slot_index == 2)
1577                    .and_then(|s| s.preset_id.as_deref())
1578                    == Some(preset.id.as_str());
1579                if already {
1580                    client
1581                        .state
1582                        .push_log(format!("T2 already uses {}", preset.label));
1583                } else if let Err(err) = client.assign_slot_preset(2, &preset.id).await {
1584                    client.state.push_log(format!("Loadout: {err}"));
1585                } else {
1586                    client
1587                        .state
1588                        .push_log(format!("T2 ← {}", preset.label));
1589                }
1590            }
1591        }
1592        InputAction::CloseOverlay => {
1593            client.state.show_loadout_menu = false;
1594            client.state.show_rotation_editor = false;
1595            client.state.rotation_editor.reset();
1596        }
1597        InputAction::ClearCombatTarget => {
1598            if let Err(err) = client.clear_combat_target().await {
1599                client.state.push_log(format!("Target: {err}"));
1600            }
1601        }
1602        InputAction::ClearCombatTargetT2 => {
1603            if let Err(err) = client.clear_combat_target_slot(2).await {
1604                client.state.push_log(format!("T2 target: {err}"));
1605            }
1606        }
1607        InputAction::Dodge => {
1608            *ctx.auto_nav = None;
1609            if let Err(err) = client.dodge().await {
1610                client.state.push_log(format!("Dodge: {err}"));
1611            }
1612        }
1613        InputAction::Lunge => {
1614            *ctx.auto_nav = None;
1615            let _ = client.stop().await;
1616            if let Err(err) = client.lunge().await {
1617                client.state.push_log(format!("Lunge: {err}"));
1618            }
1619        }
1620        InputAction::DirectionalJump { forward, strafe } => {
1621            *ctx.auto_nav = None;
1622            let _ = client.stop().await;
1623            if let Err(err) = client.directional_jump(forward, strafe).await {
1624                client.state.push_log(format!("Jump: {err}"));
1625            }
1626        }
1627        InputAction::CastHotbar { slot } => {
1628            if let Err(err) = client.cast_hotbar_ability(slot).await {
1629                client.state.push_log(format!("Hotbar {slot}: {err}"));
1630            }
1631        }
1632        InputAction::ToggleBlock => {
1633            if !client.state.blocking_active {
1634                if let Err(err) = client.set_block(true).await {
1635                    client.state.push_log(format!("Block: {err}"));
1636                }
1637            }
1638        }
1639        InputAction::ToggleStats => client.toggle_stats(),
1640        InputAction::ToggleEquip => client.toggle_equip_menu(),
1641        InputAction::CycleCharacterSheetTab => client.cycle_character_sheet_tab(),
1642        InputAction::LedgerPeriodDigit(c) => client.set_ledger_period_digit(c),
1643        InputAction::ToggleInventory => client.toggle_inventory_menu(),
1644        InputAction::ToggleKeychain => client.toggle_keychain_menu(),
1645        InputAction::ToggleQuestMenu => client.toggle_quest_menu(),
1646        InputAction::ToggleWorkersMenu => {
1647            if client.state.show_workers_menu {
1648                if let Err(err) = client.close_workers_menu().await {
1649                    client.state.push_log(format!("Worker: {err}"));
1650                }
1651            } else {
1652                client.toggle_workers_menu();
1653            }
1654        }
1655        InputAction::QuestMenuUp => client.quest_menu_move(-1),
1656        InputAction::QuestMenuDown => client.quest_menu_move(1),
1657        InputAction::QuestWithdraw => client.quest_request_withdraw(),
1658        InputAction::RotationEditorBack => {
1659            let _ = client.back_on_esc();
1660        }
1661        InputAction::RotationEditorListUp
1662        | InputAction::RotationEditorListDown
1663        | InputAction::RotationEditorEdit
1664        | InputAction::RotationEditorNew
1665        | InputAction::RotationEditorDelete
1666        | InputAction::RotationEditorAddAbility
1667        | InputAction::RotationEditorRemoveAbility
1668        | InputAction::RotationEditorMoveAbilityUp
1669        | InputAction::RotationEditorMoveAbilityDown
1670        | InputAction::RotationEditorAbilityUp
1671        | InputAction::RotationEditorAbilityDown
1672        | InputAction::RotationEditorPickerUp
1673        | InputAction::RotationEditorPickerDown
1674        | InputAction::RotationEditorPickAbility
1675        | InputAction::RotationEditorRename
1676        | InputAction::RotationEditorConfirmLabel
1677        | InputAction::RotationEditorLabelBackspace
1678        | InputAction::RotationEditorLabelChar(_)
1679        | InputAction::RotationEditorSave => {
1680            handle_rotation_editor_action(client, action).await;
1681        }
1682        InputAction::None
1683        | InputAction::Quit
1684        | InputAction::ToggleHelp
1685        | InputAction::CycleHudView
1686        | InputAction::SubmitChat
1687        | InputAction::CancelChat
1688        | InputAction::ToggleSprintMode
1689        | InputAction::ToggleMapTarget
1690        | InputAction::ConfirmMapTarget
1691        | InputAction::CancelMapTarget
1692        | InputAction::MapTargetNudge { .. }
1693        | InputAction::CancelAutoNav
1694        | InputAction::StopMovement => {}
1695        InputAction::ToggleHudLog => {
1696            let hud_view = flatland_client_lib::ClientConfig::load()
1697                .hud_view
1698                .as_deref()
1699                .and_then(flatland_client_ui::HudViewMode::from_label)
1700                .unwrap_or_default();
1701            if hud_view != flatland_client_ui::HudViewMode::Normal {
1702                client.state.push_log("LOG hide/show only works in normal HUD (press .)");
1703                return;
1704            }
1705            client.state.hud_log_hidden = !client.state.hud_log_hidden;
1706            let mut cfg = flatland_client_lib::ClientConfig::load();
1707            let _ = cfg.save_hud_log_hidden(client.state.hud_log_hidden);
1708            if client.state.hud_log_hidden {
1709                client.state.push_log("LOG hidden — press ' to show (normal HUD)");
1710            } else {
1711                client.state.push_log("LOG shown — press ' to hide");
1712            }
1713        }
1714    }
1715}
1716
1717async fn handle_rotation_editor_action<S: PlayConnection>(
1718    client: &mut GameClient<S>,
1719    action: InputAction,
1720) {
1721    match action {
1722        InputAction::RotationEditorListUp => {
1723            if client.state.rotation_editor.list_index > 0 {
1724                client.state.rotation_editor.list_index -= 1;
1725            }
1726        }
1727        InputAction::RotationEditorListDown => {
1728            let max = client.state.rotation_presets.len();
1729            if max > 0 {
1730                client.state.rotation_editor.list_index =
1731                    (client.state.rotation_editor.list_index + 1).min(max - 1);
1732            }
1733        }
1734        InputAction::RotationEditorEdit => {
1735            let idx = client.state.rotation_editor.list_index;
1736            if let Some(preset) = client.state.rotation_presets.get(idx).cloned() {
1737                client.state.rotation_editor.draft = Some(preset);
1738                client.state.rotation_editor.ability_index = 0;
1739                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1740            }
1741        }
1742        InputAction::RotationEditorNew => {
1743            let preset = next_custom_preset(&client.state.rotation_presets);
1744            client.state.rotation_editor.list_index = client.state.rotation_presets.len();
1745            client.state.rotation_editor.draft = Some(preset);
1746            client.state.rotation_editor.ability_index = 0;
1747            client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1748        }
1749        InputAction::RotationEditorDelete => {
1750            let idx = client.state.rotation_editor.list_index;
1751            let preset_id = client.state.rotation_presets.get(idx).map(|p| p.id.clone());
1752            if let Some(id) = preset_id {
1753                if preset_deletable(&id) {
1754                    if let Err(err) = client.delete_rotation_preset(&id).await {
1755                        client.state.push_log(format!("Delete: {err}"));
1756                    } else {
1757                        let max = client.state.rotation_presets.len();
1758                        client.state.rotation_editor.list_index = if max == 0 {
1759                            0
1760                        } else {
1761                            client.state.rotation_editor.list_index.min(max - 1)
1762                        };
1763                    }
1764                } else {
1765                    client.state.push_log("Cannot delete built-in preset");
1766                }
1767            }
1768        }
1769        InputAction::RotationEditorBack => match client.state.rotation_editor.mode {
1770            RotationEditorMode::EditLabel => {
1771                client.state.rotation_editor.label_buffer.clear();
1772                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1773            }
1774            RotationEditorMode::PickAbility => {
1775                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1776            }
1777            RotationEditorMode::EditSequence => {
1778                client.state.rotation_editor.draft = None;
1779                client.state.rotation_editor.mode = RotationEditorMode::List;
1780            }
1781            RotationEditorMode::List => {}
1782        },
1783        InputAction::RotationEditorAddAbility => {
1784            client.state.rotation_editor.picker_index = 0;
1785            client.state.rotation_editor.mode = RotationEditorMode::PickAbility;
1786        }
1787        InputAction::RotationEditorRemoveAbility => {
1788            let idx = client.state.rotation_editor.ability_index;
1789            if let Some(draft) = &mut client.state.rotation_editor.draft {
1790                if idx < draft.abilities.len() {
1791                    draft.abilities.remove(idx);
1792                    if client.state.rotation_editor.ability_index >= draft.abilities.len()
1793                        && client.state.rotation_editor.ability_index > 0
1794                    {
1795                        client.state.rotation_editor.ability_index -= 1;
1796                    }
1797                }
1798            }
1799        }
1800        InputAction::RotationEditorMoveAbilityUp => {
1801            let i = client.state.rotation_editor.ability_index;
1802            if let Some(draft) = &mut client.state.rotation_editor.draft {
1803                if i > 0 && i < draft.abilities.len() {
1804                    draft.abilities.swap(i, i - 1);
1805                    client.state.rotation_editor.ability_index -= 1;
1806                }
1807            }
1808        }
1809        InputAction::RotationEditorMoveAbilityDown => {
1810            let i = client.state.rotation_editor.ability_index;
1811            if let Some(draft) = &mut client.state.rotation_editor.draft {
1812                if i + 1 < draft.abilities.len() {
1813                    draft.abilities.swap(i, i + 1);
1814                    client.state.rotation_editor.ability_index += 1;
1815                }
1816            }
1817        }
1818        InputAction::RotationEditorAbilityUp => {
1819            if client.state.rotation_editor.ability_index > 0 {
1820                client.state.rotation_editor.ability_index -= 1;
1821            }
1822        }
1823        InputAction::RotationEditorAbilityDown => {
1824            if let Some(draft) = &client.state.rotation_editor.draft {
1825                if !draft.abilities.is_empty() {
1826                    client.state.rotation_editor.ability_index =
1827                        (client.state.rotation_editor.ability_index + 1)
1828                            .min(draft.abilities.len() - 1);
1829                }
1830            }
1831        }
1832        InputAction::RotationEditorPickerUp => {
1833            if client.state.rotation_editor.picker_index > 0 {
1834                client.state.rotation_editor.picker_index -= 1;
1835            }
1836        }
1837        InputAction::RotationEditorPickerDown => {
1838            let choices = editor_ability_choices(
1839                &client.state.known_abilities,
1840                &client.state.weapon_ability_id,
1841                |id| client.state.ability_auto_rotation_eligible(id),
1842            );
1843            if !choices.is_empty() {
1844                client.state.rotation_editor.picker_index =
1845                    (client.state.rotation_editor.picker_index + 1).min(choices.len() - 1);
1846            }
1847        }
1848        InputAction::RotationEditorPickAbility => {
1849            let choices = editor_ability_choices(
1850                &client.state.known_abilities,
1851                &client.state.weapon_ability_id,
1852                |id| client.state.ability_auto_rotation_eligible(id),
1853            );
1854            let pick = client.state.rotation_editor.picker_index;
1855            if let Some(ability) = choices.get(pick) {
1856                if let Some(draft) = &mut client.state.rotation_editor.draft {
1857                    let max = client.state.max_abilities_per_rotation.max(1) as usize;
1858                    if draft.abilities.len() >= max {
1859                        client
1860                            .state
1861                            .push_log(format!("Rotation full (max {max} from INT+WIS)"));
1862                    } else {
1863                        draft.abilities.push(ability.clone());
1864                        client.state.rotation_editor.ability_index =
1865                            draft.abilities.len().saturating_sub(1);
1866                    }
1867                }
1868                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1869            }
1870        }
1871        InputAction::RotationEditorRename => {
1872            if let Some(draft) = &client.state.rotation_editor.draft {
1873                client.state.rotation_editor.label_buffer = draft.label.clone();
1874                client.state.rotation_editor.mode = RotationEditorMode::EditLabel;
1875            }
1876        }
1877        InputAction::RotationEditorConfirmLabel => {
1878            let label = client.state.rotation_editor.label_buffer.trim().to_string();
1879            if label.is_empty() {
1880                client.state.push_log("Label cannot be empty");
1881            } else if let Some(draft) = &mut client.state.rotation_editor.draft {
1882                draft.label = label;
1883                client.state.rotation_editor.label_buffer.clear();
1884                client.state.rotation_editor.mode = RotationEditorMode::EditSequence;
1885            }
1886        }
1887        InputAction::RotationEditorLabelBackspace => {
1888            client.state.rotation_editor.label_buffer.pop();
1889        }
1890        InputAction::RotationEditorLabelChar(c) => {
1891            if client.state.rotation_editor.label_buffer.len() < 32 {
1892                client.state.rotation_editor.label_buffer.push(c);
1893            }
1894        }
1895        InputAction::RotationEditorSave => {
1896            let draft = match client.state.rotation_editor.draft.clone() {
1897                Some(d) => d,
1898                None => return,
1899            };
1900            if draft.abilities.is_empty() {
1901                client.state.push_log("Rotation needs at least one ability");
1902                return;
1903            }
1904            let saved_id = draft.id.clone();
1905            if let Err(err) = client.upsert_rotation_preset(draft).await {
1906                client.state.push_log(format!("Save: {err}"));
1907            } else {
1908                client.state.rotation_editor.mode = RotationEditorMode::List;
1909                client.state.rotation_editor.draft = None;
1910                if let Some(i) = client
1911                    .state
1912                    .rotation_presets
1913                    .iter()
1914                    .position(|p| p.id == saved_id)
1915                {
1916                    client.state.rotation_editor.list_index = i;
1917                }
1918            }
1919        }
1920        _ => {}
1921    }
1922}