Coverage Report

Created: 2026-10-23 22:23

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/ui/views/models/configure.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
use cosmic::Element;
3
use cosmic::iced::widget::{column, row};
4
use cosmic::iced::{Alignment, Length};
5
use cosmic::widget::{self, settings, text};
6
7
use crate::core::app::AppModel;
8
use crate::daemon::backends::{BackendInfo, BackendOption, BackendSecret};
9
use crate::daemon::client::ContextMode;
10
use crate::ui::messages::{BackendMessage, ContextsMessage, Message};
11
use super_stt_shared::models::contexts::DictationContext;
12
13
use super::surface::muted_text_color;
14
15
/// Body of the per-backend configuration sheet (shown in the right-side
16
/// context drawer): the backend's secrets (system keyring) and options (daemon
17
/// config) as one settings section. The drawer supplies the title and close
18
/// affordance, so there's no in-body header or Back button.
19
0
pub fn configure_sheet<'a>(backend: &'a BackendInfo, app: &'a AppModel) -> Element<'a, Message> {
20
0
    let spacing = cosmic::theme::spacing();
21
22
0
    let mut body: Vec<Element<'a, Message>> = Vec::new();
23
24
    // Surface a failed secret/option save inline in the sheet (Tier 1 #15)
25
    // instead of dropping it to the log.
26
0
    if let Some(message) = app.action_error_for(crate::state::ErrorScope::ConfigureBackend) {
27
0
        body.push(crate::ui::views::common::error_banner(message));
28
0
    }
29
30
    // The dictation context comes first: it is the one setting here that is
31
    // not the backend's own, and the one most likely to be changed twice in a
32
    // sitting. Only for a backend that declared it can use one — on any other,
33
    // the row would offer a choice with no effect.
34
0
    if backend.accepts_context {
35
0
        body.push(context_row(backend, app));
36
0
    }
37
38
0
    if backend.secrets.is_empty() && backend.options.is_empty() {
39
0
        body.push(
40
0
            text::body(if backend.accepts_context {
41
0
                "This backend has no other settings."
42
            } else {
43
0
                "This backend has nothing to configure."
44
            })
45
0
            .into(),
46
        );
47
    } else {
48
0
        let mut section = settings::section();
49
0
        for secret in &backend.secrets {
50
0
            let key = (backend.source.clone(), secret.name.clone());
51
0
            let configured = app
52
0
                .backend_secret_configured
53
0
                .get(&key)
54
0
                .copied()
55
0
                .unwrap_or(false);
56
0
            let input = app
57
0
                .backend_secret_inputs
58
0
                .get(&key)
59
0
                .map_or("", String::as_str);
60
0
            section = section.add(secret_row(&backend.source, secret, configured, input));
61
0
        }
62
0
        for option in &backend.options {
63
            // A boolean has exactly two values and no way to be typed wrong,
64
            // so it gets a switch that writes on the press. Everything else is
65
            // free text and keeps its field + Save.
66
0
            if option.is_bool() {
67
0
                section = section.add(bool_option_row(&backend.source, option));
68
0
                continue;
69
0
            }
70
            // An option offering a closed set is the same case as the switch,
71
            // widened: nothing to type, so nothing to type wrong, and picking
72
            // is the write. It is what an option whose allowed values lived in
73
            // its help text always wanted to be.
74
0
            if option.has_choices() {
75
0
                section = section.add(choice_option_row(&backend.source, option));
76
0
                continue;
77
0
            }
78
0
            let key = (backend.source.clone(), option.name.clone());
79
0
            let input = app
80
0
                .backend_option_inputs
81
0
                .get(&key)
82
0
                .map_or("", String::as_str);
83
0
            section = section.add(option_row(&backend.source, option, input));
84
        }
85
0
        body.push(section.into());
86
    }
87
88
0
    column(body)
89
0
        .spacing(spacing.space_m)
90
0
        .width(Length::Fill)
91
0
        .into()
92
0
}
93
94
/// Which dictation context this backend uses: the active one, a specific one,
95
/// or none.
96
///
97
/// A dropdown rather than the three buttons the API has, because the three
98
/// states are mutually exclusive and one line of a settings sheet is not the
99
/// place for a row of verbs. "Follow the active context" is first and is the
100
/// default; "No context" is last, because it is the one a user picks
101
/// deliberately and rarely.
102
0
fn context_row<'a>(backend: &'a BackendInfo, app: &'a AppModel) -> Element<'a, Message> {
103
0
    let spacing = cosmic::theme::spacing();
104
0
    let source = backend.source.clone();
105
0
    let state = app.contexts.backend_contexts.get(&backend.source);
106
107
    // Index 0 is "follow", index 1 is "none", and the rest are the contexts in
108
    // the order the page lists them — so an index maps back to a choice without
109
    // a second lookup.
110
0
    let contexts = app.contexts.choices();
111
0
    let mut labels: Vec<String> = Vec::with_capacity(contexts.len() + 2);
112
0
    labels.push(match app.contexts.active.as_deref() {
113
0
        Some(id) => format!(
114
            "Follow the active context ({})",
115
0
            app.contexts
116
0
                .get(id)
117
0
                .map_or_else(|| id.to_string(), DictationContext::display_name)
118
        ),
119
0
        None => "Follow the active context (none right now)".to_string(),
120
    });
121
0
    labels.push("No context".to_string());
122
0
    labels.extend(contexts.iter().map(|(_, name)| name.clone()));
123
124
0
    let selected = match state.map(|s| s.mode) {
125
0
        None | Some(ContextMode::Active) => Some(0),
126
0
        Some(ContextMode::Nothing) => Some(1),
127
0
        Some(ContextMode::Pinned) => state
128
0
            .and_then(|s| s.id.as_deref())
129
0
            .and_then(|id| contexts.iter().position(|(cid, _)| cid == id))
130
0
            .map(|i| i + 2)
131
            // Pinned to a context that has since been deleted. The daemon keeps
132
            // the pin, so this reads back as pinned with an id nothing matches;
133
            // showing "follow" would be a lie about what the backend receives.
134
0
            .or(Some(1)),
135
    };
136
137
0
    let ids: Vec<String> = contexts.into_iter().map(|(id, _)| id).collect();
138
0
    let dropdown = widget::dropdown(labels, selected, move |index| match index {
139
0
        0 => Message::Contexts(ContextsMessage::BackendFollowsActive(source.clone())),
140
0
        1 => Message::Contexts(ContextsMessage::BackendPinned {
141
0
            source: source.clone(),
142
0
            id: None,
143
0
        }),
144
0
        n => Message::Contexts(ContextsMessage::BackendPinned {
145
0
            source: source.clone(),
146
0
            id: ids.get(n - 2).cloned(),
147
0
        }),
148
0
    });
149
150
0
    let hint = state.and_then(|s| s.context.as_ref()).map_or_else(
151
0
        || "Nothing is sent to this backend right now.".to_string(),
152
0
        |context| format!("Sending {}.", context.display_name()),
153
    );
154
155
0
    widget::column::with_capacity(2)
156
0
        .spacing(spacing.space_xxs)
157
0
        .push(config_label(
158
0
            "Dictation context".to_string(),
159
0
            Some(hint),
160
            false,
161
        ))
162
0
        .push(dropdown)
163
0
        .into()
164
0
}
165
166
/// A label + optional caption stacked vertically, used as the heading above a
167
/// configuration row's control. The caption is dimmed so it reads as a hint.
168
/// When `required` is true, an accent-colored asterisk follows the title to
169
/// signal that the field must be filled.
170
0
pub(super) fn config_label<'a>(
171
0
    title: String,
172
0
    hint: Option<String>,
173
0
    required: bool,
174
0
) -> Element<'a, Message> {
175
0
    let mut block = widget::column::with_capacity(2).spacing(cosmic::theme::spacing().space_xxxs);
176
0
    let title_row: Element<'a, Message> = if required {
177
0
        row![
178
0
            text::body(title),
179
0
            text::body(" *").class(cosmic::theme::Text::Accent),
180
        ]
181
0
        .into()
182
    } else {
183
0
        text::body(title).into()
184
    };
185
0
    block = block.push(title_row);
186
0
    if let Some(hint) = hint.filter(|h| !h.is_empty()) {
187
0
        block =
188
0
            block.push(text::caption(hint).class(cosmic::theme::Text::Color(muted_text_color())));
189
0
    }
190
0
    block.into()
191
0
}
192
193
/// One secret-entry row for a backend (e.g. an API key): the label/description
194
/// over a full-width password field + Save when unconfigured, or a "Configured"
195
/// badge + Remove when stored. The control gets its own row beneath the label
196
/// so the input spans the (narrow) sheet width instead of being squeezed to the
197
/// right of the label.
198
0
pub(super) fn secret_row<'a>(
199
0
    source: &'a str,
200
0
    secret: &'a BackendSecret,
201
0
    configured: bool,
202
0
    input: &'a str,
203
0
) -> Element<'a, Message> {
204
0
    let spacing = cosmic::theme::spacing();
205
    // Show the backend's human label when set; fall back to the technical name.
206
0
    let display = secret.label.clone().unwrap_or_else(|| secret.name.clone());
207
0
    let description = (!secret.description.is_empty()).then(|| secret.description.clone());
208
0
    let label = config_label(display, description, secret.required);
209
210
0
    let source_owned = source.to_string();
211
0
    let name_owned = secret.name.clone();
212
213
0
    let control: Element<'a, Message> = if configured {
214
0
        let remove_source = source_owned.clone();
215
0
        let remove_name = name_owned.clone();
216
0
        row![
217
0
            text::body("Configured").width(Length::Fill),
218
0
            widget::button::destructive("Remove").on_press(Message::Backend(
219
0
                BackendMessage::BackendSecretRemoved {
220
0
                    source: remove_source,
221
0
                    name: remove_name,
222
0
                },
223
0
            )),
224
        ]
225
0
        .spacing(spacing.space_s)
226
0
        .align_y(Alignment::Center)
227
0
        .into()
228
    } else {
229
0
        let input_source = source_owned.clone();
230
0
        let input_name = name_owned.clone();
231
0
        let field = widget::text_input("Enter API key...", input)
232
0
            .on_input(move |value| {
233
0
                Message::Backend(BackendMessage::BackendSecretInputChanged {
234
0
                    source: input_source.clone(),
235
0
                    name: input_name.clone(),
236
0
                    value,
237
0
                })
238
0
            })
239
0
            .password()
240
0
            .width(Length::Fill);
241
0
        let save = widget::button::standard("Save").on_press(Message::Backend(
242
0
            BackendMessage::BackendSecretSaved {
243
0
                source: source_owned,
244
0
                name: name_owned,
245
0
            },
246
0
        ));
247
0
        row![field, save]
248
0
            .spacing(spacing.space_xs)
249
0
            .align_y(Alignment::Center)
250
0
            .into()
251
    };
252
253
0
    settings::item_row(vec![
254
0
        column![label, control]
255
0
            .spacing(spacing.space_xs)
256
0
            .width(Length::Fill)
257
0
            .into(),
258
    ])
259
0
    .into()
260
0
}
261
262
/// One `type = "bool"` option as a settings row with a switch: label,
263
/// description, and the toggle opposite them.
264
///
265
/// No Save and no Reset — the flip is the save, and flipping back to the
266
/// backend's default is what clearing an override means for two values. The
267
/// switch shows the current state, so the "(default: …)" suffix the text rows
268
/// carry would only restate it.
269
0
pub(super) fn bool_option_row<'a>(
270
0
    source: &'a str,
271
0
    option: &'a BackendOption,
272
0
) -> Element<'a, Message> {
273
0
    let display = option.label.clone().unwrap_or_else(|| option.name.clone());
274
0
    let source = source.to_string();
275
0
    let name = option.name.clone();
276
277
0
    let toggle = widget::toggler(option.bool_value()).on_toggle(move |value| {
278
0
        Message::Backend(BackendMessage::BackendOptionToggled {
279
0
            source: source.clone(),
280
0
            name: name.clone(),
281
0
            value,
282
0
        })
283
0
    });
284
285
0
    let mut item = settings::item::builder(display);
286
0
    if !option.description.is_empty() {
287
0
        item = item.description(option.description.clone());
288
0
    }
289
0
    item.control(toggle).into()
290
0
}
291
292
/// One option row for an option that declares `choices`: the label and
293
/// description beside a dropdown of the values the backend accepts.
294
///
295
/// Like the switch, and unlike the text field, there is no Save button —
296
/// picking is the write. And as with the switch, picking the declared default
297
/// clears the override rather than storing a copy of it, which is what keeps
298
/// the Reset affordance unnecessary here.
299
///
300
/// A stored value the backend no longer offers leaves the dropdown showing its
301
/// placeholder rather than a wrong selection, so the user picks again.
302
0
pub(super) fn choice_option_row<'a>(
303
0
    source: &'a str,
304
0
    option: &'a BackendOption,
305
0
) -> Element<'a, Message> {
306
0
    let display = option.label.clone().unwrap_or_else(|| option.name.clone());
307
0
    let source = source.to_string();
308
0
    let name = option.name.clone();
309
0
    let choices = option.choices.clone();
310
311
0
    let dropdown = widget::dropdown(
312
0
        option.choices.as_slice(),
313
0
        option.choice_index(),
314
0
        move |index| {
315
0
            Message::Backend(BackendMessage::BackendOptionChosen {
316
0
                source: source.clone(),
317
0
                name: name.clone(),
318
0
                value: choices[index].clone(),
319
0
            })
320
0
        },
321
    )
322
0
    .placeholder("Select");
323
324
0
    let mut item = settings::item::builder(display);
325
0
    if !option.description.is_empty() {
326
0
        item = item.description(option.description.clone());
327
0
    }
328
0
    item.control(dropdown).into()
329
0
}
330
331
/// One option-entry row for a backend (e.g. `base_url`): the label/description
332
/// over a full-width text field + Save on its own row beneath, so the input
333
/// isn't squeezed to the right of the label in the narrow sheet.
334
/// When an override is active (stored value differs from the default), a Reset
335
/// button is shown alongside Save to clear the override and revert to the
336
/// daemon default.
337
0
pub(super) fn option_row<'a>(
338
0
    source: &'a str,
339
0
    option: &'a BackendOption,
340
0
    input: &'a str,
341
0
) -> Element<'a, Message> {
342
0
    let spacing = cosmic::theme::spacing();
343
0
    let display = option.label.clone().unwrap_or_else(|| option.name.clone());
344
0
    let mut hint = option.description.clone();
345
0
    if let Some(default) = &option.default
346
0
        && !default.is_empty()
347
    {
348
0
        if hint.is_empty() {
349
0
            hint = format!("Default: {default}");
350
0
        } else {
351
0
            hint = format!("{hint} (default: {default})");
352
0
        }
353
0
    }
354
355
0
    let label = config_label(display, (!hint.is_empty()).then_some(hint), option.required);
356
357
0
    let input_source = source.to_string();
358
0
    let input_name = option.name.clone();
359
0
    let save_source = input_source.clone();
360
0
    let save_name = input_name.clone();
361
362
0
    let field = widget::text_input("", input)
363
0
        .on_input(move |value| {
364
0
            Message::Backend(BackendMessage::BackendOptionInputChanged {
365
0
                source: input_source.clone(),
366
0
                name: input_name.clone(),
367
0
                value,
368
0
            })
369
0
        })
370
0
        .width(Length::Fill);
371
0
    let save = widget::button::standard("Save").on_press(Message::Backend(
372
0
        BackendMessage::BackendOptionSaved {
373
0
            source: save_source,
374
0
            name: save_name,
375
0
        },
376
0
    ));
377
378
    // Show Reset only when an override is stored (value differs from default).
379
0
    let has_override = option.value.as_deref() != option.default.as_deref();
380
0
    let control: Element<'a, Message> = if has_override {
381
0
        let reset_source = source.to_string();
382
0
        let reset_name = option.name.clone();
383
0
        let reset = widget::button::standard("Reset").on_press(Message::Backend(
384
0
            BackendMessage::BackendOptionReset {
385
0
                source: reset_source,
386
0
                name: reset_name,
387
0
            },
388
0
        ));
389
0
        row![field, save, reset]
390
0
            .spacing(spacing.space_xs)
391
0
            .align_y(Alignment::Center)
392
0
            .into()
393
    } else {
394
0
        row![field, save]
395
0
            .spacing(spacing.space_xs)
396
0
            .align_y(Alignment::Center)
397
0
            .into()
398
    };
399
400
0
    settings::item_row(vec![
401
0
        column![label, control]
402
0
            .spacing(spacing.space_xs)
403
0
            .width(Length::Fill)
404
0
            .into(),
405
    ])
406
0
    .into()
407
0
}