Coverage Report

Created: 2026-09-05 23:00

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::ui::messages::{BackendMessage, Message};
10
11
use super::surface::muted_text_color;
12
13
/// Body of the per-backend configuration sheet (shown in the right-side
14
/// context drawer): the backend's secrets (system keyring) and options (daemon
15
/// config) as one settings section. The drawer supplies the title and close
16
/// affordance, so there's no in-body header or Back button.
17
0
pub fn configure_sheet<'a>(backend: &'a BackendInfo, app: &'a AppModel) -> Element<'a, Message> {
18
0
    let spacing = cosmic::theme::spacing();
19
20
0
    let mut body: Vec<Element<'a, Message>> = Vec::new();
21
22
    // Surface a failed secret/option save inline in the sheet (Tier 1 #15)
23
    // instead of dropping it to the log.
24
0
    if let Some(message) = app.action_error_for(crate::state::ErrorScope::ConfigureBackend) {
25
0
        body.push(crate::ui::views::common::error_banner(message));
26
0
    }
27
28
0
    if backend.secrets.is_empty() && backend.options.is_empty() {
29
0
        body.push(text::body("This backend has nothing to configure.").into());
30
0
    } else {
31
0
        let mut section = settings::section();
32
0
        for secret in &backend.secrets {
33
0
            let key = (backend.source.clone(), secret.name.clone());
34
0
            let configured = app
35
0
                .backend_secret_configured
36
0
                .get(&key)
37
0
                .copied()
38
0
                .unwrap_or(false);
39
0
            let input = app
40
0
                .backend_secret_inputs
41
0
                .get(&key)
42
0
                .map_or("", String::as_str);
43
0
            section = section.add(secret_row(&backend.source, secret, configured, input));
44
0
        }
45
0
        for option in &backend.options {
46
            // A boolean has exactly two values and no way to be typed wrong,
47
            // so it gets a switch that writes on the press. Everything else is
48
            // free text and keeps its field + Save.
49
0
            if option.is_bool() {
50
0
                section = section.add(bool_option_row(&backend.source, option));
51
0
                continue;
52
0
            }
53
0
            let key = (backend.source.clone(), option.name.clone());
54
0
            let input = app
55
0
                .backend_option_inputs
56
0
                .get(&key)
57
0
                .map_or("", String::as_str);
58
0
            section = section.add(option_row(&backend.source, option, input));
59
        }
60
0
        body.push(section.into());
61
    }
62
63
0
    column(body)
64
0
        .spacing(spacing.space_m)
65
0
        .width(Length::Fill)
66
0
        .into()
67
0
}
68
69
/// A label + optional caption stacked vertically, used as the heading above a
70
/// configuration row's control. The caption is dimmed so it reads as a hint.
71
/// When `required` is true, an accent-colored asterisk follows the title to
72
/// signal that the field must be filled.
73
0
pub(super) fn config_label<'a>(
74
0
    title: String,
75
0
    hint: Option<String>,
76
0
    required: bool,
77
0
) -> Element<'a, Message> {
78
0
    let mut block = widget::column::with_capacity(2).spacing(cosmic::theme::spacing().space_xxxs);
79
0
    let title_row: Element<'a, Message> = if required {
80
0
        row![
81
0
            text::body(title),
82
0
            text::body(" *").class(cosmic::theme::Text::Accent),
83
        ]
84
0
        .into()
85
    } else {
86
0
        text::body(title).into()
87
    };
88
0
    block = block.push(title_row);
89
0
    if let Some(hint) = hint.filter(|h| !h.is_empty()) {
90
0
        block =
91
0
            block.push(text::caption(hint).class(cosmic::theme::Text::Color(muted_text_color())));
92
0
    }
93
0
    block.into()
94
0
}
95
96
/// One secret-entry row for a backend (e.g. an API key): the label/description
97
/// over a full-width password field + Save when unconfigured, or a "Configured"
98
/// badge + Remove when stored. The control gets its own row beneath the label
99
/// so the input spans the (narrow) sheet width instead of being squeezed to the
100
/// right of the label.
101
0
pub(super) fn secret_row<'a>(
102
0
    source: &'a str,
103
0
    secret: &'a BackendSecret,
104
0
    configured: bool,
105
0
    input: &'a str,
106
0
) -> Element<'a, Message> {
107
0
    let spacing = cosmic::theme::spacing();
108
    // Show the backend's human label when set; fall back to the technical name.
109
0
    let display = secret.label.clone().unwrap_or_else(|| secret.name.clone());
110
0
    let description = (!secret.description.is_empty()).then(|| secret.description.clone());
111
0
    let label = config_label(display, description, secret.required);
112
113
0
    let source_owned = source.to_string();
114
0
    let name_owned = secret.name.clone();
115
116
0
    let control: Element<'a, Message> = if configured {
117
0
        let remove_source = source_owned.clone();
118
0
        let remove_name = name_owned.clone();
119
0
        row![
120
0
            text::body("Configured").width(Length::Fill),
121
0
            widget::button::destructive("Remove").on_press(Message::Backend(
122
0
                BackendMessage::BackendSecretRemoved {
123
0
                    source: remove_source,
124
0
                    name: remove_name,
125
0
                },
126
0
            )),
127
        ]
128
0
        .spacing(spacing.space_s)
129
0
        .align_y(Alignment::Center)
130
0
        .into()
131
    } else {
132
0
        let input_source = source_owned.clone();
133
0
        let input_name = name_owned.clone();
134
0
        let field = widget::text_input("Enter API key...", input)
135
0
            .on_input(move |value| {
136
0
                Message::Backend(BackendMessage::BackendSecretInputChanged {
137
0
                    source: input_source.clone(),
138
0
                    name: input_name.clone(),
139
0
                    value,
140
0
                })
141
0
            })
142
0
            .password()
143
0
            .width(Length::Fill);
144
0
        let save = widget::button::standard("Save").on_press(Message::Backend(
145
0
            BackendMessage::BackendSecretSaved {
146
0
                source: source_owned,
147
0
                name: name_owned,
148
0
            },
149
0
        ));
150
0
        row![field, save]
151
0
            .spacing(spacing.space_xs)
152
0
            .align_y(Alignment::Center)
153
0
            .into()
154
    };
155
156
0
    settings::item_row(vec![
157
0
        column![label, control]
158
0
            .spacing(spacing.space_xs)
159
0
            .width(Length::Fill)
160
0
            .into(),
161
    ])
162
0
    .into()
163
0
}
164
165
/// One `type = "bool"` option as a settings row with a switch: label,
166
/// description, and the toggle opposite them.
167
///
168
/// No Save and no Reset — the flip is the save, and flipping back to the
169
/// backend's default is what clearing an override means for two values. The
170
/// switch shows the current state, so the "(default: …)" suffix the text rows
171
/// carry would only restate it.
172
0
pub(super) fn bool_option_row<'a>(
173
0
    source: &'a str,
174
0
    option: &'a BackendOption,
175
0
) -> Element<'a, Message> {
176
0
    let display = option.label.clone().unwrap_or_else(|| option.name.clone());
177
0
    let source = source.to_string();
178
0
    let name = option.name.clone();
179
180
0
    let toggle = widget::toggler(option.bool_value()).on_toggle(move |value| {
181
0
        Message::Backend(BackendMessage::BackendOptionToggled {
182
0
            source: source.clone(),
183
0
            name: name.clone(),
184
0
            value,
185
0
        })
186
0
    });
187
188
0
    let mut item = settings::item::builder(display);
189
0
    if !option.description.is_empty() {
190
0
        item = item.description(option.description.clone());
191
0
    }
192
0
    item.control(toggle).into()
193
0
}
194
195
/// One option-entry row for a backend (e.g. `base_url`): the label/description
196
/// over a full-width text field + Save on its own row beneath, so the input
197
/// isn't squeezed to the right of the label in the narrow sheet.
198
/// When an override is active (stored value differs from the default), a Reset
199
/// button is shown alongside Save to clear the override and revert to the
200
/// daemon default.
201
0
pub(super) fn option_row<'a>(
202
0
    source: &'a str,
203
0
    option: &'a BackendOption,
204
0
    input: &'a str,
205
0
) -> Element<'a, Message> {
206
0
    let spacing = cosmic::theme::spacing();
207
0
    let display = option.label.clone().unwrap_or_else(|| option.name.clone());
208
0
    let mut hint = option.description.clone();
209
0
    if let Some(default) = &option.default
210
0
        && !default.is_empty()
211
    {
212
0
        if hint.is_empty() {
213
0
            hint = format!("Default: {default}");
214
0
        } else {
215
0
            hint = format!("{hint} (default: {default})");
216
0
        }
217
0
    }
218
219
0
    let label = config_label(display, (!hint.is_empty()).then_some(hint), option.required);
220
221
0
    let input_source = source.to_string();
222
0
    let input_name = option.name.clone();
223
0
    let save_source = input_source.clone();
224
0
    let save_name = input_name.clone();
225
226
0
    let field = widget::text_input("", input)
227
0
        .on_input(move |value| {
228
0
            Message::Backend(BackendMessage::BackendOptionInputChanged {
229
0
                source: input_source.clone(),
230
0
                name: input_name.clone(),
231
0
                value,
232
0
            })
233
0
        })
234
0
        .width(Length::Fill);
235
0
    let save = widget::button::standard("Save").on_press(Message::Backend(
236
0
        BackendMessage::BackendOptionSaved {
237
0
            source: save_source,
238
0
            name: save_name,
239
0
        },
240
0
    ));
241
242
    // Show Reset only when an override is stored (value differs from default).
243
0
    let has_override = option.value.as_deref() != option.default.as_deref();
244
0
    let control: Element<'a, Message> = if has_override {
245
0
        let reset_source = source.to_string();
246
0
        let reset_name = option.name.clone();
247
0
        let reset = widget::button::standard("Reset").on_press(Message::Backend(
248
0
            BackendMessage::BackendOptionReset {
249
0
                source: reset_source,
250
0
                name: reset_name,
251
0
            },
252
0
        ));
253
0
        row![field, save, reset]
254
0
            .spacing(spacing.space_xs)
255
0
            .align_y(Alignment::Center)
256
0
            .into()
257
    } else {
258
0
        row![field, save]
259
0
            .spacing(spacing.space_xs)
260
0
            .align_y(Alignment::Center)
261
0
            .into()
262
    };
263
264
0
    settings::item_row(vec![
265
0
        column![label, control]
266
0
            .spacing(spacing.space_xs)
267
0
            .width(Length::Fill)
268
0
            .into(),
269
    ])
270
0
    .into()
271
0
}