tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl App {
    #[allow(clippy::too_many_lines)]
    pub(in crate::app) fn handle_bulk_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
        use crate::ui::bulk::{BulkFocus, BulkState};
        let Some(mut modal) = self.bulk.take() else {
            self.mode = Mode::Board;
            return Ok(Action::None);
        };
        // The board's cards, in board order — what the user can see, which is what
        // they can have marked.
        let issues = crate::ui::bulk::visible_issues(&self.board, &self.store)?;
        let result = if modal.focus == BulkFocus::Edit {
            // Editing: every key is text. No command is reachable here, and that is
            // the point — one channel cannot be both a command set and a text field.
            match key.code {
                KeyCode::Esc => {
                    modal.abort_edit();
                    Ok(())
                }
                KeyCode::Enter => modal.commit_edit(),
                KeyCode::Backspace => {
                    modal.draft.pop();
                    Ok(())
                }
                KeyCode::Char(c) => {
                    modal.draft.push(c);
                    Ok(())
                }
                _ => Ok(()),
            }
        } else {
            match key.code {
                KeyCode::Esc => {
                    // Closes the modal, as every other modal here does on `esc`. The
                    // selection goes with it: `esc` is a cancel, and a cancel that
                    // leaves a half-built selection behind has not cancelled.
                    self.bulk = None;
                    self.mode = Mode::Board;
                    self.rebuild_board();
                    return Ok(Action::None);
                }
                KeyCode::Tab | KeyCode::Down => {
                    modal.move_cursor(1);
                    Ok(())
                }
                KeyCode::BackTab | KeyCode::Up => {
                    modal.move_cursor(-1);
                    Ok(())
                }
                KeyCode::Enter => {
                    if modal.row_names().is_empty() {
                        // An archive has no rows, so `enter` previews rather than
                        // entering nothing.
                        modal.preview(&self.store, &issues)
                    } else {
                        modal.begin_edit();
                        Ok(())
                    }
                }
                KeyCode::Char('p') => modal.preview(&self.store, &issues),
                KeyCode::Char('y') => {
                    if modal.is_armed() {
                        modal.confirm(&self.store)
                    } else {
                        modal.arm();
                        Ok(())
                    }
                }
                // The card under the **board** cursor, which is visible underneath
                // the modal. `X` empties the whole selection; `space` and `x` act on
                // one card. The board arms are the same three keys, so there is one
                // vocabulary and not two.
                KeyCode::Char('X') => {
                    modal.selection.clear();
                    modal.mark_stale();
                    Ok(())
                }
                KeyCode::Char(' ') | KeyCode::Char('x') => {
                    match self.board.get_selected_card().map(|c| c.number) {
                        None => Ok(()),
                        Some(card) => {
                            let outcome = if key.code == KeyCode::Char(' ') {
                                modal.selection.toggle(card).map(|_| true)
                            } else if modal.selection.remove(card) {
                                Ok(true)
                            } else {
                                Err(crate::ui::bulk::SelectionProblem::AlreadySelected)
                            };
                            match outcome {
                                Ok(_) => {
                                    modal.mark_stale();
                                    Ok(())
                                }
                                Err(problem) => {
                                    modal.note = Some(if key.code == KeyCode::Char('x') {
                                        // Unmarking something not marked is the
                                        // *desired* end state, so it reports rather
                                        // than errors.
                                        format!("#{card} was not selected")
                                    } else {
                                        problem.to_string()
                                    });
                                    Ok(())
                                }
                            }
                        }
                    }
                }
                KeyCode::Char('a') => {
                    // Cycle the action. The selection and the flags survive it: a user
                    // who marked ten cards does not re-mark them to change their mind
                    // about the verb.
                    modal.action = modal.action.next();
                    modal.state = BulkState::Selecting;
                    modal.plan_stale = true;
                    modal.cursor = 0;
                    Ok(())
                }
                KeyCode::Char('t') => {
                    // A pair's target defaults to the card the user is standing on,
                    // which is the other end of a link nine times in ten. It is a
                    // **default**, not a commitment: the `target` row is editable.
                    if let Some(card) = self.board.get_selected_card() {
                        modal.target = Some(card.number.to_string());
                        modal.mark_stale();
                    }
                    Ok(())
                }
                KeyCode::Char('c') => {
                    // `--clear` on the row under the cursor: the **`clear` row** is
                    // the one that carries the flag, never the cursor's own row. On
                    // the `clear` row it re-applies what is already named
                    // (idempotent); on any other row it names that row. `set_row`
                    // validates, so a non-clearable field (`status`) is a refusal
                    // rather than a flag the apply would ignore.
                    //
                    // The first version of this arm passed `modal.cursor` where the
                    // clear row's index belongs, so `c` on `priority` stored the
                    // word "priority" as the priority, left `flags.clear` empty, and
                    // made the next preview refuse with `invalid priority
                    // 'priority'`. It was the only key of this keymap with no test.
                    let names = modal.row_names();
                    match names.iter().position(|row| *row == "clear") {
                        None => {
                            // A pair has a target and a kind, an archive has no rows
                            // at all: there is no field to empty, and inventing one
                            // would offer a flag the apply cannot carry.
                            modal.note = Some(format!(
                                "a {} has no clearable fields — nothing to clear",
                                modal.action.as_str()
                            ));
                            Ok(())
                        }
                        Some(clear_row) => {
                            let name = modal.row_name(modal.cursor);
                            if name == "clear" {
                                let current = modal.row_value(clear_row);
                                if current == "—" {
                                    // Nothing is named, so there is nothing to
                                    // re-apply. The em dash the renderer shows for an
                                    // absent value is not a field name and must not
                                    // reach `set_row` as one.
                                    modal.note = Some(
                                        "no field is named — press c on the field you \
                                         want to empty"
                                            .to_string(),
                                    );
                                    Ok(())
                                } else {
                                    modal.set_row(clear_row, current)
                                }
                            } else {
                                modal.set_row(clear_row, name.to_string())
                            }
                        }
                    }
                }
                _ => Ok(()),
            }
        };
        if let Err(e) = &result {
            modal.note = Some(e.to_string());
        }
        // The modal closes on a **completed** apply, and on nothing else. A refusal
        // stays on screen with its reasons, because a refusal the user cannot read is
        // not a report — and WP06 already froze the rule that a refusal is a completed
        // command that reports non-zero, not an `Err`.
        let finished = matches!(modal.state, BulkState::Done(..)) && result.is_ok();
        self.bulk = Some(modal);
        if finished {
            // The modal is about to be dropped, so its note would be dropped with it.
            // The board's status line is where a result has to land.
            if let Some(note) = self.bulk.as_ref().and_then(|m| m.note.clone()) {
                self.status_msg = Some(note);
            }
            self.mode = Mode::Board;
            self.refresh_corpus()?;
        } else {
            self.mode = Mode::BulkModal;
        }
        // …and the same rule one level up. `run` propagates an `Err` out of
        // `handle_key` with `?`, so returning one here ends the session and takes the
        // note this function just recorded with it. Every other dispatch in this file
        // reports a user's refusal through `status_msg` and returns `Ok`; this one
        // used to return it, so `c` on the `status` row — or, with no `c` involved at
        // all, `enter` on the `clear` row with `title` typed in — printed
        // `cannot clear …` and quit the TUI. `visible_issues` above still returns
        // `Err`: that is an I/O failure behind the keyboard, not a request the user
        // made and was refused.
        //
        // Read by `finished` above, and deliberately not consumed: a refusal is a
        // completed command that reports.
        let _ = result;
        Ok(Action::None)
    }
}