Skip to main content

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}