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}