Skip to main content

tui_checkbox/
lib.rs

1//! # tui-checkbox
2//!
3//! A customizable checkbox widget for [Ratatui](https://github.com/ratatui/ratatui) TUI applications.
4//!
5//! ## Features
6//!
7//! - ☑️ Simple checkbox with label
8//! - 🎨 Customizable styling for checkbox and label separately
9//! - 🔤 Custom symbols (unicode, emoji, ASCII)
10//! - 📦 Optional block wrapper
11//! - ⚡ Zero-cost abstractions
12//!
13//! ## Examples
14//!
15//! Basic usage:
16//!
17//! ```no_run
18//! use ratatui::style::{Color, Style};
19//! use tui_checkbox::Checkbox;
20//!
21//! let checkbox = Checkbox::new("Enable feature", true);
22//! ```
23//!
24//! With custom styling:
25//!
26//! ```no_run
27//! use ratatui::style::{Color, Style, Modifier};
28//! use tui_checkbox::Checkbox;
29//!
30//! let checkbox = Checkbox::new("Enable feature", true)
31//!     .style(Style::default().fg(Color::White))
32//!     .checkbox_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
33//!     .label_style(Style::default().fg(Color::Gray));
34//! ```
35//!
36//! With custom symbols:
37//!
38//! ```no_run
39//! use tui_checkbox::Checkbox;
40//!
41//! let checkbox = Checkbox::new("Task", false)
42//!     .checked_symbol("✅ ")
43//!     .unchecked_symbol("⬜ ");
44//! ```
45
46#![warn(missing_docs)]
47#![warn(clippy::pedantic)]
48#![allow(clippy::cast_possible_truncation)] // Terminal dimensions are always small
49
50use std::borrow::Cow;
51
52use ratatui::buffer::Buffer;
53use ratatui::layout::Rect;
54use ratatui::style::{Style, Styled};
55use ratatui::text::{Line, Span};
56use ratatui::widgets::{Block, Widget};
57
58pub mod symbols;
59
60/// Position of the label relative to the checkbox symbol.
61#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
62pub enum LabelPosition {
63    /// Label appears to the right of the checkbox (default)
64    #[default]
65    Right,
66    /// Label appears to the left of the checkbox
67    Left,
68    /// Label appears above the checkbox
69    Top,
70    /// Label appears below the checkbox
71    Bottom,
72}
73
74/// Horizontal alignment of content within its area.
75#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
76pub enum HorizontalAlignment {
77    /// Align to the left (default)
78    #[default]
79    Left,
80    /// Align to the center
81    Center,
82    /// Align to the right
83    Right,
84}
85
86impl HorizontalAlignment {
87    /// Computes the horizontal offset for `content` width within an `available` width.
88    const fn offset(self, available: u16, content: u16) -> u16 {
89        match self {
90            Self::Left => 0,
91            Self::Center => available.saturating_sub(content) / 2,
92            Self::Right => available.saturating_sub(content),
93        }
94    }
95}
96
97/// Vertical alignment of content within its area.
98#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
99pub enum VerticalAlignment {
100    /// Align to the top (default)
101    #[default]
102    Top,
103    /// Align to the center
104    Center,
105    /// Align to the bottom
106    Bottom,
107}
108
109impl VerticalAlignment {
110    /// Computes the vertical offset for `content` height within an `available` height.
111    const fn offset(self, available: u16, content: u16) -> u16 {
112        match self {
113            Self::Top => 0,
114            Self::Center => available.saturating_sub(content) / 2,
115            Self::Bottom => available.saturating_sub(content),
116        }
117    }
118}
119
120/// Converts a borrowed [`Line`] into an owned `Line<'static>` by cloning span contents.
121fn to_owned_line(line: &Line<'_>) -> Line<'static> {
122    Line::from(
123        line.spans
124            .iter()
125            .map(|s| Span::styled(s.content.to_string(), s.style))
126            .collect::<Vec<_>>(),
127    )
128}
129
130/// A widget that displays a checkbox with a label.
131///
132/// A `Checkbox` can be in a checked or unchecked state. The checkbox is rendered with a symbol
133/// (default `☐` for unchecked and `☑` for checked) followed by a label.
134///
135/// The widget can be styled using [`Checkbox::style`] which affects both the checkbox symbol and
136/// the label. You can also style just the checkbox symbol using [`Checkbox::checkbox_style`] or
137/// the label using [`Checkbox::label_style`].
138///
139/// You can create a `Checkbox` using [`Checkbox::new`] or [`Checkbox::default`].
140///
141/// # Examples
142///
143/// ```
144/// use ratatui::style::{Color, Style, Stylize};
145/// use tui_checkbox::Checkbox;
146///
147/// Checkbox::new("Enable feature", true)
148///     .style(Style::default().fg(Color::White))
149///     .checkbox_style(Style::default().fg(Color::Green))
150///     .label_style(Style::default().fg(Color::Gray));
151/// ```
152///
153/// With a block:
154/// ```
155/// use ratatui::widgets::Block;
156/// use tui_checkbox::Checkbox;
157///
158/// Checkbox::new("Accept terms", false).block(Block::bordered().title("Settings"));
159/// ```
160#[expect(clippy::struct_field_names)] // checkbox_style needs to be differentiated from style
161#[derive(Debug, Clone, Eq, PartialEq, Hash)]
162pub struct Checkbox<'a> {
163    /// The label text displayed next to the checkbox
164    label: Line<'a>,
165    /// Whether the checkbox is checked
166    checked: bool,
167    /// Optional block to wrap the checkbox
168    block: Option<Block<'a>>,
169    /// Base style for the entire widget
170    style: Style,
171    /// Style specifically for the checkbox symbol
172    checkbox_style: Style,
173    /// Style specifically for the label text
174    label_style: Style,
175    /// Symbol to use when checked
176    checked_symbol: Cow<'a, str>,
177    /// Symbol to use when unchecked
178    unchecked_symbol: Cow<'a, str>,
179    /// Position of the label relative to the checkbox
180    label_position: LabelPosition,
181    /// Horizontal alignment of the checkbox symbol
182    horizontal_alignment: HorizontalAlignment,
183    /// Vertical alignment of the checkbox symbol
184    vertical_alignment: VerticalAlignment,
185    /// Minimum width constraint
186    min_width: Option<u16>,
187    /// Maximum width constraint
188    max_width: Option<u16>,
189    /// Whether to wrap label text to multiple lines
190    wrap_label: bool,
191}
192
193impl Default for Checkbox<'_> {
194    /// Returns a default `Checkbox` widget.
195    ///
196    /// The default widget has:
197    /// - Empty label
198    /// - Unchecked state
199    /// - No block
200    /// - Default style for all elements
201    /// - Unicode checkbox symbols (☐ and ☑)
202    /// - Label position on the right
203    /// - Left and top alignment
204    /// - No width constraints
205    /// - No label wrapping
206    ///
207    /// # Examples
208    ///
209    /// ```
210    /// use tui_checkbox::Checkbox;
211    ///
212    /// let checkbox = Checkbox::default();
213    /// ```
214    fn default() -> Self {
215        Self {
216            label: Line::default(),
217            checked: false,
218            block: None,
219            style: Style::default(),
220            checkbox_style: Style::default(),
221            label_style: Style::default(),
222            checked_symbol: Cow::Borrowed(symbols::CHECKED),
223            unchecked_symbol: Cow::Borrowed(symbols::UNCHECKED),
224            label_position: LabelPosition::default(),
225            horizontal_alignment: HorizontalAlignment::default(),
226            vertical_alignment: VerticalAlignment::default(),
227            min_width: None,
228            max_width: None,
229            wrap_label: false,
230        }
231    }
232}
233
234impl<'a> Checkbox<'a> {
235    /// Creates a new `Checkbox` with the given label and checked state.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// use tui_checkbox::Checkbox;
241    ///
242    /// let checkbox = Checkbox::new("Enable feature", true);
243    /// ```
244    ///
245    /// With styled label:
246    /// ```
247    /// use ratatui::style::Stylize;
248    /// use tui_checkbox::Checkbox;
249    ///
250    /// let checkbox = Checkbox::new("Enable feature".blue(), false);
251    /// ```
252    pub fn new<T>(label: T, checked: bool) -> Self
253    where
254        T: Into<Line<'a>>,
255    {
256        Self {
257            label: label.into(),
258            checked,
259            ..Default::default()
260        }
261    }
262
263    /// Sets the label of the checkbox.
264    ///
265    /// The label can be any type that converts into a [`Line`], such as a string or a styled span.
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// use tui_checkbox::Checkbox;
271    ///
272    /// let checkbox = Checkbox::default().label("My checkbox");
273    /// ```
274    #[must_use = "method moves the value of self and returns the modified value"]
275    pub fn label<T>(mut self, label: T) -> Self
276    where
277        T: Into<Line<'a>>,
278    {
279        self.label = label.into();
280        self
281    }
282
283    /// Sets the checked state of the checkbox.
284    ///
285    /// # Examples
286    ///
287    /// ```
288    /// use tui_checkbox::Checkbox;
289    ///
290    /// let checkbox = Checkbox::default().checked(true);
291    /// ```
292    #[must_use = "method moves the value of self and returns the modified value"]
293    pub const fn checked(mut self, checked: bool) -> Self {
294        self.checked = checked;
295        self
296    }
297
298    /// Wraps the checkbox with the given block.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use ratatui::widgets::Block;
304    /// use tui_checkbox::Checkbox;
305    ///
306    /// let checkbox = Checkbox::new("Option", false).block(Block::bordered().title("Settings"));
307    /// ```
308    #[must_use = "method moves the value of self and returns the modified value"]
309    pub fn block(mut self, block: Block<'a>) -> Self {
310        self.block = Some(block);
311        self
312    }
313
314    /// Sets the base style of the widget.
315    ///
316    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
317    /// your own type that implements [`Into<Style>`]).
318    ///
319    /// This style will be applied to both the checkbox symbol and the label unless overridden by
320    /// more specific styles.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use ratatui::style::{Color, Style};
326    /// use tui_checkbox::Checkbox;
327    ///
328    /// let checkbox = Checkbox::new("Option", false).style(Style::default().fg(Color::White));
329    /// ```
330    ///
331    /// [`Color`]: ratatui::style::Color
332    #[must_use = "method moves the value of self and returns the modified value"]
333    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
334        self.style = style.into();
335        self
336    }
337
338    /// Sets the style of the checkbox symbol.
339    ///
340    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
341    /// your own type that implements [`Into<Style>`]).
342    ///
343    /// This style will be combined with the base style set by [`Checkbox::style`].
344    ///
345    /// # Examples
346    ///
347    /// ```
348    /// use ratatui::style::{Color, Style};
349    /// use tui_checkbox::Checkbox;
350    ///
351    /// let checkbox = Checkbox::new("Option", true).checkbox_style(Style::default().fg(Color::Green));
352    /// ```
353    ///
354    /// [`Color`]: ratatui::style::Color
355    #[must_use = "method moves the value of self and returns the modified value"]
356    pub fn checkbox_style<S: Into<Style>>(mut self, style: S) -> Self {
357        self.checkbox_style = style.into();
358        self
359    }
360
361    /// Sets the style of the label text.
362    ///
363    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
364    /// your own type that implements [`Into<Style>`]).
365    ///
366    /// This style will be combined with the base style set by [`Checkbox::style`].
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use ratatui::style::{Color, Style};
372    /// use tui_checkbox::Checkbox;
373    ///
374    /// let checkbox = Checkbox::new("Option", false).label_style(Style::default().fg(Color::Gray));
375    /// ```
376    ///
377    /// [`Color`]: ratatui::style::Color
378    #[must_use = "method moves the value of self and returns the modified value"]
379    pub fn label_style<S: Into<Style>>(mut self, style: S) -> Self {
380        self.label_style = style.into();
381        self
382    }
383
384    /// Sets the symbol to use when the checkbox is checked.
385    ///
386    /// The default is `☑` (U+2611).
387    ///
388    /// # Examples
389    ///
390    /// ```
391    /// use tui_checkbox::Checkbox;
392    ///
393    /// let checkbox = Checkbox::new("Option", true).checked_symbol("[X]");
394    /// ```
395    #[must_use = "method moves the value of self and returns the modified value"]
396    pub fn checked_symbol<T>(mut self, symbol: T) -> Self
397    where
398        T: Into<Cow<'a, str>>,
399    {
400        self.checked_symbol = symbol.into();
401        self
402    }
403
404    /// Sets the symbol to use when the checkbox is unchecked.
405    ///
406    /// The default is `☐` (U+2610).
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// use tui_checkbox::Checkbox;
412    ///
413    /// let checkbox = Checkbox::new("Option", false).unchecked_symbol("[ ]");
414    /// ```
415    #[must_use = "method moves the value of self and returns the modified value"]
416    pub fn unchecked_symbol<T>(mut self, symbol: T) -> Self
417    where
418        T: Into<Cow<'a, str>>,
419    {
420        self.unchecked_symbol = symbol.into();
421        self
422    }
423
424    /// Sets the position of the label relative to the checkbox symbol.
425    ///
426    /// The default is [`LabelPosition::Right`].
427    ///
428    /// # Examples
429    ///
430    /// ```
431    /// use tui_checkbox::{Checkbox, LabelPosition};
432    ///
433    /// let checkbox = Checkbox::new("Option", false).label_position(LabelPosition::Left);
434    /// ```
435    #[must_use = "method moves the value of self and returns the modified value"]
436    pub const fn label_position(mut self, position: LabelPosition) -> Self {
437        self.label_position = position;
438        self
439    }
440
441    /// Sets the horizontal alignment of the checkbox content within its area.
442    ///
443    /// The default is [`HorizontalAlignment::Left`].
444    ///
445    /// # Examples
446    ///
447    /// ```
448    /// use tui_checkbox::{Checkbox, HorizontalAlignment};
449    ///
450    /// let checkbox = Checkbox::new("Option", false)
451    ///     .horizontal_alignment(HorizontalAlignment::Center);
452    /// ```
453    #[must_use = "method moves the value of self and returns the modified value"]
454    pub const fn horizontal_alignment(mut self, alignment: HorizontalAlignment) -> Self {
455        self.horizontal_alignment = alignment;
456        self
457    }
458
459    /// Sets the vertical alignment of the checkbox content within its area.
460    ///
461    /// The default is [`VerticalAlignment::Top`].
462    ///
463    /// # Examples
464    ///
465    /// ```
466    /// use tui_checkbox::{Checkbox, VerticalAlignment};
467    ///
468    /// let checkbox = Checkbox::new("Option", false)
469    ///     .vertical_alignment(VerticalAlignment::Center);
470    /// ```
471    #[must_use = "method moves the value of self and returns the modified value"]
472    pub const fn vertical_alignment(mut self, alignment: VerticalAlignment) -> Self {
473        self.vertical_alignment = alignment;
474        self
475    }
476
477    /// Sets the minimum width constraint for the checkbox widget.
478    ///
479    /// The default is no minimum width.
480    ///
481    /// # Examples
482    ///
483    /// ```
484    /// use tui_checkbox::Checkbox;
485    ///
486    /// let checkbox = Checkbox::new("Option", false).min_width(20);
487    /// ```
488    #[must_use = "method moves the value of self and returns the modified value"]
489    pub const fn min_width(mut self, width: u16) -> Self {
490        self.min_width = Some(width);
491        self
492    }
493
494    /// Sets the maximum width constraint for the checkbox widget.
495    ///
496    /// The default is no maximum width.
497    ///
498    /// # Examples
499    ///
500    /// ```
501    /// use tui_checkbox::Checkbox;
502    ///
503    /// let checkbox = Checkbox::new("Option", false).max_width(40);
504    /// ```
505    #[must_use = "method moves the value of self and returns the modified value"]
506    pub const fn max_width(mut self, width: u16) -> Self {
507        self.max_width = Some(width);
508        self
509    }
510
511    /// Enables or disables label text wrapping.
512    ///
513    /// When enabled, the label will wrap to multiple lines if it exceeds the available width.
514    /// The default is `false` (no wrapping).
515    ///
516    /// # Examples
517    ///
518    /// ```
519    /// use tui_checkbox::Checkbox;
520    ///
521    /// let checkbox = Checkbox::new("This is a very long label that should wrap", false)
522    ///     .wrap_label(true)
523    ///     .max_width(30);
524    /// ```
525    #[must_use = "method moves the value of self and returns the modified value"]
526    pub const fn wrap_label(mut self, wrap: bool) -> Self {
527        self.wrap_label = wrap;
528        self
529    }
530}
531
532impl Styled for Checkbox<'_> {
533    type Item = Self;
534
535    fn style(&self) -> Style {
536        self.style
537    }
538
539    fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
540        self.style = style.into();
541        self
542    }
543}
544
545impl Widget for Checkbox<'_> {
546    fn render(self, area: Rect, buf: &mut Buffer) {
547        Widget::render(&self, area, buf);
548    }
549}
550
551impl Widget for &Checkbox<'_> {
552    fn render(self, area: Rect, buf: &mut Buffer) {
553        buf.set_style(area, self.style);
554        let inner = if let Some(ref block) = self.block {
555            let inner_area = block.inner(area);
556            block.render(area, buf);
557            inner_area
558        } else {
559            area
560        };
561        self.render_checkbox(inner, buf);
562    }
563}
564
565impl Checkbox<'_> {
566    fn render_checkbox(&self, area: Rect, buf: &mut Buffer) {
567        if area.is_empty() {
568            return;
569        }
570
571        // Determine which symbol to use based on checked state
572        let symbol = if self.checked {
573            &self.checked_symbol
574        } else {
575            &self.unchecked_symbol
576        };
577
578        // Calculate the combined styles
579        let checkbox_style = self.style.patch(self.checkbox_style);
580        let label_style = self.style.patch(self.label_style);
581
582        // Apply width constraints
583        let mut render_area = area;
584        if let Some(min_width) = self.min_width {
585            render_area.width = render_area.width.max(min_width);
586        }
587        if let Some(max_width) = self.max_width {
588            render_area.width = render_area.width.min(max_width);
589        }
590
591        // Ensure render_area doesn't exceed original area
592        render_area.width = render_area.width.min(area.width);
593
594        // Create checkbox and label spans
595        let checkbox_span = Span::styled(symbol.as_ref(), checkbox_style);
596        let styled_label = self.label.clone().patch_style(label_style);
597        let owned_label = to_owned_line(&styled_label);
598
599        // Calculate dimensions based on label position
600        match self.label_position {
601            LabelPosition::Right | LabelPosition::Left => {
602                self.render_horizontal(render_area, buf, checkbox_span, owned_label);
603            }
604            LabelPosition::Top | LabelPosition::Bottom => {
605                self.render_vertical(render_area, buf, checkbox_span, owned_label);
606            }
607        }
608    }
609
610    fn render_horizontal(
611        &self,
612        area: Rect,
613        buf: &mut Buffer,
614        checkbox_span: Span<'_>,
615        label: Line<'static>,
616    ) {
617        if area.height == 0 || area.width == 0 {
618            return;
619        }
620
621        let checkbox_width = checkbox_span.width() as u16;
622        let space_width = 1u16;
623
624        // Handle wrapping if enabled
625        let label_lines = if self.wrap_label {
626            let available_width = area.width.saturating_sub(checkbox_width + space_width);
627            Self::wrap_text(&label, available_width)
628        } else {
629            vec![label]
630        };
631
632        let total_width = if label_lines.is_empty() {
633            checkbox_width
634        } else {
635            checkbox_width
636                + space_width
637                + label_lines
638                    .iter()
639                    .map(|l| l.width() as u16)
640                    .max()
641                    .unwrap_or(0)
642        };
643
644        // Calculate horizontal offset based on alignment
645        let x_offset = self.horizontal_alignment.offset(area.width, total_width);
646
647        // Calculate vertical offset based on alignment
648        let content_height = label_lines.len() as u16;
649        let y_offset = self.vertical_alignment.offset(area.height, content_height);
650
651        // Render based on label position
652        match self.label_position {
653            LabelPosition::Right if x_offset < area.width && y_offset < area.height => {
654                // Render checkbox first, then label
655                let checkbox_area = Rect {
656                    x: area.x + x_offset,
657                    y: area.y + y_offset,
658                    width: checkbox_width.min(area.width.saturating_sub(x_offset)),
659                    height: 1,
660                };
661                Line::from(vec![checkbox_span]).render(checkbox_area, buf);
662
663                // Render label lines
664                for (i, label_line) in label_lines.iter().enumerate() {
665                    let label_x = area.x + x_offset + checkbox_width + space_width;
666                    let label_y = area.y + y_offset + i as u16;
667                    if label_y < area.y + area.height && label_x < area.x + area.width {
668                        let label_area = Rect {
669                            x: label_x,
670                            y: label_y,
671                            width: area
672                                .width
673                                .saturating_sub(x_offset + checkbox_width + space_width),
674                            height: 1,
675                        };
676                        label_line.clone().render(label_area, buf);
677                    }
678                }
679            }
680            LabelPosition::Left => {
681                // Render label first, then checkbox
682                let max_label_width = label_lines
683                    .iter()
684                    .map(|l| l.width() as u16)
685                    .max()
686                    .unwrap_or(0);
687
688                // Render label lines
689                for (i, label_line) in label_lines.iter().enumerate() {
690                    let label_y = area.y + y_offset + i as u16;
691                    if label_y < area.y + area.height && x_offset < area.width {
692                        let label_area = Rect {
693                            x: area.x + x_offset,
694                            y: label_y,
695                            width: max_label_width.min(area.width.saturating_sub(x_offset)),
696                            height: 1,
697                        };
698                        label_line.clone().render(label_area, buf);
699                    }
700                }
701
702                // Render checkbox
703                let checkbox_x = area.x + x_offset + max_label_width + space_width;
704                if checkbox_x < area.x + area.width && y_offset < area.height {
705                    let checkbox_area = Rect {
706                        x: checkbox_x,
707                        y: area.y + y_offset,
708                        width: checkbox_width.min(
709                            area.width
710                                .saturating_sub(x_offset + max_label_width + space_width),
711                        ),
712                        height: 1,
713                    };
714                    Line::from(vec![checkbox_span]).render(checkbox_area, buf);
715                }
716            }
717            _ => {}
718        }
719    }
720
721    fn render_vertical(
722        &self,
723        area: Rect,
724        buf: &mut Buffer,
725        checkbox_span: Span<'_>,
726        label: Line<'static>,
727    ) {
728        if area.height == 0 || area.width == 0 {
729            return;
730        }
731
732        // Handle wrapping if enabled
733        let label_lines = if self.wrap_label {
734            Self::wrap_text(&label, area.width)
735        } else {
736            vec![label]
737        };
738
739        let checkbox_width = checkbox_span.width() as u16;
740        let label_height = label_lines.len() as u16;
741        let total_height = 1 + label_height; // checkbox + label lines
742
743        // Calculate vertical offset
744        let y_offset = self.vertical_alignment.offset(area.height, total_height);
745
746        match self.label_position {
747            LabelPosition::Top => {
748                // Render label first
749                for (i, label_line) in label_lines.iter().enumerate() {
750                    let label_y = area.y + y_offset + i as u16;
751                    if label_y < area.y + area.height {
752                        let x_offset = self
753                            .horizontal_alignment
754                            .offset(area.width, label_line.width() as u16);
755                        let label_area = Rect {
756                            x: area.x + x_offset,
757                            y: label_y,
758                            width: area.width.saturating_sub(x_offset),
759                            height: 1,
760                        };
761                        label_line.clone().render(label_area, buf);
762                    }
763                }
764
765                // Render checkbox
766                let checkbox_y = area.y + y_offset + label_height;
767                if checkbox_y < area.y + area.height {
768                    let x_offset = self.horizontal_alignment.offset(area.width, checkbox_width);
769                    let checkbox_area = Rect {
770                        x: area.x + x_offset,
771                        y: checkbox_y,
772                        width: checkbox_width.min(area.width.saturating_sub(x_offset)),
773                        height: 1,
774                    };
775                    Line::from(vec![checkbox_span]).render(checkbox_area, buf);
776                }
777            }
778            LabelPosition::Bottom => {
779                // Render checkbox first
780                let x_offset = self.horizontal_alignment.offset(area.width, checkbox_width);
781                let checkbox_area = Rect {
782                    x: area.x + x_offset,
783                    y: area.y + y_offset,
784                    width: checkbox_width.min(area.width.saturating_sub(x_offset)),
785                    height: 1,
786                };
787                Line::from(vec![checkbox_span]).render(checkbox_area, buf);
788
789                // Render label
790                for (i, label_line) in label_lines.iter().enumerate() {
791                    let label_y = area.y + y_offset + 1 + i as u16;
792                    if label_y < area.y + area.height {
793                        let x_offset = self
794                            .horizontal_alignment
795                            .offset(area.width, label_line.width() as u16);
796                        let label_area = Rect {
797                            x: area.x + x_offset,
798                            y: label_y,
799                            width: area.width.saturating_sub(x_offset),
800                            height: 1,
801                        };
802                        label_line.clone().render(label_area, buf);
803                    }
804                }
805            }
806            _ => {}
807        }
808    }
809
810    fn wrap_text(line: &Line<'_>, max_width: u16) -> Vec<Line<'static>> {
811        if max_width == 0 {
812            return vec![to_owned_line(line)];
813        }
814
815        let mut result = Vec::new();
816        let mut current_line = Vec::new();
817        let mut current_width = 0u16;
818
819        for span in &line.spans {
820            let text = span.content.as_ref();
821            let words: Vec<&str> = text.split(' ').collect();
822
823            for (i, word) in words.iter().enumerate() {
824                let word_width = word.chars().count() as u16;
825                let space_width = u16::from(i > 0 || !current_line.is_empty());
826
827                if current_width + space_width + word_width > max_width && !current_line.is_empty()
828                {
829                    result.push(Line::from(current_line.clone()));
830                    current_line.clear();
831                    current_width = 0;
832                }
833
834                if i > 0 {
835                    current_line.push(Span::styled(String::from(" "), span.style));
836                    current_width += 1;
837                }
838
839                current_line.push(Span::styled(String::from(*word), span.style));
840                current_width += word_width;
841            }
842        }
843
844        if !current_line.is_empty() {
845            result.push(Line::from(current_line));
846        }
847
848        if result.is_empty() {
849            result.push(to_owned_line(line));
850        }
851
852        result
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use ratatui::style::{Color, Modifier, Stylize};
859
860    use super::*;
861
862    #[test]
863    fn checkbox_new() {
864        let checkbox = Checkbox::new("Test", true);
865        assert_eq!(checkbox.label, Line::from("Test"));
866        assert!(checkbox.checked);
867    }
868
869    #[test]
870    fn checkbox_default() {
871        let checkbox = Checkbox::default();
872        assert_eq!(checkbox.label, Line::default());
873        assert!(!checkbox.checked);
874    }
875
876    #[test]
877    fn checkbox_label() {
878        let checkbox = Checkbox::default().label("New label");
879        assert_eq!(checkbox.label, Line::from("New label"));
880    }
881
882    #[test]
883    fn checkbox_checked() {
884        let checkbox = Checkbox::default().checked(true);
885        assert!(checkbox.checked);
886    }
887
888    #[test]
889    fn checkbox_style() {
890        let style = Style::default().fg(Color::Red);
891        let checkbox = Checkbox::default().style(style);
892        assert_eq!(checkbox.style, style);
893    }
894
895    #[test]
896    fn checkbox_checkbox_style() {
897        let style = Style::default().fg(Color::Green);
898        let checkbox = Checkbox::default().checkbox_style(style);
899        assert_eq!(checkbox.checkbox_style, style);
900    }
901
902    #[test]
903    fn checkbox_label_style() {
904        let style = Style::default().fg(Color::Blue);
905        let checkbox = Checkbox::default().label_style(style);
906        assert_eq!(checkbox.label_style, style);
907    }
908
909    #[test]
910    fn checkbox_checked_symbol() {
911        let checkbox = Checkbox::default().checked_symbol("[X]");
912        assert_eq!(checkbox.checked_symbol, "[X]");
913    }
914
915    #[test]
916    fn checkbox_unchecked_symbol() {
917        let checkbox = Checkbox::default().unchecked_symbol("[ ]");
918        assert_eq!(checkbox.unchecked_symbol, "[ ]");
919    }
920
921    #[test]
922    fn checkbox_styled_trait() {
923        let checkbox = Checkbox::default().red();
924        assert_eq!(checkbox.style, Style::default().fg(Color::Red));
925    }
926
927    #[test]
928    fn checkbox_render_unchecked() {
929        let checkbox = Checkbox::new("Test", false);
930        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
931        checkbox.render(buffer.area, &mut buffer);
932
933        // The buffer should contain the unchecked symbol followed by space and label
934        assert!(buffer
935            .cell(buffer.area.as_position())
936            .unwrap()
937            .symbol()
938            .starts_with('☐'));
939    }
940
941    #[test]
942    fn checkbox_render_checked() {
943        let checkbox = Checkbox::new("Test", true);
944        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
945        checkbox.render(buffer.area, &mut buffer);
946
947        // The buffer should contain the checked symbol followed by space and label
948        assert!(buffer
949            .cell(buffer.area.as_position())
950            .unwrap()
951            .symbol()
952            .starts_with('☑'));
953    }
954
955    #[test]
956    fn checkbox_render_empty_area() {
957        let checkbox = Checkbox::new("Test", true);
958        let mut buffer = Buffer::empty(Rect::new(0, 0, 0, 0));
959
960        // Should not panic
961        checkbox.render(buffer.area, &mut buffer);
962    }
963
964    #[test]
965    fn checkbox_render_with_block() {
966        let checkbox = Checkbox::new("Test", true).block(Block::bordered());
967        let mut buffer = Buffer::empty(Rect::new(0, 0, 12, 3));
968
969        // Should not panic
970        checkbox.render(buffer.area, &mut buffer);
971    }
972
973    #[test]
974    fn checkbox_render_with_custom_symbols() {
975        let checkbox = Checkbox::new("Test", true)
976            .checked_symbol("[X]")
977            .unchecked_symbol("[ ]");
978
979        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
980        checkbox.render(buffer.area, &mut buffer);
981
982        assert!(buffer
983            .cell(buffer.area.as_position())
984            .unwrap()
985            .symbol()
986            .starts_with('['));
987    }
988
989    #[test]
990    fn checkbox_with_styled_label() {
991        let checkbox = Checkbox::new("Test".blue(), true);
992        assert_eq!(checkbox.label.spans[0].style.fg, Some(Color::Blue));
993    }
994
995    #[test]
996    fn checkbox_complex_styling() {
997        let checkbox = Checkbox::new("Feature", true)
998            .style(Style::default().fg(Color::White))
999            .checkbox_style(
1000                Style::default()
1001                    .fg(Color::Green)
1002                    .add_modifier(Modifier::BOLD),
1003            )
1004            .label_style(Style::default().fg(Color::Gray));
1005
1006        assert_eq!(checkbox.style.fg, Some(Color::White));
1007        assert_eq!(checkbox.checkbox_style.fg, Some(Color::Green));
1008        assert_eq!(checkbox.label_style.fg, Some(Color::Gray));
1009    }
1010
1011    /// Generates a test asserting that custom checked/unchecked symbols are stored verbatim.
1012    macro_rules! symbol_tests {
1013        ($($name:ident: $checked:expr, $unchecked:expr;)*) => {
1014            $(
1015                #[test]
1016                fn $name() {
1017                    let checkbox = Checkbox::new("Test", true)
1018                        .checked_symbol($checked)
1019                        .unchecked_symbol($unchecked);
1020                    assert_eq!(checkbox.checked_symbol, $checked);
1021                    assert_eq!(checkbox.unchecked_symbol, $unchecked);
1022                }
1023            )*
1024        };
1025    }
1026
1027    symbol_tests! {
1028        checkbox_emoji_symbols: "✅ ", "⬜ ";
1029        checkbox_unicode_symbols: "● ", "○ ";
1030        checkbox_arrow_symbols: "▶ ", "▷ ";
1031        checkbox_parenthesis_symbols: "(X)", "(O)";
1032        checkbox_minus_symbols: "[+]", "[-]";
1033        checkbox_predefined_symbols: symbols::CHECKED_X, symbols::UNCHECKED_SPACE;
1034    }
1035
1036    #[test]
1037    fn checkbox_predefined_minus_symbol() {
1038        use crate::symbols;
1039        let checkbox = Checkbox::new("Test", false).unchecked_symbol(symbols::UNCHECKED_MINUS);
1040
1041        assert_eq!(checkbox.unchecked_symbol, "[-]");
1042    }
1043
1044    #[test]
1045    fn checkbox_predefined_parenthesis_symbols() {
1046        use crate::symbols;
1047        let checkbox = Checkbox::new("Test", true)
1048            .checked_symbol(symbols::CHECKED_PARENTHESIS_X)
1049            .unchecked_symbol(symbols::UNCHECKED_PARENTHESIS_O);
1050
1051        assert_eq!(checkbox.checked_symbol, "(X)");
1052        assert_eq!(checkbox.unchecked_symbol, "(O)");
1053    }
1054
1055    #[test]
1056    fn checkbox_render_emoji() {
1057        let checkbox = Checkbox::new("Emoji", true)
1058            .checked_symbol("✅ ")
1059            .unchecked_symbol("⬜ ");
1060
1061        let mut buffer = Buffer::empty(Rect::new(0, 0, 15, 1));
1062        checkbox.render(buffer.area, &mut buffer);
1063
1064        // Should render without panic
1065        assert!(buffer.area.area() > 0);
1066    }
1067
1068    #[test]
1069    fn checkbox_label_style_overrides() {
1070        let checkbox = Checkbox::new("Test", true)
1071            .style(Style::default().fg(Color::White))
1072            .label_style(Style::default().fg(Color::Blue));
1073
1074        assert_eq!(checkbox.style.fg, Some(Color::White));
1075        assert_eq!(checkbox.label_style.fg, Some(Color::Blue));
1076    }
1077
1078    #[test]
1079    fn horizontal_alignment_offset() {
1080        assert_eq!(HorizontalAlignment::Left.offset(20, 6), 0);
1081        assert_eq!(HorizontalAlignment::Center.offset(20, 6), 7);
1082        assert_eq!(HorizontalAlignment::Right.offset(20, 6), 14);
1083        // Content wider than area saturates to zero.
1084        assert_eq!(HorizontalAlignment::Center.offset(4, 10), 0);
1085        assert_eq!(HorizontalAlignment::Right.offset(4, 10), 0);
1086    }
1087
1088    #[test]
1089    fn vertical_alignment_offset() {
1090        assert_eq!(VerticalAlignment::Top.offset(10, 3), 0);
1091        assert_eq!(VerticalAlignment::Center.offset(10, 3), 3);
1092        assert_eq!(VerticalAlignment::Bottom.offset(10, 3), 7);
1093        assert_eq!(VerticalAlignment::Bottom.offset(2, 5), 0);
1094    }
1095
1096    #[test]
1097    fn to_owned_line_clones_content_and_style() {
1098        let line = Line::from(vec![Span::styled("hi", Style::default().fg(Color::Red))]);
1099        let owned = to_owned_line(&line);
1100        assert_eq!(owned.spans[0].content, "hi");
1101        assert_eq!(owned.spans[0].style.fg, Some(Color::Red));
1102    }
1103
1104    #[test]
1105    fn checkbox_builder_defaults() {
1106        let checkbox = Checkbox::default();
1107        assert_eq!(checkbox.label_position, LabelPosition::Right);
1108        assert_eq!(checkbox.horizontal_alignment, HorizontalAlignment::Left);
1109        assert_eq!(checkbox.vertical_alignment, VerticalAlignment::Top);
1110        assert_eq!(checkbox.min_width, None);
1111        assert_eq!(checkbox.max_width, None);
1112        assert!(!checkbox.wrap_label);
1113    }
1114
1115    #[test]
1116    fn checkbox_layout_builders() {
1117        let checkbox = Checkbox::new("Test", true)
1118            .label_position(LabelPosition::Left)
1119            .horizontal_alignment(HorizontalAlignment::Center)
1120            .vertical_alignment(VerticalAlignment::Bottom)
1121            .min_width(10)
1122            .max_width(40)
1123            .wrap_label(true);
1124
1125        assert_eq!(checkbox.label_position, LabelPosition::Left);
1126        assert_eq!(checkbox.horizontal_alignment, HorizontalAlignment::Center);
1127        assert_eq!(checkbox.vertical_alignment, VerticalAlignment::Bottom);
1128        assert_eq!(checkbox.min_width, Some(10));
1129        assert_eq!(checkbox.max_width, Some(40));
1130        assert!(checkbox.wrap_label);
1131    }
1132
1133    #[test]
1134    fn checkbox_render_label_left() {
1135        let checkbox = Checkbox::new("Hi", true).label_position(LabelPosition::Left);
1136        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1137        checkbox.render(buffer.area, &mut buffer);
1138        // Label renders before checkbox, so first cell is the label.
1139        assert!(buffer
1140            .cell(buffer.area.as_position())
1141            .unwrap()
1142            .symbol()
1143            .starts_with('H'));
1144    }
1145
1146    #[test]
1147    fn checkbox_render_label_top_and_bottom() {
1148        for position in [LabelPosition::Top, LabelPosition::Bottom] {
1149            let checkbox = Checkbox::new("Hi", true).label_position(position);
1150            let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
1151            // Should render both lines without panic.
1152            checkbox.render(buffer.area, &mut buffer);
1153        }
1154    }
1155
1156    #[test]
1157    fn checkbox_render_wrapped_label() {
1158        let checkbox = Checkbox::new("one two three four five", false)
1159            .wrap_label(true)
1160            .max_width(12);
1161        let mut buffer = Buffer::empty(Rect::new(0, 0, 12, 4));
1162        // Wrapping across multiple lines should not panic.
1163        checkbox.render(buffer.area, &mut buffer);
1164    }
1165
1166    #[test]
1167    fn checkbox_min_width_expands_and_max_width_limits() {
1168        // min_width does not panic when larger than the area.
1169        let checkbox = Checkbox::new("Test", true).min_width(50);
1170        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
1171        checkbox.render(buffer.area, &mut buffer);
1172
1173        // max_width smaller than the area constrains rendering.
1174        let checkbox = Checkbox::new("A long label here", false).max_width(5);
1175        let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 1));
1176        checkbox.render(buffer.area, &mut buffer);
1177    }
1178
1179    #[test]
1180    fn wrap_text_zero_width_returns_single_line() {
1181        let line = Line::from("hello world");
1182        let wrapped = Checkbox::wrap_text(&line, 0);
1183        assert_eq!(wrapped.len(), 1);
1184        assert_eq!(wrapped[0].width(), "hello world".len());
1185    }
1186
1187    #[test]
1188    fn wrap_text_splits_on_width() {
1189        let line = Line::from("aaa bbb ccc");
1190        let wrapped = Checkbox::wrap_text(&line, 4);
1191        assert_eq!(wrapped.len(), 3);
1192    }
1193
1194    #[test]
1195    fn checkbox_render_alignment_variants() {
1196        for h in [
1197            HorizontalAlignment::Left,
1198            HorizontalAlignment::Center,
1199            HorizontalAlignment::Right,
1200        ] {
1201            for v in [
1202                VerticalAlignment::Top,
1203                VerticalAlignment::Center,
1204                VerticalAlignment::Bottom,
1205            ] {
1206                let checkbox = Checkbox::new("Test", true)
1207                    .horizontal_alignment(h)
1208                    .vertical_alignment(v);
1209                let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 3));
1210                checkbox.render(buffer.area, &mut buffer);
1211            }
1212        }
1213    }
1214}