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