bos-settings: full, non-destructive control of every bread* config
The bread/breadpad/breadcrumbs/breadbox views wrote invented schemas (e.g. top-level log_level, [[profile]] name/ssids) that did not match the apps' real TOML, so they showed empty and — worse — clobbered the real config on Save, since the old config::save serialized only the keys it modelled. Rework the config layer onto toml_edit: parse each file into a DocumentMut, mutate only the specific keys a view exposes, and write it back preserving comments and any unmodelled keys (calendar password, saved-network passwords, model paths). Unit-tested. Add ui/widgets.rs (switch/entry/password/dropdown/spin/float/csv rows + view scaffold + save button) bound to the shared document, then rewrite the four views against the real schemas with far more coverage: - bread: [daemon], [lua], [modules], all five [adapters.*] with their sub-options, [events], [notifications] - breadpad: [settings], [model] + [model.ollama], [reminders], [calendar] - breadcrumbs: [settings] (7 keys), [[networks]] editor, [profiles.*] editor - breadbox: fixed to real [[contexts]] name/priority array editor Goal: configure everything from the GUI rather than hand-editing TOML. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c46e348d6a
commit
1f53377914
9 changed files with 1166 additions and 375 deletions
|
|
@ -1,162 +1,477 @@
|
|||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
//! breadcrumbs.toml — Wi-Fi profile state machine.
|
||||
//! Schema mirrors breadcrumbs/src/config.rs:
|
||||
//! [settings] scalar tunables
|
||||
//! [[networks]] saved networks (ssid / password / hidden)
|
||||
//! [profiles.<name>] per-location profile (networks, tailscale, …)
|
||||
//! `[settings]` is edited in place; the `networks` array and `profiles` table
|
||||
//! are rewritten from their editors on save. Other keys/comments are preserved.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
|
||||
};
|
||||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
||||
|
||||
use crate::config;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Default)]
|
||||
pub struct BreadcrumbsConfig {
|
||||
#[serde(default)]
|
||||
pub profile: Vec<Profile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub ssids: Vec<String>,
|
||||
}
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
config::config_dir().join("breadcrumbs/breadcrumbs.toml")
|
||||
}
|
||||
|
||||
fn rebuild_list(list: &ListBox, cfg: &Rc<RefCell<BreadcrumbsConfig>>) {
|
||||
// --- networks ---------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Network {
|
||||
ssid: String,
|
||||
password: String,
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
fn read_networks(doc: &DocumentMut) -> Vec<Network> {
|
||||
let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else {
|
||||
return Vec::new();
|
||||
};
|
||||
aot.iter()
|
||||
.map(|t| Network {
|
||||
ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(),
|
||||
password: t
|
||||
.get("password")
|
||||
.and_then(Item::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_networks(doc: &mut DocumentMut, nets: &[Network]) {
|
||||
let mut aot = ArrayOfTables::new();
|
||||
for n in nets {
|
||||
let mut t = Table::new();
|
||||
t.insert("ssid", value(&n.ssid));
|
||||
t.insert("password", value(&n.password));
|
||||
t.insert("hidden", value(n.hidden));
|
||||
aot.push(t);
|
||||
}
|
||||
doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot));
|
||||
}
|
||||
|
||||
fn rebuild_networks(list: &ListBox, model: &Rc<RefCell<Vec<Network>>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
for (i, profile) in cfg.borrow().profile.iter().enumerate() {
|
||||
for (i, n) in model.borrow().iter().enumerate() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
|
||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
||||
hbox.set_margin_top(6);
|
||||
hbox.set_margin_bottom(6);
|
||||
hbox.set_margin_start(8);
|
||||
hbox.set_margin_end(8);
|
||||
|
||||
let name_entry = Entry::new();
|
||||
name_entry.set_text(&profile.name);
|
||||
name_entry.set_width_chars(14);
|
||||
name_entry.set_placeholder_text(Some("name"));
|
||||
let ssid = Entry::new();
|
||||
ssid.set_text(&n.ssid);
|
||||
ssid.set_width_chars(16);
|
||||
ssid.set_placeholder_text(Some("SSID"));
|
||||
|
||||
let ssids_entry = Entry::new();
|
||||
ssids_entry.set_text(&profile.ssids.join(", "));
|
||||
ssids_entry.set_hexpand(true);
|
||||
ssids_entry.set_placeholder_text(Some("SSID1, SSID2, ..."));
|
||||
let pass = Entry::new();
|
||||
pass.set_text(&n.password);
|
||||
pass.set_hexpand(true);
|
||||
pass.set_visibility(false);
|
||||
pass.set_input_purpose(gtk4::InputPurpose::Password);
|
||||
pass.set_placeholder_text(Some("password"));
|
||||
|
||||
let remove_btn = Button::with_label("Remove");
|
||||
remove_btn.add_css_class("destructive-action");
|
||||
let hidden = Switch::new();
|
||||
hidden.set_active(n.hidden);
|
||||
hidden.set_valign(gtk4::Align::Center);
|
||||
hidden.set_tooltip_text(Some("Hidden network"));
|
||||
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let cfg = cfg.clone();
|
||||
name_entry.connect_changed(move |e| {
|
||||
if let Some(p) = cfg.borrow_mut().profile.get_mut(i) {
|
||||
p.name = e.text().to_string();
|
||||
let model = model.clone();
|
||||
ssid.connect_changed(move |e| {
|
||||
if let Some(n) = model.borrow_mut().get_mut(i) {
|
||||
n.ssid = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let cfg = cfg.clone();
|
||||
ssids_entry.connect_changed(move |e| {
|
||||
if let Some(p) = cfg.borrow_mut().profile.get_mut(i) {
|
||||
p.ssids = e.text()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let model = model.clone();
|
||||
pass.connect_changed(move |e| {
|
||||
if let Some(n) = model.borrow_mut().get_mut(i) {
|
||||
n.password = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let cfg = cfg.clone();
|
||||
let model = model.clone();
|
||||
hidden.connect_active_notify(move |s| {
|
||||
if let Some(n) = model.borrow_mut().get_mut(i) {
|
||||
n.hidden = s.is_active();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
remove_btn.connect_clicked(move |_| {
|
||||
cfg.borrow_mut().profile.remove(i);
|
||||
rebuild_list(&list, &cfg);
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
rebuild_networks(&list, &model);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&name_entry);
|
||||
hbox.append(&ssids_entry);
|
||||
hbox.append(&remove_btn);
|
||||
hbox.append(&ssid);
|
||||
hbox.append(&pass);
|
||||
hbox.append(&Label::new(Some("hidden")));
|
||||
hbox.append(&hidden);
|
||||
hbox.append(&remove);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
// --- profiles ---------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Profile {
|
||||
name: String,
|
||||
networks: Vec<String>,
|
||||
detect_ssids: Vec<String>,
|
||||
bootstrap: String,
|
||||
exit_node: String,
|
||||
tailscale: bool,
|
||||
include_all_known: bool,
|
||||
}
|
||||
|
||||
fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
|
||||
let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let str_list = |item: Option<&Item>| -> Vec<String> {
|
||||
item.and_then(Item::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
tbl.iter()
|
||||
.filter_map(|(name, item)| {
|
||||
let p = item.as_table()?;
|
||||
Some(Profile {
|
||||
name: name.to_string(),
|
||||
networks: str_list(p.get("networks")),
|
||||
detect_ssids: str_list(p.get("detect_ssids")),
|
||||
bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(),
|
||||
exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(),
|
||||
tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false),
|
||||
include_all_known: p
|
||||
.get("include_all_known")
|
||||
.and_then(Item::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) {
|
||||
let mut tbl = Table::new();
|
||||
let to_arr = |items: &[String]| {
|
||||
let mut a = Array::new();
|
||||
for s in items {
|
||||
a.push(s.as_str());
|
||||
}
|
||||
a
|
||||
};
|
||||
for p in profiles {
|
||||
if p.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut t = Table::new();
|
||||
t.insert("networks", value(to_arr(&p.networks)));
|
||||
t.insert("tailscale", value(p.tailscale));
|
||||
t.insert("include_all_known", value(p.include_all_known));
|
||||
if !p.detect_ssids.is_empty() {
|
||||
t.insert("detect_ssids", value(to_arr(&p.detect_ssids)));
|
||||
}
|
||||
if !p.bootstrap.is_empty() {
|
||||
t.insert("bootstrap", value(&p.bootstrap));
|
||||
}
|
||||
if !p.exit_node.is_empty() {
|
||||
t.insert("exit_node", value(&p.exit_node));
|
||||
}
|
||||
tbl.insert(&p.name, Item::Table(t));
|
||||
}
|
||||
doc.as_table_mut().insert("profiles", Item::Table(tbl));
|
||||
}
|
||||
|
||||
fn field(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
||||
let row = GBox::new(Orientation::Horizontal, 12);
|
||||
let lbl = Label::new(Some(label));
|
||||
lbl.set_xalign(0.0);
|
||||
lbl.set_width_chars(16);
|
||||
row.append(&lbl);
|
||||
control.set_hexpand(true);
|
||||
row.append(control);
|
||||
row
|
||||
}
|
||||
|
||||
fn rebuild_profiles(container: &GBox, model: &Rc<RefCell<Vec<Profile>>>) {
|
||||
while let Some(child) = container.first_child() {
|
||||
container.remove(&child);
|
||||
}
|
||||
for (i, p) in model.borrow().iter().enumerate() {
|
||||
let card = GBox::new(Orientation::Vertical, 6);
|
||||
card.add_css_class("card");
|
||||
card.set_margin_top(6);
|
||||
card.set_margin_bottom(6);
|
||||
|
||||
let header = GBox::new(Orientation::Horizontal, 8);
|
||||
let name = Entry::new();
|
||||
name.set_text(&p.name);
|
||||
name.set_hexpand(true);
|
||||
name.set_placeholder_text(Some("profile name (e.g. home)"));
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
header.append(&name);
|
||||
header.append(&remove);
|
||||
card.append(&header);
|
||||
|
||||
let networks = Entry::new();
|
||||
networks.set_text(&p.networks.join(", "));
|
||||
networks.set_placeholder_text(Some("SSID1, SSID2"));
|
||||
card.append(&field("Networks", &networks));
|
||||
|
||||
let detect = Entry::new();
|
||||
detect.set_text(&p.detect_ssids.join(", "));
|
||||
detect.set_placeholder_text(Some("SSIDs that auto-select this profile"));
|
||||
card.append(&field("Detect SSIDs", &detect));
|
||||
|
||||
let exit_node = Entry::new();
|
||||
exit_node.set_text(&p.exit_node);
|
||||
exit_node.set_placeholder_text(Some("tailscale exit node (optional)"));
|
||||
card.append(&field("Exit node", &exit_node));
|
||||
|
||||
let bootstrap = Entry::new();
|
||||
bootstrap.set_text(&p.bootstrap);
|
||||
bootstrap.set_placeholder_text(Some("bootstrap command (optional)"));
|
||||
card.append(&field("Bootstrap", &bootstrap));
|
||||
|
||||
let tailscale = Switch::new();
|
||||
tailscale.set_active(p.tailscale);
|
||||
tailscale.set_halign(gtk4::Align::Start);
|
||||
card.append(&field("Tailscale", &tailscale));
|
||||
|
||||
let include_all = Switch::new();
|
||||
include_all.set_active(p.include_all_known);
|
||||
include_all.set_halign(gtk4::Align::Start);
|
||||
card.append(&field("Include all known", &include_all));
|
||||
|
||||
// bind each control to the in-memory model entry
|
||||
macro_rules! bind_csv {
|
||||
($entry:ident, $f:ident) => {{
|
||||
let model = model.clone();
|
||||
$entry.connect_changed(move |e| {
|
||||
if let Some(p) = model.borrow_mut().get_mut(i) {
|
||||
p.$f = e
|
||||
.text()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
});
|
||||
}};
|
||||
}
|
||||
macro_rules! bind_str {
|
||||
($entry:ident, $f:ident) => {{
|
||||
let model = model.clone();
|
||||
$entry.connect_changed(move |e| {
|
||||
if let Some(p) = model.borrow_mut().get_mut(i) {
|
||||
p.$f = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}};
|
||||
}
|
||||
macro_rules! bind_bool {
|
||||
($sw:ident, $f:ident) => {{
|
||||
let model = model.clone();
|
||||
$sw.connect_active_notify(move |s| {
|
||||
if let Some(p) = model.borrow_mut().get_mut(i) {
|
||||
p.$f = s.is_active();
|
||||
}
|
||||
});
|
||||
}};
|
||||
}
|
||||
bind_str!(name, name);
|
||||
bind_csv!(networks, networks);
|
||||
bind_csv!(detect, detect_ssids);
|
||||
bind_str!(exit_node, exit_node);
|
||||
bind_str!(bootstrap, bootstrap);
|
||||
bind_bool!(tailscale, tailscale);
|
||||
bind_bool!(include_all, include_all_known);
|
||||
{
|
||||
let model = model.clone();
|
||||
let container = container.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
rebuild_profiles(&container, &model);
|
||||
});
|
||||
}
|
||||
|
||||
container.append(&card);
|
||||
}
|
||||
}
|
||||
|
||||
// --- view -------------------------------------------------------------------
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let path = config_path();
|
||||
let cfg: BreadcrumbsConfig = config::load(&path).unwrap_or_default();
|
||||
let cfg = Rc::new(RefCell::new(cfg));
|
||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||
let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
|
||||
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
|
||||
|
||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
||||
vbox.add_css_class("view-content");
|
||||
let outer = GBox::new(Orientation::Vertical, 8);
|
||||
outer.add_css_class("view-content");
|
||||
|
||||
let title = Label::new(Some("breadcrumbs"));
|
||||
title.add_css_class("title");
|
||||
title.set_xalign(0.0);
|
||||
vbox.append(&title);
|
||||
|
||||
let subtitle = Label::new(Some("Network profiles — SSIDs associated with each location."));
|
||||
subtitle.set_xalign(0.0);
|
||||
subtitle.set_margin_bottom(8);
|
||||
vbox.append(&subtitle);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
rebuild_list(&list, &cfg);
|
||||
outer.append(&title);
|
||||
|
||||
let content = GBox::new(Orientation::Vertical, 8);
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_child(Some(&list));
|
||||
vbox.append(&scroll);
|
||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
||||
scroll.set_child(Some(&content));
|
||||
outer.append(&scroll);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||
btn_row.set_margin_top(8);
|
||||
// [settings] — edited in place on the shared doc
|
||||
content.append(&w::section("Settings"));
|
||||
content.append(&w::dropdown_row(
|
||||
"Default profile",
|
||||
&doc,
|
||||
&["settings", "default_profile"],
|
||||
&["home", "away"],
|
||||
"home",
|
||||
));
|
||||
content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", ""));
|
||||
content.append(&w::entry_row(
|
||||
"Exit node",
|
||||
&doc,
|
||||
&["settings", "exit_node"],
|
||||
"tailscale exit node",
|
||||
"",
|
||||
));
|
||||
content.append(&w::entry_row(
|
||||
"Ping host",
|
||||
&doc,
|
||||
&["settings", "ping_host"],
|
||||
"1.1.1.1",
|
||||
"",
|
||||
));
|
||||
content.append(&w::entry_row(
|
||||
"Connectivity URL",
|
||||
&doc,
|
||||
&["settings", "connectivity_url"],
|
||||
"http://connectivitycheck.gstatic.com/generate_204",
|
||||
"",
|
||||
));
|
||||
content.append(&w::spin_row(
|
||||
"nmcli wait (s)",
|
||||
&doc,
|
||||
&["settings", "nmcli_wait"],
|
||||
1.0,
|
||||
120.0,
|
||||
1.0,
|
||||
8,
|
||||
));
|
||||
content.append(&w::spin_row(
|
||||
"Watch interval (s)",
|
||||
&doc,
|
||||
&["settings", "watch_interval"],
|
||||
1.0,
|
||||
600.0,
|
||||
1.0,
|
||||
12,
|
||||
));
|
||||
|
||||
let add_btn = Button::with_label("Add profile");
|
||||
// [[networks]]
|
||||
content.append(&w::section("Saved networks"));
|
||||
let net_list = ListBox::new();
|
||||
net_list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
rebuild_networks(&net_list, &nets);
|
||||
content.append(&net_list);
|
||||
let add_net = Button::with_label("Add network");
|
||||
add_net.set_halign(gtk4::Align::Start);
|
||||
{
|
||||
let cfg = cfg.clone();
|
||||
let list = list.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
cfg.borrow_mut().profile.push(Profile {
|
||||
name: "new".to_string(),
|
||||
ssids: Vec::new(),
|
||||
});
|
||||
rebuild_list(&list, &cfg);
|
||||
let nets = nets.clone();
|
||||
let net_list = net_list.clone();
|
||||
add_net.connect_clicked(move |_| {
|
||||
nets.borrow_mut().push(Network::default());
|
||||
rebuild_networks(&net_list, &nets);
|
||||
});
|
||||
}
|
||||
content.append(&add_net);
|
||||
|
||||
let save_btn = Button::with_label("Save");
|
||||
let status_lbl = Label::new(None);
|
||||
status_lbl.add_css_class("dim-label");
|
||||
|
||||
// [profiles.*]
|
||||
content.append(&w::section("Profiles"));
|
||||
let prof_box = GBox::new(Orientation::Vertical, 4);
|
||||
rebuild_profiles(&prof_box, &profiles);
|
||||
content.append(&prof_box);
|
||||
let add_prof = Button::with_label("Add profile");
|
||||
add_prof.set_halign(gtk4::Align::Start);
|
||||
{
|
||||
let cfg = cfg.clone();
|
||||
let profiles = profiles.clone();
|
||||
let prof_box = prof_box.clone();
|
||||
add_prof.connect_clicked(move |_| {
|
||||
profiles.borrow_mut().push(Profile {
|
||||
name: "new".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
rebuild_profiles(&prof_box, &profiles);
|
||||
});
|
||||
}
|
||||
content.append(&add_prof);
|
||||
|
||||
// Save — fold the network + profile editors back into the doc, then write.
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(16);
|
||||
let save_btn = Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let doc = doc.clone();
|
||||
let nets = nets.clone();
|
||||
let profiles = profiles.clone();
|
||||
let path = path.clone();
|
||||
let status_lbl = status_lbl.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
match config::save(&path, &*cfg.borrow()) {
|
||||
{
|
||||
let mut d = doc.borrow_mut();
|
||||
write_networks(&mut d, &nets.borrow());
|
||||
write_profiles(&mut d, &profiles.borrow());
|
||||
}
|
||||
match config::save_doc(&path, &doc.borrow()) {
|
||||
Ok(()) => {
|
||||
status_lbl.set_text("Saved");
|
||||
let lbl = status_lbl.clone();
|
||||
status.set_text("Saved");
|
||||
let lbl = status.clone();
|
||||
glib::timeout_add_seconds_local(3, move || {
|
||||
lbl.set_text("");
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
Err(e) => status_lbl.set_text(&format!("Error: {e}")),
|
||||
Err(e) => status.set_text(&format!("Error: {e}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&add_btn);
|
||||
btn_row.append(&save_btn);
|
||||
btn_row.append(&status_lbl);
|
||||
vbox.append(&btn_row);
|
||||
btn_row.append(&status);
|
||||
outer.append(&btn_row);
|
||||
|
||||
vbox
|
||||
outer
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue