can't be bothered writing a commit message
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
This commit is contained in:
parent
9c85a89297
commit
dc765bb526
10 changed files with 654 additions and 72 deletions
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
423
src/ui/views/keybinds.rs
Normal 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(§ion(Some("Media & function keys (globals)"), model, globals_accessor(), &rerender));
|
||||||
|
content.append(§ion(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(§ion(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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue