Skip to main content

tui_dispatch_components/
style.rs

1//! Shared styling types for tui-dispatch-components
2//!
3//! All component styles follow a standard pattern with these common fields:
4//! - `border: Option<BorderStyle>` - optional border configuration
5//! - `padding: Padding` - inner padding
6//! - `bg: Option<Color>` - background color
7//!
8//! Use the [`ComponentStyle`] trait to access these in generic code.
9
10pub use ratatui::style::{Color, Modifier, Style};
11pub use ratatui::widgets::Borders;
12
13use ratatui::widgets::{Scrollbar, ScrollbarOrientation};
14
15/// Shared base styling configuration for all components.
16#[derive(Debug, Clone)]
17pub struct BaseStyle {
18    /// Border configuration (None = no border)
19    pub border: Option<BorderStyle>,
20    /// Padding inside the component
21    pub padding: Padding,
22    /// Background color
23    pub bg: Option<Color>,
24    /// Foreground (text) color
25    pub fg: Option<Color>,
26}
27
28impl Default for BaseStyle {
29    fn default() -> Self {
30        Self {
31            border: Some(BorderStyle::default()),
32            padding: Padding::default(),
33            bg: None,
34            fg: Some(Color::Reset),
35        }
36    }
37}
38
39/// Trait for component styles that embed a shared `BaseStyle`.
40///
41/// All component styles in this crate implement this trait, ensuring
42/// consistent access to common styling fields.
43pub trait ComponentStyle {
44    /// Get the shared base style
45    fn base(&self) -> &BaseStyle;
46    /// Get border configuration
47    fn border(&self) -> Option<&BorderStyle> {
48        self.base().border.as_ref()
49    }
50    /// Get padding
51    fn padding(&self) -> &Padding {
52        &self.base().padding
53    }
54    /// Get background color
55    fn bg(&self) -> Option<Color> {
56        self.base().bg
57    }
58    /// Get foreground color
59    fn fg(&self) -> Option<Color> {
60        self.base().fg
61    }
62}
63
64/// Padding configuration for components
65#[derive(Debug, Clone, Copy, Default)]
66pub struct Padding {
67    pub top: u16,
68    pub right: u16,
69    pub bottom: u16,
70    pub left: u16,
71}
72
73impl Padding {
74    /// Create padding with the same value on all sides
75    pub fn all(v: u16) -> Self {
76        Self {
77            top: v,
78            right: v,
79            bottom: v,
80            left: v,
81        }
82    }
83
84    /// Create padding with horizontal and vertical values
85    pub fn xy(x: u16, y: u16) -> Self {
86        Self {
87            top: y,
88            right: x,
89            bottom: y,
90            left: x,
91        }
92    }
93
94    /// Create padding with individual values for each side
95    pub fn new(top: u16, right: u16, bottom: u16, left: u16) -> Self {
96        Self {
97            top,
98            right,
99            bottom,
100            left,
101        }
102    }
103
104    /// Total horizontal padding (left + right)
105    pub fn horizontal(&self) -> u16 {
106        self.left + self.right
107    }
108
109    /// Total vertical padding (top + bottom)
110    pub fn vertical(&self) -> u16 {
111        self.top + self.bottom
112    }
113}
114
115/// Border styling configuration
116#[derive(Debug, Clone)]
117pub struct BorderStyle {
118    /// Which borders to show
119    pub borders: Borders,
120    /// Default border style
121    pub style: Style,
122    /// Style override when focused (if None, uses `style`)
123    pub focused_style: Option<Style>,
124}
125
126impl Default for BorderStyle {
127    fn default() -> Self {
128        Self {
129            borders: Borders::ALL,
130            style: Style::default().fg(Color::DarkGray),
131            focused_style: Some(Style::default().fg(Color::Cyan)),
132        }
133    }
134}
135
136impl BorderStyle {
137    /// Create a border style with all borders
138    pub fn all() -> Self {
139        Self::default()
140    }
141
142    /// Create a border style with no borders
143    pub fn none() -> Self {
144        Self {
145            borders: Borders::NONE,
146            ..Default::default()
147        }
148    }
149
150    /// Get the appropriate style based on focus state
151    pub fn style_for_focus(&self, is_focused: bool) -> Style {
152        if is_focused {
153            self.focused_style.unwrap_or(self.style)
154        } else {
155            self.style
156        }
157    }
158}
159
160/// Selection styling for list components
161#[derive(Debug, Clone)]
162pub struct SelectionStyle {
163    /// Style applied to selected item (default: Cyan + Bold)
164    pub style: Option<Style>,
165    /// Prefix marker for selected item (default: "> ")
166    pub marker: Option<&'static str>,
167    /// Set to true to disable all component selection styling
168    /// (user handles it entirely in their Line rendering)
169    pub disabled: bool,
170}
171
172impl Default for SelectionStyle {
173    fn default() -> Self {
174        Self {
175            style: Some(
176                Style::default()
177                    .fg(Color::Cyan)
178                    .add_modifier(Modifier::BOLD),
179            ),
180            marker: Some("> "),
181            disabled: false,
182        }
183    }
184}
185
186impl SelectionStyle {
187    /// Create selection style with no automatic styling (user handles it)
188    pub fn disabled() -> Self {
189        Self {
190            style: None,
191            marker: None,
192            disabled: true,
193        }
194    }
195
196    /// Create selection style with only a marker, no style change
197    pub fn marker_only(marker: &'static str) -> Self {
198        Self {
199            style: None,
200            marker: Some(marker),
201            disabled: false,
202        }
203    }
204
205    /// Create selection style with only a style change, no marker
206    pub fn style_only(style: Style) -> Self {
207        Self {
208            style: Some(style),
209            marker: None,
210            disabled: false,
211        }
212    }
213}
214
215/// Scrollbar styling configuration
216#[derive(Debug, Clone)]
217pub struct ScrollbarStyle {
218    /// Style for the scrollbar thumb
219    pub thumb: Style,
220    /// Style for the scrollbar track
221    pub track: Style,
222    /// Style for the scrollbar begin symbol
223    pub begin: Style,
224    /// Style for the scrollbar end symbol
225    pub end: Style,
226    /// Override the thumb symbol (None = ratatui default)
227    pub thumb_symbol: Option<&'static str>,
228    /// Override the track symbol (None = no track)
229    pub track_symbol: Option<&'static str>,
230    /// Override the begin symbol (None = no symbol)
231    pub begin_symbol: Option<&'static str>,
232    /// Override the end symbol (None = no symbol)
233    pub end_symbol: Option<&'static str>,
234}
235
236impl Default for ScrollbarStyle {
237    fn default() -> Self {
238        Self {
239            thumb: Style::default().fg(Color::Cyan),
240            track: Style::default().fg(Color::DarkGray),
241            begin: Style::default().fg(Color::DarkGray),
242            end: Style::default().fg(Color::DarkGray),
243            thumb_symbol: Some("█"),
244            track_symbol: Some("│"),
245            begin_symbol: None,
246            end_symbol: None,
247        }
248    }
249}
250
251impl ScrollbarStyle {
252    /// Build a ratatui Scrollbar widget from this style
253    pub fn build(&self, orientation: ScrollbarOrientation) -> Scrollbar<'static> {
254        let mut scrollbar = Scrollbar::new(orientation)
255            .thumb_style(self.thumb)
256            .track_style(self.track)
257            .begin_style(self.begin)
258            .end_style(self.end)
259            .track_symbol(self.track_symbol)
260            .begin_symbol(self.begin_symbol)
261            .end_symbol(self.end_symbol);
262
263        if let Some(symbol) = self.thumb_symbol {
264            scrollbar = scrollbar.thumb_symbol(symbol);
265        }
266
267        scrollbar
268    }
269}
270
271// ============================================================================
272// Utility functions
273// ============================================================================
274
275use ratatui::text::{Line, Span};
276
277/// Highlight substring matches in text (case-insensitive)
278///
279/// Returns a `Line` with matching portions styled using `highlight_style`.
280/// Non-matching portions use the `base_style`.
281///
282/// # Example
283///
284/// ```ignore
285/// use tui_dispatch_components::style::{highlight_substring, Style, Color, Modifier};
286///
287/// let base = Style::default();
288/// let highlight = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
289/// let line = highlight_substring("Hello World", "wor", base, highlight);
290/// // Results in: "Hello " (base) + "Wor" (highlight) + "ld" (base)
291/// ```
292///
293/// # Notes
294///
295/// - Matching is case-insensitive
296/// - Only works with ASCII text; non-ASCII returns the text with base style
297/// - Empty query returns the text with base style
298pub fn highlight_substring(
299    text: &str,
300    query: &str,
301    base_style: Style,
302    highlight_style: Style,
303) -> Line<'static> {
304    if query.is_empty() {
305        return Line::styled(text.to_string(), base_style);
306    }
307
308    // Fall back for non-ASCII to avoid indexing issues
309    if !text.is_ascii() || !query.is_ascii() {
310        return Line::styled(text.to_string(), base_style);
311    }
312
313    let text_lower = text.to_lowercase();
314    let query_lower = query.to_lowercase();
315
316    let mut spans = Vec::new();
317    let mut last_end = 0;
318
319    for (start, _) in text_lower.match_indices(&query_lower) {
320        // Add non-matching part before this match
321        if start > last_end {
322            spans.push(Span::styled(text[last_end..start].to_string(), base_style));
323        }
324
325        // Add matching part with highlight
326        let end = start + query.len();
327        spans.push(Span::styled(text[start..end].to_string(), highlight_style));
328        last_end = end;
329    }
330
331    // Add remaining part after last match
332    if last_end < text.len() {
333        spans.push(Span::styled(text[last_end..].to_string(), base_style));
334    }
335
336    if spans.is_empty() {
337        Line::styled(text.to_string(), base_style)
338    } else {
339        Line::from(spans)
340    }
341}