Coverage Report

Created: 2026-07-21 00:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/ui/views/models/chips.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
use cosmic::iced::Alignment;
3
use cosmic::iced_widget::row;
4
use cosmic::widget::{self, text};
5
use cosmic::{Apply, Element};
6
7
use crate::ui::icons;
8
use crate::ui::messages::Message;
9
10
use super::surface::muted_text_color;
11
12
/// Whether a backend's models are served by an online provider. Online
13
/// backends transmit audio to a third-party service, flagged in the UI.
14
0
pub(super) fn backend_is_online(backend: &crate::daemon::backends::BackendInfo) -> bool {
15
0
    backend
16
0
        .models
17
0
        .iter()
18
0
        .any(|m| m.supported_devices.iter().any(|d| d == "none"))
19
0
}
20
21
/// Whether any model this backend serves can run on a GPU (CUDA or Metal).
22
/// Drives the "GPU" capability chip on the backend card.
23
12
pub(super) fn backend_supports_gpu(backend: &crate::daemon::backends::BackendInfo) -> bool {
24
14
    
backend.models.iter()12
.
any12
(|m| {
25
14
        m.supported_devices
26
14
            .iter()
27
16
            .
any14
(|d| d == "cuda" ||
d == "metal"10
)
28
14
    })
29
12
}
30
31
/// Whether any model this backend serves can run on the CPU. Drives the
32
/// "CPU" capability chip on the backend card.
33
6
pub(super) fn backend_supports_cpu(backend: &crate::daemon::backends::BackendInfo) -> bool {
34
6
    backend
35
6
        .models
36
6
        .iter()
37
6
        .any(|m| m.supported_devices.iter().any(|d| d == "cpu"))
38
6
}
39
40
/// A small rounded "pill" advertising one backend capability — a tinted icon
41
/// and a short label over a soft, same-hue fill. `fg` is the full-strength
42
/// tone (icon, text, and border); the fill and border are derived from it at a
43
/// lower alpha so the chip reads as a tag, not a button.
44
0
pub(super) fn capability_chip(
45
0
    icon: &'static [u8],
46
0
    label: &'static str,
47
0
    fg: cosmic::iced::Color,
48
0
) -> Element<'static, Message> {
49
0
    let spacing = cosmic::theme::spacing();
50
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
51
0
    let mut fill = fg;
52
0
    fill.a = 0.14;
53
0
    let mut edge = fg;
54
0
    edge.a = 0.32;
55
56
0
    row![
57
0
        icons::phosphor_tinted(icon, 14.0, fg),
58
0
        text::caption(label).class(cosmic::theme::Text::Color(fg)),
59
    ]
60
0
    .spacing(spacing.space_xxxs)
61
0
    .align_y(Alignment::Center)
62
0
    .apply(widget::container)
63
0
    .padding([spacing.space_xxxs, spacing.space_xs])
64
0
    .class(cosmic::theme::Container::custom(move |_| {
65
0
        cosmic::iced_widget::container::Style {
66
0
            background: Some(cosmic::iced::Background::Color(fill)),
67
0
            border: cosmic::iced::Border {
68
0
                radius: radius.into(),
69
0
                width: 1.0,
70
0
                color: edge,
71
0
            },
72
0
            ..Default::default()
73
0
        }
74
0
    }))
75
0
    .into()
76
0
}
77
78
/// A neutral, text-only pill — same shape/tone as [`capability_chip`] but
79
/// without a leading glyph. Used for the active card's "N models" count.
80
0
pub(super) fn count_chip(label: String) -> Element<'static, Message> {
81
0
    let spacing = cosmic::theme::spacing();
82
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
83
0
    let fg: cosmic::iced::Color = cosmic::theme::active()
84
0
        .current_container()
85
0
        .component
86
0
        .on
87
0
        .into();
88
0
    let mut fill = fg;
89
0
    fill.a = 0.14;
90
0
    let mut edge = fg;
91
0
    edge.a = 0.32;
92
93
0
    text::caption(label)
94
0
        .class(cosmic::theme::Text::Color(fg))
95
0
        .apply(widget::container)
96
0
        .padding([spacing.space_xxxs, spacing.space_xs])
97
0
        .class(cosmic::theme::Container::custom(move |_| {
98
0
            cosmic::iced_widget::container::Style {
99
0
                background: Some(cosmic::iced::Background::Color(fill)),
100
0
                border: cosmic::iced::Border {
101
0
                    radius: radius.into(),
102
0
                    width: 1.0,
103
0
                    color: edge,
104
0
                },
105
0
                ..Default::default()
106
0
            }
107
0
        }))
108
0
        .into()
109
0
}
110
111
/// How many model names a card lists individually before the rest collapse
112
/// into a "+N" summary chip. Three keeps the inventory to a single line for
113
/// typical model-name lengths.
114
const MAX_MODEL_TAGS: usize = 3;
115
116
/// A quiet outline "tag" for one model name: hairline border, no fill, slightly
117
/// dimmed text, with the gentle `radius_s` corner so it reads as a catalog item
118
/// rather than a status pill (which the capability chips own, fully rounded).
119
0
pub(super) fn model_tag(name: String) -> Element<'static, Message> {
120
0
    let spacing = cosmic::theme::spacing();
121
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_s;
122
0
    let on: cosmic::iced::Color = cosmic::theme::active()
123
0
        .current_container()
124
0
        .component
125
0
        .on
126
0
        .into();
127
0
    let mut edge = on;
128
0
    edge.a = 0.28;
129
0
    let mut fg = on;
130
0
    fg.a = 0.85;
131
132
0
    text::caption(name)
133
0
        .class(cosmic::theme::Text::Color(fg))
134
0
        .apply(widget::container)
135
0
        .padding([spacing.space_xxxs, spacing.space_xs])
136
0
        .class(cosmic::theme::Container::custom(move |_| {
137
0
            cosmic::iced_widget::container::Style {
138
0
                border: cosmic::iced::Border {
139
0
                    radius: radius.into(),
140
0
                    width: 1.0,
141
0
                    color: edge,
142
0
                },
143
0
                ..Default::default()
144
0
            }
145
0
        }))
146
0
        .into()
147
0
}
148
149
/// A backend's model inventory: a muted "Models" label, the first
150
/// [`MAX_MODEL_TAGS`] model names as outline [`model_tag`]s, then a filled
151
/// "+N" [`count_chip`] summarizing the rest. `None` when the backend serves no
152
/// models, so the caller skips the row.
153
0
pub(super) fn models_inventory(names: &[String]) -> Option<Element<'static, Message>> {
154
0
    if names.is_empty() {
155
0
        return None;
156
0
    }
157
0
    let spacing = cosmic::theme::spacing();
158
0
    let muted = muted_text_color();
159
0
    let mut inventory = row![text::caption("Models").class(cosmic::theme::Text::Color(muted))]
160
0
        .spacing(spacing.space_xxs)
161
0
        .align_y(Alignment::Center);
162
0
    for name in names.iter().take(MAX_MODEL_TAGS) {
163
0
        inventory = inventory.push(model_tag(name.clone()));
164
0
    }
165
0
    let rest = names.len().saturating_sub(MAX_MODEL_TAGS);
166
0
    if rest > 0 {
167
0
        inventory = inventory.push(count_chip(format!("+{rest}")));
168
0
    }
169
0
    Some(inventory.into())
170
0
}
171
172
/// The Cloud capability chip: a [`capability_chip`] with a hover tooltip
173
/// listing the hosts the backend transmits audio to. Shares the GPU/CPU
174
/// chips' neutral tone so "runs in the cloud" reads as a plain capability,
175
/// not a golden/premium value judgment.
176
0
pub(super) fn cloud_chip(fg: cosmic::iced::Color, hosts: &[String]) -> Element<'static, Message> {
177
    use super::surface::rounded_tooltip;
178
0
    let chip = capability_chip(icons::CLOUD, "Cloud", fg);
179
0
    if hosts.is_empty() {
180
0
        return chip;
181
0
    }
182
0
    let mut popup = widget::column::with_capacity(hosts.len() + 1)
183
0
        .push(text::body("Transmits audio to:"))
184
0
        .spacing(cosmic::theme::spacing().space_xxxs);
185
0
    for host in hosts {
186
0
        popup = popup.push(text::body(format!("• {host}")));
187
0
    }
188
0
    rounded_tooltip(chip, popup, widget::tooltip::Position::Top)
189
0
}
190
191
/// The capability-chip row for a backend: GPU / CPU advertise local compute,
192
/// Cloud (when `online_hosts` is `Some`) flags an online backend. Returns
193
/// `None` when there's nothing to advertise, so callers skip the row rather
194
/// than render an empty band.
195
///
196
/// `tooltips` gates the hover popovers (GPU/CPU detail, Cloud host list). The
197
/// Library installed card passes `!menu_open`: while that card's "⋯" overflow
198
/// menu is open, its chips drop their tooltips so the menu renders cleanly on
199
/// top (libcosmic draws the open menu above a tooltip, so a tooltip showing at
200
/// the same time would paint half-behind it). With the menu closed — and on
201
/// the active-backend / Browse cards, which have no overflow menu — tooltips
202
/// show as normal.
203
// reason: "supports_gpu" / "supports_cpu" are the clearest names
204
#[allow(clippy::similar_names)]
205
0
pub(super) fn capability_chips(
206
0
    supports_gpu: bool,
207
0
    supports_cpu: bool,
208
0
    online_hosts: Option<&[String]>,
209
0
    tooltips: bool,
210
0
) -> Option<Element<'static, Message>> {
211
    use super::surface::rounded_tooltip;
212
0
    let theme = cosmic::theme::active();
213
0
    let neutral: cosmic::iced::Color = theme.current_container().component.on.into();
214
215
0
    let mut chips: Vec<Element<'static, Message>> = Vec::new();
216
0
    if supports_gpu {
217
0
        let chip = capability_chip(icons::GRAPHICS_CARD, "GPU", neutral);
218
0
        chips.push(if tooltips {
219
0
            rounded_tooltip(
220
0
                chip,
221
0
                text::body("Accelerated on GPU"),
222
0
                widget::tooltip::Position::Top,
223
            )
224
        } else {
225
0
            chip
226
        });
227
0
    }
228
0
    if supports_cpu {
229
0
        let chip = capability_chip(icons::CPU, "CPU", neutral);
230
0
        chips.push(if tooltips {
231
0
            rounded_tooltip(
232
0
                chip,
233
0
                text::body("Runs on the CPU"),
234
0
                widget::tooltip::Position::Top,
235
            )
236
        } else {
237
0
            chip
238
        });
239
0
    }
240
0
    if let Some(hosts) = online_hosts {
241
0
        chips.push(if tooltips {
242
0
            cloud_chip(neutral, hosts)
243
        } else {
244
0
            capability_chip(icons::CLOUD, "Cloud", neutral)
245
        });
246
0
    }
247
0
    if chips.is_empty() {
248
0
        return None;
249
0
    }
250
0
    Some(
251
0
        row(chips)
252
0
            .spacing(cosmic::theme::spacing().space_xxs)
253
0
            .align_y(Alignment::Center)
254
0
            .into(),
255
0
    )
256
0
}
257
258
/// A segmented control of mutually-exclusive filter options: a caption label
259
/// followed by chips butted together inside a single rounded "track", so the
260
/// group reads as one unified toggle rather than separate buttons. The active
261
/// chip is filled — accent by default, or a neutral surface when `neutral` is
262
/// set (used for the secondary "Format" filter); inactive chips are transparent
263
/// so the track shows through.
264
0
pub(super) fn chip_group(
265
0
    label: &str,
266
0
    neutral: bool,
267
0
    chips: Vec<(&'static str, bool, Message)>,
268
0
) -> Element<'static, Message> {
269
    use cosmic::widget::button;
270
0
    let spacing = cosmic::theme::spacing();
271
0
    let muted = muted_text_color();
272
273
    // Chips with no gap between them; the surrounding track supplies the inset.
274
0
    let mut segments = row![].spacing(0).align_y(Alignment::Center);
275
0
    for (chip_label, active, msg) in chips {
276
0
        let chip = if active {
277
0
            if neutral {
278
0
                button::standard(chip_label)
279
            } else {
280
0
                button::suggested(chip_label)
281
            }
282
        } else {
283
0
            button::text(chip_label)
284
        }
285
        // Match the font size (14) so the label's line box hugs the glyph and
286
        // sits vertically centered, rather than floating high in the stock 20px
287
        // line box. Same centering technique `pill_label` uses (`line_height(1.0)`
288
        // = 1.0 × 14px); these stay regular buttons, not pills.
289
0
        .line_height(14)
290
0
        .padding([spacing.space_xxs, spacing.space_s])
291
0
        .on_press(msg);
292
0
        segments = segments.push(chip);
293
    }
294
295
    // The track: a surface-filled, hairline-bordered, pill-rounded container
296
    // with a small inset so the active chip visually sits within it.
297
0
    let track = widget::container(segments)
298
0
        .padding(3)
299
0
        .class(cosmic::theme::Container::custom(
300
            super::surface::pill_surface,
301
        ));
302
303
0
    row![
304
0
        text::caption(label.to_uppercase()).class(cosmic::theme::Text::Color(muted)),
305
0
        track
306
    ]
307
0
    .spacing(spacing.space_xs)
308
0
    .align_y(Alignment::Center)
309
0
    .into()
310
0
}
311
312
/// The "{shown} backends found" result-count caption above the filter chips.
313
0
pub(super) fn result_count<'a>(shown: usize) -> Element<'a, Message> {
314
0
    let muted = muted_text_color();
315
0
    let label = format!("{shown} backends found");
316
0
    text::caption(label)
317
0
        .class(cosmic::theme::Text::Color(muted))
318
0
        .into()
319
0
}
320
321
/// One unmet-requirement row: a destructive-colored warning glyph and the
322
/// message `"{label} must be set."`. The active-backend card is the obvious
323
/// source of the constraint, so the message stays short — no backend name,
324
/// no internal identifier. Only the icon is tinted red; the text uses the
325
/// default body color so the row reads cleanly. Non-dismissible: this row
326
/// disappears the moment the requirement is satisfied (no click required).
327
0
pub(super) fn requirement_warning(label: &str) -> Element<'_, Message> {
328
0
    row![
329
0
        icons::phosphor_destructive(icons::WARNING, 16.0),
330
0
        text::body(format!("{label} must be set.")),
331
    ]
332
0
    .spacing(cosmic::theme::spacing().space_xs)
333
0
    .align_y(Alignment::Center)
334
0
    .into()
335
0
}
336
337
#[cfg(test)]
338
mod capability_tests {
339
    //! Pin the device→capability mapping behind the GPU/CPU chips: `cuda` and
340
    //! `metal` both count as GPU, `cpu` as CPU, and the online sentinel `none`
341
    //! as neither. A backend aggregates the capability across every model it
342
    //! serves, so one GPU model and one CPU model surface both chips.
343
    use super::*;
344
    use crate::daemon::backends::{BackendInfo, BackendModel};
345
346
    /// Build a backend whose models declare the given device lists.
347
12
    fn backend_with_devices(per_model: &[&[&str]]) -> BackendInfo {
348
        BackendInfo {
349
12
            source: "github.com/super-stt/test".to_string(),
350
12
            name: "Test".to_string(),
351
12
            kind: "subprocess".to_string(),
352
12
            allowed_hosts: Vec::new(),
353
12
            models: per_model
354
12
                .iter()
355
12
                .enumerate()
356
12
                .map(|(i, devices)| BackendModel {
357
14
                    name: format!("m{i}"),
358
14
                    provider: "local_test".to_string(),
359
16
                    supported_devices: 
devices14
.
iter14
().
map14
(|s| (*s).to_string()).
collect14
(),
360
                    estimated_vram_bytes: 0,
361
                    multilingual: false,
362
14
                    supported_languages: Vec::new(),
363
14
                    primary_language: String::new(),
364
                    realtime: false,
365
14
                })
366
12
                .collect(),
367
12
            secrets: Vec::new(),
368
12
            options: Vec::new(),
369
        }
370
12
    }
371
372
    /// Both GPU backends (`cuda`, `metal`) surface the GPU chip, including
373
    /// when paired with `cpu` in the same model's device list.
374
    #[test]
375
2
    fn cuda_and_metal_count_as_gpu() {
376
2
        assert!(backend_supports_gpu(&backend_with_devices(&[&["cuda"]])));
377
2
        assert!(backend_supports_gpu(&backend_with_devices(&[&["metal"]])));
378
2
        assert!(backend_supports_gpu(&backend_with_devices(&[&[
379
2
            "cpu", "cuda"
380
2
        ]])));
381
2
    }
382
383
    /// A `cpu`-only model is CPU-capable and not GPU-capable.
384
    #[test]
385
2
    fn cpu_only_is_cpu_not_gpu() {
386
2
        let b = backend_with_devices(&[&["cpu"]]);
387
2
        assert!(backend_supports_cpu(&b));
388
2
        assert!(!backend_supports_gpu(&b));
389
2
    }
390
391
    /// The online sentinel `none` advertises no local compute at all — its
392
    /// card shows the Cloud chip instead (driven separately by online-ness).
393
    #[test]
394
2
    fn online_sentinel_is_neither() {
395
2
        let b = backend_with_devices(&[&["none"]]);
396
2
        assert!(!backend_supports_gpu(&b));
397
2
        assert!(!backend_supports_cpu(&b));
398
2
    }
399
400
    /// Capability is the union across a backend's models: a CPU-only model
401
    /// plus a GPU-only model yields both chips.
402
    #[test]
403
2
    fn capability_aggregates_across_models() {
404
2
        let b = backend_with_devices(&[&["cpu"], &["cuda"]]);
405
2
        assert!(backend_supports_gpu(&b));
406
2
        assert!(backend_supports_cpu(&b));
407
2
    }
408
}