1 use {
2 crate::*,
3 minimad::*,
4 unicode_width::{
5 UnicodeWidthChar,
6 UnicodeWidthStr,
7 },
8 };
9
10 pub static ELLIPSIS: &str = "…";
11
12 /// A fitter can shorten a composite to make it fit a target width
13 /// without wrapping (by removing parts and replacing them with
14 /// ellipsis)
15 #[derive(Debug, Clone, Copy)]
16 pub struct Fitter {
17 /// whether to try remove the central part of big "token"
18 /// (parts without space nor style change)
19 mid_token_ellision: bool,
20
21 /// whether to try remove the central part of big compounds
22 mid_compound_ellision: bool,
23
24 align: Alignment,
25 }
26
27 impl Default for Fitter {
default() -> Self28 fn default() -> Self {
29 Self {
30 mid_token_ellision: true,
31 mid_compound_ellision: true,
32 align: Alignment::Unspecified,
33 }
34 }
35 }
36
37 #[derive(Debug, Clone, Copy)]
38 struct CharInfo {
39 byte_idx: usize,
40 char: char, // virer
41 width: usize, // virer
42 }
str_char_infos(s: &str) -> Vec<CharInfo>43 fn str_char_infos(s: &str) -> Vec<CharInfo> {
44 s.char_indices()
45 .map(|(byte_idx, char)| CharInfo {
46 byte_idx,
47 char,
48 width: char.width().unwrap_or(0),
49 })
50 .collect()
51 }
52
53 #[derive(Debug, Clone)]
54 struct Zone {
55 compound_idx: usize,
56 byte_start_idx: usize,
57 byte_end_idx: usize,
58 char_infos: Vec<CharInfo>,
59 removable_width: usize, // cell width of string minus one character each end
60 }
61 impl Zone {
token(composite: &Composite, min_removable_width: usize) -> Vec<Zone>62 fn token(composite: &Composite, min_removable_width: usize) -> Vec<Zone> {
63 let mut zones = Vec::new();
64 for (compound_idx, compound) in composite.compounds.iter().enumerate() {
65 let s = compound.src;
66 if s.len() < min_removable_width + 2 {
67 continue;
68 }
69 let mut byte_start: Option<usize> = None;
70 for (byte_idx, char) in s.char_indices() {
71 if char.is_whitespace() {
72 if let Some(byte_start_idx) = byte_start {
73 if byte_idx - byte_start_idx >= min_removable_width + 2 {
74 let zs = &s[byte_start_idx..byte_idx];
75 let removable_width = zs.width();
76 if removable_width >= min_removable_width {
77 let char_infos = str_char_infos(zs);
78 zones.push(Zone {
79 compound_idx,
80 byte_start_idx,
81 byte_end_idx: byte_idx,
82 char_infos,
83 removable_width,
84 });
85 }
86 }
87 byte_start = None;
88 }
89 } else if byte_start.is_none() {
90 byte_start = Some(byte_idx);
91 }
92 }
93 if let Some(byte_start_idx) = byte_start {
94 let byte_end_idx = s.len();
95 if byte_end_idx - byte_start_idx >= min_removable_width + 2 {
96 let zs = &s[byte_start_idx..];
97 let removable_width = zs.width();
98 if removable_width >= min_removable_width {
99 let char_infos = str_char_infos(zs);
100 zones.push(Zone {
101 compound_idx,
102 byte_start_idx,
103 byte_end_idx,
104 char_infos,
105 removable_width,
106 });
107 }
108 }
109 }
110 }
111 zones
112 }
biggest_token(composite: &Composite, min_removable_width: usize) -> Option<Zone>113 fn biggest_token(composite: &Composite, min_removable_width: usize) -> Option<Zone> {
114 Zone::token(composite, min_removable_width)
115 .drain(..)
116 .max_by_key(|z| z.removable_width)
117 }
118 /// make a zone from each compound large enough
compounds(composite: &Composite, min_removable_width: usize) -> Vec<Zone>119 fn compounds(composite: &Composite, min_removable_width: usize) -> Vec<Zone> {
120 composite.compounds.iter()
121 .enumerate()
122 .filter_map(|(compound_idx, compound)| {
123 let char_infos = str_char_infos(compound.src);
124 if char_infos.len() < 2 + min_removable_width {
125 return None;
126 }
127 let removable = &compound.src[char_infos[1].byte_idx..char_infos[char_infos.len()-1].byte_idx];
128 let removable_width = removable.width();
129 if removable_width < min_removable_width {
130 None
131 } else {
132 Some(Zone {
133 compound_idx,
134 byte_start_idx: 0,
135 byte_end_idx: compound.src.len(),
136 char_infos,
137 removable_width,
138 })
139 }
140 })
141 .collect()
142 }
biggest_compound(composite: &Composite, min_removable_width: usize) -> Option<Zone>143 fn biggest_compound(composite: &Composite, min_removable_width: usize) -> Option<Zone> {
144 Zone::compounds(composite, min_removable_width)
145 .drain(..)
146 .max_by_key(|z| z.removable_width)
147 }
148 /// return the gain (that is the removed minus 1 for the ellipsis length)
cut(&self, composite: &mut Composite, to_remove: usize) -> usize149 fn cut(&self, composite: &mut Composite, to_remove: usize) -> usize {
150 if self.removable_width < 2 {
151 return 0;
152 }
153 let compound = &composite.compounds[self.compound_idx];
154 let len = self.char_infos.len();
155 let mut start_char_idx = len / 2;
156 let mut end_char_idx = start_char_idx;
157 let mut removed_width = 0;
158 loop {
159 // we alternatively grow left and right
160 if (end_char_idx-start_char_idx)%2 == 0 {
161 if end_char_idx + 1 >= len {
162 break;
163 }
164 end_char_idx += 1;
165 } else {
166 if start_char_idx <= 1 {
167 break;
168 }
169 start_char_idx -= 1;
170 }
171 let start_byte_idx = self.byte_start_idx + self.char_infos[start_char_idx].byte_idx;
172 let end_byte_idx = self.byte_start_idx + self.char_infos[end_char_idx].byte_idx;
173 removed_width = (&compound.src[start_byte_idx..end_byte_idx]).width();
174 if removed_width >= to_remove {
175 break;
176 }
177 }
178 let start_byte_idx = self.byte_start_idx + self.char_infos[start_char_idx].byte_idx;
179 let end_byte_idx = self.byte_start_idx + self.char_infos[end_char_idx].byte_idx;
180 let head = compound.sub(0, start_byte_idx);
181 let tail = compound.tail(end_byte_idx);
182 composite.compounds[self.compound_idx] = head;
183 composite.compounds.insert(self.compound_idx+1, Compound::raw_str(ELLIPSIS));
184 composite.compounds.insert(self.compound_idx+2, tail);
185
186 removed_width - 1
187 }
188 }
189
190 impl Fitter {
191
192 /// create a fitter for when you want a specific alignment.
193 ///
194 /// You may still change the mid_token_ellision and mid_compound_ellision
195 /// later
for_align(align: Alignment) -> Self196 pub fn for_align(align: Alignment) -> Self {
197 let internal_ellision = align == Alignment::Unspecified;
198 Self {
199 mid_token_ellision: internal_ellision,
200 mid_compound_ellision: internal_ellision,
201 align,
202 }
203 }
204
205 /// ensure the composite fits the max_width, by replacing some parts
206 /// with ellisions
fit<'s>( self, fc: &mut FmtComposite<'s>, max_width: usize, skin: &MadSkin )207 pub fn fit<'s>(
208 self,
209 fc: &mut FmtComposite<'s>,
210 max_width: usize,
211 skin: &MadSkin
212 ) {
213 // some special cases because they're hard to check after
214 if fc.visible_length <= max_width {
215 return;
216 } else if max_width == 0 {
217 fc.composite.compounds.clear();
218 fc.visible_length = 0;
219 return;
220 } else if max_width == 1 {
221 fc.composite.compounds.clear();
222 fc.composite.compounds.push(Compound::raw_str(ELLIPSIS));
223 fc.visible_length = 1;
224 return;
225 }
226
227 let mut excess = fc.visible_length - max_width;
228
229 // note: computing all zones once would be faster but would involve either
230 // recomputing compound_idx ou finding another index scheme
231
232 if self.mid_token_ellision {
233 // cutting in the middle of big no space parts
234 while excess > 0 {
235 let mut gain = 0;
236 if let Some(zone) = Zone::biggest_token(&fc.composite, 3) {
237 gain = zone.cut(&mut fc.composite, excess + 1);
238 }
239 if gain == 0 {
240 break;
241 }
242 excess -= gain.min(excess);
243 }
244 }
245
246 if self.mid_compound_ellision {
247 // cutting in the middle of big compounds
248 while excess > 0 {
249 let mut gain = 0;
250 // we'll look for zones of removable width at least 2
251 // (because we put the ellipsis in place)
252 if let Some(zone) = Zone::biggest_compound(&fc.composite, 2) {
253 gain = zone.cut(&mut fc.composite, excess + 1);
254 }
255 if gain == 0 {
256 break;
257 }
258 excess -= gain.min(excess);
259 }
260 }
261
262 if excess == 0 {
263 fc.recompute_width(skin);
264 return;
265 }
266
267 let compounds = &mut fc.composite.compounds;
268 // we'll have to compensate with 1 or 2 ellipsis, so the "excess" is
269 // increased accordingly we increase
270 let (mut excess_left, mut excess_right) = match self.align {
271 Alignment::Right => (excess + 1, 0),
272 Alignment::Left | Alignment:: Unspecified => (0, excess + 1),
273 Alignment::Center => {
274 let left = excess / 2;
275 let right = excess - left;
276 if left > 0 {
277 (left + 1, right + 1)
278 } else {
279 (0, right + 1)
280 }
281 },
282 };
283
284 if excess_left > 0 {
285 // left truncating
286 while excess_left > 0 && !compounds.is_empty() {
287 let compound = &mut compounds[0];
288 let char_infos = str_char_infos(compound.src);
289 let mut last_removed_char_idx = 0;
290 let mut removed_width = 0;
291 loop {
292 removed_width += char_infos[last_removed_char_idx].width;
293 if removed_width >= excess_left || last_removed_char_idx + 1 == char_infos.len() {
294 break;
295 }
296 last_removed_char_idx += 1;
297 }
298 if last_removed_char_idx + 1 == char_infos.len() {
299 // we remove the whole compound
300 compounds.remove(0);
301 excess_left -= removed_width.min(excess_left);
302 } else {
303 // we cut the left part
304 compound.src = &compound.src[char_infos[last_removed_char_idx+1].byte_idx..];
305 excess_left = 0;
306 }
307 }
308 compounds.insert(0, Compound::raw_str(ELLIPSIS));
309 }
310
311 if excess_right > 0 {
312 // right truncating
313 while excess_right > 0 && !compounds.is_empty() {
314 let last_idx = compounds.len()-1;
315 let compound = &mut compounds[last_idx];
316 let char_infos = str_char_infos(compound.src);
317 let mut removed_width = 0;
318 let mut end_byte_idx = compound.src.len();
319 for ci in char_infos.iter().rev() {
320 end_byte_idx = ci.byte_idx;
321 removed_width += ci.width;
322 if removed_width >= excess_right {
323 break;
324 }
325 }
326 if end_byte_idx == 0 {
327 // we remove the whole compound
328 compounds.pop();
329 excess_right -= removed_width.min(excess_right);
330 } else {
331 // we cut the right part
332 compound.src = &compound.src[..end_byte_idx];
333 excess_right = 0;
334 }
335 }
336 compounds.push(Compound::raw_str(ELLIPSIS));
337 }
338
339 fc.recompute_width(skin);
340 }
341
342 }
343
344 /// Tests of fitting, that is cutting the composite at best to make it
345 /// fit a given width (if possible)
346 ///
347 /// The print which happens in case of failure isn't really well
348 /// formatted. A solution if a test fails is to do
349 /// cargo test fit_tests -- --nocapture
350 #[cfg(test)]
351 mod fit_tests {
352
353 use minimad::{
354 Alignment,
355 Composite,
356 };
357 use crate::{
358 Fitter,
359 FmtComposite,
360 };
361
check_fit_align(src: &str, target_width: usize, align: Alignment)362 fn check_fit_align(src: &str, target_width: usize, align: Alignment) {
363 dbg!((target_width, align));
364 let skin = crate::get_default_skin();
365 let mut fc = FmtComposite::from(Composite::from_inline(src), &skin);
366 let fitter = Fitter::for_align(align);
367 fitter.fit(&mut fc, target_width, &skin);
368 dbg!(&fc);
369 assert!(fc.visible_length <= target_width); // can be smaller
370 }
371
check_fit(src: &str, target_width: usize)372 fn check_fit(src: &str, target_width: usize) {
373 check_fit_align(src, target_width, Alignment::Right);
374 check_fit_align(src, target_width, Alignment::Left);
375 check_fit_align(src, target_width, Alignment::Center);
376 check_fit_align(src, target_width, Alignment::Unspecified);
377 }
378
379 #[test]
test_fit()380 fn test_fit() {
381
382 let sentence = "This sentence has **short** and **much longer** parts, and some Korean: *一曰道,二曰天*.";
383 check_fit(sentence, 60);
384 check_fit(sentence, 40);
385
386 let five_issues = "一曰道,二曰天,三曰地,四曰將,五曰法。";
387 check_fit(five_issues, 15);
388 check_fit(five_issues, 8);
389
390 let status = "ab *cd* `12345 123456789`";
391 check_fit(status, 17);
392 check_fit(status, 2);
393 }
394
395 }
396