Extract bos-settings into its own repo; add Appearance/Startup Apps/Display editors
Split out of the bos repo so a bos-settings release no longer requires a BOS ISO release (and vice versa) — CI here publishes straight to the [breadway] pacman repo on tag push, independent of bos's own release cadence. Also adds real GUI editors for the Hyprland JSON config surfaces bos gained recently (hypr/settings.json, hypr/monitors.json, hypr/autostart.json): - Appearance panel: gaps, borders (with a real color picker), blur, shadow, tiling layout, and basic input settings. - Display panel: a monitor-rule list editor alongside the existing live connected-monitor readout. - Startup Apps panel: toggle/edit/add/remove the extra autostart commands. - --page argv support for deep-linking (breadhelp's guides can now open bos-settings straight to a specific page). - Fixed a stale "View keybinds cheat sheet" button pointing at a file deleted when breadhelp replaced bos-keybinds/bos-welcome.
This commit is contained in:
parent
d0fc16bc84
commit
121e93d273
14 changed files with 1801 additions and 30 deletions
224
src/ui/views/appearance.rs
Normal file
224
src/ui/views/appearance.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by
|
||||
//! `scripts/ui/settings.lua` on the Hyprland side (see hyprland.lua). Unlike
|
||||
//! the TOML-backed views, this has no comments to preserve, so it's modeled
|
||||
//! as a plain typed struct (round-tripped whole) rather than the
|
||||
//! `toml_edit`/`Doc` path-based editor the other panels use.
|
||||
//!
|
||||
//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s
|
||||
//! `DEFAULTS` table on the Hyprland side — both independently define "what
|
||||
//! BOS ships out of the box," the same duplication `binds.json`/
|
||||
//! `content/keybinds.rs` already accept for the same reason (two different
|
||||
//! languages/processes reading the same file, neither able to import the
|
||||
//! other's defaults).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Adjustment, Box as GBox, ColorDialog, ColorDialogButton, DropDown, Entry, Expression, Orientation,
|
||||
SpinButton, StringList, Switch,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Appearance {
|
||||
gaps_in: i64,
|
||||
gaps_out: i64,
|
||||
border_size: i64,
|
||||
active_border: String,
|
||||
inactive_border: String,
|
||||
layout: String,
|
||||
resize_on_border: bool,
|
||||
rounding: i64,
|
||||
blur_enabled: bool,
|
||||
blur_size: i64,
|
||||
blur_passes: i64,
|
||||
shadow_enabled: bool,
|
||||
shadow_range: i64,
|
||||
shadow_render_power: i64,
|
||||
kb_layout: String,
|
||||
follow_mouse: i64,
|
||||
natural_scroll: bool,
|
||||
}
|
||||
|
||||
impl Default for Appearance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gaps_in: 5,
|
||||
gaps_out: 10,
|
||||
border_size: 2,
|
||||
active_border: "rgba(88c0d0ff)".to_string(),
|
||||
inactive_border: "rgba(4c566aff)".to_string(),
|
||||
layout: "dwindle".to_string(),
|
||||
resize_on_border: true,
|
||||
rounding: 8,
|
||||
blur_enabled: true,
|
||||
blur_size: 6,
|
||||
blur_passes: 2,
|
||||
shadow_enabled: true,
|
||||
shadow_range: 12,
|
||||
shadow_render_power: 3,
|
||||
kb_layout: "us".to_string(),
|
||||
follow_mouse: 1,
|
||||
natural_scroll: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/settings.json")
|
||||
}
|
||||
|
||||
/// A missing or malformed file yields defaults — same failsafe posture as
|
||||
/// the Lua loader reading this same file on the Hyprland side.
|
||||
fn load() -> Appearance {
|
||||
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(a: &Appearance) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(a).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// "rgba(RRGGBBAA)" (Hyprland's format) <-> gdk::RGBA, so the color fields
|
||||
/// get a real color-picker button instead of a raw hex text field.
|
||||
fn parse_hypr_color(s: &str) -> Option<gtk4::gdk::RGBA> {
|
||||
let inner = s.strip_prefix("rgba(")?.strip_suffix(')')?;
|
||||
if inner.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&inner[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&inner[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&inner[4..6], 16).ok()?;
|
||||
let a = u8::from_str_radix(&inner[6..8], 16).ok()?;
|
||||
Some(gtk4::gdk::RGBA::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0))
|
||||
}
|
||||
|
||||
fn to_hypr_color(c: >k4::gdk::RGBA) -> String {
|
||||
let clamp = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("rgba({:02x}{:02x}{:02x}{:02x})", clamp(c.red()), clamp(c.green()), clamp(c.blue()), clamp(c.alpha()))
|
||||
}
|
||||
|
||||
fn color_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox {
|
||||
let cur = parse_hypr_color(get(&model.borrow())).unwrap_or(gtk4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0));
|
||||
let btn = ColorDialogButton::new(Some(ColorDialog::builder().with_alpha(true).build()));
|
||||
btn.set_rgba(&cur);
|
||||
let model = model.clone();
|
||||
btn.connect_rgba_notify(move |b| {
|
||||
set(&mut model.borrow_mut(), to_hypr_color(&b.rgba()));
|
||||
});
|
||||
w::row(label, &btn)
|
||||
}
|
||||
|
||||
fn spin_row(label: &str, model: &Rc<RefCell<Appearance>>, min: f64, max: f64, get: fn(&Appearance) -> i64, set: fn(&mut Appearance, i64)) -> GBox {
|
||||
let cur = get(&model.borrow());
|
||||
let adj = Adjustment::new(cur as f64, min, max, 1.0, 1.0, 0.0);
|
||||
let spin = SpinButton::new(Some(&adj), 1.0, 0);
|
||||
let model = model.clone();
|
||||
spin.connect_value_changed(move |s| set(&mut model.borrow_mut(), s.value() as i64));
|
||||
w::row(label, &spin)
|
||||
}
|
||||
|
||||
fn switch_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> bool, set: fn(&mut Appearance, bool)) -> GBox {
|
||||
let sw = Switch::new();
|
||||
sw.set_active(get(&model.borrow()));
|
||||
let model = model.clone();
|
||||
sw.connect_active_notify(move |s| set(&mut model.borrow_mut(), s.is_active()));
|
||||
w::row(label, &sw)
|
||||
}
|
||||
|
||||
fn entry_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> String, set: fn(&mut Appearance, String)) -> GBox {
|
||||
let entry = Entry::new();
|
||||
entry.set_text(&get(&model.borrow()));
|
||||
entry.set_hexpand(true);
|
||||
entry.set_width_chars(16);
|
||||
let model = model.clone();
|
||||
entry.connect_changed(move |e| set(&mut model.borrow_mut(), e.text().to_string()));
|
||||
w::row(label, &entry)
|
||||
}
|
||||
|
||||
fn dropdown_row(label: &str, model: &Rc<RefCell<Appearance>>, options: &[&str], get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox {
|
||||
let cur = get(&model.borrow()).to_string();
|
||||
let dd = DropDown::new(Some(StringList::new(options)), Expression::NONE);
|
||||
dd.set_selected(options.iter().position(|o| *o == cur).unwrap_or(0) as u32);
|
||||
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
|
||||
let model = model.clone();
|
||||
dd.connect_selected_notify(move |dd| {
|
||||
if let Some(opt) = owned.get(dd.selected() as usize) {
|
||||
set(&mut model.borrow_mut(), opt.clone());
|
||||
}
|
||||
});
|
||||
w::row(label, &dd)
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Appearance");
|
||||
|
||||
content.append(&w::hint(
|
||||
"Gaps, borders, blur, and input feel for Hyprland — the same \
|
||||
settings.json the compositor itself reads at login.",
|
||||
));
|
||||
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
|
||||
content.append(&w::section("Layout & borders"));
|
||||
content.append(&spin_row("Gaps (inner)", &model, 0.0, 50.0, |a| a.gaps_in, |a, v| a.gaps_in = v));
|
||||
content.append(&spin_row("Gaps (outer)", &model, 0.0, 50.0, |a| a.gaps_out, |a, v| a.gaps_out = v));
|
||||
content.append(&spin_row("Border width", &model, 0.0, 10.0, |a| a.border_size, |a, v| a.border_size = v));
|
||||
content.append(&color_row("Active border color", &model, |a| &a.active_border, |a, v| a.active_border = v));
|
||||
content.append(&color_row("Inactive border color", &model, |a| &a.inactive_border, |a, v| a.inactive_border = v));
|
||||
content.append(&dropdown_row("Tiling layout", &model, &["dwindle", "master"], |a| &a.layout, |a, v| a.layout = v));
|
||||
content.append(&switch_row("Resize by dragging borders", &model, |a| a.resize_on_border, |a, v| a.resize_on_border = v));
|
||||
|
||||
content.append(&w::section("Effects"));
|
||||
content.append(&spin_row("Corner rounding", &model, 0.0, 30.0, |a| a.rounding, |a, v| a.rounding = v));
|
||||
content.append(&switch_row("Blur", &model, |a| a.blur_enabled, |a, v| a.blur_enabled = v));
|
||||
content.append(&spin_row("Blur size", &model, 0.0, 20.0, |a| a.blur_size, |a, v| a.blur_size = v));
|
||||
content.append(&spin_row("Blur passes", &model, 1.0, 5.0, |a| a.blur_passes, |a, v| a.blur_passes = v));
|
||||
content.append(&switch_row("Window shadows", &model, |a| a.shadow_enabled, |a, v| a.shadow_enabled = v));
|
||||
content.append(&spin_row("Shadow range", &model, 0.0, 40.0, |a| a.shadow_range, |a, v| a.shadow_range = v));
|
||||
content.append(&spin_row("Shadow render power", &model, 1.0, 4.0, |a| a.shadow_render_power, |a, v| a.shadow_render_power = v));
|
||||
|
||||
content.append(&w::section("Input"));
|
||||
content.append(&entry_row("Keyboard layout", &model, |a| a.kb_layout.clone(), |a, v| a.kb_layout = v));
|
||||
let follow_mouse_row = spin_row("Focus-follows-mouse mode", &model, 0.0, 3.0, |a| a.follow_mouse, |a, v| a.follow_mouse = v);
|
||||
content.append(&follow_mouse_row);
|
||||
content.append(&w::hint("0-3 — see the Hyprland wiki's follow_mouse setting for exact behavior of each value."));
|
||||
content.append(&switch_row("Natural scrolling (touchpad)", &model, |a| a.natural_scroll, |a, v| a.natural_scroll = v));
|
||||
|
||||
content.append(&w::hint("Applies on next login/Hyprland reload — this saves settings.json, it doesn't reload Hyprland live."));
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(16);
|
||||
let save_btn = gtk4::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status = gtk4::Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| match save(&model.borrow()) {
|
||||
Ok(()) => {
|
||||
status.set_text("Saved");
|
||||
let lbl = status.clone();
|
||||
glib::timeout_add_seconds_local(3, move || {
|
||||
lbl.set_text("");
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
Err(e) => status.set_text(&format!("Error: {e}")),
|
||||
});
|
||||
}
|
||||
btn_row.append(&save_btn);
|
||||
btn_row.append(&status);
|
||||
outer.append(&btn_row);
|
||||
|
||||
outer
|
||||
}
|
||||
201
src/ui/views/autostart.rs
Normal file
201
src/ui/views/autostart.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
//! hypr/autostart.json — the *extra*, user-toggleable autostart apps
|
||||
//! (breadbar, hypridle, bos-netcheck, breadhelp, plus anything a user adds).
|
||||
//! The core bootstrap sequence (theme generation, dark-mode gsettings,
|
||||
//! polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd)
|
||||
//! stays hardcoded in hyprland.lua on purpose — it's timing/order-sensitive
|
||||
//! infrastructure, not something this panel exposes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Entry_ {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AutostartFile {
|
||||
#[serde(default)]
|
||||
extra: Vec<Entry_>,
|
||||
}
|
||||
|
||||
fn default_extra() -> Vec<Entry_> {
|
||||
vec![
|
||||
Entry_ { command: "breadbar".to_string(), label: "Bar (breadbar)".to_string(), enabled: true },
|
||||
Entry_ { command: "hypridle".to_string(), label: "Idle / lock daemon (hypridle)".to_string(), enabled: true },
|
||||
Entry_ { command: "bos-netcheck".to_string(), label: "Network connectivity check".to_string(), enabled: true },
|
||||
Entry_ { command: "breadhelp --autostart".to_string(), label: "BOS Help (first-run onboarding)".to_string(), enabled: true },
|
||||
]
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/autostart.json")
|
||||
}
|
||||
|
||||
fn load() -> Vec<Entry_> {
|
||||
std::fs::read_to_string(config_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<AutostartFile>(&s).ok())
|
||||
.map(|f| f.extra)
|
||||
.unwrap_or_else(default_extra)
|
||||
}
|
||||
|
||||
fn save(entries: &[Entry_]) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = AutostartFile { extra: entries.to_vec() };
|
||||
std::fs::write(path, serde_json::to_string_pretty(&file).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<Entry_>>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
for (i, entry) in model.borrow().iter().enumerate() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
||||
hbox.set_margin_top(6);
|
||||
hbox.set_margin_bottom(6);
|
||||
hbox.set_margin_start(8);
|
||||
hbox.set_margin_end(8);
|
||||
|
||||
let enabled = Switch::new();
|
||||
enabled.set_active(entry.enabled);
|
||||
enabled.set_valign(gtk4::Align::Center);
|
||||
|
||||
let label = Entry::new();
|
||||
label.set_text(&entry.label);
|
||||
label.set_placeholder_text(Some("Label"));
|
||||
label.set_width_chars(20);
|
||||
|
||||
let command = Entry::new();
|
||||
command.set_text(&entry.command);
|
||||
command.set_placeholder_text(Some("command"));
|
||||
command.set_hexpand(true);
|
||||
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let model = model.clone();
|
||||
enabled.connect_active_notify(move |s| {
|
||||
if let Some(e) = model.borrow_mut().get_mut(i) {
|
||||
e.enabled = s.is_active();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
label.connect_changed(move |e| {
|
||||
if let Some(entry) = model.borrow_mut().get_mut(i) {
|
||||
entry.label = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
command.connect_changed(move |e| {
|
||||
if let Some(entry) = model.borrow_mut().get_mut(i) {
|
||||
entry.command = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
rebuild(&list, &model);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&enabled);
|
||||
hbox.append(&label);
|
||||
hbox.append(&command);
|
||||
hbox.append(&remove);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Startup Apps");
|
||||
|
||||
content.append(&w::hint(
|
||||
"What launches after login, beyond the core desktop (bar, theme, \
|
||||
clipboard, etc. always start regardless). Toggle off, edit, or add \
|
||||
your own — each row is one command run at login.",
|
||||
));
|
||||
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
|
||||
content.append(&w::section("Extra autostart apps"));
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
list.add_css_class("boxed-list");
|
||||
rebuild(&list, &model);
|
||||
content.append(&list);
|
||||
|
||||
let add_btn = Button::with_label("Add app");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(6);
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
model.borrow_mut().push(Entry_ { command: String::new(), label: String::new(), enabled: true });
|
||||
rebuild(&list, &model);
|
||||
});
|
||||
}
|
||||
content.append(&add_btn);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(16);
|
||||
let save_btn = Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
// Empty command rows (still-being-typed "Add app" entries) are
|
||||
// dropped on save rather than written as a broken autostart.json
|
||||
// entry the Lua loader would otherwise have to reject.
|
||||
let entries: Vec<Entry_> = model.borrow().iter().filter(|e| !e.command.trim().is_empty()).cloned().collect();
|
||||
match save(&entries) {
|
||||
Ok(()) => {
|
||||
status.set_text("Saved");
|
||||
let lbl = status.clone();
|
||||
glib::timeout_add_seconds_local(3, move || {
|
||||
lbl.set_text("");
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
Err(e) => status.set_text(&format!("Error: {e}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
btn_row.append(&save_btn);
|
||||
btn_row.append(&status);
|
||||
outer.append(&btn_row);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -1,11 +1,78 @@
|
|||
//! Display: live-connected-monitor readout (from `hyprctl monitors -j`) plus
|
||||
//! a real editor for `hypr/monitors.json` — the monitor *layout* Hyprland
|
||||
//! itself reads at login via `scripts/display/monitors.lua`. Like
|
||||
//! appearance.rs/autostart.rs, this is a plain typed struct round-tripped
|
||||
//! whole (JSON has no comments to preserve), not the TOML `Doc` pattern.
|
||||
//!
|
||||
//! `Default` here (the single wildcard rule) must stay in sync with
|
||||
//! `scripts/display/monitors.lua`'s own `DEFAULT_MONITORS` fallback.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button};
|
||||
use std::process::Command;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
fn get_monitors() -> Vec<(String, String)> {
|
||||
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct MonitorRule {
|
||||
output: String,
|
||||
#[serde(default = "default_mode")]
|
||||
mode: String,
|
||||
#[serde(default = "default_position")]
|
||||
position: String,
|
||||
#[serde(default = "default_scale")]
|
||||
scale: String,
|
||||
}
|
||||
|
||||
fn default_mode() -> String {
|
||||
"preferred".to_string()
|
||||
}
|
||||
fn default_position() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
fn default_scale() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
impl Default for MonitorRule {
|
||||
fn default() -> Self {
|
||||
Self { output: String::new(), mode: default_mode(), position: default_position(), scale: default_scale() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct MonitorsFile {
|
||||
#[serde(default)]
|
||||
monitors: Vec<MonitorRule>,
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/monitors.json")
|
||||
}
|
||||
|
||||
fn load() -> Vec<MonitorRule> {
|
||||
std::fs::read_to_string(config_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<MonitorsFile>(&s).ok())
|
||||
.filter(|f| !f.monitors.is_empty())
|
||||
.map(|f| f.monitors)
|
||||
.unwrap_or_else(|| vec![MonitorRule::default()])
|
||||
}
|
||||
|
||||
fn save(rules: &[MonitorRule]) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = MonitorsFile { monitors: rules.to_vec() };
|
||||
std::fs::write(path, serde_json::to_string_pretty(&file).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn get_live_monitors() -> Vec<(String, String)> {
|
||||
let Ok(output) = std::process::Command::new("hyprctl").args(["monitors", "-j"]).output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
|
|
@ -34,16 +101,111 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
|
|||
/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
|
||||
fn open_in_terminal(path: &std::path::Path) {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
|
||||
if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
|
||||
if let Ok(mut child) = std::process::Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
|
||||
std::thread::spawn(move || { let _ = child.wait(); });
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<MonitorRule>>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
for (i, rule) in model.borrow().iter().enumerate() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
||||
hbox.set_margin_top(6);
|
||||
hbox.set_margin_bottom(6);
|
||||
hbox.set_margin_start(8);
|
||||
hbox.set_margin_end(8);
|
||||
|
||||
let output = Entry::new();
|
||||
output.set_text(&rule.output);
|
||||
output.set_placeholder_text(Some("any (blank = all)"));
|
||||
output.set_width_chars(12);
|
||||
|
||||
let mode = Entry::new();
|
||||
mode.set_text(&rule.mode);
|
||||
mode.set_placeholder_text(Some("preferred / 1920x1080@60"));
|
||||
mode.set_width_chars(18);
|
||||
|
||||
let position = Entry::new();
|
||||
position.set_text(&rule.position);
|
||||
position.set_placeholder_text(Some("auto / 0x0"));
|
||||
position.set_width_chars(10);
|
||||
|
||||
let scale = Entry::new();
|
||||
scale.set_text(&rule.scale);
|
||||
scale.set_placeholder_text(Some("auto / 1"));
|
||||
scale.set_width_chars(8);
|
||||
scale.set_hexpand(true);
|
||||
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let model = model.clone();
|
||||
output.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.output = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
mode.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.mode = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
position.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.position = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
scale.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.scale = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
if model.borrow().is_empty() {
|
||||
model.borrow_mut().push(MonitorRule::default());
|
||||
}
|
||||
rebuild(&list, &model);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&Label::new(Some("Output")));
|
||||
hbox.append(&output);
|
||||
hbox.append(&Label::new(Some("Mode")));
|
||||
hbox.append(&mode);
|
||||
hbox.append(&Label::new(Some("Position")));
|
||||
hbox.append(&position);
|
||||
hbox.append(&Label::new(Some("Scale")));
|
||||
hbox.append(&scale);
|
||||
hbox.append(&remove);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Display");
|
||||
|
||||
content.append(&w::section("Connected monitors"));
|
||||
let monitors = get_monitors();
|
||||
let monitors = get_live_monitors();
|
||||
if monitors.is_empty() {
|
||||
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
|
||||
} else {
|
||||
|
|
@ -52,16 +214,58 @@ pub fn build() -> GBox {
|
|||
}
|
||||
}
|
||||
|
||||
content.append(&w::section("Configuration"));
|
||||
content.append(&w::section("Layout"));
|
||||
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.",
|
||||
"One row per monitor rule. Leave Output blank to match any monitor \
|
||||
(the default — works on any hardware). Applies on next login/reload.",
|
||||
));
|
||||
|
||||
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
|
||||
// hyprland.conf/keybinds.conf pair — those names only ever matched a
|
||||
// stale, unshipped dotfiles/ directory, so this button opened (or
|
||||
// silently created) the wrong file entirely.
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
list.add_css_class("boxed-list");
|
||||
rebuild(&list, &model);
|
||||
content.append(&list);
|
||||
|
||||
let add_btn = Button::with_label("Add monitor rule");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(6);
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
model.borrow_mut().push(MonitorRule::default());
|
||||
rebuild(&list, &model);
|
||||
});
|
||||
}
|
||||
content.append(&add_btn);
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(16);
|
||||
let save_btn = Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| match save(&model.borrow()) {
|
||||
Ok(()) => {
|
||||
status.set_text("Saved");
|
||||
let lbl = status.clone();
|
||||
glib::timeout_add_seconds_local(3, move || {
|
||||
lbl.set_text("");
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
Err(e) => status.set_text(&format!("Error: {e}")),
|
||||
});
|
||||
}
|
||||
btn_row.append(&save_btn);
|
||||
btn_row.append(&status);
|
||||
outer.append(&btn_row);
|
||||
|
||||
content.append(&w::section("Advanced"));
|
||||
let open_btn = Button::with_label("Open hyprland.lua in editor");
|
||||
open_btn.set_halign(gtk4::Align::Start);
|
||||
{
|
||||
|
|
@ -70,15 +274,14 @@ pub fn build() -> GBox {
|
|||
}
|
||||
content.append(&open_btn);
|
||||
|
||||
// Keybinds are defined inline in hyprland.lua (no separate file); point
|
||||
// this at the shipped cheat sheet instead of a keybinds.conf that has
|
||||
// never existed on BOS.
|
||||
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
|
||||
// breadhelp is the real keybind viewer now (SUPER+/) — /usr/share/bos/
|
||||
// keybinds.txt was deleted when breadhelp replaced bos-keybinds/
|
||||
// bos-welcome, so this used to open a file that no longer exists.
|
||||
let keybinds_btn = Button::with_label("View keybinds (breadhelp)");
|
||||
keybinds_btn.set_halign(gtk4::Align::Start);
|
||||
{
|
||||
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 |_| {
|
||||
let _ = std::process::Command::new("breadhelp").spawn();
|
||||
});
|
||||
content.append(&keybinds_btn);
|
||||
|
||||
outer
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
pub mod about;
|
||||
pub mod appearance;
|
||||
pub mod aur;
|
||||
pub mod autostart;
|
||||
pub mod bread;
|
||||
pub mod breadbar;
|
||||
pub mod breadbox;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue