bos-settings moves to git.breadway.dev/Breadway/bos-settings (full history preserved via git-filter-repo) so its release cadence is decoupled from BOS's own. breadhelp takes its place as this repo's workspace member: a GTK4 onboarding/help center replacing the old bos-welcome/bos-keybinds bash scripts with searchable guides, an interactive keybind viewer (sourced from the new keybinds.toml, not parsed out of hyprland.lua or hardcoded), a troubleshooting wizard with one-click fixes, and a proper first-run tour. bos-netcheck extracts bos-welcome's network-check half, which still needs to run every login independent of breadhelp's own first-run gating. hyprland.lua's keybinds/settings/monitors/autostart are now JSON-driven (binds.json/settings.json/monitors.json/autostart.json) with every loader pcall-wrapped and falling back to hardcoded defaults per field on bad or missing config, so bread* apps (bos-settings' new editors, and breadhelp's keybind viewer) can read/write this config without ever being able to leave the compositor unable to start. CI's package.yml now builds breadhelp instead of bos-settings on tag push; bos-settings needs its own equivalent workflow in its new repo (not yet set up).
173 lines
6.3 KiB
Rust
173 lines
6.3 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gtk4::prelude::*;
|
|
use gtk4::{Box as GBox, Entry, Label, ListBox, ListBoxRow, Orientation, Paned, ScrolledWindow, SelectionMode, Widget};
|
|
|
|
use crate::config::Mode;
|
|
use crate::content::{keybinds::Keybind, ContentStore};
|
|
|
|
use super::{guide_view, keybind_viewer};
|
|
|
|
struct CommandEntry {
|
|
name: &'static str,
|
|
description: &'static str,
|
|
}
|
|
|
|
/// A small, curated command gallery — not every `bread*` binary, just the
|
|
/// ones worth surfacing to someone searching for "what can I run".
|
|
const COMMANDS: &[CommandEntry] = &[
|
|
CommandEntry { name: "bos-update", description: "Update both the system (pacman) and the bread ecosystem (bakery) in one go." },
|
|
CommandEntry { name: "bos-settings", description: "Open BOS Settings." },
|
|
CommandEntry { name: "breadbox", description: "Open the app launcher." },
|
|
CommandEntry { name: "breadpad", description: "Open notes/reminders." },
|
|
CommandEntry { name: "breadman", description: "Open the notes/task manager." },
|
|
CommandEntry { name: "breadpaper", description: "Open the wallpaper picker." },
|
|
CommandEntry { name: "breadclip", description: "Open clipboard history." },
|
|
CommandEntry { name: "breadcrumbs", description: "Manage Wi-Fi profiles." },
|
|
];
|
|
|
|
enum Kind {
|
|
Guide { category: String, id: String },
|
|
Keybind(Keybind),
|
|
Command { name: &'static str, description: &'static str },
|
|
}
|
|
|
|
struct IndexEntry {
|
|
label: String,
|
|
haystack: String,
|
|
kind: Kind,
|
|
}
|
|
|
|
fn build_index(store: &ContentStore, binds: &[Keybind]) -> Vec<IndexEntry> {
|
|
let mut index = Vec::new();
|
|
for guide in store.all() {
|
|
let haystack = format!("{} {}", guide.meta.title, guide.meta.tags.join(" ")).to_lowercase();
|
|
index.push(IndexEntry {
|
|
label: guide.meta.title.clone(),
|
|
haystack,
|
|
kind: Kind::Guide { category: guide.category.clone(), id: guide.id.clone() },
|
|
});
|
|
}
|
|
for bind in binds {
|
|
let label = format!("{} — {}", bind.combo, bind.action);
|
|
let haystack = label.to_lowercase();
|
|
index.push(IndexEntry { label, haystack, kind: Kind::Keybind(bind.clone()) });
|
|
}
|
|
for cmd in COMMANDS {
|
|
let haystack = format!("{} {}", cmd.name, cmd.description).to_lowercase();
|
|
index.push(IndexEntry {
|
|
label: cmd.name.to_string(),
|
|
haystack,
|
|
kind: Kind::Command { name: cmd.name, description: cmd.description },
|
|
});
|
|
}
|
|
index
|
|
}
|
|
|
|
pub fn build(store: Rc<ContentStore>, binds: Rc<Vec<Keybind>>, mode: Mode) -> Widget {
|
|
let hpaned = Paned::new(Orientation::Horizontal);
|
|
hpaned.set_position(320);
|
|
hpaned.set_shrink_start_child(false);
|
|
hpaned.set_resize_start_child(false);
|
|
|
|
let left = GBox::new(Orientation::Vertical, 8);
|
|
left.add_css_class("view-content");
|
|
|
|
let search = Entry::new();
|
|
search.set_placeholder_text(Some("Search guides, keybinds, commands\u{2026}"));
|
|
left.append(&search);
|
|
|
|
let results_list = ListBox::new();
|
|
results_list.set_selection_mode(SelectionMode::None);
|
|
results_list.set_activate_on_single_click(true);
|
|
results_list.add_css_class("boxed-list");
|
|
let results_scroller = ScrolledWindow::builder().child(&results_list).vexpand(true).build();
|
|
left.append(&results_scroller);
|
|
|
|
let detail = GBox::new(Orientation::Vertical, 10);
|
|
detail.add_css_class("view-content");
|
|
let detail_scroller = ScrolledWindow::builder().child(&detail).vexpand(true).hexpand(true).build();
|
|
|
|
let index = Rc::new(build_index(&store, &binds));
|
|
|
|
let populate: Rc<dyn Fn(&str)> = {
|
|
let results_list = results_list.clone();
|
|
let index = index.clone();
|
|
Rc::new(move |query: &str| {
|
|
while let Some(child) = results_list.first_child() {
|
|
results_list.remove(&child);
|
|
}
|
|
let q = query.to_lowercase();
|
|
let matches = index
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, e)| q.is_empty() || e.haystack.contains(&q))
|
|
.take(40);
|
|
for (i, entry) in matches {
|
|
let row = ListBoxRow::new();
|
|
row.set_widget_name(&i.to_string());
|
|
let lbl = Label::new(Some(&entry.label));
|
|
lbl.set_xalign(0.0);
|
|
lbl.set_wrap(true);
|
|
lbl.set_margin_top(6);
|
|
lbl.set_margin_bottom(6);
|
|
lbl.set_margin_start(8);
|
|
lbl.set_margin_end(8);
|
|
row.set_child(Some(&lbl));
|
|
results_list.append(&row);
|
|
}
|
|
})
|
|
};
|
|
populate("");
|
|
|
|
search.connect_changed({
|
|
let populate = populate.clone();
|
|
move |entry| populate(&entry.text())
|
|
});
|
|
|
|
results_list.connect_row_activated(move |_, row| {
|
|
let Ok(i) = row.widget_name().parse::<usize>() else { return };
|
|
let Some(entry) = index.get(i) else { return };
|
|
|
|
while let Some(child) = detail.first_child() {
|
|
detail.remove(&child);
|
|
}
|
|
|
|
match &entry.kind {
|
|
Kind::Guide { category, id } => {
|
|
if let Some(guide) = store.guide(category, id) {
|
|
detail.append(&guide_view::build(&store, guide, mode.is_simplified(), &binds));
|
|
}
|
|
}
|
|
Kind::Keybind(bind) => {
|
|
let list = ListBox::new();
|
|
list.set_selection_mode(SelectionMode::None);
|
|
list.add_css_class("boxed-list");
|
|
list.append(&keybind_viewer::row(bind));
|
|
detail.append(&list);
|
|
}
|
|
Kind::Command { name, description } => {
|
|
let title = Label::new(Some(name));
|
|
title.add_css_class("title");
|
|
title.set_xalign(0.0);
|
|
detail.append(&title);
|
|
|
|
let desc = Label::new(Some(description));
|
|
desc.set_wrap(true);
|
|
desc.set_xalign(0.0);
|
|
detail.append(&desc);
|
|
|
|
let run_btn = gtk4::Button::with_label("Run");
|
|
run_btn.set_halign(gtk4::Align::Start);
|
|
let cmd = name.to_string();
|
|
run_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
|
|
detail.append(&run_btn);
|
|
}
|
|
}
|
|
});
|
|
|
|
hpaned.set_start_child(Some(&left));
|
|
hpaned.set_end_child(Some(&detail_scroller));
|
|
|
|
hpaned.upcast()
|
|
}
|