pandora-kit 0.6.4

Interactive TUI toolkit for the Hefesto framework
Documentation
//! Helper functions that replicate the internal height formulas
//! used by `hefesto-widgets` pop-ups when height is `Auto`.
//!
//! # Why this exists
//!
//! Several pop-ups in `hefesto-widgets` (`ChoosePopup`, `SpinPopup`,
//! `TreePopup`, `FileBrowserPopup`, `TextInputPopup`) override the
//! pop-up height **inside** their `render` method when the user has
//! not explicitly set a height (i.e. height is `PopupSize::Auto`):
//!
//! ```ignore
//! if popup.get_height() == PopupSize::Auto {
//!     popup = popup.height(PopupSize::Fixed(actual_height));
//! }
//! let inner = popup.render_inner(area, buf);
//! ```
//!
//! This means the rect returned by `resolve_rect` **before** rendering
//! can differ from the rect **actually** rendered. The mismatch causes:
//!
//! - **Left/right borders** — work because the X coordinate is untouched
//!   by the height change.
//! - **Top/bottom borders** — fail because centering uses the *wrong*
//!   height, so the Y and bottom edge differ.
//!
//! Once the user drags from left/right the pop-up gets an explicit
//! `origin`, removing the centering height dependency; top/bottom
//! then start working (but the calculated rect is still wrong).
//!
//! # How the fix works
//!
//! Each sub-command that uses an affected pop-up applies the same
//! height formula **before** calling `resolve_rect`, setting the height
//! explicitly to `PopupSize::Fixed(...)`. This makes the widget's
//! internal `if Auto` branch a no-op, so `resolve_rect` returns a rect
//! that matches the rendered position exactly.
//!
//! # Sync contract
//!
//! These constants and formulas **must** be kept in sync with the
//! corresponding `render` methods in `hefesto-widgets`.  If the widget
//! crate changes its internal height logic, update this module.
//! Check the widget source at:
//! `~/.cargo/registry/src/index.crates.io-*/hefesto-widgets-*/src/popups/`

use ratatui::layout::Rect;
use hefesto_widgets::{ChoosePopup, ChoosePopupState, PopupSize};

// ── ChoosePopup / FilterPopup ──────────────────────────────────
//
// Widget source: popups/choose_popup/mod.rs, render()
//   let default_h = PopupSize::Fixed((self.items.len() as u16 + 4).min(60));

/// Maximum default height for ChoosePopup (and filter).
pub const CHOOSE_DEFAULT_MAX_H: u16 = 60;

/// Default height a ChoosePopup renders when height is `Auto`.
pub fn choose_default_height(items_len: usize) -> u16 {
    ((items_len as u16) + 4).min(CHOOSE_DEFAULT_MAX_H)
}

/// Compute the actual rect a ChoosePopup will render at.
///
/// Pass `items_len` only if the popup's height is `Auto` (i.e. the
/// user didn't provide an explicit `--height`).  When the popup
/// already has a fixed height, pass `None` to leave it unchanged.
pub fn choose_rect(popup: &ChoosePopup<'_>, items_len: usize, area: Rect) -> Rect {
    // The popup's internal height is private, so we can't check it.
    // We always set the height explicitly and let the caller decide
    // whether to pass a user-provided height or use the default.
    //
    // This function is primarily for the regression test; subcommands
    // apply the height before calling resolve_rect themselves.
    fixed_rect(popup, choose_default_height(items_len), area)
}

fn fixed_rect(popup: &ChoosePopup<'_>, h: u16, area: Rect) -> Rect {
    let clone = popup.clone().height(PopupSize::Fixed(h));
    clone.resolve_rect(area, &ChoosePopupState::default())
}

// ── SpinPopup ──────────────────────────────────────────────────
//
// Widget source: popups/spin_popup/mod.rs, render()
//   let show_footer = state.finished && self.finished_msg.is_some();
//   let content_h = {
//       let mut h = 2u16;
//       if self.command.is_some()  { h += 1; }
//       if !self.output_lines.is_empty() {
//           h += self.output_lines.len().min(self.max_output_rows as usize) as u16;
//           h += 1;
//       }
//       if show_footer  { h += 2; }
//       h
//   };
//   let total_height = content_h + 2;  // popup borders (top + bottom)

/// Default height a SpinPopup renders when height is `Auto`.
///
/// * `has_cmd` — whether `.command(...)` was set
/// * `output_lines_len` — current number of output lines
/// * `max_output_rows` — value passed to `.max_output_rows(...)` (default 10)
/// * `show_footer` — whether the finished footer is visible
///   (`state.finished && finished_msg.is_some()`)
pub fn spin_default_height(
    has_cmd: bool,
    output_lines_len: usize,
    max_output_rows: u16,
    show_footer: bool,
) -> u16 {
    let mut content_h = 2u16;
    if has_cmd {
        content_h += 1;
    }
    if output_lines_len > 0 {
        let clamped = (output_lines_len as u16).min(max_output_rows);
        content_h += clamped + 1; // lines + log box top border
    }
    if show_footer {
        content_h += 2; // text + bottom border
    }
    content_h + 2 // popup border top + bottom
}

// ── TreePopup ──────────────────────────────────────────────────
//
// Widget source: popups/tree_popup/mod.rs, render()
//   let header_height: u16 = if self.popup.has_header() { 2 } else { 0 };
//   let tree_height = self.tree.visible_count(&state.expanded) as u16 + 2 + header_height;
//   let total_height = tree_height.max(5);

/// Default height a TreePopup renders when height is `Auto`.
///
/// * `visible_count` — number of currently visible tree items
///   (see `total_visible` in `menu.rs`)
/// * `has_header` — whether the popup has a header bar (`.header()`)
pub fn tree_default_height(visible_count: usize, has_header: bool) -> u16 {
    let header_height: u16 = if has_header { 2 } else { 0 };
    let tree_height = (visible_count as u16) + 2 + header_height;
    tree_height.max(5)
}

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

    // ── choose_default_height ──

    #[test]
    fn choose_default_height_few_items() {
        assert_eq!(choose_default_height(4), 8);  // 4 + 4
    }

    #[test]
    fn choose_default_height_caps_at_max() {
        let many = CHOOSE_DEFAULT_MAX_H as usize - 4 + 10;
        assert_eq!(choose_default_height(many), CHOOSE_DEFAULT_MAX_H);
    }

    #[test]
    fn choose_default_height_no_items() {
        assert_eq!(choose_default_height(0), 4);
    }

    // ── spin_default_height ──

    #[test]
    fn spin_default_height_minimal() {
        // no command, no output, no footer
        assert_eq!(spin_default_height(false, 0, 10, false), 4);
    }

    #[test]
    fn spin_default_height_with_cmd() {
        assert_eq!(spin_default_height(true, 0, 10, false), 5);
    }

    #[test]
    fn spin_default_height_with_output() {
        assert_eq!(spin_default_height(false, 5, 10, false), 10); // 2 + 5 + 1 + 2
    }

    #[test]
    fn spin_default_height_output_clamped() {
        assert_eq!(spin_default_height(false, 50, 10, false), 15); // 2 + (clamped 10) + 1 + 2
    }

    #[test]
    fn spin_default_height_with_footer() {
        assert_eq!(spin_default_height(false, 0, 10, true), 6); // 2 + 2 + 2
    }

    #[test]
    fn spin_default_height_all() {
        assert_eq!(spin_default_height(true, 3, 10, true), 11); // 2 + 1 + (3+1) + 2 + 2
    }

    // ── tree_default_height ──

    #[test]
    fn tree_default_height_no_header() {
        assert_eq!(tree_default_height(3, false), 5); // max(3 + 2, 5)
    }

    #[test]
    fn tree_default_height_with_header() {
        assert_eq!(tree_default_height(3, true), 7); // max(3 + 2 + 2, 5)
    }

    #[test]
    fn tree_default_height_empty_tree() {
        assert_eq!(tree_default_height(0, false), 5); // max(0 + 2, 5)
    }

    #[test]
    fn tree_default_height_many_items() {
        assert_eq!(tree_default_height(20, true), 24); // 20 + 2 + 2
    }
}