oxiui_table/text_integration.rs
1//! `oxiui-text` integration for `oxiui-table`.
2//!
3//! When the `text-table` feature is enabled, cells can carry styled rich-text
4//! spans rendered through `oxiui_text::TextPipeline`. This keeps the core
5//! `Cell` enum free of the `oxiui-text` dependency while enabling CJK/emoji
6//! shaping and per-span formatting (bold, italic, color, letter-spacing) in the
7//! renderer layer.
8//!
9//! # Usage
10//!
11//! ```rust,no_run
12//! # #[cfg(feature = "text-table")]
13//! # {
14//! use oxiui_table::text_integration::{RichCell, StyledSpan};
15//! use oxiui_text::TextStyle;
16//!
17//! let mut cell = RichCell::plain("Hello, δΈη!");
18//! cell.push_span(StyledSpan {
19//! text: " (emoji π)".to_owned(),
20//! style: TextStyle::new(16.0).color([255, 200, 0, 255]),
21//! });
22//! let plain = cell.plain_text();
23//! assert_eq!(plain, "Hello, δΈη! (emoji π)");
24//! # }
25//! ```
26//!
27//! # Feature flag
28//!
29//! All types in this module are unconditionally compiled but only meaningful
30//! when the `text-table` feature is enabled (which gates the `oxiui-text`
31//! dependency). Import paths should use `#[cfg(feature = "text-table")]` at
32//! the call site when conditional.
33
34#[cfg(feature = "text-table")]
35use oxiui_text::{ShapedText, TextPipeline, TextStyle};
36
37use crate::Cell;
38
39// ββ StyledSpan ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
40
41/// A single styled text fragment inside a [`RichCell`].
42///
43/// Each span carries its own [`oxiui_text::TextStyle`] so that a single table
44/// cell can mix normal text with bold/italic/coloured segments and CJK or
45/// emoji runs.
46#[cfg(feature = "text-table")]
47#[derive(Clone, Debug)]
48pub struct StyledSpan {
49 /// The text content (UTF-8, may include CJK code-points and emoji).
50 pub text: String,
51 /// Rendering style for this span.
52 pub style: TextStyle,
53}
54
55/// A fallback span type used when the `text-table` feature is disabled.
56///
57/// This allows the `RichCell` struct to exist unconditionally while only
58/// needing `oxiui_text::TextStyle` when the feature flag is on.
59#[cfg(not(feature = "text-table"))]
60#[derive(Clone, Debug)]
61pub struct StyledSpan {
62 /// The text content.
63 pub text: String,
64}
65
66// ββ RichCell βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
67
68/// A table cell whose content is a sequence of [`StyledSpan`]s.
69///
70/// `RichCell` extends the plain [`Cell::Text`] model with per-span styling,
71/// enabling mixed bold/italic/coloured text and proper CJK/emoji shaping
72/// through the `TextPipeline`.
73///
74/// # Relationship to `Cell`
75///
76/// `RichCell` is intentionally separate from [`crate::Cell`] so that data
77/// sources that do not need rich text are unaffected by the `oxiui-text`
78/// dependency. Renderers that understand `RichCell` can convert it to a
79/// `Cell::Text` for generic rendering via [`RichCell::to_plain_cell`].
80#[derive(Clone, Debug, Default)]
81pub struct RichCell {
82 spans: Vec<StyledSpan>,
83}
84
85impl RichCell {
86 /// Create an empty `RichCell`.
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 /// Create a `RichCell` with a single plain span using the default style.
92 #[cfg(feature = "text-table")]
93 pub fn plain(text: impl Into<String>) -> Self {
94 let mut cell = Self::new();
95 cell.spans.push(StyledSpan {
96 text: text.into(),
97 style: TextStyle::default(),
98 });
99 cell
100 }
101
102 /// Create a `RichCell` with a single plain span (no-feature version).
103 #[cfg(not(feature = "text-table"))]
104 pub fn plain(text: impl Into<String>) -> Self {
105 let mut cell = Self::new();
106 cell.spans.push(StyledSpan { text: text.into() });
107 cell
108 }
109
110 /// Append a span to this cell.
111 pub fn push_span(&mut self, span: StyledSpan) {
112 self.spans.push(span);
113 }
114
115 /// Borrow the span list.
116 pub fn spans(&self) -> &[StyledSpan] {
117 &self.spans
118 }
119
120 /// Return the concatenated plain text of all spans (no style information).
121 pub fn plain_text(&self) -> String {
122 self.spans.iter().map(|s| s.text.as_str()).collect()
123 }
124
125 /// Convert to a plain [`Cell::Text`] for generic (non-rich) rendering.
126 pub fn to_plain_cell(&self) -> Cell {
127 Cell::Text(self.plain_text())
128 }
129
130 /// Shape and measure all spans using `pipeline`, returning per-span shaped
131 /// results.
132 ///
133 /// Each element of the returned `Vec` corresponds to the span at the same
134 /// index in [`RichCell::spans`]. The pipeline retains per-font shape
135 /// caches internally, so repeated calls are faster than the first.
136 ///
137 /// # Errors
138 ///
139 /// Returns a `String` error message if any span fails to shape. Spans
140 /// before the failing one are still returned (partial success).
141 #[cfg(feature = "text-table")]
142 pub fn shape_spans(&self, pipeline: &mut TextPipeline) -> Result<Vec<ShapedText>, String> {
143 let mut results = Vec::with_capacity(self.spans.len());
144 for span in &self.spans {
145 let shaped = pipeline
146 .shape(&span.text, &span.style)
147 .map_err(|e| e.to_string())?;
148 results.push(shaped);
149 }
150 Ok(results)
151 }
152
153 /// Measure the combined bounding box of all spans using `pipeline`.
154 ///
155 /// Returns `(total_width, max_height)` in logical pixels. Individual span
156 /// widths are summed horizontally (single-line model); height is the
157 /// maximum across all spans.
158 ///
159 /// # Errors
160 ///
161 /// Propagates pipeline shaping errors.
162 #[cfg(feature = "text-table")]
163 pub fn measure(&self, pipeline: &mut TextPipeline) -> Result<(f32, f32), String> {
164 let mut total_w = 0.0_f32;
165 let mut max_h = 0.0_f32;
166 for span in &self.spans {
167 let (w, h) = pipeline
168 .measure(&span.text, &span.style)
169 .map_err(|e| e.to_string())?;
170 total_w += w;
171 max_h = max_h.max(h);
172 }
173 Ok((total_w, max_h))
174 }
175}
176
177// ββ Cell extensions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
178
179/// Extension trait for converting `Cell` values to/from `RichCell`.
180///
181/// Automatically implemented for [`Cell`].
182pub trait CellRichExt {
183 /// Wrap this cell's display string into a single-span [`RichCell`].
184 fn to_rich_cell(&self) -> RichCell;
185}
186
187impl CellRichExt for Cell {
188 fn to_rich_cell(&self) -> RichCell {
189 RichCell::plain(self.to_string())
190 }
191}
192
193// ββ Tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn rich_cell_default_empty() {
201 let cell = RichCell::new();
202 assert!(cell.spans().is_empty());
203 assert_eq!(cell.plain_text(), "");
204 }
205
206 #[test]
207 fn rich_cell_plain_single_span() {
208 let cell = RichCell::plain("hello");
209 assert_eq!(cell.spans().len(), 1);
210 assert_eq!(cell.plain_text(), "hello");
211 }
212
213 #[test]
214 fn rich_cell_multi_span_plain_text() {
215 let mut cell = RichCell::plain("Hello, ");
216 cell.push_span(RichCell::plain("δΈη!").spans()[0].clone());
217 assert_eq!(cell.plain_text(), "Hello, δΈη!");
218 }
219
220 #[test]
221 fn rich_cell_to_plain_cell_is_text() {
222 let cell = RichCell::plain("data");
223 let plain = cell.to_plain_cell();
224 assert!(matches!(plain, Cell::Text(_)));
225 assert_eq!(plain.to_string(), "data");
226 }
227
228 #[test]
229 fn cell_to_rich_cell_int() {
230 use super::CellRichExt;
231 let cell = Cell::Int(42);
232 let rich = cell.to_rich_cell();
233 assert_eq!(rich.plain_text(), "42");
234 }
235
236 #[test]
237 fn cell_to_rich_cell_float() {
238 use super::CellRichExt;
239 // 3.14 is intentional test data (not PI); suppress approx_constant lint.
240 #[allow(clippy::approx_constant)]
241 let cell = Cell::Float(3.14_f64);
242 let rich = cell.to_rich_cell();
243 assert_eq!(rich.plain_text(), "3.14");
244 }
245
246 #[test]
247 fn cell_to_rich_cell_bool() {
248 use super::CellRichExt;
249 let cell = Cell::Bool(true);
250 let rich = cell.to_rich_cell();
251 assert_eq!(rich.plain_text(), "true");
252 }
253
254 #[test]
255 fn cell_to_rich_cell_empty() {
256 use super::CellRichExt;
257 let cell = Cell::Empty;
258 let rich = cell.to_rich_cell();
259 assert_eq!(rich.plain_text(), "");
260 }
261
262 #[test]
263 fn rich_cell_cjk_codepoints_preserved() {
264 // The plain_text concatenation must preserve multi-byte UTF-8 sequences.
265 let cell = RichCell::plain("ζ₯ζ¬θͺγγΉγ");
266 assert_eq!(cell.plain_text(), "ζ₯ζ¬θͺγγΉγ");
267 }
268
269 #[test]
270 fn rich_cell_emoji_codepoints_preserved() {
271 let cell = RichCell::plain("ππ¦π");
272 assert_eq!(cell.plain_text(), "ππ¦π");
273 }
274
275 #[test]
276 fn rich_cell_mixed_cjk_emoji_ascii() {
277 let mut cell = RichCell::plain("Hello ");
278 cell.push_span(RichCell::plain("δΈη π").spans()[0].clone());
279 assert_eq!(cell.plain_text(), "Hello δΈη π");
280 }
281
282 #[cfg(feature = "text-table")]
283 #[test]
284 fn styled_span_has_style_field() {
285 let span = StyledSpan {
286 text: "test".to_owned(),
287 style: TextStyle::new(14.0),
288 };
289 assert!((span.style.font_size - 14.0).abs() < f32::EPSILON);
290 }
291}