can't be bothered writing a commit message
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s

This commit is contained in:
Breadway 2026-07-16 18:05:49 +08:00
parent 9c85a89297
commit dc765bb526
10 changed files with 654 additions and 72 deletions

View file

@ -1,6 +1,6 @@
# bos-settings # bos-settings
System settings app for [BOS (Bread Operating System)](https://github.com/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively. System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively.
Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Still the only pacman-packaged (not bakery-managed) bread app — see `packaging/README.md`. Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Still the only pacman-packaged (not bakery-managed) bread app — see `packaging/README.md`.

View file

@ -1,11 +1,11 @@
# Maintainer: Breadway <rileyhorsham@gmail.com> # Maintainer: Breadway <plasticbread849@gmail.com>
pkgname=bos-settings pkgname=bos-settings
pkgver=0.1.0 pkgver=0.1.0
pkgrel=1 pkgrel=1
pkgdesc="System settings app for Bread OS" pkgdesc="System settings app for Bread OS"
arch=('x86_64') arch=('x86_64')
url="https://github.com/Breadway/bos-settings" url="https://git.breadway.dev/Breadway/bos-settings"
license=('MIT') license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,

View file

@ -39,6 +39,7 @@ pub const SYSTEM_ITEMS: &[SidebarItem] = &[
item("power", "Power", "battery-good-symbolic"), item("power", "Power", "battery-good-symbolic"),
item("datetime", "Date & Time", "preferences-system-time-symbolic"), item("datetime", "Date & Time", "preferences-system-time-symbolic"),
item_sub("hyprland", "Display", "monitors.json", "video-display-symbolic"), item_sub("hyprland", "Display", "monitors.json", "video-display-symbolic"),
item_sub("keybinds", "Keybinds", "binds.json", "input-keyboard-symbolic"),
item_sub("autostart", "Startup Apps", "autostart.json", "system-run-symbolic"), item_sub("autostart", "Startup Apps", "autostart.json", "system-run-symbolic"),
item("users", "Users", "system-users-symbolic"), item("users", "Users", "system-users-symbolic"),
]; ];
@ -94,7 +95,19 @@ pub fn build(default_id: &str) -> (GBox, ListBox) {
} }
} }
vbox.append(&list); // Unscrolled, this list's ~30 rows (4 sections' worth of items plus
// headers) force a minimum window height that exceeds the *logical*
// screen height at Hyprland scale factors above 1.0 (e.g. a 1200px-tall
// panel becomes 800 logical px at scale 1.5) — GTK can't shrink below a
// widget's natural size, so the window (and anything below the fold,
// like a panel's Save button) gets clipped by the compositor with no way
// to reach it. Scrolling the nav list independently of the content pane
// lets the window's minimum height drop to whatever a single row needs.
let scroll = gtk4::ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
scroll.set_child(Some(&list));
vbox.append(&scroll);
(vbox, list) (vbox, list)
} }

View file

@ -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, Switch, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, PasswordEntry, Switch,
}; };
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
@ -78,11 +78,10 @@ fn rebuild_networks(list: &ListBox, model: &Rc<RefCell<Vec<Network>>>) {
ssid.set_width_chars(16); ssid.set_width_chars(16);
ssid.set_placeholder_text(Some("SSID")); ssid.set_placeholder_text(Some("SSID"));
let pass = Entry::new(); let pass = PasswordEntry::new();
pass.set_text(&n.password); pass.set_text(&n.password);
pass.set_hexpand(true); pass.set_hexpand(true);
pass.set_visibility(false); pass.set_show_peek_icon(true);
pass.set_input_purpose(gtk4::InputPurpose::Password);
pass.set_placeholder_text(Some("password")); pass.set_placeholder_text(Some("password"));
let hidden = Switch::new(); let hidden = Switch::new();

View file

@ -5,17 +5,105 @@
//! discoverable home in BOS Settings instead of only being reachable from a //! discoverable home in BOS Settings instead of only being reachable from a
//! terminal. //! terminal.
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{ use gtk4::{
Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation, Box as GBox, Button, FileDialog, FileFilter, FlowBox, Image, Label, Orientation, Picture,
ResponseType,
}; };
use crate::ui::widgets as w; use crate::ui::widgets as w;
/// Extensions breadpaper's own `validate()` accepts — narrower than what
/// GdkPixbuf can decode (svg/tiff/etc.), so both the file picker's filter and
/// the library scan below stay in sync with what `breadpaper set` will
/// actually take instead of offering images it'll reject.
const WALLPAPER_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"];
/// Caps how many thumbnails the library grid ever builds. Each one decodes a
/// scaled `Pixbuf` synchronously on the main thread (see `thumbnail`) — fine
/// for a bounded, explicitly-triggered scan (same "costs seconds, gated
/// behind a button" posture as network.rs's Wi-Fi scan), not fine as an
/// unbounded walk of someone's whole Pictures folder.
const MAX_LIBRARY_ITEMS: usize = 80;
const MAX_SCAN_DEPTH: usize = 4;
fn wallpaper_library_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join("Pictures/Backgrounds")
}
fn is_wallpaper_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| WALLPAPER_EXTS.iter().any(|ext| ext.eq_ignore_ascii_case(e)))
.unwrap_or(false)
}
/// Recursively collects image paths under `dir` (sorted, depth- and
/// count-bounded) — the library is organized in subfolders (e.g. by show/
/// series), not a flat directory, so a non-recursive scan would find nothing.
fn scan_wallpapers(dir: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 || out.len() >= MAX_LIBRARY_ITEMS {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if out.len() >= MAX_LIBRARY_ITEMS {
return;
}
let path = entry.path();
if path.is_dir() {
walk(&path, depth - 1, out);
} else if is_wallpaper_file(&path) {
out.push(path);
}
}
}
let mut out = Vec::new();
walk(dir, MAX_SCAN_DEPTH, &mut out);
out
}
/// A clickable thumbnail: scaled-decode preview + filename, wrapped in a
/// plain `Button` so the whole card is the click target. Returns `None` for
/// files GdkPixbuf can't decode (corrupt/unsupported) rather than showing a
/// broken-image placeholder for every miss.
fn thumbnail(path: &Path, on_pick: impl Fn(PathBuf) + 'static) -> Option<Button> {
let pixbuf = gtk4::gdk_pixbuf::Pixbuf::from_file_at_scale(path, 160, 100, true).ok()?;
let texture = gtk4::gdk::Texture::for_pixbuf(&pixbuf);
let card = GBox::new(Orientation::Vertical, 4);
card.set_margin_top(4);
card.set_margin_bottom(4);
card.set_margin_start(4);
card.set_margin_end(4);
let picture = Picture::for_paintable(&texture);
picture.set_size_request(160, 100);
picture.set_content_fit(gtk4::ContentFit::Cover);
card.append(&picture);
let name = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
let name_lbl = Label::new(Some(&name));
name_lbl.add_css_class("caption");
name_lbl.add_css_class("dim-label");
name_lbl.set_max_width_chars(20);
name_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::Middle);
card.append(&name_lbl);
let btn = Button::new();
btn.set_child(Some(&card));
btn.set_tooltip_text(Some(&path.display().to_string()));
let path = path.to_path_buf();
btn.connect_clicked(move |_| on_pick(path.clone()));
Some(btn)
}
fn current_wallpaper() -> Option<PathBuf> { fn current_wallpaper() -> Option<PathBuf> {
let out = Command::new("breadpaper").arg("get").output().ok()?; let out = Command::new("breadpaper").arg("get").output().ok()?;
if !out.status.success() { if !out.status.success() {
@ -45,6 +133,35 @@ fn refresh_preview(preview: &Image, path_lbl: &Label) {
} }
} }
/// Runs `breadpaper set <path>`, which also drives `awww img` + pywal
/// palette generation (routinely 1-3s — pywal spawns Python + an
/// ImageMagick backend, not the "sub-second" call this used to assume). GTK
/// widgets aren't `Send`, so the command runs on its own thread and the
/// result comes back over a channel (same pattern as snapshots.rs).
fn set_wallpaper(path: PathBuf, preview: Image, path_lbl: Label, status: Label) {
status.set_text("Setting...");
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = Command::new("breadpaper").arg("set").arg(&path).status().map(|s| s.success()).unwrap_or(false);
let _ = tx.send_blocking(ok);
});
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
refresh_preview(&preview, &path_lbl);
status.set_text("Wallpaper set");
} else {
status.set_text("breadpaper failed — see terminal/journal");
}
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
});
}
pub fn build() -> GBox { pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Wallpaper"); let (outer, c) = w::view_scaffold("Wallpaper");
@ -87,71 +204,33 @@ pub fn build() -> GBox {
let status = status.clone(); let status = status.clone();
choose_btn.connect_clicked(move |btn| { choose_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok()); let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = FileChooserDialog::new( let dialog = FileDialog::new();
Some("Choose a wallpaper"), dialog.set_title("Choose a wallpaper");
window.as_ref(),
FileChooserAction::Open,
&[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)],
);
// Restricted to what breadpaper's own validate() actually // Restricted to what breadpaper's own validate() actually
// accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats() // accepts — GdkPixbuf (and thus the file dialog's own preview)
// also offers svg/tiff/etc. that breadpaper rejects outright. // would happily offer svg/tiff/etc. that breadpaper rejects
let filter = gtk4::FileFilter::new(); // outright.
for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] { let filter = FileFilter::new();
for ext in WALLPAPER_EXTS {
filter.add_suffix(ext); filter.add_suffix(ext);
} }
filter.set_name(Some("Images")); filter.set_name(Some("Images"));
dialog.add_filter(&filter); let filters = gtk4::gio::ListStore::new::<FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
dialog.set_default_filter(Some(&filter));
let preview = preview.clone(); let preview = preview.clone();
let path_lbl = path_lbl.clone(); let path_lbl = path_lbl.clone();
let status = status.clone(); let status = status.clone();
dialog.connect_response(move |dialog, response| { dialog.open(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if response == ResponseType::Accept { if let Ok(file) = result {
if let Some(file) = dialog.file() { if let Some(path) = file.path() {
if let Some(path) = file.path() { set_wallpaper(path, preview.clone(), path_lbl.clone(), status.clone());
// breadpaper set runs `awww img` + pywal palette
// generation, which is routinely 1-3s (pywal
// spawns Python + an ImageMagick backend) — not
// the "sub-second" call this used to assume.
// GTK widgets aren't Send, so run it in a thread
// and hand the result back over a channel
// (same pattern as snapshots.rs).
status.set_text("Setting...");
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = Command::new("breadpaper")
.arg("set")
.arg(&path)
.status()
.map(|s| s.success())
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
refresh_preview(&preview, &path_lbl);
status.set_text("Wallpaper set");
} else {
status.set_text("breadpaper failed — see terminal/journal");
}
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
});
}
} }
} }
dialog.close();
}); });
dialog.show();
}); });
} }
@ -159,5 +238,62 @@ pub fn build() -> GBox {
btn_row.append(&status); btn_row.append(&status);
c.append(&btn_row); c.append(&btn_row);
// Library: a thumbnail grid so picking a wallpaper doesn't mean guessing
// blind from filenames in a file-picker list. Scanned lazily behind a
// button click, not at panel-build time — every view is built eagerly at
// app launch (see window.rs), and decoding dozens of scaled thumbnails
// synchronously would add real latency to every bos-settings launch, not
// just the first visit to this panel.
c.append(&w::section("Library"));
let library_dir = wallpaper_library_dir();
let flow = FlowBox::new();
flow.set_selection_mode(gtk4::SelectionMode::None);
flow.set_max_children_per_line(6);
flow.set_row_spacing(4);
flow.set_column_spacing(4);
flow.set_homogeneous(true);
let flow_wrapper = GBox::new(Orientation::Vertical, 4);
let browse_btn = Button::with_label(&format!("Browse {}", library_dir.display()));
browse_btn.set_halign(gtk4::Align::Start);
flow_wrapper.append(&browse_btn);
c.append(&flow_wrapper);
{
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
let flow = flow.clone();
let flow_wrapper = flow_wrapper.clone();
let library_dir = library_dir.clone();
browse_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
while let Some(child) = flow.first_child() {
flow.remove(&child);
}
let paths = scan_wallpapers(&library_dir);
if paths.is_empty() {
flow_wrapper.append(&w::empty_state(
"image-missing",
"No wallpapers found",
&format!("Nothing under {} — use Choose image... above instead.", library_dir.display()),
));
} else {
for path in &paths {
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
if let Some(thumb) = thumbnail(path, move |p| {
set_wallpaper(p, preview.clone(), path_lbl.clone(), status.clone());
}) {
flow.insert(&thumb, -1);
}
}
flow_wrapper.append(&flow);
}
btn.set_visible(false);
});
}
outer outer
} }

423
src/ui/views/keybinds.rs Normal file
View file

@ -0,0 +1,423 @@
//! hypr/binds.json — Hyprland keybind editor, read by
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua). The
//! schema has four kinds of bind lists (`globals`, `common`, and one per
//! keyboard `layouts` entry) and each bind's shape varies by `action`
//! (`exec` needs `command`, `move_dir` needs `direction`, workspace-focus
//! needs `workspace`, mouse binds need `options.mouse`, ...). Rather than
//! modelling every action's field set as its own row layout — which would
//! mean a combinatorial explosion of widgets and silently dropping any
//! action shape this editor doesn't already know about — `action`/`key`/
//! `mods` get real fields (the ones every bind has) and everything else
//! round-trips through `#[serde(flatten)]` into a small inline-JSON column,
//! same trade-off the other Hyprland JSON editors (appearance.rs,
//! hyprland.rs, autostart.rs) already make: no comments to preserve, so this
//! is a whole-file round trip, not the `toml_edit`/`Doc` path-based pattern.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, ListBox, ListBoxRow,
Orientation, StringList,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::ui::widgets as w;
#[derive(Clone, Serialize, Deserialize, Default)]
#[serde(default)]
struct Bind {
action: String,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
mods: Vec<String>,
/// Everything else a bind can carry — `command`, `direction`,
/// `workspace`, `x`, `y`, `layout`, `options`, and any action shape not
/// yet invented. Edited as compact inline JSON (see `extra_field`).
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Serialize, Deserialize, Default)]
#[serde(default)]
struct BindsFile {
active_layout: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
default_mods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
globals: Vec<Bind>,
#[serde(skip_serializing_if = "Vec::is_empty")]
common: Vec<Bind>,
/// A `BTreeMap` (alphabetical), not the file's original insertion order
/// — same "whole-file round trip, formatting not preserved" trade-off as
/// the rest of this file's JSON-config siblings.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
layouts: BTreeMap<String, Vec<Bind>>,
}
fn config_path() -> std::path::PathBuf {
crate::config::config_dir().join("hypr/binds.json")
}
fn load() -> BindsFile {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn save(f: &BindsFile) -> 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(f).unwrap_or_default())
}
fn mods_to_text(mods: &[String]) -> String {
mods.join(", ")
}
fn text_to_mods(s: &str) -> Vec<String> {
s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()
}
/// A `Vec<Bind>` accessor that always finds the right list regardless of
/// whether it's `globals`, `common`, or a named entry in `layouts` — lets one
/// row-builder work for every section instead of three near-duplicates.
type SectionAccessor = Rc<dyn Fn(&mut BindsFile) -> &mut Vec<Bind>>;
fn globals_accessor() -> SectionAccessor {
Rc::new(|f| &mut f.globals)
}
fn common_accessor() -> SectionAccessor {
Rc::new(|f| &mut f.common)
}
fn layout_accessor(name: String) -> SectionAccessor {
Rc::new(move |f| f.layouts.entry(name.clone()).or_default())
}
fn bind_row(
model: &Rc<RefCell<BindsFile>>,
accessor: &SectionAccessor,
idx: usize,
rerender: &Rc<dyn Fn()>,
) -> ListBoxRow {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 6);
hbox.set_margin_top(4);
hbox.set_margin_bottom(4);
hbox.set_margin_start(6);
hbox.set_margin_end(6);
let (mods_cur, key_cur, action_cur, extra_cur) = {
let mut m = model.borrow_mut();
let bind = &accessor(&mut m)[idx];
(
mods_to_text(&bind.mods),
bind.key.clone().unwrap_or_default(),
bind.action.clone(),
if bind.extra.is_empty() { String::new() } else { serde_json::to_string(&bind.extra).unwrap_or_default() },
)
};
let mods = Entry::new();
mods.set_text(&mods_cur);
mods.set_placeholder_text(Some("SUPER, SHIFT"));
mods.set_width_chars(14);
let key = Entry::new();
key.set_text(&key_cur);
key.set_placeholder_text(Some("key"));
key.set_width_chars(8);
let action = Entry::new();
action.set_text(&action_cur);
action.set_placeholder_text(Some("exec / focus / move_dir / ..."));
action.set_width_chars(12);
let extra = Entry::new();
extra.set_text(&extra_cur);
extra.set_placeholder_text(Some(r#"{"command": "..."}"#));
extra.set_hexpand(true);
let remove = Button::with_label("Remove");
remove.add_css_class("destructive-action");
{
let model = model.clone();
let accessor = accessor.clone();
mods.connect_changed(move |e| {
let mut m = model.borrow_mut();
if let Some(b) = accessor(&mut m).get_mut(idx) {
b.mods = text_to_mods(&e.text());
}
});
}
{
let model = model.clone();
let accessor = accessor.clone();
key.connect_changed(move |e| {
let mut m = model.borrow_mut();
if let Some(b) = accessor(&mut m).get_mut(idx) {
let t = e.text().to_string();
b.key = if t.is_empty() { None } else { Some(t) };
}
});
}
{
let model = model.clone();
let accessor = accessor.clone();
action.connect_changed(move |e| {
let mut m = model.borrow_mut();
if let Some(b) = accessor(&mut m).get_mut(idx) {
b.action = e.text().to_string();
}
});
}
{
let model = model.clone();
let accessor = accessor.clone();
// Only commit on valid JSON — otherwise every keystroke while
// composing e.g. `{"command":"kitty"}` would momentarily wipe the
// bind's extra fields the instant the text isn't parseable yet.
extra.connect_changed(move |e| {
let text = e.text();
let parsed: Result<Map<String, Value>, _> =
if text.trim().is_empty() { Ok(Map::new()) } else { serde_json::from_str(&text) };
match parsed {
Ok(map) => {
e.remove_css_class("error");
let mut m = model.borrow_mut();
if let Some(b) = accessor(&mut m).get_mut(idx) {
b.extra = map;
}
}
Err(_) => e.add_css_class("error"),
}
});
}
{
let model = model.clone();
let accessor = accessor.clone();
let rerender = rerender.clone();
remove.connect_clicked(move |_| {
accessor(&mut model.borrow_mut()).remove(idx);
rerender();
});
}
hbox.append(&mods);
hbox.append(&key);
hbox.append(&action);
hbox.append(&extra);
hbox.append(&remove);
row.set_child(Some(&hbox));
row
}
/// A section = a title, an "Add bind" button, and the section's bind rows —
/// shared by Globals, Common, and every named layout.
fn section(
title: Option<&str>,
model: &Rc<RefCell<BindsFile>>,
accessor: SectionAccessor,
rerender: &Rc<dyn Fn()>,
) -> GBox {
let wrapper = GBox::new(Orientation::Vertical, 4);
if let Some(title) = title {
wrapper.append(&w::section(title));
}
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
list.add_css_class("boxed-list");
let count = accessor(&mut model.borrow_mut()).len();
for i in 0..count {
list.append(&bind_row(model, &accessor, i, rerender));
}
wrapper.append(&list);
let add_btn = Button::with_label("Add bind");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(4);
{
let model = model.clone();
let accessor = accessor.clone();
let rerender = rerender.clone();
add_btn.connect_clicked(move |_| {
accessor(&mut model.borrow_mut()).push(Bind { action: "exec".to_string(), ..Default::default() });
rerender();
});
}
wrapper.append(&add_btn);
wrapper
}
fn rerender(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
while let Some(child) = content.first_child() {
content.remove(&child);
}
populate(content, model, status);
}
fn populate(content: &GBox, model: &Rc<RefCell<BindsFile>>, status: &Label) {
let rerender: Rc<dyn Fn()> = {
let content = content.clone();
let model = model.clone();
let status = status.clone();
Rc::new(move || rerender(&content, &model, &status))
};
content.append(&w::hint(
"Mods/Key/Action are the fields every bind needs. The last column \
holds action-specific extras as inline JSON e.g. \
{\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
{\"workspace\": \"e+1\"}, {\"options\": {\"repeating\": true}} — \
leave it blank for actions with none (close, exit, fullscreen, ...). \
Applies on next login/reload.",
));
let layout_names: Vec<String> = model.borrow().layouts.keys().cloned().collect();
let top_row = GBox::new(Orientation::Horizontal, 12);
top_row.append(&{
let dd = DropDown::new(
Some(StringList::new(&layout_names.iter().map(String::as_str).collect::<Vec<_>>())),
Expression::NONE,
);
let cur = model.borrow().active_layout.clone();
dd.set_selected(layout_names.iter().position(|n| *n == cur).unwrap_or(0) as u32);
let model = model.clone();
let layout_names = layout_names.clone();
dd.connect_selected_notify(move |dd| {
if let Some(name) = layout_names.get(dd.selected() as usize) {
model.borrow_mut().active_layout = name.clone();
}
});
w::row("Active layout", &dd)
});
content.append(&top_row);
let default_mods = Entry::new();
default_mods.set_text(&mods_to_text(&model.borrow().default_mods));
default_mods.set_hexpand(true);
default_mods.set_width_chars(20);
{
let model = model.clone();
default_mods.connect_changed(move |e| {
model.borrow_mut().default_mods = text_to_mods(&e.text());
});
}
content.append(&w::row("Default mods", &default_mods));
content.append(&section(Some("Media & function keys (globals)"), model, globals_accessor(), &rerender));
content.append(&section(Some("Common (every layout)"), model, common_accessor(), &rerender));
for name in &layout_names {
let header = GBox::new(Orientation::Horizontal, 8);
let title = Label::new(Some(&format!("Layout: {name}")));
title.add_css_class("heading");
title.set_hexpand(true);
title.set_xalign(0.0);
title.set_margin_top(12);
header.append(&title);
let remove_layout = Button::with_label("Remove layout");
remove_layout.add_css_class("destructive-action");
{
let model = model.clone();
let rerender = rerender.clone();
let name = name.clone();
remove_layout.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Remove layout \"{name}\"?"))
.detail("Deletes every bind defined under this layout. This can't be undone here.")
.buttons(["Cancel", "Remove"])
.cancel_button(0)
.default_button(0)
.build();
let model = model.clone();
let rerender = rerender.clone();
let name = name.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
let mut m = model.borrow_mut();
m.layouts.remove(&name);
if m.active_layout == name {
m.active_layout = m.layouts.keys().next().cloned().unwrap_or_default();
}
drop(m);
rerender();
}
});
});
}
header.append(&remove_layout);
content.append(&header);
// No section title here — the "Layout: X" header above (with its
// own Remove button) already covers it.
content.append(&section(None, model, layout_accessor(name.clone()), &rerender));
}
let add_layout_row = GBox::new(Orientation::Horizontal, 8);
add_layout_row.set_margin_top(12);
let new_layout_name = Entry::new();
new_layout_name.set_placeholder_text(Some("new layout name"));
let add_layout_btn = Button::with_label("Add layout");
{
let model = model.clone();
let rerender = rerender.clone();
let entry = new_layout_name.clone();
add_layout_btn.connect_clicked(move |_| {
let name = entry.text().trim().to_string();
if name.is_empty() {
return;
}
model.borrow_mut().layouts.entry(name).or_default();
entry.set_text("");
rerender();
});
}
add_layout_row.append(&new_layout_name);
add_layout_row.append(&add_layout_btn);
content.append(&add_layout_row);
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Keybinds");
let model = Rc::new(RefCell::new(load()));
let status = Label::new(None);
status.add_css_class("dim-label");
populate(&content, &model, &status);
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 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
}

View file

@ -15,6 +15,7 @@ pub mod datetime;
pub mod firewall; pub mod firewall;
pub mod firmware; pub mod firmware;
pub mod hyprland; pub mod hyprland;
pub mod keybinds;
pub mod network; pub mod network;
pub mod packages; pub mod packages;
pub mod power; pub mod power;

View file

@ -10,7 +10,7 @@
//! only runs when the user clicks Scan. //! only runs when the user clicks Scan.
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch}; use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, PasswordEntry, ScrolledWindow, Switch};
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashSet; use std::collections::HashSet;
use std::process::Command; use std::process::Command;
@ -127,8 +127,8 @@ pub fn build() -> GBox {
// network — set visible/hidden rather than a modal dialog, to keep this // 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. // panel's async flow in one place instead of a nested dialog callback.
let pw_row = GBox::new(Orientation::Horizontal, 8); let pw_row = GBox::new(Orientation::Horizontal, 8);
let pw_entry = Entry::new(); let pw_entry = PasswordEntry::new();
pw_entry.set_visibility(false); pw_entry.set_show_peek_icon(true);
pw_entry.set_hexpand(true); pw_entry.set_hexpand(true);
pw_entry.set_placeholder_text(Some("Password")); pw_entry.set_placeholder_text(Some("Password"));
let pw_connect_btn = Button::with_label("Connect"); let pw_connect_btn = Button::with_label("Connect");

View file

@ -92,7 +92,14 @@ pub fn hint(text: &str) -> Label {
/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow /// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow
/// stretches it to the full window width — on a maximized/ultrawide window /// stretches it to the full window width — on a maximized/ultrawide window
/// that leaves the control on every row stranded ~1500px from its label. /// that leaves the control on every row stranded ~1500px from its label.
const CONTENT_MAX_WIDTH: i32 = 760; ///
/// `set_size_request` below sets this as a *minimum*, not just a cap (GTK4
/// has no real max-width clamp without libadwaita's `AdwClamp`) — so this
/// number also becomes a floor under the window's minimum width. 760 pushed
/// the window's minimum past the logical screen width at Hyprland scale 2.0
/// (e.g. 1920px physical -> 960 logical), clipping the app. 560 keeps rows
/// readable while surviving scale 2.0 on common panel widths.
const CONTENT_MAX_WIDTH: i32 = 560;
/// A centered placeholder for a panel's empty state (no snapshots yet, no /// 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 /// scan results yet, etc) — a dim icon + title + hint, instead of a single
@ -186,14 +193,16 @@ pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default:
row(label, &entry) row(label, &entry)
} }
/// `PasswordEntry` (core GTK4 since 4.0, not a libadwaita widget) has a
/// built-in reveal/unhide eye icon via `set_show_peek_icon` — the correct
/// idiomatic replacement for a masked `Entry`, which had no way to unhide.
pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox { pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox {
let cur = config::get_str(&doc.borrow(), path).unwrap_or_default(); let cur = config::get_str(&doc.borrow(), path).unwrap_or_default();
let entry = Entry::new(); let entry = gtk4::PasswordEntry::new();
entry.set_text(&cur); entry.set_text(&cur);
entry.set_visibility(false); entry.set_show_peek_icon(true);
entry.set_hexpand(true); entry.set_hexpand(true);
entry.set_width_chars(28); entry.set_width_chars(28);
entry.set_input_purpose(gtk4::InputPurpose::Password);
let doc = doc.clone(); let doc = doc.clone();
entry.connect_changed(move |e| { entry.connect_changed(move |e| {
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str()); config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());

View file

@ -55,6 +55,7 @@ pub fn build_ui(app: &Application, requested_page: Option<String>) {
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"));
stack.add_named(&views::keybinds::build(), Some("keybinds"));
stack.add_named(&views::appearance::build(), Some("appearance")); stack.add_named(&views::appearance::build(), Some("appearance"));
stack.add_named(&views::autostart::build(), Some("autostart")); stack.add_named(&views::autostart::build(), Some("autostart"));