travelagent 1.11.1

Agent-first TUI code review tool
//! Frame-layout + picker-state bundle owned by `App` at `app.ui_layout`.
//!
//! Extracted from `app/mod.rs` in task #51 PR B (struct +
//! field migration) and PR C (encapsulation); moved into its own
//! file in v1.6 once the surface was stable enough to warrant the
//! split. Everything here answers "what does the current frame look
//! like and which floating picker is open".
//!
//! Fields with cross-field invariants (`file_list_width_pct`, the
//! two comment-template-picker fields) are private and must be
//! mutated through the methods below, which enforce the clamp and
//! "cursor resets when filter changes" invariants. The rest stay
//! `pub` because they're independent state with no invariants.

use std::collections::HashSet;

/// See module docs for the rationale behind the mixed `pub`/private
/// field visibilities.
#[derive(Debug, Clone)]
pub struct UiLayoutState {
    /// Whether the file-list pane is visible on the left side.
    pub show_file_list: bool,
    /// File-list pane width as a percentage of the main content area.
    /// Always in `[FILE_LIST_MIN_PCT, FILE_LIST_MAX_PCT]`; mutate via
    /// [`Self::set_file_list_width_pct`] / [`Self::shrink_file_list_width`]
    /// / [`Self::grow_file_list_width`].
    file_list_width_pct: u16,
    /// Cached `Rect` for the file-list pane, captured during the last
    /// frame render. Used by mouse hit-testing and pane-width drag
    /// handlers. `None` before the first render.
    pub file_list_area: Option<ratatui::layout::Rect>,
    /// Cached `Rect` for the diff pane, same semantics as
    /// `file_list_area`.
    pub diff_area: Option<ratatui::layout::Rect>,
    /// Directory paths that are currently expanded in the file tree.
    /// String keys rather than `PathBuf` so JSON round-trips are
    /// trivial and the tree renderer can look up by any prefix.
    pub expanded_dirs: HashSet<String>,
    /// Whether the `:` command palette popup is enabled. Off-by-config
    /// for keyboard-purist users; still toggleable at runtime.
    pub command_palette: bool,
    /// Highlighted row in the filtered comment-template picker.
    /// Invariant: reset to 0 on every filter mutation so a stale
    /// selection can't land on the wrong entry. Mutate only through
    /// the `*_template_cursor` / `*_template_filter` methods.
    comment_template_picker_cursor: usize,
    /// Filter buffer for the comment-template picker (typed after
    /// `Ctrl+T`). Private — see `comment_template_picker_cursor`.
    comment_template_picker_filter: String,
}

impl Default for UiLayoutState {
    fn default() -> Self {
        Self {
            show_file_list: true,
            file_list_width_pct: 20,
            file_list_area: None,
            diff_area: None,
            expanded_dirs: HashSet::new(),
            command_palette: true,
            comment_template_picker_cursor: 0,
            comment_template_picker_filter: String::new(),
        }
    }
}

impl UiLayoutState {
    /// Minimum file-list width percentage of the main content area.
    /// Anything tighter erases the filename column and makes the tree
    /// unreadable; the `<` keybinding clamps to this floor.
    pub const FILE_LIST_MIN_PCT: u16 = 10;
    /// Maximum file-list width percentage of the main content area.
    /// Beyond this the diff pane loses its breathing room; the `>`
    /// keybinding clamps to this ceiling.
    pub const FILE_LIST_MAX_PCT: u16 = 60;
    /// Step size used by `<` / `>` to resize the file list pane.
    pub const FILE_LIST_STEP_PCT: u16 = 5;

    /// Current file-list width as a percentage of the main content
    /// area. Always clamped to `[FILE_LIST_MIN_PCT, FILE_LIST_MAX_PCT]`
    /// by the mutating methods on this struct.
    pub fn file_list_width_pct(&self) -> u16 {
        self.file_list_width_pct
    }

    /// Set the file-list width, clamped to the legal range. Returns
    /// the clamped value so callers can branch on whether the request
    /// was honored as-is.
    pub fn set_file_list_width_pct(&mut self, pct: u16) -> u16 {
        let clamped = pct.clamp(Self::FILE_LIST_MIN_PCT, Self::FILE_LIST_MAX_PCT);
        self.file_list_width_pct = clamped;
        clamped
    }

    /// Try to shrink the file-list pane by `FILE_LIST_STEP_PCT`. Returns
    /// `Some(new_pct)` if the width changed, `None` if it was already at
    /// (or would drop below) `FILE_LIST_MIN_PCT`.
    pub fn shrink_file_list_width(&mut self) -> Option<u16> {
        let new_pct = self
            .file_list_width_pct
            .saturating_sub(Self::FILE_LIST_STEP_PCT)
            .max(Self::FILE_LIST_MIN_PCT);
        if new_pct == self.file_list_width_pct {
            None
        } else {
            self.file_list_width_pct = new_pct;
            Some(new_pct)
        }
    }

    /// Try to grow the file-list pane by `FILE_LIST_STEP_PCT`. Returns
    /// `Some(new_pct)` if the width changed, `None` if it was already at
    /// (or would exceed) `FILE_LIST_MAX_PCT`.
    pub fn grow_file_list_width(&mut self) -> Option<u16> {
        let new_pct = self
            .file_list_width_pct
            .saturating_add(Self::FILE_LIST_STEP_PCT)
            .min(Self::FILE_LIST_MAX_PCT);
        if new_pct == self.file_list_width_pct {
            None
        } else {
            self.file_list_width_pct = new_pct;
            Some(new_pct)
        }
    }

    /// Read-only view of the template-picker filter buffer.
    pub fn template_filter(&self) -> &str {
        &self.comment_template_picker_filter
    }

    /// Highlighted row in the filtered template picker. Callers that
    /// need to map this to an entry must re-run the filter themselves
    /// (see `ui::comment_template_picker::filter_templates`).
    pub fn template_cursor(&self) -> usize {
        self.comment_template_picker_cursor
    }

    /// Append a character to the filter and reset the cursor to 0.
    /// Invariant: every mutation to the filter resets the cursor so a
    /// stale selection can't land on the wrong entry after the filter
    /// rewords the candidate list.
    pub fn push_template_filter_char(&mut self, c: char) {
        self.comment_template_picker_filter.push(c);
        self.comment_template_picker_cursor = 0;
    }

    /// Pop the last character from the filter and reset the cursor to 0.
    pub fn pop_template_filter_char(&mut self) {
        self.comment_template_picker_filter.pop();
        self.comment_template_picker_cursor = 0;
    }

    /// Word-delete in the filter (trailing whitespace then trailing
    /// non-whitespace), then reset the cursor. Mirrors the palette
    /// `DeleteWord` handling so Ctrl+W feels consistent across pickers.
    pub fn word_delete_template_filter(&mut self) {
        while self
            .comment_template_picker_filter
            .chars()
            .last()
            .is_some_and(char::is_whitespace)
        {
            self.comment_template_picker_filter.pop();
        }
        while self
            .comment_template_picker_filter
            .chars()
            .last()
            .is_some_and(|c| !c.is_whitespace())
        {
            self.comment_template_picker_filter.pop();
        }
        self.comment_template_picker_cursor = 0;
    }

    /// Clear the filter buffer and reset the cursor.
    pub fn clear_template_filter(&mut self) {
        self.comment_template_picker_filter.clear();
        self.comment_template_picker_cursor = 0;
    }

    /// Move the template-picker cursor down by one, capped at the last
    /// valid row of a list with `filtered_len` entries. No-op when the
    /// list is empty.
    pub fn advance_template_cursor(&mut self, filtered_len: usize) {
        if let Some(max_idx) = filtered_len.checked_sub(1) {
            self.comment_template_picker_cursor =
                (self.comment_template_picker_cursor + 1).min(max_idx);
        }
    }

    /// Move the template-picker cursor up by one (saturating at 0).
    pub fn retreat_template_cursor(&mut self) {
        self.comment_template_picker_cursor = self.comment_template_picker_cursor.saturating_sub(1);
    }

    /// Reset both the filter buffer and cursor. Called on picker
    /// open/close so the next session starts from a clean slate.
    pub fn reset_template_picker(&mut self) {
        self.comment_template_picker_filter.clear();
        self.comment_template_picker_cursor = 0;
    }
}

#[cfg(test)]
mod tests {
    use super::UiLayoutState;

    #[test]
    fn set_width_clamps_to_min() {
        let mut ui = UiLayoutState::default();
        let got = ui.set_file_list_width_pct(2);
        assert_eq!(got, UiLayoutState::FILE_LIST_MIN_PCT);
        assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MIN_PCT);
    }

    #[test]
    fn set_width_clamps_to_max() {
        let mut ui = UiLayoutState::default();
        let got = ui.set_file_list_width_pct(99);
        assert_eq!(got, UiLayoutState::FILE_LIST_MAX_PCT);
        assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MAX_PCT);
    }

    #[test]
    fn shrink_at_floor_returns_none() {
        let mut ui = UiLayoutState::default();
        ui.set_file_list_width_pct(UiLayoutState::FILE_LIST_MIN_PCT);
        assert_eq!(ui.shrink_file_list_width(), None);
        assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MIN_PCT);
    }

    #[test]
    fn grow_at_ceiling_returns_none() {
        let mut ui = UiLayoutState::default();
        ui.set_file_list_width_pct(UiLayoutState::FILE_LIST_MAX_PCT);
        assert_eq!(ui.grow_file_list_width(), None);
        assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MAX_PCT);
    }

    #[test]
    fn shrink_and_grow_step_by_step_pct() {
        let mut ui = UiLayoutState::default();
        let start = ui.file_list_width_pct();
        let grew = ui.grow_file_list_width().expect("default is below max");
        assert_eq!(grew, start + UiLayoutState::FILE_LIST_STEP_PCT);
        let shrunk = ui.shrink_file_list_width().expect("just grew above min");
        assert_eq!(shrunk, start);
    }

    #[test]
    fn filter_mutation_resets_cursor() {
        // Contract: every mutation that changes the filter buffer must
        // force the cursor back to 0. The exact non-zero position the
        // cursor was sitting at is irrelevant — what matters is that a
        // stale cursor can't survive a filter edit.
        let mut ui = UiLayoutState::default();
        ui.advance_template_cursor(6);
        ui.advance_template_cursor(6);
        assert_ne!(ui.template_cursor(), 0, "precondition: seeded non-zero");

        ui.push_template_filter_char('a');
        assert_eq!(ui.template_cursor(), 0, "push should reset cursor");
        assert_eq!(ui.template_filter(), "a");

        ui.advance_template_cursor(4);
        assert_ne!(ui.template_cursor(), 0, "precondition: advanced again");
        ui.pop_template_filter_char();
        assert_eq!(ui.template_cursor(), 0, "pop should reset cursor");
        assert_eq!(ui.template_filter(), "");
    }

    #[test]
    fn word_delete_peels_whitespace_then_word() {
        let mut ui = UiLayoutState::default();
        "hello world  "
            .chars()
            .for_each(|c| ui.push_template_filter_char(c));
        ui.advance_template_cursor(5);
        ui.word_delete_template_filter();
        assert_eq!(ui.template_filter(), "hello ");
        assert_eq!(ui.template_cursor(), 0);
    }

    #[test]
    fn clear_template_filter_resets_both() {
        let mut ui = UiLayoutState::default();
        ui.push_template_filter_char('x');
        ui.advance_template_cursor(10);
        ui.clear_template_filter();
        assert_eq!(ui.template_filter(), "");
        assert_eq!(ui.template_cursor(), 0);
    }

    #[test]
    fn advance_template_cursor_caps_at_max() {
        let mut ui = UiLayoutState::default();
        for _ in 0..10 {
            ui.advance_template_cursor(4);
        }
        assert_eq!(ui.template_cursor(), 3);
    }

    #[test]
    fn advance_template_cursor_with_empty_list_is_noop() {
        let mut ui = UiLayoutState::default();
        ui.advance_template_cursor(0);
        assert_eq!(ui.template_cursor(), 0);
    }

    #[test]
    fn advance_template_cursor_with_single_item_stays_at_zero() {
        // Regression: caller passing `filtered.len() == 1` previously
        // had to compute `checked_sub(1) = Some(0)`, which was the
        // confusing shape the v1.6 signature change targeted.
        let mut ui = UiLayoutState::default();
        ui.advance_template_cursor(1);
        assert_eq!(ui.template_cursor(), 0);
        ui.advance_template_cursor(1);
        assert_eq!(ui.template_cursor(), 0);
    }

    #[test]
    fn retreat_template_cursor_saturates_at_zero() {
        let mut ui = UiLayoutState::default();
        ui.retreat_template_cursor();
        assert_eq!(ui.template_cursor(), 0);
    }
}