ailloli_ui_terminal_core/
cell.rs

1//! Styled terminal grid cells and wide-character occupancy markers.
2
3use serde::{Deserialize, Serialize};
4
5use crate::hyperlink::TerminalHyperlinkId;
6use crate::style::TerminalStyle;
7
8/// Number and role of grid columns occupied by a terminal cell.
9///
10/// The enum is metadata only: [`TerminalCell`] constructors do not measure or
11/// validate their text.
12///
13/// # Examples
14///
15/// ```
16/// use ailloli_ui_terminal_core::CellWidth;
17/// assert_ne!(CellWidth::WideLeading, CellWidth::WideTrailing);
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum CellWidth {
21    /// One ordinary grid column, including a blank cell.
22    Narrow,
23    /// First column of a two-column glyph; carries the visible text.
24    WideLeading,
25    /// Second column of a two-column glyph; normally carries empty text.
26    WideTrailing,
27}
28
29/// One styled cell in the terminal grid.
30///
31/// Fields are public and deserialization is unchecked, so callers can create
32/// inconsistent text/width pairs. State/screen mutation APIs preserve the
33/// leading/trailing convention when used normally.
34///
35/// # Examples
36///
37/// ```
38/// use ailloli_ui_terminal_core::{CellWidth, TerminalCell};
39/// let cell = TerminalCell::default();
40/// assert_eq!((cell.text.as_str(), cell.width), (" ", CellWidth::Narrow));
41/// ```
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct TerminalCell {
44    /// Rendered grapheme text; a trailing wide cell normally uses `""`.
45    pub text: String,
46    /// Foreground, background, and SGR attributes.
47    pub style: TerminalStyle,
48    /// Grid-column occupancy marker.
49    pub width: CellWidth,
50    /// Active OSC 8 hyperlink, or `None` when the cell is not linked.
51    pub hyperlink: Option<TerminalHyperlinkId>,
52}
53
54impl TerminalCell {
55    /// Creates one narrow space with the supplied style and no hyperlink.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalStyle};
61    /// assert!(TerminalCell::blank(TerminalStyle::default()).is_blank());
62    /// ```
63    pub fn blank(style: TerminalStyle) -> Self {
64        Self {
65            text: " ".to_string(),
66            style,
67            width: CellWidth::Narrow,
68            hyperlink: None,
69        }
70    }
71
72    /// Creates a narrow cell, storing `text` verbatim without width validation.
73    ///
74    /// Empty or multi-character strings are accepted.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use ailloli_ui_terminal_core::{CellWidth, TerminalCell, TerminalStyle};
80    /// let cell = TerminalCell::narrow("A", TerminalStyle::default());
81    /// assert_eq!((cell.text.as_str(), cell.width), ("A", CellWidth::Narrow));
82    /// ```
83    pub fn narrow(text: impl Into<String>, style: TerminalStyle) -> Self {
84        Self {
85            text: text.into(),
86            style,
87            width: CellWidth::Narrow,
88            hyperlink: None,
89        }
90    }
91
92    /// Creates the text-bearing first half of a wide cell pair.
93    ///
94    /// The caller is responsible for supplying genuinely two-column text and a
95    /// matching [`Self::wide_trailing`] cell.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use ailloli_ui_terminal_core::{CellWidth, TerminalCell, TerminalStyle};
101    /// let cell = TerminalCell::wide_leading("界", TerminalStyle::default());
102    /// assert_eq!(cell.width, CellWidth::WideLeading);
103    /// ```
104    pub fn wide_leading(text: impl Into<String>, style: TerminalStyle) -> Self {
105        Self {
106            text: text.into(),
107            style,
108            width: CellWidth::WideLeading,
109            hyperlink: None,
110        }
111    }
112
113    /// Creates the empty second half of a wide cell pair.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use ailloli_ui_terminal_core::{CellWidth, TerminalCell, TerminalStyle};
119    /// let cell = TerminalCell::wide_trailing(TerminalStyle::default());
120    /// assert!(cell.text.is_empty() && cell.width == CellWidth::WideTrailing);
121    /// ```
122    pub fn wide_trailing(style: TerminalStyle) -> Self {
123        Self {
124            text: String::new(),
125            style,
126            width: CellWidth::WideTrailing,
127            hyperlink: None,
128        }
129    }
130
131    /// Replaces the optional hyperlink and returns the updated cell.
132    ///
133    /// Passing `None` clears a previous link.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalHyperlinkId, TerminalStyle};
139    /// let cell = TerminalCell::narrow("docs", TerminalStyle::default())
140    ///     .hyperlink(Some(TerminalHyperlinkId(7)));
141    /// assert_eq!(cell.hyperlink, Some(TerminalHyperlinkId(7)));
142    /// ```
143    pub fn hyperlink(mut self, hyperlink: Option<TerminalHyperlinkId>) -> Self {
144        self.hyperlink = hyperlink;
145        self
146    }
147
148    /// Returns `true` only for an exact narrow single-space cell.
149    ///
150    /// Style and hyperlink do not affect the result.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use ailloli_ui_terminal_core::{TerminalCell, TerminalStyle};
156    /// assert!(TerminalCell::default().is_blank());
157    /// assert!(!TerminalCell::narrow("", TerminalStyle::default()).is_blank());
158    /// ```
159    pub fn is_blank(&self) -> bool {
160        self.width == CellWidth::Narrow && self.text == " "
161    }
162}
163
164impl Default for TerminalCell {
165    fn default() -> Self {
166        Self::blank(TerminalStyle::default())
167    }
168}