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