tix-tracker 0.11.0

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

impl App {
    pub(in crate::app) fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
        // tix #1496: while the `/` line has the keyboard, every key belongs to the
        // query. The list underneath filters as you type, so Enter only *commits* the
        // input (the query stays and the list keeps its filter) and Esc clears it — the
        // same bargain `/` makes on the board, so one gesture does not mean two things
        // in the same tool. Closing the overlay is one Esc away in both states.
        if self.help_searching {
            match key.code {
                KeyCode::Enter => self.help_searching = false,
                KeyCode::Esc => {
                    self.help_query.clear();
                    self.help_searching = false;
                }
                KeyCode::Backspace => {
                    self.help_query.pop();
                }
                KeyCode::Char(c) => self.help_query.push(c),
                _ => {}
            }
            // A narrower list starts at the top: keeping an offset from the wider list
            // would scroll the matches away before the reader saw them.
            self.help_scroll = 0;
            return Ok(Action::None);
        }
        match key.code {
            KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
                self.close_help();
            }
            // Not a toggle: `/` starts the input line, and the query it leaves behind is
            // what filters the list. Reopening it with the query still there is how you
            // extend a search, and Esc inside it is how you clear one.
            KeyCode::Char('/') => self.help_searching = true,
            // tix #1441/#1442: the overlay scrolls — one row for the cursor keys its
            // context already binds (`next`/`previous`, which until now the help
            // advertised and this handler ignored), one page for PgUp/PgDn, one wheel
            // notch for the wheel. The same three steps the detail pane uses, because
            // "scroll" meaning something different per surface is how a keyboard stops
            // being learnable.
            KeyCode::Down | KeyCode::Char('j') => self.scroll_help(1),
            KeyCode::Up | KeyCode::Char('k') => self.scroll_help(-1),
            KeyCode::PageDown => self.scroll_help(i32::from(self.help_page())),
            KeyCode::PageUp => self.scroll_help(-i32::from(self.help_page())),
            _ => {}
        }
        Ok(Action::None)
    }

    /// Rows one page step moves in the overlay: what fits, less a row of overlap.
    pub(in crate::app) fn help_page(&self) -> u16 {
        self.help_metrics.get().visible.saturating_sub(1).max(1)
    }

    /// Open the keybindings overlay, always at the top and unfiltered (tix #1441).
    ///
    /// A help screen that reappeared halfway down — from a scroll the user made some
    /// minutes ago — would hide the first bindings from exactly the person who opened
    /// it to look them up. The same goes for a `/` query left over from last time
    /// (tix #1496).
    pub(in crate::app) fn open_help(&mut self, return_to: Mode) {
        self.help_scroll = 0;
        self.help_query.clear();
        self.help_searching = false;
        self.mode = Mode::Help {
            return_to: Box::new(return_to),
        };
    }

    /// Scroll the overlay by `rows` (negative scrolls up), clamped to the list.
    ///
    /// The clamp is what makes the last row reachable *and* keeps the list from being
    /// scrolled into emptiness — a bound the old truncating overlay did not need
    /// because it could not move at all.
    pub(in crate::app) fn scroll_help(&mut self, rows: i32) {
        let max = i32::from(self.help_metrics.get().max_scroll);
        let next = i32::from(self.help_scroll) + rows;
        self.help_scroll = next.clamp(0, max) as u16;
    }

    pub(in crate::app) fn close_help(&mut self) {
        if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
            self.mode = *return_to;
        }
    }

    /// Open the startup tips modal over whatever the session opened on (tix
    /// #1632), starting at a random tip so the same advice does not wear out.
    ///
    /// A no-op when the embedded list is empty, so a build whose `tips.toml`
    /// failed to parse simply has no dialog rather than an empty one.
    pub fn open_tips(&mut self) {
        let tips = crate::tips::all();
        if tips.is_empty() {
            return;
        }
        let return_to = std::mem::replace(&mut self.mode, Mode::Board);
        self.tips = Some(crate::ui::tips::TipsState::new(crate::tips::random_start(
            tips.len(),
        )));
        self.mode = Mode::Tips {
            return_to: Box::new(return_to),
        };
    }

    /// Close the tips modal, landing back on the mode it was opened from.
    pub(in crate::app) fn close_tips(&mut self) {
        self.tips = None;
        if let Mode::Tips { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
            self.mode = *return_to;
        }
        // The raw key path returns before the binding layer's `sync_top_mode`, so
        // the rail would keep highlighting whatever it did before the modal opened
        // — the create form `tix new` lands on, for one. Close by re-deriving it.
        self.sync_top_mode();
    }

    /// The tips modal's keys (tix #1632), read raw — see `handle_key`.
    ///
    /// `Left`/`h`/`p` and `Right`/`l`/`n` walk the list, `r` jumps to a random
    /// tip (never the one already shown), `Enter`/`Esc`/`q` closes.
    /// `d` turns the tips off and persists the setting, then the modal shows the
    /// one line telling the reader how to turn them back on; the keys stay raw so
    /// `d` here means "disable", not the board's "delete".
    pub(in crate::app) fn handle_tips_key(&mut self, key: KeyEvent) -> Result<Action> {
        match key.code {
            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::Char('Q') => {
                self.close_tips();
            }
            KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('p') => {
                let len = crate::tips::all().len();
                if let Some(state) = self.tips.as_mut() {
                    state.previous(len);
                }
            }
            KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('n') => {
                let len = crate::tips::all().len();
                if let Some(state) = self.tips.as_mut() {
                    state.next(len);
                }
            }
            KeyCode::Char('r') | KeyCode::Char('R') => {
                let len = crate::tips::all().len();
                if let Some(state) = self.tips.as_mut() {
                    // Inert on the "tips are off" notice: there is no tip on
                    // screen to replace.
                    if !state.disabled {
                        state.randomize(len);
                    }
                }
            }
            KeyCode::Char('d') | KeyCode::Char('D') => self.disable_tips(),
            _ => {}
        }
        Ok(Action::None)
    }

    /// Turn the startup tips off and write the setting to `tix.toml` (tix #1632).
    ///
    /// The write goes through [`App::persist_config`], the same writer the settings
    /// surface uses, so the value lands in the file the next session reads. The
    /// modal stays open showing the "set `tips_on_startup = true` to bring it
    /// back" line — the reader just made a permanent choice and is told so before
    /// the dialog goes away.
    pub(in crate::app) fn disable_tips(&mut self) {
        self.store.config.tips_on_startup = false;
        let saved = self.persist_config();
        if let Some(state) = self.tips.as_mut() {
            state.disabled = true;
        }
        if !saved {
            // `persist_config` already put the reason in the status line; the
            // modal's notice would otherwise promise a persistence that did not
            // happen.
            self.status_msg =
                Some("tips disabled for this session — tix.toml could not be written".to_string());
        }
    }
}