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