line_ui/element/
fixed_width.rs

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