Coverage Report

Created: 2026-09-05 23:00

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
12
pub(super) fn update_offer(
28
12
    entry: Option<&super_stt_shared::registry::RegistryBackend>,
29
12
    in_flight: bool,
30
12
) -> Option<String> {
31
12
    if in_flight {
32
2
        return None;
33
10
    }
34
10
    let 
e8
= entry
?2
;
35
8
    e.update_available.then(|| 
e.version2
.
clone2
())
36
12
}
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
20
pub(super) fn backend_supports_gpu(backend: &crate::daemon::backends::BackendInfo) -> bool {
135
20
    if !backend.installed_accel.is_empty() {
136
8
        return backend.installed_accel.iter().any(|a| a != "cpu");
137
12
    }
138
14
    
backend.models.iter()12
.
any12
(|m| {
139
14
        m.supported_devices
140
14
            .iter()
141
16
            .
any14
(|d| d == "cuda" ||
d == "metal"10
||
d == "gpu"8
)
142
14
    })
143
20
}
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
10
pub(super) fn stage_device_support(
161
10
    offered: Option<&[String]>,
162
10
    backend: &crate::daemon::backends::BackendInfo,
163
10
) -> StageCompute {
164
10
    match offered {
165
6
        Some(devices) => StageCompute {
166
6
            gpu: devices.iter().any(|d| d == "gpu"),
167
6
            cpu: devices.iter().any(|d| 
d4
==
"cpu"4
),
168
        },
169
4
        None => StageCompute {
170
4
            gpu: backend_supports_gpu(backend),
171
4
            cpu: backend_supports_cpu(backend),
172
4
        },
173
    }
174
10
}
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
8
pub(super) fn model_is_online(backend: &crate::daemon::backends::BackendInfo, model: &str) -> bool {
186
8
    backend
187
8
        .models
188
8
        .iter()
189
8
        .find(|m| m.name == model)
190
8
        .is_some_and(|m| 
m.supported_devices.iter()6
.
any6
(|d| d == "none"))
191
8
}
192
193
/// Whether any model this backend serves can run on the CPU. Drives the
194
/// "CPU" capability chip on the backend card.
195
10
pub(super) fn backend_supports_cpu(backend: &crate::daemon::backends::BackendInfo) -> bool {
196
10
    backend
197
10
        .models
198
10
        .iter()
199
10
        .any(|m| m.supported_devices.iter().any(|d| d == "cpu"))
200
10
}
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
10
pub(super) fn backend_has_user_url(backend: &crate::daemon::backends::BackendInfo) -> bool {
213
10
    backend.options.iter().any(|o| 
{8
214
8
        o.name == super_stt_registry_types::manifest::BASE_URL_OPTION
215
8
            && o.value.as_ref().is_some_and(|v| !
v.trim()6
.
is_empty6
())
216
8
    })
217
10
}
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
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
242
0
    let mut fill = fg;
243
0
    fill.a = 0.14;
244
0
    let mut edge = fg;
245
0
    edge.a = 0.32;
246
247
0
    row![
248
0
        icons::phosphor_tinted(icon, 14.0, fg),
249
0
        text::caption(label).class(cosmic::theme::Text::Color(fg)),
250
    ]
251
0
    .spacing(spacing.space_xxxs)
252
0
    .align_y(Alignment::Center)
253
0
    .apply(widget::container)
254
0
    .padding([spacing.space_xxxs, spacing.space_xs])
255
0
    .class(cosmic::theme::Container::custom(move |_| {
256
0
        cosmic::iced::widget::container::Style {
257
0
            background: Some(cosmic::iced::Background::Color(fill)),
258
0
            border: cosmic::iced::Border {
259
0
                radius: radius.into(),
260
0
                width: 1.0,
261
0
                color: edge,
262
0
            },
263
0
            ..Default::default()
264
0
        }
265
0
    }))
266
0
    .into()
267
0
}
268
269
/// A neutral, text-only pill — same shape/tone as [`capability_chip`] but
270
/// without a leading glyph. Used for the active card's "N models" count.
271
0
pub(super) fn count_chip(label: String) -> Element<'static, Message> {
272
0
    let spacing = cosmic::theme::spacing();
273
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_xl;
274
0
    let fg: cosmic::iced::Color = cosmic::theme::active()
275
0
        .current_container()
276
0
        .component
277
0
        .on
278
0
        .into();
279
0
    let mut fill = fg;
280
0
    fill.a = 0.14;
281
0
    let mut edge = fg;
282
0
    edge.a = 0.32;
283
284
0
    text::caption(label)
285
0
        .class(cosmic::theme::Text::Color(fg))
286
0
        .apply(widget::container)
287
0
        .padding([spacing.space_xxxs, spacing.space_xs])
288
0
        .class(cosmic::theme::Container::custom(move |_| {
289
0
            cosmic::iced::widget::container::Style {
290
0
                background: Some(cosmic::iced::Background::Color(fill)),
291
0
                border: cosmic::iced::Border {
292
0
                    radius: radius.into(),
293
0
                    width: 1.0,
294
0
                    color: edge,
295
0
                },
296
0
                ..Default::default()
297
0
            }
298
0
        }))
299
0
        .into()
300
0
}
301
302
/// How many model names a card lists individually before the rest collapse
303
/// into a "+N" summary chip. Three keeps the inventory to a single line for
304
/// typical model-name lengths.
305
const MAX_MODEL_TAGS: usize = 3;
306
307
/// A quiet outline "tag" for one model name: hairline border, no fill, slightly
308
/// dimmed text, with the gentle `radius_s` corner so it reads as a catalog item
309
/// rather than a status pill (which the capability chips own, fully rounded).
310
0
pub(super) fn model_tag(name: String) -> Element<'static, Message> {
311
0
    let spacing = cosmic::theme::spacing();
312
0
    let radius = cosmic::theme::active().cosmic().corner_radii.radius_s;
313
0
    let on: cosmic::iced::Color = cosmic::theme::active()
314
0
        .current_container()
315
0
        .component
316
0
        .on
317
0
        .into();
318
0
    let mut edge = on;
319
0
    edge.a = 0.28;
320
0
    let mut fg = on;
321
0
    fg.a = 0.85;
322
323
0
    text::caption(name)
324
0
        .class(cosmic::theme::Text::Color(fg))
325
0
        .apply(widget::container)
326
0
        .padding([spacing.space_xxxs, spacing.space_xs])
327
0
        .class(cosmic::theme::Container::custom(move |_| {
328
0
            cosmic::iced::widget::container::Style {
329
0
                border: cosmic::iced::Border {
330
0
                    radius: radius.into(),
331
0
                    width: 1.0,
332
0
                    color: edge,
333
0
                },
334
0
                ..Default::default()
335
0
            }
336
0
        }))
337
0
        .into()
338
0
}
339
340
/// The models a backend ships, split by what they are for.
341
///
342
/// A backend may serve both kinds from one install — a weights bundle that
343
/// transcribes *and* cleans up — and the Library cards say so, since which
344
/// kinds you get is the first thing that decides whether a backend is the one
345
/// you want.
346
pub(super) struct RoleGroup {
347
    /// Human label for the role, e.g. `"Speech to text"`.
348
    pub label: &'static str,
349
    /// The model names in this group, in catalog order.
350
    pub names: Vec<String>,
351
}
352
353
/// How a model `role` reads on a card.
354
///
355
/// An unknown role — one a newer backend declares and this build has no name
356
/// for — reads as speech-to-text, matching the wire default and the daemon's
357
/// own reading of a missing value.
358
0
pub(super) fn role_label(role: &str) -> &'static str {
359
0
    match role {
360
0
        "post_processor" => "Post-processing",
361
0
        _ => "Speech to text",
362
    }
363
0
}
364
365
/// Group `(name, role)` pairs by role, transcription first.
366
///
367
/// The order is fixed rather than catalog-derived so two backends with the same
368
/// kinds present them the same way round. Empty groups are dropped, so a
369
/// single-role backend yields exactly one.
370
0
pub(super) fn role_groups<'a>(models: impl Iterator<Item = (&'a str, &'a str)>) -> Vec<RoleGroup> {
371
0
    let mut transcription = Vec::new();
372
0
    let mut post_processing = Vec::new();
373
0
    for (name, role) in models {
374
0
        if role_label(role) == "Post-processing" {
375
0
            post_processing.push(name.to_string());
376
0
        } else {
377
0
            transcription.push(name.to_string());
378
0
        }
379
    }
380
0
    [
381
0
        RoleGroup {
382
0
            label: "Speech to text",
383
0
            names: transcription,
384
0
        },
385
0
        RoleGroup {
386
0
            label: "Post-processing",
387
0
            names: post_processing,
388
0
        },
389
0
    ]
390
0
    .into_iter()
391
0
    .filter(|g| !g.names.is_empty())
392
0
    .collect()
393
0
}
394
395
/// A backend's model inventory: one row per kind of model it ships — a muted
396
/// role label, the first [`MAX_MODEL_TAGS`] names as outline [`model_tag`]s,
397
/// then a filled "+N" [`count_chip`] for the rest. `None` when the backend
398
/// serves no models, so the caller skips the row.
399
///
400
/// The label names the kind rather than saying "Models", so a card answers
401
/// "what does this ship?" without the user opening it.
402
0
pub(super) fn models_inventory(groups: &[RoleGroup]) -> Option<Element<'static, Message>> {
403
0
    if groups.is_empty() {
404
0
        return None;
405
0
    }
406
0
    let spacing = cosmic::theme::spacing();
407
0
    let muted = muted_text_color();
408
0
    let rows: Vec<Element<'static, Message>> = groups
409
0
        .iter()
410
0
        .map(|group| {
411
0
            let mut inventory =
412
0
                row![text::caption(group.label).class(cosmic::theme::Text::Color(muted))]
413
0
                    .spacing(spacing.space_xxs)
414
0
                    .align_y(Alignment::Center);
415
0
            for name in group.names.iter().take(MAX_MODEL_TAGS) {
416
0
                inventory = inventory.push(model_tag(name.clone()));
417
0
            }
418
0
            let rest = group.names.len().saturating_sub(MAX_MODEL_TAGS);
419
0
            if rest > 0 {
420
0
                inventory = inventory.push(count_chip(format!("+{rest}")));
421
0
            }
422
0
            inventory.into()
423
0
        })
424
0
        .collect();
425
0
    Some(
426
0
        cosmic::iced::widget::column(rows)
427
0
            .spacing(spacing.space_xxs)
428
0
            .into(),
429
0
    )
430
0
}
431
432
/// Where an online backend sends audio, as a card can describe it: the hosts
433
/// its `backend.toml` declares, plus whether the user pointed it at an endpoint
434
/// of their own.
435
#[derive(Clone, Copy)]
436
pub(super) struct CloudEgress<'a> {
437
    /// The backend's declared `[network].allowed_hosts`.
438
    pub hosts: &'a [String],
439
    /// Whether a `base_url` the user set adds an endpoint beyond `hosts`.
440
    pub user_url: bool,
441
}
442
443
/// The Cloud capability chip: a [`capability_chip`] with a hover tooltip
444
/// listing the hosts the backend transmits audio to. Shares the GPU/CPU
445
/// chips' neutral tone so "runs in the cloud" reads as a plain capability,
446
/// not a golden/premium value judgment.
447
///
448
/// A user-set `base_url` is named as a line, never as the address: it is the
449
/// user's own value, and printing it would put a configured endpoint on a card
450
/// they may be showing someone.
451
0
pub(super) fn cloud_chip(
452
0
    fg: cosmic::iced::Color,
453
0
    egress: CloudEgress<'_>,
454
0
) -> Element<'static, Message> {
455
    use super::surface::rounded_tooltip;
456
0
    let chip = capability_chip(icons::CLOUD, "Cloud", fg);
457
0
    if egress.hosts.is_empty() && !egress.user_url {
458
0
        return chip;
459
0
    }
460
0
    let mut popup = widget::column::with_capacity(egress.hosts.len() + 2)
461
0
        .push(text::body("Transmits audio to:"))
462
0
        .spacing(cosmic::theme::spacing().space_xxxs);
463
0
    for host in egress.hosts {
464
0
        popup = popup.push(text::body(format!("• {host}")));
465
0
    }
466
0
    if egress.user_url {
467
0
        popup = popup.push(text::body("• another URL you set"));
468
0
    }
469
0
    rounded_tooltip(chip, popup, widget::tooltip::Position::Top)
470
0
}
471
472
/// The capability-chip row for a backend: GPU / CPU advertise local compute,
473
/// Cloud (when `online` is `Some`) flags an online backend. Returns
474
/// `None` when there's nothing to advertise, so callers skip the row rather
475
/// than render an empty band.
476
///
477
/// `tooltips` gates the hover popovers (GPU/CPU detail, Cloud host list). The
478
/// Library installed card passes `!menu_open`: while that card's "⋯" overflow
479
/// menu is open, its chips drop their tooltips so the menu renders cleanly on
480
/// top (libcosmic draws the open menu above a tooltip, so a tooltip showing at
481
/// the same time would paint half-behind it). With the menu closed — and on
482
/// the active-backend / Browse cards, which have no overflow menu — tooltips
483
/// show as normal.
484
// reason: "supports_gpu" / "supports_cpu" are the clearest names
485
#[allow(clippy::similar_names)]
486
0
pub(super) fn capability_chips(
487
0
    supports_gpu: bool,
488
0
    supports_cpu: bool,
489
0
    online: Option<CloudEgress<'_>>,
490
0
    tooltips: bool,
491
0
) -> Option<Element<'static, Message>> {
492
    use super::surface::rounded_tooltip;
493
0
    let theme = cosmic::theme::active();
494
0
    let neutral: cosmic::iced::Color = theme.current_container().component.on.into();
495
496
0
    let mut chips: Vec<Element<'static, Message>> = Vec::new();
497
0
    if supports_gpu {
498
0
        let chip = capability_chip(icons::GRAPHICS_CARD, "GPU", neutral);
499
0
        chips.push(if tooltips {
500
0
            rounded_tooltip(
501
0
                chip,
502
0
                text::body("Accelerated on GPU"),
503
0
                widget::tooltip::Position::Top,
504
            )
505
        } else {
506
0
            chip
507
        });
508
0
    }
509
0
    if supports_cpu {
510
0
        let chip = capability_chip(icons::CPU, "CPU", neutral);
511
0
        chips.push(if tooltips {
512
0
            rounded_tooltip(
513
0
                chip,
514
0
                text::body("Runs on the CPU"),
515
0
                widget::tooltip::Position::Top,
516
            )
517
        } else {
518
0
            chip
519
        });
520
0
    }
521
0
    if let Some(egress) = online {
522
0
        chips.push(if tooltips {
523
0
            cloud_chip(neutral, egress)
524
        } else {
525
0
            capability_chip(icons::CLOUD, "Cloud", neutral)
526
        });
527
0
    }
528
0
    if chips.is_empty() {
529
0
        return None;
530
0
    }
531
0
    Some(
532
0
        row(chips)
533
0
            .spacing(cosmic::theme::spacing().space_xxs)
534
0
            .align_y(Alignment::Center)
535
0
            .into(),
536
0
    )
537
0
}
538
539
/// A segmented control of mutually-exclusive filter options: a caption label
540
/// followed by chips butted together inside a single rounded "track", so the
541
/// group reads as one unified toggle rather than separate buttons. The active
542
/// chip is filled — accent by default, or a neutral surface when `neutral` is
543
/// set (used for the secondary "Format" filter); inactive chips are transparent
544
/// so the track shows through.
545
/// The "Kind" segmented filter: which stage's models a backend must serve.
546
///
547
/// Both Library tabs render it from here, so the wording and the order of the
548
/// choices cannot drift between Installed and Browse.
549
0
pub(super) fn role_filter_chips(
550
0
    current: RoleFilter,
551
0
    on_pick: fn(RoleFilter) -> Message,
552
0
) -> Element<'static, Message> {
553
0
    chip_group(
554
0
        "Kind",
555
        false,
556
0
        vec![
557
0
            ("All", current == RoleFilter::All, on_pick(RoleFilter::All)),
558
0
            (
559
0
                "Transcription",
560
0
                current == RoleFilter::Transcription,
561
0
                on_pick(RoleFilter::Transcription),
562
0
            ),
563
0
            (
564
0
                "Post-processing",
565
0
                current == RoleFilter::PostProcessing,
566
0
                on_pick(RoleFilter::PostProcessing),
567
0
            ),
568
        ],
569
    )
570
0
}
571
572
/// The "Runs on" segmented filter: local models, cloud models, or both.
573
///
574
/// Shared for the same reason [`role_filter_chips`] is — the Installed tab
575
/// gained this filter after Browse had it, and two copies would drift.
576
0
pub(super) fn runs_on_chips(
577
0
    current: Option<bool>,
578
0
    on_pick: fn(Option<bool>) -> Message,
579
0
) -> Element<'static, Message> {
580
0
    chip_group(
581
0
        "Runs on",
582
        false,
583
0
        vec![
584
0
            ("All", current.is_none(), on_pick(None)),
585
0
            ("Local", current == Some(false), on_pick(Some(false))),
586
0
            ("Cloud", current == Some(true), on_pick(Some(true))),
587
        ],
588
    )
589
0
}
590
591
0
pub(super) fn chip_group(
592
0
    label: &str,
593
0
    neutral: bool,
594
0
    chips: Vec<(&'static str, bool, Message)>,
595
0
) -> Element<'static, Message> {
596
    use cosmic::widget::button;
597
0
    let spacing = cosmic::theme::spacing();
598
0
    let muted = muted_text_color();
599
600
    // Chips with no gap between them; the surrounding track supplies the inset.
601
0
    let mut segments = row![].spacing(0).align_y(Alignment::Center);
602
0
    for (chip_label, active, msg) in chips {
603
0
        let chip = if active {
604
0
            if neutral {
605
0
                button::standard(chip_label)
606
            } else {
607
0
                button::suggested(chip_label)
608
            }
609
        } else {
610
0
            button::text(chip_label)
611
        }
612
        // Match the font size (14) so the label's line box hugs the glyph and
613
        // sits vertically centered, rather than floating high in the stock 20px
614
        // line box. Same centering technique `pill_label` uses (`line_height(1.0)`
615
        // = 1.0 × 14px); these stay regular buttons, not pills.
616
0
        .line_height(14)
617
0
        .padding([spacing.space_xxs, spacing.space_s])
618
0
        .on_press(msg);
619
0
        segments = segments.push(chip);
620
    }
621
622
    // The track: a surface-filled, hairline-bordered, pill-rounded container
623
    // with a small inset so the active chip visually sits within it.
624
0
    let track = widget::container(segments)
625
0
        .padding(3)
626
0
        .class(cosmic::theme::Container::custom(
627
            super::surface::pill_surface,
628
        ));
629
630
0
    row![
631
0
        text::caption(label.to_uppercase()).class(cosmic::theme::Text::Color(muted)),
632
0
        track
633
    ]
634
0
    .spacing(spacing.space_xs)
635
0
    .align_y(Alignment::Center)
636
0
    .into()
637
0
}
638
639
/// The "{shown} backends found" result-count caption above the filter chips.
640
0
pub(super) fn result_count<'a>(shown: usize) -> Element<'a, Message> {
641
0
    let muted = muted_text_color();
642
0
    let label = format!("{shown} backends found");
643
0
    text::caption(label)
644
0
        .class(cosmic::theme::Text::Color(muted))
645
0
        .into()
646
0
}
647
648
/// One unmet-requirement row: a destructive-colored warning glyph and the
649
/// message `"{label} must be set."`. The active-backend card is the obvious
650
/// source of the constraint, so the message stays short — no backend name,
651
/// no internal identifier. Only the icon is tinted red; the text uses the
652
/// default body color so the row reads cleanly. Non-dismissible: this row
653
/// disappears the moment the requirement is satisfied (no click required).
654
0
pub(super) fn requirement_warning(label: &str) -> Element<'_, Message> {
655
0
    row![
656
0
        icons::phosphor_destructive(icons::WARNING, 16.0),
657
0
        text::body(format!("{label} must be set.")),
658
    ]
659
0
    .spacing(cosmic::theme::spacing().space_xs)
660
0
    .align_y(Alignment::Center)
661
0
    .into()
662
0
}
663
664
#[cfg(test)]
665
mod capability_tests {
666
    //! Pin the device→capability mapping behind the GPU/CPU chips and the
667
    //! device picker. GPU capability is a property of what the *install* can
668
    //! do: `installed_accel` is authoritative when present (a non-`cpu` entry
669
    //! means GPU-capable), falling back to the manifest's `supported_devices`
670
    //! (`cuda`/`metal`/`gpu` count as GPU, `cpu` as CPU) when there is no
671
    //! install record. The online sentinel `none` counts as neither. A
672
    //! backend aggregates capability across every model it serves, so one GPU
673
    //! model and one CPU model surface both chips.
674
    use super::*;
675
    use crate::daemon::backends::{BackendInfo, BackendModel};
676
677
    /// Build a backend whose models declare the given device lists.
678
38
    fn backend_with_devices(per_model: &[&[&str]]) -> BackendInfo {
679
        BackendInfo {
680
38
            source: "github.com/super-stt/test".to_string(),
681
38
            description: String::new(),
682
38
            name: "Test".to_string(),
683
38
            version: "1.0.0".to_string(),
684
38
            kind: "subprocess".to_string(),
685
38
            allowed_hosts: Vec::new(),
686
38
            installed_accel: Vec::new(),
687
38
            models: per_model
688
38
                .iter()
689
38
                .enumerate()
690
38
                .map(|(i, devices)| BackendModel {
691
40
                    name: format!("m{i}"),
692
40
                    provider: String::new(),
693
54
                    supported_devices: 
devices40
.
iter40
().
map40
(|s| (*s).to_string()).
collect40
(),
694
                    estimated_vram_bytes: 0,
695
                    multilingual: false,
696
40
                    supported_languages: Vec::new(),
697
40
                    primary_language: String::new(),
698
                    realtime: false,
699
40
                    role: "transcription".into(),
700
40
                })
701
38
                .collect(),
702
38
            secrets: Vec::new(),
703
38
            options: Vec::new(),
704
        }
705
38
    }
706
707
    /// Both GPU backends (`cuda`, `metal`) surface the GPU chip, including
708
    /// when paired with `cpu` in the same model's device list.
709
    #[test]
710
2
    fn cuda_and_metal_count_as_gpu() {
711
2
        assert!(backend_supports_gpu(&backend_with_devices(&[&["cuda"]])));
712
2
        assert!(backend_supports_gpu(&backend_with_devices(&[&["metal"]])));
713
2
        assert!(backend_supports_gpu(&backend_with_devices(&[&[
714
2
            "cpu", "cuda"
715
2
        ]])));
716
2
    }
717
718
    /// A `cpu`-only model is CPU-capable and not GPU-capable.
719
    #[test]
720
2
    fn cpu_only_is_cpu_not_gpu() {
721
2
        let b = backend_with_devices(&[&["cpu"]]);
722
2
        assert!(backend_supports_cpu(&b));
723
2
        assert!(!backend_supports_gpu(&b));
724
2
    }
725
726
    /// The online sentinel `none` advertises no local compute at all — its
727
    /// card shows the Cloud chip instead (driven separately by online-ness).
728
    #[test]
729
2
    fn online_sentinel_is_neither() {
730
2
        let b = backend_with_devices(&[&["none"]]);
731
2
        assert!(!backend_supports_gpu(&b));
732
2
        assert!(!backend_supports_cpu(&b));
733
2
    }
734
735
    /// Capability is the union across a backend's models: a CPU-only model
736
    /// plus a GPU-only model yields both chips.
737
    #[test]
738
2
    fn capability_aggregates_across_models() {
739
2
        let b = backend_with_devices(&[&["cpu"], &["cuda"]]);
740
2
        assert!(backend_supports_gpu(&b));
741
2
        assert!(backend_supports_cpu(&b));
742
2
    }
743
744
    /// A one-model backend whose model declares `supported` and whose install
745
    /// record declares `installed`.
746
16
    fn backend_with_install(supported: &[&str], installed: &[&str]) -> BackendInfo {
747
16
        let mut backend = backend_with_devices(&[supported]);
748
16
        backend.models[0].name = "m".to_string();
749
16
        backend.installed_accel = installed.iter().map(|a| 
(*a)14
.
to_string14
()).collect();
750
16
        backend
751
16
    }
752
753
    /// The daemon offers an empty list for two different reasons, and a
754
    /// caller deciding whether to enable a "Load" action must tell them
755
    /// apart: the online sentinel needs no device at all, while a GPU-only
756
    /// model on an install that resolved to CPU-only can be loaded nowhere.
757
    /// Only the model's own `supported_devices` separates them, which is why
758
    /// this stayed a local read after the lists moved to the daemon.
759
    #[test]
760
2
    fn only_the_online_sentinel_reads_as_online() {
761
2
        let online = backend_with_install(&["none"], &[]);
762
2
        assert!(model_is_online(&online, "m"));
763
764
2
        let gpu_only_on_a_cpu_install = backend_with_install(&["gpu"], &["cpu"]);
765
2
        assert!(!model_is_online(&gpu_only_on_a_cpu_install, "m"));
766
2
    }
767
768
    /// The daemon's answer wins over the catalog: it is scoped to the stage's
769
    /// role and narrowed to this host, so a backend whose manifest claims a
770
    /// GPU shows no GPU chip once the daemon says it cannot offer one.
771
    #[test]
772
2
    fn the_daemons_list_drives_the_chips_when_it_has_answered() {
773
2
        let backend = backend_with_install(&["cpu", "gpu"], &["cuda"]);
774
2
        assert_eq!(
775
2
            stage_device_support(Some(&["cpu".to_string()]), &backend),
776
            StageCompute {
777
                gpu: false,
778
                cpu: true
779
            }
780
        );
781
2
        assert_eq!(
782
2
            stage_device_support(Some(&["cpu".to_string(), "gpu".to_string()]), &backend),
783
            StageCompute {
784
                gpu: true,
785
                cpu: true
786
            }
787
        );
788
        // An answered-but-empty list is an answer: nothing local to run.
789
2
        assert_eq!(
790
2
            stage_device_support(Some(&[]), &backend),
791
            StageCompute {
792
                gpu: false,
793
                cpu: false
794
            }
795
        );
796
2
    }
797
798
    /// No answer — a backend no stage has selected, or one whose answer has
799
    /// not landed — falls back to the catalog rather than blanking the chips.
800
    #[test]
801
2
    fn an_unanswered_backend_falls_back_to_the_catalog() {
802
2
        assert_eq!(
803
2
            stage_device_support(None, &backend_with_install(&["cpu", "gpu"], &["cuda"])),
804
            StageCompute {
805
                gpu: true,
806
                cpu: true
807
            }
808
        );
809
2
        assert_eq!(
810
2
            stage_device_support(None, &backend_with_install(&["cpu", "gpu"], &["cpu"])),
811
            StageCompute {
812
                gpu: false,
813
                cpu: true
814
            }
815
        );
816
2
    }
817
818
    #[test]
819
2
    fn model_is_online_is_false_for_a_local_model_and_an_unknown_one() {
820
2
        let backend = backend_with_install(&["cpu", "gpu"], &["cuda"]);
821
2
        assert!(!model_is_online(&backend, "m"));
822
2
        assert!(!model_is_online(&backend, "absent"));
823
2
    }
824
825
    /// The install record is authoritative over the manifest, on the fallback
826
    /// path the daemon does not answer for: a CUDA-labeled model on a
827
    /// CPU-only install shows no GPU chip.
828
    #[test]
829
2
    fn a_cpu_only_install_hides_the_gpu_chip_even_if_the_manifest_claims_cuda() {
830
2
        let backend = backend_with_install(&["cpu", "cuda"], &["cpu"]);
831
2
        assert!(!backend_supports_gpu(&backend));
832
2
    }
833
834
    /// An accelerated install record is authoritative even when the model
835
    /// uses the unified `"gpu"` spelling instead of a legacy accel name.
836
    #[test]
837
2
    fn an_accelerated_install_shows_the_gpu_chip() {
838
2
        let backend = backend_with_install(&["cpu", "gpu"], &["rocm"]);
839
2
        assert!(backend_supports_gpu(&backend));
840
2
    }
841
842
    /// Build a backend declaring a `base_url` option with the given effective
843
    /// value — what `GET /backend/list` reports once the user has (or hasn't) set
844
    /// one.
845
8
    fn backend_with_base_url(value: Option<&str>) -> BackendInfo {
846
        use crate::daemon::backends::BackendOption;
847
8
        let mut b = backend_with_devices(&[&["none"]]);
848
8
        b.options = vec![BackendOption {
849
8
            name: "base_url".to_string(),
850
8
            label: None,
851
8
            description: String::new(),
852
8
            r#type: Some("string".to_string()),
853
8
            default: None,
854
8
            required: false,
855
8
            value: value.map(ToString::to_string),
856
8
        }];
857
8
        b
858
8
    }
859
860
    /// The Cloud chip has to account for egress the manifest does not describe.
861
    /// A `base_url` the user set is exactly that; an unset or blank one is not,
862
    /// and must not put a phantom line on the card.
863
    #[test]
864
2
    fn user_url_is_flagged_only_once_a_value_is_set() {
865
2
        assert!(backend_has_user_url(&backend_with_base_url(Some(
866
2
            "https://gw.example.com"
867
2
        ))));
868
2
        assert!(!backend_has_user_url(&backend_with_base_url(None)));
869
2
        assert!(!backend_has_user_url(&backend_with_base_url(Some("  "))));
870
        // A backend that declares no such option never flags one.
871
2
        assert!(!backend_has_user_url(&backend_with_devices(&[&["none"]])));
872
2
    }
873
874
    /// The daemon authorizes whatever the override holds, so a value equal to a
875
    /// `default` an older daemon still reports is still egress the manifest did
876
    /// not declare. The card must say so rather than compare the two.
877
    #[test]
878
2
    fn user_url_is_flagged_even_when_it_equals_a_reported_default() {
879
2
        let mut b = backend_with_base_url(Some("https://api.example.com"));
880
2
        b.options[0].default = Some("https://api.example.com".to_string());
881
2
        assert!(backend_has_user_url(&b));
882
2
    }
883
}
884
885
#[cfg(test)]
886
mod update_offer_tests {
887
    //! Pin when the daemon's answer is shown. The comparison itself is the
888
    //! daemon's — these fix what the card does with it, including the failure
889
    //! this started from: the flag rides on the registry catalog, so a chip can
890
    //! survive the update it describes and then do nothing when clicked.
891
    use super::update_offer;
892
    use super_stt_shared::registry::RegistryBackend;
893
894
10
    fn entry(installed: Option<&str>, latest: &str) -> RegistryBackend {
895
        // Mirrors what the daemon computes, so the fixture cannot claim an
896
        // update the daemon would not report.
897
10
        let update_available = installed
898
10
            .is_some_and(|i| 
super_stt_registry_types::version::update_available8
(
i8
,
latest8
));
899
10
        RegistryBackend {
900
10
            id: "y".to_string(),
901
10
            backend_id: None,
902
10
            source: "github.com/x/y".to_string(),
903
10
            version: latest.to_string(),
904
10
            name: "Y".to_string(),
905
10
            description: None,
906
10
            license: "Apache-2.0".to_string(),
907
10
            kind: "wasm".to_string(),
908
10
            contract: "v1".to_string(),
909
10
            min_client: None,
910
10
            allowed_hosts: Vec::new(),
911
10
            online: true,
912
10
            supports_gpu: false,
913
10
            supports_cpu: false,
914
10
            models: Vec::new(),
915
10
            secrets: Vec::new(),
916
10
            options: Vec::new(),
917
10
            compatibility: super_stt_shared::registry::Compatibility {
918
10
                compatible: true,
919
10
                selected_asset: None,
920
10
                reason: None,
921
10
                needs_client_update: false,
922
10
            },
923
10
            installed_version: installed.map(String::from),
924
10
            update_available,
925
10
            index_stale: None,
926
10
        }
927
10
    }
928
929
    #[test]
930
2
    fn offers_only_a_newer_version() {
931
2
        assert_eq!(
932
2
            update_offer(Some(&entry(Some("0.1.0"), "0.1.1")), false),
933
2
            Some("0.1.1".to_string())
934
        );
935
        // Already current — this is the state that was being drawn from a stale
936
        // catalog and clicked repeatedly.
937
2
        assert_eq!(
938
2
            update_offer(Some(&entry(Some("0.1.1"), "0.1.1")), false),
939
            None
940
        );
941
        // An index older than what is installed must not prompt a downgrade.
942
2
        assert_eq!(
943
2
            update_offer(Some(&entry(Some("0.2.0"), "0.1.1")), false),
944
            None
945
        );
946
2
    }
947
948
    #[test]
949
2
    fn withholds_while_an_install_is_in_flight() {
950
2
        assert_eq!(
951
2
            update_offer(Some(&entry(Some("0.1.0"), "0.1.1")), true),
952
            None
953
        );
954
2
    }
955
956
    #[test]
957
2
    fn needs_a_catalog_entry_and_an_installed_version() {
958
2
        assert_eq!(update_offer(None, false), None);
959
2
        assert_eq!(update_offer(Some(&entry(None, "0.1.1")), false), None);
960
2
    }
961
}