Skip to main content

tix/app/
shell.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// The batch selection's issue numbers, for the board highlight. `None` when
6    /// nothing is marked, so the renderer can skip the lookup entirely.
7    #[must_use]
8    pub(in crate::app) fn marked_numbers(&self) -> Option<&[u32]> {
9        self.bulk
10            .as_ref()
11            .map(|modal| modal.selection.numbers())
12            .filter(|numbers| !numbers.is_empty())
13    }
14
15    /// The marked set a content action should cover, when the anchor card is
16    /// marked and more than one issue is marked. `None` keeps the action on the
17    /// single issue the detail pane shows.
18    #[must_use]
19    pub(in crate::app) fn bulk_targets_for(&self, anchor: u32) -> Option<Vec<u32>> {
20        self.bulk
21            .as_ref()
22            .filter(|modal| modal.selection.len() > 1 && modal.selection.contains(anchor))
23            .map(|modal| modal.selection.numbers().to_vec())
24    }
25
26    /// The filenames of the marked set, for an archive/restore prompt. Falls back
27    /// to the anchor's own file.
28    pub(in crate::app) fn bulk_filenames(&self, anchor: u32, filename: &str) -> Vec<String> {
29        match self.bulk_targets_for(anchor) {
30            Some(numbers) => numbers
31                .iter()
32                .filter_map(|number| {
33                    self.store
34                        .get(IssueSelector::Number(*number))
35                        .ok()
36                        .map(|issue| issue.filename)
37                })
38                .collect(),
39            None => vec![filename.to_string()],
40        }
41    }
42
43    pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
44        if key.kind != KeyEventKind::Press {
45            return Ok(Action::None);
46        }
47        // Feedback belongs to the keypress that produced it. The next accepted
48        // key either replaces it with its own outcome or leaves the active mode
49        // showing its ordinary state.
50        self.action_feedback = None;
51        let context = self.input_context();
52        // The mode shell is consulted **before** the `F(12)` sentinel rewrite
53        // (tui-mode-navigation, D2). A shell chord (F1–F4/F12, ctrl+n) switches
54        // modes only when the active top-level context does not bind the key
55        // itself — so `F2` inside a form is still `open_picker`, and a genuinely
56        // unbound key still becomes the sentinel.
57        if let Some(action) = self.shell_action(context, key) {
58            let result = self.run_shell_action(action);
59            self.sync_top_mode();
60            return result;
61        }
62        // The rail owns the keyboard while it is focused, and is entered with
63        // `h`/`Left` when the active mode has no leftward target of its own
64        // (see `rail_entry_available`). Both checks sit after the shell chords,
65        // so a mode switch still works from inside the rail, and before the
66        // binding resolution, so the rail's `j`/`k`/`Enter` are read raw rather
67        // than rewritten to the mode's own bindings. Each view owns the
68        // availability rule: text editors keep `h`, while a form at column zero
69        // can hand `Left` to the rail.
70        if self.nav_focus.is_some() {
71            return self.handle_nav_key(key);
72        }
73        // The startup tips modal (tix #1632) owns the keyboard raw, and **before**
74        // the rail-entry check below: its keys include `Left`/`h`, which that check
75        // would otherwise read as "focus the navigation rail". Its keys are raw for
76        // the usual reason too — the board binds several of the same letters
77        // (`n`, `p`, `d`), and the binding layer would canonicalize them first.
78        if matches!(self.mode, Mode::Tips { .. }) {
79            return self.handle_tips_key(key);
80        }
81        // The `F10` Portfolio page's **Roadmap** tab (WP05) owns two raw-key layers
82        // ahead of the binding layer (and ahead of the rail-entry check below, so
83        // `h` typed into an owner name is a letter rather than a jump to the rail).
84        // An open overlay is a text surface, so its keys must not canonicalize
85        // (`j` would become `Down`, `q` would become `Esc`); and the tab's command
86        // letters (`c`/`e`/`a`/`x`/`f`/`r`) are not bound in
87        // `InputContext::Portfolio` and would otherwise be rewritten to the inert
88        // `F(12)` sentinel. Everything else on the page — `j`/`k`, `Tab`, `q`, `?` —
89        // still resolves through the bindings so a rebind keeps working, and the
90        // rail is never focused while an overlay is open.
91        if matches!(self.mode, Mode::Portfolio) {
92            if self.portfolio_roadmap_overlay.is_some() {
93                return self.handle_roadmap_overlay_key(key);
94            }
95            if self.handle_roadmap_tab_key(key)? {
96                return Ok(Action::None);
97            }
98        }
99        if matches!(key.code, KeyCode::Left | KeyCode::Char('h'))
100            && self.rail_entry_available(key.code)
101        {
102            self.focus_rail();
103            return Ok(Action::None);
104        }
105        // The filter modal's open value picker owns the keyboard **ahead of** the
106        // binding layer, exactly as the triage inbox's dropdown does: its keys must
107        // reach `DropdownState` unrewritten, or `l`/`enter` would canonicalize to the
108        // modal's open-picker action and the list could neither be filtered nor
109        // chosen once it was open. `esc` closes it (raw), and typing filters it.
110        if matches!(self.mode, Mode::FilterModal) && self.filter_picker.is_some() {
111            return self.handle_filter_picker_key(key);
112        }
113        // The due-warning overlay's four answers are raw keys, for the reason the
114        // picker above is: the board binds `d` (delete) and `g`… the same
115        // keystrokes, and the binding layer would canonicalize them to *its*
116        // events before the overlay ever saw the key the user pressed.
117        if matches!(self.mode, Mode::RecurringWarning) {
118            return self.handle_recurring_warning_key(key);
119        }
120        // The mention surfaces' keys are raw, for the same reason: the board binds
121        // some of the same letters, and the binding layer would canonicalize them
122        // into *its* events before the overlay saw the key the user pressed.
123        if matches!(self.mode, Mode::MentionNotice) {
124            return self.handle_mention_notice_key(key);
125        }
126        if matches!(self.mode, Mode::Mentions) {
127            return self.handle_mentions_view_key(key);
128        }
129        // The F8 Schedule page's own raw layer (WP07 T036/T037), placed with the
130        // other raw layers and ahead of the binding layer for the same reason they
131        // are: the page's `[schedule]` table in `tix_keybindings.toml` binds the Series tab's
132        // actions, so a `Tab` there resolves to the inert sentinel and `c`/`e`/`a`
133        // are unbound. Its bound keys (`j`/`k`, `q`, `g`, `p`, `r`, `s`, `i`) still
134        // resolve through the bindings, so a rebind of one keeps working.
135        if matches!(self.mode, Mode::Schedule) && self.handle_schedule_raw_key(key)? {
136            return Ok(Action::None);
137        }
138        // The `F7` Stats page's **Burndown** tab (WP06 T030), placed with the other raw
139        // layers and ahead of the binding layer for the same reason: the tab's controls
140        // (`[`, `]`, `p`, `t`, `u`, `v`, `d`, `D`) are bound nowhere, so the binding layer
141        // would rewrite them to the inert `F(12)` sentinel. Its bound keys (`j`/`k`,
142        // `Tab`, `?`, `q`) still resolve through the bindings, so a rebind keeps working,
143        // and the layer returns `false` on every other Stats tab.
144        if matches!(self.mode, Mode::Stats) && self.handle_burndown_raw_key(key)? {
145            return Ok(Action::None);
146        }
147        let key = if let Some(action) = self.bindings.action(context, key) {
148            if let Some(feedback) = self.action_refusal(context, action) {
149                if let Some(status) = feedback.status.clone() {
150                    self.status_msg = Some(status);
151                }
152                self.action_feedback = Some(feedback);
153                return Ok(Action::None);
154            }
155            self.bindings.canonical_event(context, action)
156        } else if Self::allows_text_fallback(context) {
157            key
158        } else {
159            KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)
160        };
161        let result = self.handle_resolved_key(context, key);
162        self.sync_top_mode();
163        result
164    }
165
166    /// The shell action a key resolves to in `context`, if any.
167    ///
168    /// `None` unless the context is one of the five top-level modes, the context
169    /// does **not** bind the key, and the shell context does. The local binding
170    /// always wins: this is the single rule that keeps `F2`-in-form as the picker.
171    pub(in crate::app) fn shell_action(
172        &self,
173        context: InputContext,
174        key: KeyEvent,
175    ) -> Option<TuiAction> {
176        if !matches!(
177            context,
178            InputContext::Board
179                | InputContext::Triage
180                | InputContext::Cycle
181                | InputContext::Milestones
182                | InputContext::PlanningManagement
183                | InputContext::Settings
184                | InputContext::Form
185        ) {
186            return None;
187        }
188        if self.bindings.action(context, key).is_some() {
189            return None;
190        }
191        self.bindings.action(InputContext::Shell, key)
192    }
193
194    /// Run a shell action: switch modes or cycle the navigation display.
195    pub(in crate::app) fn run_shell_action(&mut self, action: TuiAction) -> Result<Action> {
196        match action {
197            TuiAction::SwitchToBoard => self.switch_top_mode(TopMode::Board),
198            TuiAction::SwitchToTriage => self.switch_top_mode(TopMode::Triage),
199            TuiAction::SwitchToCycle => self.switch_top_mode(TopMode::Cycle),
200            TuiAction::SwitchToMilestones => self.switch_top_mode(TopMode::Milestones),
201            TuiAction::SwitchToRepos => self.switch_top_mode(TopMode::Repos),
202            TuiAction::SwitchToStats => self.switch_top_mode(TopMode::Stats),
203            TuiAction::HealthForm => self.open_health_form(),
204            TuiAction::SwitchToSchedule => self.switch_top_mode(TopMode::Schedule),
205            TuiAction::SwitchToLibrary => self.switch_top_mode(TopMode::Library),
206            TuiAction::SwitchToPortfolio => self.switch_top_mode(TopMode::Portfolio),
207            TuiAction::SwitchToNewIssue => self.switch_top_mode(TopMode::NewIssue),
208            TuiAction::SwitchToConfig => self.switch_top_mode(TopMode::Config),
209            TuiAction::ToggleNavigation => {
210                self.nav_display = self.nav_display.next();
211                if self.nav_display == NavDisplay::Off {
212                    // A cursor on a rail nobody can see is a keyboard trap.
213                    self.nav_focus = None;
214                }
215                Ok(Action::None)
216            }
217            _ => Ok(Action::None),
218        }
219    }
220
221    /// Switch the shell to `target`, reusing each mode's existing open path.
222    ///
223    /// The sibling top-level state is dropped first so a switch does not leave a
224    /// stale modal behind to reappear when the mode is left again; the *openers*
225    /// are the same methods the letter keys call.
226    pub fn switch_top_mode(&mut self, target: TopMode) -> Result<Action> {
227        // Released by default; only the Milestones arm re-arms it after opening
228        // the Config surface. Keeping this here (not derived from the target in
229        // `open_settings`) means every other entry point — `s`, `b` — is Config.
230        self.milestone_mode = false;
231        match target {
232            TopMode::Board => {
233                self.close_top_level_state();
234                self.mode = Mode::Board;
235                self.rebuild_board();
236            }
237            TopMode::Triage => {
238                self.close_top_level_state();
239                self.mode = Mode::Board;
240                let result = self.open_triage_inbox();
241                self.sync_top_mode();
242                return result;
243            }
244            TopMode::Cycle => {
245                self.close_top_level_state();
246                self.cycle_state = Some(crate::ui::cycle::CycleState::default());
247                self.mode = Mode::Cycle;
248            }
249            TopMode::NewIssue => {
250                self.close_top_level_state();
251                self.open_new_issue_form();
252            }
253            TopMode::Config => {
254                self.close_top_level_state();
255                self.open_settings(None);
256            }
257            TopMode::Milestones => {
258                // The Milestones mode is the Config surface pinned to the
259                // milestones section, with the section rail hidden (`render_config`
260                // checks `milestone_mode`). Pinning the planning segment here means
261                // the body's row cursor is already the milestones list.
262                self.close_top_level_state();
263                self.open_settings(None);
264                self.milestone_mode = true;
265                if let Some(settings) = self.settings.as_mut() {
266                    settings.section = ConfigSection::Milestones;
267                    // No rail is drawn; the index owns the keys.
268                    settings.focus(ConfigLevel::Index);
269                }
270                let modal = self.planning.get_or_insert_with(PlanningModal::new);
271                modal.segment = PlanningSegment::Milestones;
272                modal.prompt = None;
273                modal.selected = 0;
274            }
275            TopMode::Repos => {
276                self.close_top_level_state();
277                self.repos_cursor = 0;
278                self.mode = Mode::Repos;
279            }
280            TopMode::Stats => {
281                self.close_top_level_state();
282                self.stats_cursor = 0;
283                // WP05: the page opens on the tab it always showed, and the Time tab's
284                // snapshot is taken when that tab is first shown (or after a mutation),
285                // never per frame.
286                self.stats_tab = StatsTab::Health;
287                self.stats_time = None;
288                // WP06: the Burndown tab's selections are session state too, and its fold
289                // is taken when the tab is first shown — so the page opens on the tab it
290                // always showed, with no analytics read at all.
291                self.stats_burndown = None;
292                // Re-read before drawing: a page that opened with a stale document
293                // would show a state the files no longer have.
294                self.refresh_health();
295                self.mode = Mode::Stats;
296            }
297            TopMode::Schedule => {
298                self.close_top_level_state();
299                self.schedule = Some(crate::ui::schedule::ScheduleState::new());
300                // A page opened with a stale read would show a state the files no
301                // longer have: the capacity read and the schedule warnings are
302                // re-derived before the mode draws.
303                self.refresh_capacity_view();
304                self.refresh_cycle_schedule_warnings();
305                self.mode = Mode::Schedule;
306            }
307            TopMode::Library => {
308                self.close_top_level_state();
309                self.library = Some(crate::ui::library::LibraryState::new());
310                self.mode = Mode::Library;
311            }
312            TopMode::Portfolio => {
313                self.close_top_level_state();
314                self.portfolio = Some(crate::ui::portfolio::PortfolioState::new());
315                // The page opens on the empty filter; a filter is session state and
316                // is never persisted, so a fresh visit starts from "everything".
317                self.roadmap_filter = crate::roadmap::RoadmapFilter::default();
318                self.refresh_portfolio_projects();
319                self.mode = Mode::Portfolio;
320            }
321        }
322        self.sync_top_mode();
323        Ok(Action::None)
324    }
325
326    /// Drop the state of any top-level mode, so a switch starts clean.
327    pub(in crate::app) fn close_top_level_state(&mut self) {
328        self.triage = None;
329        self.cycle_state = None;
330        self.settings = None;
331        self.planning = None;
332        self.planning_form = None;
333        self.date_picker = None;
334        self.form = None;
335        self.form_picker = None;
336        self.filter_modal = None;
337        self.filter_picker = None;
338        self.schedule = None;
339        self.library = None;
340        self.portfolio = None;
341        self.portfolio_projects = None;
342        self.portfolio_reports = None;
343        self.portfolio_epics = None;
344        self.portfolio_roadmap = None;
345        self.portfolio_roadmap_overlay = None;
346        // WP05: the two time overlays belong to the Stats page; leaving the page (or
347        // entering another) drops them rather than leaving a form over an unrelated
348        // mode. The Time tab's snapshot is dropped with them, so the next visit
349        // re-reads the files (see `switch_top_mode`).
350        self.time_form = None;
351        self.time_filter_form = None;
352        self.stats_time = None;
353        // A message about the mode being left would be stale over the mode being
354        // entered; a refusal raised by the new mode's opener sets its own afterwards.
355        self.status_msg = None;
356        self.action_feedback = None;
357    }
358
359    /// Record the top-level mode implied by the current [`Mode`].
360    ///
361    /// Derived rather than assigned at every entry point: `t`, `m`, `n`, `s`, and
362    /// every close path already move `mode`, so reading it back keeps the
363    /// navigation highlight in step without a second place to update.
364    ///
365    /// A planning form or date picker opened **from the Config mode** belongs to
366    /// Config, not to the Cycle mode its input context would otherwise imply:
367    /// `settings` is `Some` exactly while the Config mode's state is present, and
368    /// its sub-surfaces keep it.
369    pub(in crate::app) fn sync_top_mode(&mut self) {
370        if self.settings.is_some() {
371            // The Config surface hosts two top-level modes: F12 Config, and the
372            // F5 Milestones mode that borrows it (`milestone_mode`). The flag,
373            // not the `InputContext`, decides which — `Milestones` is only one of
374            // the contexts this surface can be in.
375            self.top_mode = if self.milestone_mode {
376                TopMode::Milestones
377            } else {
378                TopMode::Config
379            };
380            return;
381        }
382        if let Some(mode) = Self::top_mode_for_context(self.input_context()) {
383            self.top_mode = mode;
384        }
385    }
386
387    pub(in crate::app) fn top_mode_for_context(context: InputContext) -> Option<TopMode> {
388        match context {
389            InputContext::Board => Some(TopMode::Board),
390            InputContext::Triage => Some(TopMode::Triage),
391            InputContext::Cycle => Some(TopMode::Cycle),
392            InputContext::PlanningManagement
393            | InputContext::PlanningConfirm
394            | InputContext::PlanningForm
395            | InputContext::DatePicker => Some(TopMode::Cycle),
396            InputContext::Settings | InputContext::SettingsPrompt => Some(TopMode::Config),
397            InputContext::Milestones => Some(TopMode::Milestones),
398            InputContext::Repos => Some(TopMode::Repos),
399            InputContext::Stats => Some(TopMode::Stats),
400            InputContext::Schedule => Some(TopMode::Schedule),
401            InputContext::Library => Some(TopMode::Library),
402            InputContext::Portfolio => Some(TopMode::Portfolio),
403            InputContext::Form | InputContext::FormPicker => Some(TopMode::NewIssue),
404            _ => None,
405        }
406    }
407
408    pub(in crate::app) fn allows_text_fallback(context: InputContext) -> bool {
409        matches!(
410            context,
411            InputContext::Form
412                | InputContext::FormPicker
413                | InputContext::Filter
414                | InputContext::FilterModal
415                // #1496: the overlay's `/` line takes free text. Without this, every
416                // letter arrives as `F(12)` — the rewrite happens before the handler sees
417                // the key — and the search line would be untypeable. The *list* mode is
418                // deliberately not listed here: it has no text entry, so an unbound key
419                // there is a no-op either way.
420                | InputContext::HelpSearch
421                | InputContext::ContextMenu
422                | InputContext::PlanningForm
423                | InputContext::Comment
424                | InputContext::Asset
425                | InputContext::MoveIntoDone
426                | InputContext::LinkPrompt
427                | InputContext::SettingsPrompt
428                | InputContext::BulkModal
429                | InputContext::Triage
430                // WP05: the views modal's name prompt. The **list** context is
431                // deliberately absent: it is a menu, so an unbound key there must
432                // be inert rather than typed.
433                | InputContext::ViewsPrompt
434        )
435    }
436
437    pub(in crate::app) fn input_context(&self) -> InputContext {
438        match &self.mode {
439            Mode::Board => InputContext::Board,
440            Mode::RolloverDialog => InputContext::Rollover,
441            Mode::Detail if self.related_picker.is_some() => InputContext::RelatedPicker,
442            Mode::Detail => InputContext::Detail,
443            Mode::Form if self.form_picker.is_some() => InputContext::FormPicker,
444            Mode::Form => InputContext::Form,
445            Mode::ConfirmDelete | Mode::ConfirmArchive { .. } => InputContext::ConfirmDelete,
446            Mode::MoveIntoDone { .. } => InputContext::MoveIntoDone,
447            Mode::TransitionReason { .. } => InputContext::MoveIntoDone,
448            Mode::ConfirmBlockedMove { .. } => InputContext::ConfirmBlockedMove,
449            Mode::Help { .. } if self.help_searching => InputContext::HelpSearch,
450            Mode::Help { .. } => InputContext::Help,
451            // The tips modal draws over the board (or the create form) and reads
452            // its own keys raw, so the board's context is the honest one: it is
453            // what the modal is standing in for while it is open.
454            Mode::Tips { .. } => InputContext::Board,
455            Mode::ActionMenu { .. } => InputContext::ContextMenu,
456            Mode::Filter { .. } => InputContext::Filter,
457            Mode::FilterModal => InputContext::FilterModal,
458            Mode::Settings
459                if self
460                    .settings
461                    .as_ref()
462                    .is_some_and(|modal| modal.prompt.is_some())
463                    // A planning delete confirmation raised from the Config
464                    // mode's cycles/milestones sections is answered with the
465                    // same `y`/`esc` keys, so it uses the same context.
466                    || self
467                        .planning
468                        .as_ref()
469                        .is_some_and(|modal| modal.prompt.is_some()) =>
470            {
471                InputContext::SettingsPrompt
472            }
473            Mode::Settings if self.milestone_mode => InputContext::Milestones,
474            Mode::Settings => InputContext::Settings,
475            Mode::PlanningManagement
476                if self
477                    .planning
478                    .as_ref()
479                    .is_some_and(|modal| modal.prompt.is_some()) =>
480            {
481                InputContext::PlanningConfirm
482            }
483            Mode::PlanningManagement => InputContext::PlanningManagement,
484            Mode::PlanningForm => InputContext::PlanningForm,
485            Mode::DatePicker { .. } => InputContext::DatePicker,
486            Mode::Comment { .. } => InputContext::Comment,
487            Mode::Asset { .. } => InputContext::Asset,
488            Mode::EditRepair { .. } => InputContext::EditRepair,
489            Mode::EditConflict { .. } => InputContext::EditConflict,
490            Mode::LinkCreate { .. } | Mode::LinkEdit { .. } => InputContext::LinkPrompt,
491            // #1384: a single-line selector prompt, so it shares the link prompt's
492            // Submit/Cancel bindings rather than adding a context that would bind
493            // the same two keys.
494            Mode::ParentPrompt { .. } => InputContext::LinkPrompt,
495            Mode::ConfirmRemoveRelationship { .. } => InputContext::ConfirmDelete,
496            Mode::BulkModal => InputContext::BulkModal,
497            Mode::TriageInbox => InputContext::Triage,
498            Mode::Cycle => InputContext::Cycle,
499            Mode::Repos => InputContext::Repos,
500            Mode::Stats => InputContext::Stats,
501            // The form carries free text, so it takes the typing context — the same
502            // one the WIP override prompt uses.
503            Mode::HealthForm => InputContext::Comment,
504            // WP05: both time overlays carry free text (minutes, a date, a reason, an
505            // author), so they take the typing context for the same reason.
506            Mode::TimeForm | Mode::TimeFilterForm => InputContext::Comment,
507            // The overlay draws over the board and its keys never reach the
508            // binding layer (see `handle_key`), so the board's context is the
509            // honest one: what is on screen underneath is the board.
510            // Both mention surfaces draw over the board and read their own keys
511            // raw (see `handle_key`), so the board's context is the honest one.
512            Mode::Mentions | Mode::MentionNotice => InputContext::Board,
513            Mode::RecurringWarning => InputContext::Board,
514            // The override modal carries the one free-text field, so it takes the
515            // text context: a reason is typed, not pressed.
516            Mode::WipOverride { .. } => InputContext::Comment,
517            Mode::Schedule => InputContext::Schedule,
518            Mode::Library => InputContext::Library,
519            Mode::Portfolio => InputContext::Portfolio,
520            // WP05. The views modal has two key layers, exactly as the settings
521            // modal has one and the triage inbox has one: a prompt takes the keys
522            // first (and is the only layer that carries free text), the list has
523            // them otherwise. A delete confirmation borrows the shared
524            // `ConfirmDelete` context, so `y`/`esc` mean here what they mean
525            // everywhere else that confirms.
526            Mode::Views => match self.views.as_ref().and_then(|modal| modal.prompt.as_ref()) {
527                Some(ViewsPrompt::ConfirmDelete { .. }) => InputContext::ConfirmDelete,
528                Some(ViewsPrompt::Create(_) | ViewsPrompt::Rename { .. }) => {
529                    InputContext::ViewsPrompt
530                }
531                None => InputContext::Views,
532            },
533        }
534    }
535
536    /// The batch modal's keys (WP07 T032/T033).
537    ///
538    /// Every branch here either changes the modal's **own** state or calls a shared
539    /// service. There is no `IssuePatch` built here and no `store.update` here: the
540    /// modal holds `PatchFlags` and the plan/apply live in `bulk::plan`/`bulk::apply`,
541    /// which is what "reject TUI-local patch construction or direct writes" asks for.
542    ///
543    /// `p` previews, `y` **arms** and then `y` again confirms, and `esc` cancels from
544    /// any state. Two `y` presses because "I saw it" and "I approved it" are two
545    /// decisions, and one `y` conflates them.
546    ///
547    /// Open the batch modal. The selection is whatever the board currently holds;
548    /// opening the modal never adds or removes a mark.
549    ///
550    /// The board's `(active_col, selected)` cursor is never written here, so opening
551    /// and cancelling the modal restores the board exactly (T031).
552    pub(in crate::app) fn open_bulk_modal(&mut self, action: crate::ui::bulk::BatchAction) {
553        if self.bulk.is_none() {
554            self.bulk = Some(crate::ui::bulk::BulkModal::new(action));
555        }
556        self.mode = Mode::BulkModal;
557    }
558
559    /// Toggle the card under the board cursor into the batch selection, **without**
560    /// opening the batch modal.
561    ///
562    /// This is what `space` does: marking is a board gesture, so the first keystroke
563    /// must not put a modal over the board. `B` remains the one way to open the
564    /// modal, and the status line names the running count on every change.
565    ///
566    /// A repeated `space` on the same card unmarks it — "toggle", not "add" — which
567    /// is the opposite of the modal's own `space` (that one refuses a duplicate,
568    /// because there it is a selection surface rather than a toggle).
569    pub(in crate::app) fn toggle_mark_selected(&mut self) {
570        let Some(number) = self.board.get_selected_card().map(|card| card.number) else {
571            self.status_msg = Some("no card under the cursor to mark".to_string());
572            return;
573        };
574        if self
575            .bulk
576            .as_ref()
577            .is_some_and(|modal| modal.selection.contains(number))
578        {
579            self.unmark_selected_card();
580            return;
581        }
582        let modal = self.bulk.get_or_insert_with(|| {
583            crate::ui::bulk::BulkModal::new(crate::ui::bulk::BatchAction::Edit)
584        });
585        match modal.selection.toggle(number) {
586            Ok(_) => {
587                let count = modal.selection.len();
588                self.status_msg = Some(format!("#{number} marked ({count} selected)"));
589            }
590            Err(problem) => self.status_msg = Some(problem.to_string()),
591        }
592    }
593
594    /// Unmark the card under the board cursor. A card that was not marked is
595    /// reported rather than silently ignored.
596    pub(in crate::app) fn unmark_selected_card(&mut self) {
597        let Some(number) = self.board.get_selected_card().map(|card| card.number) else {
598            self.status_msg = Some("no card under the cursor to unmark".to_string());
599            return;
600        };
601        let Some(modal) = self.bulk.as_mut() else {
602            self.status_msg = Some(format!("#{number} was not marked"));
603            return;
604        };
605        if !modal.selection.remove(number) {
606            self.status_msg = Some(format!("#{number} was not marked"));
607            return;
608        }
609        let remaining = modal.selection.len();
610        if remaining == 0 {
611            self.bulk = None;
612            self.status_msg = Some(format!("#{number} unmarked — no issues selected"));
613        } else {
614            self.status_msg = Some(format!("#{number} unmarked ({remaining} selected)"));
615        }
616    }
617
618    /// Empty the batch selection from the board, leaving the cursor in place.
619    pub(in crate::app) fn clear_marks(&mut self) {
620        let cleared = self
621            .bulk
622            .as_ref()
623            .is_some_and(|modal| !modal.selection.is_empty());
624        self.bulk = None;
625        self.status_msg = Some(if cleared {
626            "Batch selection cleared".to_string()
627        } else {
628            "no issues selected — press `space` to mark".to_string()
629        });
630    }
631
632    pub(in crate::app) fn handle_resolved_key(
633        &mut self,
634        _context: InputContext,
635        key: KeyEvent,
636    ) -> Result<Action> {
637        match self.mode.clone() {
638            Mode::Board => self.handle_board_key(key),
639            Mode::RolloverDialog => self.handle_rollover_dialog_key(key),
640            Mode::Detail => self.handle_detail_key(key),
641            Mode::Form => self.handle_form_key(key),
642            Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
643            Mode::ConfirmArchive { .. } => self.handle_confirm_archive_key(key),
644            Mode::MoveIntoDone { .. } => self.handle_move_into_done_key(key),
645            Mode::TransitionReason { .. } => self.handle_transition_reason_key(key),
646            Mode::ConfirmBlockedMove { warning } => {
647                self.handle_confirm_blocked_move_key(key, &warning)
648            }
649            Mode::Help { .. } => self.handle_help_key(key),
650            Mode::Tips { .. } => self.handle_tips_key(key),
651            Mode::ActionMenu { .. } => self.handle_action_menu_key(key),
652            Mode::Filter { .. } => self.handle_filter_key(key),
653            Mode::FilterModal => self.handle_filter_modal_key(key),
654            Mode::BulkModal => self.handle_bulk_modal_key(key),
655            Mode::TriageInbox => self.handle_triage_key(key),
656            Mode::Cycle => self.handle_cycle_key(key),
657            Mode::Repos => self.handle_repos_key(key),
658            Mode::Stats => self.handle_stats_key(key),
659            Mode::HealthForm => self.handle_health_form_key(key),
660            Mode::TimeForm => self.handle_time_form_key(key),
661            Mode::TimeFilterForm => self.handle_time_filter_form_key(key),
662            Mode::MentionNotice => self.handle_mention_notice_key(key),
663            Mode::Mentions => self.handle_mentions_view_key(key),
664            Mode::RecurringWarning => self.handle_recurring_warning_key(key),
665            Mode::WipOverride { .. } => self.handle_wip_override_key(key),
666            Mode::Schedule => self.handle_schedule_key(key),
667            Mode::Library => self.handle_library_key(key),
668            Mode::Portfolio => self.handle_portfolio_key(key),
669            Mode::Views => self.handle_views_key(key),
670            Mode::Settings => self.handle_settings_key(key),
671            Mode::PlanningManagement => self.handle_planning_key(key),
672            Mode::PlanningForm => self.handle_planning_form_key(key),
673            Mode::DatePicker { .. } => self.handle_date_picker_key(key),
674            Mode::Comment { .. } => self.handle_comment_key(key),
675            Mode::Asset { .. } => self.handle_asset_key(key),
676            Mode::EditRepair { .. } => self.handle_edit_repair_key(key),
677            Mode::EditConflict { .. } => self.handle_edit_conflict_key(key),
678            Mode::LinkCreate { .. } => self.handle_link_prompt_key(key),
679            Mode::LinkEdit { .. } => self.handle_link_prompt_key(key),
680            Mode::ParentPrompt { .. } => self.handle_parent_prompt_key(key),
681            Mode::ConfirmRemoveRelationship { .. } => self.handle_remove_relationship_key(key),
682        }
683    }
684}