Skip to main content

ib_shell_item/string/
bar.rs

1use std::cmp;
2
3use bon::Builder;
4use widestring::{Utf16String, utf16str};
5
6/// Block characters from empty to full (8 steps).
7const BLOCKS: [char; 8] = [
8    '\u{258F}', '\u{258E}', '\u{258D}', '\u{258C}', '\u{258B}', '\u{258A}', '\u{2589}', '\u{2588}',
9];
10const BLOCKS_N: u64 = BLOCKS.len() as u64;
11
12/// Hair space
13///
14/// https://en.wikipedia.org/wiki/Whitespace_character#Hair_spaces_around_dashes
15pub const HAIR_SPACE: char = '\u{200A}';
16
17/*
18#[derive(Debug, Clone, Copy, Default)]
19pub enum HorizontalAlignment {
20    #[default]
21    Left,
22    Center,
23    Right,
24}
25*/
26
27/**
28Make plain text bars with Unicode
29[block elements](https://en.wikipedia.org/wiki/Block_Elements).
30
31## Monospaced vs. proportional fonts
32It is easier to make string bars with proportional fonts,
33which often looks better too.
34But these string bars look bad with monospaced fonts.
35On the other side, monospaced bars look okay with proportional fonts.
36
37Windows 11 File Explorer uses a proportional font by default.
38However, Windows 10 uses a monospaced one;
39and even on Windows 11 the user may use tools like MacType to
40customize the font.
41
42So we use monospaced mode by default, but if you know the app will
43use a proportional font, you should use proportional mode;
44or provide an option to the user.
45
46Is is also found later that proportional bar's width is unstable across screens with different DPI.
47
48Related issues:
49- [资源管理器文件夹大小集成功能在win10的显示问题 - Issue #112 - IbEverythingExt](https://github.com/Chaoses-Ib/IbEverythingExt/issues/112)
50
51## Alignment
52Unfortunately, Unicode only provides full block elements for lower and left variants.
53For right variants, there are only 4/8 and 1/8 blocks, and some fonts don't even support them,
54like Microsoft YaHei.
55
56So we only provide left alignment to simplify the implementation.
57*/
58#[derive(Builder, Debug, Clone)]
59pub struct StringBar {
60    value: u64,
61
62    /// [`StringBar::value`] is allowed to be larger than [`StringBar::max`].
63    max: u64,
64
65    /// In 1/8-block units.
66    ///
67    /// - File Explorer:
68    ///   This is equivalent to device-independent pixels (in default scale).
69    ///   If too wide, the column will be truncated from right to left, even it's right-aligned.
70    ///
71    ///   The thinnest column by default, Size column, defaults to 100dip width,
72    ///   but only ~84dip is used to display text.
73    ///   For monospaced mode, the max width won't cause trurncation is ~16 with a [`HAIR_SPACE`],
74    ///   and ~15 with a space.
75    ///   For proportional mode, it's ~25.
76    #[builder(default = 15)]
77    width: u16,
78
79    /// For bars too short, return a minimum bar instead an empty string.
80    #[builder(default)]
81    min_bar: bool,
82
83    /*
84    #[builder(default)]
85    alignment: HorizontalAlignment,
86    */
87    /// See [`StringBar`] for details.
88    #[builder(default)]
89    proportional_font: bool,
90}
91
92impl StringBar {
93    const fn width(&self) -> u64 {
94        self.width as _
95    }
96
97    fn min_bar(&self) -> Utf16String {
98        if self.min_bar {
99            utf16str!("\u{258F}").into()
100        } else {
101            Default::default()
102        }
103    }
104
105    pub fn to_utf16_string(&self) -> Utf16String {
106        if self.proportional_font {
107            self.proportional_font_to_utf16_string()
108        } else {
109            self.monospaced_font_to_utf16_string()
110        }
111    }
112
113    /// For proportional fonts, making bar is easy.
114    ///
115    /// Unfortunately, we also can't overlap bar with label string easily.
116    ///
117    /// See [`StringBar`] for details.
118    pub fn proportional_font_to_utf16_string(&self) -> Utf16String {
119        let i = self.min_bar as u64;
120        let n = if self.max == 0 {
121            i
122        } else {
123            cmp::min(
124                (self.value.saturating_mul(self.width()) / self.max) + i,
125                self.width(),
126            )
127        };
128        let bar = utf16str!("\u{258F}").repeat(n as usize);
129        bar
130    }
131
132    /// See [`StringBar`] for details.
133    pub fn monospaced_font_to_utf16_string(&self) -> Utf16String {
134        if self.max == 0 {
135            return self.min_bar();
136        }
137
138        // The total number of 1/8-block units that should be filled across the entire bar
139        let units = self.value.saturating_mul(self.width()) / self.max;
140        if units == 0 {
141            return self.min_bar();
142        }
143
144        let full_blocks = units / BLOCKS_N;
145        let rem = units % BLOCKS_N;
146
147        let capacity = (full_blocks + ((rem > 0) as u64)) as usize;
148        let mut bar = Utf16String::with_capacity(capacity);
149
150        /*
151        match self.alignment {
152            HorizontalAlignment::Right => {
153                if rem > 0 {
154                    result.push(BLOCKS[rem as usize - 1]);
155                }
156            }
157            _ => {}
158        }
159        */
160        for _ in 0..full_blocks {
161            bar.push(*BLOCKS.last().unwrap());
162        }
163        /*
164        match self.alignment {
165            HorizontalAlignment::Left | HorizontalAlignment::Center => {
166                if rem > 0 {
167                    result.push(BLOCKS[rem as usize - 1]);
168                }
169            }
170            _ => {}
171        }
172        */
173        if rem > 0 {
174            bar.push(BLOCKS[rem as usize - 1]);
175        }
176
177        bar
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn empty() {
187        let bar = StringBar::builder().value(0).max(100).width(80).build();
188        let result = bar.to_utf16_string();
189        assert!(result.is_empty());
190
191        let bar = StringBar::builder()
192            .value(0)
193            .max(100)
194            .width(80)
195            .min_bar(true)
196            .build();
197        let result = bar.to_utf16_string();
198        assert_eq!(result.to_string(), "\u{258F}");
199    }
200
201    #[test]
202    fn zero_max() {
203        let bar = StringBar::builder().value(5).max(0).width(80).build();
204        let result = bar.to_utf16_string();
205        assert!(result.is_empty());
206
207        let bar = StringBar::builder()
208            .value(5)
209            .max(0)
210            .width(80)
211            .min_bar(true)
212            .build();
213        let result = bar.to_utf16_string();
214        assert_eq!(result.to_string(), "\u{258F}");
215    }
216
217    #[test]
218    fn half_full() {
219        let bar = StringBar::builder().value(50).max(100).width(80).build();
220        let result = bar.to_utf16_string();
221        assert_eq!(result.len(), 5);
222        assert_eq!(result.to_string(), "█████");
223    }
224
225    #[test]
226    fn full() {
227        let bar = StringBar::builder().value(100).max(100).width(80).build();
228        let result = bar.to_utf16_string();
229        assert_eq!(result.len(), 10);
230        assert_eq!(result.to_string(), "██████████");
231    }
232
233    #[test]
234    fn partial_block_left() {
235        let bar = StringBar::builder().value(53).max(100).width(80).build();
236        let result = bar.to_utf16_string();
237        // 53% of 80 units = 42.4 units → 5 full blocks (40 units) + 2 units partial
238        assert_eq!(result.len(), 6);
239        assert_eq!(result.to_string(), "█████▎");
240        assert_eq!(result.chars().nth(5).unwrap(), '\u{258E}');
241    }
242
243    /*
244    #[test]
245    fn partial_block_right() {
246        let bar = StringBar::builder()
247            .current(53)
248            .max(100)
249            .width(80)
250            .alignment(HorizontalAlignment::Right)
251            .build();
252        let result = bar.to_utf16_string();
253        // Right: partial first → "▎█████"
254        assert_eq!(result.len(), 6);
255        assert_eq!(result.chars().nth(0).unwrap(), '\u{258E}');
256    }
257    */
258}