Skip to main content

ftui_widgets/
borders.rs

1//! Border styling primitives.
2
3/// Border characters for drawing.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct BorderSet {
6    /// Vertical border character.
7    pub vertical: char,
8    /// Horizontal border character.
9    pub horizontal: char,
10    /// Top-left corner character.
11    pub top_left: char,
12    /// Top-right corner character.
13    pub top_right: char,
14    /// Bottom-left corner character.
15    pub bottom_left: char,
16    /// Bottom-right corner character.
17    pub bottom_right: char,
18    /// Upward tee junction character.
19    pub tee_up: char,
20    /// Downward tee junction character.
21    pub tee_down: char,
22    /// Leftward tee junction character.
23    pub tee_left: char,
24    /// Rightward tee junction character.
25    pub tee_right: char,
26    /// Cross junction character.
27    pub cross: char,
28}
29
30impl BorderSet {
31    /// ASCII fallback border (+, -, |).
32    pub const ASCII: Self = Self {
33        vertical: '|',
34        horizontal: '-',
35        top_left: '+',
36        top_right: '+',
37        bottom_left: '+',
38        bottom_right: '+',
39        tee_up: '+',
40        tee_down: '+',
41        tee_left: '+',
42        tee_right: '+',
43        cross: '+',
44    };
45
46    /// Rounded corners (╭, ╮, ╯, ╰).
47    pub const ROUNDED: Self = Self {
48        vertical: '│',
49        horizontal: '─',
50        top_left: '╭',
51        top_right: '╮',
52        bottom_left: '╰',
53        bottom_right: '╯',
54        tee_up: '┴',
55        tee_down: '┬',
56        tee_left: '┤',
57        tee_right: '├',
58        cross: '┼',
59    };
60
61    /// Square single-line border.
62    pub const SQUARE: Self = Self {
63        vertical: '│',
64        horizontal: '─',
65        top_left: '┌',
66        top_right: '┐',
67        bottom_left: '└',
68        bottom_right: '┘',
69        tee_up: '┴',
70        tee_down: '┬',
71        tee_left: '┤',
72        tee_right: '├',
73        cross: '┼',
74    };
75
76    /// Double lines (║, ═).
77    pub const DOUBLE: Self = Self {
78        vertical: '║',
79        horizontal: '═',
80        top_left: '╔',
81        top_right: '╗',
82        bottom_left: '╚',
83        bottom_right: '╝',
84        tee_up: '╩',
85        tee_down: '╦',
86        tee_left: '╣',
87        tee_right: '╠',
88        cross: '╬',
89    };
90
91    /// Heavy lines (┃, ━).
92    pub const HEAVY: Self = Self {
93        vertical: '┃',
94        horizontal: '━',
95        top_left: '┏',
96        top_right: '┓',
97        bottom_left: '┗',
98        bottom_right: '┛',
99        tee_up: '┻',
100        tee_down: '┳',
101        tee_left: '┫',
102        tee_right: '┣',
103        cross: '╋',
104    };
105}
106
107/// Border style presets.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum BorderType {
110    /// No border (but space reserved if Borders::ALL is set).
111    #[default]
112    Square,
113    /// ASCII fallback border.
114    Ascii,
115    /// Single line border with rounded corners.
116    Rounded,
117    /// Double line border.
118    Double,
119    /// Heavy line border.
120    Heavy,
121    /// Custom border character set.
122    Custom(BorderSet),
123}
124
125impl BorderType {
126    /// Convert this border type to its corresponding border character set.
127    pub fn to_border_set(&self) -> BorderSet {
128        match self {
129            BorderType::Square => BorderSet::SQUARE,
130            BorderType::Ascii => BorderSet::ASCII,
131            BorderType::Rounded => BorderSet::ROUNDED,
132            BorderType::Double => BorderSet::DOUBLE,
133            BorderType::Heavy => BorderSet::HEAVY,
134            BorderType::Custom(set) => *set,
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn ascii_is_ascii_only() {
145        let set = BorderSet::ASCII;
146        let chars = [
147            set.vertical,
148            set.horizontal,
149            set.top_left,
150            set.top_right,
151            set.bottom_left,
152            set.bottom_right,
153            set.tee_up,
154            set.tee_down,
155            set.tee_left,
156            set.tee_right,
157            set.cross,
158        ];
159        assert!(chars.iter().all(|c| c.is_ascii()));
160    }
161
162    #[test]
163    fn square_has_box_drawing() {
164        let set = BorderSet::SQUARE;
165        assert_eq!(set.horizontal, '─');
166        assert_eq!(set.vertical, '│');
167        assert_eq!(set.cross, '┼');
168    }
169
170    #[test]
171    fn rounded_has_round_corners() {
172        let set = BorderSet::ROUNDED;
173        assert_eq!(set.top_left, '╭');
174        assert_eq!(set.top_right, '╮');
175        assert_eq!(set.bottom_left, '╰');
176        assert_eq!(set.bottom_right, '╯');
177        assert_eq!(set.horizontal, '─');
178        assert_eq!(set.vertical, '│');
179    }
180
181    #[test]
182    fn double_has_double_lines() {
183        let set = BorderSet::DOUBLE;
184        assert_eq!(set.horizontal, '═');
185        assert_eq!(set.vertical, '║');
186        assert_eq!(set.top_left, '╔');
187        assert_eq!(set.top_right, '╗');
188        assert_eq!(set.bottom_left, '╚');
189        assert_eq!(set.bottom_right, '╝');
190        assert_eq!(set.cross, '╬');
191    }
192
193    #[test]
194    fn heavy_has_heavy_lines() {
195        let set = BorderSet::HEAVY;
196        assert_eq!(set.horizontal, '━');
197        assert_eq!(set.vertical, '┃');
198        assert_eq!(set.top_left, '┏');
199        assert_eq!(set.top_right, '┓');
200        assert_eq!(set.bottom_left, '┗');
201        assert_eq!(set.bottom_right, '┛');
202        assert_eq!(set.cross, '╋');
203    }
204
205    #[test]
206    fn all_border_sets_have_11_fields() {
207        for set in [
208            BorderSet::ASCII,
209            BorderSet::ROUNDED,
210            BorderSet::SQUARE,
211            BorderSet::DOUBLE,
212            BorderSet::HEAVY,
213        ] {
214            let chars = [
215                set.vertical,
216                set.horizontal,
217                set.top_left,
218                set.top_right,
219                set.bottom_left,
220                set.bottom_right,
221                set.tee_up,
222                set.tee_down,
223                set.tee_left,
224                set.tee_right,
225                set.cross,
226            ];
227            assert_eq!(chars.len(), 11);
228            // horizontal and vertical are distinct in all sets
229            assert_ne!(set.horizontal, set.vertical);
230        }
231    }
232
233    #[test]
234    fn box_drawing_sets_have_distinct_corners() {
235        // Non-ASCII sets use unique box drawing chars for each corner
236        for set in [
237            BorderSet::ROUNDED,
238            BorderSet::SQUARE,
239            BorderSet::DOUBLE,
240            BorderSet::HEAVY,
241        ] {
242            let corners = [
243                set.top_left,
244                set.top_right,
245                set.bottom_left,
246                set.bottom_right,
247            ];
248            for (i, a) in corners.iter().enumerate() {
249                for (j, b) in corners.iter().enumerate() {
250                    if i != j {
251                        assert_ne!(a, b, "corners {i} and {j} should differ");
252                    }
253                }
254            }
255        }
256    }
257
258    #[test]
259    fn ascii_set_reuses_plus_for_junctions() {
260        let set = BorderSet::ASCII;
261        // ASCII uses '+' for all corners, tees, and cross
262        assert_eq!(set.top_left, '+');
263        assert_eq!(set.top_right, '+');
264        assert_eq!(set.bottom_left, '+');
265        assert_eq!(set.bottom_right, '+');
266        assert_eq!(set.tee_up, '+');
267        assert_eq!(set.tee_down, '+');
268        assert_eq!(set.tee_left, '+');
269        assert_eq!(set.tee_right, '+');
270        assert_eq!(set.cross, '+');
271    }
272
273    #[test]
274    fn border_type_to_border_set_roundtrip() {
275        assert_eq!(BorderType::Square.to_border_set(), BorderSet::SQUARE);
276        assert_eq!(BorderType::Ascii.to_border_set(), BorderSet::ASCII);
277        assert_eq!(BorderType::Rounded.to_border_set(), BorderSet::ROUNDED);
278        assert_eq!(BorderType::Double.to_border_set(), BorderSet::DOUBLE);
279        assert_eq!(BorderType::Heavy.to_border_set(), BorderSet::HEAVY);
280    }
281
282    #[test]
283    fn border_type_default_is_square() {
284        assert_eq!(BorderType::default(), BorderType::Square);
285    }
286
287    #[test]
288    fn border_type_custom_uses_provided_set() {
289        let custom = BorderSet {
290            vertical: '!',
291            horizontal: '-',
292            top_left: '/',
293            top_right: '\\',
294            bottom_left: '\\',
295            bottom_right: '/',
296            tee_up: '+',
297            tee_down: '+',
298            tee_left: '+',
299            tee_right: '+',
300            cross: '*',
301        };
302
303        assert_eq!(BorderType::Custom(custom).to_border_set(), custom);
304    }
305
306    #[test]
307    fn borders_none_is_zero() {
308        assert!(Borders::NONE.is_empty());
309        assert_eq!(Borders::NONE.bits(), 0);
310    }
311
312    #[test]
313    fn borders_all_contains_all_sides() {
314        assert!(Borders::ALL.contains(Borders::TOP));
315        assert!(Borders::ALL.contains(Borders::RIGHT));
316        assert!(Borders::ALL.contains(Borders::BOTTOM));
317        assert!(Borders::ALL.contains(Borders::LEFT));
318    }
319
320    #[test]
321    fn borders_individual_bits_are_distinct() {
322        let sides = [Borders::TOP, Borders::RIGHT, Borders::BOTTOM, Borders::LEFT];
323        for (i, a) in sides.iter().enumerate() {
324            for (j, b) in sides.iter().enumerate() {
325                if i != j {
326                    assert!(!a.contains(*b), "side {i} should not contain side {j}");
327                }
328            }
329        }
330    }
331
332    #[test]
333    fn borders_union_and_intersection() {
334        let top_left = Borders::TOP | Borders::LEFT;
335        assert!(top_left.contains(Borders::TOP));
336        assert!(top_left.contains(Borders::LEFT));
337        assert!(!top_left.contains(Borders::RIGHT));
338
339        let top_right = Borders::TOP | Borders::RIGHT;
340        let intersection = top_left & top_right;
341        assert!(intersection.contains(Borders::TOP));
342        assert!(!intersection.contains(Borders::LEFT));
343        assert!(!intersection.contains(Borders::RIGHT));
344    }
345
346    #[test]
347    fn borders_default_is_none() {
348        assert_eq!(Borders::default(), Borders::NONE);
349    }
350
351    #[test]
352    fn non_ascii_sets_have_no_ascii_chars() {
353        for set in [
354            BorderSet::ROUNDED,
355            BorderSet::SQUARE,
356            BorderSet::DOUBLE,
357            BorderSet::HEAVY,
358        ] {
359            let chars = [
360                set.vertical,
361                set.horizontal,
362                set.top_left,
363                set.top_right,
364                set.bottom_left,
365                set.bottom_right,
366                set.tee_up,
367                set.tee_down,
368                set.tee_left,
369                set.tee_right,
370                set.cross,
371            ];
372            assert!(
373                chars.iter().all(|c| !c.is_ascii()),
374                "non-ASCII border set should have no ASCII chars"
375            );
376        }
377    }
378
379    #[test]
380    fn border_set_tees_are_consistent() {
381        // For SQUARE: tees should share chars with edges
382        let set = BorderSet::SQUARE;
383        // tee_up (┴) connects vertical and horizontal lines going up
384        // tee_down (┬) connects going down
385        // They should all be distinct from each other and from corners
386        let tees = [set.tee_up, set.tee_down, set.tee_left, set.tee_right];
387        for (i, a) in tees.iter().enumerate() {
388            for (j, b) in tees.iter().enumerate() {
389                if i != j {
390                    assert_ne!(a, b, "tees {i} and {j} should differ");
391                }
392            }
393        }
394    }
395}
396
397bitflags::bitflags! {
398    /// Bitflags for which borders to render.
399    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
400    pub struct Borders: u8 {
401        /// No borders.
402        const NONE   = 0b0000;
403        /// Top border.
404        const TOP    = 0b0001;
405        /// Right border.
406        const RIGHT  = 0b0010;
407        /// Bottom border.
408        const BOTTOM = 0b0100;
409        /// Left border.
410        const LEFT   = 0b1000;
411        /// All four borders.
412        const ALL    = Self::TOP.bits() | Self::RIGHT.bits() | Self::BOTTOM.bits() | Self::LEFT.bits();
413    }
414}