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<'s, E: Element<'s>> 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<'s, E: Element<'s>, T: Element<'s>> 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<'s>>(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(
67        &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        result.extend(self.truncation.render());
98        (result, Gap(self.width - accumulated_width))
99    }
100}
101
102impl<'s, E, T> Element<'s> for FixedWidth<E, T>
103where
104    E: Element<'s>,
105    T: Element<'s>,
106{
107    fn width(&self) -> usize {
108        self.width
109    }
110
111    fn render(&self) -> impl DoubleEndedIterator<Item = RenderChunk<'s>> {
112        let (result, gap) = match self.truncate {
113            Direction::Left => {
114                let (mut result, gap) =
115                    self.render_impl(self.content.render().rev(), truncate_start);
116                result.reverse();
117                (result, gap)
118            }
119            Direction::Right => self.render_impl(self.content.render(), truncate_end),
120        };
121        let mut result = VecDeque::from(result);
122
123        match self.pad {
124            Direction::Left => {
125                for chunk in gap.render() {
126                    result.push_front(chunk);
127                }
128            }
129            Direction::Right => result.extend(gap.render()),
130        }
131
132        result.into_iter()
133    }
134}
135
136fn truncate_end<'s>(input: RenderChunk<'s>, target: usize) -> RenderChunk<'s> {
137    let mut best_index = 0;
138    let mut best_width = 0;
139
140    for (index, _) in input.value.char_indices().skip(1) {
141        let width = crate::width(&input.value[..index]);
142        if width <= target {
143            best_index = index;
144            best_width = width;
145        } else {
146            break;
147        }
148    }
149
150    debug_assert!(best_width <= target);
151    RenderChunk::with_known_width(&input.value[..best_index], best_width, input.style)
152}
153
154fn truncate_start<'s>(input: RenderChunk<'s>, target: usize) -> RenderChunk<'s> {
155    let mut best_index = input.value.len();
156    let mut best_width = 0;
157
158    for (index, _) in input.value.char_indices().rev() {
159        let width = crate::width(&input.value[index..]);
160        if width <= target {
161            best_index = index;
162            best_width = width;
163        } else {
164            break;
165        }
166    }
167
168    debug_assert!(best_width <= target);
169    RenderChunk::with_known_width(&input.value[best_index..], best_width, input.style)
170}
171
172/// The alignment or padding applied to a [`FixedWidth`] element.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum Direction {
175    /// Left (start).
176    #[default]
177    Left,
178    /// Right (end).
179    Right,
180}
181
182#[cfg(test)]
183mod tests {
184    use crate::element::{Cursor, IntoElement, Text};
185
186    use super::*;
187
188    #[test]
189    fn width_zero() {
190        let element = "hello".fixed_width(0);
191        let render: Vec<_> = element.render().collect();
192        assert_eq!(render, []);
193    }
194
195    #[test]
196    fn empty_content() {
197        let element = ().fixed_width(4);
198        let render: Vec<_> = element.render().collect();
199        assert_eq!(render, [RenderChunk::from("    ")]);
200    }
201
202    #[test]
203    fn blank_chunks_do_not_drop_cursor() {
204        let element = (Text::from(""), Text::from(""), Cursor).fixed_width(0);
205        let render: Vec<_> = element.render().collect();
206        assert_eq!(render, ["".into(), "".into(), RenderChunk::CURSOR]);
207    }
208
209    #[test]
210    fn blank_content() {
211        let element = "".fixed_width(5);
212        let render: Vec<_> = element.render().collect();
213        assert_eq!(render, ["", "     "].map(RenderChunk::from));
214    }
215
216    #[test]
217    fn short_content() {
218        let element = "foo".fixed_width(6);
219        let render: Vec<_> = element.render().collect();
220        assert_eq!(render, ["foo", "   "].map(RenderChunk::from));
221    }
222
223    #[test]
224    fn equal_content() {
225        let element = "foobar".fixed_width(6);
226        let render: Vec<_> = element.render().collect();
227        assert_eq!(render, ["foobar"].map(RenderChunk::from));
228    }
229
230    #[test]
231    fn long_content() {
232        let element = "foobarbaz".fixed_width(8);
233        let render: Vec<_> = element.render().collect();
234        assert_eq!(render, ["foobarba"].map(RenderChunk::from));
235    }
236
237    #[test]
238    fn long_content_with_more() {
239        let element = (Text::from("foobarbaz"), Text::from("asdf")).fixed_width(8);
240        let render: Vec<_> = element.render().collect();
241        assert_eq!(render, ["foobarba"].map(RenderChunk::from));
242    }
243
244    #[test]
245    fn short_content_truncated_left() {
246        let element = "foo".fixed_width(6).truncated(Direction::Left);
247        let render: Vec<_> = element.render().collect();
248        assert_eq!(render, ["foo", "   "].map(RenderChunk::from));
249    }
250
251    #[test]
252    fn equal_content_truncated_left() {
253        let element = "foobar".fixed_width(6).truncated(Direction::Left);
254        let render: Vec<_> = element.render().collect();
255        assert_eq!(render, ["foobar"].map(RenderChunk::from));
256    }
257
258    #[test]
259    fn long_content_truncated_left() {
260        let element = "foobarbaz".fixed_width(8).truncated(Direction::Left);
261        let render: Vec<_> = element.render().collect();
262        assert_eq!(render, ["oobarbaz"].map(RenderChunk::from));
263    }
264
265    #[test]
266    fn long_content_with_more_truncated_left() {
267        let element = (Text::from("asdf"), Text::from("foobarbaz"))
268            .fixed_width(8)
269            .truncated(Direction::Left);
270        let render: Vec<_> = element.render().collect();
271        assert_eq!(render, ["oobarbaz"].map(RenderChunk::from));
272    }
273
274    #[test]
275    fn short_content_padded_left() {
276        let element = "foo".fixed_width(6).padded(Direction::Left);
277        let render: Vec<_> = element.render().collect();
278        assert_eq!(render, ["   ", "foo"].map(RenderChunk::from));
279    }
280
281    #[test]
282    fn short_content_with_truncation() {
283        let element = "foo".fixed_width(6).truncated_with("$".into_element());
284        let render: Vec<_> = element.render().collect();
285        assert_eq!(render, ["foo", "   "].map(RenderChunk::from));
286    }
287
288    #[test]
289    fn equal_content_with_truncation() {
290        let element = "foobar".fixed_width(6).truncated_with("$".into_element());
291        let render: Vec<_> = element.render().collect();
292        assert_eq!(render, ["foobar"].map(RenderChunk::from));
293    }
294
295    #[test]
296    fn long_content_with_truncation() {
297        let element = "foobarbaz"
298            .fixed_width(6)
299            .truncated_with("$".into_element());
300        let render: Vec<_> = element.render().collect();
301        assert_eq!(render, ["fooba", "$"].map(RenderChunk::from));
302    }
303
304    #[test]
305    fn long_content_with_truncation_on_left() {
306        let element = "foobarbaz"
307            .fixed_width(6)
308            .truncated(Direction::Left)
309            .truncated_with("$".into_element());
310        let render: Vec<_> = element.render().collect();
311        assert_eq!(render, ["$", "arbaz"].map(RenderChunk::from));
312    }
313}