dev #1
22 changed files with 1310 additions and 250 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bos-settings"
|
name = "bos-settings"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"bread-theme",
|
"bread-theme",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bos-settings"
|
name = "bos-settings"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,26 @@ use std::cell::RefCell;
|
||||||
const APP_CSS: &str = "\
|
const APP_CSS: &str = "\
|
||||||
.view-content { padding: 24px; }\n\
|
.view-content { padding: 24px; }\n\
|
||||||
.view-content > label.title { margin-bottom: 16px; }\n\
|
.view-content > label.title { margin-bottom: 16px; }\n\
|
||||||
|
/* Sidebar row sub-labels (the underlying binary/config name under a row's \
|
||||||
|
human label) — smaller than the shared sheet's default dim-label size. */\n\
|
||||||
|
.caption { font-size: 11px; }\n\
|
||||||
|
/* bread-theme's shared sheet only overrides background-color on \
|
||||||
|
suggested/destructive buttons, not background-image — so Adwaita's \
|
||||||
|
built-in gradient (bright blue/red) paints over our flat colour \
|
||||||
|
underneath it. Belongs upstream in bread-theme; patched locally here \
|
||||||
|
until that's worth its own release. */\n\
|
||||||
|
button.suggested-action, button.destructive-action { background-image: none; }\n\
|
||||||
|
/* Same upstream gap for scale (Sound's volume sliders) — the shared sheet \
|
||||||
|
has no `scale` rules at all, so they render in Adwaita's default blue. */\n\
|
||||||
|
scale trough { background-color: alpha(@on-surface, 0.15); border-radius: 999px; min-height: 6px; background-image: none; }\n\
|
||||||
|
scale highlight { background-color: @accent; background-image: none; border-radius: 999px; }\n\
|
||||||
|
scale slider { background-color: @on-surface; border-radius: 999px; }\n\
|
||||||
|
/* Destructive actions must not follow the wallpaper palette: @red is \
|
||||||
|
pywal's color1, which can land on gold/yellow/anything depending on the \
|
||||||
|
wallpaper (it did, this session) — making Delete/Remove look like a \
|
||||||
|
primary action instead of a dangerous one. Fixed regardless of palette. */\n\
|
||||||
|
button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\
|
||||||
|
button.destructive-action:hover { background-color: #d64535; }\n\
|
||||||
";
|
";
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,80 @@
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation};
|
use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation};
|
||||||
|
|
||||||
pub struct SidebarItem {
|
pub struct SidebarItem {
|
||||||
|
/// Must match the `Stack` page name registered in `window.rs`.
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
pub label: &'static str,
|
pub label: &'static str,
|
||||||
|
/// Dim second line — the underlying binary/config name, for items whose
|
||||||
|
/// human label doesn't already make that obvious.
|
||||||
|
pub sublabel: Option<&'static str>,
|
||||||
|
/// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships
|
||||||
|
/// the full Adwaita-compatible symbolic set this app relies on).
|
||||||
|
pub icon: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const APPS_ITEMS: &[SidebarItem] = &[
|
const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem {
|
||||||
SidebarItem { id: "bread", label: "bread" },
|
SidebarItem { id, label, sublabel: None, icon }
|
||||||
SidebarItem { id: "breadbar", label: "breadbar" },
|
}
|
||||||
SidebarItem { id: "breadbox", label: "breadbox" },
|
|
||||||
SidebarItem { id: "breadcrumbs", label: "breadcrumbs" },
|
const fn item_sub(
|
||||||
SidebarItem { id: "breadpad", label: "breadpad" },
|
id: &'static str,
|
||||||
SidebarItem { id: "breadpaper", label: "breadpaper" },
|
label: &'static str,
|
||||||
SidebarItem { id: "breadsearch", label: "breadsearch" },
|
sublabel: &'static str,
|
||||||
|
icon: &'static str,
|
||||||
|
) -> SidebarItem {
|
||||||
|
SidebarItem { id, label, sublabel: Some(sublabel), icon }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouped by task, not by "app vs system internals" — a user thinks "I want
|
||||||
|
// to change my Wi-Fi" or "I want to change my wallpaper", not "which of
|
||||||
|
// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of
|
||||||
|
// the old "Apps" bucket into System for the same reason.
|
||||||
|
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
|
||||||
|
item("network", "Network", "network-wireless-symbolic"),
|
||||||
|
item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"),
|
||||||
|
item("sound", "Sound", "audio-volume-high-symbolic"),
|
||||||
|
item("datetime", "Date & Time", "preferences-system-time-symbolic"),
|
||||||
|
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
|
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
|
||||||
SidebarItem { id: "snapshots", label: "Snapshots" },
|
item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"),
|
||||||
SidebarItem { id: "packages", label: "Packages" },
|
item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"),
|
||||||
SidebarItem { id: "hyprland", label: "Hyprland" },
|
item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"),
|
||||||
|
item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"),
|
||||||
|
item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"),
|
||||||
|
item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"),
|
||||||
|
item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[
|
||||||
|
item("packages", "Packages", "package-x-generic-symbolic"),
|
||||||
|
item("snapshots", "Snapshots", "document-open-recent-symbolic"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")];
|
||||||
|
|
||||||
pub fn build() -> (GBox, ListBox) {
|
pub fn build() -> (GBox, ListBox) {
|
||||||
let vbox = GBox::new(Orientation::Vertical, 0);
|
let vbox = GBox::new(Orientation::Vertical, 0);
|
||||||
vbox.add_css_class("sidebar");
|
vbox.add_css_class("sidebar");
|
||||||
vbox.set_width_request(190);
|
vbox.set_width_request(210);
|
||||||
|
|
||||||
let list = ListBox::new();
|
let list = ListBox::new();
|
||||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||||
list.add_css_class("sidebar");
|
list.add_css_class("sidebar");
|
||||||
|
|
||||||
append_section(&list, "Apps", APPS_ITEMS);
|
|
||||||
append_section(&list, "System", SYSTEM_ITEMS);
|
append_section(&list, "System", SYSTEM_ITEMS);
|
||||||
|
append_section(&list, "Personalization", PERSONALIZATION_ITEMS);
|
||||||
|
append_section(&list, "Maintenance", MAINTENANCE_ITEMS);
|
||||||
|
append_section(&list, None, ABOUT_ITEMS);
|
||||||
|
|
||||||
// Select the bread row so it matches the default stack page
|
// Select About so it matches the default stack page.
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
loop {
|
loop {
|
||||||
match list.row_at_index(i) {
|
match list.row_at_index(i) {
|
||||||
None => break,
|
None => break,
|
||||||
Some(row) if row.widget_name() == "bread" => {
|
Some(row) if row.widget_name() == "about" => {
|
||||||
list.select_row(Some(&row));
|
list.select_row(Some(&row));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -51,24 +86,47 @@ pub fn build() -> (GBox, ListBox) {
|
||||||
(vbox, list)
|
(vbox, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) {
|
fn append_section(list: &ListBox, title: impl Into<Option<&'static str>>, items: &[SidebarItem]) {
|
||||||
let header_row = ListBoxRow::new();
|
if let Some(title) = title.into() {
|
||||||
header_row.set_selectable(false);
|
let header_row = ListBoxRow::new();
|
||||||
header_row.set_activatable(false);
|
header_row.set_selectable(false);
|
||||||
let header_lbl = Label::new(Some(title));
|
header_row.set_activatable(false);
|
||||||
header_lbl.add_css_class("section-header");
|
let header_lbl = Label::new(Some(title));
|
||||||
header_lbl.set_xalign(0.0);
|
header_lbl.add_css_class("section-header");
|
||||||
header_row.set_child(Some(&header_lbl));
|
header_lbl.set_xalign(0.0);
|
||||||
list.append(&header_row);
|
header_row.set_child(Some(&header_lbl));
|
||||||
|
list.append(&header_row);
|
||||||
|
}
|
||||||
|
|
||||||
for item in items {
|
for entry in items {
|
||||||
let row = ListBoxRow::new();
|
let row = ListBoxRow::new();
|
||||||
row.set_widget_name(item.id);
|
row.set_widget_name(entry.id);
|
||||||
let lbl = Label::new(Some(item.label));
|
|
||||||
|
let hbox = GBox::new(Orientation::Horizontal, 10);
|
||||||
|
hbox.set_margin_top(4);
|
||||||
|
hbox.set_margin_bottom(4);
|
||||||
|
|
||||||
|
let icon = Image::from_icon_name(entry.icon);
|
||||||
|
icon.set_pixel_size(16);
|
||||||
|
hbox.append(&icon);
|
||||||
|
|
||||||
|
let labels = GBox::new(Orientation::Vertical, 0);
|
||||||
|
let lbl = Label::new(Some(entry.label));
|
||||||
lbl.set_xalign(0.0);
|
lbl.set_xalign(0.0);
|
||||||
lbl.set_margin_top(2);
|
labels.append(&lbl);
|
||||||
lbl.set_margin_bottom(2);
|
if let Some(sub) = entry.sublabel {
|
||||||
row.set_child(Some(&lbl));
|
let sub_lbl = Label::new(Some(sub));
|
||||||
|
sub_lbl.add_css_class("dim-label");
|
||||||
|
sub_lbl.set_xalign(0.0);
|
||||||
|
// Match the sidebar's smaller "section-header" scale rather than
|
||||||
|
// the shared sheet's default dim-label size, so it reads as a
|
||||||
|
// caption under the row label, not a second full-size label.
|
||||||
|
sub_lbl.add_css_class("caption");
|
||||||
|
labels.append(&sub_lbl);
|
||||||
|
}
|
||||||
|
hbox.append(&labels);
|
||||||
|
|
||||||
|
row.set_child(Some(&hbox));
|
||||||
list.append(&row);
|
list.append(&row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
172
bos-settings/src/ui/views/about.rs
Normal file
172
bos-settings/src/ui/views/about.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
//! Read-only system info, plus the one thing worth making writable: hostname.
|
||||||
|
//! BOS is a rolling release (no fixed version number to show — `os-release`
|
||||||
|
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
|
||||||
|
//! here the way a point-release distro's About panel would have one.
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GBox, Button, Entry, Label, Orientation};
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
|
fn os_pretty_name() -> String {
|
||||||
|
fs::read_to_string("/etc/os-release")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
s.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "BOS".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hostname() -> String {
|
||||||
|
fs::read_to_string("/etc/hostname")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kernel() -> String {
|
||||||
|
Command::new("uname")
|
||||||
|
.arg("-r")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cpu() -> String {
|
||||||
|
let model = fs::read_to_string("/proc/cpuinfo")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
s.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
||||||
|
if cores > 0 {
|
||||||
|
format!("{model} ({cores} threads)")
|
||||||
|
} else {
|
||||||
|
model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memory() -> String {
|
||||||
|
let kb = fs::read_to_string("/proc/meminfo")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
s.lines()
|
||||||
|
.find(|l| l.starts_with("MemTotal:"))
|
||||||
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
|
});
|
||||||
|
match kb {
|
||||||
|
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
|
||||||
|
None => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gpu() -> String {
|
||||||
|
let Ok(output) = Command::new("lspci").output() else {
|
||||||
|
return "unknown".to_string();
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
text.lines()
|
||||||
|
// "Display controller" covers integrated GPUs some laptop chipsets
|
||||||
|
// (this dev laptop's AMD Radeon 860M included) report under instead
|
||||||
|
// of "VGA compatible controller" — without it those show "unknown".
|
||||||
|
.find(|l| {
|
||||||
|
l.contains("VGA compatible controller")
|
||||||
|
|| l.contains("3D controller")
|
||||||
|
|| l.contains("Display controller")
|
||||||
|
})
|
||||||
|
.and_then(|l| l.split(": ").nth(1))
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disk_usage() -> String {
|
||||||
|
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else {
|
||||||
|
return "unknown".to_string();
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
text.lines()
|
||||||
|
.nth(1)
|
||||||
|
.map(|l| {
|
||||||
|
let cols: Vec<&str> = l.split_whitespace().collect();
|
||||||
|
match cols.as_slice() {
|
||||||
|
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
|
||||||
|
_ => l.trim().to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uptime() -> String {
|
||||||
|
Command::new("uptime")
|
||||||
|
.arg("-p")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build() -> GBox {
|
||||||
|
let (outer, content) = w::view_scaffold("About");
|
||||||
|
|
||||||
|
content.append(&w::info_row("Operating system", &os_pretty_name()));
|
||||||
|
content.append(&w::info_row("Kernel", &kernel()));
|
||||||
|
content.append(&w::info_row("CPU", &cpu()));
|
||||||
|
content.append(&w::info_row("GPU", &gpu()));
|
||||||
|
content.append(&w::info_row("Memory", &memory()));
|
||||||
|
content.append(&w::info_row("Disk (/)", &disk_usage()));
|
||||||
|
content.append(&w::info_row("Uptime", &uptime()));
|
||||||
|
|
||||||
|
content.append(&w::section("Hostname"));
|
||||||
|
content.append(&w::hint(
|
||||||
|
"Changes the machine's network name. Takes effect immediately; \
|
||||||
|
needs your password (polkit).",
|
||||||
|
));
|
||||||
|
|
||||||
|
let hn_row = GBox::new(Orientation::Horizontal, 12);
|
||||||
|
let entry = Entry::new();
|
||||||
|
entry.set_text(&hostname());
|
||||||
|
entry.set_hexpand(true);
|
||||||
|
let apply_btn = Button::with_label("Apply");
|
||||||
|
let status = Label::new(None);
|
||||||
|
status.add_css_class("dim-label");
|
||||||
|
|
||||||
|
{
|
||||||
|
let entry = entry.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
apply_btn.connect_clicked(move |_| {
|
||||||
|
let name = entry.text().to_string();
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
status.set_text("Hostname can't be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
|
let status2 = status.clone();
|
||||||
|
status.set_text("Applying…");
|
||||||
|
w::stream_command_then(
|
||||||
|
&["pkexec", "hostnamectl", "set-hostname", name.trim()],
|
||||||
|
log_buf.clone(),
|
||||||
|
move || {
|
||||||
|
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
status2.set_text("Applied");
|
||||||
|
} else {
|
||||||
|
status2.set_text(&format!("Error: {}", text.trim()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hn_row.append(&entry);
|
||||||
|
hn_row.append(&apply_btn);
|
||||||
|
content.append(&hn_row);
|
||||||
|
content.append(&status);
|
||||||
|
|
||||||
|
outer
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,9 @@ pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
|
|
||||||
let (outer, c) = w::view_scaffold("bread");
|
let (outer, c) = w::view_scaffold("Daemon");
|
||||||
|
|
||||||
|
c.append(&w::service_control("breadd.service"));
|
||||||
|
|
||||||
c.append(&w::section("Daemon"));
|
c.append(&w::section("Daemon"));
|
||||||
c.append(&w::dropdown_row(
|
c.append(&w::dropdown_row(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ use gtk4::prelude::*;
|
||||||
use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
|
use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
fn css_path() -> PathBuf {
|
fn css_path() -> PathBuf {
|
||||||
crate::config::config_dir().join("breadbar/style.css")
|
crate::config::config_dir().join("breadbar/style.css")
|
||||||
}
|
}
|
||||||
|
|
@ -10,37 +12,42 @@ pub fn build() -> GBox {
|
||||||
let path = css_path();
|
let path = css_path();
|
||||||
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
|
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
let (outer, content) = w::view_scaffold("Bar");
|
||||||
vbox.add_css_class("view-content");
|
content.append(&w::hint(
|
||||||
|
"CSS overrides for breadbar. Leave empty to use the default bread theme. \
|
||||||
let title = Label::new(Some("breadbar"));
|
Reloads live on save — no need to restart the bar.",
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
vbox.append(&title);
|
|
||||||
|
|
||||||
let subtitle = Label::new(Some(
|
|
||||||
"CSS overrides for breadbar. Leave empty to use the default bread theme.",
|
|
||||||
));
|
));
|
||||||
subtitle.set_xalign(0.0);
|
|
||||||
subtitle.set_margin_bottom(8);
|
|
||||||
subtitle.set_wrap(true);
|
|
||||||
vbox.append(&subtitle);
|
|
||||||
|
|
||||||
let buf = gtk4::TextBuffer::new(None);
|
let buf = gtk4::TextBuffer::new(None);
|
||||||
buf.set_text(&existing_css);
|
buf.set_text(&existing_css);
|
||||||
|
|
||||||
let text_view = TextView::with_buffer(&buf);
|
let text_view = TextView::with_buffer(&buf);
|
||||||
text_view.set_monospace(true);
|
text_view.set_monospace(true);
|
||||||
|
text_view.set_top_margin(12);
|
||||||
|
text_view.set_bottom_margin(12);
|
||||||
|
text_view.set_left_margin(12);
|
||||||
|
text_view.set_right_margin(12);
|
||||||
|
|
||||||
|
// A borderless full-bleed textview reads as a rendering bug, not an
|
||||||
|
// editor — give it the same card framing every other panel's content
|
||||||
|
// gets, and cap its height so it doesn't compete with the button row
|
||||||
|
// for the one screen's worth of space.
|
||||||
|
let editor_card = GBox::new(Orientation::Vertical, 0);
|
||||||
|
editor_card.add_css_class("card");
|
||||||
|
editor_card.set_vexpand(true);
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_vexpand(true);
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_min_content_height(360);
|
||||||
scroll.set_child(Some(&text_view));
|
scroll.set_child(Some(&text_view));
|
||||||
vbox.append(&scroll);
|
editor_card.append(&scroll);
|
||||||
|
content.append(&editor_card);
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||||
btn_row.set_margin_top(12);
|
btn_row.set_margin_top(12);
|
||||||
|
|
||||||
let save_btn = Button::with_label("Save");
|
let save_btn = Button::with_label("Save");
|
||||||
|
save_btn.add_css_class("suggested-action");
|
||||||
let status_lbl = Label::new(None);
|
let status_lbl = Label::new(None);
|
||||||
status_lbl.add_css_class("dim-label");
|
status_lbl.add_css_class("dim-label");
|
||||||
|
|
||||||
|
|
@ -55,7 +62,18 @@ pub fn build() -> GBox {
|
||||||
}
|
}
|
||||||
match std::fs::write(&path, text.as_str()) {
|
match std::fs::write(&path, text.as_str()) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
status_lbl.set_text("Saved");
|
// breadbar has no systemd unit (it's launched directly by
|
||||||
|
// hyprland.lua's exec-once) — SIGHUP is its own documented
|
||||||
|
// live-reload mechanism (see this file's own header
|
||||||
|
// comment: "reload live: kill -HUP $(pidof breadbar)").
|
||||||
|
// -x pkill exits non-zero if breadbar isn't running,
|
||||||
|
// which is fine — the file's still saved either way.
|
||||||
|
let reloaded = std::process::Command::new("pkill")
|
||||||
|
.args(["-HUP", "-x", "breadbar"])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
status_lbl.set_text(if reloaded { "Saved & reloaded" } else { "Saved" });
|
||||||
let lbl = status_lbl.clone();
|
let lbl = status_lbl.clone();
|
||||||
glib::timeout_add_seconds_local(3, move || {
|
glib::timeout_add_seconds_local(3, move || {
|
||||||
lbl.set_text("");
|
lbl.set_text("");
|
||||||
|
|
@ -69,7 +87,7 @@ pub fn build() -> GBox {
|
||||||
|
|
||||||
btn_row.append(&save_btn);
|
btn_row.append(&save_btn);
|
||||||
btn_row.append(&status_lbl);
|
btn_row.append(&status_lbl);
|
||||||
vbox.append(&btn_row);
|
outer.append(&btn_row);
|
||||||
|
|
||||||
vbox
|
outer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use gtk4::{
|
||||||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct Context {
|
struct Context {
|
||||||
|
|
@ -62,6 +63,17 @@ fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
|
||||||
while let Some(child) = list.first_child() {
|
while let Some(child) = list.first_child() {
|
||||||
list.remove(&child);
|
list.remove(&child);
|
||||||
}
|
}
|
||||||
|
if model.borrow().is_empty() {
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
row.set_selectable(false);
|
||||||
|
row.set_child(Some(&w::empty_state(
|
||||||
|
"view-app-grid-symbolic",
|
||||||
|
"No launcher contexts yet",
|
||||||
|
"Add one to control which apps/categories breadbox surfaces first.",
|
||||||
|
)));
|
||||||
|
list.append(&row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (i, ctx) in model.borrow().iter().enumerate() {
|
for (i, ctx) in model.borrow().iter().enumerate() {
|
||||||
let row = ListBoxRow::new();
|
let row = ListBoxRow::new();
|
||||||
row.set_selectable(false);
|
row.set_selectable(false);
|
||||||
|
|
@ -128,21 +140,13 @@ pub fn build() -> GBox {
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
|
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
|
||||||
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
let (outer, content) = w::view_scaffold("Launcher");
|
||||||
vbox.add_css_class("view-content");
|
content.append(&w::service_control("breadbox-sync.service"));
|
||||||
|
|
||||||
let title = Label::new(Some("breadbox"));
|
content.append(&w::section("Contexts"));
|
||||||
title.add_css_class("title");
|
content.append(&w::hint(
|
||||||
title.set_xalign(0.0);
|
|
||||||
vbox.append(&title);
|
|
||||||
|
|
||||||
let subtitle = Label::new(Some(
|
|
||||||
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
|
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
|
||||||
));
|
));
|
||||||
subtitle.set_xalign(0.0);
|
|
||||||
subtitle.set_wrap(true);
|
|
||||||
subtitle.set_margin_bottom(8);
|
|
||||||
vbox.append(&subtitle);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
let list = ListBox::new();
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
|
@ -151,7 +155,7 @@ pub fn build() -> GBox {
|
||||||
let scroll = ScrolledWindow::new();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_vexpand(true);
|
scroll.set_vexpand(true);
|
||||||
scroll.set_child(Some(&list));
|
scroll.set_child(Some(&list));
|
||||||
vbox.append(&scroll);
|
content.append(&scroll);
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||||
btn_row.set_margin_top(8);
|
btn_row.set_margin_top(8);
|
||||||
|
|
@ -198,7 +202,7 @@ pub fn build() -> GBox {
|
||||||
btn_row.append(&add_btn);
|
btn_row.append(&add_btn);
|
||||||
btn_row.append(&save_btn);
|
btn_row.append(&save_btn);
|
||||||
btn_row.append(&status_lbl);
|
btn_row.append(&status_lbl);
|
||||||
vbox.append(&btn_row);
|
outer.append(&btn_row);
|
||||||
|
|
||||||
vbox
|
outer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
bos-settings/src/ui/views/breadclip.rs
Normal file
24
bos-settings/src/ui/views/breadclip.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
//! breadclip — clipboard history popup + its background daemon (breadclipd).
|
||||||
|
//! No config file to edit: breadclip takes no persistent settings. This
|
||||||
|
//! panel exists purely so the daemon backing it is visible/controllable
|
||||||
|
//! from bos-settings instead of only from a terminal — previously breadclip
|
||||||
|
//! had no presence in Settings at all despite running its own service.
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::Box as GBox;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
|
pub fn build() -> GBox {
|
||||||
|
let (outer, c) = w::view_scaffold("Clipboard");
|
||||||
|
|
||||||
|
c.append(&w::hint(
|
||||||
|
"breadclip keeps a history of copied text/images and shows it as a \
|
||||||
|
popup (SUPER+V or SUPER+SHIFT+V). breadclipd is the background \
|
||||||
|
daemon that actually watches the clipboard — breadclip itself is \
|
||||||
|
just the popup UI, launched on demand.",
|
||||||
|
));
|
||||||
|
c.append(&w::service_control("breadclipd.service"));
|
||||||
|
|
||||||
|
outer
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ use std::rc::Rc;
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
|
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch,
|
||||||
};
|
};
|
||||||
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
||||||
|
|
||||||
|
|
@ -334,20 +334,8 @@ pub fn build() -> GBox {
|
||||||
let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
|
let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
|
||||||
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
|
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
|
||||||
|
|
||||||
let outer = GBox::new(Orientation::Vertical, 8);
|
let (outer, content) = w::view_scaffold("Wi-Fi Profiles");
|
||||||
outer.add_css_class("view-content");
|
content.append(&w::service_control("breadcrumbs.service"));
|
||||||
|
|
||||||
let title = Label::new(Some("breadcrumbs"));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
outer.append(&title);
|
|
||||||
|
|
||||||
let content = GBox::new(Orientation::Vertical, 8);
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
|
||||||
scroll.set_child(Some(&content));
|
|
||||||
outer.append(&scroll);
|
|
||||||
|
|
||||||
// [settings] — edited in place on the shared doc
|
// [settings] — edited in place on the shared doc
|
||||||
content.append(&w::section("Settings"));
|
content.append(&w::section("Settings"));
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
|
|
||||||
let (outer, c) = w::view_scaffold("breadpad");
|
let (outer, c) = w::view_scaffold("Notes");
|
||||||
|
|
||||||
c.append(&w::section("Capture"));
|
c.append(&w::section("Capture"));
|
||||||
c.append(&w::dropdown_row(
|
c.append(&w::dropdown_row(
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,21 @@ fn current_wallpaper() -> Option<PathBuf> {
|
||||||
fn refresh_preview(preview: &Image, path_lbl: &Label) {
|
fn refresh_preview(preview: &Image, path_lbl: &Label) {
|
||||||
match current_wallpaper() {
|
match current_wallpaper() {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
path_lbl.set_text(&path.display().to_string());
|
let filename = path.file_name().map(|f| f.to_string_lossy().to_string());
|
||||||
|
path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)"));
|
||||||
|
path_lbl.set_tooltip_text(Some(&path.display().to_string()));
|
||||||
preview.set_from_file(Some(&path));
|
preview.set_from_file(Some(&path));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
path_lbl.set_text("No wallpaper set");
|
path_lbl.set_text("No wallpaper set");
|
||||||
|
path_lbl.set_tooltip_text(None);
|
||||||
preview.set_icon_name(Some("image-missing"));
|
preview.set_icon_name(Some("image-missing"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
pub fn build() -> GBox {
|
||||||
let (outer, c) = w::view_scaffold("breadpaper");
|
let (outer, c) = w::view_scaffold("Wallpaper");
|
||||||
|
|
||||||
c.append(&w::hint(
|
c.append(&w::hint(
|
||||||
"Sets the desktop wallpaper, generates a matching pywal palette, and \
|
"Sets the desktop wallpaper, generates a matching pywal palette, and \
|
||||||
|
|
@ -51,23 +54,30 @@ pub fn build() -> GBox {
|
||||||
the whole desktop's accent colors.",
|
the whole desktop's accent colors.",
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let preview_card = GBox::new(Orientation::Vertical, 8);
|
||||||
|
preview_card.add_css_class("card");
|
||||||
|
preview_card.set_halign(gtk4::Align::Center);
|
||||||
|
preview_card.set_margin_top(8);
|
||||||
|
preview_card.set_margin_bottom(8);
|
||||||
|
|
||||||
let preview = Image::new();
|
let preview = Image::new();
|
||||||
preview.set_pixel_size(320);
|
preview.set_pixel_size(320);
|
||||||
preview.set_margin_top(8);
|
preview_card.append(&preview);
|
||||||
preview.set_margin_bottom(8);
|
|
||||||
c.append(&preview);
|
|
||||||
|
|
||||||
let path_lbl = Label::new(None);
|
let path_lbl = Label::new(None);
|
||||||
path_lbl.set_wrap(true);
|
path_lbl.set_wrap(true);
|
||||||
path_lbl.add_css_class("dim-label");
|
path_lbl.add_css_class("dim-label");
|
||||||
c.append(&path_lbl);
|
preview_card.append(&path_lbl);
|
||||||
|
c.append(&preview_card);
|
||||||
|
|
||||||
refresh_preview(&preview, &path_lbl);
|
refresh_preview(&preview, &path_lbl);
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||||
btn_row.set_margin_top(8);
|
btn_row.set_margin_top(8);
|
||||||
|
btn_row.set_halign(gtk4::Align::Center);
|
||||||
|
|
||||||
let choose_btn = Button::with_label("Choose image...");
|
let choose_btn = Button::with_label("Choose image...");
|
||||||
|
choose_btn.add_css_class("suggested-action");
|
||||||
let status = Label::new(None);
|
let status = Label::new(None);
|
||||||
status.add_css_class("dim-label");
|
status.add_css_class("dim-label");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
|
|
||||||
let (outer, c) = w::view_scaffold("breadsearch");
|
let (outer, c) = w::view_scaffold("File Search");
|
||||||
|
c.append(&w::service_control("breadmill.service"));
|
||||||
|
|
||||||
c.append(&w::section("Power"));
|
c.append(&w::section("Power"));
|
||||||
c.append(&w::hint(
|
c.append(&w::hint(
|
||||||
|
|
@ -34,22 +35,24 @@ pub fn build() -> GBox {
|
||||||
));
|
));
|
||||||
|
|
||||||
c.append(&w::section("Model"));
|
c.append(&w::section("Model"));
|
||||||
// breadsearch v0.2.1+ publishes breadmill built with --features full
|
// breadsearch v0.2.3+ publishes breadmill built with --features full
|
||||||
// (npu + rocm + cuda all in one binary, ort load-dynamic/dlopen — no
|
// (npu + rocm + cuda + openvino all in one binary, ort load-dynamic/
|
||||||
// separate build needed per backend). Selecting a GPU/NPU backend here
|
// dlopen — no separate build needed per backend). Selecting a GPU/NPU
|
||||||
// only takes effect if the matching ONNX Runtime is actually present on
|
// backend here only takes effect if the matching ONNX Runtime is
|
||||||
// this machine; breadmill logs which EP really registered at startup.
|
// actually present on this machine; breadmill logs which EP really
|
||||||
|
// registered at startup.
|
||||||
c.append(&w::dropdown_row(
|
c.append(&w::dropdown_row(
|
||||||
"Compute backend",
|
"Compute backend",
|
||||||
&doc,
|
&doc,
|
||||||
&["model", "backend"],
|
&["model", "backend"],
|
||||||
&["cpu", "npu", "rocm", "cuda"],
|
&["cpu", "npu", "rocm", "cuda", "openvino"],
|
||||||
"cpu",
|
"cpu",
|
||||||
));
|
));
|
||||||
c.append(&w::hint(
|
c.append(&w::hint(
|
||||||
"npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \
|
"npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \
|
||||||
NVIDIA GPU. Each needs the matching ONNX Runtime installed and \
|
NVIDIA GPU, openvino = Intel iGPU/Arc GPU. Each needs the matching \
|
||||||
visible to breadmill (system package, or ORT_DYLIB_PATH) — check \
|
ONNX Runtime installed and visible to breadmill (system package, \
|
||||||
|
or ORT_DYLIB_PATH) — check \
|
||||||
`journalctl --user -u breadmill` after restarting for a \
|
`journalctl --user -u breadmill` after restarting for a \
|
||||||
\"Successfully registered\" line confirming it actually took. \
|
\"Successfully registered\" line confirming it actually took. \
|
||||||
ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \
|
ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \
|
||||||
|
|
@ -108,7 +111,7 @@ pub fn build() -> GBox {
|
||||||
));
|
));
|
||||||
|
|
||||||
c.append(&w::hint(
|
c.append(&w::hint(
|
||||||
"Changes take effect after: systemctl --user restart breadmill",
|
"Changes take effect after a restart — use the Restart button above.",
|
||||||
));
|
));
|
||||||
|
|
||||||
outer.append(&w::save_button(&doc, path));
|
outer.append(&w::save_button(&doc, path));
|
||||||
|
|
|
||||||
107
bos-settings/src/ui/views/datetime.rs
Normal file
107
bos-settings/src/ui/views/datetime.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
|
||||||
|
//! default (see `post-install.sh`), so NTP sync is on out of the box — this
|
||||||
|
//! panel is mostly for picking a timezone and confirming sync is healthy.
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
|
fn show_property(prop: &str) -> String {
|
||||||
|
Command::new("timedatectl")
|
||||||
|
.args(["show", &format!("--property={prop}")])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| {
|
||||||
|
String::from_utf8_lossy(&o.stdout)
|
||||||
|
.trim()
|
||||||
|
.strip_prefix(&format!("{prop}="))
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_timezones() -> Vec<String> {
|
||||||
|
Command::new("timedatectl")
|
||||||
|
.arg("list-timezones")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_time_label() -> String {
|
||||||
|
Command::new("date")
|
||||||
|
.arg("+%A, %d %B %Y %H:%M")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build() -> GBox {
|
||||||
|
let (outer, content) = w::view_scaffold("Date & Time");
|
||||||
|
|
||||||
|
content.append(&w::info_row("Current time", ¤t_time_label()));
|
||||||
|
|
||||||
|
content.append(&w::section("Timezone"));
|
||||||
|
let zones = list_timezones();
|
||||||
|
let current_tz = show_property("Timezone");
|
||||||
|
if zones.is_empty() {
|
||||||
|
content.append(&w::hint("Couldn't list timezones (is timedatectl available?)."));
|
||||||
|
} else {
|
||||||
|
let labels: Vec<&str> = zones.iter().map(String::as_str).collect();
|
||||||
|
let model = StringList::new(&labels);
|
||||||
|
let dd = DropDown::new(Some(model), Expression::NONE);
|
||||||
|
let sel = zones.iter().position(|z| *z == current_tz).unwrap_or(0);
|
||||||
|
dd.set_selected(sel as u32);
|
||||||
|
|
||||||
|
let status = Label::new(None);
|
||||||
|
status.add_css_class("dim-label");
|
||||||
|
|
||||||
|
{
|
||||||
|
let zones = zones.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
dd.connect_selected_notify(move |dd| {
|
||||||
|
let Some(tz) = zones.get(dd.selected() as usize) else { return };
|
||||||
|
status.set_text("Applying…");
|
||||||
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
|
let status2 = status.clone();
|
||||||
|
w::stream_command_then(
|
||||||
|
&["pkexec", "timedatectl", "set-timezone", tz],
|
||||||
|
log_buf.clone(),
|
||||||
|
move || {
|
||||||
|
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||||
|
status2.set_text(if text.trim().is_empty() {
|
||||||
|
"Applied"
|
||||||
|
} else {
|
||||||
|
"Error — check the timezone name"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
content.append(&w::row("Timezone", &dd));
|
||||||
|
content.append(&status);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.append(&w::section("Network time"));
|
||||||
|
let ntp_sw = Switch::new();
|
||||||
|
ntp_sw.set_active(show_property("NTP") == "yes");
|
||||||
|
ntp_sw.connect_active_notify(|s| {
|
||||||
|
let val = if s.is_active() { "true" } else { "false" };
|
||||||
|
let _ = Command::new("pkexec").args(["timedatectl", "set-ntp", val]).spawn();
|
||||||
|
});
|
||||||
|
content.append(&w::row("Synchronize automatically", &ntp_sw));
|
||||||
|
|
||||||
|
let synced = show_property("NTPSynchronized") == "yes";
|
||||||
|
content.append(&w::hint(if synced {
|
||||||
|
"Synchronized."
|
||||||
|
} else {
|
||||||
|
"Not synchronized yet (needs network)."
|
||||||
|
}));
|
||||||
|
|
||||||
|
outer
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{Box as GBox, Button, Label, Orientation};
|
use gtk4::{Box as GBox, Button};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn get_monitors() -> Vec<String> {
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
|
fn get_monitors() -> Vec<(String, String)> {
|
||||||
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
|
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
|
@ -17,7 +19,7 @@ fn get_monitors() -> Vec<String> {
|
||||||
let w = m.get("width")?.as_u64()?;
|
let w = m.get("width")?.as_u64()?;
|
||||||
let h = m.get("height")?.as_u64()?;
|
let h = m.get("height")?.as_u64()?;
|
||||||
let refresh = m.get("refreshRate")?.as_f64()?;
|
let refresh = m.get("refreshRate")?.as_f64()?;
|
||||||
Some(format!("{name} {w}x{h} @ {refresh:.0}Hz"))
|
Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -38,58 +40,46 @@ fn open_in_terminal(path: &std::path::Path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
pub fn build() -> GBox {
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
let (outer, content) = w::view_scaffold("Display");
|
||||||
vbox.add_css_class("view-content");
|
|
||||||
|
|
||||||
let title = Label::new(Some("Hyprland"));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
vbox.append(&title);
|
|
||||||
|
|
||||||
let monitors_lbl = Label::new(Some("Connected monitors"));
|
|
||||||
monitors_lbl.set_xalign(0.0);
|
|
||||||
monitors_lbl.set_margin_top(8);
|
|
||||||
monitors_lbl.set_margin_bottom(4);
|
|
||||||
vbox.append(&monitors_lbl);
|
|
||||||
|
|
||||||
|
content.append(&w::section("Connected monitors"));
|
||||||
let monitors = get_monitors();
|
let monitors = get_monitors();
|
||||||
if monitors.is_empty() {
|
if monitors.is_empty() {
|
||||||
let lbl = Label::new(Some("No monitors detected (is Hyprland running?)"));
|
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
vbox.append(&lbl);
|
|
||||||
} else {
|
} else {
|
||||||
for mon in &monitors {
|
for (name, mode) in &monitors {
|
||||||
let lbl = Label::new(Some(mon));
|
content.append(&w::info_row(name, mode));
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl.add_css_class("monospace");
|
|
||||||
vbox.append(&lbl);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content.append(&w::section("Configuration"));
|
||||||
|
content.append(&w::hint(
|
||||||
|
"Monitor layout, keyboard/input, and workspace rules are configured \
|
||||||
|
directly in hyprland.lua — there's no live editor for them here yet.",
|
||||||
|
));
|
||||||
|
|
||||||
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
|
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
|
||||||
// hyprland.conf/keybinds.conf pair — those names only ever matched a
|
// hyprland.conf/keybinds.conf pair — those names only ever matched a
|
||||||
// stale, unshipped dotfiles/ directory, so this button opened (or
|
// stale, unshipped dotfiles/ directory, so this button opened (or
|
||||||
// silently created) the wrong file entirely.
|
// silently created) the wrong file entirely.
|
||||||
let open_btn = Button::with_label("Open hyprland.lua in editor");
|
let open_btn = Button::with_label("Open hyprland.lua in editor");
|
||||||
open_btn.set_margin_top(16);
|
|
||||||
open_btn.set_halign(gtk4::Align::Start);
|
open_btn.set_halign(gtk4::Align::Start);
|
||||||
{
|
{
|
||||||
let conf_path = hypr_path("hyprland.lua");
|
let conf_path = hypr_path("hyprland.lua");
|
||||||
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
|
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
|
||||||
}
|
}
|
||||||
vbox.append(&open_btn);
|
content.append(&open_btn);
|
||||||
|
|
||||||
// Keybinds are defined inline in hyprland.lua (no separate file); point
|
// Keybinds are defined inline in hyprland.lua (no separate file); point
|
||||||
// this at the shipped cheat sheet instead of a keybinds.conf that has
|
// this at the shipped cheat sheet instead of a keybinds.conf that has
|
||||||
// never existed on BOS.
|
// never existed on BOS.
|
||||||
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
|
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
|
||||||
keybinds_btn.set_margin_top(8);
|
|
||||||
keybinds_btn.set_halign(gtk4::Align::Start);
|
keybinds_btn.set_halign(gtk4::Align::Start);
|
||||||
{
|
{
|
||||||
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
|
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
|
||||||
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
|
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
|
||||||
}
|
}
|
||||||
vbox.append(&keybinds_btn);
|
content.append(&keybinds_btn);
|
||||||
|
|
||||||
vbox
|
outer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
|
pub mod about;
|
||||||
pub mod bread;
|
pub mod bread;
|
||||||
pub mod breadbar;
|
pub mod breadbar;
|
||||||
pub mod breadbox;
|
pub mod breadbox;
|
||||||
|
pub mod breadclip;
|
||||||
pub mod breadcrumbs;
|
pub mod breadcrumbs;
|
||||||
pub mod breadpad;
|
pub mod breadpad;
|
||||||
pub mod breadpaper;
|
pub mod breadpaper;
|
||||||
pub mod breadsearch;
|
pub mod breadsearch;
|
||||||
|
pub mod datetime;
|
||||||
pub mod hyprland;
|
pub mod hyprland;
|
||||||
|
pub mod network;
|
||||||
pub mod packages;
|
pub mod packages;
|
||||||
pub mod snapshots;
|
pub mod snapshots;
|
||||||
|
pub mod sound;
|
||||||
|
|
|
||||||
324
bos-settings/src/ui/views/network.rs
Normal file
324
bos-settings/src/ui/views/network.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user
|
||||||
|
//! manage connections via polkit already, so the common paths (scan, connect,
|
||||||
|
//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases
|
||||||
|
//! are punted to `nm-connection-editor` via the Advanced button rather than
|
||||||
|
//! reimplemented here.
|
||||||
|
//!
|
||||||
|
//! The scan is deliberately NOT run in `build()` — every view is constructed
|
||||||
|
//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds;
|
||||||
|
//! doing it here would add that latency to every bos-settings launch. It
|
||||||
|
//! only runs when the user clicks Scan.
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct WifiNetwork {
|
||||||
|
ssid: String,
|
||||||
|
signal: i32,
|
||||||
|
secured: bool,
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn radio_enabled() -> bool {
|
||||||
|
Command::new("nmcli")
|
||||||
|
.args(["radio", "wifi"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ethernet_status() -> Option<String> {
|
||||||
|
let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
text.lines().find_map(|l| {
|
||||||
|
let mut cols = l.splitn(3, ':');
|
||||||
|
let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?);
|
||||||
|
(ty == "ethernet").then(|| format!("{dev}: {state}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_connection_names() -> HashSet<String> {
|
||||||
|
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else {
|
||||||
|
return HashSet::new();
|
||||||
|
};
|
||||||
|
String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
|
||||||
|
/// signal — the same AP shows once per band/BSSID otherwise).
|
||||||
|
fn scan_wifi() -> Vec<WifiNetwork> {
|
||||||
|
let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output();
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
let Ok(out) = Command::new("nmcli")
|
||||||
|
.args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let mut by_ssid: std::collections::HashMap<String, WifiNetwork> = std::collections::HashMap::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let mut cols = line.splitn(4, ':');
|
||||||
|
let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next());
|
||||||
|
let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue };
|
||||||
|
let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let net = WifiNetwork {
|
||||||
|
ssid: ssid.to_string(),
|
||||||
|
signal,
|
||||||
|
secured: security.map(|s| !s.is_empty()).unwrap_or(false),
|
||||||
|
active: in_use == Some("*"),
|
||||||
|
};
|
||||||
|
by_ssid
|
||||||
|
.entry(ssid.to_string())
|
||||||
|
.and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() })
|
||||||
|
.or_insert(net);
|
||||||
|
}
|
||||||
|
let mut list: Vec<_> = by_ssid.into_values().collect();
|
||||||
|
list.sort_by(|a, b| b.signal.cmp(&a.signal));
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build() -> GBox {
|
||||||
|
let (outer, content) = w::view_scaffold("Network");
|
||||||
|
|
||||||
|
content.append(&w::section("Wi-Fi"));
|
||||||
|
let radio_sw = Switch::new();
|
||||||
|
radio_sw.set_active(radio_enabled());
|
||||||
|
radio_sw.connect_active_notify(|s| {
|
||||||
|
let val = if s.is_active() { "on" } else { "off" };
|
||||||
|
let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn();
|
||||||
|
});
|
||||||
|
content.append(&w::row("Wi-Fi radio", &radio_sw));
|
||||||
|
|
||||||
|
let status = Label::new(None);
|
||||||
|
status.add_css_class("dim-label");
|
||||||
|
status.set_xalign(0.0);
|
||||||
|
|
||||||
|
let list = ListBox::new();
|
||||||
|
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_min_content_height(220);
|
||||||
|
scroll.set_child(Some(&list));
|
||||||
|
// Hidden until a scan actually produces results — a 220px empty scroll
|
||||||
|
// area between the radio row and the Scan button was dead space on
|
||||||
|
// every fresh open of this panel.
|
||||||
|
scroll.set_visible(false);
|
||||||
|
content.append(&scroll);
|
||||||
|
|
||||||
|
let not_scanned = w::hint("Not scanned yet — press Scan to see nearby networks.");
|
||||||
|
content.append(¬_scanned);
|
||||||
|
|
||||||
|
// Password entry, shown only while connecting to a secured, unknown
|
||||||
|
// network — set visible/hidden rather than a modal dialog, to keep this
|
||||||
|
// panel's async flow in one place instead of a nested dialog callback.
|
||||||
|
let pw_row = GBox::new(Orientation::Horizontal, 8);
|
||||||
|
let pw_entry = Entry::new();
|
||||||
|
pw_entry.set_visibility(false);
|
||||||
|
pw_entry.set_hexpand(true);
|
||||||
|
pw_entry.set_placeholder_text(Some("Password"));
|
||||||
|
let pw_connect_btn = Button::with_label("Connect");
|
||||||
|
pw_row.append(&pw_entry);
|
||||||
|
pw_row.append(&pw_connect_btn);
|
||||||
|
pw_row.set_visible(false);
|
||||||
|
content.append(&pw_row);
|
||||||
|
content.append(&status);
|
||||||
|
|
||||||
|
let pending_ssid: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
let connect_open_or_known = {
|
||||||
|
let status = status.clone();
|
||||||
|
move |ssid: String| {
|
||||||
|
status.set_text(&format!("Connecting to {ssid}…"));
|
||||||
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
|
let status2 = status.clone();
|
||||||
|
let ssid2 = ssid.clone();
|
||||||
|
let known = known_connection_names();
|
||||||
|
let args: Vec<String> = if known.contains(&ssid) {
|
||||||
|
vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()]
|
||||||
|
} else {
|
||||||
|
vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()]
|
||||||
|
};
|
||||||
|
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||||
|
w::stream_command_then(&args_ref, log_buf.clone(), move || {
|
||||||
|
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||||
|
if text.to_lowercase().contains("error") {
|
||||||
|
status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim()));
|
||||||
|
} else {
|
||||||
|
status2.set_text(&format!("Connected to {ssid2}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let pw_row = pw_row.clone();
|
||||||
|
let pw_entry = pw_entry.clone();
|
||||||
|
let pending_ssid = pending_ssid.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
pw_connect_btn.connect_clicked(move |_| {
|
||||||
|
let Some(ssid) = pending_ssid.borrow_mut().take() else { return };
|
||||||
|
let password = pw_entry.text().to_string();
|
||||||
|
pw_row.set_visible(false);
|
||||||
|
pw_entry.set_text("");
|
||||||
|
status.set_text(&format!("Connecting to {ssid}…"));
|
||||||
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
|
let status2 = status.clone();
|
||||||
|
let ssid2 = ssid.clone();
|
||||||
|
w::stream_command_then(
|
||||||
|
&["nmcli", "dev", "wifi", "connect", &ssid, "password", &password],
|
||||||
|
log_buf.clone(),
|
||||||
|
move || {
|
||||||
|
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
|
||||||
|
if text.to_lowercase().contains("error") {
|
||||||
|
status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?"));
|
||||||
|
} else {
|
||||||
|
status2.set_text(&format!("Connected to {ssid2}"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let populate_list = {
|
||||||
|
let list = list.clone();
|
||||||
|
let scroll = scroll.clone();
|
||||||
|
let not_scanned = not_scanned.clone();
|
||||||
|
let pw_row = pw_row.clone();
|
||||||
|
let pending_ssid = pending_ssid.clone();
|
||||||
|
let connect_open_or_known = connect_open_or_known.clone();
|
||||||
|
move |networks: Vec<WifiNetwork>| {
|
||||||
|
while let Some(child) = list.first_child() {
|
||||||
|
list.remove(&child);
|
||||||
|
}
|
||||||
|
not_scanned.set_visible(false);
|
||||||
|
scroll.set_visible(true);
|
||||||
|
if networks.is_empty() {
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
row.set_selectable(false);
|
||||||
|
row.set_child(Some(&w::empty_state(
|
||||||
|
"network-wireless-offline-symbolic",
|
||||||
|
"No networks found",
|
||||||
|
"Try Scan again, or check Wi-Fi radio is on.",
|
||||||
|
)));
|
||||||
|
list.append(&row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let known = known_connection_names();
|
||||||
|
for net in networks {
|
||||||
|
let row = ListBoxRow::new();
|
||||||
|
let hbox = GBox::new(Orientation::Horizontal, 12);
|
||||||
|
hbox.set_margin_top(4);
|
||||||
|
hbox.set_margin_bottom(4);
|
||||||
|
hbox.set_margin_start(8);
|
||||||
|
hbox.set_margin_end(8);
|
||||||
|
|
||||||
|
let name = if net.active {
|
||||||
|
format!("{} (connected)", net.ssid)
|
||||||
|
} else {
|
||||||
|
net.ssid.clone()
|
||||||
|
};
|
||||||
|
let name_lbl = Label::new(Some(&name));
|
||||||
|
name_lbl.set_hexpand(true);
|
||||||
|
name_lbl.set_xalign(0.0);
|
||||||
|
if net.active {
|
||||||
|
name_lbl.add_css_class("heading");
|
||||||
|
}
|
||||||
|
|
||||||
|
let signal_icon_name = match net.signal.clamp(0, 100) {
|
||||||
|
0..=24 => "network-wireless-signal-weak-symbolic",
|
||||||
|
25..=49 => "network-wireless-signal-ok-symbolic",
|
||||||
|
50..=74 => "network-wireless-signal-good-symbolic",
|
||||||
|
_ => "network-wireless-signal-excellent-symbolic",
|
||||||
|
};
|
||||||
|
let signal_icon = gtk4::Image::from_icon_name(signal_icon_name);
|
||||||
|
signal_icon.add_css_class("dim-label");
|
||||||
|
|
||||||
|
let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100))));
|
||||||
|
meta_lbl.add_css_class("dim-label");
|
||||||
|
|
||||||
|
hbox.append(&name_lbl);
|
||||||
|
if net.secured {
|
||||||
|
let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic");
|
||||||
|
lock_icon.add_css_class("dim-label");
|
||||||
|
hbox.append(&lock_icon);
|
||||||
|
}
|
||||||
|
hbox.append(&signal_icon);
|
||||||
|
hbox.append(&meta_lbl);
|
||||||
|
|
||||||
|
if !net.active {
|
||||||
|
let connect_btn = Button::with_label("Connect");
|
||||||
|
let ssid = net.ssid.clone();
|
||||||
|
let secured = net.secured;
|
||||||
|
let already_known = known.contains(&net.ssid);
|
||||||
|
let pw_row = pw_row.clone();
|
||||||
|
let pending_ssid = pending_ssid.clone();
|
||||||
|
let connect_open_or_known = connect_open_or_known.clone();
|
||||||
|
connect_btn.connect_clicked(move |_| {
|
||||||
|
if secured && !already_known {
|
||||||
|
*pending_ssid.borrow_mut() = Some(ssid.clone());
|
||||||
|
pw_row.set_visible(true);
|
||||||
|
} else {
|
||||||
|
pw_row.set_visible(false);
|
||||||
|
connect_open_or_known(ssid.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
hbox.append(&connect_btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.set_child(Some(&hbox));
|
||||||
|
list.append(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let scan_btn = Button::with_label("Scan");
|
||||||
|
{
|
||||||
|
let status = status.clone();
|
||||||
|
let populate_list = populate_list.clone();
|
||||||
|
scan_btn.connect_clicked(move |btn| {
|
||||||
|
btn.set_sensitive(false);
|
||||||
|
status.set_text("Scanning…");
|
||||||
|
let (tx, rx) = async_channel::bounded::<Vec<WifiNetwork>>(1);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let nets = scan_wifi();
|
||||||
|
let _ = tx.send_blocking(nets);
|
||||||
|
});
|
||||||
|
let btn = btn.clone();
|
||||||
|
let status = status.clone();
|
||||||
|
let populate_list = populate_list.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Ok(nets) = rx.recv().await {
|
||||||
|
let count = nets.len();
|
||||||
|
populate_list(nets);
|
||||||
|
status.set_text(&format!("Found {count} network(s)"));
|
||||||
|
}
|
||||||
|
btn.set_sensitive(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
content.append(&scan_btn);
|
||||||
|
|
||||||
|
if let Some(eth) = ethernet_status() {
|
||||||
|
content.append(&w::section("Ethernet"));
|
||||||
|
content.append(&w::info_row("Status", ð));
|
||||||
|
}
|
||||||
|
|
||||||
|
content.append(&w::section("Advanced"));
|
||||||
|
content.append(&w::hint("VPN, 802.1x, and static IP configuration aren't covered here."));
|
||||||
|
let adv_btn = Button::with_label("Open connection editor");
|
||||||
|
adv_btn.set_halign(gtk4::Align::Start);
|
||||||
|
adv_btn.connect_clicked(|_| {
|
||||||
|
let _ = Command::new("nm-connection-editor").spawn();
|
||||||
|
});
|
||||||
|
content.append(&adv_btn);
|
||||||
|
|
||||||
|
outer
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
use async_channel;
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
|
Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{BufRead, BufReader};
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
use crate::ui::widgets as w;
|
use crate::ui::widgets as w;
|
||||||
|
use crate::ui::widgets::{stream_command, stream_command_then};
|
||||||
|
|
||||||
fn read_installed() -> HashMap<String, String> {
|
fn read_installed() -> HashMap<String, String> {
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||||
|
|
@ -43,61 +41,7 @@ fn read_installed() -> HashMap<String, String> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
|
fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) {
|
||||||
stream_command_then(args, log_buf, || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as stream_command, but runs `on_done` once the process's output
|
|
||||||
/// stream ends (i.e. the child has exited) — the channel closes when both
|
|
||||||
/// the stdout- and stderr-forwarding threads drop their sender, which only
|
|
||||||
/// happens after `child.wait()` returns.
|
|
||||||
fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) {
|
|
||||||
let (sender, receiver) = async_channel::bounded::<String>(256);
|
|
||||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut child = match Command::new(&args[0])
|
|
||||||
.args(&args[1..])
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = sender.send_blocking(format!("Error: {e}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Merge stderr into the channel too.
|
|
||||||
// Both are Some because we spawned with Stdio::piped() above.
|
|
||||||
let stdout = child.stdout.take().expect("stdout piped");
|
|
||||||
let stderr = child.stderr.take().expect("stderr piped");
|
|
||||||
|
|
||||||
let tx2 = sender.clone();
|
|
||||||
let stderr_thread = std::thread::spawn(move || {
|
|
||||||
for line in BufReader::new(stderr).lines().flatten() {
|
|
||||||
let _ = tx2.send_blocking(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for line in BufReader::new(stdout).lines().flatten() {
|
|
||||||
let _ = sender.send_blocking(line);
|
|
||||||
}
|
|
||||||
let _ = child.wait();
|
|
||||||
let _ = stderr_thread.join();
|
|
||||||
});
|
|
||||||
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
while let Ok(line) = receiver.recv().await {
|
|
||||||
let mut end = log_buf.end_iter();
|
|
||||||
log_buf.insert(&mut end, &format!("{line}\n"));
|
|
||||||
}
|
|
||||||
on_done();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) {
|
|
||||||
while let Some(child) = list.first_child() {
|
while let Some(child) = list.first_child() {
|
||||||
list.remove(&child);
|
list.remove(&child);
|
||||||
}
|
}
|
||||||
|
|
@ -106,13 +50,11 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) {
|
||||||
if packages.is_empty() {
|
if packages.is_empty() {
|
||||||
let row = ListBoxRow::new();
|
let row = ListBoxRow::new();
|
||||||
row.set_selectable(false);
|
row.set_selectable(false);
|
||||||
let lbl = Label::new(Some(
|
row.set_child(Some(&w::empty_state(
|
||||||
"No bakery packages found (~/.local/state/bakery/installed.json)",
|
"package-x-generic-symbolic",
|
||||||
));
|
"No bakery packages found",
|
||||||
lbl.set_margin_top(8);
|
"~/.local/state/bakery/installed.json is missing or empty.",
|
||||||
lbl.set_margin_bottom(8);
|
)));
|
||||||
lbl.set_margin_start(8);
|
|
||||||
row.set_child(Some(&lbl));
|
|
||||||
list.append(&row);
|
list.append(&row);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -140,11 +82,14 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) {
|
||||||
let update_btn = Button::with_label("Update");
|
let update_btn = Button::with_label("Update");
|
||||||
{
|
{
|
||||||
let log_buf = log_buf.clone();
|
let log_buf = log_buf.clone();
|
||||||
|
let log_view = log_view.clone();
|
||||||
let list = list.clone();
|
let list = list.clone();
|
||||||
update_btn.connect_clicked(move |_| {
|
update_btn.connect_clicked(move |_| {
|
||||||
log_buf.set_text("");
|
log_buf.set_text("");
|
||||||
|
log_view.set_visible(true);
|
||||||
let list2 = list.clone();
|
let list2 = list.clone();
|
||||||
let log_buf2 = log_buf.clone();
|
let log_buf2 = log_buf.clone();
|
||||||
|
let log_view2 = log_view.clone();
|
||||||
// Route through stream_command (like the other buttons) so
|
// Route through stream_command (like the other buttons) so
|
||||||
// output is visible and the row refreshes with the new
|
// output is visible and the row refreshes with the new
|
||||||
// version once the update actually finishes — previously
|
// version once the update actually finishes — previously
|
||||||
|
|
@ -152,7 +97,7 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) {
|
||||||
// swallowed, so a missing `bakery` binary made the button
|
// swallowed, so a missing `bakery` binary made the button
|
||||||
// look broken with zero feedback either way.
|
// look broken with zero feedback either way.
|
||||||
stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || {
|
stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || {
|
||||||
populate_packages(&list2, &log_buf2);
|
populate_packages(&list2, &log_buf2, &log_view2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -166,35 +111,32 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
pub fn build() -> GBox {
|
||||||
let vbox = GBox::new(Orientation::Vertical, 0);
|
let (outer, content) = w::view_scaffold("Packages");
|
||||||
vbox.add_css_class("view-content");
|
content.append(&w::hint(
|
||||||
|
"Bread ecosystem packages installed via bakery, and system packages via pacman below.",
|
||||||
let title = Label::new(Some("Packages"));
|
));
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
vbox.append(&title);
|
|
||||||
|
|
||||||
let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below."));
|
|
||||||
subtitle.set_xalign(0.0);
|
|
||||||
subtitle.set_margin_bottom(16);
|
|
||||||
vbox.append(&subtitle);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
let list = ListBox::new();
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
|
|
||||||
let log_buf = gtk4::TextBuffer::new(None);
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
populate_packages(&list, &log_buf);
|
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
|
||||||
scroll.set_vexpand(true);
|
|
||||||
scroll.set_child(Some(&list));
|
|
||||||
vbox.append(&scroll);
|
|
||||||
|
|
||||||
|
// Hidden until a command actually produces output — an always-visible
|
||||||
|
// empty log box below a short package list was the single biggest
|
||||||
|
// "dead space" offender in the app.
|
||||||
let log_view = TextView::with_buffer(&log_buf);
|
let log_view = TextView::with_buffer(&log_buf);
|
||||||
log_view.set_editable(false);
|
log_view.set_editable(false);
|
||||||
log_view.set_monospace(true);
|
log_view.set_monospace(true);
|
||||||
log_view.set_height_request(140);
|
log_view.set_height_request(140);
|
||||||
log_view.set_margin_top(8);
|
log_view.set_margin_top(8);
|
||||||
|
log_view.set_visible(false);
|
||||||
|
|
||||||
|
populate_packages(&list, &log_buf, &log_view);
|
||||||
|
|
||||||
|
let scroll = ScrolledWindow::new();
|
||||||
|
scroll.set_vexpand(true);
|
||||||
|
scroll.set_child(Some(&list));
|
||||||
|
content.append(&scroll);
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||||
btn_row.set_margin_top(12);
|
btn_row.set_margin_top(12);
|
||||||
|
|
@ -206,23 +148,27 @@ pub fn build() -> GBox {
|
||||||
|
|
||||||
{
|
{
|
||||||
let log_buf = log_buf.clone();
|
let log_buf = log_buf.clone();
|
||||||
|
let log_view = log_view.clone();
|
||||||
check_btn.connect_clicked(move |_| {
|
check_btn.connect_clicked(move |_| {
|
||||||
log_buf.set_text("");
|
log_buf.set_text("");
|
||||||
|
log_view.set_visible(true);
|
||||||
stream_command(&["bakery", "list"], log_buf.clone());
|
stream_command(&["bakery", "list"], log_buf.clone());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let log_buf = log_buf.clone();
|
let log_buf = log_buf.clone();
|
||||||
|
let log_view = log_view.clone();
|
||||||
update_all_btn.connect_clicked(move |_| {
|
update_all_btn.connect_clicked(move |_| {
|
||||||
log_buf.set_text("");
|
log_buf.set_text("");
|
||||||
|
log_view.set_visible(true);
|
||||||
stream_command(&["bakery", "update", "--all"], log_buf.clone());
|
stream_command(&["bakery", "update", "--all"], log_buf.clone());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
btn_row.append(&check_btn);
|
btn_row.append(&check_btn);
|
||||||
btn_row.append(&update_all_btn);
|
btn_row.append(&update_all_btn);
|
||||||
vbox.append(&btn_row);
|
content.append(&btn_row);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// System packages (pacman) — the other update channel. bakery only
|
// System packages (pacman) — the other update channel. bakery only
|
||||||
|
|
@ -232,8 +178,8 @@ pub fn build() -> GBox {
|
||||||
// only exposed the bakery half, so a user relying on it alone would
|
// only exposed the bakery half, so a user relying on it alone would
|
||||||
// never get base-system updates through the GUI.
|
// never get base-system updates through the GUI.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
vbox.append(&w::section("System packages (pacman)"));
|
content.append(&w::section("System packages (pacman)"));
|
||||||
vbox.append(&w::hint(
|
content.append(&w::hint(
|
||||||
"Base system, kernel, bos-settings, and republished AUR packages — \
|
"Base system, kernel, bos-settings, and republished AUR packages — \
|
||||||
the other half of what `bos-update` covers. Needs your password \
|
the other half of what `bos-update` covers. Needs your password \
|
||||||
(polkit) since pacman requires root.",
|
(polkit) since pacman requires root.",
|
||||||
|
|
@ -244,15 +190,17 @@ pub fn build() -> GBox {
|
||||||
let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
|
let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
|
||||||
{
|
{
|
||||||
let log_buf = log_buf.clone();
|
let log_buf = log_buf.clone();
|
||||||
|
let log_view = log_view.clone();
|
||||||
pacman_update_btn.connect_clicked(move |_| {
|
pacman_update_btn.connect_clicked(move |_| {
|
||||||
log_buf.set_text("");
|
log_buf.set_text("");
|
||||||
|
log_view.set_visible(true);
|
||||||
stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone());
|
stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
pacman_btn_row.append(&pacman_update_btn);
|
pacman_btn_row.append(&pacman_update_btn);
|
||||||
vbox.append(&pacman_btn_row);
|
content.append(&pacman_btn_row);
|
||||||
|
|
||||||
vbox.append(&log_view);
|
content.append(&log_view);
|
||||||
|
|
||||||
vbox
|
outer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ use gtk4::{
|
||||||
};
|
};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct SnapshotRow {
|
struct SnapshotRow {
|
||||||
number: String,
|
number: String,
|
||||||
|
|
@ -50,7 +52,9 @@ fn list_snapshots() -> Vec<SnapshotRow> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn populate_list(list: &ListBox) {
|
/// Returns whether the list ended up empty, so callers can disable the
|
||||||
|
/// selection-dependent buttons instead of leaving them clickable no-ops.
|
||||||
|
fn populate_list(list: &ListBox) -> bool {
|
||||||
while let Some(child) = list.first_child() {
|
while let Some(child) = list.first_child() {
|
||||||
list.remove(&child);
|
list.remove(&child);
|
||||||
}
|
}
|
||||||
|
|
@ -58,13 +62,14 @@ fn populate_list(list: &ListBox) {
|
||||||
if snapshots.is_empty() {
|
if snapshots.is_empty() {
|
||||||
let row = ListBoxRow::new();
|
let row = ListBoxRow::new();
|
||||||
row.set_selectable(false);
|
row.set_selectable(false);
|
||||||
let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)"));
|
row.set_child(Some(&w::empty_state(
|
||||||
lbl.set_margin_top(8);
|
"document-open-recent-symbolic",
|
||||||
lbl.set_margin_bottom(8);
|
"No snapshots yet",
|
||||||
lbl.set_margin_start(8);
|
"Snapshots are created automatically on every pacman transaction \
|
||||||
row.set_child(Some(&lbl));
|
(snapper may not be configured yet).",
|
||||||
|
)));
|
||||||
list.append(&row);
|
list.append(&row);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
for snap in &snapshots {
|
for snap in &snapshots {
|
||||||
let row = ListBoxRow::new();
|
let row = ListBoxRow::new();
|
||||||
|
|
@ -94,33 +99,24 @@ fn populate_list(list: &ListBox) {
|
||||||
row.set_child(Some(&hbox));
|
row.set_child(Some(&hbox));
|
||||||
list.append(&row);
|
list.append(&row);
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
pub fn build() -> GBox {
|
||||||
let vbox = GBox::new(Orientation::Vertical, 0);
|
let (outer, content) = w::view_scaffold("Snapshots");
|
||||||
vbox.add_css_class("view-content");
|
content.append(&w::hint(
|
||||||
|
|
||||||
let title = Label::new(Some("Snapshots"));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
vbox.append(&title);
|
|
||||||
|
|
||||||
let subtitle = Label::new(Some(
|
|
||||||
"System snapshots created by snap-pac on each pacman transaction. \
|
"System snapshots created by snap-pac on each pacman transaction. \
|
||||||
Boot into one from the GRUB menu to recover; delete old ones here.",
|
Boot into one from the GRUB menu to recover; delete old ones here.",
|
||||||
));
|
));
|
||||||
subtitle.set_xalign(0.0);
|
|
||||||
subtitle.set_margin_bottom(16);
|
|
||||||
vbox.append(&subtitle);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
let list = ListBox::new();
|
||||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||||
populate_list(&list);
|
let empty = populate_list(&list);
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_vexpand(true);
|
scroll.set_vexpand(true);
|
||||||
scroll.set_child(Some(&list));
|
scroll.set_child(Some(&list));
|
||||||
vbox.append(&scroll);
|
content.append(&scroll);
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||||
btn_row.set_margin_top(12);
|
btn_row.set_margin_top(12);
|
||||||
|
|
@ -129,11 +125,17 @@ pub fn build() -> GBox {
|
||||||
let rollback_btn = Button::with_label("Boot into selected...");
|
let rollback_btn = Button::with_label("Boot into selected...");
|
||||||
let delete_btn = Button::with_label("Delete selected");
|
let delete_btn = Button::with_label("Delete selected");
|
||||||
delete_btn.add_css_class("destructive-action");
|
delete_btn.add_css_class("destructive-action");
|
||||||
|
rollback_btn.set_sensitive(!empty);
|
||||||
|
delete_btn.set_sensitive(!empty);
|
||||||
|
|
||||||
{
|
{
|
||||||
let list = list.clone();
|
let list = list.clone();
|
||||||
|
let rollback_btn = rollback_btn.clone();
|
||||||
|
let delete_btn = delete_btn.clone();
|
||||||
refresh_btn.connect_clicked(move |_| {
|
refresh_btn.connect_clicked(move |_| {
|
||||||
populate_list(&list);
|
let empty = populate_list(&list);
|
||||||
|
rollback_btn.set_sensitive(!empty);
|
||||||
|
delete_btn.set_sensitive(!empty);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,11 +180,14 @@ pub fn build() -> GBox {
|
||||||
|
|
||||||
{
|
{
|
||||||
let list = list.clone();
|
let list = list.clone();
|
||||||
|
let rollback_btn = rollback_btn.clone();
|
||||||
delete_btn.connect_clicked(move |btn| {
|
delete_btn.connect_clicked(move |btn| {
|
||||||
let Some(row) = list.selected_row() else { return };
|
let Some(row) = list.selected_row() else { return };
|
||||||
let number = row.widget_name().to_string();
|
let number = row.widget_name().to_string();
|
||||||
if number.is_empty() { return }
|
if number.is_empty() { return }
|
||||||
|
|
||||||
|
let rollback_btn = rollback_btn.clone();
|
||||||
|
let delete_btn = btn.clone();
|
||||||
let window = btn
|
let window = btn
|
||||||
.root()
|
.root()
|
||||||
.and_then(|r| r.downcast::<gtk4::Window>().ok());
|
.and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||||
|
|
@ -197,6 +202,8 @@ pub fn build() -> GBox {
|
||||||
|
|
||||||
let window2 = window.clone();
|
let window2 = window.clone();
|
||||||
let list2 = list.clone();
|
let list2 = list.clone();
|
||||||
|
let rollback_btn2 = rollback_btn.clone();
|
||||||
|
let delete_btn2 = delete_btn.clone();
|
||||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||||
if result != Ok(1) { return }
|
if result != Ok(1) { return }
|
||||||
|
|
||||||
|
|
@ -218,10 +225,14 @@ pub fn build() -> GBox {
|
||||||
|
|
||||||
let list = list2.clone();
|
let list = list2.clone();
|
||||||
let window = window2.clone();
|
let window = window2.clone();
|
||||||
|
let rollback_btn = rollback_btn2.clone();
|
||||||
|
let delete_btn = delete_btn2.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let ok = rx.recv().await.unwrap_or(false);
|
let ok = rx.recv().await.unwrap_or(false);
|
||||||
if ok {
|
if ok {
|
||||||
populate_list(&list);
|
let empty = populate_list(&list);
|
||||||
|
rollback_btn.set_sensitive(!empty);
|
||||||
|
delete_btn.set_sensitive(!empty);
|
||||||
} else {
|
} else {
|
||||||
let err = AlertDialog::builder()
|
let err = AlertDialog::builder()
|
||||||
.message("Delete failed")
|
.message("Delete failed")
|
||||||
|
|
@ -239,7 +250,7 @@ pub fn build() -> GBox {
|
||||||
btn_row.append(&refresh_btn);
|
btn_row.append(&refresh_btn);
|
||||||
btn_row.append(&rollback_btn);
|
btn_row.append(&rollback_btn);
|
||||||
btn_row.append(&delete_btn);
|
btn_row.append(&delete_btn);
|
||||||
vbox.append(&btn_row);
|
outer.append(&btn_row);
|
||||||
|
|
||||||
vbox
|
outer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
167
bos-settings/src/ui/views/sound.rs
Normal file
167
bos-settings/src/ui/views/sound.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
//! Output/input volume and device selection over PipeWire's pulse
|
||||||
|
//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media
|
||||||
|
//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl`
|
||||||
|
//! because it can enumerate devices with human-readable descriptions and
|
||||||
|
//! switch the default in one command; `wpctl` cannot easily do either.
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use crate::ui::widgets as w;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct Device {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
mute: bool,
|
||||||
|
volume: std::collections::HashMap<String, VolumeChannel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct VolumeChannel {
|
||||||
|
value_percent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
fn percent(&self) -> f64 {
|
||||||
|
self.volume
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_devices(kind: &str) -> Vec<Device> {
|
||||||
|
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
serde_json::from_slice(&output.stdout).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_device_name(kind: &str) -> Option<String> {
|
||||||
|
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
|
||||||
|
Command::new("pactl")
|
||||||
|
.arg(flag)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one section (Output or Input): device dropdown + volume slider + mute
|
||||||
|
/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
|
||||||
|
/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
|
||||||
|
/// likewise.
|
||||||
|
fn build_section(
|
||||||
|
title: &str,
|
||||||
|
kind: &'static str,
|
||||||
|
set_default_flag: &'static str,
|
||||||
|
vol_flag: &'static str,
|
||||||
|
mute_flag: &'static str,
|
||||||
|
) -> GBox {
|
||||||
|
let section = GBox::new(Orientation::Vertical, 4);
|
||||||
|
section.append(&w::section(title));
|
||||||
|
|
||||||
|
let devices = list_devices(kind);
|
||||||
|
if devices.is_empty() {
|
||||||
|
section.append(&w::hint("No devices found."));
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_name = default_device_name(kind);
|
||||||
|
let selected_idx = current_name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|n| devices.iter().position(|d| &d.name == n))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let current = devices[selected_idx].clone();
|
||||||
|
|
||||||
|
let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
|
||||||
|
let model = StringList::new(&labels);
|
||||||
|
let dd = DropDown::new(Some(model), Expression::NONE);
|
||||||
|
dd.set_selected(selected_idx as u32);
|
||||||
|
section.append(&w::row("Device", &dd));
|
||||||
|
|
||||||
|
let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
|
||||||
|
scale.set_value(current.percent());
|
||||||
|
scale.set_size_request(180, -1);
|
||||||
|
scale.set_draw_value(true);
|
||||||
|
scale.set_value_pos(gtk4::PositionType::Right);
|
||||||
|
section.append(&w::row("Volume", &scale));
|
||||||
|
|
||||||
|
let mute_sw = Switch::new();
|
||||||
|
mute_sw.set_active(current.mute);
|
||||||
|
section.append(&w::row("Mute", &mute_sw));
|
||||||
|
|
||||||
|
// Device switch: fire-and-forget pactl call, then resync the slider/mute
|
||||||
|
// switch to whatever the newly-default device's actual state is.
|
||||||
|
{
|
||||||
|
let devices = devices.clone();
|
||||||
|
let scale = scale.clone();
|
||||||
|
let mute_sw = mute_sw.clone();
|
||||||
|
dd.connect_selected_notify(move |dd| {
|
||||||
|
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
||||||
|
let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
|
||||||
|
scale.set_value(d.percent());
|
||||||
|
mute_sw.set_active(d.mute);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume: target whichever device is currently selected in the dropdown,
|
||||||
|
// not necessarily the system default at the time this closure was built.
|
||||||
|
{
|
||||||
|
let devices = devices.clone();
|
||||||
|
let dd = dd.clone();
|
||||||
|
scale.connect_value_changed(move |s| {
|
||||||
|
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
||||||
|
let pct = format!("{}%", s.value() as i64);
|
||||||
|
let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let devices = devices.clone();
|
||||||
|
let dd = dd.clone();
|
||||||
|
mute_sw.connect_active_notify(move |s| {
|
||||||
|
let Some(d) = devices.get(dd.selected() as usize) else { return };
|
||||||
|
let val = if s.is_active() { "1" } else { "0" };
|
||||||
|
let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
section
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build() -> GBox {
|
||||||
|
let (outer, content) = w::view_scaffold("Sound");
|
||||||
|
|
||||||
|
content.append(&build_section(
|
||||||
|
"Output",
|
||||||
|
"sinks",
|
||||||
|
"set-default-sink",
|
||||||
|
"set-sink-volume",
|
||||||
|
"set-sink-mute",
|
||||||
|
));
|
||||||
|
content.append(&build_section(
|
||||||
|
"Input",
|
||||||
|
"sources",
|
||||||
|
"set-default-source",
|
||||||
|
"set-source-volume",
|
||||||
|
"set-source-mute",
|
||||||
|
));
|
||||||
|
|
||||||
|
content.append(&w::section("Advanced"));
|
||||||
|
content.append(&w::hint(
|
||||||
|
"Per-app volume, port selection, and profile switching aren't covered here.",
|
||||||
|
));
|
||||||
|
let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
|
||||||
|
mixer_btn.set_halign(gtk4::Align::Start);
|
||||||
|
mixer_btn.connect_clicked(|_| {
|
||||||
|
let _ = Command::new("pavucontrol").spawn();
|
||||||
|
});
|
||||||
|
content.append(&mixer_btn);
|
||||||
|
|
||||||
|
outer
|
||||||
|
}
|
||||||
|
|
@ -31,8 +31,20 @@ fn field_label(text: &str) -> Label {
|
||||||
lbl
|
lbl
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
/// A label + control row, the same layout every `*_row` helper below uses.
|
||||||
|
/// Styled as its own small card (background + padding + rounded corners) so
|
||||||
|
/// a page of settings reads as a list of distinct rows, not a flat column of
|
||||||
|
/// labels with the control floating far away at the window's edge — which is
|
||||||
|
/// what plain edge-aligned rows look like once [`view_scaffold`] stops
|
||||||
|
/// letting the content column stretch to the full window width.
|
||||||
|
/// Exposed so panels that front live system state (not a `Doc`) — About,
|
||||||
|
/// Sound, Date & Time, Network — can still lay out rows consistently with
|
||||||
|
/// the TOML-editor panels.
|
||||||
|
pub fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
||||||
let row = GBox::new(Orientation::Horizontal, 16);
|
let row = GBox::new(Orientation::Horizontal, 16);
|
||||||
|
row.add_css_class("card");
|
||||||
|
row.set_margin_top(3);
|
||||||
|
row.set_margin_bottom(3);
|
||||||
row.append(&field_label(label));
|
row.append(&field_label(label));
|
||||||
control.set_halign(gtk4::Align::End);
|
control.set_halign(gtk4::Align::End);
|
||||||
control.set_valign(gtk4::Align::Center);
|
control.set_valign(gtk4::Align::Center);
|
||||||
|
|
@ -40,6 +52,15 @@ fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
|
||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A label + read-only value label, for panels that only display state
|
||||||
|
/// (About's CPU/memory/disk readouts, etc).
|
||||||
|
pub fn info_row(label: &str, value: &str) -> GBox {
|
||||||
|
let value_lbl = Label::new(Some(value));
|
||||||
|
value_lbl.add_css_class("dim-label");
|
||||||
|
value_lbl.set_selectable(true);
|
||||||
|
row(label, &value_lbl)
|
||||||
|
}
|
||||||
|
|
||||||
/// A bold section heading with spacing above it.
|
/// A bold section heading with spacing above it.
|
||||||
pub fn section(text: &str) -> Label {
|
pub fn section(text: &str) -> Label {
|
||||||
let lbl = Label::new(Some(text));
|
let lbl = Label::new(Some(text));
|
||||||
|
|
@ -60,6 +81,41 @@ pub fn hint(text: &str) -> Label {
|
||||||
lbl
|
lbl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Widest a settings column is allowed to grow. Without a cap, `content`
|
||||||
|
/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow
|
||||||
|
/// stretches it to the full window width — on a maximized/ultrawide window
|
||||||
|
/// that leaves the control on every row stranded ~1500px from its label.
|
||||||
|
const CONTENT_MAX_WIDTH: i32 = 760;
|
||||||
|
|
||||||
|
/// A centered placeholder for a panel's empty state (no snapshots yet, no
|
||||||
|
/// scan results yet, etc) — a dim icon + title + hint, instead of a single
|
||||||
|
/// small sentence lost in an otherwise-empty scroll area.
|
||||||
|
pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox {
|
||||||
|
let wrapper = GBox::new(Orientation::Vertical, 6);
|
||||||
|
wrapper.set_valign(gtk4::Align::Center);
|
||||||
|
wrapper.set_halign(gtk4::Align::Center);
|
||||||
|
wrapper.set_vexpand(true);
|
||||||
|
wrapper.set_margin_top(32);
|
||||||
|
wrapper.set_margin_bottom(32);
|
||||||
|
|
||||||
|
let icon = gtk4::Image::from_icon_name(icon_name);
|
||||||
|
icon.set_pixel_size(48);
|
||||||
|
icon.add_css_class("dim-label");
|
||||||
|
wrapper.append(&icon);
|
||||||
|
|
||||||
|
let title_lbl = Label::new(Some(title));
|
||||||
|
title_lbl.add_css_class("heading");
|
||||||
|
wrapper.append(&title_lbl);
|
||||||
|
|
||||||
|
let hint_lbl = Label::new(Some(hint_text));
|
||||||
|
hint_lbl.add_css_class("dim-label");
|
||||||
|
hint_lbl.set_justify(gtk4::Justification::Center);
|
||||||
|
hint_lbl.set_wrap(true);
|
||||||
|
wrapper.append(&hint_lbl);
|
||||||
|
|
||||||
|
wrapper
|
||||||
|
}
|
||||||
|
|
||||||
/// Standard view scaffold: an outer vertical box with a title and a scrollable
|
/// Standard view scaffold: an outer vertical box with a title and a scrollable
|
||||||
/// content area. Append setting rows to the returned `content`, then append a
|
/// content area. Append setting rows to the returned `content`, then append a
|
||||||
/// [`save_button`] to `outer`. Returns `(outer, content)`.
|
/// [`save_button`] to `outer`. Returns `(outer, content)`.
|
||||||
|
|
@ -72,7 +128,19 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) {
|
||||||
title_lbl.set_xalign(0.0);
|
title_lbl.set_xalign(0.0);
|
||||||
outer.append(&title_lbl);
|
outer.append(&title_lbl);
|
||||||
|
|
||||||
let content = GBox::new(Orientation::Vertical, 8);
|
let content = GBox::new(Orientation::Vertical, 4);
|
||||||
|
// Explicit hexpand(false) overrides the auto-computed value GTK would
|
||||||
|
// otherwise derive from the hexpand-ing labels inside every row, and
|
||||||
|
// Start keeps content pinned under the left-aligned title instead of
|
||||||
|
// stretching (Fill) or floating in the middle of a wide pane (Center).
|
||||||
|
content.set_hexpand(false);
|
||||||
|
// Center rather than left-align: on a wide/maximized window, a
|
||||||
|
// left-hugging column just moves the dead space from "right of every
|
||||||
|
// row" to "right of the whole column" — centering distributes it evenly
|
||||||
|
// either side instead, which reads as deliberate rather than stranded.
|
||||||
|
content.set_halign(gtk4::Align::Center);
|
||||||
|
content.set_size_request(CONTENT_MAX_WIDTH, -1);
|
||||||
|
|
||||||
let scroll = gtk4::ScrolledWindow::new();
|
let scroll = gtk4::ScrolledWindow::new();
|
||||||
scroll.set_vexpand(true);
|
scroll.set_vexpand(true);
|
||||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
||||||
|
|
@ -205,6 +273,142 @@ pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
|
||||||
row(label, &entry)
|
row(label, &entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run `args` as a subprocess, streaming stdout+stderr into `log_buf` line by
|
||||||
|
/// line, then call `on_done` once the process exits. GTK widgets aren't
|
||||||
|
/// `Send`, so the child runs on its own thread and results come back over an
|
||||||
|
/// `async_channel` to a `glib::spawn_future_local` task that touches the
|
||||||
|
/// widget. Shared by every panel that shells out to a CLI tool and wants live
|
||||||
|
/// output (Packages, Network, Firewall, Firmware, ...).
|
||||||
|
pub fn stream_command_then(
|
||||||
|
args: &[&str],
|
||||||
|
log_buf: gtk4::TextBuffer,
|
||||||
|
on_done: impl FnOnce() + 'static,
|
||||||
|
) {
|
||||||
|
let (sender, receiver) = async_channel::bounded::<String>(256);
|
||||||
|
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut child = match std::process::Command::new(&args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = sender.send_blocking(format!("Error: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both are Some because we spawned with Stdio::piped() above.
|
||||||
|
let stdout = child.stdout.take().expect("stdout piped");
|
||||||
|
let stderr = child.stderr.take().expect("stderr piped");
|
||||||
|
|
||||||
|
let tx2 = sender.clone();
|
||||||
|
let stderr_thread = std::thread::spawn(move || {
|
||||||
|
for line in std::io::BufRead::lines(std::io::BufReader::new(stderr)).flatten() {
|
||||||
|
let _ = tx2.send_blocking(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for line in std::io::BufRead::lines(std::io::BufReader::new(stdout)).flatten() {
|
||||||
|
let _ = sender.send_blocking(line);
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = stderr_thread.join();
|
||||||
|
});
|
||||||
|
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok(line) = receiver.recv().await {
|
||||||
|
let mut end = log_buf.end_iter();
|
||||||
|
log_buf.insert(&mut end, &format!("{line}\n"));
|
||||||
|
}
|
||||||
|
on_done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`stream_command_then`] with no completion callback.
|
||||||
|
pub fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
|
||||||
|
stream_command_then(args, log_buf, || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn systemctl_active(unit: &str) -> bool {
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(["--user", "is-active", "--quiet", unit])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls —
|
||||||
|
/// every bread-ecosystem panel whose app is actually a daemon (not just a
|
||||||
|
/// config file) gets this, instead of only ever being able to edit the TOML
|
||||||
|
/// and hope the running process picks it up.
|
||||||
|
pub fn service_control(unit: &'static str) -> GBox {
|
||||||
|
let wrapper = GBox::new(Orientation::Vertical, 4);
|
||||||
|
wrapper.append(§ion("Service"));
|
||||||
|
|
||||||
|
let status_lbl = Label::new(None);
|
||||||
|
status_lbl.add_css_class("dim-label");
|
||||||
|
status_lbl.set_selectable(true);
|
||||||
|
wrapper.append(&row(unit, &status_lbl));
|
||||||
|
|
||||||
|
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
||||||
|
btn_row.set_margin_top(4);
|
||||||
|
let toggle_btn = Button::new();
|
||||||
|
let restart_btn = Button::with_label("Restart");
|
||||||
|
let logs_btn = Button::with_label("View logs");
|
||||||
|
|
||||||
|
// Rc<dyn Fn()> rather than a plain closure: three different button
|
||||||
|
// handlers all need to re-run this after their action completes, and
|
||||||
|
// plain closures aren't Clone.
|
||||||
|
let refresh: Rc<dyn Fn()> = {
|
||||||
|
let status_lbl = status_lbl.clone();
|
||||||
|
let toggle_btn = toggle_btn.clone();
|
||||||
|
Rc::new(move || {
|
||||||
|
let active = systemctl_active(unit);
|
||||||
|
status_lbl.set_text(if active { "Running" } else { "Stopped" });
|
||||||
|
toggle_btn.set_label(if active { "Stop" } else { "Start" });
|
||||||
|
})
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
toggle_btn.connect_clicked(move |_| {
|
||||||
|
let verb = if systemctl_active(unit) { "stop" } else { "start" };
|
||||||
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
restart_btn.connect_clicked(move |_| {
|
||||||
|
let log_buf = gtk4::TextBuffer::new(None);
|
||||||
|
let refresh = refresh.clone();
|
||||||
|
stream_command_then(&["systemctl", "--user", "restart", unit], log_buf, move || {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logs_btn.connect_clicked(move |_| {
|
||||||
|
let _ = std::process::Command::new("kitty")
|
||||||
|
.args(["-e", "journalctl", "--user", "-u", unit, "-f"])
|
||||||
|
.spawn();
|
||||||
|
});
|
||||||
|
|
||||||
|
btn_row.append(&toggle_btn);
|
||||||
|
btn_row.append(&restart_btn);
|
||||||
|
btn_row.append(&logs_btn);
|
||||||
|
wrapper.append(&btn_row);
|
||||||
|
|
||||||
|
wrapper
|
||||||
|
}
|
||||||
|
|
||||||
/// A Save button + transient status label that persists the document to `path`.
|
/// A Save button + transient status label that persists the document to `path`.
|
||||||
pub fn save_button(doc: &Doc, path: PathBuf) -> GBox {
|
pub fn save_button(doc: &Doc, path: PathBuf) -> GBox {
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ pub fn build_ui(app: &Application) {
|
||||||
crate::theme::load(&WidgetExt::display(&window));
|
crate::theme::load(&WidgetExt::display(&window));
|
||||||
|
|
||||||
let hpaned = Paned::new(Orientation::Horizontal);
|
let hpaned = Paned::new(Orientation::Horizontal);
|
||||||
hpaned.set_position(190);
|
hpaned.set_position(210);
|
||||||
hpaned.set_shrink_start_child(false);
|
hpaned.set_shrink_start_child(false);
|
||||||
hpaned.set_resize_start_child(false);
|
hpaned.set_resize_start_child(false);
|
||||||
|
|
||||||
|
|
@ -25,20 +25,25 @@ pub fn build_ui(app: &Application) {
|
||||||
stack.set_hexpand(true);
|
stack.set_hexpand(true);
|
||||||
stack.set_vexpand(true);
|
stack.set_vexpand(true);
|
||||||
|
|
||||||
|
stack.add_named(&views::about::build(), Some("about"));
|
||||||
|
stack.add_named(&views::network::build(), Some("network"));
|
||||||
|
stack.add_named(&views::sound::build(), Some("sound"));
|
||||||
|
stack.add_named(&views::datetime::build(), Some("datetime"));
|
||||||
stack.add_named(&views::snapshots::build(), Some("snapshots"));
|
stack.add_named(&views::snapshots::build(), Some("snapshots"));
|
||||||
stack.add_named(&views::packages::build(), Some("packages"));
|
stack.add_named(&views::packages::build(), Some("packages"));
|
||||||
stack.add_named(&views::bread::build(), Some("bread"));
|
stack.add_named(&views::bread::build(), Some("bread"));
|
||||||
stack.add_named(&views::breadbar::build(), Some("breadbar"));
|
stack.add_named(&views::breadbar::build(), Some("breadbar"));
|
||||||
stack.add_named(&views::breadbox::build(), Some("breadbox"));
|
stack.add_named(&views::breadbox::build(), Some("breadbox"));
|
||||||
|
stack.add_named(&views::breadclip::build(), Some("breadclip"));
|
||||||
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
|
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
|
||||||
stack.add_named(&views::breadpad::build(), Some("breadpad"));
|
stack.add_named(&views::breadpad::build(), Some("breadpad"));
|
||||||
stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
|
stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
|
||||||
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
|
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
|
||||||
stack.add_named(&views::hyprland::build(), Some("hyprland"));
|
stack.add_named(&views::hyprland::build(), Some("hyprland"));
|
||||||
|
|
||||||
// Default to the bread panel — Snapshots was previously first, an odd
|
// Default to About — a settings app's most GNOME-Settings-like landing
|
||||||
// first impression for a settings app named after the bread ecosystem.
|
// page: identifies the machine, no config editor thrown at you first.
|
||||||
stack.set_visible_child_name("bread");
|
stack.set_visible_child_name("about");
|
||||||
|
|
||||||
{
|
{
|
||||||
let stack = stack.clone();
|
let stack = stack.clone();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue