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}