Skip to main content

tui_lipan/widgets/containers/
mod.rs

1//! Container widgets.
2
3use std::sync::Arc;
4
5use crate::callback::Callback;
6use crate::core::element::{Element, ElementKind};
7use crate::layout::axis::Axis;
8use crate::style::{
9    Align, BorderStyle, Justify, LayoutConstraints, Length, Padding, RichText, Style, StyleSlot,
10};
11use crate::widgets::TabsEvent;
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14pub(crate) mod exit_retention;
15pub(crate) mod layout;
16pub(crate) mod node;
17pub(crate) mod reconcile;
18
19pub(crate) use self::layout::measure_stack;
20
21/// Visual style variant for border tabs in a [`VStack`].
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
23pub enum TabVariant {
24    /// Classic style: `[ Active ]|Inactive`
25    #[default]
26    Classic,
27    /// Minimal style: `Active - Inactive` (no brackets, differentiated by color only)
28    Minimal,
29    /// Custom brackets and separator.
30    Custom {
31        /// Characters surrounding the active tab (prefix, suffix).
32        active_brackets: (char, char),
33        /// String used to separate tabs (e.g., `"|"`, `" | "`, `" :: "`).
34        separator: &'static str,
35    },
36}
37
38/// Focus-aware sizing policy for stack containers.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
40pub enum FocusSizing {
41    /// No focus-aware sizing.
42    #[default]
43    None,
44    /// Accordion-style focus sizing (lazygit-style).
45    Accordion(FocusAccordion),
46}
47
48/// Focus traversal behavior for a container subtree.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
50pub enum FocusScope {
51    /// Use the surrounding focus traversal behavior.
52    #[default]
53    None,
54    /// Remove this subtree from traversal, automatic fallback, and click focus.
55    /// Explicit keyed focus requests may still enter the subtree.
56    Exclude,
57    /// Keep next/previous focus traversal within this subtree while it contains focus.
58    Contain,
59}
60
61impl FocusSizing {
62    /// Use the default accordion policy.
63    pub fn accordion() -> Self {
64        Self::Accordion(FocusAccordion::default())
65    }
66}
67
68/// Accordion sizing policy for focused stacks.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub struct FocusAccordion {
71    /// Minimum height for the focused child.
72    pub focused_min: u16,
73    /// Height assigned to non-focused children when squashed.
74    pub collapsed: u16,
75    /// Height assigned to non-focused children in tiny mode.
76    pub tiny_collapsed: u16,
77    /// Flex weight multiplier for the focused child in accordion mode.
78    pub expanded_weight: u16,
79    /// Height threshold to enter squashed mode.
80    pub squash_threshold: u16,
81    /// Height threshold to enter tiny mode.
82    pub tiny_threshold: u16,
83    /// When `true`, the VStack automatically remembers the last focused child
84    /// and keeps it expanded when focus moves outside the stack entirely.
85    ///
86    /// Defaults to `true` - the right out-of-the-box behaviour for multi-panel
87    /// layouts (lazygit-style). Set to `false` to disable.
88    pub sticky: bool,
89}
90
91impl Default for FocusAccordion {
92    fn default() -> Self {
93        Self {
94            focused_min: 7,
95            collapsed: 3,
96            tiny_collapsed: 1,
97            expanded_weight: 2,
98            squash_threshold: 28,
99            tiny_threshold: 21,
100            sticky: true,
101        }
102    }
103}
104
105impl TabVariant {
106    pub(crate) fn separator(&self) -> &'static str {
107        match self {
108            Self::Classic => "|",
109            Self::Minimal => " - ",
110            Self::Custom { separator, .. } => separator,
111        }
112    }
113
114    pub(crate) fn separator_width(&self) -> usize {
115        UnicodeWidthStr::width(self.separator())
116    }
117
118    pub(crate) fn active_padding_width(&self) -> (usize, usize) {
119        match self {
120            Self::Classic => (2, 2),
121            Self::Minimal => (0, 0),
122            Self::Custom {
123                active_brackets: (l, r),
124                ..
125            } => (
126                UnicodeWidthChar::width(*l).unwrap_or(1),
127                UnicodeWidthChar::width(*r).unwrap_or(1),
128            ),
129        }
130    }
131
132    pub(crate) fn inactive_padding_width(&self) -> (usize, usize) {
133        match self {
134            Self::Classic => (1, 1),
135            Self::Minimal => (0, 0),
136            Self::Custom { .. } => (0, 0),
137        }
138    }
139
140    pub(crate) fn active_surround(
141        &self,
142    ) -> (
143        std::borrow::Cow<'static, str>,
144        std::borrow::Cow<'static, str>,
145    ) {
146        use std::borrow::Cow;
147        match self {
148            Self::Classic => (Cow::Borrowed("[ "), Cow::Borrowed(" ]")),
149            Self::Minimal => (Cow::Borrowed(""), Cow::Borrowed("")),
150            Self::Custom {
151                active_brackets: (l, r),
152                ..
153            } => (Cow::Owned(l.to_string()), Cow::Owned(r.to_string())),
154        }
155    }
156
157    pub(crate) fn inactive_surround(
158        &self,
159    ) -> (
160        std::borrow::Cow<'static, str>,
161        std::borrow::Cow<'static, str>,
162    ) {
163        use std::borrow::Cow;
164        match self {
165            Self::Classic => (Cow::Borrowed(" "), Cow::Borrowed(" ")),
166            Self::Minimal => (Cow::Borrowed(""), Cow::Borrowed("")),
167            Self::Custom { .. } => (Cow::Borrowed(""), Cow::Borrowed("")),
168        }
169    }
170}
171
172/// Shared properties for stack containers.
173#[derive(Clone, Debug)]
174pub(crate) struct StackProps {
175    /// Gap between children.
176    pub gap: u16,
177    /// Padding applied to the inner area.
178    /// Default: `Padding::default()`.
179    pub padding: Padding,
180    /// Base style (used for background fill).
181    pub style: Style,
182    /// Cross-axis alignment.
183    /// Default: `Align::Start`.
184    pub align: Align,
185    /// Main-axis alignment.
186    /// Default: `Justify::Start`.
187    pub justify: Justify,
188    /// Requested width.
189    /// Default: `Length::Flex(1)`.
190    pub width: Length,
191    /// Requested height.
192    /// Default: `Length::Flex(1)`.
193    pub height: Length,
194    /// Focus-aware sizing policy.
195    pub focus_sizing: FocusSizing,
196    /// Focus traversal behavior for this subtree.
197    pub focus_scope: FocusScope,
198    /// Draw a border.
199    pub border: bool,
200    /// Border style.
201    /// Default: `BorderStyle::Plain`.
202    pub border_style: BorderStyle,
203    /// Distribute flex items evenly, ignoring remainders.
204    pub even_flex: bool,
205}
206
207impl Default for StackProps {
208    fn default() -> Self {
209        Self {
210            gap: 0,
211            padding: Padding::default(),
212            style: Style::default(),
213            align: Align::Start,
214            justify: Justify::Start,
215            // Containers default to flex-like behavior.
216            width: Length::Flex(1),
217            height: Length::Flex(1),
218            focus_sizing: FocusSizing::None,
219            focus_scope: FocusScope::None,
220            border: false,
221            border_style: BorderStyle::Plain,
222            even_flex: false,
223        }
224    }
225}
226
227macro_rules! impl_stack_props {
228    ($name:ident) => {
229        impl $name {
230            /// Add a child.
231            pub fn child(mut self, child: impl Into<Element>) -> Self {
232                self.children.push(child.into());
233                self
234            }
235
236            /// Replace all children, discarding anything already added with
237            /// [`child`](Self::child). Call `child` repeatedly to append instead.
238            pub fn children(mut self, children: impl IntoIterator<Item = Element>) -> Self {
239                self.children = children.into_iter().collect();
240                self
241            }
242
243            /// Set border.
244            pub fn border(mut self, border: bool) -> Self {
245                self.props.border = border;
246                self
247            }
248
249            /// Set border style.
250            pub fn border_style(mut self, border_style: BorderStyle) -> Self {
251                self.props.border_style = border_style;
252                self
253            }
254
255            /// Set padding.
256            pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
257                self.props.padding = padding.into();
258                self
259            }
260
261            /// Set base style.
262            pub fn style(mut self, style: Style) -> Self {
263                self.props.style = style;
264                self
265            }
266
267            /// Set gap.
268            pub fn gap(mut self, gap: u16) -> Self {
269                self.props.gap = gap;
270                self
271            }
272
273            /// Set cross-axis alignment.
274            pub fn align(mut self, align: Align) -> Self {
275                self.props.align = align;
276                self
277            }
278
279            /// Distribute flex items perfectly evenly, ignoring pixel remainder.
280            pub fn even_flex(mut self, even: bool) -> Self {
281                self.props.even_flex = even;
282                self
283            }
284
285            /// Set main-axis alignment.
286            ///
287            /// `Justify::SpaceBetween`/`SpaceAround`/`SpaceEvenly` only show
288            /// spacing when children have non-flex main-axis sizing. The stack's
289            /// default child contribution is `Flex(1)` on the main axis, so
290            /// children fill all available space and the layout looks identical
291            /// to `Start`. Set each child's main-axis length to `Length::Auto`
292            /// or a fixed `Length::Px(_)` to leave slack for the spacer math.
293            pub fn justify(mut self, justify: Justify) -> Self {
294                self.props.justify = justify;
295                self
296            }
297
298            /// Override requested width.
299            pub fn width(mut self, width: Length) -> Self {
300                self.props.width = width;
301                self
302            }
303
304            /// Override requested height.
305            pub fn height(mut self, height: Length) -> Self {
306                self.props.height = height;
307                self
308            }
309
310            /// Set focus-aware sizing behavior.
311            pub fn focus_sizing(mut self, sizing: FocusSizing) -> Self {
312                self.props.focus_sizing = sizing;
313                self
314            }
315
316            /// Set focus traversal behavior for this subtree.
317            pub fn focus_scope(mut self, scope: FocusScope) -> Self {
318                self.props.focus_scope = scope;
319                self
320            }
321        }
322    };
323}
324
325/// A vertical stack container.
326#[derive(Clone, Default)]
327pub struct VStack {
328    /// Layout properties.
329    pub(crate) props: StackProps,
330    /// Children.
331    pub(crate) children: Vec<Element>,
332    /// Optional tab titles rendered in the top border.
333    pub(crate) tab_titles: Vec<RichText>,
334    /// Index of the active tab.
335    pub(crate) active_tab: usize,
336    /// Callback fired when a border tab is clicked.
337    pub(crate) on_tab_change: Option<Callback<TabsEvent>>,
338    /// Style applied to the active tab.
339    pub(crate) active_tab_style: StyleSlot,
340    /// Style applied to inactive tabs and separators.
341    pub(crate) inactive_tab_style: Style,
342    /// Visual variant for border tabs.
343    pub(crate) tab_variant: TabVariant,
344    /// Optional title prefix (rendered before tabs).
345    pub(crate) title_prefix: Option<Arc<str>>,
346}
347
348impl_stack_props!(VStack);
349
350impl VStack {
351    /// Create an empty vertical stack.
352    pub fn new() -> Self {
353        Self::default()
354    }
355
356    /// Set tab titles rendered in the top border.
357    pub fn tab_titles<I, S>(mut self, titles: I) -> Self
358    where
359        I: IntoIterator<Item = S>,
360        S: Into<RichText>,
361    {
362        self.tab_titles = titles.into_iter().map(Into::into).collect();
363        self
364    }
365
366    /// Set the active tab index.
367    pub fn active_tab(mut self, active_tab: usize) -> Self {
368        self.active_tab = active_tab;
369        self
370    }
371
372    /// Set the style for the active tab.
373    pub fn active_tab_style(mut self, style: Style) -> Self {
374        self.active_tab_style = StyleSlot::Replace(style);
375        self
376    }
377
378    /// Extend the themed active tab style with the given style.
379    pub fn extend_active_tab_style(mut self, style: Style) -> Self {
380        self.active_tab_style = StyleSlot::Extend(style);
381        self
382    }
383
384    /// Inherit active tab style from the active theme.
385    pub fn inherit_active_tab_style(mut self) -> Self {
386        self.active_tab_style = StyleSlot::Inherit;
387        self
388    }
389
390    /// Set the active tab style slot directly.
391    pub fn active_tab_style_slot(mut self, slot: StyleSlot) -> Self {
392        self.active_tab_style = slot;
393        self
394    }
395
396    /// Set the style for inactive tabs.
397    pub fn inactive_tab_style(mut self, style: Style) -> Self {
398        self.inactive_tab_style = style;
399        self
400    }
401
402    /// Callback fired when the active tab changes via border tab clicks.
403    pub fn on_tab_change(mut self, cb: Callback<TabsEvent>) -> Self {
404        self.on_tab_change = Some(cb);
405        self
406    }
407
408    /// Set the visual variant for border tabs.
409    pub fn tab_variant(mut self, variant: TabVariant) -> Self {
410        self.tab_variant = variant;
411        self
412    }
413
414    /// Set an optional prefix rendered before the tabs.
415    pub fn title_prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
416        self.title_prefix = Some(prefix.into());
417        self
418    }
419
420    pub(crate) fn border_index_at_col(
421        tab_titles: &[RichText],
422        active_tab: usize,
423        tab_variant: TabVariant,
424        col: usize,
425    ) -> Option<usize> {
426        let mut x = 0usize;
427
428        for (i, title) in tab_titles.iter().enumerate() {
429            let title_w = title.width();
430
431            let (pad_l, pad_r) = if i == active_tab {
432                tab_variant.active_padding_width()
433            } else {
434                tab_variant.inactive_padding_width()
435            };
436
437            let w = title_w.saturating_add(pad_l).saturating_add(pad_r);
438
439            if col < x.saturating_add(w) {
440                return Some(i);
441            }
442            x = x.saturating_add(w);
443
444            // Add separator width if not the last tab.
445            if i + 1 < tab_titles.len() {
446                let sep_w = tab_variant.separator_width();
447                if col < x.saturating_add(sep_w) {
448                    return None; // Click on separator.
449                }
450                x = x.saturating_add(sep_w);
451            }
452        }
453
454        None
455    }
456}
457
458impl From<VStack> for Element {
459    fn from(value: VStack) -> Self {
460        let has_children = !value.children.is_empty();
461        // For Flex containers, use minimal chrome size to prevent
462        // claiming full content size during min-size calculations.
463        let is_flex_h = matches!(value.props.height, Length::Flex(_));
464        let is_flex_w = matches!(value.props.width, Length::Flex(_));
465        let is_auto_h = matches!(value.props.height, Length::Auto);
466        let chrome_h = value.props.padding.vertical() + if value.props.border { 2 } else { 0 };
467        let auto_h_depends_on_width = is_auto_h
468            && value
469                .children
470                .iter()
471                .any(crate::widgets::scroll_child_height_depends_on_width);
472
473        let (min_w, measured_min_h) = match (is_flex_w, is_flex_h) {
474            (true, true) => {
475                let mut w = value.props.padding.horizontal();
476                let mut h = value.props.padding.vertical();
477                if value.props.border {
478                    w += 2;
479                    h += 2;
480                }
481                if has_children {
482                    w = w.max(1);
483                    h = h.max(1);
484                }
485                (w, h)
486            }
487            (true, false) => {
488                let (_w, h) =
489                    measure_stack(&value.props, &value.children, Axis::Vertical, None, None);
490                let mut min_w = value.props.padding.horizontal();
491                if value.props.border {
492                    min_w += 2;
493                }
494                (min_w, h)
495            }
496            (false, true) => {
497                let (w, content_h) =
498                    measure_stack(&value.props, &value.children, Axis::Vertical, None, None);
499                let mut chrome_h = value.props.padding.vertical();
500                if value.props.border {
501                    chrome_h += 2;
502                }
503                (w, chrome_h.max(content_h))
504            }
505            (false, false) => {
506                measure_stack(&value.props, &value.children, Axis::Vertical, None, None)
507            }
508        };
509
510        let min_h = if auto_h_depends_on_width {
511            chrome_h
512        } else {
513            measured_min_h
514        };
515
516        Element::new(ElementKind::VStack(value)).with_layout(
517            LayoutConstraints::default()
518                .min_width(Length::Px(min_w))
519                .min_height(Length::Px(min_h)),
520        )
521    }
522}
523
524impl crate::layout::hash::LayoutHash for VStack {
525    fn layout_hash(
526        &self,
527        hasher: &mut impl std::hash::Hasher,
528        recurse: &dyn Fn(&Element) -> Option<u64>,
529    ) -> Option<()> {
530        crate::layout::hash::hash_stack_props(&self.props, hasher);
531        crate::layout::hash::hash_children(&self.children, hasher, recurse)
532    }
533}
534
535/// A horizontal stack container.
536#[derive(Clone)]
537pub struct HStack {
538    /// Layout properties.
539    pub(crate) props: StackProps,
540    /// Children.
541    pub(crate) children: Vec<Element>,
542}
543
544impl Default for HStack {
545    fn default() -> Self {
546        Self {
547            props: StackProps {
548                align: Align::Center,
549                ..StackProps::default()
550            },
551            children: Vec::new(),
552        }
553    }
554}
555
556impl_stack_props!(HStack);
557
558impl HStack {
559    /// Create an empty horizontal stack.
560    pub fn new() -> Self {
561        Self::default()
562    }
563}
564
565impl From<HStack> for Element {
566    fn from(value: HStack) -> Self {
567        let has_children = !value.children.is_empty();
568        // For Flex containers, use minimal chrome size to prevent
569        // claiming full content size during min-size calculations.
570        let is_flex_h = matches!(value.props.height, Length::Flex(_));
571        let is_flex_w = matches!(value.props.width, Length::Flex(_));
572        let is_auto_h = matches!(value.props.height, Length::Auto);
573        let chrome_h = value.props.padding.vertical() + if value.props.border { 2 } else { 0 };
574        let auto_h_depends_on_width = is_auto_h
575            && value
576                .children
577                .iter()
578                .any(crate::widgets::scroll_child_height_depends_on_width);
579
580        let (min_w, measured_min_h) = match (is_flex_w, is_flex_h) {
581            (true, true) => {
582                // Both axes are Flex - only chrome size matters for min constraints.
583                // Matches VStack (true, true) behavior. Avoids a full measure_stack
584                // call that would recursively measure all children.
585                let mut chrome_w = value.props.padding.horizontal();
586                let mut chrome_h = value.props.padding.vertical();
587                if value.props.border {
588                    chrome_w += 2;
589                    chrome_h += 2;
590                }
591                if has_children {
592                    chrome_w = chrome_w.max(1);
593                    chrome_h = chrome_h.max(1);
594                }
595                (chrome_w, chrome_h)
596            }
597            (true, false) => {
598                let (_w, h) =
599                    measure_stack(&value.props, &value.children, Axis::Horizontal, None, None);
600                let mut min_w = value.props.padding.horizontal();
601                if value.props.border {
602                    min_w += 2;
603                }
604                (min_w, h)
605            }
606            (false, true) => {
607                let (w, content_h) =
608                    measure_stack(&value.props, &value.children, Axis::Horizontal, None, None);
609                let mut chrome_h = value.props.padding.vertical();
610                if value.props.border {
611                    chrome_h += 2;
612                }
613                (w, chrome_h.max(content_h))
614            }
615            (false, false) => {
616                measure_stack(&value.props, &value.children, Axis::Horizontal, None, None)
617            }
618        };
619
620        let min_h = if auto_h_depends_on_width {
621            chrome_h
622        } else {
623            measured_min_h
624        };
625
626        Element::new(ElementKind::HStack(value)).with_layout(
627            LayoutConstraints::default()
628                .min_width(Length::Px(min_w))
629                .min_height(Length::Px(min_h)),
630        )
631    }
632}
633
634impl crate::layout::hash::LayoutHash for HStack {
635    fn layout_hash(
636        &self,
637        hasher: &mut impl std::hash::Hasher,
638        recurse: &dyn Fn(&Element) -> Option<u64>,
639    ) -> Option<()> {
640        crate::layout::hash::hash_stack_props(&self.props, hasher);
641        crate::layout::hash::hash_children(&self.children, hasher, recurse)
642    }
643}