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}