tui-dispatch-components 0.7.0

Pre-built UI components for tui-dispatch
Documentation
//! Shared styling types for tui-dispatch-components
//!
//! All component styles follow a standard pattern with these common fields:
//! - `border: Option<BorderStyle>` - optional border configuration
//! - `padding: Padding` - inner padding
//! - `bg: Option<Color>` - background color
//!
//! Use the [`ComponentStyle`] trait to access these in generic code.

pub use ratatui::style::{Color, Modifier, Style};
pub use ratatui::widgets::Borders;

use ratatui::widgets::{Scrollbar, ScrollbarOrientation};

/// Shared base styling configuration for all components.
#[derive(Debug, Clone)]
pub struct BaseStyle {
    /// Border configuration (None = no border)
    pub border: Option<BorderStyle>,
    /// Padding inside the component
    pub padding: Padding,
    /// Background color
    pub bg: Option<Color>,
    /// Foreground (text) color
    pub fg: Option<Color>,
}

impl Default for BaseStyle {
    fn default() -> Self {
        Self {
            border: Some(BorderStyle::default()),
            padding: Padding::default(),
            bg: None,
            fg: Some(Color::Reset),
        }
    }
}

/// Trait for component styles that embed a shared `BaseStyle`.
///
/// All component styles in this crate implement this trait, ensuring
/// consistent access to common styling fields.
pub trait ComponentStyle {
    /// Get the shared base style
    fn base(&self) -> &BaseStyle;
    /// Get border configuration
    fn border(&self) -> Option<&BorderStyle> {
        self.base().border.as_ref()
    }
    /// Get padding
    fn padding(&self) -> &Padding {
        &self.base().padding
    }
    /// Get background color
    fn bg(&self) -> Option<Color> {
        self.base().bg
    }
    /// Get foreground color
    fn fg(&self) -> Option<Color> {
        self.base().fg
    }
}

/// Padding configuration for components
#[derive(Debug, Clone, Copy, Default)]
pub struct Padding {
    pub top: u16,
    pub right: u16,
    pub bottom: u16,
    pub left: u16,
}

impl Padding {
    /// Create padding with the same value on all sides
    pub fn all(v: u16) -> Self {
        Self {
            top: v,
            right: v,
            bottom: v,
            left: v,
        }
    }

    /// Create padding with horizontal and vertical values
    pub fn xy(x: u16, y: u16) -> Self {
        Self {
            top: y,
            right: x,
            bottom: y,
            left: x,
        }
    }

    /// Create padding with individual values for each side
    pub fn new(top: u16, right: u16, bottom: u16, left: u16) -> Self {
        Self {
            top,
            right,
            bottom,
            left,
        }
    }

    /// Total horizontal padding (left + right)
    pub fn horizontal(&self) -> u16 {
        self.left + self.right
    }

    /// Total vertical padding (top + bottom)
    pub fn vertical(&self) -> u16 {
        self.top + self.bottom
    }
}

/// Border styling configuration
#[derive(Debug, Clone)]
pub struct BorderStyle {
    /// Which borders to show
    pub borders: Borders,
    /// Default border style
    pub style: Style,
    /// Style override when focused (if None, uses `style`)
    pub focused_style: Option<Style>,
}

impl Default for BorderStyle {
    fn default() -> Self {
        Self {
            borders: Borders::ALL,
            style: Style::default().fg(Color::DarkGray),
            focused_style: Some(Style::default().fg(Color::Cyan)),
        }
    }
}

impl BorderStyle {
    /// Create a border style with all borders
    pub fn all() -> Self {
        Self::default()
    }

    /// Create a border style with no borders
    pub fn none() -> Self {
        Self {
            borders: Borders::NONE,
            ..Default::default()
        }
    }

    /// Get the appropriate style based on focus state
    pub fn style_for_focus(&self, is_focused: bool) -> Style {
        if is_focused {
            self.focused_style.unwrap_or(self.style)
        } else {
            self.style
        }
    }
}

/// Selection styling for list components
#[derive(Debug, Clone)]
pub struct SelectionStyle {
    /// Style applied to selected item (default: Cyan + Bold)
    pub style: Option<Style>,
    /// Prefix marker for selected item (default: "> ")
    pub marker: Option<&'static str>,
    /// Set to true to disable all component selection styling
    /// (user handles it entirely in their Line rendering)
    pub disabled: bool,
}

impl Default for SelectionStyle {
    fn default() -> Self {
        Self {
            style: Some(
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            ),
            marker: Some("> "),
            disabled: false,
        }
    }
}

impl SelectionStyle {
    /// Create selection style with no automatic styling (user handles it)
    pub fn disabled() -> Self {
        Self {
            style: None,
            marker: None,
            disabled: true,
        }
    }

    /// Create selection style with only a marker, no style change
    pub fn marker_only(marker: &'static str) -> Self {
        Self {
            style: None,
            marker: Some(marker),
            disabled: false,
        }
    }

    /// Create selection style with only a style change, no marker
    pub fn style_only(style: Style) -> Self {
        Self {
            style: Some(style),
            marker: None,
            disabled: false,
        }
    }
}

/// Scrollbar styling configuration
#[derive(Debug, Clone)]
pub struct ScrollbarStyle {
    /// Style for the scrollbar thumb
    pub thumb: Style,
    /// Style for the scrollbar track
    pub track: Style,
    /// Style for the scrollbar begin symbol
    pub begin: Style,
    /// Style for the scrollbar end symbol
    pub end: Style,
    /// Override the thumb symbol (None = ratatui default)
    pub thumb_symbol: Option<&'static str>,
    /// Override the track symbol (None = no track)
    pub track_symbol: Option<&'static str>,
    /// Override the begin symbol (None = no symbol)
    pub begin_symbol: Option<&'static str>,
    /// Override the end symbol (None = no symbol)
    pub end_symbol: Option<&'static str>,
}

impl Default for ScrollbarStyle {
    fn default() -> Self {
        Self {
            thumb: Style::default().fg(Color::Cyan),
            track: Style::default().fg(Color::DarkGray),
            begin: Style::default().fg(Color::DarkGray),
            end: Style::default().fg(Color::DarkGray),
            thumb_symbol: Some(""),
            track_symbol: Some(""),
            begin_symbol: None,
            end_symbol: None,
        }
    }
}

impl ScrollbarStyle {
    /// Build a ratatui Scrollbar widget from this style
    pub fn build(&self, orientation: ScrollbarOrientation) -> Scrollbar<'static> {
        let mut scrollbar = Scrollbar::new(orientation)
            .thumb_style(self.thumb)
            .track_style(self.track)
            .begin_style(self.begin)
            .end_style(self.end)
            .track_symbol(self.track_symbol)
            .begin_symbol(self.begin_symbol)
            .end_symbol(self.end_symbol);

        if let Some(symbol) = self.thumb_symbol {
            scrollbar = scrollbar.thumb_symbol(symbol);
        }

        scrollbar
    }
}

// ============================================================================
// Utility functions
// ============================================================================

use ratatui::text::{Line, Span};

/// Highlight substring matches in text (case-insensitive)
///
/// Returns a `Line` with matching portions styled using `highlight_style`.
/// Non-matching portions use the `base_style`.
///
/// # Example
///
/// ```ignore
/// use tui_dispatch_components::style::{highlight_substring, Style, Color, Modifier};
///
/// let base = Style::default();
/// let highlight = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
/// let line = highlight_substring("Hello World", "wor", base, highlight);
/// // Results in: "Hello " (base) + "Wor" (highlight) + "ld" (base)
/// ```
///
/// # Notes
///
/// - Matching is case-insensitive
/// - Only works with ASCII text; non-ASCII returns the text with base style
/// - Empty query returns the text with base style
pub fn highlight_substring(
    text: &str,
    query: &str,
    base_style: Style,
    highlight_style: Style,
) -> Line<'static> {
    if query.is_empty() {
        return Line::styled(text.to_string(), base_style);
    }

    // Fall back for non-ASCII to avoid indexing issues
    if !text.is_ascii() || !query.is_ascii() {
        return Line::styled(text.to_string(), base_style);
    }

    let text_lower = text.to_lowercase();
    let query_lower = query.to_lowercase();

    let mut spans = Vec::new();
    let mut last_end = 0;

    for (start, _) in text_lower.match_indices(&query_lower) {
        // Add non-matching part before this match
        if start > last_end {
            spans.push(Span::styled(text[last_end..start].to_string(), base_style));
        }

        // Add matching part with highlight
        let end = start + query.len();
        spans.push(Span::styled(text[start..end].to_string(), highlight_style));
        last_end = end;
    }

    // Add remaining part after last match
    if last_end < text.len() {
        spans.push(Span::styled(text[last_end..].to_string(), base_style));
    }

    if spans.is_empty() {
        Line::styled(text.to_string(), base_style)
    } else {
        Line::from(spans)
    }
}