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