line_ui/element/
fixed_width.rs

1/*
2 * Copyright (c) 2025 Jasmine Tai. All rights reserved.
3 */
4
5use std::collections::VecDeque;
6
7use crate::element::{Element, Gap};
8use crate::render::RenderChunk;
9
10/// An element that pads or truncates its contents to a constant width.
11#[derive(Debug, Clone)]
12pub struct FixedWidth<E, T = ()> {
13    width: usize,
14    truncate: Direction,
15    pad: Direction,
16    content: E,
17    truncation: T,
18}
19
20impl<E: Element> FixedWidth<E, ()> {
21    /// Creates a new [`FixedWidth`] with the specified width and content.
22    pub fn new(width: usize, content: E) -> Self {
23        FixedWidth {
24            width,
25            truncate: Direction::Right,
26            pad: Direction::Right,
27            content,
28            truncation: (),
29        }
30    }
31}
32
33impl<E: Element, T: Element> FixedWidth<E, T> {
34    /// Changes the side on which the content is truncated.
35    ///
36    /// This option only takes effect if the content is wider than the width.
37    pub fn truncated(mut self, truncate: Direction) -> Self {
38        self.truncate = truncate;
39        self
40    }
41
42    /// Changes the side on which padding is added.
43    ///
44    /// This option only takes effect if the content is narrower than the width.
45    pub fn padded(mut self, pad: Direction) -> Self {
46        self.pad = pad;
47        self
48    }
49
50    /// Changes the element displayed when truncation occurs.
51    ///
52    /// This option only takes effect if the content is wider than the width.
53    /// When this happens, this element is displayed on the side that is
54    /// truncated. This element's width must not exceed the width of the
55    /// `FixedWidth`.
56    pub fn truncated_with<U: Element>(self, truncation: U) -> FixedWidth<E, U> {
57        FixedWidth {
58            width: self.width,
59            truncate: self.truncate,
60            pad: self.pad,
61            content: self.content,
62            truncation,
63        }
64    }
65
66    fn render_impl<'s>(
67        &'s self,
68        content: impl DoubleEndedIterator<Item = RenderChunk<'s>>,
69        truncate: impl for<'t> Fn(RenderChunk<'t>, usize) -> RenderChunk<'t>,
70    ) -> (Vec<RenderChunk<'s>>, Gap) {
71        let full_content_width = self.content.width();
72        if full_content_width <= self.width {
73            // Entire content fits.
74            return (content.collect(), Gap(self.width - full_content_width));
75        }
76
77        // Truncation is required.
78        let mut result = Vec::new();
79        let mut accumulated_width = self.truncation.width();
80
81        for item in content {
82            let item_width = item.width;
83            let available_width = self.width - accumulated_width;
84            if item_width > available_width {
85                if available_width > 0 {
86                    let truncated_item = truncate(item, available_width);
87                    accumulated_width += truncated_item.width;
88                    result.push(truncated_item);
89                }
90                break;
91            } else {
92                accumulated_width += item.width;
93                result.push(item);
94            }
95        }
96
97        for item in self.truncation.render() {
98            result.push(item);
99        }
100        (result, Gap(self.width - accumulated_width))
101    }
102}
103
104impl<E: Element, T: Element> Element for FixedWidth<E, T> {
105    fn width(&self) -> usize {
106        self.width
107    }
108
109    fn render(&self) -> impl DoubleEndedIterator<Item = RenderChunk<'_>> {
110        let (result, gap) = match self.truncate {
111            Direction::Left => {
112                let (mut result, gap) =
113                    self.render_impl(self.content.render().rev(), truncate_start);
114                result.reverse();
115                (result, gap)
116            }
117            Direction::Right => self.render_impl(self.content.render(), truncate_end),
118        };
119        let mut result = VecDeque::from(result);
120
121        match self.pad {
122            Direction::Left => {
123                for chunk in gap.into_render() {
124                    result.push_front(chunk);
125                }
126            }
127            Direction::Right => {
128                for chunk in gap.into_render() {
129                    result.push_back(chunk);
130                }
131            }
132        }
133
134        result.into_iter()
135    }
136}
137
138fn truncate_end<'s>(input: RenderChunk<'s>, target: usize) -> RenderChunk<'s> {
139    let mut best_index = 0;
140    let mut best_width = 0;
141
142    for (index, _) in input.value.char_indices().skip(1) {
143        let width = crate::width(&input.value[..index]);
144        if width <= target {
145            best_index = index;
146            best_width = width;
147        } else {
148            break;
149        }
150    }
151
152    debug_assert!(best_width <= target);
153    RenderChunk::with_known_width(&input.value[..best_index], best_width, input.style)
154}
155
156fn truncate_start<'s>(input: RenderChunk<'s>, target: usize) -> RenderChunk<'s> {
157    let mut best_index = input.value.len();
158    let mut best_width = 0;
159
160    for (index, _) in input.value.char_indices().rev() {
161        let width = crate::width(&input.value[index..]);
162        if width <= target {
163            best_index = index;
164            best_width = width;
165        } else {
166            break;
167        }
168    }
169
170    debug_assert!(best_width <= target);
171    RenderChunk::with_known_width(&input.value[best_index..], best_width, input.style)
172}
173
174/// The alignment or padding applied to a [`FixedWidth`] element.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum Direction {
177    /// Left (start).
178    #[default]
179    Left,
180    /// Right (end).
181    Right,
182}
183
184#[cfg(test)]
185mod tests {
186    use crate::element::{Cursor, IntoElement, Text};
187
188    use super::*;
189
190    #[test]
191    fn width_zero() {
192        let element = "hello".fixed_width(0);
193        let render: Vec<_> = element.render().collect();
194        assert_eq!(render, []);
195    }
196
197    #[test]
198    fn empty_content() {
199        let element = ().fixed_width(4);
200        let render: Vec<_> = element.render().collect();
201        assert_eq!(render, [RenderChunk::from("    ")]);
202    }
203
204    #[test]
205    fn blank_chunks_do_not_drop_cursor() {
206        let element = (Text::from(""), Text::from(""), Cursor).fixed_width(0);
207        let render: Vec<_> = element.render().collect();
208        assert_eq!(render, ["".into(), "".into(), RenderChunk::CURSOR]);
209    }
210
211    #[test]
212    fn blank_content() {
213        let element = "".fixed_width(5);
214        let render: Vec<_> = element.render().collect();
215        assert_eq!(render, ["", "     "].map(RenderChunk::from));
216    }
217
218    #[test]
219    fn short_content() {
220        let element = "foo".fixed_width(6);
221        let render: Vec<_> = element.render().collect();
222        assert_eq!(render, ["foo", "   "].map(RenderChunk::from));
223    }
224
225    #[test]
226    fn equal_content() {
227        let element = "foobar".fixed_width(6);
228        let render: Vec<_> = element.render().collect();
229        assert_eq!(render, ["foobar"].map(RenderChunk::from));
230    }
231
232    #[test]
233    fn long_content() {
234        let element = "foobarbaz".fixed_width(8);
235        let render: Vec<_> = element.render().collect();
236        assert_eq!(render, ["foobarba"].map(RenderChunk::from));
237    }
238
239    #[test]
240    fn long_content_with_more() {
241        let element = (Text::from("foobarbaz"), Text::from("asdf")).fixed_width(8);
242        let render: Vec<_> = element.render().collect();
243        assert_eq!(render, ["foobarba"].map(RenderChunk::from));
244    }
245
246    #[test]
247    fn short_content_truncated_left() {
248        let element = "foo".fixed_width(6).truncated(Direction::Left);
249        let render: Vec<_> = element.render().collect();
250        assert_eq!(render, ["foo", "   "].map(RenderChunk::from));
251    }
252
253    #[test]
254    fn equal_content_truncated_left() {
255        let element = "foobar".fixed_width(6).truncated(Direction::Left);
256        let render: Vec<_> = element.render().collect();
257        assert_eq!(render, ["foobar"].map(RenderChunk::from));
258    }
259
260    #[test]
261    fn long_content_truncated_left() {
262        let element = "foobarbaz".fixed_width(8).truncated(Direction::Left);
263        let render: Vec<_> = element.render().collect();
264        assert_eq!(render, ["oobarbaz"].map(RenderChunk::from));
265    }
266
267    #[test]
268    fn long_content_with_more_truncated_left() {
269        let element = (Text::from("asdf"), Text::from("foobarbaz"))
270            .fixed_width(8)
271            .truncated(Direction::Left);
272        let render: Vec<_> = element.render().collect();
273        assert_eq!(render, ["oobarbaz"].map(RenderChunk::from));
274    }
275
276    #[test]
277    fn short_content_padded_left() {
278        let element = "foo".fixed_width(6).padded(Direction::Left);
279        let render: Vec<_> = element.render().collect();
280        assert_eq!(render, ["   ", "foo"].map(RenderChunk::from));
281    }
282
283    #[test]
284    fn short_content_with_truncation() {
285        let element = "foo".fixed_width(6).truncated_with("$".into_element());
286        let render: Vec<_> = element.render().collect();
287        assert_eq!(render, ["foo", "   "].map(RenderChunk::from));
288    }
289
290    #[test]
291    fn equal_content_with_truncation() {
292        let element = "foobar".fixed_width(6).truncated_with("$".into_element());
293        let render: Vec<_> = element.render().collect();
294        assert_eq!(render, ["foobar"].map(RenderChunk::from));
295    }
296
297    #[test]
298    fn long_content_with_truncation() {
299        let element = "foobarbaz"
300            .fixed_width(6)
301            .truncated_with("$".into_element());
302        let render: Vec<_> = element.render().collect();
303        assert_eq!(render, ["fooba", "$"].map(RenderChunk::from));
304    }
305
306    #[test]
307    fn long_content_with_truncation_on_left() {
308        let element = "foobarbaz"
309            .fixed_width(6)
310            .truncated(Direction::Left)
311            .truncated_with("$".into_element());
312        let render: Vec<_> = element.render().collect();
313        assert_eq!(render, ["$", "arbaz"].map(RenderChunk::from));
314    }
315}