wl-pick 0.5.0

A live grid of window and display previews, for picking one
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
//! Labels.
//!
//! Building a font system and rasterising the first glyphs costs ~55ms, about
//! what the compositor spends copying window pixels back for a handful of
//! windows. So all of it happens on a worker thread started before the captures
//! and joined after them: by the time anything is drawn, every label is shaped
//! and its glyphs are already in the cache, and painting one costs ~0.1ms.
//!
//! Sizes here are physical pixels — the caller scales logical units first,
//! because the chrome buffer it paints into is physical too.

use std::thread::{self, JoinHandle};

use cosmic_text::{
    Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight,
    Wrap, fontdb,
};

use crate::shm::Painter;
use crate::theme::{Argb, Rect};

pub struct Labels {
    fs: FontSystem,
    cache: SwashCache,
    lines: Vec<Buffer>,
    family: String,
}

/// Shape `texts` into one centred single line each, at most `box_w` wide.
pub fn spawn(
    texts: Vec<String>,
    family: String,
    font_px: f32,
    line_h: f32,
    box_w: f32,
) -> JoinHandle<Labels> {
    thread::spawn(move || build(texts, family, font_px, line_h, box_w))
}

/// The default family: whatever this system calls its monospace font.
pub const SYSTEM_MONO: &str = "monospace";

/// Families to try when fontconfig cannot be asked, roughly in order of how
/// likely a distribution is to ship one as its default monospace.
const MONO_CANDIDATES: &[&str] = &[
    "Noto Sans Mono",
    "DejaVu Sans Mono",
    "Liberation Mono",
    "Adwaita Mono",
    "Source Code Pro",
    "Hack",
    "Fira Mono",
    "Courier New",
];

/// The fonts to shape with, and whether that is already every font on the
/// system.
///
/// The full scan costs ~37ms, which the label thread cannot always hide inside
/// the capture wait. A user's own font directories are tiny, so a named family
/// is looked for there first and the scan is paid only when the family is not
/// there. (A label that then needs a glyph the family lacks adds the rest
/// later; see `build`.) The generic default lives among the system fonts.
fn font_db(family: &str) -> (FontSystem, bool) {
    let mut db = fontdb::Database::new();
    let mut full = is_generic(family);
    if !full {
        if let Ok(home) = std::env::var("HOME") {
            db.load_fonts_dir(format!("{home}/.fonts"));
            db.load_fonts_dir(format!("{home}/.local/share/fonts"));
        }
        full = interpret(&db, family).is_none();
    }
    if full {
        db.load_system_fonts();
    }
    // The locale only orders CJK fallbacks; labels are ids and titles.
    let fs = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
    (fs, full)
}

fn is_generic(family: &str) -> bool {
    family.eq_ignore_ascii_case(SYSTEM_MONO)
}

/// The database's own spelling of `want`, matched the way fontconfig matches:
/// without caring about case.
fn family_named(db: &fontdb::Database, want: &str) -> Option<String> {
    db.faces()
        .flat_map(|face| face.families.iter())
        .find(|(name, _)| name.eq_ignore_ascii_case(want))
        .map(|(name, _)| name.clone())
}

/// A family name, and the face within it to ask for.
///
/// Fonts are commonly known by their full display name -- "Berkeley Mono Medium
/// SemiCondensed" is what `fc-match` prints and what a font menu shows -- but
/// only "Berkeley Mono" is the family; the rest names a face inside it. Asking
/// fontdb for the whole string matches nothing, so a request is split into the
/// longest part that is a real family and a style read off the remainder.
#[derive(Debug, PartialEq)]
struct Choice {
    family: String,
    weight: Weight,
    stretch: Stretch,
}

impl Choice {
    fn plain(family: &str) -> Self {
        Self {
            family: family.to_string(),
            weight: Weight::NORMAL,
            stretch: Stretch::Normal,
        }
    }

    fn attrs(&self) -> Attrs<'_> {
        Attrs::new()
            .family(Family::Name(&self.family))
            .weight(self.weight)
            .stretch(self.stretch)
    }

    /// The name of what was chosen, in the shape a font name is written in.
    fn name(&self) -> String {
        // Weight before width, the way fonts name themselves. A default value
        // adds nothing: "Berkeley Mono", not "Berkeley Mono Regular Normal".
        let mut parts = vec![self.family.as_str()];
        if self.weight != Weight::NORMAL {
            parts.extend(spelling(WEIGHTS, self.weight));
        }
        if self.stretch != Stretch::Normal {
            parts.extend(spelling(WIDTHS, self.stretch));
        }
        parts.join(" ")
    }
}

/// What `request` names, as a family the database has plus a style.
fn interpret(db: &fontdb::Database, request: &str) -> Option<Choice> {
    split_request(request, |name| family_named(db, name))
}

/// Split `request` into the longest leading part that names a family and a
/// style read off the words after it. `lookup` answers with the database's own
/// spelling of a family, or `None` if it has no such family.
///
/// Longest first, so a family whose own name ends in a style word -- "Fira Code
/// Light" is a family in its own right -- wins over reading that word as a
/// style. A trailing word that is neither a weight nor a width means this
/// reading of the name is wrong, so the search keeps shortening rather than
/// quietly ignoring it.
fn split_request(request: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<Choice> {
    let words: Vec<&str> = request.split_whitespace().collect();
    for split in (1..=words.len()).rev() {
        let Some(family) = lookup(&words[..split].join(" ")) else {
            continue;
        };
        let mut choice = Choice::plain(&family);
        if read_style(&words[split..], &mut choice) {
            return Some(choice);
        }
    }
    None
}

/// Apply the style `words` to `choice`, or report that one of them is not a
/// style at all.
fn read_style(words: &[&str], choice: &mut Choice) -> bool {
    let mut i = 0;
    while i < words.len() {
        // Fonts spell a two-part style either way round -- "ExtraLight" and
        // "Extra Light" are the same face -- so each pair of words gets a look
        // before either is read on its own.
        let pair = words.get(i..i + 2).map(|two| two.concat());
        if pair
            .as_deref()
            .is_some_and(|pair| apply_style(pair, choice))
        {
            i += 2;
        } else if apply_style(words[i], choice) {
            i += 1;
        } else {
            return false;
        }
    }
    true
}

/// Read one word as a weight or a width, and apply it.
fn apply_style(word: &str, choice: &mut Choice) -> bool {
    if let Some(weight) = lookup(WEIGHTS, word) {
        choice.weight = weight;
    } else if let Some(stretch) = lookup(WIDTHS, word) {
        choice.stretch = stretch;
    } else {
        return false;
    }
    true
}

/// Style words as fonts spell them, joined or spaced, in any case.
fn normalise(word: &str) -> String {
    word.chars()
        .filter(|c| c.is_ascii_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect()
}

/// Weight words as fonts spell them. The first spelling of each value is the
/// one used when naming a weight back, so canonical spellings come first.
const WEIGHTS: &[(&str, Weight)] = &[
    ("Thin", Weight::THIN),
    ("Hairline", Weight::THIN),
    ("ExtraLight", Weight::EXTRA_LIGHT),
    ("UltraLight", Weight::EXTRA_LIGHT),
    ("Light", Weight::LIGHT),
    ("Regular", Weight::NORMAL),
    ("Normal", Weight::NORMAL),
    ("Book", Weight::NORMAL),
    ("Medium", Weight::MEDIUM),
    ("SemiBold", Weight::SEMIBOLD),
    ("DemiBold", Weight::SEMIBOLD),
    ("Bold", Weight::BOLD),
    ("ExtraBold", Weight::EXTRA_BOLD),
    ("UltraBold", Weight::EXTRA_BOLD),
    ("Black", Weight::BLACK),
    ("Heavy", Weight::BLACK),
];

/// Width words, likewise. fontdb calls this a stretch.
const WIDTHS: &[(&str, Stretch)] = &[
    ("UltraCondensed", Stretch::UltraCondensed),
    ("ExtraCondensed", Stretch::ExtraCondensed),
    ("Condensed", Stretch::Condensed),
    ("SemiCondensed", Stretch::SemiCondensed),
    ("Normal", Stretch::Normal),
    ("SemiExpanded", Stretch::SemiExpanded),
    ("Expanded", Stretch::Expanded),
    ("ExtraExpanded", Stretch::ExtraExpanded),
    ("UltraExpanded", Stretch::UltraExpanded),
];

/// What `word` means, whatever way it is spelled.
fn lookup<T: Copy>(table: &[(&str, T)], word: &str) -> Option<T> {
    let word = normalise(word);
    table
        .iter()
        .find(|(spelling, _)| normalise(spelling) == word)
        .map(|(_, value)| *value)
}

/// How to spell `value`, for saying afterwards what was used.
fn spelling<T: PartialEq>(table: &[(&'static str, T)], value: T) -> Option<&'static str> {
    table
        .iter()
        .find(|(_, known)| *known == value)
        .map(|(word, _)| *word)
}

/// What to shape the labels with, given what was asked for.
///
/// A request that names nothing at all is worth complaining about: silently
/// drawing in some other font looks like the setting was ignored, which is
/// exactly how it reads from the outside.
fn choose(db: &fontdb::Database, request: &str) -> Choice {
    if !is_generic(request) {
        if let Some(choice) = interpret(db, request) {
            return choice;
        }
        let fallback = system_mono(db);
        eprintln!(
            "wl-pick: no font matching {request:?}, using {:?}; \
             `fc-match -f '%{{family}}\\n' {request:?}` names the family",
            fallback.family
        );
        return fallback;
    }
    system_mono(db)
}

/// The generic default, resolved to a real family. cosmic-text's own generic
/// goes through fontdb's built-in preference ("FreeMono"), which is usually
/// absent and then lands on an arbitrary face — so ask fontconfig instead,
/// since that is what the rest of the desktop uses.
fn system_mono(db: &fontdb::Database) -> Choice {
    fc_match_mono()
        .filter(|name| family_named(db, name).is_some())
        .or_else(|| {
            MONO_CANDIDATES
                .iter()
                .find(|name| family_named(db, name).is_some())
                .map(|name| name.to_string())
        })
        .map_or_else(|| Choice::plain(SYSTEM_MONO), |name| Choice::plain(&name))
}

/// What fontconfig says "monospace" means here. A system without the fontconfig
/// tools is not an error: the candidate list covers the common defaults.
fn fc_match_mono() -> Option<String> {
    let out = std::process::Command::new("fc-match")
        .args(["-f", "%{family}", SYSTEM_MONO])
        .output()
        .ok()?;
    let text = String::from_utf8(out.stdout).ok()?;
    // fc-match can answer with several comma-separated aliases for one face.
    let first = text.split(',').next()?.trim().to_string();
    (!first.is_empty()).then_some(first)
}

fn build(texts: Vec<String>, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels {
    let metrics = Metrics::new(font_px, line_h);
    let (mut fs, full) = font_db(&family);
    let choice = choose(fs.db(), &family);
    let attrs = choice.attrs();
    let mut cache = SwashCache::new();
    let mut lines: Vec<Buffer> = texts
        .iter()
        .map(|text| shape(&mut fs, &mut cache, &attrs, metrics, text, box_w))
        .collect();
    // A family draws a box for a character it has no glyph for, and the rest of
    // the system's fonts are what fill those in. A title in another script, or
    // with an emoji in it, is worth the scan; most are not, so it waits for
    // one. Faces keep their ids when more are added, so only the lines that
    // showed a box are shaped again.
    if !full && lines.iter_mut().any(has_missing_glyph) {
        fs.db_mut().load_system_fonts();
        for (line, text) in lines.iter_mut().zip(&texts) {
            if has_missing_glyph(line) {
                *line = shape(&mut fs, &mut cache, &attrs, metrics, text, box_w);
            }
        }
    }
    Labels {
        fs,
        cache,
        lines,
        // What was actually used, not what was asked for.
        family: choice.name(),
    }
}

/// One centred line, fitted to `box_w` and already rasterised into `cache`, so
/// the first paint costs nothing.
fn shape(
    fs: &mut FontSystem,
    cache: &mut SwashCache,
    attrs: &Attrs,
    metrics: Metrics,
    text: &str,
    box_w: f32,
) -> Buffer {
    let fitted = ellipsize(fs, attrs, metrics, text, box_w);
    let mut buf = Buffer::new(fs, metrics);
    buf.set_wrap(Wrap::None);
    buf.set_size(Some(box_w), Some(metrics.line_height));
    buf.set_text(&fitted, attrs, Shaping::Advanced, Some(Align::Center));
    buf.draw(fs, cache, Color::rgb(0, 0, 0), |_, _, _, _, _| {});
    buf
}

/// Glyph 0 is `.notdef` in every font: the box drawn for a character the font
/// does not have.
fn has_missing_glyph(line: &mut Buffer) -> bool {
    line.layout_runs()
        .any(|run| run.glyphs.iter().any(|glyph| glyph.glyph_id == 0))
}

/// Shorten `text` until it fits in `box_w`, ending with an ellipsis, since
/// window titles are arbitrarily long.
fn ellipsize(
    fs: &mut FontSystem,
    attrs: &Attrs,
    metrics: Metrics,
    text: &str,
    box_w: f32,
) -> String {
    let measure = |fs: &mut FontSystem, s: &str| {
        let mut b = Buffer::new(fs, metrics);
        b.set_wrap(Wrap::None);
        b.set_size(None, Some(metrics.line_height));
        b.set_text(s, attrs, Shaping::Advanced, None);
        b.shape_until_scroll(fs, false);
        b.layout_runs().map(|r| r.line_w).fold(0.0, f32::max)
    };

    let full = measure(fs, text);
    if full <= box_w {
        return text.to_string();
    }
    let chars: Vec<char> = text.chars().collect();
    // Proportional first guess, then shrink geometrically. Bounded, because a
    // pathological title should not cost hundreds of reshapes.
    let mut keep = ((chars.len() as f32) * box_w / full).floor() as usize;
    for _ in 0..12 {
        keep = keep.min(chars.len().saturating_sub(1));
        let mut s: String = chars[..keep].iter().collect();
        s.push('…');
        if keep == 0 || measure(fs, &s) <= box_w {
            return s;
        }
        keep = (keep * 9 / 10).min(keep.saturating_sub(1));
    }
    let mut s: String = chars[..keep.min(chars.len())].iter().collect();
    s.push('…');
    s
}

impl Labels {
    /// The family the labels were actually shaped with, which for the generic
    /// default depends on what this system has installed.
    pub fn family(&self) -> &str {
        &self.family
    }

    /// Draw label `i` inside `at` (physical px), clipped to it.
    pub fn draw(&mut self, p: &mut Painter, i: usize, at: Rect, color: Argb) {
        let Some(buf) = self.lines.get_mut(i) else {
            return;
        };
        let rgb = Color::rgb((color >> 16) as u8, (color >> 8) as u8, color as u8);
        buf.draw(&mut self.fs, &mut self.cache, rgb, |x, y, w, h, c| {
            p.blend(
                Rect {
                    x: at.x + x,
                    y: at.y + y,
                    w: w as i32,
                    h: h as i32,
                },
                (c.r(), c.g(), c.b(), c.a()),
                at,
            );
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The whole point of a label is pixels on the surface, so assert some.
    #[test]
    fn draws_visible_glyphs() {
        let (w, h) = (400, 34);
        let mut labels = build(
            vec!["Hello".to_string()],
            "monospace".to_string(),
            26.0,
            h as f32,
            w as f32,
        );
        let mut px = vec![0u8; (w * h * 4) as usize];
        let mut p = Painter::new(&mut px, w, h);
        let at = Rect { x: 0, y: 0, w, h };
        labels.draw(&mut p, 0, at, 0x00ffffff);
        let touched = px
            .chunks_exact(4)
            .filter(|c| c[0] != 0 || c[1] != 0 || c[2] != 0)
            .count();
        assert!(touched > 20, "only {touched} pixels were painted");
    }

    #[test]
    fn the_generic_default_resolves_to_a_real_monospace_family() {
        let (fs, _) = font_db(SYSTEM_MONO);
        let choice = choose(fs.db(), SYSTEM_MONO);
        assert_ne!(
            choice.family, SYSTEM_MONO,
            "should have named a real family"
        );
        assert!(
            family_named(fs.db(), &choice.family).is_some(),
            "{:?} is not in the database",
            choice.family
        );
    }

    #[test]
    fn a_family_that_is_not_installed_falls_back_to_the_default() {
        // This used to pass the name straight through to cosmic-text, which
        // shaped in whatever it liked while --verbose reported the name that
        // had been asked for -- so a font setting that did nothing looked
        // exactly like one that worked.
        let (fs, _) = font_db(SYSTEM_MONO);
        let asked = choose(fs.db(), "No Such Family At All");
        assert_eq!(asked, choose(fs.db(), SYSTEM_MONO), "should be the default");
        assert_ne!(asked.family, "No Such Family At All");
    }

    /// A database that knows exactly these families.
    fn db(families: &[&'static str]) -> impl Fn(&str) -> Option<String> {
        let families: Vec<&str> = families.to_vec();
        move |want| {
            families
                .iter()
                .find(|name| name.eq_ignore_ascii_case(want))
                .map(|name| name.to_string())
        }
    }

    #[test]
    fn a_full_font_name_splits_into_family_and_style() {
        // What fc-match prints and what a font menu shows: only the first part
        // of it is the family, which is why asking fontdb for the whole string
        // matched nothing.
        let choice = split_request("Berkeley Mono Medium SemiCondensed", db(&["Berkeley Mono"]))
            .expect("should resolve");
        assert_eq!(choice.family, "Berkeley Mono");
        assert_eq!(choice.weight, Weight::MEDIUM);
        assert_eq!(choice.stretch, Stretch::SemiCondensed);
        assert_eq!(choice.name(), "Berkeley Mono Medium SemiCondensed");
    }

    #[test]
    fn a_plain_family_keeps_its_defaults() {
        let choice = split_request("Berkeley Mono", db(&["Berkeley Mono"])).expect("resolves");
        assert_eq!(choice, Choice::plain("Berkeley Mono"));
        assert_eq!(choice.name(), "Berkeley Mono");
    }

    #[test]
    fn a_family_may_end_in_a_style_word() {
        // "Fira Code Light" is a family in its own right, so it must win over
        // reading "Light" as the weight of "Fira Code".
        let choice = split_request("Fira Code Light", db(&["Fira Code", "Fira Code Light"]))
            .expect("resolves");
        assert_eq!(choice.family, "Fira Code Light");
        assert_eq!(choice.weight, Weight::NORMAL);
    }

    #[test]
    fn style_words_are_spelled_many_ways() {
        let cases = [
            ("Iosevka demibold", Weight::SEMIBOLD, Stretch::Normal),
            ("Iosevka Extra Light", Weight::EXTRA_LIGHT, Stretch::Normal),
            (
                "Iosevka ULTRACONDENSED",
                Weight::NORMAL,
                Stretch::UltraCondensed,
            ),
            ("Iosevka Bold Condensed", Weight::BOLD, Stretch::Condensed),
        ];
        for (request, weight, stretch) in cases {
            let choice = split_request(request, db(&["Iosevka"]))
                .unwrap_or_else(|| panic!("{request:?} should resolve"));
            assert_eq!(choice.family, "Iosevka", "{request:?}");
            assert_eq!(choice.weight, weight, "{request:?}");
            assert_eq!(choice.stretch, stretch, "{request:?}");
        }
    }

    #[test]
    fn a_name_that_means_nothing_resolves_to_nothing() {
        // The caller warns and falls back. Quietly dropping the word it cannot
        // read would shape in the wrong face and say nothing about it.
        let known = db(&["Berkeley Mono"]);
        assert_eq!(split_request("Berkeley Monospace Bold", &known), None);
        assert_eq!(split_request("Berkeley Mono Nonsense", &known), None);
        assert_eq!(split_request("Comic Sans", &known), None);
        assert_eq!(split_request("", &known), None);
    }

    #[test]
    fn case_does_not_matter_but_the_font_keeps_its_own_spelling() {
        let choice = split_request("berkeley mono bold", db(&["Berkeley Mono"])).expect("resolves");
        assert_eq!(choice.family, "Berkeley Mono", "the database's spelling");
        assert_eq!(choice.weight, Weight::BOLD);
    }

    #[test]
    fn a_missing_glyph_is_noticed() {
        // One face on its own cannot have every script, so an emoji and a CJK
        // character shaped against it alone are the box the fallback exists
        // to replace -- and plain ASCII in the same face is not.
        let mut full = fontdb::Database::new();
        full.load_system_fonts();
        let Some(face) = full.faces().find(|f| f.monospaced) else {
            return; // a system with no fonts at all has nothing to test
        };
        let fontdb::Source::File(path) = &face.source else {
            return;
        };
        let mut one = fontdb::Database::new();
        one.load_font_file(path)
            .expect("the face came from this file");
        let mut fs = FontSystem::new_with_locale_and_db("en-US".to_string(), one);
        let mut cache = SwashCache::new();
        let attrs = Attrs::new();
        let metrics = Metrics::new(26.0, 34.0);
        let mut boxes = shape(&mut fs, &mut cache, &attrs, metrics, "日本 🦀", 400.0);
        assert!(has_missing_glyph(&mut boxes));
        let mut plain = shape(&mut fs, &mut cache, &attrs, metrics, "Hello", 400.0);
        assert!(!has_missing_glyph(&mut plain));
    }

    #[test]
    fn ellipsizes_long_titles() {
        let mut fs = FontSystem::new();
        let attrs = Attrs::new();
        let metrics = Metrics::new(26.0, 34.0);
        let long = "a very long window title that certainly does not fit in one narrow cell";
        let out = ellipsize(&mut fs, &attrs, metrics, long, 200.0);
        assert!(out.ends_with('…'), "got {out:?}");
        assert!(out.chars().count() < long.chars().count());
        // Short text is left alone.
        assert_eq!(ellipsize(&mut fs, &attrs, metrics, "zsh", 200.0), "zsh");
    }
}