super-stt-app/src/ui/views/models/add_sheet.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | use cosmic::iced::widget::{column, row}; |
3 | | use cosmic::iced::{Alignment, Length}; |
4 | | use cosmic::widget::{self, button, space::horizontal as horizontal_space, text}; |
5 | | use cosmic::{Apply, Element}; |
6 | | |
7 | | use crate::core::app::AppModel; |
8 | | use crate::state::registry::{AddSource, PreviewState}; |
9 | | use crate::ui::icons; |
10 | | use crate::ui::messages::{Message, ModelsPageMessage}; |
11 | | |
12 | | use super::chips::{CloudEgress, capability_chips, count_chip, model_device_chips, role_label}; |
13 | | use super::surface::{card_divider, muted_text_color}; |
14 | | |
15 | | /// Right-side "Install manually" sheet, rendered as a COSMIC context drawer. |
16 | | /// |
17 | | /// One path, not two. The sheet used to offer a repository card and a folder |
18 | | /// card side by side, each with its own install button that fired straight at |
19 | | /// the daemon: paste a URL, press Install, and either a backend appeared or a |
20 | | /// line landed in a log. Here the source is a choice inside a single flow, and |
21 | | /// resolving it is a separate step from committing to it — Check fills the |
22 | | /// preview, Install commits what is in it. |
23 | | /// |
24 | | /// The preview is where every outcome lands, successes and failures alike. That |
25 | | /// is the point of it: a rejected resolve is keyed by the pasted URL, which |
26 | | /// matches no Browse card, so before this the only place it appeared was the |
27 | | /// log (issue #423). |
28 | 0 | pub fn add_backend_sheet(app: &AppModel) -> Element<'_, Message> { |
29 | 0 | let spacing = cosmic::theme::spacing(); |
30 | 0 | let muted = muted_text_color(); |
31 | 0 | let source = app.registry.add_source; |
32 | | |
33 | | // Source row: label left, dropdown right, the COSMIC settings idiom. |
34 | 0 | let selected = AddSource::ALL.iter().position(|s| *s == source); |
35 | 0 | let source_row = row![ |
36 | 0 | text::body("Source").class(cosmic::theme::Text::Color(muted)), |
37 | 0 | horizontal_space(), |
38 | 0 | widget::dropdown(&SOURCE_LABELS, selected, |i| { |
39 | 0 | Message::ModelsPage(ModelsPageMessage::AddSourceChanged( |
40 | 0 | AddSource::ALL[i.min(AddSource::ALL.len() - 1)], |
41 | 0 | )) |
42 | 0 | }), |
43 | | ] |
44 | 0 | .spacing(spacing.space_xs) |
45 | 0 | .align_y(Alignment::Center); |
46 | | |
47 | 0 | let entry_row = match source { |
48 | 0 | AddSource::Repository => repository_entry(app, spacing.space_xs), |
49 | 0 | AddSource::Folder => folder_entry(app, spacing.space_xs), |
50 | | }; |
51 | | |
52 | 0 | let sheet = column![ |
53 | 0 | source_row, |
54 | 0 | entry_row, |
55 | 0 | card_divider(), |
56 | 0 | preview_panel(app, muted), |
57 | | ] |
58 | 0 | .spacing(spacing.space_s) |
59 | 0 | .apply(widget::container) |
60 | 0 | .class(cosmic::theme::Container::List) |
61 | 0 | .padding(spacing.space_m) |
62 | 0 | .width(Length::Fill); |
63 | | |
64 | 0 | sheet.into() |
65 | 0 | } |
66 | | |
67 | | /// The drawer's pinned footer: the Install action. |
68 | | /// |
69 | | /// Rendered through `ContextDrawer::footer` rather than as the last row of the |
70 | | /// sheet, because the sheet's body scrolls. A resolved preview is tall enough |
71 | | /// to push a button at the end of that column past the bottom of the drawer, |
72 | | /// which left the one control the whole flow builds towards only reachable by |
73 | | /// scrolling to it. |
74 | 0 | pub fn add_backend_footer(app: &AppModel) -> Element<'_, Message> { |
75 | 0 | install_row(app, muted_text_color()) |
76 | 0 | } |
77 | | |
78 | | /// Dropdown labels, in [`AddSource::ALL`] order. `widget::dropdown` borrows |
79 | | /// this for the life of the view, so it is a const rather than a temporary. |
80 | | const SOURCE_LABELS: [&str; 2] = ["Git repository", "Local folder"]; |
81 | | |
82 | | /// URL field plus Check. Resolving a URL spends one of GitHub's 60 |
83 | | /// unauthenticated API calls an hour (`GITHUB_TOKEN` is optional, so most |
84 | | /// installs have that budget), so the trip is worth an explicit press rather |
85 | | /// than a debounce that would spend several per URL typed. |
86 | 0 | fn repository_entry(app: &AppModel, gap: u16) -> Element<'_, Message> { |
87 | 0 | let url = app.registry.custom_repo_input.trim(); |
88 | 0 | let checking = app.registry.add_preview.is_checking(); |
89 | | |
90 | 0 | let mut check = button::standard(if checking { |
91 | 0 | "Checking\u{2026}" |
92 | | } else { |
93 | 0 | "Check" |
94 | | }); |
95 | 0 | if !checking && !url.is_empty() { |
96 | 0 | check = check.on_press(Message::ModelsPage(ModelsPageMessage::CheckAddSource)); |
97 | 0 | } |
98 | | |
99 | 0 | row![ |
100 | 0 | widget::text_input( |
101 | | "https://github.com/owner/backend", |
102 | 0 | &app.registry.custom_repo_input |
103 | | ) |
104 | 0 | .on_input(|x| Message::ModelsPage(ModelsPageMessage::RegistryCustomRepoInputChanged(x))) |
105 | 0 | .on_submit(|_| Message::ModelsPage(ModelsPageMessage::CheckAddSource)) |
106 | 0 | .width(Length::Fill), |
107 | 0 | check, |
108 | | ] |
109 | 0 | .spacing(gap) |
110 | 0 | .align_y(Alignment::Center) |
111 | 0 | .into() |
112 | 0 | } |
113 | | |
114 | | /// Path field, the picker, and Check. |
115 | | /// |
116 | | /// The path is typed as well as picked: a folder someone is developing a |
117 | | /// backend in is a path they already know, and making them walk a file dialog |
118 | | /// to a directory they could have pasted is the slower half of the flow. The |
119 | | /// picker stays for the times the path is easier found than remembered. |
120 | | /// |
121 | | /// Check is here for the same reason it is in the URL row — the field can hold |
122 | | /// a path nothing has read yet — even though a local manifest costs no API |
123 | | /// budget to read. |
124 | 0 | fn folder_entry(app: &AppModel, gap: u16) -> Element<'_, Message> { |
125 | 0 | let path = app.registry.add_folder.trim(); |
126 | 0 | let checking = app.registry.add_preview.is_checking(); |
127 | | |
128 | 0 | let mut check = button::standard(if checking { |
129 | 0 | "Checking\u{2026}" |
130 | | } else { |
131 | 0 | "Check" |
132 | | }); |
133 | 0 | if !checking && !path.is_empty() { |
134 | 0 | check = check.on_press(Message::ModelsPage(ModelsPageMessage::CheckAddSource)); |
135 | 0 | } |
136 | | |
137 | 0 | row![ |
138 | 0 | widget::text_input("/home/you/dev/my-backend", &app.registry.add_folder) |
139 | 0 | .on_input(|x| Message::ModelsPage(ModelsPageMessage::AddFolderInputChanged(x))) |
140 | 0 | .on_submit(|_| Message::ModelsPage(ModelsPageMessage::CheckAddSource)) |
141 | 0 | .width(Length::Fill), |
142 | 0 | button::standard("Choose\u{2026}") |
143 | 0 | .on_press(Message::ModelsPage(ModelsPageMessage::ImportBackendFromDir)), |
144 | 0 | check, |
145 | | ] |
146 | 0 | .spacing(gap) |
147 | 0 | .align_y(Alignment::Center) |
148 | 0 | .into() |
149 | 0 | } |
150 | | |
151 | | /// The preview: what this source would install, or why it could not be read. |
152 | 0 | fn preview_panel(app: &AppModel, muted: cosmic::iced::Color) -> Element<'_, Message> { |
153 | 0 | let spacing = cosmic::theme::spacing(); |
154 | | |
155 | 0 | let body: Element<'_, Message> = match &app.registry.add_preview { |
156 | | PreviewState::Empty => { |
157 | 0 | let hint = match app.registry.add_source { |
158 | | AddSource::Repository => { |
159 | 0 | "Paste a repository URL and press Check. Super STT reads its latest release \ |
160 | 0 | and shows what it found before anything is installed." |
161 | | } |
162 | | AddSource::Folder => { |
163 | 0 | "Choose a folder that holds a backend.toml manifest. Super STT reads it and \ |
164 | 0 | shows what it found before anything is copied." |
165 | | } |
166 | | }; |
167 | 0 | column![ |
168 | 0 | text::body("Nothing to preview yet"), |
169 | 0 | text::caption(hint).class(cosmic::theme::Text::Color(muted)), |
170 | | ] |
171 | 0 | .spacing(spacing.space_xxs) |
172 | 0 | .into() |
173 | | } |
174 | | |
175 | | // A spinner beside the line, not just the line: reading a release takes |
176 | | // a round-trip to the forge, and a static sentence gives no sign that |
177 | | // anything is still happening. The glyph is a still SVG, so the angle |
178 | | // comes from state and the app ticks it while the check is in flight. |
179 | 0 | PreviewState::Checking(what) => row![ |
180 | 0 | icons::spinner(18.0, muted, app.registry.add_spin), |
181 | 0 | text::body(format!("Reading {what}\u{2026}")).class(cosmic::theme::Text::Color(muted)), |
182 | | ] |
183 | 0 | .spacing(spacing.space_xs) |
184 | 0 | .align_y(Alignment::Center) |
185 | 0 | .into(), |
186 | | |
187 | 0 | PreviewState::Failed(err) => row![ |
188 | 0 | icons::phosphor_destructive(icons::WARNING, 15.0), |
189 | 0 | text::body(err.clone()).width(Length::Fill), |
190 | | ] |
191 | 0 | .spacing(spacing.space_xs) |
192 | 0 | .into(), |
193 | | |
194 | 0 | PreviewState::Ready(backend) => resolved_preview(backend, muted), |
195 | | }; |
196 | | |
197 | 0 | widget::container(body) |
198 | 0 | .padding(spacing.space_s) |
199 | 0 | .width(Length::Fill) |
200 | 0 | .class(cosmic::theme::Container::Primary) |
201 | 0 | .into() |
202 | 0 | } |
203 | | |
204 | | /// The resolved backend: identity, the facts a person is agreeing to, and the |
205 | | /// models it serves. |
206 | 0 | fn resolved_preview( |
207 | 0 | b: &super_stt_shared::registry::RegistryBackend, |
208 | 0 | muted: cosmic::iced::Color, |
209 | 0 | ) -> Element<'_, Message> { |
210 | 0 | let spacing = cosmic::theme::spacing(); |
211 | 0 | let mut col = widget::column::with_capacity(9).spacing(spacing.space_xs); |
212 | | |
213 | | // An incompatible host is the most useful thing the panel can say, so it |
214 | | // says it first, above the name. |
215 | 0 | if !b.compatibility.compatible { |
216 | 0 | let reason = b |
217 | 0 | .compatibility |
218 | 0 | .reason |
219 | 0 | .as_deref() |
220 | 0 | .unwrap_or("This backend cannot run on this computer."); |
221 | 0 | col = col.push( |
222 | 0 | row![ |
223 | 0 | icons::phosphor_destructive(icons::WARNING, 15.0), |
224 | 0 | text::body(reason.to_string()).width(Length::Fill), |
225 | 0 | ] |
226 | 0 | .spacing(spacing.space_xs), |
227 | 0 | ); |
228 | 0 | } |
229 | | |
230 | 0 | col = col.push( |
231 | 0 | row![ |
232 | 0 | text::title4(b.name.clone()), |
233 | 0 | text::body(b.version.clone()).class(cosmic::theme::Text::Color(muted)), |
234 | | ] |
235 | 0 | .spacing(spacing.space_xs) |
236 | 0 | .align_y(Alignment::End), |
237 | | ); |
238 | 0 | col = col.push(text::caption(b.source.clone()).class(cosmic::theme::Text::Color(muted))); |
239 | | |
240 | 0 | if let Some(desc) = b.description.as_deref().filter(|d| !d.is_empty()) { |
241 | 0 | col = col.push(text::body(desc.to_string()).class(cosmic::theme::Text::Color(muted))); |
242 | 0 | } |
243 | | |
244 | 0 | let egress = b.online.then_some(CloudEgress { |
245 | 0 | hosts: b.allowed_hosts.as_slice(), |
246 | 0 | user_url: false, |
247 | 0 | }); |
248 | 0 | if let Some(chips) = capability_chips(b.supports_gpu, b.supports_cpu, egress, false) { |
249 | 0 | col = col.push(chips); |
250 | 0 | } |
251 | | |
252 | 0 | col = col.push(card_divider()); |
253 | 0 | for (label, value, style) in facts(b) { |
254 | 0 | let value = match style { |
255 | 0 | FactStyle::Plain => text::caption(value), |
256 | | // A hostname is machine text, and setting it as such is what tells |
257 | | // the reader it is a literal address rather than prose. |
258 | 0 | FactStyle::Mono => text::monotext(value), |
259 | | // "Nothing. Runs offline." is the one fact here that is good news, |
260 | | // and the person installing a microphone reader is looking for it. |
261 | 0 | FactStyle::Good => text::caption(value).class(cosmic::theme::Text::Color( |
262 | 0 | cosmic::theme::active().cosmic().success.base.into(), |
263 | 0 | )), |
264 | | }; |
265 | 0 | col = col.push( |
266 | 0 | row![ |
267 | 0 | text::caption(label) |
268 | 0 | .class(cosmic::theme::Text::Color(muted)) |
269 | 0 | .width(Length::Fixed(86.0)), |
270 | 0 | value.width(Length::Fill), |
271 | | ] |
272 | 0 | .spacing(spacing.space_xs), |
273 | | ); |
274 | | } |
275 | | |
276 | | // One row per model rather than one joined caption line: a preview is read |
277 | | // to find out what a backend actually serves, and names run together with |
278 | | // separators are exactly the shape that stops being read. |
279 | 0 | if !b.models.is_empty() { |
280 | 0 | col = col.push(card_divider()); |
281 | 0 | col = col.push( |
282 | 0 | text::caption(if b.models.len() == 1 { |
283 | 0 | "Model" |
284 | | } else { |
285 | 0 | "Models" |
286 | | }) |
287 | 0 | .class(cosmic::theme::Text::Color(muted)), |
288 | | ); |
289 | 0 | for m in &b.models { |
290 | 0 | let mut r = row![ |
291 | 0 | text::caption(m.name.clone()).width(Length::Fill), |
292 | 0 | count_chip(role_label(&m.product.role).to_string()), |
293 | | ] |
294 | 0 | .spacing(spacing.space_xxs) |
295 | 0 | .align_y(Alignment::Center); |
296 | 0 | for chip in model_device_chips(&m.supported_devices) { |
297 | 0 | r = r.push(chip); |
298 | 0 | } |
299 | 0 | col = col.push(r); |
300 | | } |
301 | 0 | } |
302 | | |
303 | 0 | col.into() |
304 | 0 | } |
305 | | |
306 | | /// How a fact's value should read. |
307 | | #[derive(Clone, Copy)] |
308 | | enum FactStyle { |
309 | | Plain, |
310 | | /// A literal machine value: a hostname, a target triple. |
311 | | Mono, |
312 | | /// Good news worth colouring. |
313 | | Good, |
314 | | } |
315 | | |
316 | | /// The label/value rows: what a person is actually agreeing to run. |
317 | 0 | fn facts(b: &super_stt_shared::registry::RegistryBackend) -> Vec<(String, String, FactStyle)> { |
318 | 0 | let mut out = Vec::with_capacity(5); |
319 | | |
320 | 0 | out.push(( |
321 | 0 | "Runs as".to_string(), |
322 | 0 | match b.kind.as_str() { |
323 | 0 | "wasm" => "WASM component, sandboxed".to_string(), |
324 | 0 | "subprocess" => "Native subprocess".to_string(), |
325 | 0 | other => other.to_string(), |
326 | | }, |
327 | 0 | FactStyle::Plain, |
328 | | )); |
329 | | |
330 | | // Only when there is something to get. A required secret is a real cost |
331 | | // the preview can warn about — installing is not enough, you have to go |
332 | | // fetch a credential before it will run — but every local backend needs |
333 | | // none, so a row that mostly reads "Nothing" is padding. |
334 | 0 | let needed: Vec<&str> = b |
335 | 0 | .secrets |
336 | 0 | .iter() |
337 | 0 | .filter(|s| s.required) |
338 | 0 | .map(|s| s.label.as_str()) |
339 | 0 | .collect(); |
340 | 0 | if !needed.is_empty() { |
341 | 0 | out.push(("Needs".to_string(), needed.join(", "), FactStyle::Plain)); |
342 | 0 | } |
343 | | |
344 | | // Always, including when it is empty: "nothing, runs offline" is the |
345 | | // reassurance someone installing a microphone reader is looking for, so |
346 | | // the absence is worth as much as the list. |
347 | 0 | if b.allowed_hosts.is_empty() { |
348 | 0 | out.push(( |
349 | 0 | "Reaches".to_string(), |
350 | 0 | "Nothing. Runs offline.".to_string(), |
351 | 0 | FactStyle::Good, |
352 | 0 | )); |
353 | 0 | } else { |
354 | 0 | out.push(( |
355 | 0 | "Reaches".to_string(), |
356 | 0 | b.allowed_hosts.join(", "), |
357 | 0 | FactStyle::Mono, |
358 | 0 | )); |
359 | 0 | } |
360 | | |
361 | 0 | if let Some(asset) = &b.compatibility.selected_asset |
362 | 0 | && !asset.target.is_empty() |
363 | | { |
364 | 0 | let accel = asset.accel.join(", "); |
365 | 0 | out.push(( |
366 | 0 | "Built for".to_string(), |
367 | 0 | if accel.is_empty() { |
368 | 0 | asset.target.clone() |
369 | | } else { |
370 | 0 | format!("{}, {accel}", asset.target) |
371 | | }, |
372 | 0 | FactStyle::Mono, |
373 | | )); |
374 | 0 | } |
375 | | |
376 | 0 | out.push(("License".to_string(), b.license.clone(), FactStyle::Plain)); |
377 | 0 | out |
378 | 0 | } |
379 | | |
380 | | /// Footer: what the button will do on the left, the button on the right. |
381 | | /// |
382 | | /// Install commits the previewed backend, so it is live only while there is a |
383 | | /// preview to commit and this machine can run it. Its label names what it |
384 | | /// installs, so the press and its result read as the same act. |
385 | 0 | fn install_row(app: &AppModel, muted: cosmic::iced::Color) -> Element<'_, Message> { |
386 | 0 | let spacing = cosmic::theme::spacing(); |
387 | 0 | let ready = app.registry.add_preview.ready(); |
388 | | |
389 | 0 | let key = app.registry.add_key(); |
390 | 0 | let in_flight = app.registry.installs.get(&key); |
391 | 0 | let start_error = app.registry.install_errors.get(&key); |
392 | | |
393 | 0 | let (label, live) = match (in_flight, ready) { |
394 | 0 | (Some(s), _) => ( |
395 | 0 | match &s.error { |
396 | 0 | Some(_) => "Failed".to_string(), |
397 | 0 | None => format!( |
398 | | "Installing\u{2026} ({})", |
399 | 0 | super::download::phase_label(s.phase) |
400 | | ), |
401 | | }, |
402 | | false, |
403 | | ), |
404 | 0 | (None, Some(b)) if b.compatibility.compatible => (format!("Install {}", b.name), true), |
405 | 0 | _ => ("Install".to_string(), false), |
406 | | }; |
407 | | |
408 | 0 | let mut btn = button::suggested(label); |
409 | 0 | if live { |
410 | 0 | btn = btn.on_press(Message::ModelsPage(ModelsPageMessage::InstallPreviewed)); |
411 | 0 | } |
412 | | |
413 | 0 | let note: Element<'_, Message> = if let Some(err) = start_error { |
414 | 0 | row![ |
415 | 0 | icons::phosphor_destructive(icons::WARNING, 14.0), |
416 | 0 | text::caption(format!("Failed to start: {err}")), |
417 | | ] |
418 | 0 | .spacing(spacing.space_xxs) |
419 | 0 | .align_y(Alignment::Center) |
420 | 0 | .into() |
421 | 0 | } else if let Some(s) = in_flight { |
422 | 0 | match (&s.error, s.bytes_total) { |
423 | 0 | (Some(err), _) => text::caption(format!("Failed: {err}")) |
424 | 0 | .class(cosmic::theme::Text::Color(muted)) |
425 | 0 | .into(), |
426 | 0 | (None, Some(total)) if total > 0 => { |
427 | 0 | text::caption(format!("{}%", (s.bytes_done * 100) / total)) |
428 | 0 | .class(cosmic::theme::Text::Color(muted)) |
429 | 0 | .into() |
430 | | } |
431 | 0 | _ => horizontal_space().into(), |
432 | | } |
433 | 0 | } else if let Some(b) = ready { |
434 | | // What the press will install, beside the press. Without it the footer |
435 | | // is a bare button and the version it commits to is only readable by |
436 | | // scrolling the preview back up. |
437 | 0 | let where_from = match app.registry.add_source { |
438 | 0 | AddSource::Repository => "from its latest release", |
439 | 0 | AddSource::Folder => "copied from disk", |
440 | | }; |
441 | 0 | text::caption(format!("Version {}, {where_from}", b.version)) |
442 | 0 | .class(cosmic::theme::Text::Color(muted)) |
443 | 0 | .into() |
444 | | } else { |
445 | 0 | horizontal_space().into() |
446 | | }; |
447 | | |
448 | 0 | row![widget::container(note).width(Length::Fill), btn] |
449 | 0 | .spacing(spacing.space_xs) |
450 | 0 | .align_y(Alignment::Center) |
451 | 0 | .width(Length::Fill) |
452 | 0 | .into() |
453 | 0 | } |