termimad/fit/
composite_fit.rs

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