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