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