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::*;

/// The tips modal (tix #1632): the mode it was opened from is rendered
/// underneath (per `return_to`, exactly as `render_help` does), and the tip
/// floats above it in a centered modal box.
pub(in crate::app) fn render_tips(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
    match return_to {
        Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
        Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
        Mode::Form => render_board(app, f, area, commandbar::MODE_BOARD),
        _ => render_board(app, f, area, commandbar::MODE_BOARD),
    }
    if let Some(state) = app.tips.as_ref() {
        crate::ui::tips::render(f, area, state, crate::tips::all());
    }
}

/// Help as a centered modal (FR-010): the screen the user came from stays
/// visible underneath (rendered per `return_to`), and the command list floats
/// above it in a bordered box. `?`/`q`/Esc close it (see `handle_help_key`).
#[allow(clippy::too_many_lines)]
pub(in crate::app) fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
    match return_to {
        Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
        Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
        _ => render_board(app, f, area, commandbar::MODE_BOARD),
    }

    let help_entries = commandbar::help_overlay_for(
        &app.bindings,
        app.top_mode,
        app.schedule_help_tab(),
        app.stats_help_tab(),
    );
    // tix #1496: `/` filters the list, best match first. `fuzzy_score` is the same
    // subsequence scorer `tix search` and the board's `/` use, so "clon" finds
    // `clone issue` and "nwiss" finds it too — one notion of "fuzzy" in the tool, not
    // a second one that behaves almost the same.
    let query = app.help_query.trim().to_string();
    // Contiguous matches first; the fuzzy scorer is the **fallback**, not the first
    // pass. A bare subsequence is far too permissive over a hundred short rows —
    // `clone` is a subsequence of `Esc / q / ? cancel context_menu`, so a pure fuzzy
    // filter would keep most of the list and rank the binding you asked for somewhere
    // inside it. The literal search is what actually narrows; when it finds nothing
    // (`nwiss` has no literal home) the scorer takes over, which is the case that makes
    // the search *fuzzy* rather than a second substring filter.
    let mut fuzzy_fallback = false;
    let matches: Vec<(String, String, String)> = if query.is_empty() {
        help_entries.clone()
    } else {
        let needle = query.to_lowercase();
        let haystack = |entry: &(String, String, String)| {
            format!("{} {} {}", entry.0, entry.1, entry.2).to_lowercase()
        };
        let literal: Vec<(String, String, String)> = help_entries
            .iter()
            .filter(|entry| haystack(entry).contains(&needle))
            .cloned()
            .collect();
        if literal.is_empty() {
            fuzzy_fallback = true;
            // Best first; `sort_by` is stable, so equal scores keep the declared order —
            // which is the order the overlay is normally read in.
            let mut scored: Vec<(u32, (String, String, String))> = help_entries
                .iter()
                .filter_map(|entry| {
                    crate::search::fuzzy_score(&query, &haystack(entry))
                        .map(|score| (score, entry.clone()))
                })
                .collect();
            scored.sort_by_key(|a| std::cmp::Reverse(a.0));
            scored.into_iter().map(|(_, entry)| entry).collect()
        } else {
            literal
        }
    };
    let total = help_entries.len();
    // How many entry rows fit, once the border and this line are accounted for.
    // Computed **before** the rows so the notice can be counted against the same
    // budget rather than pushed off the bottom itself.
    let frame_rows = area.height.saturating_sub(2);
    let visible = (matches.len() as u16).min(frame_rows.saturating_sub(1));
    let hidden = matches.len().saturating_sub(visible as usize);
    // tix #1441: record what only the renderer knows, so the handlers can clamp a
    // step without re-deriving the frame.
    app.help_metrics.set(HelpMetrics {
        visible,
        max_scroll: u16::try_from(hidden).unwrap_or(u16::MAX),
    });
    let offset = usize::from(app.help_scroll).min(hidden);
    let mut rows: Vec<Line<'static>> = matches
        .iter()
        .skip(offset)
        .take(visible as usize)
        .map(|(key, desc, scope)| {
            Line::from(vec![
                Span::styled(format!("{key:<20}"), Style::default().fg(Color::Cyan)),
                Span::styled(format!("{desc:<28}"), Style::default().fg(Color::White)),
                Span::styled(scope.clone(), Style::default().fg(Color::DarkGray)),
            ])
        })
        .collect();
    // Returns the column the terminal caret belongs at, when the input line is up.
    let caret = if app.help_searching {
        let mut spans = vec![
            Span::styled("/", Style::default().fg(Color::Yellow)),
            Span::styled(app.help_query.clone(), Style::default().fg(Color::White)),
        ];
        // The count belongs on this line too: while typing is exactly when the reader
        // wants to know whether the filter found anything, and the rows below say it
        // only by being short. It sits after the caret, dim, so it reads as status
        // rather than as more input.
        if !query.is_empty() {
            spans.push(Span::styled(
                format!(
                    "  — {} of {total} bindings{}",
                    matches.len(),
                    if fuzzy_fallback { " (fuzzy)" } else { "" },
                ),
                Style::default().fg(Color::DarkGray),
            ));
        }
        rows.push(Line::from(spans));
        Some(Line::from(format!("/{}", app.help_query)).width())
    } else if !query.is_empty() {
        // What the filter did, and where in the *matches* the reader is — the same two
        // facts the unfiltered line carries, because a filtered list that does not say
        // how much it is hiding reads as the whole list.
        // Says which search answered, so a fallback that matched rows the reader did not
        // type is explained rather than mysterious.
        let mode = if fuzzy_fallback { " (fuzzy)" } else { "" };
        rows.push(Line::from(Span::styled(
            match hidden {
                0 => format!(
                    "match \"{query}\" — {} of {total} bindings{mode}",
                    matches.len()
                ),
                _ => format!(
                    "match \"{query}\" — {} of {total} bindings, rows {}-{}{mode}",
                    matches.len(),
                    offset + 1,
                    offset + visible as usize,
                ),
            },
            Style::default().fg(Color::DarkGray),
        )));
        None
    } else if hidden > 0 {
        // **Say what is missing — and where you are.** The overlay used to be
        // silently truncated at the frame height, so a short terminal showed a
        // complete-looking list that was quietly missing bindings, and a binding
        // absent from the help is a binding nobody can find. Now that it scrolls, the
        // same line has to answer "is there more?" *and* "am I at the top?", or a
        // scrolled reader cannot tell the difference between the first page and the
        // last.
        //
        // One row of the budget, so it stays short enough not to wrap.
        rows.push(Line::from(Span::styled(
            format!(
                "rows {}-{} of {} — {} above, {} below",
                offset + 1,
                offset + visible as usize,
                matches.len(),
                offset,
                hidden - offset,
            ),
            Style::default().fg(Color::DarkGray),
        )));
        None
    } else {
        None
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .title("Help [? close]")
        .style(Style::default().fg(Color::Cyan));
    let para = Paragraph::new(rows).block(block);
    // The match rows plus the one line that carries the input prompt, the filter
    // summary or the scroll position — whichever of those is showing.
    let notice_rows = u16::from(caret.is_some() || !query.is_empty() || hidden > 0);
    let drawn_rows = visible + notice_rows;

    let modal_w = 60.min(area.width).max(30);
    // Sized to what is actually drawn, so a filter that narrows the list to three
    // bindings gives a three-row box rather than leaving a tall empty one.
    let modal_h = (drawn_rows + 2).min(area.height).max(4);
    let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
    let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
    let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);

    if area.height >= modal_h && area.width >= modal_w {
        f.render_widget(Clear, modal_area);
        f.render_widget(para, modal_area);
    }
    // The input line has the keyboard, so the caret goes where the next character will
    // land — the same rule the create form follows (tix #1486), and the reason the
    // prompt is drawn as a row rather than only described in the footer.
    if let Some(column) = caret
        && area.height >= modal_h
        && area.width >= modal_w
    {
        let inner = Block::default().borders(Borders::ALL).inner(modal_area);
        let row = inner.y + visible.min(inner.height.saturating_sub(1));
        let x = inner.x
            + u16::try_from(column)
                .unwrap_or(u16::MAX)
                .min(inner.width.saturating_sub(1));
        f.set_cursor_position(ratatui::layout::Position::new(x, row));
    }
    // Any click on the help screen closes it, so the whole overlay resolves to
    // the same branch as `?`/`q`/Esc.
    app.record_region(area, Action::HelpClose);
}