Skip to main content

oxitext_layout/
options.rs

1//! Layout options and configuration for [`crate::engine::LayoutEngine`].
2//!
3//! Provides [`LayoutOptions`] — a comprehensive configuration struct for layout
4//! passes — along with its builder [`LayoutOptionsBuilder`], as well as
5//! [`TruncationMode`] and [`TabStops`] sub-configurations.
6
7use oxitext_core::{FlowDirection, TextAlignment, TextDecoration};
8
9/// Mode for text truncation when content exceeds `max_width`.
10///
11/// The caller is responsible for pre-measuring the ellipsis string ("…",
12/// U+2026) and pre-shaping its glyph so that the layout engine can apply
13/// truncation without font-access dependencies.
14#[derive(Debug, Clone)]
15pub struct TruncationMode {
16    /// Maximum line width in pixels before truncation kicks in.
17    pub max_width: f32,
18    /// The pre-computed total advance width of the ellipsis string.
19    ///
20    /// Caller measures "…" (U+2026) before passing here.
21    pub ellipsis_advance: f32,
22    /// Pre-shaped glyph ID for the ellipsis character (or 0 for a fallback
23    /// marker).
24    pub ellipsis_glyph_id: u16,
25}
26
27/// Tab stop configuration.
28///
29/// Explicit tab stop positions take priority; once exhausted (or when the list
30/// is empty), the [`Self::default_interval`] drives implicit stops.
31#[derive(Debug, Clone)]
32pub struct TabStops {
33    /// Explicit tab stop x-positions (sorted ascending).
34    pub positions: Vec<f32>,
35    /// Default interval for implicit tab stops (used when `positions` is empty
36    /// or exhausted).
37    pub default_interval: f32,
38}
39
40impl TabStops {
41    /// Creates a [`TabStops`] with no explicit positions and the given default
42    /// interval.
43    pub fn with_interval(interval: f32) -> Self {
44        Self {
45            positions: Vec::new(),
46            default_interval: interval,
47        }
48    }
49
50    /// Returns the next tab stop x-position from a given cursor x.
51    ///
52    /// Scans the explicit `positions` first; if none is strictly greater than
53    /// `cursor_x + 0.5`, falls back to the default interval arithmetic.
54    pub fn next_stop(&self, cursor_x: f32) -> f32 {
55        for &pos in &self.positions {
56            if pos > cursor_x + 0.5 {
57                return pos;
58            }
59        }
60        // Use default interval.
61        let next = ((cursor_x / self.default_interval).floor() + 1.0) * self.default_interval;
62        next.max(cursor_x + 1.0)
63    }
64}
65
66impl Default for TabStops {
67    fn default() -> Self {
68        Self {
69            positions: Vec::new(),
70            default_interval: 80.0,
71        }
72    }
73}
74
75/// Comprehensive layout options for a single layout pass.
76///
77/// Construct via [`LayoutOptions::builder()`] for a fluent API, or create
78/// directly and use [`Default`] for the standard left-aligned horizontal flow.
79#[derive(Debug, Clone)]
80pub struct LayoutOptions {
81    /// Horizontal text alignment within the line box.
82    pub alignment: TextAlignment,
83    /// Text flow direction (horizontal or vertical).
84    pub flow_direction: FlowDirection,
85    /// Truncation configuration; `None` disables truncation.
86    pub truncation: Option<TruncationMode>,
87    /// Tab stop configuration.
88    pub tab_stops: TabStops,
89    /// Extra vertical space (in pixels) inserted between paragraphs when using
90    /// [`crate::engine::LayoutEngine::layout_paragraphs`].
91    pub paragraph_spacing: f32,
92    /// When `true`, CJK fullwidth punctuation at the start or end of a line is
93    /// allowed to overhang ("hang") into the margin by half its advance width.
94    ///
95    /// This is the CSS `hanging-punctuation: allow-end` behaviour from CSS Text
96    /// Module Level 3 §3.  Affects [`crate::engine::LayoutEngine::layout_with_options`].
97    pub hanging_punctuation: bool,
98    /// Optional text decoration to apply to all rendered runs.
99    ///
100    /// When set, [`crate::engine::LayoutEngine::layout_with_options`] computes
101    /// [`oxitext_core::DecorationRect`]s for every line and stores them in
102    /// [`crate::engine::LayoutResult::decorations`].
103    pub decoration: Option<TextDecoration>,
104    /// Inline objects to be positioned during layout.
105    ///
106    /// Each [`oxitext_core::InlineObject`] is appended after the shaped glyphs
107    /// on the last line, advancing the cursor by `object.advance` for each.
108    pub inline_objects: Vec<oxitext_core::InlineObject>,
109}
110
111impl Default for LayoutOptions {
112    fn default() -> Self {
113        Self {
114            alignment: TextAlignment::Left,
115            flow_direction: FlowDirection::Horizontal,
116            truncation: None,
117            tab_stops: TabStops::default(),
118            paragraph_spacing: 0.0,
119            hanging_punctuation: false,
120            decoration: None,
121            inline_objects: Vec::new(),
122        }
123    }
124}
125
126impl LayoutOptions {
127    /// Returns a new [`LayoutOptionsBuilder`] initialised with the defaults.
128    pub fn builder() -> LayoutOptionsBuilder {
129        LayoutOptionsBuilder(Self::default())
130    }
131}
132
133/// Fluent builder for [`LayoutOptions`].
134pub struct LayoutOptionsBuilder(LayoutOptions);
135
136impl LayoutOptionsBuilder {
137    /// Sets the text alignment.
138    pub fn alignment(mut self, a: TextAlignment) -> Self {
139        self.0.alignment = a;
140        self
141    }
142
143    /// Sets the flow direction.
144    pub fn flow_direction(mut self, d: FlowDirection) -> Self {
145        self.0.flow_direction = d;
146        self
147    }
148
149    /// Enables truncation with the given mode.
150    pub fn truncation(mut self, t: TruncationMode) -> Self {
151        self.0.truncation = Some(t);
152        self
153    }
154
155    /// Sets the tab stop configuration.
156    pub fn tab_stops(mut self, ts: TabStops) -> Self {
157        self.0.tab_stops = ts;
158        self
159    }
160
161    /// Sets the paragraph spacing.
162    pub fn paragraph_spacing(mut self, s: f32) -> Self {
163        self.0.paragraph_spacing = s;
164        self
165    }
166
167    /// Enables or disables hanging punctuation.
168    ///
169    /// When `true`, CJK fullwidth punctuation at a line's start/end overhangs
170    /// into the margin by half its advance.  Defaults to `false`.
171    pub fn hanging_punctuation(mut self, hp: bool) -> Self {
172        self.0.hanging_punctuation = hp;
173        self
174    }
175
176    /// Sets the text decoration to apply to all lines in the layout.
177    ///
178    /// When set, the layout engine computes [`oxitext_core::DecorationRect`]s
179    /// for each line and stores them in
180    /// [`crate::engine::LayoutResult::decorations`].
181    pub fn decoration(mut self, d: TextDecoration) -> Self {
182        self.0.decoration = Some(d);
183        self
184    }
185
186    /// Sets the inline objects to be positioned during layout.
187    ///
188    /// The objects are appended after the shaped glyphs, advancing the cursor
189    /// by each object's `advance` width.
190    pub fn inline_objects(mut self, objects: Vec<oxitext_core::InlineObject>) -> Self {
191        self.0.inline_objects = objects;
192        self
193    }
194
195    /// Consumes the builder and returns the final [`LayoutOptions`].
196    pub fn build(self) -> LayoutOptions {
197        self.0
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn tab_stops_with_interval_default() {
207        let ts = TabStops::with_interval(80.0);
208        assert!(ts.positions.is_empty());
209        assert_eq!(ts.default_interval, 80.0);
210    }
211
212    #[test]
213    fn tab_stops_next_stop_interval() {
214        let ts = TabStops::with_interval(80.0);
215        // Cursor at 10 → next stop at 80.
216        let stop = ts.next_stop(10.0);
217        assert!((stop - 80.0).abs() < 1.0, "expected ~80.0, got {stop}");
218        // Cursor at 80 → next stop at 160.
219        let stop2 = ts.next_stop(80.0);
220        assert!((stop2 - 160.0).abs() < 1.0, "expected ~160.0, got {stop2}");
221    }
222
223    #[test]
224    fn tab_stops_explicit_positions() {
225        let ts = TabStops {
226            positions: vec![50.0, 120.0, 200.0],
227            default_interval: 80.0,
228        };
229        // cursor at 0 → first explicit stop 50.
230        assert!((ts.next_stop(0.0) - 50.0).abs() < 1.0);
231        // cursor at 50 → next explicit stop 120.
232        assert!((ts.next_stop(50.5) - 120.0).abs() < 1.0);
233        // cursor at 210 (beyond all explicit) → default interval: 210/80=2.625 →
234        // floor+1 = 3 → 240.
235        let stop = ts.next_stop(210.0);
236        assert!((stop - 240.0).abs() < 1.0, "expected ~240.0, got {stop}");
237    }
238
239    #[test]
240    fn tab_stops_default_impl() {
241        let ts = TabStops::default();
242        assert_eq!(ts.default_interval, 80.0);
243    }
244
245    #[test]
246    fn layout_options_default() {
247        let opts = LayoutOptions::default();
248        assert_eq!(opts.alignment, TextAlignment::Left);
249        assert_eq!(opts.flow_direction, FlowDirection::Horizontal);
250        assert!(opts.truncation.is_none());
251        assert_eq!(opts.paragraph_spacing, 0.0);
252    }
253
254    #[test]
255    fn layout_options_builder_sets_fields() {
256        let opts = LayoutOptions::builder()
257            .alignment(TextAlignment::Center)
258            .flow_direction(FlowDirection::Vertical)
259            .paragraph_spacing(12.0)
260            .build();
261        assert_eq!(opts.alignment, TextAlignment::Center);
262        assert_eq!(opts.flow_direction, FlowDirection::Vertical);
263        assert_eq!(opts.paragraph_spacing, 12.0);
264    }
265
266    #[test]
267    fn layout_options_builder_with_truncation() {
268        let trunc = TruncationMode {
269            max_width: 100.0,
270            ellipsis_advance: 10.0,
271            ellipsis_glyph_id: 42,
272        };
273        let opts = LayoutOptions::builder().truncation(trunc).build();
274        let t = opts.truncation.as_ref().expect("truncation should be set");
275        assert_eq!(t.max_width, 100.0);
276        assert_eq!(t.ellipsis_glyph_id, 42);
277    }
278
279    #[test]
280    fn layout_options_builder_with_tab_stops() {
281        let ts = TabStops::with_interval(40.0);
282        let opts = LayoutOptions::builder().tab_stops(ts).build();
283        assert_eq!(opts.tab_stops.default_interval, 40.0);
284    }
285
286    #[test]
287    fn layout_options_with_decoration() {
288        let opts = LayoutOptions::builder()
289            .decoration(TextDecoration::Underline {
290                color: oxitext_core::Rgba8 {
291                    r: 0,
292                    g: 0,
293                    b: 0,
294                    a: 255,
295                },
296                thickness: 1.0,
297                offset: 2.0,
298            })
299            .build();
300        assert!(opts.decoration.is_some());
301        match opts.decoration {
302            Some(TextDecoration::Underline {
303                thickness, offset, ..
304            }) => {
305                assert_eq!(thickness, 1.0);
306                assert_eq!(offset, 2.0);
307            }
308            _ => panic!("expected Underline decoration"),
309        }
310    }
311
312    #[test]
313    fn layout_options_decoration_none_by_default() {
314        let opts = LayoutOptions::default();
315        assert!(opts.decoration.is_none());
316    }
317
318    #[test]
319    fn test_layout_options_with_inline_objects() {
320        use oxitext_core::InlineObject;
321        let obj = InlineObject {
322            id: 1,
323            width: 20.0,
324            height: 20.0,
325            baseline_offset: 0.0,
326            advance: 20.0,
327        };
328        let opts = LayoutOptions::builder().inline_objects(vec![obj]).build();
329        assert_eq!(opts.inline_objects.len(), 1);
330    }
331
332    #[test]
333    fn test_styled_run_vertical_position_default() {
334        use oxitext_core::VerticalPosition;
335        // Verify the field exists and defaults to Normal
336        let vp = VerticalPosition::Normal;
337        assert_eq!(vp.effective_size(16.0), 16.0);
338    }
339}