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 | | } |