From 081ae80e6c4aaae97d903e434b0680eb088f142e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 13:27:25 +0000 Subject: [PATCH] Scaffold BOS repo: dotfiles, ISO profile, and bos-settings GTK4 app Implements all four components from the BOS spec: - dotfiles/: default Hyprland, bread, breadbox, breadcrumbs configs - iso/: archiso profiledef, package list, Calamares YAML modules, post-install.sh - bos-settings/: Cargo workspace with GTK4 settings app (8 views: snapshots, packages, bread, breadbar, breadbox, breadcrumbs, breadpad, hyprland) https://claude.ai/code/session_01WszGHvCmxgcyTwNSkfLF9P --- Cargo.toml | 11 +++ src/config/mod.rs | 24 +++++ src/main.rs | 12 +++ src/state.rs | 11 +++ src/theme.rs | 88 +++++++++++++++++++ src/ui/mod.rs | 3 + src/ui/sidebar.rs | 66 ++++++++++++++ src/ui/views/bread.rs | 149 +++++++++++++++++++++++++++++++ src/ui/views/breadbar.rs | 58 ++++++++++++ src/ui/views/breadbox.rs | 134 ++++++++++++++++++++++++++++ src/ui/views/breadcrumbs.rs | 134 ++++++++++++++++++++++++++++ src/ui/views/breadpad.rs | 118 +++++++++++++++++++++++++ src/ui/views/hyprland.rs | 92 +++++++++++++++++++ src/ui/views/mod.rs | 8 ++ src/ui/views/packages.rs | 171 ++++++++++++++++++++++++++++++++++++ src/ui/views/snapshots.rs | 167 +++++++++++++++++++++++++++++++++++ src/ui/window.rs | 57 ++++++++++++ 17 files changed, 1303 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/config/mod.rs create mode 100644 src/main.rs create mode 100644 src/state.rs create mode 100644 src/theme.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/sidebar.rs create mode 100644 src/ui/views/bread.rs create mode 100644 src/ui/views/breadbar.rs create mode 100644 src/ui/views/breadbox.rs create mode 100644 src/ui/views/breadcrumbs.rs create mode 100644 src/ui/views/breadpad.rs create mode 100644 src/ui/views/hyprland.rs create mode 100644 src/ui/views/mod.rs create mode 100644 src/ui/views/packages.rs create mode 100644 src/ui/views/snapshots.rs create mode 100644 src/ui/window.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..24dabe0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "bos-settings" +version = "0.1.0" +edition = "2021" + +[dependencies] +gtk4 = { version = "0.9", features = ["v4_12"] } +glib = "0.20" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..08b005b --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,24 @@ +use std::error::Error; +use std::path::Path; + +pub fn load serde::Deserialize<'de>>(path: &Path) -> Result> { + let text = std::fs::read_to_string(path)?; + Ok(toml::from_str(&text)?) +} + +pub fn save(path: &Path, val: &T) -> Result<(), Box> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, toml::to_string_pretty(val)?)?; + Ok(()) +} + +pub fn config_dir() -> std::path::PathBuf { + dirs_path() +} + +fn dirs_path() -> std::path::PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + std::path::PathBuf::from(home).join(".config") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c93a8d2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,12 @@ +mod config; +mod state; +mod theme; +mod ui; + +fn main() { + let app = gtk4::Application::builder() + .application_id("com.breadway.bos-settings") + .build(); + app.connect_activate(ui::window::build_ui); + app.run(); +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..e0e760e --- /dev/null +++ b/src/state.rs @@ -0,0 +1,11 @@ +pub struct AppState { + pub current_view: String, +} + +impl AppState { + pub fn new() -> Self { + Self { + current_view: "snapshots".to_string(), + } + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..ae850d0 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,88 @@ +use gtk4::prelude::*; +use gtk4::CssProvider; + +const CSS: &str = r#" +window { + background-color: #2e3440; + color: #eceff4; +} + +.sidebar { + background-color: #3b4252; + border-right: 1px solid #434c5e; +} + +.sidebar row { + padding: 8px 12px; + color: #d8dee9; +} + +.sidebar row:selected { + background-color: #5e81ac; + color: #eceff4; +} + +.sidebar .section-header { + padding: 12px 12px 4px 12px; + font-size: 0.75em; + font-weight: bold; + color: #616e88; + text-transform: uppercase; + letter-spacing: 1px; +} + +.view-content { + padding: 24px; +} + +.view-content label.title { + font-size: 1.4em; + font-weight: bold; + color: #eceff4; + margin-bottom: 16px; +} + +button { + background-color: #5e81ac; + color: #eceff4; + border: none; + border-radius: 4px; + padding: 6px 16px; +} + +button:hover { + background-color: #81a1c1; +} + +button.destructive-action { + background-color: #bf616a; +} + +button.destructive-action:hover { + background-color: #d08770; +} + +entry { + background-color: #434c5e; + color: #eceff4; + border: 1px solid #4c566a; + border-radius: 4px; +} + +textview { + background-color: #272c36; + color: #a3be8c; + font-family: monospace; + padding: 8px; +} +"#; + +pub fn load(display: >k4::gdk::Display) { + let provider = CssProvider::new(); + provider.load_from_string(CSS); + gtk4::style_context_add_provider_for_display( + display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..1a2b383 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod sidebar; +pub mod views; +pub mod window; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs new file mode 100644 index 0000000..daafb0b --- /dev/null +++ b/src/ui/sidebar.rs @@ -0,0 +1,66 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation, Separator}; + +pub struct SidebarItem { + pub id: &'static str, + pub label: &'static str, +} + +pub const APPS_ITEMS: &[SidebarItem] = &[ + SidebarItem { id: "bread", label: "bread" }, + SidebarItem { id: "breadbar", label: "breadbar" }, + SidebarItem { id: "breadbox", label: "breadbox" }, + SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, + SidebarItem { id: "breadpad", label: "breadpad" }, +]; + +pub const SYSTEM_ITEMS: &[SidebarItem] = &[ + SidebarItem { id: "snapshots", label: "Snapshots" }, + SidebarItem { id: "packages", label: "Packages" }, + SidebarItem { id: "hyprland", label: "Hyprland" }, +]; + +pub fn build() -> (GBox, ListBox) { + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("sidebar"); + vbox.set_width_request(190); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::Single); + list.add_css_class("sidebar"); + + append_section(&list, "Apps", APPS_ITEMS); + append_section(&list, "System", SYSTEM_ITEMS); + + // Select first item by default + if let Some(first) = list.row_at_index(1) { + list.select_row(Some(&first)); + } + + vbox.append(&list); + (vbox, list) +} + +fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { + // Section header (non-selectable) + let header_row = ListBoxRow::new(); + header_row.set_selectable(false); + header_row.set_activatable(false); + let header_lbl = Label::new(Some(title)); + header_lbl.add_css_class("section-header"); + header_lbl.set_xalign(0.0); + header_row.set_child(Some(&header_lbl)); + list.append(&header_row); + + for item in items { + let row = ListBoxRow::new(); + row.set_widget_name(item.id); + + let lbl = Label::new(Some(item.label)); + lbl.set_xalign(0.0); + lbl.set_margin_top(2); + lbl.set_margin_bottom(2); + row.set_child(Some(&lbl)); + list.append(&row); + } +} diff --git a/src/ui/views/bread.rs b/src/ui/views/bread.rs new file mode 100644 index 0000000..ca4b080 --- /dev/null +++ b/src/ui/views/bread.rs @@ -0,0 +1,149 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, DropDown, Label, Orientation, StringList, Switch}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; + +use crate::config; + +#[derive(Deserialize, Serialize, Clone)] +pub struct BreadConfig { + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default)] + pub adapters: AdaptersConfig, +} + +fn default_log_level() -> String { + "info".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Default)] +pub struct AdaptersConfig { + #[serde(default = "default_true")] + pub keyboard: bool, + #[serde(default = "default_true")] + pub mouse: bool, + #[serde(default = "default_true")] + pub touchpad: bool, + #[serde(default = "default_true")] + pub bluetooth: bool, + #[serde(default = "default_true")] + pub gamepad: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for BreadConfig { + fn default() -> Self { + Self { + log_level: default_log_level(), + adapters: AdaptersConfig::default(), + } + } +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("bread/breadd.toml") +} + +fn adapter_row(label: &str, active: bool, cfg: Rc>, field: &'static str) -> GBox { + let row = GBox::new(Orientation::Horizontal, 16); + let lbl = Label::new(Some(label)); + lbl.set_hexpand(true); + lbl.set_xalign(0.0); + let sw = Switch::new(); + sw.set_active(active); + sw.connect_active_notify(move |s| { + let val = s.is_active(); + let mut c = cfg.borrow_mut(); + match field { + "keyboard" => c.adapters.keyboard = val, + "mouse" => c.adapters.mouse = val, + "touchpad" => c.adapters.touchpad = val, + "bluetooth" => c.adapters.bluetooth = val, + "gamepad" => c.adapters.gamepad = val, + _ => {} + } + }); + row.append(&lbl); + row.append(&sw); + row +} + +pub fn build() -> GBox { + let path = config_path(); + let cfg: BreadConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); + + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("bread")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); + + // Log level + let row = GBox::new(Orientation::Horizontal, 16); + row.set_margin_bottom(8); + let lbl = Label::new(Some("Log level")); + lbl.set_hexpand(true); + lbl.set_xalign(0.0); + let levels = StringList::new(&["error", "warn", "info", "debug", "trace"]); + let dropdown = DropDown::new(Some(levels), gtk4::Expression::NONE); + let current_pos = match cfg.borrow().log_level.as_str() { + "error" => 0u32, + "warn" => 1, + "info" => 2, + "debug" => 3, + "trace" => 4, + _ => 2, + }; + dropdown.set_selected(current_pos); + { + let cfg = cfg.clone(); + dropdown.connect_selected_notify(move |dd| { + let levels = ["error", "warn", "info", "debug", "trace"]; + if let Some(&level) = levels.get(dd.selected() as usize) { + cfg.borrow_mut().log_level = level.to_string(); + } + }); + } + row.append(&lbl); + row.append(&dropdown); + vbox.append(&row); + + // Adapter toggles + let adapter_label = Label::new(Some("Adapters")); + adapter_label.set_xalign(0.0); + adapter_label.set_margin_top(8); + adapter_label.set_margin_bottom(4); + vbox.append(&adapter_label); + + let (kbd, mouse, touchpad, bluetooth, gamepad) = { + let c = cfg.borrow(); + (c.adapters.keyboard, c.adapters.mouse, c.adapters.touchpad, c.adapters.bluetooth, c.adapters.gamepad) + }; + + vbox.append(&adapter_row("Keyboard", kbd, cfg.clone(), "keyboard")); + vbox.append(&adapter_row("Mouse", mouse, cfg.clone(), "mouse")); + vbox.append(&adapter_row("Touchpad", touchpad, cfg.clone(), "touchpad")); + vbox.append(&adapter_row("Bluetooth", bluetooth, cfg.clone(), "bluetooth")); + vbox.append(&adapter_row("Gamepad", gamepad, cfg.clone(), "gamepad")); + + let save_btn = Button::with_label("Save"); + save_btn.set_margin_top(16); + save_btn.set_halign(gtk4::Align::Start); + { + let cfg = cfg.clone(); + save_btn.connect_clicked(move |_| { + let _ = config::save(&path, &*cfg.borrow()); + }); + } + vbox.append(&save_btn); + + vbox +} diff --git a/src/ui/views/breadbar.rs b/src/ui/views/breadbar.rs new file mode 100644 index 0000000..2314111 --- /dev/null +++ b/src/ui/views/breadbar.rs @@ -0,0 +1,58 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView}; +use std::path::PathBuf; + +fn css_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + PathBuf::from(home).join(".config/breadbar/style.css") +} + +pub fn build() -> GBox { + let path = css_path(); + let existing_css = std::fs::read_to_string(&path).unwrap_or_default(); + + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("breadbar")); + 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); + buf.set_text(&existing_css); + + let text_view = TextView::with_buffer(&buf); + text_view.set_monospace(true); + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&text_view)); + vbox.append(&scroll); + + let save_btn = Button::with_label("Save"); + save_btn.set_margin_top(12); + save_btn.set_halign(gtk4::Align::Start); + { + let path = path.clone(); + save_btn.connect_clicked(move |_| { + let (start, end) = buf.bounds(); + let text = buf.text(&start, &end, false); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, text.as_str()); + }); + } + vbox.append(&save_btn); + + vbox +} diff --git a/src/ui/views/breadbox.rs b/src/ui/views/breadbox.rs new file mode 100644 index 0000000..715ce71 --- /dev/null +++ b/src/ui/views/breadbox.rs @@ -0,0 +1,134 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; + +use crate::config; + +#[derive(Deserialize, Serialize, Clone, Default)] +pub struct BreadboxConfig { + #[serde(default)] + pub context: Vec, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Context { + pub name: String, + #[serde(default)] + pub apps: Vec, +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadbox/config.toml") +} + +fn rebuild_list(list: &ListBox, cfg: &Rc>) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + for (i, ctx) in cfg.borrow().context.iter().enumerate() { + let row = ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 8); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let name_entry = Entry::new(); + name_entry.set_text(&ctx.name); + name_entry.set_width_chars(16); + + let apps_entry = Entry::new(); + apps_entry.set_text(&ctx.apps.join(", ")); + apps_entry.set_hexpand(true); + apps_entry.set_placeholder_text(Some("app1, app2, ...")); + + { + let cfg = cfg.clone(); + name_entry.connect_changed(move |e| { + if let Some(c) = cfg.borrow_mut().context.get_mut(i) { + c.name = e.text().to_string(); + } + }); + } + { + let cfg = cfg.clone(); + apps_entry.connect_changed(move |e| { + if let Some(c) = cfg.borrow_mut().context.get_mut(i) { + c.apps = e + .text() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + }); + } + + hbox.append(&name_entry); + hbox.append(&apps_entry); + row.set_child(Some(&hbox)); + list.append(&row); + } +} + +pub fn build() -> GBox { + let path = config_path(); + let cfg: BreadboxConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); + + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("breadbox")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); + + let subtitle = Label::new(Some("Context priority lists — apps shown in each context.")); + subtitle.set_xalign(0.0); + subtitle.set_margin_bottom(8); + vbox.append(&subtitle); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + + rebuild_list(&list, &cfg); + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&list)); + vbox.append(&scroll); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(8); + + let add_btn = Button::with_label("Add context"); + { + let cfg = cfg.clone(); + let list = list.clone(); + add_btn.connect_clicked(move |_| { + cfg.borrow_mut().context.push(Context { + name: "new".to_string(), + apps: Vec::new(), + }); + rebuild_list(&list, &cfg); + }); + } + + let save_btn = Button::with_label("Save"); + { + let cfg = cfg.clone(); + let path = path.clone(); + save_btn.connect_clicked(move |_| { + let _ = config::save(&path, &*cfg.borrow()); + }); + } + + btn_row.append(&add_btn); + btn_row.append(&save_btn); + vbox.append(&btn_row); + + vbox +} diff --git a/src/ui/views/breadcrumbs.rs b/src/ui/views/breadcrumbs.rs new file mode 100644 index 0000000..4062645 --- /dev/null +++ b/src/ui/views/breadcrumbs.rs @@ -0,0 +1,134 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; + +use crate::config; + +#[derive(Deserialize, Serialize, Clone, Default)] +pub struct BreadcrumbsConfig { + #[serde(default)] + pub profile: Vec, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Profile { + pub name: String, + #[serde(default)] + pub ssids: Vec, +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadcrumbs/breadcrumbs.toml") +} + +fn rebuild_list(list: &ListBox, cfg: &Rc>) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + for (i, profile) in cfg.borrow().profile.iter().enumerate() { + let row = ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 8); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let name_entry = Entry::new(); + name_entry.set_text(&profile.name); + name_entry.set_width_chars(16); + + let ssids_entry = Entry::new(); + ssids_entry.set_text(&profile.ssids.join(", ")); + ssids_entry.set_hexpand(true); + ssids_entry.set_placeholder_text(Some("SSID1, SSID2, ...")); + + { + let cfg = cfg.clone(); + name_entry.connect_changed(move |e| { + if let Some(p) = cfg.borrow_mut().profile.get_mut(i) { + p.name = e.text().to_string(); + } + }); + } + { + let cfg = cfg.clone(); + ssids_entry.connect_changed(move |e| { + if let Some(p) = cfg.borrow_mut().profile.get_mut(i) { + p.ssids = e + .text() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + }); + } + + hbox.append(&name_entry); + hbox.append(&ssids_entry); + row.set_child(Some(&hbox)); + list.append(&row); + } +} + +pub fn build() -> GBox { + let path = config_path(); + let cfg: BreadcrumbsConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); + + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("breadcrumbs")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); + + let subtitle = Label::new(Some("Network profiles — SSIDs associated with each location.")); + subtitle.set_xalign(0.0); + subtitle.set_margin_bottom(8); + vbox.append(&subtitle); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + + rebuild_list(&list, &cfg); + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&list)); + vbox.append(&scroll); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(8); + + let add_btn = Button::with_label("Add profile"); + { + let cfg = cfg.clone(); + let list = list.clone(); + add_btn.connect_clicked(move |_| { + cfg.borrow_mut().profile.push(Profile { + name: "new".to_string(), + ssids: Vec::new(), + }); + rebuild_list(&list, &cfg); + }); + } + + let save_btn = Button::with_label("Save"); + { + let cfg = cfg.clone(); + let path = path.clone(); + save_btn.connect_clicked(move |_| { + let _ = config::save(&path, &*cfg.borrow()); + }); + } + + btn_row.append(&add_btn); + btn_row.append(&save_btn); + vbox.append(&btn_row); + + vbox +} diff --git a/src/ui/views/breadpad.rs b/src/ui/views/breadpad.rs new file mode 100644 index 0000000..9c553f4 --- /dev/null +++ b/src/ui/views/breadpad.rs @@ -0,0 +1,118 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, Orientation, Switch}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::rc::Rc; + +use crate::config; + +#[derive(Deserialize, Serialize, Clone)] +pub struct BreadpadConfig { + #[serde(default = "default_model")] + pub model: String, + #[serde(default = "default_true")] + pub reminders: bool, + #[serde(default = "default_true")] + pub calendar: bool, +} + +fn default_model() -> String { + "claude-sonnet-4-6".to_string() +} + +fn default_true() -> bool { + true +} + +impl Default for BreadpadConfig { + fn default() -> Self { + Self { + model: default_model(), + reminders: true, + calendar: true, + } + } +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadpad/breadpad.toml") +} + +pub fn build() -> GBox { + let path = config_path(); + let cfg: BreadpadConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); + + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("breadpad")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); + + // Model entry + let row = GBox::new(Orientation::Horizontal, 16); + let lbl = Label::new(Some("Model")); + lbl.set_hexpand(true); + lbl.set_xalign(0.0); + let model_entry = Entry::new(); + model_entry.set_text(&cfg.borrow().model); + { + let cfg = cfg.clone(); + model_entry.connect_changed(move |e| { + cfg.borrow_mut().model = e.text().to_string(); + }); + } + row.append(&lbl); + row.append(&model_entry); + vbox.append(&row); + + // Reminders toggle + let row = GBox::new(Orientation::Horizontal, 16); + let lbl = Label::new(Some("Reminders")); + lbl.set_hexpand(true); + lbl.set_xalign(0.0); + let sw = Switch::new(); + sw.set_active(cfg.borrow().reminders); + { + let cfg = cfg.clone(); + sw.connect_active_notify(move |s| { + cfg.borrow_mut().reminders = s.is_active(); + }); + } + row.append(&lbl); + row.append(&sw); + vbox.append(&row); + + // Calendar toggle + let row = GBox::new(Orientation::Horizontal, 16); + let lbl = Label::new(Some("Calendar integration")); + lbl.set_hexpand(true); + lbl.set_xalign(0.0); + let sw = Switch::new(); + sw.set_active(cfg.borrow().calendar); + { + let cfg = cfg.clone(); + sw.connect_active_notify(move |s| { + cfg.borrow_mut().calendar = s.is_active(); + }); + } + row.append(&lbl); + row.append(&sw); + vbox.append(&row); + + let save_btn = Button::with_label("Save"); + save_btn.set_margin_top(16); + save_btn.set_halign(gtk4::Align::Start); + { + let cfg = cfg.clone(); + let path = path.clone(); + save_btn.connect_clicked(move |_| { + let _ = config::save(&path, &*cfg.borrow()); + }); + } + vbox.append(&save_btn); + + vbox +} diff --git a/src/ui/views/hyprland.rs b/src/ui/views/hyprland.rs new file mode 100644 index 0000000..cb43b30 --- /dev/null +++ b/src/ui/views/hyprland.rs @@ -0,0 +1,92 @@ +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Label, Orientation}; +use std::process::Command; + +fn get_monitors() -> Vec { + let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&output.stdout); + let Ok(monitors) = serde_json::from_str::>(&text) else { + return Vec::new(); + }; + monitors + .iter() + .filter_map(|m| { + let name = m.get("name")?.as_str()?; + let w = m.get("width")?.as_u64()?; + let h = m.get("height")?.as_u64()?; + let refresh = m.get("refreshRate")?.as_f64()?; + Some(format!("{name} {w}×{h} @ {refresh:.0}Hz")) + }) + .collect() +} + +fn config_path() -> std::path::PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + std::path::PathBuf::from(home).join(".config/hypr/hyprland.conf") +} + +pub fn build() -> GBox { + let vbox = GBox::new(Orientation::Vertical, 12); + 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); + + // Monitors section + 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); + + let monitors = get_monitors(); + if monitors.is_empty() { + let lbl = Label::new(Some("No monitors detected (is Hyprland running?)")); + lbl.set_xalign(0.0); + vbox.append(&lbl); + } else { + for mon in &monitors { + let lbl = Label::new(Some(mon)); + lbl.set_xalign(0.0); + lbl.add_css_class("monospace"); + vbox.append(&lbl); + } + } + + // Open config button + let open_btn = Button::with_label("Open hyprland.conf in editor"); + open_btn.set_margin_top(16); + open_btn.set_halign(gtk4::Align::Start); + { + let path = config_path(); + open_btn.connect_clicked(move |_| { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string()); + let _ = Command::new(&editor) + .arg(path.to_str().unwrap_or("")) + .spawn(); + }); + } + vbox.append(&open_btn); + + // Open keybinds button + let keybinds_btn = Button::with_label("Open keybinds.conf in editor"); + keybinds_btn.set_margin_top(8); + keybinds_btn.set_halign(gtk4::Align::Start); + { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let kb_path = std::path::PathBuf::from(home).join(".config/hypr/keybinds.conf"); + keybinds_btn.connect_clicked(move |_| { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string()); + let _ = Command::new(&editor) + .arg(kb_path.to_str().unwrap_or("")) + .spawn(); + }); + } + vbox.append(&keybinds_btn); + + vbox +} diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs new file mode 100644 index 0000000..67763f0 --- /dev/null +++ b/src/ui/views/mod.rs @@ -0,0 +1,8 @@ +pub mod bread; +pub mod breadbar; +pub mod breadbox; +pub mod breadcrumbs; +pub mod breadpad; +pub mod hyprland; +pub mod packages; +pub mod snapshots; diff --git a/src/ui/views/packages.rs b/src/ui/views/packages.rs new file mode 100644 index 0000000..448ec53 --- /dev/null +++ b/src/ui/views/packages.rs @@ -0,0 +1,171 @@ +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::process::Command; + +#[derive(Deserialize, Default)] +struct InstalledPackages { + #[serde(flatten)] + packages: HashMap, +} + +#[derive(Deserialize)] +struct PackageInfo { + version: String, +} + +fn read_installed() -> HashMap { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let path = std::path::Path::new(&home) + .join(".local/state/bakery/installed.json"); + + let Ok(text) = std::fs::read_to_string(&path) else { + return HashMap::new(); + }; + + let Ok(parsed) = serde_json::from_str::>(&text) else { + return HashMap::new(); + }; + + parsed + .into_iter() + .filter_map(|(name, val)| { + let version = val + .get("version") + .or_else(|| val.as_str().map(|_| &val)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Some((name, version)) + }) + .collect() +} + +pub fn build() -> GBox { + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("view-content"); + + 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.")); + subtitle.set_xalign(0.0); + subtitle.set_margin_bottom(16); + vbox.append(&subtitle); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + + let packages = read_installed(); + if packages.is_empty() { + let row = ListBoxRow::new(); + let lbl = Label::new(Some("No bakery packages found (~/.local/state/bakery/installed.json)")); + lbl.set_margin_top(8); + lbl.set_margin_bottom(8); + lbl.set_margin_start(8); + row.set_child(Some(&lbl)); + list.append(&row); + } else { + let mut names: Vec<_> = packages.iter().collect(); + names.sort_by_key(|(k, _)| k.as_str()); + for (name, version) in names { + let row = ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 16); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let name_lbl = Label::new(Some(name)); + name_lbl.set_hexpand(true); + name_lbl.set_xalign(0.0); + + let ver_lbl = Label::new(Some(version)); + ver_lbl.set_xalign(1.0); + + let update_btn = Button::with_label("Update"); + let pkg_name = name.clone(); + update_btn.connect_clicked(move |_| { + let _ = Command::new("bakery") + .args(["update", &pkg_name]) + .spawn(); + }); + + hbox.append(&name_lbl); + hbox.append(&ver_lbl); + hbox.append(&update_btn); + row.set_child(Some(&hbox)); + list.append(&row); + } + } + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&list)); + vbox.append(&scroll); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(12); + + let check_btn = Button::with_label("Check for updates"); + let update_all_btn = Button::with_label("Update all"); + + let log_buf = gtk4::TextBuffer::new(None); + let log_view = TextView::with_buffer(&log_buf); + log_view.set_editable(false); + log_view.set_height_request(120); + log_view.set_margin_top(8); + + { + let log_buf = log_buf.clone(); + check_btn.connect_clicked(move |_| { + log_buf.set_text("Checking for updates...\n"); + match Command::new("bakery").args(["list"]).output() { + Ok(out) => { + let text = String::from_utf8_lossy(&out.stdout); + log_buf.set_text(&format!("{text}\n")); + } + Err(e) => { + log_buf.set_text(&format!("Error: {e}\n")); + } + } + }); + } + + { + let log_buf = log_buf.clone(); + update_all_btn.connect_clicked(move |_| { + log_buf.set_text("Running bakery update --all...\n"); + let (sender, receiver) = glib::MainContext::channel(glib::Priority::DEFAULT); + std::thread::spawn(move || { + let result = Command::new("bakery") + .args(["update", "--all"]) + .output(); + match result { + Ok(out) => { + let _ = sender.send(String::from_utf8_lossy(&out.stdout).to_string()); + } + Err(e) => { + let _ = sender.send(format!("Error: {e}\n")); + } + } + }); + receiver.attach(None, move |msg| { + log_buf.set_text(&msg); + glib::ControlFlow::Break + }); + }); + } + + btn_row.append(&check_btn); + btn_row.append(&update_all_btn); + vbox.append(&btn_row); + vbox.append(&log_view); + + vbox +} diff --git a/src/ui/views/snapshots.rs b/src/ui/views/snapshots.rs new file mode 100644 index 0000000..da34141 --- /dev/null +++ b/src/ui/views/snapshots.rs @@ -0,0 +1,167 @@ +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, Label, ListBox, ListBoxRow, MessageDialog, Orientation, ScrolledWindow, +}; +use std::process::Command; + +struct SnapshotRow { + number: String, + date: String, + description: String, +} + +fn list_snapshots() -> Vec { + let Ok(output) = Command::new("snapper") + .args(["list", "--output-cols", "number,date,description"]) + .output() + else { + return Vec::new(); + }; + + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + .skip(2) // header + separator + .filter_map(|line| { + let cols: Vec<&str> = line.splitn(3, '|').collect(); + if cols.len() == 3 { + Some(SnapshotRow { + number: cols[0].trim().to_string(), + date: cols[1].trim().to_string(), + description: cols[2].trim().to_string(), + }) + } else { + None + } + }) + .collect() +} + +fn confirm_rollback(number: &str) { + let number = number.to_string(); + let dialog = MessageDialog::new( + None::<>k4::Window>, + gtk4::DialogFlags::MODAL, + gtk4::MessageType::Question, + gtk4::ButtonsType::OkCancel, + &format!("Roll back to snapshot #{number}?\n\nReboot required to apply."), + ); + dialog.connect_response(move |d, resp| { + if resp == gtk4::ResponseType::Ok { + let _ = Command::new("snapper") + .args(["rollback", &number]) + .status(); + let info = MessageDialog::new( + None::<>k4::Window>, + gtk4::DialogFlags::MODAL, + gtk4::MessageType::Info, + gtk4::ButtonsType::Ok, + "Rollback queued. Please reboot to apply.", + ); + info.connect_response(|d, _| d.destroy()); + info.present(); + } + d.destroy(); + }); + dialog.present(); +} + +pub fn build() -> GBox { + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("view-content"); + + 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.")); + subtitle.set_xalign(0.0); + subtitle.set_margin_bottom(16); + vbox.append(&subtitle); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::Single); + + let snapshots = list_snapshots(); + if snapshots.is_empty() { + let row = ListBoxRow::new(); + let lbl = Label::new(Some( + "No snapshots found (snapper may not be configured yet)", + )); + lbl.set_margin_top(8); + lbl.set_margin_bottom(8); + lbl.set_margin_start(8); + row.set_child(Some(&lbl)); + list.append(&row); + } else { + for snap in &snapshots { + let row = ListBoxRow::new(); + row.set_widget_name(&snap.number); + + let hbox = GBox::new(Orientation::Horizontal, 16); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let num_lbl = Label::new(Some(&snap.number)); + num_lbl.set_width_chars(4); + num_lbl.set_xalign(0.0); + + let date_lbl = Label::new(Some(&snap.date)); + date_lbl.set_width_chars(22); + date_lbl.set_xalign(0.0); + + let desc_lbl = Label::new(Some(&snap.description)); + desc_lbl.set_hexpand(true); + desc_lbl.set_xalign(0.0); + + hbox.append(&num_lbl); + hbox.append(&date_lbl); + hbox.append(&desc_lbl); + row.set_child(Some(&hbox)); + list.append(&row); + } + } + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&list)); + vbox.append(&scroll); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(12); + + let rollback_btn = Button::with_label("Rollback to selected"); + let delete_btn = Button::with_label("Delete selected"); + delete_btn.add_css_class("destructive-action"); + + { + let list = list.clone(); + rollback_btn.connect_clicked(move |_| { + let Some(row) = list.selected_row() else { + return; + }; + let number = row.widget_name().to_string(); + confirm_rollback(&number); + }); + } + + { + let list = list.clone(); + delete_btn.connect_clicked(move |_| { + let Some(row) = list.selected_row() else { + return; + }; + let number = row.widget_name().to_string(); + let _ = Command::new("snapper").args(["delete", &number]).status(); + }); + } + + btn_row.append(&rollback_btn); + btn_row.append(&delete_btn); + vbox.append(&btn_row); + + vbox +} diff --git a/src/ui/window.rs b/src/ui/window.rs new file mode 100644 index 0000000..0088675 --- /dev/null +++ b/src/ui/window.rs @@ -0,0 +1,57 @@ +use gtk4::prelude::*; +use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Paned, Stack}; + +use super::sidebar; +use super::views; + +pub fn build_ui(app: &Application) { + let window = ApplicationWindow::builder() + .application(app) + .title("BOS Settings") + .default_width(960) + .default_height(640) + .build(); + + crate::theme::load(&window.display()); + + let hpaned = Paned::new(Orientation::Horizontal); + hpaned.set_position(190); + hpaned.set_shrink_start_child(false); + hpaned.set_resize_start_child(false); + + let (sidebar_box, list) = sidebar::build(); + + let stack = Stack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + + stack.add_named(&views::snapshots::build(), Some("snapshots")); + stack.add_named(&views::packages::build(), Some("packages")); + stack.add_named(&views::bread::build(), Some("bread")); + stack.add_named(&views::breadbar::build(), Some("breadbar")); + stack.add_named(&views::breadbox::build(), Some("breadbox")); + stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); + stack.add_named(&views::breadpad::build(), Some("breadpad")); + stack.add_named(&views::hyprland::build(), Some("hyprland")); + + // Default to snapshots view + stack.set_visible_child_name("snapshots"); + + { + let stack = stack.clone(); + list.connect_row_selected(move |_, row| { + if let Some(row) = row { + let name = row.widget_name(); + if !name.is_empty() { + stack.set_visible_child_name(&name); + } + } + }); + } + + hpaned.set_start_child(Some(&sidebar_box)); + hpaned.set_end_child(Some(&stack)); + + window.set_child(Some(&hpaned)); + window.present(); +}