Coverage Report

Created: 2026-10-23 22:23

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::state::registry::RoleFilter;
8
use crate::ui::icons;
9
use crate::ui::messages::Message;
10
11
use super::surface::muted_text_color;
12
13
/// The version an update should offer, or `None` for no update.
14
///
15
/// Whether an update exists is the daemon's answer, read from
16
/// `update_available`: it is the side that reads the installed manifest off
17
/// disk and owns the index, so the comparison lives there and no client
18
/// re-derives it. This only decides whether to *show* that answer.
19
///
20
/// Withheld while an install is in flight for this backend: the chip would
21
/// otherwise stay clickable during its own update, and every further click
22
/// would reach a daemon that has nothing left to do.
23
///
24
/// The flag rides on the registry catalog, not the backends list, so it goes
25
/// stale unless that catalog is refetched — which is what left an update
26
/// offered after the update it describes had already happened.
27
6
pub(super) fn update_offer(
28
6
    entry: Option<&super_stt_shared::registry::RegistryBackend>,
29
6
    in_flight: bool,
30
6
) -> Option<String> {
31
6
    if in_flight {
32
1
        return None;
33
5
    }
34
5
    let 
e4
= entry
?1
;
35
4
    e.update_available.then(|| 
e.version1
.
clone1
())
36
6
}
37
38
/// Accent chip marking a backend with a newer version — and the control that
39
/// applies it.
40
///
41
/// Shaped like the capability chips beside it but accent-colored and clickable,
42
/// because unlike them it reports something the user can act on. Being the
43
/// action as well as the sign is what lets it work on the Models page, whose
44
/// card carries no other route to an update.
45
///
46
/// `tooltips` is off while a card's overflow menu is open, for the same reason
47
/// the capability chips suppress theirs: a tooltip would paint half-behind the
48
/// menu.
49
0
pub(super) fn update_chip(
50
0
    source: &str,
51
0
    version: &str,
52
0
    tooltips: bool,
53
0
) -> Element<'static, Message> {
54
0
    let spacing = cosmic::theme::spacing();
55
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
56
0
    let fg: cosmic::iced::Color = cosmic::theme::active().cosmic().accent.base.into();
57
58
0
    let chip = widget::button::custom(
59
0
        row![
60
0
            icons::phosphor_tinted(icons::ARROWS_CLOCKWISE, 14.0, fg),
61
0
            text::caption("Update").class(cosmic::theme::Text::Color(fg)),
62
        ]
63
0
        .spacing(spacing.space_xxxs)
64
0
        .align_y(Alignment::Center),
65
    )
66
0
    .padding([spacing.space_xxxs, spacing.space_xs])
67
0
    .class(super::surface::accent_button_class(radius))
68
0
    .on_press(Message::ModelsPage(
69
0
        crate::ui::messages::ModelsPageMessage::UpdateBackend(source.to_string()),
70
0
    ))
71
0
    .into();
72
73
0
    if tooltips {
74
0
        super::surface::rounded_tooltip(
75
0
            chip,
76
0
            text::body(format!("Update to {version}")),
77
0
            widget::tooltip::Position::Top,
78
        )
79
    } else {
80
0
        chip
81
    }
82
0
}
83
84
/// The update chip's in-flight form: same shape, muted, and inert.
85
///
86
/// Reads the `InstallStatus` a Browse install reports on — an update *is* an
87
/// install — so the phase and percentage mean what they mean there. It replaces
88
/// the update chip in place on both cards, so the control the user pressed
89
/// becomes the progress they are waiting on rather than disappearing.
90
0
pub(super) fn update_progress_chip(
91
0
    s: &crate::state::registry::InstallStatus,
92
0
) -> Element<'static, Message> {
93
0
    let label = match (&s.error, s.bytes_total) {
94
0
        (Some(_), _) => "Update failed".to_string(),
95
0
        (None, Some(total)) if total > 0 => {
96
0
            format!("Updating\u{2026} {}%", (s.bytes_done * 100) / total)
97
        }
98
0
        _ => format!(
99
            "Updating\u{2026} ({})",
100
0
            super::download::phase_label(s.phase)
101
        ),
102
    };
103
0
    let fg = muted_text_color();
104
0
    let chip = inert_chip(icons::ARROWS_CLOCKWISE, label, fg);
105
0
    match &s.error {
106
        // The reason is too long for the chip and too important to drop.
107
0
        Some(err) => super::surface::rounded_tooltip(
108
0
            chip,
109
0
            text::body(format!("{err}")),
110
0
            widget::tooltip::Position::Top,
111
        ),
112
0
        None => chip,
113
    }
114
0
}
115
116
/// Whether a backend's models are served by an online provider. Online
117
/// backends transmit audio to a third-party service, flagged in the UI.
118
0
pub(super) fn backend_is_online(backend: &crate::daemon::backends::BackendInfo) -> bool {
119
0
    backend
120
0
        .models
121
0
        .iter()
122
0
        .any(|m| m.supported_devices.iter().any(|d| d == "none"))
123
0
}
124
125
/// Whether this backend's *installed build* can run models on a GPU. Drives
126
/// the "GPU" capability chip on the backend card.
127
///
128
/// Reads `installed_accel` — the accel of the asset actually on disk — rather
129
/// than the manifest alone: a CUDA-only backend installed on an AMD host
130
/// lands on its CPU asset, and the chip must not claim a capability that
131
/// asset does not have. An empty `installed_accel` means no record (a
132
/// local-directory import, or an install predating it), so this falls back
133
/// to the models' declared `supported_devices`.
134
10
pub(super) fn backend_supports_gpu(backend: &crate::daemon::backends::BackendInfo) -> bool {
135
10
    if !backend.installed_accel.is_empty() {
136
4
        return backend.installed_accel.iter().any(|a| a != "cpu");
137
6
    }
138
7
    
backend.models.iter()6
.
any6
(|m| {
139
7
        m.supported_devices
140
7
            .iter()
141
8
            .
any7
(|d| d == "cuda" ||
d == "metal"5
||
d == "gpu"4
)
142
7
    })
143
10
}
144
145
/// What a stage's backend can run its models on, as the capability chips read
146
/// it.
147
#[derive(Debug, PartialEq, Eq)]
148
pub(super) struct StageCompute {
149
    pub gpu: bool,
150
    pub cpu: bool,
151
}
152
153
/// What a stage's backend can run its models on.
154
///
155
/// `offered` is the daemon's own answer for the stage's selected backend —
156
/// `/pipeline/{stage}/device/list`, already scoped to the models this stage
157
/// would run and narrowed to what the install and host can do. Every other
158
/// card (the Library's, the picker sheet's) is for a backend no stage has
159
/// selected, which the daemon has no verb for, so those read the catalog.
160
5
pub(super) fn stage_device_support(
161
5
    offered: Option<&[String]>,
162
5
    backend: &crate::daemon::backends::BackendInfo,
163
5
) -> StageCompute {
164
5
    match offered {
165
3
        Some(devices) => StageCompute {
166
3
            gpu: devices.iter().any(|d| d == "gpu"),
167
3
            cpu: devices.iter().any(|d| 
d2
==
"cpu"2
),
168
        },
169
2
        None => StageCompute {
170
2
            gpu: backend_supports_gpu(backend),
171
2
            cpu: backend_supports_cpu(backend),
172
2
        },
173
    }
174
5
}
175
176
/// Whether `model` is the online sentinel (`supported_devices == ["none"]`)
177
/// — genuinely no local device to pick, ever.
178
///
179
/// The daemon's device list is empty for this case — but an empty list means
180
/// two different things: this one (nothing to pick because there is nothing
181
/// local to run), or a local model this specific install cannot run on *any*
182
/// device (e.g. a GPU-only model with only a CPU asset installed). A caller
183
/// deciding whether to enable a "Load" action must not conflate the two: only
184
/// this one needs no device at all. Returns `false` for an unknown model.
185
4
pub(super) fn model_is_online(backend: &crate::daemon::backends::BackendInfo, model: &str) -> bool {
186
4
    backend
187
4
        .models
188
4
        .iter()
189
4
        .find(|m| m.name == model)
190
4
        .is_some_and(|m| 
m.supported_devices.iter()3
.
any3
(|d| d == "none"))
191
4
}
192
193
/// Whether any model this backend serves can run on the CPU. Drives the
194
/// "CPU" capability chip on the backend card.
195
5
pub(super) fn backend_supports_cpu(backend: &crate::daemon::backends::BackendInfo) -> bool {
196
5
    backend
197
5
        .models
198
5
        .iter()
199
5
        .any(|m| m.supported_devices.iter().any(|d| d == "cpu"))
200
5
}
201
202
/// Whether the user pointed this backend at an endpoint of their own — a
203
/// `base_url` option carrying a value. That value is egress the backend's
204
/// `allowed_hosts` does not describe, so the Cloud chip has to account for it;
205
/// the address itself stays out of the card (see [`cloud_chip`]).
206
///
207
/// Keyed on the value being present, which is the daemon's own rule: it
208
/// authorizes whatever the override holds, whether or not that happens to equal
209
/// a `default` some older daemon still reports. Testing against `default` would
210
/// hide the line for a user who set the endpoint to the same string, on a card
211
/// whose whole job is disclosing where audio goes.
212
5
pub(super) fn backend_has_user_url(backend: &crate::daemon::backends::BackendInfo) -> bool {
213
5
    backend.options.iter().any(|o| 
{4
214
4
        o.name == super_stt_registry_types::manifest::BASE_URL_OPTION
215
4
            && o.value.as_ref().is_some_and(|v| !
v.trim()3
.
is_empty3
())
216
4
    })
217
5
}
218
219
/// A small rounded "pill" advertising one backend capability — a tinted icon
220
/// and a short label over a soft, same-hue fill. `fg` is the full-strength
221
/// tone (icon, text, and border); the fill and border are derived from it at a
222
/// lower alpha so the chip reads as a tag, not a button.
223
0
pub(super) fn capability_chip(
224
0
    icon: &'static [u8],
225
0
    label: &'static str,
226
0
    fg: cosmic::iced::Color,
227
0
) -> Element<'static, Message> {
228
0
    inert_chip(icon, label.to_string(), fg)
229
0
}
230
231
/// The chip shape itself, for a label only known at runtime. `capability_chip`
232
/// is the fixed-label form; both render identically so a chip built from a
233
/// progress percentage sits in a row of capability chips without looking
234
/// foreign.
235
0
pub(super) fn inert_chip(
236
0
    icon: &'static [u8],
237
0
    label: String,
238
0
    fg: cosmic::iced::Color,
239
0
) -> Element<'static, Message> {
240
0
    let spacing = cosmic::theme::spacing();
241
0
    chip_shell(
242
0
        row![
243
0
            icons::phosphor_tinted(icon, 14.0, fg),
244
0
            text::caption(label).class(cosmic::theme::Text::Color(fg)),
245
        ]
246
0
        .spacing(spacing.space_xxxs)
247
0
        .align_y(Alignment::Center),
248
0
        fg,
249
0
        spacing.space_xs,
250
    )
251
0
}
252
253
/// The pill itself: soft same-hue fill, hairline border, pill radius. Shared so
254
/// a chip with a glyph alone cannot drift from one carrying a label.
255
0
fn chip_shell<'a>(
256
0
    content: impl Into<Element<'a, Message>>,
257
0
    fg: cosmic::iced::Color,
258
0
    h_padding: u16,
259
0
) -> Element<'a, Message> {
260
0
    let spacing = cosmic::theme::spacing();
261
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
262
0
    let mut fill = fg;
263
0
    fill.a = 0.14;
264
0
    let mut edge = fg;
265
0
    edge.a = 0.32;
266
267
0
    content
268
0
        .into()
269
0
        .apply(widget::container)
270
0
        .padding([spacing.space_xxxs, h_padding])
271
0
        .class(cosmic::theme::Container::custom(move |_| {
272
0
            cosmic::iced::widget::container::Style {
273
0
                background: Some(cosmic::iced::Background::Color(fill)),
274
0
                border: cosmic::iced::Border {
275
0
                    radius: radius.into(),
276
0
                    width: 1.0,
277
0
                    color: edge,
278
0
                },
279
0
                ..Default::default()
280
0
            }
281
0
        }))
282
0
        .into()
283
0
}
284
285
/// One chip per device a model runs on, glyph only, in the order the
286
/// backend-level [`capability_chips`] use.
287
///
288
/// Every device the model declares, not the first one that matches: a Whisper
289
/// model runs on GPU *and* CPU, and collapsing that to one badge told the
290
/// reader the wrong thing about where it can run.
291
///
292
/// The glyph carries it without a word. A model list is read down the names,
293
/// and nine rows each repeating "GPU" beside a graphics-card icon is nine
294
/// copies of what the icon already said. The tooltip keeps the word reachable.
295
0
pub(super) fn model_device_chips(devices: &[String]) -> Vec<Element<'static, Message>> {
296
    use super::surface::rounded_tooltip;
297
0
    let spacing = cosmic::theme::spacing();
298
0
    let fg: cosmic::iced::Color = cosmic::theme::active()
299
0
        .current_container()
300
0
        .component
301
0
        .on
302
0
        .into();
303
0
    let has = |want: &str| devices.iter().any(|d| d == want);
304
305
0
    [
306
0
        (has("gpu"), icons::GRAPHICS_CARD, "Accelerated on GPU"),
307
0
        (has("cpu"), icons::CPU, "Runs on the CPU"),
308
0
        (has("none"), icons::CLOUD, "Runs in the cloud"),
309
0
    ]
310
0
    .into_iter()
311
0
    .filter(|(present, _, _)| *present)
312
0
    .map(|(_, glyph, tip)| {
313
0
        rounded_tooltip(
314
0
            chip_shell(
315
0
                icons::phosphor_tinted(glyph, 14.0, fg),
316
0
                fg,
317
0
                spacing.space_xxs,
318
            ),
319
0
            text::body(tip),
320
0
            widget::tooltip::Position::Top,
321
        )
322
0
    })
323
0
    .collect()
324
0
}
325
326
/// A neutral, text-only pill — same shape/tone as [`capability_chip`] but
327
/// without a leading glyph. Used for the active card's "N models" count.
328
0
pub(super) fn count_chip(label: String) -> Element<'static, Message> {
329
0
    let spacing = cosmic::theme::spacing();
330
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
331
0
    let fg: cosmic::iced::Color = cosmic::theme::active()
332
0
        .current_container()
333
0
        .component
334
0
        .on
335
0
        .into();
336
0
    let mut fill = fg;
337
0
    fill.a = 0.14;
338
0
    let mut edge = fg;
339
0
    edge.a = 0.32;
340
341
0
    text::caption(label)
342
0
        .class(cosmic::theme::Text::Color(fg))
343
0
        .apply(widget::container)
344
0
        .padding([spacing.space_xxxs, spacing.space_xs])
345
0
        .class(cosmic::theme::Container::custom(move |_| {
346
0
            cosmic::iced::widget::container::Style {
347
0
                background: Some(cosmic::iced::Background::Color(fill)),
348
0
                border: cosmic::iced::Border {
349
0
                    radius: radius.into(),
350
0
                    width: 1.0,
351
0
                    color: edge,
352
0
                },
353
0
                ..Default::default()
354
0
            }
355
0
        }))
356
0
        .into()
357
0
}
358
359
/// How many model names a card lists individually before the rest collapse
360
/// into a "+N" summary chip. Three keeps the inventory to a single line for
361
/// typical model-name lengths.
362
const MAX_MODEL_TAGS: usize = 3;
363
364
/// A quiet outline "tag" for one model name: hairline border, no fill, slightly
365
/// dimmed text, with the gentle `radius_s` corner so it reads as a catalog item
366
/// rather than a status pill (which the capability chips own, fully rounded).
367
0
pub(super) fn model_tag(name: String) -> Element<'static, Message> {
368
0
    let spacing = cosmic::theme::spacing();
369
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_s;
370
0
    let on: cosmic::iced::Color = cosmic::theme::active()
371
0
        .current_container()
372
0
        .component
373
0
        .on
374
0
        .into();
375
0
    let mut edge = on;
376
0
    edge.a = 0.28;
377
0
    let mut fg = on;
378
0
    fg.a = 0.85;
379
380
0
    text::caption(name)
381
0
        .class(cosmic::theme::Text::Color(fg))
382
0
        .apply(widget::container)
383
0
        .padding([spacing.space_xxxs, spacing.space_xs])
384
0
        .class(cosmic::theme::Container::custom(move |_| {
385
0
            cosmic::iced::widget::container::Style {
386
0
                border: cosmic::iced::Border {
387
0
                    radius: radius.into(),
388
0
                    width: 1.0,
389
0
                    color: edge,
390
0
                },
391
0
                ..Default::default()
392
0
            }
393
0
        }))
394
0
        .into()
395
0
}
396
397
/// The models a backend ships, split by what they are for.
398
///
399
/// A backend may serve both kinds from one install — a weights bundle that
400
/// transcribes *and* cleans up — and the Library cards say so, since which
401
/// kinds you get is the first thing that decides whether a backend is the one
402
/// you want.
403
pub(super) struct RoleGroup {
404
    /// Human label for the role, e.g. `"Speech to text"`.
405
    pub label: &'static str,
406
    /// The model names in this group, in catalog order.
407
    pub names: Vec<String>,
408
}
409
410
/// How a model `role` reads on a card.
411
///
412
/// An unknown role — one a newer backend declares and this build has no name
413
/// for — reads as speech-to-text, matching the wire default and the daemon's
414
/// own reading of a missing value.
415
0
pub(super) fn role_label(role: &str) -> &'static str {
416
0
    match role {
417
0
        "post_processor" => "Post-processing",
418
0
        _ => "Speech to text",
419
    }
420
0
}
421
422
/// Group `(name, role)` pairs by role, transcription first.
423
///
424
/// The order is fixed rather than catalog-derived so two backends with the same
425
/// kinds present them the same way round. Empty groups are dropped, so a
426
/// single-role backend yields exactly one.
427
0
pub(super) fn role_groups<'a>(models: impl Iterator<Item = (&'a str, &'a str)>) -> Vec<RoleGroup> {
428
0
    let mut transcription = Vec::new();
429
0
    let mut post_processing = Vec::new();
430
0
    for (name, role) in models {
431
0
        if role_label(role) == "Post-processing" {
432
0
            post_processing.push(name.to_string());
433
0
        } else {
434
0
            transcription.push(name.to_string());
435
0
        }
436
    }
437
0
    [
438
0
        RoleGroup {
439
0
            label: "Speech to text",
440
0
            names: transcription,
441
0
        },
442
0
        RoleGroup {
443
0
            label: "Post-processing",
444
0
            names: post_processing,
445
0
        },
446
0
    ]
447
0
    .into_iter()
448
0
    .filter(|g| !g.names.is_empty())
449
0
    .collect()
450
0
}
451
452
/// A backend's model inventory: one row per kind of model it ships — a muted
453
/// role label, the first [`MAX_MODEL_TAGS`] names as outline [`model_tag`]s,
454
/// then a filled "+N" [`count_chip`] for the rest. `None` when the backend
455
/// serves no models, so the caller skips the row.
456
///
457
/// The label names the kind rather than saying "Models", so a card answers
458
/// "what does this ship?" without the user opening it.
459
///
460
/// Each row wraps rather than squeezes. It shares its line with the capability
461
/// chips, and three long names beside a GPU *and* a CPU chip are wider than a
462
/// card at the default window size: a plain row then breaks a tag's name
463
/// across two lines inside its pill and crushes the "+N" chip to a sliver.
464
/// Super TTS's Qwen cards hit it first. No backend here pairs three long names
465
/// with two device chips yet, but nothing stops one doing so. Wrapping moves
466
/// the tags that do not fit to a second line, whole.
467
0
pub(super) fn models_inventory(groups: &[RoleGroup]) -> Option<Element<'static, Message>> {
468
0
    if groups.is_empty() {
469
0
        return None;
470
0
    }
471
0
    let spacing = cosmic::theme::spacing();
472
0
    let muted = muted_text_color();
473
0
    let rows: Vec<Element<'static, Message>> = groups
474
0
        .iter()
475
0
        .map(|group| {
476
0
            let mut inventory =
477
0
                row![text::caption(group.label).class(cosmic::theme::Text::Color(muted))]
478
0
                    .spacing(spacing.space_xxs)
479
0
                    .align_y(Alignment::Center);
480
0
            for name in group.names.iter().take(MAX_MODEL_TAGS) {
481
0
                inventory = inventory.push(model_tag(name.clone()));
482
0
            }
483
0
            let rest = group.names.len().saturating_sub(MAX_MODEL_TAGS);
484
0
            if rest > 0 {
485
0
                inventory = inventory.push(count_chip(format!("+{rest}")));
486
0
            }
487
0
            inventory.wrap().vertical_spacing(spacing.space_xxs).into()
488
0
        })
489
0
        .collect();
490
0
    Some(
491
0
        cosmic::iced::widget::column(rows)
492
0
            .spacing(spacing.space_xxs)
493
0
            .into(),
494
0
    )
495
0
}
496
497
/// Where an online backend sends audio, as a card can describe it: the hosts
498
/// its `backend.toml` declares, plus whether the user pointed it at an endpoint
499
/// of their own.
500
#[derive(Clone, Copy)]
501
pub(super) struct CloudEgress<'a> {
502
    /// The backend's declared `[network].allowed_hosts`.
503
    pub hosts: &'a [String],
504
    /// Whether a `base_url` the user set adds an endpoint beyond `hosts`.
505
    pub user_url: bool,
506
}
507
508
/// The Cloud capability chip: a [`capability_chip`] with a hover tooltip
509
/// listing the hosts the backend transmits audio to. Shares the GPU/CPU
510
/// chips' neutral tone so "runs in the cloud" reads as a plain capability,
511
/// not a golden/premium value judgment.
512
///
513
/// A user-set `base_url` is named as a line, never as the address: it is the
514
/// user's own value, and printing it would put a configured endpoint on a card
515
/// they may be showing someone.
516
0
pub(super) fn cloud_chip(
517
0
    fg: cosmic::iced::Color,
518
0
    egress: CloudEgress<'_>,
519
0
) -> Element<'static, Message> {
520
    use super::surface::rounded_tooltip;
521
0
    let chip = capability_chip(icons::CLOUD, "Cloud", fg);
522
0
    if egress.hosts.is_empty() && !egress.user_url {
523
0
        return chip;
524
0
    }
525
0
    let mut popup = widget::column::with_capacity(egress.hosts.len() + 2)
526
0
        .push(text::body("Transmits audio to:"))
527
0
        .spacing(cosmic::theme::spacing().space_xxxs);
528
0
    for host in egress.hosts {
529
0
        popup = popup.push(text::body(format!("• {host}")));
530
0
    }
531
0
    if egress.user_url {
532
0
        popup = popup.push(text::body("• another URL you set"));
533
0
    }
534
0
    rounded_tooltip(chip, popup, widget::tooltip::Position::Top)
535
0
}
536
537
/// The capability-chip row for a backend: GPU / CPU advertise local compute,
538
/// Cloud (when `online` is `Some`) flags an online backend. Returns
539
/// `None` when there's nothing to advertise, so callers skip the row rather
540
/// than render an empty band.
541
///
542
/// `tooltips` gates the hover popovers (GPU/CPU detail, Cloud host list). The
543
/// Library installed card passes `!menu_open`: while that card's "⋯" overflow
544
/// menu is open, its chips drop their tooltips so the menu renders cleanly on
545
/// top (libcosmic draws the open menu above a tooltip, so a tooltip showing at
546
/// the same time would paint half-behind it). With the menu closed — and on
547
/// the active-backend / Browse cards, which have no overflow menu — tooltips
548
/// show as normal.
549
// reason: "supports_gpu" / "supports_cpu" are the clearest names
550
#[allow(clippy::similar_names)]
551
0
pub(super) fn capability_chips(
552
0
    supports_gpu: bool,
553
0
    supports_cpu: bool,
554
0
    online: Option<CloudEgress<'_>>,
555
0
    tooltips: bool,
556
0
) -> Option<Element<'static, Message>> {
557
    use super::surface::rounded_tooltip;
558
0
    let theme = cosmic::theme::active();
559
0
    let neutral: cosmic::iced::Color = theme.current_container().component.on.into();
560
561
0
    let mut chips: Vec<Element<'static, Message>> = Vec::new();
562
0
    if supports_gpu {
563
0
        let chip = capability_chip(icons::GRAPHICS_CARD, "GPU", neutral);
564
0
        chips.push(if tooltips {
565
0
            rounded_tooltip(
566
0
                chip,
567
0
                text::body("Accelerated on GPU"),
568
0
                widget::tooltip::Position::Top,
569
            )
570
        } else {
571
0
            chip
572
        });
573
0
    }
574
0
    if supports_cpu {
575
0
        let chip = capability_chip(icons::CPU, "CPU", neutral);
576
0
        chips.push(if tooltips {
577
0
            rounded_tooltip(
578
0
                chip,
579
0
                text::body("Runs on the CPU"),
580
0
                widget::tooltip::Position::Top,
581
            )
582
        } else {
583
0
            chip
584
        });
585
0
    }
586
0
    if let Some(egress) = online {
587
0
        chips.push(if tooltips {
588
0
            cloud_chip(neutral, egress)
589
        } else {
590
0
            capability_chip(icons::CLOUD, "Cloud", neutral)
591
        });
592
0
    }
593
0
    if chips.is_empty() {
594
0
        return None;
595
0
    }
596
0
    Some(
597
0
        row(chips)
598
0
            .spacing(cosmic::theme::spacing().space_xxs)
599
0
            .align_y(Alignment::Center)
600
0
            .into(),
601
0
    )
602
0
}
603
604
/// A segmented control of mutually-exclusive filter options: a caption label
605
/// followed by chips butted together inside a single rounded "track", so the
606
/// group reads as one unified toggle rather than separate buttons. The active
607
/// chip is filled — accent by default, or a neutral surface when `neutral` is
608
/// set (used for the secondary "Format" filter); inactive chips are transparent
609
/// so the track shows through.
610
/// The "Kind" segmented filter: which stage's models a backend must serve.
611
///
612
/// Both Library tabs render it from here, so the wording and the order of the
613
/// choices cannot drift between Installed and Browse.
614
0
pub(super) fn role_filter_chips(
615
0
    current: RoleFilter,
616
0
    on_pick: fn(RoleFilter) -> Message,
617
0
) -> Element<'static, Message> {
618
0
    chip_group(
619
0
        "Kind",
620
        false,
621
0
        vec![
622
0
            ("All", current == RoleFilter::All, on_pick(RoleFilter::All)),
623
0
            (
624
0
                "Transcription",
625
0
                current == RoleFilter::Transcription,
626
0
                on_pick(RoleFilter::Transcription),
627
0
            ),
628
0
            (
629
0
                "Post-processing",
630
0
                current == RoleFilter::PostProcessing,
631
0
                on_pick(RoleFilter::PostProcessing),
632
0
            ),
633
        ],
634
    )
635
0
}
636
637
/// The "Runs on" segmented filter: local models, cloud models, or both.
638
///
639
/// Shared for the same reason [`role_filter_chips`] is — the Installed tab
640
/// gained this filter after Browse had it, and two copies would drift.
641
0
pub(super) fn runs_on_chips(
642
0
    current: Option<bool>,
643
0
    on_pick: fn(Option<bool>) -> Message,
644
0
) -> Element<'static, Message> {
645
0
    chip_group(
646
0
        "Runs on",
647
        false,
648
0
        vec![
649
0
            ("All", current.is_none(), on_pick(None)),
650
0
            ("Local", current == Some(false), on_pick(Some(false))),
651
0
            ("Cloud", current == Some(true), on_pick(Some(true))),
652
        ],
653
    )
654
0
}
655
656
0
pub(super) fn chip_group(
657
0
    label: &str,
658
0
    neutral: bool,
659
0
    chips: Vec<(&'static str, bool, Message)>,
660
0
) -> Element<'static, Message> {
661
    use cosmic::widget::button;
662
0
    let spacing = cosmic::theme::spacing();
663
0
    let muted = muted_text_color();
664
665
    // Chips with no gap between them; the surrounding track supplies the inset.
666
0
    let mut segments = row![].spacing(0).align_y(Alignment::Center);
667
0
    for (chip_label, active, msg) in chips {
668
0
        let chip = if active {
669
0
            if neutral {
670
0
                button::standard(chip_label)
671
            } else {
672
0
                button::suggested(chip_label)
673
            }
674
        } else {
675
0
            button::text(chip_label)
676
        }
677
        // Match the font size (14) so the label's line box hugs the glyph and
678
        // sits vertically centered, rather than floating high in the stock 20px
679
        // line box. Same centering technique `pill_label` uses (`line_height(1.0)`
680
        // = 1.0 × 14px); these stay regular buttons, not pills.
681
0
        .line_height(14)
682
0
        .padding([spacing.space_xxs, spacing.space_s])
683
0
        .on_press(msg);
684
0
        segments = segments.push(chip);
685
    }
686
687
    // The track: a surface-filled, hairline-bordered, pill-rounded container
688
    // with a small inset so the active chip visually sits within it.
689
0
    let track = widget::container(segments)
690
0
        .padding(3)
691
0
        .class(cosmic::theme::Container::custom(
692
            super::surface::pill_surface,
693
        ));
694
695
0
    row![
696
0
        text::caption(label.to_uppercase()).class(cosmic::theme::Text::Color(muted)),
697
0
        track
698
    ]
699
0
    .spacing(spacing.space_xs)
700
0
    .align_y(Alignment::Center)
701
0
    .into()
702
0
}
703
704
/// The "{shown} backends found" result-count caption above the filter chips,
705
/// singular at one.
706
0
pub(super) fn result_count<'a>(shown: usize) -> Element<'a, Message> {
707
0
    let muted = muted_text_color();
708
0
    let label = if shown == 1 {
709
0
        "1 backend found".to_string()
710
    } else {
711
0
        format!("{shown} backends found")
712
    };
713
0
    text::caption(label)
714
0
        .class(cosmic::theme::Text::Color(muted))
715
0
        .into()
716
0
}
717
718
/// One unmet-requirement row: a destructive-colored warning glyph and the
719
/// message `"{label} must be set."`. The active-backend card is the obvious
720
/// source of the constraint, so the message stays short — no backend name,
721
/// no internal identifier. Only the icon is tinted red; the text uses the
722
/// default body color so the row reads cleanly. Non-dismissible: this row
723
/// disappears the moment the requirement is satisfied (no click required).
724
0
pub(super) fn requirement_warning(label: &str) -> Element<'_, Message> {
725
0
    row![
726
0
        icons::phosphor_destructive(icons::WARNING, 16.0),
727
0
        text::body(format!("{label} must be set.")),
728
    ]
729
0
    .spacing(cosmic::theme::spacing().space_xs)
730
0
    .align_y(Alignment::Center)
731
0
    .into()
732
0
}
733
734
#[cfg(test)]
735
mod capability_tests {
736
    //! Pin the device→capability mapping behind the GPU/CPU chips and the
737
    //! device picker. GPU capability is a property of what the *install* can
738
    //! do: `installed_accel` is authoritative when present (a non-`cpu` entry
739
    //! means GPU-capable), falling back to the manifest's `supported_devices`
740
    //! (`cuda`/`metal`/`gpu` count as GPU, `cpu` as CPU) when there is no
741
    //! install record. The online sentinel `none` counts as neither. A
742
    //! backend aggregates capability across every model it serves, so one GPU
743
    //! model and one CPU model surface both chips.
744
    use super::*;
745
    use crate::daemon::backends::{BackendInfo, BackendModel};
746
747
    /// Build a backend whose models declare the given device lists.
748
19
    fn backend_with_devices(per_model: &[&[&str]]) -> BackendInfo {
749
        BackendInfo {
750
19
            source: "github.com/super-stt/test".to_string(),
751
19
            description: String::new(),
752
19
            name: "Test".to_string(),
753
19
            version: "1.0.0".to_string(),
754
19
            kind: "subprocess".to_string(),
755
19
            allowed_hosts: Vec::new(),
756
19
            installed_accel: Vec::new(),
757
19
            models: per_model
758
19
                .iter()
759
19
                .enumerate()
760
19
                .map(|(i, devices)| BackendModel {
761
20
                    name: format!("m{i}"),
762
20
                    provider: String::new(),
763
27
                    supported_devices: 
devices20
.
iter20
().
map20
(|s| (*s).to_string()).
collect20
(),
764
                    estimated_vram_bytes: 0,
765
                    multilingual: false,
766
20
                    supported_languages: Vec::new(),
767
20
                    primary_language: String::new(),
768
                    realtime: false,
769
20
                    role: "transcription".into(),
770
20
                })
771
19
                .collect(),
772
19
            secrets: Vec::new(),
773
19
            options: Vec::new(),
774
            accepts_context: false,
775
        }
776
19
    }
777
778
    /// Both GPU backends (`cuda`, `metal`) surface the GPU chip, including
779
    /// when paired with `cpu` in the same model's device list.
780
    #[test]
781
1
    fn cuda_and_metal_count_as_gpu() {
782
1
        assert!(backend_supports_gpu(&backend_with_devices(&[&["cuda"]])));
783
1
        assert!(backend_supports_gpu(&backend_with_devices(&[&["metal"]])));
784
1
        assert!(backend_supports_gpu(&backend_with_devices(&[&[
785
1
            "cpu", "cuda"
786
1
        ]])));
787
1
    }
788
789
    /// A `cpu`-only model is CPU-capable and not GPU-capable.
790
    #[test]
791
1
    fn cpu_only_is_cpu_not_gpu() {
792
1
        let b = backend_with_devices(&[&["cpu"]]);
793
1
        assert!(backend_supports_cpu(&b));
794
1
        assert!(!backend_supports_gpu(&b));
795
1
    }
796
797
    /// The online sentinel `none` advertises no local compute at all — its
798
    /// card shows the Cloud chip instead (driven separately by online-ness).
799
    #[test]
800
1
    fn online_sentinel_is_neither() {
801
1
        let b = backend_with_devices(&[&["none"]]);
802
1
        assert!(!backend_supports_gpu(&b));
803
1
        assert!(!backend_supports_cpu(&b));
804
1
    }
805
806
    /// Capability is the union across a backend's models: a CPU-only model
807
    /// plus a GPU-only model yields both chips.
808
    #[test]
809
1
    fn capability_aggregates_across_models() {
810
1
        let b = backend_with_devices(&[&["cpu"], &["cuda"]]);
811
1
        assert!(backend_supports_gpu(&b));
812
1
        assert!(backend_supports_cpu(&b));
813
1
    }
814
815
    /// A one-model backend whose model declares `supported` and whose install
816
    /// record declares `installed`.
817
8
    fn backend_with_install(supported: &[&str], installed: &[&str]) -> BackendInfo {
818
8
        let mut backend = backend_with_devices(&[supported]);
819
8
        backend.models[0].name = "m".to_string();
820
8
        backend.installed_accel = installed.iter().map(|a| 
(*a)7
.
to_string7
()).collect();
821
8
        backend
822
8
    }
823
824
    /// The daemon offers an empty list for two different reasons, and a
825
    /// caller deciding whether to enable a "Load" action must tell them
826
    /// apart: the online sentinel needs no device at all, while a GPU-only
827
    /// model on an install that resolved to CPU-only can be loaded nowhere.
828
    /// Only the model's own `supported_devices` separates them, which is why
829
    /// this stayed a local read after the lists moved to the daemon.
830
    #[test]
831
1
    fn only_the_online_sentinel_reads_as_online() {
832
1
        let online = backend_with_install(&["none"], &[]);
833
1
        assert!(model_is_online(&online, "m"));
834
835
1
        let gpu_only_on_a_cpu_install = backend_with_install(&["gpu"], &["cpu"]);
836
1
        assert!(!model_is_online(&gpu_only_on_a_cpu_install, "m"));
837
1
    }
838
839
    /// The daemon's answer wins over the catalog: it is scoped to the stage's
840
    /// role and narrowed to this host, so a backend whose manifest claims a
841
    /// GPU shows no GPU chip once the daemon says it cannot offer one.
842
    #[test]
843
1
    fn the_daemons_list_drives_the_chips_when_it_has_answered() {
844
1
        let backend = backend_with_install(&["cpu", "gpu"], &["cuda"]);
845
1
        assert_eq!(
846
1
            stage_device_support(Some(&["cpu".to_string()]), &backend),
847
            StageCompute {
848
                gpu: false,
849
                cpu: true
850
            }
851
        );
852
1
        assert_eq!(
853
1
            stage_device_support(Some(&["cpu".to_string(), "gpu".to_string()]), &backend),
854
            StageCompute {
855
                gpu: true,
856
                cpu: true
857
            }
858
        );
859
        // An answered-but-empty list is an answer: nothing local to run.
860
1
        assert_eq!(
861
1
            stage_device_support(Some(&[]), &backend),
862
            StageCompute {
863
                gpu: false,
864
                cpu: false
865
            }
866
        );
867
1
    }
868
869
    /// No answer — a backend no stage has selected, or one whose answer has
870
    /// not landed — falls back to the catalog rather than blanking the chips.
871
    #[test]
872
1
    fn an_unanswered_backend_falls_back_to_the_catalog() {
873
1
        assert_eq!(
874
1
            stage_device_support(None, &backend_with_install(&["cpu", "gpu"], &["cuda"])),
875
            StageCompute {
876
                gpu: true,
877
                cpu: true
878
            }
879
        );
880
1
        assert_eq!(
881
1
            stage_device_support(None, &backend_with_install(&["cpu", "gpu"], &["cpu"])),
882
            StageCompute {
883
                gpu: false,
884
                cpu: true
885
            }
886
        );
887
1
    }
888
889
    #[test]
890
1
    fn model_is_online_is_false_for_a_local_model_and_an_unknown_one() {
891
1
        let backend = backend_with_install(&["cpu", "gpu"], &["cuda"]);
892
1
        assert!(!model_is_online(&backend, "m"));
893
1
        assert!(!model_is_online(&backend, "absent"));
894
1
    }
895
896
    /// The install record is authoritative over the manifest, on the fallback
897
    /// path the daemon does not answer for: a CUDA-labeled model on a
898
    /// CPU-only install shows no GPU chip.
899
    #[test]
900
1
    fn a_cpu_only_install_hides_the_gpu_chip_even_if_the_manifest_claims_cuda() {
901
1
        let backend = backend_with_install(&["cpu", "cuda"], &["cpu"]);
902
1
        assert!(!backend_supports_gpu(&backend));
903
1
    }
904
905
    /// An accelerated install record is authoritative even when the model
906
    /// uses the unified `"gpu"` spelling instead of a legacy accel name.
907
    #[test]
908
1
    fn an_accelerated_install_shows_the_gpu_chip() {
909
1
        let backend = backend_with_install(&["cpu", "gpu"], &["rocm"]);
910
1
        assert!(backend_supports_gpu(&backend));
911
1
    }
912
913
    /// Build a backend declaring a `base_url` option with the given effective
914
    /// value — what `GET /backend/list` reports once the user has (or hasn't) set
915
    /// one.
916
4
    fn backend_with_base_url(value: Option<&str>) -> BackendInfo {
917
        use crate::daemon::backends::BackendOption;
918
4
        let mut b = backend_with_devices(&[&["none"]]);
919
4
        b.options = vec![BackendOption {
920
4
            name: "base_url".to_string(),
921
4
            label: None,
922
4
            description: String::new(),
923
4
            r#type: Some("string".to_string()),
924
4
            default: None,
925
4
            choices: Vec::new(),
926
4
            min: None,
927
4
            max: None,
928
4
            step: None,
929
4
            required: false,
930
4
            value: value.map(ToString::to_string),
931
4
        }];
932
4
        b
933
4
    }
934
935
    /// The Cloud chip has to account for egress the manifest does not describe.
936
    /// A `base_url` the user set is exactly that; an unset or blank one is not,
937
    /// and must not put a phantom line on the card.
938
    #[test]
939
1
    fn user_url_is_flagged_only_once_a_value_is_set() {
940
1
        assert!(backend_has_user_url(&backend_with_base_url(Some(
941
1
            "https://gw.example.com"
942
1
        ))));
943
1
        assert!(!backend_has_user_url(&backend_with_base_url(None)));
944
1
        assert!(!backend_has_user_url(&backend_with_base_url(Some("  "))));
945
        // A backend that declares no such option never flags one.
946
1
        assert!(!backend_has_user_url(&backend_with_devices(&[&["none"]])));
947
1
    }
948
949
    /// The daemon authorizes whatever the override holds, so a value equal to a
950
    /// `default` an older daemon still reports is still egress the manifest did
951
    /// not declare. The card must say so rather than compare the two.
952
    #[test]
953
1
    fn user_url_is_flagged_even_when_it_equals_a_reported_default() {
954
1
        let mut b = backend_with_base_url(Some("https://api.example.com"));
955
1
        b.options[0].default = Some("https://api.example.com".to_string());
956
1
        assert!(backend_has_user_url(&b));
957
1
    }
958
}
959
960
#[cfg(test)]
961
mod update_offer_tests {
962
    //! Pin when the daemon's answer is shown. The comparison itself is the
963
    //! daemon's — these fix what the card does with it, including the failure
964
    //! this started from: the flag rides on the registry catalog, so a chip can
965
    //! survive the update it describes and then do nothing when clicked.
966
    use super::update_offer;
967
    use super_stt_shared::registry::RegistryBackend;
968
969
5
    fn entry(installed: Option<&str>, latest: &str) -> RegistryBackend {
970
        // Mirrors what the daemon computes, so the fixture cannot claim an
971
        // update the daemon would not report.
972
5
        let update_available = installed
973
5
            .is_some_and(|i| 
super_stt_registry_types::version::update_available4
(
i4
,
latest4
));
974
5
        RegistryBackend {
975
5
            id: "y".to_string(),
976
5
            backend_id: None,
977
5
            source: "github.com/x/y".to_string(),
978
5
            version: latest.to_string(),
979
5
            name: "Y".to_string(),
980
5
            description: None,
981
5
            license: "Apache-2.0".to_string(),
982
5
            kind: "wasm".to_string(),
983
5
            contract: "v1".to_string(),
984
5
            min_client: None,
985
5
            allowed_hosts: Vec::new(),
986
5
            online: true,
987
5
            supports_gpu: false,
988
5
            supports_cpu: false,
989
5
            models: Vec::new(),
990
5
            secrets: Vec::new(),
991
5
            options: Vec::new(),
992
5
            compatibility: super_stt_shared::registry::Compatibility {
993
5
                compatible: true,
994
5
                selected_asset: None,
995
5
                reason: None,
996
5
                needs_client_update: false,
997
5
            },
998
5
            installed_version: installed.map(String::from),
999
5
            update_available,
1000
5
            index_stale: None,
1001
5
        }
1002
5
    }
1003
1004
    #[test]
1005
1
    fn offers_only_a_newer_version() {
1006
1
        assert_eq!(
1007
1
            update_offer(Some(&entry(Some("0.1.0"), "0.1.1")), false),
1008
1
            Some("0.1.1".to_string())
1009
        );
1010
        // Already current — this is the state that was being drawn from a stale
1011
        // catalog and clicked repeatedly.
1012
1
        assert_eq!(
1013
1
            update_offer(Some(&entry(Some("0.1.1"), "0.1.1")), false),
1014
            None
1015
        );
1016
        // An index older than what is installed must not prompt a downgrade.
1017
1
        assert_eq!(
1018
1
            update_offer(Some(&entry(Some("0.2.0"), "0.1.1")), false),
1019
            None
1020
        );
1021
1
    }
1022
1023
    #[test]
1024
1
    fn withholds_while_an_install_is_in_flight() {
1025
1
        assert_eq!(
1026
1
            update_offer(Some(&entry(Some("0.1.0"), "0.1.1")), true),
1027
            None
1028
        );
1029
1
    }
1030
1031
    #[test]
1032
1
    fn needs_a_catalog_entry_and_an_installed_version() {
1033
1
        assert_eq!(update_offer(None, false), None);
1034
1
        assert_eq!(update_offer(Some(&entry(None, "0.1.1")), false), None);
1035
1
    }
1036
}