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