dev #2
57 changed files with 0 additions and 4333 deletions
|
|
@ -1,40 +0,0 @@
|
||||||
name: Build and publish package
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
package:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
container:
|
|
||||||
image: archlinux:latest
|
|
||||||
steps:
|
|
||||||
# Note: no actions/checkout — the archlinux image has no Node, which JS
|
|
||||||
# actions require. Everything runs as shell steps and clones manually.
|
|
||||||
- name: Build and publish
|
|
||||||
env:
|
|
||||||
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
|
|
||||||
useradd -m builder
|
|
||||||
git config --global --add safe.directory '*'
|
|
||||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
|
||||||
cd /home/builder/src
|
|
||||||
git archive --format=tar.gz --prefix="breadhelp-${VERSION}/" HEAD \
|
|
||||||
> packaging/arch/breadhelp/breadhelp-${VERSION}.tar.gz
|
|
||||||
SHA=$(sha256sum packaging/arch/breadhelp/breadhelp-${VERSION}.tar.gz | awk '{print $1}')
|
|
||||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/breadhelp/PKGBUILD
|
|
||||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/breadhelp/PKGBUILD
|
|
||||||
chown -R builder:builder /home/builder/src
|
|
||||||
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
|
||||||
su builder -c "cd /home/builder/src/packaging/arch/breadhelp && makepkg -f --noconfirm --nocheck"
|
|
||||||
PKG=$(find /home/builder/src/packaging/arch/breadhelp -name '*.pkg.tar.zst' | head -1)
|
|
||||||
curl -fsS -X PUT \
|
|
||||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary "@${PKG}" \
|
|
||||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
|
||||||
1108
Cargo.lock
generated
1108
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
||||||
[workspace]
|
|
||||||
members = ["breadhelp"]
|
|
||||||
resolver = "2"
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "breadhelp"
|
|
||||||
version = "0.2.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
|
||||||
gdk4 = "0.11"
|
|
||||||
gtk4-layer-shell = "0.8"
|
|
||||||
glib = "0.22"
|
|
||||||
# Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/
|
|
||||||
# breadbox/breadpad load, so this looks like part of the same desktop.
|
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
toml = "0.8"
|
|
||||||
# Non-destructive state editing (mirrors bos-settings/src/config/mod.rs).
|
|
||||||
toml_edit = "0.22"
|
|
||||||
async-channel = "2"
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# breadhelp content
|
|
||||||
|
|
||||||
Guides and troubleshooting data, installed to `/usr/share/breadhelp/content/`.
|
|
||||||
User overrides/additions live at `~/.local/share/breadhelp/content/` (same
|
|
||||||
shape; a colliding guide directory name wins wholesale over the system copy).
|
|
||||||
|
|
||||||
Categories are just top-level directories here — the Learn tab lists whatever
|
|
||||||
exists, so adding a category is a content change, not a code change:
|
|
||||||
|
|
||||||
```
|
|
||||||
getting-started/
|
|
||||||
daily-use/
|
|
||||||
customization/
|
|
||||||
apps/
|
|
||||||
troubleshooting/
|
|
||||||
_symptoms/ # troubleshooting wizard's symptom-tree *.toml files
|
|
||||||
advanced/
|
|
||||||
```
|
|
||||||
|
|
||||||
Each guide is a directory with:
|
|
||||||
|
|
||||||
- `meta.toml` — title, difficulty, tags, related guides, estimated time
|
|
||||||
- `content.md` — the guide body (constrained Markdown subset + `[Run]`/
|
|
||||||
`[Show Keybind]` tags — see `src/content/markdown.rs`)
|
|
||||||
- `content.beginner.md` — optional, used in Beginner/Dad mode instead of
|
|
||||||
`content.md` when present
|
|
||||||
|
|
||||||
Content lands starting at M2.
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# Snapshots and rollback
|
|
||||||
|
|
||||||
Every package change snapshots your root filesystem automatically (snapper +
|
|
||||||
snap-pac). Nothing to configure — it just happens.
|
|
||||||
|
|
||||||
## Roll back
|
|
||||||
|
|
||||||
- From BOS Settings' Snapshots page, or
|
|
||||||
- Right from the GRUB menu at boot (pick an older snapshot to boot into).
|
|
||||||
|
|
||||||
## Take a manual snapshot before something risky
|
|
||||||
|
|
||||||
- [Run]snapper -c root create --description "manual snapshot"|Create a snapshot now[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Snapshots and rollback"
|
|
||||||
difficulty = "advanced"
|
|
||||||
tags = ["snapper", "snapshots", "rollback"]
|
|
||||||
related = []
|
|
||||||
estimated_minutes = 3
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Notes and tasks
|
|
||||||
|
|
||||||
Two dedicated apps, so quick notes and real task tracking don't compete for
|
|
||||||
the same window.
|
|
||||||
|
|
||||||
- [Show Keybind]super + u[/Show Keybind] opens breadpad — a fast popup for jotting something down right now.
|
|
||||||
- [Show Keybind]super + m[/Show Keybind] opens breadman — reminders and a task list for things that need to stick around.
|
|
||||||
|
|
||||||
- [Run]breadpad|Open Notes[/Run]
|
|
||||||
- [Run]breadman|Open Tasks[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Notes and tasks"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["breadpad", "breadman", "notes"]
|
|
||||||
related = []
|
|
||||||
estimated_minutes = 2
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Customizing the bar and launcher
|
|
||||||
|
|
||||||
The bar (breadbar) and launcher (breadbox) both have their own settings
|
|
||||||
pages — position, modules, appearance.
|
|
||||||
|
|
||||||
- [Run]bos-settings --page breadbar|Open Bar Settings[/Run]
|
|
||||||
- [Run]bos-settings --page breadbox|Open Launcher Settings[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Customizing the bar and launcher"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["breadbar", "breadbox", "customization"]
|
|
||||||
related = ["01-what-is-bos"]
|
|
||||||
estimated_minutes = 2
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Searching your files
|
|
||||||
|
|
||||||
breadsearch indexes your files so you can find things instantly, without
|
|
||||||
digging through folders.
|
|
||||||
|
|
||||||
- Open it from BOS Settings, or run it directly.
|
|
||||||
- Type a few letters of a filename — results update as you type.
|
|
||||||
|
|
||||||
- [Run]breadsearch|Open File Search[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Searching your files"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["search", "files", "breadsearch"]
|
|
||||||
related = []
|
|
||||||
estimated_minutes = 2
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Clipboard history
|
|
||||||
|
|
||||||
BOS remembers what you've copied, not just the last thing.
|
|
||||||
|
|
||||||
- [Show Keybind]super + v[/Show Keybind] opens clipboard history — pick anything you've copied recently and paste it again.
|
|
||||||
|
|
||||||
- [Run]breadclip|Open Clipboard History[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Clipboard history"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["clipboard", "breadclip"]
|
|
||||||
related = []
|
|
||||||
estimated_minutes = 1
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
# Welcome!
|
|
||||||
|
|
||||||
This computer runs BOS — a friendly desktop built for you.
|
|
||||||
|
|
||||||
- Hold down the SUPER key (it looks like a little window logo) and press other keys to do things.
|
|
||||||
- [Show Keybind]super + /[/Show Keybind] always shows you every shortcut, so you never have to memorize anything.
|
|
||||||
- [Show Keybind]super + ,[/Show Keybind] opens Settings.
|
|
||||||
- If anything ever looks wrong, your computer saves a safety copy every time something changes — nothing is ever truly lost.
|
|
||||||
|
|
||||||
- [Run]bos-settings|Open Settings[/Run]
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# Welcome to BOS
|
|
||||||
|
|
||||||
BOS (the Bread Operating System) is a complete Hyprland desktop with the
|
|
||||||
bread ecosystem preinstalled: a bar, a launcher, notes, Wi-Fi profiles, and a
|
|
||||||
settings app that needs no config files.
|
|
||||||
|
|
||||||
## The basics
|
|
||||||
|
|
||||||
- SUPER is the Windows/Cmd key — almost everything starts with it.
|
|
||||||
- [Show Keybind]super + /[/Show Keybind] shows the full keybind cheatsheet any time.
|
|
||||||
- [Show Keybind]super + ,[/Show Keybind] opens BOS Settings — configure the bar, launcher, Wi-Fi, notes, snapshots, and package updates all in one place.
|
|
||||||
- [Show Keybind]super + space[/Show Keybind] opens the app launcher (breadbox).
|
|
||||||
- [Show Keybind]super + return[/Show Keybind] opens a terminal.
|
|
||||||
|
|
||||||
## Safety net
|
|
||||||
|
|
||||||
Your system snapshots itself on every package change. If an update breaks
|
|
||||||
something, roll it back from BOS Settings or pick a snapshot right from the
|
|
||||||
GRUB menu at boot.
|
|
||||||
|
|
||||||
- [Run]bos-settings|Open BOS Settings[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "What is BOS?"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["overview", "welcome"]
|
|
||||||
related = ["02-essential-keybinds"]
|
|
||||||
estimated_minutes = 2
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# The essential keybinds
|
|
||||||
|
|
||||||
Hyprland is keyboard-first. You don't need a mouse for any of this — but
|
|
||||||
nothing here is mandatory either, click around if you'd rather.
|
|
||||||
|
|
||||||
## Windows
|
|
||||||
|
|
||||||
- [Show Keybind]super + return[/Show Keybind] opens a terminal
|
|
||||||
- [Show Keybind]super + backspace[/Show Keybind] closes the focused window
|
|
||||||
- [Show Keybind]super + f[/Show Keybind] toggles fullscreen
|
|
||||||
- [Show Keybind]super + i[/Show Keybind] toggles floating
|
|
||||||
|
|
||||||
## Apps
|
|
||||||
|
|
||||||
- [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox)
|
|
||||||
- [Show Keybind]super + e[/Show Keybind] opens files
|
|
||||||
- [Show Keybind]super + b[/Show Keybind] opens the browser
|
|
||||||
- [Show Keybind]super + u[/Show Keybind] opens notes
|
|
||||||
|
|
||||||
## The full list
|
|
||||||
|
|
||||||
The Home tab's keybind viewer always has the complete, current list — this
|
|
||||||
guide only covers the ones you'll use every day.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "The essential keybinds"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["keybinds", "hyprland"]
|
|
||||||
related = ["01-what-is-bos"]
|
|
||||||
estimated_minutes = 3
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# Personalizing BOS
|
|
||||||
|
|
||||||
The whole desktop recolors itself from your wallpaper — bar, launcher,
|
|
||||||
notes, settings, and this help center all pick up the same palette
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
## Change your wallpaper
|
|
||||||
|
|
||||||
- [Run]breadpaper|Open the wallpaper picker[/Run]
|
|
||||||
|
|
||||||
Picking a new wallpaper regenerates the color palette for every bread app —
|
|
||||||
no restart needed, everything live-reloads.
|
|
||||||
|
|
||||||
## Change the bar and launcher
|
|
||||||
|
|
||||||
Open BOS Settings for per-app options:
|
|
||||||
|
|
||||||
- [Run]bos-settings|Open BOS Settings[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Personalizing BOS"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["wallpaper", "theme", "customization"]
|
|
||||||
related = []
|
|
||||||
estimated_minutes = 3
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# Live guided tour steps, walked by ui::tour. Phase A validation subset —
|
|
||||||
# just enough to prove the spotlight/must-do engine end-to-end (breadbar's
|
|
||||||
# plain observe-only highlight, breadbox's real openlayer detection) before
|
|
||||||
# authoring the full app-by-app tour. See breadhelp-tour.lua for the
|
|
||||||
# `--tour-event` ids this matches against.
|
|
||||||
|
|
||||||
[[step]]
|
|
||||||
id = "welcome"
|
|
||||||
title = "Welcome to BOS"
|
|
||||||
body = "This is a quick, hands-on tour of your desktop. Instead of just describing things, we'll point at the real thing on screen as we go. Press Next when you're ready."
|
|
||||||
|
|
||||||
[[step]]
|
|
||||||
id = "breadbar"
|
|
||||||
title = "Breadbar"
|
|
||||||
body = "This bar across the top of your screen always shows your workspace, the time, and quick status for network, volume, and battery. Click any icon on it to see more."
|
|
||||||
target_namespace = "breadbar"
|
|
||||||
|
|
||||||
[[step]]
|
|
||||||
id = "breadbox"
|
|
||||||
title = "Opening apps"
|
|
||||||
body = "Everything in BOS opens from here. Press [Show Keybind]super + space[/Show Keybind] to open breadbox, then start typing an app's name and press Enter to launch it."
|
|
||||||
target_namespace = "breadbox"
|
|
||||||
launch = "breadbox"
|
|
||||||
success_event = "layer-closed:breadbox"
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# Fixing Wi-Fi problems
|
|
||||||
|
|
||||||
Most Wi-Fi issues clear up by toggling the radio off and back on.
|
|
||||||
|
|
||||||
- [Run]nmcli radio wifi off && sleep 1 && nmcli radio wifi on|Reconnect Wi-Fi[/Run]
|
|
||||||
|
|
||||||
For saved networks and profiles (home, work, away), use breadcrumbs:
|
|
||||||
|
|
||||||
- [Run]breadcrumbs|Manage Wi-Fi Profiles[/Run]
|
|
||||||
|
|
||||||
Or jump straight to the Network settings page:
|
|
||||||
|
|
||||||
- [Run]bos-settings --page network|Open Network Settings[/Run]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
title = "Fixing Wi-Fi problems"
|
|
||||||
difficulty = "beginner"
|
|
||||||
tags = ["wifi", "network", "breadcrumbs"]
|
|
||||||
related = []
|
|
||||||
estimated_minutes = 2
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# Symptom tree for the troubleshooting wizard — walked by
|
|
||||||
# src/ui/troubleshoot_wizard.rs via src/content/troubleshoot.rs.
|
|
||||||
|
|
||||||
[[node]]
|
|
||||||
id = "start"
|
|
||||||
prompt = "What's wrong with your sound?"
|
|
||||||
[[node.options]]
|
|
||||||
label = "No sound at all"
|
|
||||||
goto = "restart-audio"
|
|
||||||
[[node.options]]
|
|
||||||
label = "Wrong output device (e.g. HDMI instead of speakers)"
|
|
||||||
goto = "wrong-device"
|
|
||||||
|
|
||||||
[[node]]
|
|
||||||
id = "restart-audio"
|
|
||||||
prompt = "Restarting the audio service fixes this most of the time."
|
|
||||||
[node.fix]
|
|
||||||
description = "Restart PipeWire/WirePlumber"
|
|
||||||
command = "systemctl --user restart wireplumber pipewire pipewire.socket pipewire-pulse pipewire-pulse.socket"
|
|
||||||
requires_confirm = false
|
|
||||||
|
|
||||||
[[node]]
|
|
||||||
id = "wrong-device"
|
|
||||||
prompt = "Open Sound settings and pick the right output device from the list."
|
|
||||||
[[node.options]]
|
|
||||||
label = "Open Sound settings"
|
|
||||||
goto = "open-sound-settings"
|
|
||||||
|
|
||||||
[[node]]
|
|
||||||
id = "open-sound-settings"
|
|
||||||
prompt = "Opening BOS Settings' Sound page."
|
|
||||||
[node.fix]
|
|
||||||
description = "Open Sound settings"
|
|
||||||
command = "bos-settings --page sound"
|
|
||||||
requires_confirm = false
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
[[node]]
|
|
||||||
id = "start"
|
|
||||||
prompt = "What's happening with Wi-Fi?"
|
|
||||||
[[node.options]]
|
|
||||||
label = "It won't connect / keeps dropping"
|
|
||||||
goto = "toggle-radio"
|
|
||||||
[[node.options]]
|
|
||||||
label = "I need to manage saved networks or profiles"
|
|
||||||
goto = "breadcrumbs"
|
|
||||||
|
|
||||||
[[node]]
|
|
||||||
id = "toggle-radio"
|
|
||||||
prompt = "Toggling the Wi-Fi radio off and back on clears up most connection issues."
|
|
||||||
[node.fix]
|
|
||||||
description = "Reconnect Wi-Fi"
|
|
||||||
command = "nmcli radio wifi off && sleep 1 && nmcli radio wifi on"
|
|
||||||
requires_confirm = false
|
|
||||||
|
|
||||||
[[node]]
|
|
||||||
id = "breadcrumbs"
|
|
||||||
prompt = "breadcrumbs manages your saved networks and profiles (home, work, away)."
|
|
||||||
[node.fix]
|
|
||||||
description = "Open breadcrumbs"
|
|
||||||
command = "breadcrumbs"
|
|
||||||
requires_confirm = false
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
//! argv handling for a `HANDLES_COMMAND_LINE` GApplication — every
|
|
||||||
//! invocation (including ones launched while breadhelp is already running,
|
|
||||||
//! forwarded over D-Bus to the primary instance) reaches
|
|
||||||
//! `connect_command_line`, so `--onboard` on a second launch re-triggers the
|
|
||||||
//! tour in the existing window instead of spawning a duplicate.
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct Action {
|
|
||||||
/// Force-restart the onboarding tour from step 0, regardless of whether
|
|
||||||
/// it was already completed or in progress.
|
|
||||||
pub force_onboard: bool,
|
|
||||||
/// Set by hyprland.lua's every-login autostart entry, as opposed to a
|
|
||||||
/// user explicitly running `breadhelp` or pressing SUPER+/. Lets
|
|
||||||
/// `present()` build the window (so the app is ready to respond the
|
|
||||||
/// instant it's needed) without popping it open when onboarding is
|
|
||||||
/// already done — autostart should only be visible on a genuine first
|
|
||||||
/// run, never on every subsequent login.
|
|
||||||
pub autostart: bool,
|
|
||||||
/// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a
|
|
||||||
/// system event. Resolved to banner text by `services::breadd::resolve`.
|
|
||||||
pub suggest: Option<String>,
|
|
||||||
/// From `breadhelp-tour.lua` forwarding a bread/Hyprland event (window
|
|
||||||
/// opened, layer opened, workspace changed, or a rebind-chained ping) as
|
|
||||||
/// a candidate "the user just did the thing" signal for the live tour.
|
|
||||||
/// Only acted on if a tour is currently waiting for this exact id —
|
|
||||||
/// see the crash-safety note on `ui::tour`.
|
|
||||||
pub tour_event: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(args: &[std::ffi::OsString]) -> Action {
|
|
||||||
let mut action = Action::default();
|
|
||||||
let mut it = args.iter().skip(1);
|
|
||||||
while let Some(arg) = it.next() {
|
|
||||||
if arg == "--onboard" {
|
|
||||||
action.force_onboard = true;
|
|
||||||
} else if arg == "--autostart" {
|
|
||||||
action.autostart = true;
|
|
||||||
} else if arg == "--suggest" {
|
|
||||||
action.suggest = it.next().and_then(|s| s.to_str()).map(str::to_string);
|
|
||||||
} else if arg == "--tour-event" {
|
|
||||||
action.tour_event = it.next().and_then(|s| s.to_str()).map(str::to_string);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
//! `~/.config/breadhelp/state.toml` — non-destructive TOML editing, same
|
|
||||||
//! `load_doc`/`save_doc` discipline as `bos-settings/src/config/mod.rs`: a
|
|
||||||
//! missing file yields defaults, a file that exists but fails to parse is
|
|
||||||
//! backed up once before falling back, so a bad edit is always recoverable.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use toml_edit::{value, DocumentMut};
|
|
||||||
|
|
||||||
pub fn config_dir() -> PathBuf {
|
|
||||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
|
||||||
let p = PathBuf::from(xdg);
|
|
||||||
if p.is_absolute() {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
|
||||||
PathBuf::from(home).join(".config")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_path() -> PathBuf {
|
|
||||||
config_dir().join("breadhelp").join("state.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_doc(path: &Path) -> DocumentMut {
|
|
||||||
let Ok(text) = std::fs::read_to_string(path) else {
|
|
||||||
return DocumentMut::default();
|
|
||||||
};
|
|
||||||
match text.parse::<DocumentMut>() {
|
|
||||||
Ok(doc) => doc,
|
|
||||||
Err(e) => {
|
|
||||||
let backup = PathBuf::from(format!("{}.bak", path.display()));
|
|
||||||
eprintln!(
|
|
||||||
"breadhelp: {} failed to parse ({e}); backed up to {} before falling back to defaults",
|
|
||||||
path.display(),
|
|
||||||
backup.display()
|
|
||||||
);
|
|
||||||
let _ = std::fs::write(&backup, &text);
|
|
||||||
DocumentMut::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_doc(path: &Path, doc: &DocumentMut) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
||||||
eprintln!("breadhelp: couldn't create {}: {e}", parent.display());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(path, doc.to_string()) {
|
|
||||||
eprintln!("breadhelp: couldn't write {}: {e}", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum Mode {
|
|
||||||
Normal,
|
|
||||||
Beginner,
|
|
||||||
Dad,
|
|
||||||
Compact,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Mode {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Mode::Normal => "normal",
|
|
||||||
Mode::Beginner => "beginner",
|
|
||||||
Mode::Dad => "dad",
|
|
||||||
Mode::Compact => "compact",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Self {
|
|
||||||
match s {
|
|
||||||
"beginner" => Mode::Beginner,
|
|
||||||
"dad" => Mode::Dad,
|
|
||||||
"compact" => Mode::Compact,
|
|
||||||
_ => Mode::Normal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Beginner/Dad pick `content.beginner.md` over `content.md` when present.
|
|
||||||
pub fn is_simplified(self) -> bool {
|
|
||||||
matches!(self, Mode::Beginner | Mode::Dad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct State {
|
|
||||||
doc: DocumentMut,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl State {
|
|
||||||
pub fn load() -> Self {
|
|
||||||
let path = state_path();
|
|
||||||
let doc = load_doc(&path);
|
|
||||||
Self { doc, path }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn onboarding_completed(&self) -> bool {
|
|
||||||
self.doc
|
|
||||||
.get("onboarding")
|
|
||||||
.and_then(|t| t.get("completed"))
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_onboarding_completed(&mut self, completed: bool) {
|
|
||||||
self.doc["onboarding"]["completed"] = value(completed);
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn onboarding_step(&self) -> i64 {
|
|
||||||
self.doc.get("onboarding").and_then(|t| t.get("step")).and_then(|v| v.as_integer()).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_onboarding_step(&mut self, step: i64) {
|
|
||||||
self.doc["onboarding"]["step"] = value(step);
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the instant before `services::hyprland::rebind_temp` is called for
|
|
||||||
/// a tour step with no compositor-observable signal (e.g. the screenshot
|
|
||||||
/// step), cleared the instant after reverting. If breadhelp is killed
|
|
||||||
/// mid-step, this is how the next launch knows a keybind was left
|
|
||||||
/// pointing at a chained `--tour-event` ping and self-heals it before
|
|
||||||
/// the user can be surprised by a stray tour popup — see `ui::tour`.
|
|
||||||
pub fn pending_rebind(&self) -> Option<(String, String)> {
|
|
||||||
let key = self.doc.get("tour")?.get("pending_rebind_key")?.as_str()?.to_string();
|
|
||||||
let original = self.doc.get("tour")?.get("pending_rebind_original")?.as_str()?.to_string();
|
|
||||||
Some((key, original))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_pending_rebind(&mut self, key: &str, original_bind_value: &str) {
|
|
||||||
self.doc["tour"]["pending_rebind_key"] = value(key);
|
|
||||||
self.doc["tour"]["pending_rebind_original"] = value(original_bind_value);
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_pending_rebind(&mut self) {
|
|
||||||
if let Some(table) = self.doc.get_mut("tour").and_then(|t| t.as_table_mut()) {
|
|
||||||
table.remove("pending_rebind_key");
|
|
||||||
table.remove("pending_rebind_original");
|
|
||||||
}
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mode(&self) -> Mode {
|
|
||||||
let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal");
|
|
||||||
Mode::from_str(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_mode(&mut self, mode: Mode) {
|
|
||||||
self.doc["general"]["mode"] = value(mode.as_str());
|
|
||||||
self.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save(&self) {
|
|
||||||
save_doc(&self.path, &self.doc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
//! Loads `~/.config/hypr/binds.json` — the SAME structured data
|
|
||||||
//! `hyprland.lua` itself loads (via `scripts/input/{binds,keybinds}.lua`) to
|
|
||||||
//! generate the real Hyprland binds. Reading this directly, rather than a
|
|
||||||
//! separately-maintained cheatsheet, means the keybind viewer can never
|
|
||||||
//! drift from what's actually bound.
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Keybind {
|
|
||||||
pub combo: String,
|
|
||||||
pub action: String,
|
|
||||||
#[allow(dead_code)] // reserved for Ask-tab filtering/future grouping
|
|
||||||
pub category: String,
|
|
||||||
/// What the onboarding tour's "Try it" button runs for this bind.
|
|
||||||
pub demo_cmd: Option<String>,
|
|
||||||
/// The `<dispatcher>,<args>` half of the real `hyprctl keyword bind`
|
|
||||||
/// value (e.g. `"exec,breadbox"`) — only synthesized for `action: "exec"`
|
|
||||||
/// entries, since binds.json's other actions ("close", "fullscreen", ...)
|
|
||||||
/// are this app's own loader abstraction, not real Hyprland dispatcher
|
|
||||||
/// names, and there's no safe generic way to reconstruct one. Binds
|
|
||||||
/// without this simply don't offer a rebind button.
|
|
||||||
pub raw: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct RawEntry {
|
|
||||||
action: String,
|
|
||||||
#[serde(default)]
|
|
||||||
command: Option<String>,
|
|
||||||
key: String,
|
|
||||||
#[serde(default)]
|
|
||||||
mods: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
label: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
category: String,
|
|
||||||
#[serde(default)]
|
|
||||||
demo_cmd: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct BindsFile {
|
|
||||||
#[serde(default)]
|
|
||||||
default_mods: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
bindings: Vec<RawEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binds_json_path() -> std::path::PathBuf {
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
|
||||||
std::path::PathBuf::from(home).join(".config/hypr/binds.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> Vec<Keybind> {
|
|
||||||
let Ok(text) = std::fs::read_to_string(binds_json_path()) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let file: BindsFile = match serde_json::from_str(&text) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadhelp: binds.json failed to parse: {e}");
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let default_mods = if file.default_mods.is_empty() { vec!["SUPER".to_string()] } else { file.default_mods };
|
|
||||||
|
|
||||||
file.bindings
|
|
||||||
.into_iter()
|
|
||||||
.map(|entry| {
|
|
||||||
// An explicit "mods": [] (media keys) must NOT fall back to
|
|
||||||
// default_mods — only an omitted `mods` field should, matching
|
|
||||||
// scripts/input/keybinds.lua's own normalize_mods semantics.
|
|
||||||
let mods = entry.mods.unwrap_or_else(|| default_mods.clone());
|
|
||||||
let combo = if mods.is_empty() {
|
|
||||||
entry.key.to_lowercase()
|
|
||||||
} else {
|
|
||||||
format!("{} + {}", mods.join(" + ").to_lowercase(), entry.key.to_lowercase())
|
|
||||||
};
|
|
||||||
let raw = (entry.action == "exec").then(|| entry.command.clone()).flatten().map(|c| format!("exec,{c}"));
|
|
||||||
Keybind {
|
|
||||||
combo,
|
|
||||||
action: entry.label.unwrap_or(entry.action),
|
|
||||||
category: entry.category,
|
|
||||||
demo_cmd: entry.demo_cmd,
|
|
||||||
raw,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Case-insensitive lookup by combo (e.g. "super + space") for `[Show
|
|
||||||
/// Keybind]` tags — falls back to `None` (rendered as raw text) rather than
|
|
||||||
/// panicking when a guide references a combo that doesn't exist.
|
|
||||||
pub fn find<'a>(binds: &'a [Keybind], combo: &str) -> Option<&'a Keybind> {
|
|
||||||
let norm = normalize(combo);
|
|
||||||
binds.iter().find(|b| normalize(&b.combo) == norm)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize(combo: &str) -> String {
|
|
||||||
combo.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort conversion of a human combo ("super + shift + u") into
|
|
||||||
/// hyprctl's `<mods>_<mods>,<key>` form ("SUPER_SHIFT,U") for the temporary
|
|
||||||
/// rebind feature — not a full Hyprland key-name parser, just enough for the
|
|
||||||
/// common single-letter/named-key binds this viewer lists.
|
|
||||||
pub fn to_hypr_mods_key(combo: &str) -> String {
|
|
||||||
let parts: Vec<String> = combo
|
|
||||||
.split(|c: char| c == '+' || c.is_whitespace())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(normalize_token)
|
|
||||||
.collect();
|
|
||||||
match parts.split_last() {
|
|
||||||
None => String::new(),
|
|
||||||
Some((key, [])) => key.clone(),
|
|
||||||
Some((key, mods)) => format!("{},{}", mods.join("_"), key),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_token(tok: &str) -> String {
|
|
||||||
match tok.to_lowercase().as_str() {
|
|
||||||
"space" => "SPACE".to_string(),
|
|
||||||
"return" | "enter" => "RETURN".to_string(),
|
|
||||||
"backspace" => "BACKSPACE".to_string(),
|
|
||||||
other => other.to_uppercase(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
//! Constrained-subset markdown parser + renderer for guide `content.md`.
|
|
||||||
//!
|
|
||||||
//! Deliberately not a full CommonMark parser (`pulldown-cmark`/`comrak`) and
|
|
||||||
//! deliberately not rendered as HTML in a WebKitGTK view: no sibling app in
|
|
||||||
//! this ecosystem embeds a browser engine, a full parser still wouldn't
|
|
||||||
//! understand the `[Run]`/`[Show Keybind]` tags so it buys nothing, and this
|
|
||||||
//! content is authored in-repo by BOS maintainers, not arbitrary user input —
|
|
||||||
//! a small subset is an acceptable authoring limit, not a security compromise.
|
|
||||||
//!
|
|
||||||
//! Supported: `#`/`##` headings, one-block-per-line paragraphs, `- ` bullets,
|
|
||||||
//! inline `**bold**`/`` `code` `` (rendered via GTK's own Pango markup), and
|
|
||||||
//! two custom tags: `[Run]<command>[/Run]` (optional `|Label` suffix) and
|
|
||||||
//! `[Show Keybind]<combo>[/Show Keybind]`.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Label, Orientation};
|
|
||||||
|
|
||||||
use super::keybinds::Keybind;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum Span {
|
|
||||||
Text(String),
|
|
||||||
Run { command: String, label: String },
|
|
||||||
Keybind(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum Block {
|
|
||||||
Heading(u8, String),
|
|
||||||
Paragraph(Vec<Span>),
|
|
||||||
Bullet(Vec<Span>),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(src: &str) -> Vec<Block> {
|
|
||||||
let mut blocks = Vec::new();
|
|
||||||
for raw_line in src.lines() {
|
|
||||||
let line = raw_line.trim();
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(rest) = line.strip_prefix("## ") {
|
|
||||||
blocks.push(Block::Heading(2, rest.trim().to_string()));
|
|
||||||
} else if let Some(rest) = line.strip_prefix("# ") {
|
|
||||||
blocks.push(Block::Heading(1, rest.trim().to_string()));
|
|
||||||
} else if let Some(rest) = line.strip_prefix("- ") {
|
|
||||||
blocks.push(Block::Bullet(parse_spans(rest)));
|
|
||||||
} else {
|
|
||||||
blocks.push(Block::Paragraph(parse_spans(line)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_spans(text: &str) -> Vec<Span> {
|
|
||||||
const RUN_OPEN: &str = "[Run]";
|
|
||||||
const RUN_CLOSE: &str = "[/Run]";
|
|
||||||
const KB_OPEN: &str = "[Show Keybind]";
|
|
||||||
const KB_CLOSE: &str = "[/Show Keybind]";
|
|
||||||
|
|
||||||
let mut spans = Vec::new();
|
|
||||||
let mut rest = text;
|
|
||||||
loop {
|
|
||||||
let run_idx = rest.find(RUN_OPEN);
|
|
||||||
let kb_idx = rest.find(KB_OPEN);
|
|
||||||
let next = match (run_idx, kb_idx) {
|
|
||||||
(Some(r), Some(k)) if r <= k => Some((r, true)),
|
|
||||||
(Some(_), Some(k)) => Some((k, false)),
|
|
||||||
(Some(r), None) => Some((r, true)),
|
|
||||||
(None, Some(k)) => Some((k, false)),
|
|
||||||
(None, None) => None,
|
|
||||||
};
|
|
||||||
let Some((idx, is_run)) = next else {
|
|
||||||
if !rest.is_empty() {
|
|
||||||
spans.push(Span::Text(rest.to_string()));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
if idx > 0 {
|
|
||||||
spans.push(Span::Text(rest[..idx].to_string()));
|
|
||||||
}
|
|
||||||
let (open_len, close_tag) = if is_run { (RUN_OPEN.len(), RUN_CLOSE) } else { (KB_OPEN.len(), KB_CLOSE) };
|
|
||||||
let after = &rest[idx + open_len..];
|
|
||||||
let Some(end) = after.find(close_tag) else {
|
|
||||||
// Unterminated tag — keep the literal text rather than dropping it.
|
|
||||||
spans.push(Span::Text(rest[idx..].to_string()));
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let inner = &after[..end];
|
|
||||||
if is_run {
|
|
||||||
let (command, label) = match inner.split_once('|') {
|
|
||||||
Some((c, l)) => (c.trim().to_string(), l.trim().to_string()),
|
|
||||||
None => (inner.trim().to_string(), inner.trim().to_string()),
|
|
||||||
};
|
|
||||||
spans.push(Span::Run { command, label });
|
|
||||||
} else {
|
|
||||||
spans.push(Span::Keybind(inner.trim().to_string()));
|
|
||||||
}
|
|
||||||
rest = &after[end + close_tag.len()..];
|
|
||||||
}
|
|
||||||
spans
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(blocks: &[Block], binds: &[Keybind]) -> gtk4::Widget {
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 10);
|
|
||||||
for block in blocks {
|
|
||||||
match block {
|
|
||||||
Block::Heading(level, text) => {
|
|
||||||
let lbl = Label::new(None);
|
|
||||||
let size = if *level == 1 { "large" } else { "medium" };
|
|
||||||
lbl.set_markup(&format!("<b><span size='{size}'>{}</span></b>", glib::markup_escape_text(text)));
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl.set_wrap(true);
|
|
||||||
vbox.append(&lbl);
|
|
||||||
}
|
|
||||||
Block::Paragraph(spans) => vbox.append(&render_spans(spans, binds)),
|
|
||||||
Block::Bullet(spans) => {
|
|
||||||
let row = GBox::new(Orientation::Horizontal, 6);
|
|
||||||
row.append(&Label::new(Some("\u{2022}")));
|
|
||||||
row.append(&render_spans(spans, binds));
|
|
||||||
vbox.append(&row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vbox.upcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_spans(spans: &[Span], binds: &[Keybind]) -> gtk4::Widget {
|
|
||||||
let row = GBox::new(Orientation::Horizontal, 6);
|
|
||||||
for span in spans {
|
|
||||||
match span {
|
|
||||||
Span::Text(t) => {
|
|
||||||
if t.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let lbl = Label::new(None);
|
|
||||||
lbl.set_markup(&inline_markup(t));
|
|
||||||
lbl.set_wrap(true);
|
|
||||||
// `set_wrap` alone only wraps once something else constrains
|
|
||||||
// the label's allocated width (true inside guide_view's fixed-
|
|
||||||
// width scrolled pane) — a freestanding top-level window (the
|
|
||||||
// tour callout) has nothing to allocate against, so without
|
|
||||||
// this a long paragraph reports its unwrapped natural width
|
|
||||||
// and the whole window balloons to fit it.
|
|
||||||
lbl.set_max_width_chars(48);
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
row.append(&lbl);
|
|
||||||
}
|
|
||||||
Span::Run { command, label } => {
|
|
||||||
let btn = Button::with_label(label);
|
|
||||||
let cmd = command.clone();
|
|
||||||
btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
|
|
||||||
row.append(&btn);
|
|
||||||
}
|
|
||||||
Span::Keybind(combo) => {
|
|
||||||
let text = super::keybinds::find(binds, combo).map(|k| k.combo.clone()).unwrap_or_else(|| combo.clone());
|
|
||||||
let chip = Label::new(Some(&text));
|
|
||||||
chip.add_css_class("keybind-chip");
|
|
||||||
row.append(&chip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
row.upcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inline_markup(text: &str) -> String {
|
|
||||||
let escaped = glib::markup_escape_text(text).to_string();
|
|
||||||
let bolded = replace_delim(&escaped, "**", "<b>", "</b>");
|
|
||||||
replace_delim(&bolded, "`", "<tt>", "</tt>")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Alternates `open`/`close` between successive `delim`-separated segments —
|
|
||||||
/// an odd number of delimiters leaves a dangling tag, which GTK's markup
|
|
||||||
/// parser reports as a warning rather than crashing (acceptable: this only
|
|
||||||
/// runs over content authored in-repo, per the module's rationale above).
|
|
||||||
fn replace_delim(s: &str, delim: &str, open: &str, close: &str) -> String {
|
|
||||||
let mut parts = s.split(delim);
|
|
||||||
let mut out = parts.next().unwrap_or_default().to_string();
|
|
||||||
for (i, part) in parts.enumerate() {
|
|
||||||
out.push_str(if i % 2 == 0 { open } else { close });
|
|
||||||
out.push_str(part);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_heading_bullet_paragraph() {
|
|
||||||
let blocks = parse("# Title\n\nSome text.\n\n- a bullet\n");
|
|
||||||
assert_eq!(blocks.len(), 3);
|
|
||||||
assert!(matches!(&blocks[0], Block::Heading(1, t) if t == "Title"));
|
|
||||||
assert!(matches!(&blocks[1], Block::Paragraph(_)));
|
|
||||||
assert!(matches!(&blocks[2], Block::Bullet(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_run_tag_with_label() {
|
|
||||||
let spans = parse_spans("Click [Run]bos-update|Update everything[/Run] to update.");
|
|
||||||
assert_eq!(spans.len(), 3);
|
|
||||||
assert!(matches!(&spans[0], Span::Text(t) if t == "Click "));
|
|
||||||
assert_eq!(
|
|
||||||
spans[1],
|
|
||||||
Span::Run { command: "bos-update".into(), label: "Update everything".into() }
|
|
||||||
);
|
|
||||||
assert!(matches!(&spans[2], Span::Text(t) if t == " to update."));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_run_tag_without_label() {
|
|
||||||
let spans = parse_spans("[Run]breadpad[/Run]");
|
|
||||||
assert_eq!(spans, vec![Span::Run { command: "breadpad".into(), label: "breadpad".into() }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_show_keybind_tag() {
|
|
||||||
let spans = parse_spans("Press [Show Keybind]super + space[/Show Keybind] to launch.");
|
|
||||||
assert_eq!(spans[1], Span::Keybind("super + space".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn counts_multiple_tags_in_one_line() {
|
|
||||||
let spans =
|
|
||||||
parse_spans("[Run]a[/Run] then [Show Keybind]super+/[/Show Keybind] then [Run]b|B[/Run]");
|
|
||||||
let run_count = spans.iter().filter(|s| matches!(s, Span::Run { .. })).count();
|
|
||||||
let kb_count = spans.iter().filter(|s| matches!(s, Span::Keybind(_))).count();
|
|
||||||
assert_eq!(run_count, 2);
|
|
||||||
assert_eq!(kb_count, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unterminated_tag_falls_back_to_text() {
|
|
||||||
let spans = parse_spans("oops [Run]no closing tag");
|
|
||||||
assert_eq!(spans, vec![Span::Text("oops ".into()), Span::Text("[Run]no closing tag".into())]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
//! `meta.toml` schema — one per guide directory.
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone, Default, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum Difficulty {
|
|
||||||
#[default]
|
|
||||||
Beginner,
|
|
||||||
Intermediate,
|
|
||||||
Advanced,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone, Default)]
|
|
||||||
pub struct GuideMeta {
|
|
||||||
pub title: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub difficulty: Difficulty,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
/// Guide dir-names in the same or another category. Unresolvable entries
|
|
||||||
/// are silently dropped when rendering "related guides" — never a crash.
|
|
||||||
#[serde(default)]
|
|
||||||
pub related: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub estimated_minutes: u32,
|
|
||||||
/// Overrides the parent directory name as the guide's category, if set.
|
|
||||||
#[serde(default)]
|
|
||||||
pub category: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
//! Guide content: `/usr/share/breadhelp/content/` (system, shipped by the
|
|
||||||
//! package) merged with `~/.local/share/breadhelp/content/` (user overrides/
|
|
||||||
//! additions). A guide directory name colliding between the two roots means
|
|
||||||
//! the user copy wins wholesale — no field-level merge, same "replace the
|
|
||||||
//! whole doc" discipline `bos-settings/src/config/mod.rs` uses for TOML, just
|
|
||||||
//! at the file-tree level.
|
|
||||||
|
|
||||||
pub mod keybinds;
|
|
||||||
pub mod markdown;
|
|
||||||
pub mod meta;
|
|
||||||
pub mod tour;
|
|
||||||
pub mod troubleshoot;
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use meta::GuideMeta;
|
|
||||||
|
|
||||||
const SYSTEM_ROOT: &str = "/usr/share/breadhelp/content";
|
|
||||||
|
|
||||||
/// Categories are just top-level content directories — this only fixes the
|
|
||||||
/// *display order* of ones we know about; unrecognized directories still
|
|
||||||
/// show up (sorted alphabetically after these) instead of being hidden.
|
|
||||||
const CATEGORY_ORDER: &[&str] = &[
|
|
||||||
"getting-started",
|
|
||||||
"daily-use",
|
|
||||||
"customization",
|
|
||||||
"apps",
|
|
||||||
"troubleshooting",
|
|
||||||
"advanced",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub struct Guide {
|
|
||||||
pub id: String,
|
|
||||||
pub category: String,
|
|
||||||
pub meta: GuideMeta,
|
|
||||||
pub dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ContentStore {
|
|
||||||
guides: Vec<Guide>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_content_root() -> PathBuf {
|
|
||||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
|
||||||
let p = PathBuf::from(xdg);
|
|
||||||
if p.is_absolute() {
|
|
||||||
return p.join("breadhelp/content");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
|
||||||
PathBuf::from(home).join(".local/share/breadhelp/content")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_root(root: &Path, out: &mut BTreeMap<(String, String), Guide>) {
|
|
||||||
let Ok(categories) = std::fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for cat_entry in categories.flatten() {
|
|
||||||
let cat_path = cat_entry.path();
|
|
||||||
if !cat_path.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let category = cat_entry.file_name().to_string_lossy().into_owned();
|
|
||||||
let Ok(guide_dirs) = std::fs::read_dir(&cat_path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for guide_entry in guide_dirs.flatten() {
|
|
||||||
let guide_dir = guide_entry.path();
|
|
||||||
if !guide_dir.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let id = guide_entry.file_name().to_string_lossy().into_owned();
|
|
||||||
// troubleshooting/_symptoms holds wizard data, not guides.
|
|
||||||
if id == "_symptoms" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let meta_path = guide_dir.join("meta.toml");
|
|
||||||
let Ok(meta_text) = std::fs::read_to_string(&meta_path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(meta) = toml::from_str::<GuideMeta>(&meta_text) else {
|
|
||||||
eprintln!("breadhelp: {} failed to parse, skipping", meta_path.display());
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let effective_category = meta.category.clone().unwrap_or_else(|| category.clone());
|
|
||||||
out.insert(
|
|
||||||
(effective_category.clone(), id.clone()),
|
|
||||||
Guide { id, category: effective_category, meta, dir: guide_dir },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn category_rank(category: &str) -> usize {
|
|
||||||
CATEGORY_ORDER.iter().position(|c| *c == category).unwrap_or(CATEGORY_ORDER.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentStore {
|
|
||||||
pub fn load() -> Self {
|
|
||||||
let mut by_key = BTreeMap::new();
|
|
||||||
scan_root(Path::new(SYSTEM_ROOT), &mut by_key);
|
|
||||||
scan_root(&user_content_root(), &mut by_key);
|
|
||||||
|
|
||||||
let mut guides: Vec<Guide> = by_key.into_values().collect();
|
|
||||||
guides.sort_by(|a, b| {
|
|
||||||
(category_rank(&a.category), &a.category, &a.id).cmp(&(category_rank(&b.category), &b.category, &b.id))
|
|
||||||
});
|
|
||||||
Self { guides }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn categories(&self) -> Vec<String> {
|
|
||||||
let mut seen = Vec::new();
|
|
||||||
for g in &self.guides {
|
|
||||||
if !seen.contains(&g.category) {
|
|
||||||
seen.push(g.category.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn guides_in(&self, category: &str) -> Vec<&Guide> {
|
|
||||||
self.guides.iter().filter(|g| g.category == category).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn guide(&self, category: &str, id: &str) -> Option<&Guide> {
|
|
||||||
self.guides.iter().find(|g| g.category == category && g.id == id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all(&self) -> &[Guide] {
|
|
||||||
&self.guides
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The guide body for the given mode — `content.beginner.md` when in a
|
|
||||||
/// simplified mode and present, otherwise `content.md`. Never errors: a
|
|
||||||
/// missing file just renders as an empty guide rather than crashing.
|
|
||||||
pub fn body(&self, guide: &Guide, simplified: bool) -> String {
|
|
||||||
if simplified {
|
|
||||||
let beginner = guide.dir.join("content.beginner.md");
|
|
||||||
if let Ok(text) = std::fs::read_to_string(&beginner) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::fs::read_to_string(guide.dir.join("content.md")).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
//! Live tour step loader — same shape of problem as `troubleshoot.rs`'s
|
|
||||||
//! symptom trees: TOML data, not hardcoded Rust, so the tour can be edited
|
|
||||||
//! without a rebuild. Fields are flat optionals rather than a tagged enum in
|
|
||||||
//! the TOML itself (mirroring `SymptomOption::goto`'s plain-string encoding)
|
|
||||||
//! to keep hand-authored `onboarding.toml` simple; `target()`/`success()`
|
|
||||||
//! turn that into the richer shape `ui::tour` actually wants to match on.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum Target {
|
|
||||||
/// A `gtk4-layer-shell` surface identified by namespace, e.g. "breadbox".
|
|
||||||
Namespace(String),
|
|
||||||
/// A plain toplevel window identified by its Wayland app-id/class.
|
|
||||||
WindowClass(String),
|
|
||||||
/// Pure-concept step — full-screen dim, centered callout, no spotlight.
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum Success {
|
|
||||||
/// Auto-advance when a `--tour-event <id>` matching this arrives.
|
|
||||||
Event(String),
|
|
||||||
/// Auto-advance after this many seconds if no event arrives first.
|
|
||||||
Timeout(u64),
|
|
||||||
/// Next button only — no event can signal this step's completion.
|
|
||||||
Manual,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone)]
|
|
||||||
pub struct Step {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub body: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub target_namespace: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub target_window_class: Option<String>,
|
|
||||||
/// Shell command run when the step starts, e.g. launching breadbox so
|
|
||||||
/// there's something to highlight. Skipped if the target is already
|
|
||||||
/// resolvable — several bread apps toggle-close on a second invocation
|
|
||||||
/// of the same command, so firing this unconditionally could close an
|
|
||||||
/// already-open instance instead of opening one.
|
|
||||||
#[serde(default)]
|
|
||||||
pub launch: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub success_event: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub success_timeout_seconds: Option<u64>,
|
|
||||||
/// A keybind combo (e.g. "super + shift + s") to temporarily rebind so
|
|
||||||
/// its original action still runs but is chained with a
|
|
||||||
/// `--tour-event <success_event>` ping — for steps whose action has no
|
|
||||||
/// compositor-observable signal at all (e.g. taking a screenshot).
|
|
||||||
/// Reverted the moment this step is left, and self-healed on the next
|
|
||||||
/// launch if breadhelp crashes mid-step — see `ui::tour`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub rebind_combo: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Step {
|
|
||||||
pub fn target(&self) -> Target {
|
|
||||||
if let Some(ns) = &self.target_namespace {
|
|
||||||
Target::Namespace(ns.clone())
|
|
||||||
} else if let Some(class) = &self.target_window_class {
|
|
||||||
Target::WindowClass(class.clone())
|
|
||||||
} else {
|
|
||||||
Target::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn success(&self) -> Success {
|
|
||||||
if let Some(id) = &self.success_event {
|
|
||||||
Success::Event(id.clone())
|
|
||||||
} else if let Some(secs) = self.success_timeout_seconds {
|
|
||||||
Success::Timeout(secs)
|
|
||||||
} else {
|
|
||||||
Success::Manual
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct TourFile {
|
|
||||||
#[serde(rename = "step", default)]
|
|
||||||
steps: Vec<Step>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEM_TOUR_PATH: &str = "/usr/share/breadhelp/content/tours/onboarding.toml";
|
|
||||||
|
|
||||||
pub fn load() -> Vec<Step> {
|
|
||||||
let Ok(text) = std::fs::read_to_string(Path::new(SYSTEM_TOUR_PATH)) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
match toml::from_str::<TourFile>(&text) {
|
|
||||||
Ok(f) => f.steps,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("breadhelp: {SYSTEM_TOUR_PATH} failed to parse: {e}");
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
//! Symptom-tree loader for the troubleshooting wizard (UI lands in M5;
|
|
||||||
//! this loader is defined now alongside the rest of the content system since
|
|
||||||
//! it's the same shape of problem as `keybinds.rs`/`meta.rs`).
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone)]
|
|
||||||
pub struct SymptomOption {
|
|
||||||
pub label: String,
|
|
||||||
/// Another node id, or "<file-stem>:<id>" to jump to a different file.
|
|
||||||
pub goto: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone)]
|
|
||||||
pub struct Fix {
|
|
||||||
pub description: String,
|
|
||||||
pub command: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub requires_confirm: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Clone)]
|
|
||||||
pub struct SymptomNode {
|
|
||||||
pub id: String,
|
|
||||||
pub prompt: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub options: Vec<SymptomOption>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub fix: Option<Fix>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct SymptomFile {
|
|
||||||
#[serde(rename = "node", default)]
|
|
||||||
nodes: Vec<SymptomNode>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEM_SYMPTOMS_DIR: &str = "/usr/share/breadhelp/content/troubleshooting/_symptoms";
|
|
||||||
|
|
||||||
/// Loads every `*.toml` file in the symptoms directory, keyed by file stem so
|
|
||||||
/// a `SymptomOption::goto` of `"no-sound:check-mute"` can be resolved.
|
|
||||||
pub fn load_all() -> std::collections::HashMap<String, Vec<SymptomNode>> {
|
|
||||||
let mut out = std::collections::HashMap::new();
|
|
||||||
let Ok(entries) = std::fs::read_dir(Path::new(SYSTEM_SYMPTOMS_DIR)) else {
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match toml::from_str::<SymptomFile>(&text) {
|
|
||||||
Ok(f) => {
|
|
||||||
out.insert(stem.to_string(), f.nodes);
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("breadhelp: {} failed to parse: {e}", path.display()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
mod cli;
|
|
||||||
mod config;
|
|
||||||
mod content;
|
|
||||||
mod services;
|
|
||||||
mod theme;
|
|
||||||
mod ui;
|
|
||||||
|
|
||||||
use gtk4::gio::ApplicationFlags;
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// GApplication requires a dotted id (g_application_id_is_valid rejects a
|
|
||||||
// bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting
|
|
||||||
// it, which would break D-Bus single-instance activation). The Wayland
|
|
||||||
// app-id GTK reports is this exact string — the Hyprland window rule and
|
|
||||||
// .desktop's StartupWMClass must match it verbatim.
|
|
||||||
//
|
|
||||||
// HANDLES_COMMAND_LINE: `breadhelp --onboard` while already running must
|
|
||||||
// reach the running (primary) instance's argv, not just re-activate it
|
|
||||||
// with no arguments — that's what lets a second launch re-trigger the
|
|
||||||
// onboarding tour instead of only focusing the window.
|
|
||||||
let app = gtk4::Application::builder()
|
|
||||||
.application_id("com.breadway.breadhelp")
|
|
||||||
.flags(ApplicationFlags::HANDLES_COMMAND_LINE)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
app.connect_command_line(|app, cmdline| {
|
|
||||||
let action = cli::parse(&cmdline.arguments());
|
|
||||||
ui::window::present(app, action);
|
|
||||||
glib::ExitCode::SUCCESS
|
|
||||||
});
|
|
||||||
|
|
||||||
// Best-effort revert of a tour step's temporarily-rebound keybind (see
|
|
||||||
// `ui::tour`'s crash-safety notes) on a clean exit — `rebind_temp` is a
|
|
||||||
// detached fire-and-forget `hyprctl` spawn, so it still completes even
|
|
||||||
// though this process is exiting right after.
|
|
||||||
app.connect_shutdown(|_| {
|
|
||||||
ui::tour::self_heal();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.run();
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
//! Maps a `--suggest <id>` payload (sent by a breadd Lua module, e.g.
|
|
||||||
//! `breadhelp-suggest.lua` reacting to `bread.monitor.connected`) to Home
|
|
||||||
//! tab banner text.
|
|
||||||
|
|
||||||
pub struct Suggestion {
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve(id: &str) -> Option<Suggestion> {
|
|
||||||
match id {
|
|
||||||
"monitor-setup" => Some(Suggestion {
|
|
||||||
text: "New monitor detected — want to set up your display layout?".to_string(),
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Fire-and-forget: runs `command` via a shell, matching the idiom already
|
|
||||||
/// used throughout bos-settings' views (`let _ = Command::new(...).spawn()`).
|
|
||||||
pub fn run(command: &str) {
|
|
||||||
if let Err(e) = Command::new("sh").arg("-c").arg(command).spawn() {
|
|
||||||
eprintln!("breadhelp: failed to run `{command}`: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs `command` on a background thread and delivers success/failure back
|
|
||||||
/// onto the GTK main loop via `on_done` — plain `spawn()` only confirms the
|
|
||||||
/// process *launched*, not how it exited, and one-click fixes need to report
|
|
||||||
/// that back to the user (a toast), so this blocks a throwaway thread on
|
|
||||||
/// `Command::status()` instead of the UI thread.
|
|
||||||
pub fn run_reporting(command: &str, on_done: impl Fn(bool) + 'static) {
|
|
||||||
let command = command.to_string();
|
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let status = Command::new("sh").arg("-c").arg(&command).status();
|
|
||||||
let _ = tx.send_blocking(status.map(|s| s.success()).unwrap_or(false));
|
|
||||||
});
|
|
||||||
glib::MainContext::default().spawn_local(async move {
|
|
||||||
if let Ok(success) = rx.recv().await {
|
|
||||||
on_done(success);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
use serde::Deserialize;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Runtime-only rebind via `hyprctl keyword bind`: takes effect immediately
|
|
||||||
/// and resets on the next Hyprland reload/login. `hyprland.lua` can't safely
|
|
||||||
/// be rewritten line-by-line by a program (hand-authored Lua, arbitrary
|
|
||||||
/// formatting), so this is the only rebind path — callers must label it
|
|
||||||
/// clearly as temporary in the UI.
|
|
||||||
///
|
|
||||||
/// `bind_value` is the full `<mods>,<key>,<dispatcher>,<args>` string, e.g.
|
|
||||||
/// `"SUPER,U,exec,breadpad"`.
|
|
||||||
pub fn rebind_temp(bind_value: &str) {
|
|
||||||
if let Err(e) = Command::new("hyprctl").args(["keyword", "bind", bind_value]).spawn() {
|
|
||||||
eprintln!("breadhelp: hyprctl rebind failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Client {
|
|
||||||
pub class: String,
|
|
||||||
pub at: (i32, i32),
|
|
||||||
pub size: (i32, i32),
|
|
||||||
/// Numeric monitor id — join against `monitors()` by `Monitor::id` for a name.
|
|
||||||
pub monitor: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct Monitor {
|
|
||||||
pub id: i32,
|
|
||||||
pub name: String,
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub width: i32,
|
|
||||||
pub height: i32,
|
|
||||||
pub focused: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LayerClient {
|
|
||||||
pub namespace: String,
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub w: i32,
|
|
||||||
pub h: i32,
|
|
||||||
pub monitor: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_json(cmd: &str) -> Option<serde_json::Value> {
|
|
||||||
let output = Command::new("hyprctl").args(["-j", cmd]).output().ok()?;
|
|
||||||
output.status.success().then(|| serde_json::from_slice(&output.stdout).ok()).flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clients() -> Vec<Client> {
|
|
||||||
query_json("clients").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn monitors() -> Vec<Monitor> {
|
|
||||||
query_json("monitors").and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `hyprctl -j layers` nests surfaces as `{monitor_name: {levels: {level: [surface]}}}`
|
|
||||||
/// with no fixed schema the `hyprland` crate or any sibling repo has a typed struct
|
|
||||||
/// for, so this walks the `serde_json::Value` by hand instead of deriving `Deserialize`.
|
|
||||||
pub fn layers() -> Vec<LayerClient> {
|
|
||||||
let Some(serde_json::Value::Object(by_monitor)) = query_json("layers") else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for (monitor_name, monitor_val) in by_monitor {
|
|
||||||
let Some(levels) = monitor_val.get("levels").and_then(|v| v.as_object()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for entries in levels.values().filter_map(|v| v.as_array()) {
|
|
||||||
for entry in entries {
|
|
||||||
let fields = (
|
|
||||||
entry.get("namespace").and_then(|v| v.as_str()),
|
|
||||||
entry.get("x").and_then(|v| v.as_i64()),
|
|
||||||
entry.get("y").and_then(|v| v.as_i64()),
|
|
||||||
entry.get("w").and_then(|v| v.as_i64()),
|
|
||||||
entry.get("h").and_then(|v| v.as_i64()),
|
|
||||||
);
|
|
||||||
if let (Some(namespace), Some(x), Some(y), Some(w), Some(h)) = fields {
|
|
||||||
out.push(LayerClient {
|
|
||||||
namespace: namespace.to_string(),
|
|
||||||
x: x as i32,
|
|
||||||
y: y as i32,
|
|
||||||
w: w as i32,
|
|
||||||
h: h as i32,
|
|
||||||
monitor: monitor_name.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
pub mod breadd;
|
|
||||||
pub mod exec;
|
|
||||||
pub mod hyprland;
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
//! Theming for breadhelp.
|
|
||||||
//!
|
|
||||||
//! Like bos-settings, breadhelp owns almost no styling: it loads the
|
|
||||||
//! ecosystem's shared stylesheet (generated by `bread-theme` from the pywal
|
|
||||||
//! palette) and layers only the layout rules specific to this app's tab
|
|
||||||
//! switcher + content shell on top.
|
|
||||||
|
|
||||||
use gtk4::CssProvider;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
|
|
||||||
const APP_CSS: &str = "\
|
|
||||||
.view-content { padding: 24px; }\n\
|
|
||||||
.view-content > label.title { margin-bottom: 16px; }\n\
|
|
||||||
/* [Show Keybind] chip: reads as a keycap inline with guide/keybind-viewer text. */\n\
|
|
||||||
label.keybind-chip { \
|
|
||||||
font-family: monospace; \
|
|
||||||
font-size: 11px; \
|
|
||||||
padding: 2px 6px; \
|
|
||||||
border-radius: 6px; \
|
|
||||||
background-color: alpha(@on-surface, 0.12); \
|
|
||||||
}\n\
|
|
||||||
/* Special modes (ui/modes.rs) — Beginner/Dad bump text and touch targets; \
|
|
||||||
Compact tightens padding. Content simplification (content.beginner.md) \
|
|
||||||
is a separate, content-level concern — this is visual only. */\n\
|
|
||||||
.beginner-mode label, .dad-mode label { font-size: 15px; }\n\
|
|
||||||
.dad-mode label { font-size: 17px; }\n\
|
|
||||||
.dad-mode button { min-height: 44px; padding: 10px 18px; }\n\
|
|
||||||
.beginner-mode button { min-height: 36px; }\n\
|
|
||||||
.compact-mode .view-content { padding: 12px; }\n\
|
|
||||||
/* ui::tour — mask strips dim everything except the spotlight cutout; the \
|
|
||||||
callout is a small floating card near (or, for no-target steps, centered \
|
|
||||||
over) the highlighted thing. The shared stylesheet's `window { \
|
|
||||||
background-color: @bg }` rule matches every GtkWindow including these — \
|
|
||||||
without this override GTK paints that opaque backdrop before the \
|
|
||||||
translucent mask box composites onto it, so the alpha never reaches the \
|
|
||||||
compositor and the \"dim\" reads as solid black. A class-qualified \
|
|
||||||
selector beats the shared rule's bare type selector on specificity \
|
|
||||||
regardless of stylesheet load order. */\n\
|
|
||||||
window.tour-window { background-color: transparent; }\n\
|
|
||||||
.tour-mask { background-color: rgba(0, 0, 0, 0.65); }\n\
|
|
||||||
.tour-callout { \
|
|
||||||
background-color: @surface; \
|
|
||||||
color: @on-surface; \
|
|
||||||
border-radius: 12px; \
|
|
||||||
padding: 16px 20px; \
|
|
||||||
}\n\
|
|
||||||
.tour-callout label.title { font-weight: bold; font-size: 15px; }\n\
|
|
||||||
/* The one payoff line in the whole tour — the user did the real thing and \
|
|
||||||
the compositor proved it — shouldn't read as the same throwaway meta \
|
|
||||||
text as the step counter. */\n\
|
|
||||||
.tour-confirmed { color: @green; font-weight: bold; }\n\
|
|
||||||
/* Extra top margin (on top of the callout's uniform 12px row gap) marks \
|
|
||||||
the \"Show me\" fallback as its own beat, not a continuation of the \
|
|
||||||
instructional paragraph above it. */\n\
|
|
||||||
.tour-hint-row { margin-top: 8px; }\n\
|
|
||||||
";
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(_display: >k4::gdk::Display) {
|
|
||||||
bread_theme::gtk::apply_shared();
|
|
||||||
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Label, Orientation, ScrolledWindow, Widget};
|
|
||||||
|
|
||||||
use crate::content::{keybinds::Keybind, markdown, ContentStore, Guide};
|
|
||||||
|
|
||||||
pub fn build(store: &ContentStore, guide: &Guide, simplified: bool, binds: &[Keybind]) -> Widget {
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
|
||||||
vbox.add_css_class("view-content");
|
|
||||||
|
|
||||||
let title = Label::new(Some(&guide.meta.title));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
vbox.append(&title);
|
|
||||||
|
|
||||||
let body = store.body(guide, simplified);
|
|
||||||
let blocks = markdown::parse(&body);
|
|
||||||
vbox.append(&markdown::render(&blocks, binds));
|
|
||||||
|
|
||||||
ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast()
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Align, ApplicationWindow, Box as GBox, Button, DropDown, FlowBox, Label, Orientation, Revealer,
|
|
||||||
RevealerTransitionType, ScrolledWindow, StringList, Widget,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::config::Mode;
|
|
||||||
use crate::content::keybinds::Keybind;
|
|
||||||
|
|
||||||
use super::{keybind_viewer, troubleshoot_wizard};
|
|
||||||
|
|
||||||
const FIX_SOUND: &str = "systemctl --user restart wireplumber pipewire pipewire.socket pipewire-pulse pipewire-pulse.socket";
|
|
||||||
const FIX_WIFI: &str = "nmcli radio wifi off && sleep 1 && nmcli radio wifi on";
|
|
||||||
const FIX_UPDATE: &str = "bos-update";
|
|
||||||
const FIX_BACKUP: &str = "snapper -c root create --description 'breadhelp manual backup'";
|
|
||||||
|
|
||||||
/// Handle back to the parts of Home that get updated after construction —
|
|
||||||
/// the suggestion banner (from breadd, see `services::breadd`) and the
|
|
||||||
/// one-click-fix toast, neither of which exist until the widget tree is
|
|
||||||
/// built once.
|
|
||||||
pub struct Home {
|
|
||||||
pub root: Widget,
|
|
||||||
banner_revealer: Revealer,
|
|
||||||
banner_label: Label,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Home {
|
|
||||||
pub fn set_suggestion(&self, text: Option<&str>) {
|
|
||||||
match text {
|
|
||||||
Some(t) => {
|
|
||||||
self.banner_label.set_label(t);
|
|
||||||
self.banner_revealer.set_reveal_child(true);
|
|
||||||
}
|
|
||||||
None => self.banner_revealer.set_reveal_child(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build(
|
|
||||||
binds: &[Keybind],
|
|
||||||
parent_window: &ApplicationWindow,
|
|
||||||
initial_mode: Mode,
|
|
||||||
on_mode_change: impl Fn(Mode) + 'static,
|
|
||||||
) -> Home {
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 16);
|
|
||||||
vbox.add_css_class("view-content");
|
|
||||||
|
|
||||||
let header = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
let title = Label::new(Some("Home"));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
title.set_hexpand(true);
|
|
||||||
header.append(&title);
|
|
||||||
header.append(&mode_selector(initial_mode, on_mode_change));
|
|
||||||
vbox.append(&header);
|
|
||||||
|
|
||||||
let banner_label = Label::new(None);
|
|
||||||
banner_label.set_wrap(true);
|
|
||||||
banner_label.set_xalign(0.0);
|
|
||||||
banner_label.set_hexpand(true);
|
|
||||||
let banner_dismiss = Button::from_icon_name("window-close-symbolic");
|
|
||||||
banner_dismiss.set_tooltip_text(Some("Dismiss"));
|
|
||||||
banner_dismiss.update_property(&[gtk4::accessible::Property::Label("Dismiss")]);
|
|
||||||
let banner_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
banner_row.add_css_class("card");
|
|
||||||
banner_row.set_margin_top(4);
|
|
||||||
banner_row.set_margin_bottom(4);
|
|
||||||
banner_row.append(&banner_label);
|
|
||||||
banner_row.append(&banner_dismiss);
|
|
||||||
let banner_revealer = Revealer::new();
|
|
||||||
banner_revealer.set_transition_type(RevealerTransitionType::SlideDown);
|
|
||||||
banner_revealer.set_child(Some(&banner_row));
|
|
||||||
banner_revealer.set_reveal_child(false);
|
|
||||||
{
|
|
||||||
let banner_revealer = banner_revealer.clone();
|
|
||||||
banner_dismiss.connect_clicked(move |_| banner_revealer.set_reveal_child(false));
|
|
||||||
}
|
|
||||||
vbox.append(&banner_revealer);
|
|
||||||
|
|
||||||
vbox.append(&quick_fixes());
|
|
||||||
|
|
||||||
let troubleshoot_btn = Button::with_label("Something's wrong?");
|
|
||||||
troubleshoot_btn.set_halign(Align::Start);
|
|
||||||
{
|
|
||||||
let parent = parent_window.clone();
|
|
||||||
troubleshoot_btn.connect_clicked(move |_| troubleshoot_wizard::open(&parent));
|
|
||||||
}
|
|
||||||
vbox.append(&troubleshoot_btn);
|
|
||||||
|
|
||||||
// The live guided tour (ui::tour) is the real hands-on onboarding now —
|
|
||||||
// a static text preview of the same guide titles Learn already lists
|
|
||||||
// was a fossil of the old in-window wizard and just duplicated Learn's
|
|
||||||
// sidebar. A single re-entry point is all this tab needs.
|
|
||||||
let replay_btn = Button::with_label("Replay the tour");
|
|
||||||
replay_btn.set_halign(Align::Start);
|
|
||||||
{
|
|
||||||
let parent = parent_window.clone();
|
|
||||||
replay_btn.connect_clicked(move |_| {
|
|
||||||
crate::ui::tour::restart(&WidgetExt::display(&parent));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
vbox.append(&replay_btn);
|
|
||||||
|
|
||||||
vbox.append(&keybind_viewer::build(binds));
|
|
||||||
|
|
||||||
let root = ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast();
|
|
||||||
|
|
||||||
Home { root, banner_revealer, banner_label }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn quick_fixes() -> Widget {
|
|
||||||
let heading_box = GBox::new(Orientation::Vertical, 8);
|
|
||||||
|
|
||||||
let heading = Label::new(Some("Quick fixes"));
|
|
||||||
heading.add_css_class("section-header");
|
|
||||||
heading.set_xalign(0.0);
|
|
||||||
heading_box.append(&heading);
|
|
||||||
|
|
||||||
let flow = FlowBox::new();
|
|
||||||
flow.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
flow.set_max_children_per_line(4);
|
|
||||||
flow.set_column_spacing(8);
|
|
||||||
flow.set_row_spacing(8);
|
|
||||||
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.set_xalign(0.0);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
|
|
||||||
for (label, command) in [
|
|
||||||
("Fix sound", FIX_SOUND),
|
|
||||||
("Reconnect Wi-Fi", FIX_WIFI),
|
|
||||||
("Update everything", FIX_UPDATE),
|
|
||||||
("Create backup", FIX_BACKUP),
|
|
||||||
] {
|
|
||||||
let btn = Button::with_label(label);
|
|
||||||
let command = command.to_string();
|
|
||||||
let status = status.clone();
|
|
||||||
let label = label.to_string();
|
|
||||||
btn.connect_clicked(move |_| {
|
|
||||||
status.set_label(&format!("{label}\u{2026}"));
|
|
||||||
let status = status.clone();
|
|
||||||
let label = label.clone();
|
|
||||||
crate::services::exec::run_reporting(&command, move |success| {
|
|
||||||
status.set_label(&if success { format!("{label}: done.") } else { format!("{label}: something went wrong.") });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
flow.insert(&btn, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
heading_box.append(&flow);
|
|
||||||
heading_box.append(&status);
|
|
||||||
heading_box.upcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mode_selector(initial: Mode, on_change: impl Fn(Mode) + 'static) -> DropDown {
|
|
||||||
let labels = StringList::new(&["Normal", "Beginner", "Dad", "Compact"]);
|
|
||||||
let dropdown = DropDown::new(Some(labels), gtk4::Expression::NONE);
|
|
||||||
dropdown.set_selected(mode_index(initial));
|
|
||||||
dropdown.set_tooltip_text(Some("Display mode"));
|
|
||||||
dropdown.update_property(&[gtk4::accessible::Property::Label("Display mode")]);
|
|
||||||
dropdown.connect_selected_notify(move |dd| {
|
|
||||||
let mode = match dd.selected() {
|
|
||||||
1 => Mode::Beginner,
|
|
||||||
2 => Mode::Dad,
|
|
||||||
3 => Mode::Compact,
|
|
||||||
_ => Mode::Normal,
|
|
||||||
};
|
|
||||||
on_change(mode);
|
|
||||||
});
|
|
||||||
dropdown
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mode_index(mode: Mode) -> u32 {
|
|
||||||
match mode {
|
|
||||||
Mode::Normal => 0,
|
|
||||||
Mode::Beginner => 1,
|
|
||||||
Mode::Dad => 2,
|
|
||||||
Mode::Compact => 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, MenuButton, Orientation, Popover, ScrolledWindow, SelectionMode};
|
|
||||||
|
|
||||||
use crate::content::keybinds::{self, Keybind};
|
|
||||||
|
|
||||||
pub fn build(binds: &[Keybind]) -> gtk4::Widget {
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 6);
|
|
||||||
|
|
||||||
let header = Label::new(Some("Keybinds"));
|
|
||||||
header.add_css_class("title");
|
|
||||||
header.set_xalign(0.0);
|
|
||||||
vbox.append(&header);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(SelectionMode::None);
|
|
||||||
list.add_css_class("boxed-list");
|
|
||||||
|
|
||||||
if binds.is_empty() {
|
|
||||||
let empty = Label::new(Some("No binds.json found at ~/.config/hypr/binds.json"));
|
|
||||||
empty.add_css_class("dim-label");
|
|
||||||
vbox.append(&empty);
|
|
||||||
return vbox.upcast();
|
|
||||||
}
|
|
||||||
|
|
||||||
for bind in binds {
|
|
||||||
list.append(&row(bind));
|
|
||||||
}
|
|
||||||
|
|
||||||
let scroller = ScrolledWindow::builder().min_content_height(320).vexpand(true).child(&list).build();
|
|
||||||
vbox.append(&scroller);
|
|
||||||
vbox.upcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn row(bind: &Keybind) -> ListBoxRow {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 12);
|
|
||||||
hbox.set_margin_top(6);
|
|
||||||
hbox.set_margin_bottom(6);
|
|
||||||
hbox.set_margin_start(8);
|
|
||||||
hbox.set_margin_end(8);
|
|
||||||
|
|
||||||
let combo = Label::new(Some(&bind.combo));
|
|
||||||
combo.add_css_class("keybind-chip");
|
|
||||||
combo.set_width_chars(20);
|
|
||||||
combo.set_xalign(0.0);
|
|
||||||
hbox.append(&combo);
|
|
||||||
|
|
||||||
let action = Label::new(Some(&bind.action));
|
|
||||||
action.set_hexpand(true);
|
|
||||||
action.set_xalign(0.0);
|
|
||||||
action.set_wrap(true);
|
|
||||||
hbox.append(&action);
|
|
||||||
|
|
||||||
if let Some(demo) = &bind.demo_cmd {
|
|
||||||
let try_btn = Button::with_label("Try it");
|
|
||||||
let cmd = demo.clone();
|
|
||||||
try_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
|
|
||||||
hbox.append(&try_btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(raw) = bind.raw.clone() {
|
|
||||||
let rebind_btn = MenuButton::builder().icon_name("document-edit-symbolic").build();
|
|
||||||
rebind_btn.set_tooltip_text(Some("Temporary rebind"));
|
|
||||||
// Icon-only button — screen readers have nothing else to announce.
|
|
||||||
rebind_btn.update_property(&[gtk4::accessible::Property::Label("Temporary rebind")]);
|
|
||||||
rebind_btn.set_popover(Some(&rebind_popover(&raw)));
|
|
||||||
hbox.append(&rebind_btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
row
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebind_popover(raw: &str) -> Popover {
|
|
||||||
let popover = Popover::new();
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 8);
|
|
||||||
vbox.set_margin_top(10);
|
|
||||||
vbox.set_margin_bottom(10);
|
|
||||||
vbox.set_margin_start(10);
|
|
||||||
vbox.set_margin_end(10);
|
|
||||||
|
|
||||||
let hint = Label::new(Some("Temporary — resets on reload/login.\nEdit hyprland.lua for a permanent change."));
|
|
||||||
hint.add_css_class("dim-label");
|
|
||||||
hint.set_wrap(true);
|
|
||||||
vbox.append(&hint);
|
|
||||||
|
|
||||||
let entry = Entry::new();
|
|
||||||
entry.set_placeholder_text(Some("e.g. super + shift + u"));
|
|
||||||
vbox.append(&entry);
|
|
||||||
|
|
||||||
let apply = Button::with_label("Apply temporarily");
|
|
||||||
apply.add_css_class("suggested-action");
|
|
||||||
let raw = raw.to_string();
|
|
||||||
let popover_weak = popover.downgrade();
|
|
||||||
apply.connect_clicked(move |_| {
|
|
||||||
let combo = entry.text();
|
|
||||||
if combo.trim().is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mods_key = keybinds::to_hypr_mods_key(&combo);
|
|
||||||
if mods_key.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
crate::services::hyprland::rebind_temp(&format!("{mods_key},{raw}"));
|
|
||||||
if let Some(p) = popover_weak.upgrade() {
|
|
||||||
p.popdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
vbox.append(&apply);
|
|
||||||
|
|
||||||
popover.set_child(Some(&vbox));
|
|
||||||
popover
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Label, ListBox, ListBoxRow, Orientation, Paned, ScrolledWindow, SelectionMode, Stack, Widget};
|
|
||||||
|
|
||||||
use crate::config::Mode;
|
|
||||||
use crate::content::{keybinds::Keybind, ContentStore};
|
|
||||||
|
|
||||||
use super::guide_view;
|
|
||||||
|
|
||||||
pub fn build(store: &ContentStore, binds: &[Keybind], mode: Mode) -> Widget {
|
|
||||||
let hpaned = Paned::new(Orientation::Horizontal);
|
|
||||||
hpaned.set_position(220);
|
|
||||||
hpaned.set_shrink_start_child(false);
|
|
||||||
hpaned.set_resize_start_child(false);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(SelectionMode::Single);
|
|
||||||
list.add_css_class("sidebar");
|
|
||||||
|
|
||||||
let stack = Stack::new();
|
|
||||||
stack.set_hexpand(true);
|
|
||||||
stack.set_vexpand(true);
|
|
||||||
|
|
||||||
let mut first_page: Option<String> = None;
|
|
||||||
|
|
||||||
for category in store.categories() {
|
|
||||||
let header_row = ListBoxRow::new();
|
|
||||||
header_row.set_selectable(false);
|
|
||||||
header_row.set_activatable(false);
|
|
||||||
let header_lbl = Label::new(Some(&display_category(&category)));
|
|
||||||
header_lbl.add_css_class("section-header");
|
|
||||||
header_lbl.set_xalign(0.0);
|
|
||||||
header_row.set_child(Some(&header_lbl));
|
|
||||||
list.append(&header_row);
|
|
||||||
|
|
||||||
for guide in store.guides_in(&category) {
|
|
||||||
let page_name = format!("{}/{}", guide.category, guide.id);
|
|
||||||
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_widget_name(&page_name);
|
|
||||||
let lbl = Label::new(Some(&guide.meta.title));
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
lbl.set_margin_top(4);
|
|
||||||
lbl.set_margin_bottom(4);
|
|
||||||
lbl.set_margin_start(8);
|
|
||||||
row.set_child(Some(&lbl));
|
|
||||||
list.append(&row);
|
|
||||||
|
|
||||||
let view = guide_view::build(store, guide, mode.is_simplified(), binds);
|
|
||||||
stack.add_named(&view, Some(&page_name));
|
|
||||||
if first_page.is_none() {
|
|
||||||
first_page = Some(page_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(first) = &first_page {
|
|
||||||
stack.set_visible_child_name(first);
|
|
||||||
let mut i = 0;
|
|
||||||
loop {
|
|
||||||
match list.row_at_index(i) {
|
|
||||||
None => break,
|
|
||||||
Some(row) if row.widget_name() == *first => {
|
|
||||||
list.select_row(Some(&row));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => i += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let stack = stack.clone();
|
|
||||||
list.connect_row_selected(move |_, row| {
|
|
||||||
if let Some(row) = row {
|
|
||||||
let name = row.widget_name();
|
|
||||||
if !name.is_empty() {
|
|
||||||
stack.set_visible_child_name(&name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let sidebar_scroller = ScrolledWindow::builder().child(&list).width_request(220).build();
|
|
||||||
hpaned.set_start_child(Some(&sidebar_scroller));
|
|
||||||
hpaned.set_end_child(Some(&stack));
|
|
||||||
|
|
||||||
hpaned.upcast()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn display_category(id: &str) -> String {
|
|
||||||
id.split('-').map(capitalize).collect::<Vec<_>>().join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capitalize(s: &str) -> String {
|
|
||||||
let mut chars = s.chars();
|
|
||||||
match chars.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
pub mod ask;
|
|
||||||
pub mod guide_view;
|
|
||||||
pub mod home;
|
|
||||||
pub mod keybind_viewer;
|
|
||||||
pub mod learn;
|
|
||||||
pub mod modes;
|
|
||||||
pub mod tabs;
|
|
||||||
pub mod tour;
|
|
||||||
pub mod troubleshoot_wizard;
|
|
||||||
pub mod window;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::Widget;
|
|
||||||
|
|
||||||
use crate::config::Mode;
|
|
||||||
|
|
||||||
const ALL_CLASSES: &[&str] = &["beginner-mode", "dad-mode", "compact-mode"];
|
|
||||||
|
|
||||||
/// Swaps the mode CSS class on `root` — the actual layout rules (font-size
|
|
||||||
/// bumps, larger touch targets for Dad, tighter margins for Compact) live in
|
|
||||||
/// `theme.rs`'s `APP_CSS`, same "app owns only layout" split as
|
|
||||||
/// `bos-settings/src/theme.rs`. Applied to the window's root widget so it
|
|
||||||
/// affects every tab, not just Home.
|
|
||||||
pub fn apply(root: &impl IsA<Widget>, mode: Mode) {
|
|
||||||
let root = root.upcast_ref::<Widget>();
|
|
||||||
for class in ALL_CLASSES {
|
|
||||||
root.remove_css_class(class);
|
|
||||||
}
|
|
||||||
let class = match mode {
|
|
||||||
Mode::Normal => return,
|
|
||||||
Mode::Beginner => "beginner-mode",
|
|
||||||
Mode::Dad => "dad-mode",
|
|
||||||
Mode::Compact => "compact-mode",
|
|
||||||
};
|
|
||||||
root.add_css_class(class);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Align, Stack, StackSwitcher};
|
|
||||||
|
|
||||||
/// Home/Learn/Ask tab switcher. Only 3 pages, so GTK's built-in
|
|
||||||
/// `StackSwitcher` is a better fit here than the grouped-`ListBox` sidebar
|
|
||||||
/// bos-settings uses for its ~19 settings pages.
|
|
||||||
pub fn build(stack: &Stack) -> StackSwitcher {
|
|
||||||
let switcher = StackSwitcher::new();
|
|
||||||
switcher.set_stack(Some(stack));
|
|
||||||
switcher.set_halign(Align::Center);
|
|
||||||
switcher.set_margin_top(8);
|
|
||||||
switcher.set_margin_bottom(8);
|
|
||||||
switcher
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
//! The floating explanation bubble: title/body (rendered through the same
|
|
||||||
//! constrained markdown subset guides use, so `[Show Keybind]`/`[Run]` tags
|
|
||||||
//! work here too), dot progress, Back/Next. Positioned just below (or, if
|
|
||||||
//! that would run off the bottom of the screen, above) the spotlighted
|
|
||||||
//! target; a `Target::None` step gets no anchors at all, which wlr-layer-
|
|
||||||
//! shell/gtk4-layer-shell centers on the monitor by convention.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Button, Label, Orientation, Window};
|
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
||||||
|
|
||||||
use crate::content::keybinds::Keybind;
|
|
||||||
use crate::content::markdown;
|
|
||||||
use crate::content::tour::Step;
|
|
||||||
|
|
||||||
use super::target::Rect;
|
|
||||||
|
|
||||||
pub struct Callout {
|
|
||||||
pub window: Window,
|
|
||||||
pub back_btn: Button,
|
|
||||||
pub next_btn: Button,
|
|
||||||
pub skip_btn: Button,
|
|
||||||
pub demo_btn: Option<Button>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const CALLOUT_WIDTH: i32 = 360;
|
|
||||||
/// Rough height estimate used only to decide "does this fit below the
|
|
||||||
/// target" before the widget has actually been laid out — doesn't need to
|
|
||||||
/// be exact, just enough to avoid running off the bottom of the screen.
|
|
||||||
const ESTIMATED_HEIGHT: i32 = 220;
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn build(
|
|
||||||
monitor: &gdk4::Monitor,
|
|
||||||
rect: Option<Rect>,
|
|
||||||
monitor_size: (i32, i32),
|
|
||||||
step: &Step,
|
|
||||||
binds: &[Keybind],
|
|
||||||
step_index: usize,
|
|
||||||
total: usize,
|
|
||||||
is_last: bool,
|
|
||||||
confirmed: bool,
|
|
||||||
show_demo: bool,
|
|
||||||
) -> Callout {
|
|
||||||
let window = Window::new();
|
|
||||||
window.set_decorated(false);
|
|
||||||
window.add_css_class("tour-window");
|
|
||||||
window.init_layer_shell();
|
|
||||||
window.set_layer(Layer::Overlay);
|
|
||||||
window.set_keyboard_mode(KeyboardMode::OnDemand);
|
|
||||||
window.set_monitor(Some(monitor));
|
|
||||||
|
|
||||||
if let Some(rect) = rect {
|
|
||||||
let (mon_w, mon_h) = monitor_size;
|
|
||||||
window.set_anchor(Edge::Left, true);
|
|
||||||
window.set_anchor(Edge::Top, true);
|
|
||||||
let margin_left = (rect.x).clamp(20, (mon_w - CALLOUT_WIDTH - 20).max(20));
|
|
||||||
let below = rect.y + rect.h + 12;
|
|
||||||
let margin_top = if below + ESTIMATED_HEIGHT <= mon_h {
|
|
||||||
below
|
|
||||||
} else {
|
|
||||||
(rect.y - 12 - ESTIMATED_HEIGHT).max(20)
|
|
||||||
};
|
|
||||||
window.set_margin(Edge::Left, margin_left);
|
|
||||||
window.set_margin(Edge::Top, margin_top);
|
|
||||||
}
|
|
||||||
// No anchors for a `Target::None` step — left to the compositor, which
|
|
||||||
// centers unanchored layer surfaces.
|
|
||||||
|
|
||||||
let root = GBox::new(Orientation::Vertical, 12);
|
|
||||||
root.add_css_class("tour-callout");
|
|
||||||
root.set_size_request(CALLOUT_WIDTH, -1);
|
|
||||||
|
|
||||||
let title = Label::new(Some(&step.title));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
title.set_wrap(true);
|
|
||||||
root.append(&title);
|
|
||||||
|
|
||||||
let body = markdown::render(&markdown::parse(&step.body), binds);
|
|
||||||
root.append(&body);
|
|
||||||
|
|
||||||
if confirmed {
|
|
||||||
let done = Label::new(Some("\u{2713} Nice work — click Next to continue."));
|
|
||||||
done.add_css_class("tour-confirmed");
|
|
||||||
done.set_xalign(0.0);
|
|
||||||
root.append(&done);
|
|
||||||
}
|
|
||||||
|
|
||||||
let demo_btn = if show_demo {
|
|
||||||
let hint_row = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
hint_row.add_css_class("tour-hint-row");
|
|
||||||
let hint_label = Label::new(Some("Still there?"));
|
|
||||||
hint_label.add_css_class("dim-label");
|
|
||||||
hint_label.set_hexpand(true);
|
|
||||||
hint_label.set_xalign(0.0);
|
|
||||||
let btn = Button::with_label("Show me");
|
|
||||||
hint_row.append(&hint_label);
|
|
||||||
hint_row.append(&btn);
|
|
||||||
root.append(&hint_row);
|
|
||||||
Some(btn)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Text only, not a dot row too — a separate row of dots next to this
|
|
||||||
// said the identical thing twice with no added legibility.
|
|
||||||
let counter = Label::new(Some(&format!("Step {} of {total}", step_index + 1)));
|
|
||||||
counter.add_css_class("dim-label");
|
|
||||||
counter.set_xalign(0.0);
|
|
||||||
root.append(&counter);
|
|
||||||
|
|
||||||
let nav = GBox::new(Orientation::Horizontal, 8);
|
|
||||||
let skip_btn = Button::with_label("Skip tour");
|
|
||||||
skip_btn.add_css_class("flat");
|
|
||||||
let back_btn = Button::with_label("Back");
|
|
||||||
back_btn.set_sensitive(step_index > 0);
|
|
||||||
let spacer = GBox::new(Orientation::Horizontal, 0);
|
|
||||||
spacer.set_hexpand(true);
|
|
||||||
let next_btn = Button::with_label(if is_last { "Finish" } else { "Next" });
|
|
||||||
next_btn.add_css_class("suggested-action");
|
|
||||||
nav.append(&skip_btn);
|
|
||||||
nav.append(&spacer);
|
|
||||||
nav.append(&back_btn);
|
|
||||||
nav.append(&next_btn);
|
|
||||||
root.append(&nav);
|
|
||||||
|
|
||||||
window.set_child(Some(&root));
|
|
||||||
window.present();
|
|
||||||
if confirmed {
|
|
||||||
next_btn.grab_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
Callout { window, back_btn, next_btn, skip_btn, demo_btn }
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
//! The spotlight: up to 4 `Layer::Overlay` strip windows (top/bottom/left/
|
|
||||||
//! right) sized to leave a target's bounding box as a real gap with no
|
|
||||||
//! surface over it at all — gtk4-layer-shell has no click-through/input-
|
|
||||||
//! region API (confirmed in its own docs), so the only way to let pointer
|
|
||||||
//! input reach the real UI underneath is to literally not cover it with
|
|
||||||
//! anything. A `Target::None` step instead gets one full-screen dim window,
|
|
||||||
//! since there's nothing to leave a gap around.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Box as GBox, Window};
|
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
||||||
|
|
||||||
use super::target::Rect;
|
|
||||||
|
|
||||||
fn new_mask_window(monitor: &gdk4::Monitor) -> Window {
|
|
||||||
let window = Window::new();
|
|
||||||
window.set_decorated(false);
|
|
||||||
window.add_css_class("tour-window");
|
|
||||||
window.init_layer_shell();
|
|
||||||
window.set_layer(Layer::Overlay);
|
|
||||||
window.set_keyboard_mode(KeyboardMode::None);
|
|
||||||
window.set_monitor(Some(monitor));
|
|
||||||
let dim = GBox::new(gtk4::Orientation::Vertical, 0);
|
|
||||||
dim.add_css_class("tour-mask");
|
|
||||||
dim.set_hexpand(true);
|
|
||||||
dim.set_vexpand(true);
|
|
||||||
window.set_child(Some(&dim));
|
|
||||||
window
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One dim window covering the whole monitor — used for `Target::None` steps.
|
|
||||||
pub fn full_screen(monitor: &gdk4::Monitor) -> Vec<Window> {
|
|
||||||
let window = new_mask_window(monitor);
|
|
||||||
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
|
||||||
window.set_anchor(edge, true);
|
|
||||||
}
|
|
||||||
window.present();
|
|
||||||
vec![window]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The 4-strip letterbox around `rect`, in monitor-local coordinates.
|
|
||||||
/// `monitor_size` is `(width, height)` from `gdk4::Monitor::geometry()`.
|
|
||||||
pub fn around(monitor: &gdk4::Monitor, rect: Rect, monitor_size: (i32, i32)) -> Vec<Window> {
|
|
||||||
let (mon_w, mon_h) = monitor_size;
|
|
||||||
let mut windows = Vec::new();
|
|
||||||
|
|
||||||
let top_h = rect.y;
|
|
||||||
if top_h > 0 {
|
|
||||||
let w = new_mask_window(monitor);
|
|
||||||
w.set_anchor(Edge::Top, true);
|
|
||||||
w.set_anchor(Edge::Left, true);
|
|
||||||
w.set_anchor(Edge::Right, true);
|
|
||||||
w.set_default_size(-1, top_h);
|
|
||||||
w.present();
|
|
||||||
windows.push(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
let bottom_h = mon_h - (rect.y + rect.h);
|
|
||||||
if bottom_h > 0 {
|
|
||||||
let w = new_mask_window(monitor);
|
|
||||||
w.set_anchor(Edge::Bottom, true);
|
|
||||||
w.set_anchor(Edge::Left, true);
|
|
||||||
w.set_anchor(Edge::Right, true);
|
|
||||||
w.set_default_size(-1, bottom_h);
|
|
||||||
w.present();
|
|
||||||
windows.push(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
let left_w = rect.x;
|
|
||||||
if left_w > 0 {
|
|
||||||
let w = new_mask_window(monitor);
|
|
||||||
w.set_anchor(Edge::Left, true);
|
|
||||||
w.set_anchor(Edge::Top, true);
|
|
||||||
w.set_margin(Edge::Top, rect.y);
|
|
||||||
w.set_default_size(left_w, rect.h);
|
|
||||||
w.present();
|
|
||||||
windows.push(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
let right_w = mon_w - (rect.x + rect.w);
|
|
||||||
if right_w > 0 {
|
|
||||||
let w = new_mask_window(monitor);
|
|
||||||
w.set_anchor(Edge::Right, true);
|
|
||||||
w.set_anchor(Edge::Top, true);
|
|
||||||
w.set_margin(Edge::Top, rect.y);
|
|
||||||
w.set_default_size(right_w, rect.h);
|
|
||||||
w.present();
|
|
||||||
windows.push(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
windows
|
|
||||||
}
|
|
||||||
|
|
@ -1,456 +0,0 @@
|
||||||
//! The live guided tour: a screen-wide overlay (layer-shell mask strips +
|
|
||||||
//! a floating callout, see `mask`/`callout`) that walks the user through
|
|
||||||
//! real on-screen components instead of describing them in a window of
|
|
||||||
//! breadhelp's own. Replaces the old in-window `Onboarding` wizard.
|
|
||||||
//!
|
|
||||||
//! Steps are user-paced, so the engine always tears down and rebuilds the 5
|
|
||||||
//! layer-shell windows on every transition rather than repositioning them in
|
|
||||||
//! place — simpler and cheap enough at this frequency.
|
|
||||||
|
|
||||||
mod callout;
|
|
||||||
mod mask;
|
|
||||||
mod target;
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::Window;
|
|
||||||
|
|
||||||
use crate::config::State;
|
|
||||||
use crate::content::keybinds::{self, Keybind};
|
|
||||||
use crate::content::tour::{self, Step, Success};
|
|
||||||
use crate::services::{exec, hyprland};
|
|
||||||
|
|
||||||
use callout::Callout;
|
|
||||||
|
|
||||||
/// How long a step with no detected target waits before offering a "Show
|
|
||||||
/// me" fallback button. Deliberately NOT a timer that fires the demo
|
|
||||||
/// automatically — an auto-fired demo can't be told apart from the user's
|
|
||||||
/// own keypress from their side, silently teaching them nothing while
|
|
||||||
/// looking like it worked. The button preserves that the user chose it.
|
|
||||||
const HINT_DELAY_SECS: u32 = 10;
|
|
||||||
const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(400);
|
|
||||||
|
|
||||||
struct TourState {
|
|
||||||
steps: Vec<Step>,
|
|
||||||
binds: Vec<Keybind>,
|
|
||||||
display: gdk4::Display,
|
|
||||||
index: usize,
|
|
||||||
masks: Vec<Window>,
|
|
||||||
callout: Option<Callout>,
|
|
||||||
poll_source: Option<glib::SourceId>,
|
|
||||||
timeout_source: Option<glib::SourceId>,
|
|
||||||
hint_source: Option<glib::SourceId>,
|
|
||||||
/// Whether the current step's `success_event` has already fired. Doesn't
|
|
||||||
/// auto-advance — just switches the callout into a "done, click Next
|
|
||||||
/// when ready" state, so the user actually has time to read the step
|
|
||||||
/// instead of it vanishing the instant the target event arrives.
|
|
||||||
confirmed: bool,
|
|
||||||
/// Whether the "Show me" fallback button has appeared for this step yet
|
|
||||||
/// (see `HINT_DELAY_SECS`).
|
|
||||||
hint_visible: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static STATE: RefCell<Option<TourState>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Begin the tour from wherever `state.toml`'s `onboarding.step` left off —
|
|
||||||
/// the every-login autostart's genuine-first-run trigger.
|
|
||||||
pub fn start(display: &gdk4::Display) {
|
|
||||||
self_heal();
|
|
||||||
let steps = tour::load();
|
|
||||||
if steps.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let start_index = (State::load().onboarding_step() as usize).min(steps.len() - 1);
|
|
||||||
STATE.with(|cell| {
|
|
||||||
*cell.borrow_mut() = Some(TourState {
|
|
||||||
steps,
|
|
||||||
binds: keybinds::load(),
|
|
||||||
display: display.clone(),
|
|
||||||
index: 0,
|
|
||||||
masks: Vec::new(),
|
|
||||||
callout: None,
|
|
||||||
poll_source: None,
|
|
||||||
timeout_source: None,
|
|
||||||
hint_source: None,
|
|
||||||
confirmed: false,
|
|
||||||
hint_visible: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
enter_step(start_index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `breadhelp --onboard` — restart from step 0 regardless of prior progress.
|
|
||||||
pub fn restart(display: &gdk4::Display) {
|
|
||||||
let mut state = State::load();
|
|
||||||
state.set_onboarding_completed(false);
|
|
||||||
state.set_onboarding_step(0);
|
|
||||||
start(display);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reverts a keybind left rebound by a crashed previous run before it can
|
|
||||||
/// surprise the user (e.g. popping breadhelp open on a later, unrelated
|
|
||||||
/// press) — see `config::State::pending_rebind`. Safe to call any time,
|
|
||||||
/// including when nothing is pending.
|
|
||||||
pub fn self_heal() {
|
|
||||||
revert_pending_rebind();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `--tour-event <id>` arrived, forwarded by `breadhelp-tour.lua` over the
|
|
||||||
/// same D-Bus argv-forwarding `--suggest` already uses. Hard no-op unless
|
|
||||||
/// the active tour's current step is actually waiting for this exact id —
|
|
||||||
/// a stray/late event must never be able to do anything visible. Does NOT
|
|
||||||
/// auto-advance to the next step — it confirms the current one (so the
|
|
||||||
/// callout can show a "done" state) and leaves moving on to an explicit
|
|
||||||
/// Next click, since jumping topics the instant the target event fires
|
|
||||||
/// gives the user no time to actually read anything about it.
|
|
||||||
pub fn on_tour_event(id: &str) {
|
|
||||||
let hit = STATE.with(|cell| {
|
|
||||||
let mut borrow = cell.borrow_mut();
|
|
||||||
let Some(state) = borrow.as_mut() else { return None };
|
|
||||||
// Only a *visibly* confirmed step blocks a re-fire. `confirmed` gets
|
|
||||||
// set below, before `render_step` runs, so that render actually
|
|
||||||
// paints the "done" state instead of the stale pre-confirmation one
|
|
||||||
// — but that means a step whose render silently no-ops (e.g. the
|
|
||||||
// near-fullscreen suppression branch racing a `closelayer` event)
|
|
||||||
// would otherwise latch `confirmed = true` with no callout to show
|
|
||||||
// for it, permanently blocking every later retry. Checking
|
|
||||||
// `callout.is_some()` alongside `confirmed` is what actually
|
|
||||||
// prevents that: a confirmed-but-invisible step is not treated as
|
|
||||||
// handled, so a later duplicate event gets another chance.
|
|
||||||
if state.confirmed && state.callout.is_some() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let matches = matches!(&state.steps[state.index].success(), Success::Event(e) if e == id);
|
|
||||||
if !matches {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
state.confirmed = true;
|
|
||||||
Some((state.index, state.steps[state.index].clone(), state.binds.clone(), state.steps.len()))
|
|
||||||
});
|
|
||||||
if let Some((index, step, binds, total)) = hit {
|
|
||||||
// force_no_target=true: we already have authoritative confirmation
|
|
||||||
// from the real compositor event that fired this — see the comment
|
|
||||||
// in `render_step` on why re-querying live state here is unsafe.
|
|
||||||
render_step(index, &step, &binds, index + 1 >= total, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn go_next() {
|
|
||||||
let next = STATE.with(|cell| cell.borrow().as_ref().map(|s| (s.index + 1, s.steps.len())));
|
|
||||||
let Some((next_index, total)) = next else { return };
|
|
||||||
if next_index >= total {
|
|
||||||
finish();
|
|
||||||
} else {
|
|
||||||
enter_step(next_index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn go_back() {
|
|
||||||
let index = STATE.with(|cell| cell.borrow().as_ref().map(|s| s.index));
|
|
||||||
let Some(index) = index else { return };
|
|
||||||
if index > 0 {
|
|
||||||
enter_step(index - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish() {
|
|
||||||
revert_pending_rebind();
|
|
||||||
teardown_visuals();
|
|
||||||
let mut state = State::load();
|
|
||||||
state.set_onboarding_completed(true);
|
|
||||||
STATE.with(|cell| *cell.borrow_mut() = None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn teardown_visuals() {
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(state) = cell.borrow_mut().as_mut() {
|
|
||||||
if let Some(id) = state.poll_source.take() {
|
|
||||||
id.remove();
|
|
||||||
}
|
|
||||||
if let Some(id) = state.timeout_source.take() {
|
|
||||||
id.remove();
|
|
||||||
}
|
|
||||||
if let Some(id) = state.hint_source.take() {
|
|
||||||
id.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
clear_visuals();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Just the mask + callout windows — never touches `poll_source`/
|
|
||||||
/// `timeout_source`, so this is safe to call from inside their own
|
|
||||||
/// still-executing callbacks (unlike `teardown_visuals`).
|
|
||||||
fn clear_visuals() {
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(state) = cell.borrow_mut().as_mut() {
|
|
||||||
for w in state.masks.drain(..) {
|
|
||||||
w.close();
|
|
||||||
}
|
|
||||||
if let Some(c) = state.callout.take() {
|
|
||||||
c.window.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_step(index: usize) {
|
|
||||||
revert_pending_rebind();
|
|
||||||
teardown_visuals();
|
|
||||||
|
|
||||||
let (step, binds, total) = STATE.with(|cell| {
|
|
||||||
let mut borrow = cell.borrow_mut();
|
|
||||||
let state = borrow.as_mut().expect("enter_step called with no active tour");
|
|
||||||
state.index = index;
|
|
||||||
state.confirmed = false;
|
|
||||||
state.hint_visible = false;
|
|
||||||
(state.steps[index].clone(), state.binds.clone(), state.steps.len())
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut cfg = State::load();
|
|
||||||
cfg.set_onboarding_step(index as i64);
|
|
||||||
|
|
||||||
render_step(index, &step, &binds, index + 1 >= total, false);
|
|
||||||
|
|
||||||
// Watch for the target appearing from the user's OWN action (pressing
|
|
||||||
// the real shortcut) — never fire `launch` here. A step that teaches a
|
|
||||||
// shortcut only actually teaches it if the user is the one who presses
|
|
||||||
// it; see `HINT_DELAY_SECS` for the deliberate, user-initiated fallback.
|
|
||||||
if !target::is_resolved(&step.target()) {
|
|
||||||
schedule_poll(index);
|
|
||||||
if step.launch.is_some() {
|
|
||||||
schedule_hint(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if step.rebind_combo.is_some() {
|
|
||||||
apply_rebind_for_step(&step, &binds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Success::Timeout(secs) = step.success() {
|
|
||||||
let source = glib::timeout_add_seconds_local(secs as u32, move || {
|
|
||||||
let still_current = STATE.with(|cell| cell.borrow().as_ref().map(|s| s.index)) == Some(index);
|
|
||||||
// Clear our own handle before advancing rather than letting
|
|
||||||
// `teardown_visuals` call `.remove()` on us — this closure is
|
|
||||||
// already mid-dispatch and about to return `Break`, and self-
|
|
||||||
// removing via `SourceId::remove()` from inside your own
|
|
||||||
// callback is a GLib footgun (can log a spurious critical).
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.timeout_source = None;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if still_current {
|
|
||||||
go_next();
|
|
||||||
}
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.timeout_source = Some(source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Watches for the step's target appearing — from the user's own real
|
|
||||||
/// keypress, or from clicking "Show me" (see `schedule_hint`), the poll
|
|
||||||
/// doesn't care which. Runs for as long as the step stays current; there's
|
|
||||||
/// no attempt cap because we're waiting on the user, not a fast-launching
|
|
||||||
/// process, and Next is always available as a manual escape hatch anyway.
|
|
||||||
fn schedule_poll(index: usize) {
|
|
||||||
let source = glib::timeout_add_local(WATCH_POLL_INTERVAL, move || {
|
|
||||||
let still_current = STATE.with(|cell| cell.borrow().as_ref().map(|s| s.index)) == Some(index);
|
|
||||||
if !still_current {
|
|
||||||
return glib::ControlFlow::Break;
|
|
||||||
}
|
|
||||||
let (step, binds, total) = STATE.with(|cell| {
|
|
||||||
let borrow = cell.borrow();
|
|
||||||
let s = borrow.as_ref().unwrap();
|
|
||||||
(s.steps[index].clone(), s.binds.clone(), s.steps.len())
|
|
||||||
});
|
|
||||||
if target::is_resolved(&step.target()) {
|
|
||||||
render_step(index, &step, &binds, index + 1 >= total, false);
|
|
||||||
clear_poll_source();
|
|
||||||
} else {
|
|
||||||
schedule_poll(index);
|
|
||||||
}
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.poll_source = Some(source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_poll_source() {
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.poll_source = None;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After `HINT_DELAY_SECS` with no target detected, reveals the "Show me"
|
|
||||||
/// fallback button by re-rendering the step with `hint_visible` set.
|
|
||||||
fn schedule_hint(index: usize) {
|
|
||||||
let source = glib::timeout_add_seconds_local(HINT_DELAY_SECS, move || {
|
|
||||||
// Clear our own handle first — see the self-removal note on the
|
|
||||||
// Timeout branch in `enter_step`, same footgun applies here.
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.hint_source = None;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let should_show = STATE.with(|cell| {
|
|
||||||
let borrow = cell.borrow();
|
|
||||||
borrow.as_ref().map(|s| s.index == index && !s.confirmed).unwrap_or(false)
|
|
||||||
});
|
|
||||||
if should_show {
|
|
||||||
let (step, binds, total) = STATE.with(|cell| {
|
|
||||||
let mut borrow = cell.borrow_mut();
|
|
||||||
let s = borrow.as_mut().unwrap();
|
|
||||||
s.hint_visible = true;
|
|
||||||
(s.steps[index].clone(), s.binds.clone(), s.steps.len())
|
|
||||||
});
|
|
||||||
render_step(index, &step, &binds, index + 1 >= total, false);
|
|
||||||
}
|
|
||||||
glib::ControlFlow::Break
|
|
||||||
});
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.hint_source = Some(source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The "Show me" button's click handler — the only place `launch` is ever
|
|
||||||
/// fired, and only because the user explicitly asked for it.
|
|
||||||
fn trigger_demo() {
|
|
||||||
let launch = STATE.with(|cell| {
|
|
||||||
let borrow = cell.borrow();
|
|
||||||
borrow.as_ref().and_then(|s| s.steps[s.index].launch.clone())
|
|
||||||
});
|
|
||||||
if let Some(cmd) = launch {
|
|
||||||
exec::run(&cmd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the tour done without finishing every step — same end state as
|
|
||||||
/// completing it normally (won't auto-relaunch on next login), just
|
|
||||||
/// reachable at any point via the callout's low-emphasis "Skip tour".
|
|
||||||
fn skip_tour() {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the mask + callout for `step` and installs them, tearing down
|
|
||||||
/// whatever was there before — called once immediately in `enter_step`
|
|
||||||
/// (usually with no target yet) and again by `schedule_poll` once a
|
|
||||||
/// `launch`ed app's window/layer actually appears.
|
|
||||||
fn render_step(index: usize, step: &Step, binds: &[Keybind], is_last: bool, force_no_target: bool) {
|
|
||||||
let (display, total, confirmed, hint_visible) = STATE.with(|cell| {
|
|
||||||
let borrow = cell.borrow();
|
|
||||||
let s = borrow.as_ref().unwrap();
|
|
||||||
(s.display.clone(), s.steps.len(), s.confirmed, s.hint_visible)
|
|
||||||
});
|
|
||||||
|
|
||||||
// `force_no_target` is set by `on_tour_event`'s confirmation path: a
|
|
||||||
// fresh `hyprctl -j layers` query right after a real `closelayer` event
|
|
||||||
// has been observed to still report the surface as present — Hyprland's
|
|
||||||
// event and its own layer-list snapshot aren't tightly ordered — which
|
|
||||||
// re-triggers the near-fullscreen suppression below and drops the
|
|
||||||
// confirmed callout right back into invisible limbo. `on_tour_event`
|
|
||||||
// already has authoritative confirmation the target is gone from the
|
|
||||||
// real compositor event itself, so that path skips re-querying live
|
|
||||||
// state entirely rather than trusting a `hyprctl` snapshot that may not
|
|
||||||
// have caught up yet.
|
|
||||||
let resolved = if force_no_target { None } else { target::resolve(&step.target(), &display) };
|
|
||||||
let monitor = resolved.as_ref().map(|r| r.monitor.clone()).or_else(|| target::focused_monitor(&display));
|
|
||||||
let Some(monitor) = monitor else { return };
|
|
||||||
let geo = monitor.geometry();
|
|
||||||
let monitor_size = (geo.width(), geo.height());
|
|
||||||
let rect = resolved.map(|r| r.rect);
|
|
||||||
|
|
||||||
// breadbox/breadclip/breadsearch anchor all 4 edges as fullscreen click-
|
|
||||||
// catchers — their visible launcher panel is small, but the surface
|
|
||||||
// hyprctl reports covers the whole monitor, and that surface owns all
|
|
||||||
// pointer input while it's open. A callout drawn now would be buried
|
|
||||||
// underneath it with no way to click Back/Next. The instructional
|
|
||||||
// callout already showed (with working buttons) before the app opened;
|
|
||||||
// once it's confirmed up, get out of the way entirely until it closes.
|
|
||||||
// A step whose target resolves this way should key `success_event` off
|
|
||||||
// the surface *closing* (e.g. `layer-closed:breadbox`), not opening —
|
|
||||||
// by the time that fires the surface is already gone, so `confirmed`'s
|
|
||||||
// re-render lands on a normal (non-fullscreen) target and is clickable.
|
|
||||||
if let Some(r) = rect {
|
|
||||||
if is_near_fullscreen(r, monitor_size) {
|
|
||||||
// Just the visuals, not `teardown_visuals()` — this can run from
|
|
||||||
// inside `schedule_poll`'s own still-executing closure, and that
|
|
||||||
// function also cancels `poll_source` via `SourceId::remove()`,
|
|
||||||
// which must never be called on a source's own in-progress
|
|
||||||
// dispatch (see the identical self-removal note in `enter_step`).
|
|
||||||
clear_visuals();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let masks = match rect {
|
|
||||||
Some(r) => mask::around(&monitor, r, monitor_size),
|
|
||||||
None => mask::full_screen(&monitor),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only worth offering "Show me" while the target genuinely isn't up yet
|
|
||||||
// — once `rect` resolves, the user can plainly see it themselves.
|
|
||||||
let show_demo = hint_visible && !confirmed && rect.is_none() && step.launch.is_some();
|
|
||||||
|
|
||||||
let callout = callout::build(&monitor, rect, monitor_size, step, binds, index, total, is_last, confirmed, show_demo);
|
|
||||||
callout.back_btn.connect_clicked(|_| go_back());
|
|
||||||
callout.next_btn.connect_clicked(|_| go_next());
|
|
||||||
callout.skip_btn.connect_clicked(|_| skip_tour());
|
|
||||||
if let Some(btn) = &callout.demo_btn {
|
|
||||||
btn.connect_clicked(|_| trigger_demo());
|
|
||||||
}
|
|
||||||
|
|
||||||
clear_visuals();
|
|
||||||
STATE.with(|cell| {
|
|
||||||
if let Some(s) = cell.borrow_mut().as_mut() {
|
|
||||||
s.masks = masks;
|
|
||||||
s.callout = Some(callout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// breadbox/breadclip/breadsearch anchor all 4 edges as fullscreen click-
|
|
||||||
/// catchers, so their reported surface geometry is the whole monitor even
|
|
||||||
/// though the visible launcher panel is small.
|
|
||||||
fn is_near_fullscreen(rect: target::Rect, monitor_size: (i32, i32)) -> bool {
|
|
||||||
let (mon_w, mon_h) = monitor_size;
|
|
||||||
rect.w * 10 >= mon_w * 9 && rect.h * 10 >= mon_h * 9
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_rebind_for_step(step: &Step, binds: &[Keybind]) {
|
|
||||||
let (Some(combo), Some(event_id)) = (&step.rebind_combo, &step.success_event) else { return };
|
|
||||||
let Some(kb) = keybinds::find(binds, combo) else { return };
|
|
||||||
let Some(raw) = &kb.raw else { return };
|
|
||||||
let Some(command) = raw.strip_prefix("exec,") else { return };
|
|
||||||
let mods_key = keybinds::to_hypr_mods_key(&kb.combo);
|
|
||||||
let original_bind_value = format!("{mods_key},{raw}");
|
|
||||||
let escaped = command.replace('\'', "'\\''");
|
|
||||||
let chained_bind_value = format!("{mods_key},exec,sh -c '{escaped} ; breadhelp --tour-event {event_id}'");
|
|
||||||
let mut cfg = State::load();
|
|
||||||
cfg.set_pending_rebind(&mods_key, &original_bind_value);
|
|
||||||
hyprland::rebind_temp(&chained_bind_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn revert_pending_rebind() {
|
|
||||||
let mut cfg = State::load();
|
|
||||||
if let Some((_key, original)) = cfg.pending_rebind() {
|
|
||||||
hyprland::rebind_temp(&original);
|
|
||||||
}
|
|
||||||
cfg.clear_pending_rebind();
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
//! Resolves a `content::tour::Target` to a screen rect + the `gdk4::Monitor`
|
|
||||||
//! it's on, via `services::hyprland`'s `hyprctl -j` snapshots. `hyprctl -j
|
|
||||||
//! clients` reports a window's monitor as a numeric id, while GDK/Wayland
|
|
||||||
//! only exposes a monitor's connector name — `hyprctl -j monitors` is the
|
|
||||||
//! join between the two (`Client::monitor == Monitor::id`, then match
|
|
||||||
//! `Monitor::name` against `gdk4::Monitor::connector()`).
|
|
||||||
|
|
||||||
use gdk4::prelude::*;
|
|
||||||
|
|
||||||
use crate::content::tour::Target;
|
|
||||||
use crate::services::hyprland;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub struct Rect {
|
|
||||||
pub x: i32,
|
|
||||||
pub y: i32,
|
|
||||||
pub w: i32,
|
|
||||||
pub h: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Resolved {
|
|
||||||
pub rect: Rect,
|
|
||||||
pub monitor: gdk4::Monitor,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `None` means the target isn't on screen (app not launched yet, or a
|
|
||||||
/// pure-concept `Target::None` step with no spotlight at all).
|
|
||||||
pub fn resolve(target: &Target, display: &gdk4::Display) -> Option<Resolved> {
|
|
||||||
let (rect, monitor_name) = match target {
|
|
||||||
Target::None => return None,
|
|
||||||
Target::Namespace(ns) => {
|
|
||||||
let layer = hyprland::layers().into_iter().find(|l| &l.namespace == ns)?;
|
|
||||||
(Rect { x: layer.x, y: layer.y, w: layer.w, h: layer.h }, layer.monitor)
|
|
||||||
}
|
|
||||||
Target::WindowClass(class) => {
|
|
||||||
let client = hyprland::clients().into_iter().find(|c| c.class.eq_ignore_ascii_case(class))?;
|
|
||||||
let name = hyprland::monitors().into_iter().find(|m| m.id == client.monitor)?.name;
|
|
||||||
(Rect { x: client.at.0, y: client.at.1, w: client.size.0, h: client.size.1 }, name)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let monitor = find_gdk_monitor(display, &monitor_name)?;
|
|
||||||
Some(Resolved { rect, monitor })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_gdk_monitor(display: &gdk4::Display, name: &str) -> Option<gdk4::Monitor> {
|
|
||||||
display.monitors().iter::<gdk4::Monitor>().filter_map(|m| m.ok()).find(|m| m.connector().as_deref() == Some(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Falls back to this when a step has no target (nothing to resolve a
|
|
||||||
/// monitor from) or a `launch`ed target never appeared — the tour should
|
|
||||||
/// still show up on whichever output the user is actually looking at.
|
|
||||||
pub fn focused_monitor(display: &gdk4::Display) -> Option<gdk4::Monitor> {
|
|
||||||
let name = hyprland::monitors().into_iter().find(|m| m.focused)?.name;
|
|
||||||
find_gdk_monitor(display, &name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `target` is currently resolvable on screen — checked before
|
|
||||||
/// firing a step's `launch` command, since breadbox/breadclip/breadsearch
|
|
||||||
/// toggle-close on a second invocation of the same command; unconditionally
|
|
||||||
/// launching would close an already-open instance instead of opening one.
|
|
||||||
pub fn is_resolved(target: &Target) -> bool {
|
|
||||||
match target {
|
|
||||||
Target::None => true,
|
|
||||||
Target::Namespace(ns) => hyprland::layers().iter().any(|l| &l.namespace == ns),
|
|
||||||
Target::WindowClass(class) => hyprland::clients().iter().any(|c| c.class.eq_ignore_ascii_case(class)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
//! Troubleshooting wizard: a small modal window that walks a symptom tree
|
|
||||||
//! (`content::troubleshoot`) — prompt as a heading, `options` as buttons,
|
|
||||||
//! a leaf's `fix` as a "Run fix" button + result status.
|
|
||||||
//!
|
|
||||||
//! Uses the `Rc<Self>` method-receiver pattern (rather than a boxed
|
|
||||||
//! recursive closure) so each button can re-render the same widget tree at
|
|
||||||
//! a different node — `self.clone()` (an `Rc` clone) moves cleanly into
|
|
||||||
//! GTK's `'static` signal closures.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Align, Box as GBox, Button, Label, Orientation, Window};
|
|
||||||
|
|
||||||
use crate::content::troubleshoot::{self, SymptomNode};
|
|
||||||
|
|
||||||
/// Curated symptom files with a human label — file stems aren't user-facing
|
|
||||||
/// strings, and not every `_symptoms/*.toml` necessarily belongs on the
|
|
||||||
/// top-level picker.
|
|
||||||
const SYMPTOMS: &[(&str, &str)] = &[("no-sound", "No sound"), ("wifi-wont-connect", "Wi-Fi won't connect")];
|
|
||||||
|
|
||||||
struct Wizard {
|
|
||||||
content: GBox,
|
|
||||||
trees: HashMap<String, Vec<SymptomNode>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(parent: &impl IsA<gtk4::Window>) {
|
|
||||||
let window = Window::builder()
|
|
||||||
.transient_for(parent)
|
|
||||||
.modal(true)
|
|
||||||
.title("Something's wrong?")
|
|
||||||
.default_width(480)
|
|
||||||
.default_height(360)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let content = GBox::new(Orientation::Vertical, 12);
|
|
||||||
content.add_css_class("view-content");
|
|
||||||
|
|
||||||
let wizard = Rc::new(Wizard { content: content.clone(), trees: troubleshoot::load_all() });
|
|
||||||
wizard.render_picker();
|
|
||||||
|
|
||||||
window.set_child(Some(&content));
|
|
||||||
window.present();
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Wizard {
|
|
||||||
fn clear(&self) {
|
|
||||||
while let Some(child) = self.content.first_child() {
|
|
||||||
self.content.remove(&child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_picker(self: &Rc<Self>) {
|
|
||||||
self.clear();
|
|
||||||
|
|
||||||
let title = Label::new(Some("What's wrong?"));
|
|
||||||
title.add_css_class("title");
|
|
||||||
title.set_xalign(0.0);
|
|
||||||
self.content.append(&title);
|
|
||||||
|
|
||||||
let mut any = false;
|
|
||||||
for (file, label) in SYMPTOMS {
|
|
||||||
if !self.trees.contains_key(*file) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
any = true;
|
|
||||||
let btn = Button::with_label(label);
|
|
||||||
btn.set_halign(Align::Start);
|
|
||||||
let this = self.clone();
|
|
||||||
let file = file.to_string();
|
|
||||||
btn.connect_clicked(move |_| this.render_node(&file, "start"));
|
|
||||||
self.content.append(&btn);
|
|
||||||
}
|
|
||||||
if !any {
|
|
||||||
let empty = Label::new(Some("No troubleshooting guides installed."));
|
|
||||||
empty.add_css_class("dim-label");
|
|
||||||
self.content.append(&empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_node(self: &Rc<Self>, file: &str, id: &str) {
|
|
||||||
let Some(nodes) = self.trees.get(file) else {
|
|
||||||
self.render_picker();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(node) = nodes.iter().find(|n| n.id == id) else {
|
|
||||||
self.render_picker();
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.clear();
|
|
||||||
|
|
||||||
let prompt = Label::new(Some(&node.prompt));
|
|
||||||
prompt.set_wrap(true);
|
|
||||||
prompt.set_xalign(0.0);
|
|
||||||
self.content.append(&prompt);
|
|
||||||
|
|
||||||
for opt in &node.options {
|
|
||||||
let btn = Button::with_label(&opt.label);
|
|
||||||
btn.set_halign(Align::Start);
|
|
||||||
let this = self.clone();
|
|
||||||
// `goto` is either a bare node id (same file) or
|
|
||||||
// "<file-stem>:<id>" to jump into a different symptom file.
|
|
||||||
let (goto_file, goto_id) = match opt.goto.split_once(':') {
|
|
||||||
Some((f, i)) => (f.to_string(), i.to_string()),
|
|
||||||
None => (file.to_string(), opt.goto.clone()),
|
|
||||||
};
|
|
||||||
btn.connect_clicked(move |_| this.render_node(&goto_file, &goto_id));
|
|
||||||
self.content.append(&btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(fix) = &node.fix {
|
|
||||||
let desc = Label::new(Some(&fix.description));
|
|
||||||
desc.set_xalign(0.0);
|
|
||||||
self.content.append(&desc);
|
|
||||||
|
|
||||||
let status = Label::new(None);
|
|
||||||
status.set_xalign(0.0);
|
|
||||||
status.add_css_class("dim-label");
|
|
||||||
|
|
||||||
let run_btn = Button::with_label("Run fix");
|
|
||||||
run_btn.add_css_class("suggested-action");
|
|
||||||
run_btn.set_halign(Align::Start);
|
|
||||||
let command = fix.command.clone();
|
|
||||||
let status_for_click = status.clone();
|
|
||||||
run_btn.connect_clicked(move |_| {
|
|
||||||
status_for_click.set_label("Running\u{2026}");
|
|
||||||
let status = status_for_click.clone();
|
|
||||||
crate::services::exec::run_reporting(&command, move |success| {
|
|
||||||
status.set_label(if success { "Done." } else { "Something went wrong — see the terminal for details." });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
self.content.append(&run_btn);
|
|
||||||
self.content.append(&status);
|
|
||||||
}
|
|
||||||
|
|
||||||
let back_btn = Button::with_label("Start over");
|
|
||||||
back_btn.set_halign(Align::Start);
|
|
||||||
let this = self.clone();
|
|
||||||
back_btn.connect_clicked(move |_| this.render_picker());
|
|
||||||
self.content.append(&back_btn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Stack, StackTransitionType};
|
|
||||||
|
|
||||||
use crate::cli::Action;
|
|
||||||
use crate::config::State;
|
|
||||||
use crate::content::{keybinds, ContentStore};
|
|
||||||
|
|
||||||
use super::home::Home;
|
|
||||||
use super::{ask, learn, modes, tabs, tour};
|
|
||||||
|
|
||||||
const DEFAULT_TAB: &str = "home";
|
|
||||||
|
|
||||||
struct Handle {
|
|
||||||
window: ApplicationWindow,
|
|
||||||
home: Home,
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static HANDLE: RefCell<Option<Handle>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called from every `command_line` invocation — including ones forwarded
|
|
||||||
/// over D-Bus to this primary instance by a second `breadhelp` launch — so
|
|
||||||
/// it must build the window at most once and reuse it thereafter.
|
|
||||||
pub fn present(app: &Application, action: Action) {
|
|
||||||
HANDLE.with(|cell| {
|
|
||||||
let mut just_built = false;
|
|
||||||
{
|
|
||||||
let mut cell_ref = cell.borrow_mut();
|
|
||||||
if cell_ref.is_none() {
|
|
||||||
*cell_ref = Some(build(app));
|
|
||||||
just_built = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let cell_ref = cell.borrow();
|
|
||||||
let handle = cell_ref.as_ref().unwrap();
|
|
||||||
let display = WidgetExt::display(&handle.window);
|
|
||||||
|
|
||||||
if action.force_onboard {
|
|
||||||
tour::restart(&display);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(id) = &action.tour_event {
|
|
||||||
tour::on_tour_event(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(id) = &action.suggest {
|
|
||||||
if let Some(s) = crate::services::breadd::resolve(id) {
|
|
||||||
handle.home.set_suggestion(Some(&s.text));
|
|
||||||
}
|
|
||||||
// Only focus the window the first time this process builds it
|
|
||||||
// (i.e. breadhelp wasn't already running) — a background daemon
|
|
||||||
// event shouldn't steal focus from whatever the user is doing.
|
|
||||||
if just_built {
|
|
||||||
handle.window.present();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every-login autostart builds the window (so the app is ready to
|
|
||||||
// respond to SUPER+/ instantly) but only starts the tour / pops the
|
|
||||||
// window open on a genuine first run — never on later logins.
|
|
||||||
if action.autostart && !State::load().onboarding_completed() {
|
|
||||||
tour::start(&display);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let silent_autostart = action.autostart && State::load().onboarding_completed();
|
|
||||||
if !silent_autostart {
|
|
||||||
handle.window.present();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build(app: &Application) -> Handle {
|
|
||||||
// First thing on every cold start: revert any keybind a previous,
|
|
||||||
// crashed run may have left temporarily rebound mid tour-step (see
|
|
||||||
// `ui::tour`'s crash-safety notes) before the user could be surprised
|
|
||||||
// by it firing again.
|
|
||||||
tour::self_heal();
|
|
||||||
|
|
||||||
let window = ApplicationWindow::builder()
|
|
||||||
.application(app)
|
|
||||||
.title("BOS Help")
|
|
||||||
.default_width(880)
|
|
||||||
.default_height(600)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
crate::theme::load(&WidgetExt::display(&window));
|
|
||||||
|
|
||||||
let store = Rc::new(ContentStore::load());
|
|
||||||
let binds = Rc::new(keybinds::load());
|
|
||||||
let state = State::load();
|
|
||||||
|
|
||||||
modes::apply(&window, state.mode());
|
|
||||||
|
|
||||||
let stack = Stack::new();
|
|
||||||
stack.set_hexpand(true);
|
|
||||||
stack.set_vexpand(true);
|
|
||||||
stack.set_transition_type(StackTransitionType::SlideLeftRight);
|
|
||||||
|
|
||||||
let home = super::home::build(&binds, &window, state.mode(), {
|
|
||||||
let window = window.clone();
|
|
||||||
move |mode| {
|
|
||||||
modes::apply(&window, mode);
|
|
||||||
let mut state = State::load();
|
|
||||||
state.set_mode(mode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stack.add_titled(&home.root, Some("home"), "Home");
|
|
||||||
stack.add_titled(&learn::build(&store, &binds, state.mode()), Some("learn"), "Learn");
|
|
||||||
stack.add_titled(&ask::build(store.clone(), binds.clone(), state.mode()), Some("ask"), "Ask");
|
|
||||||
stack.set_visible_child_name(DEFAULT_TAB);
|
|
||||||
|
|
||||||
let switcher = tabs::build(&stack);
|
|
||||||
|
|
||||||
let content_vbox = GBox::new(Orientation::Vertical, 0);
|
|
||||||
content_vbox.append(&switcher);
|
|
||||||
content_vbox.append(&stack);
|
|
||||||
|
|
||||||
window.set_child(Some(&content_vbox));
|
|
||||||
// Deliberately not presented here — `present()` (the caller) decides
|
|
||||||
// whether this initial build should actually be shown (see
|
|
||||||
// `silent_autostart` above), so a from-cold every-login autostart with
|
|
||||||
// onboarding already complete builds a ready-but-hidden window instead
|
|
||||||
// of flashing it open. On a genuine first run, the tour overlay runs
|
|
||||||
// independently of this window (see `tour::start`) — it never needs to
|
|
||||||
// be shown at all until the user explicitly opens it later.
|
|
||||||
|
|
||||||
Handle { window, home }
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
|
||||||
|
|
||||||
pkgname=breadhelp
|
|
||||||
pkgver=0.1.0
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="Onboarding and help center for Bread OS"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/Breadway/bos"
|
|
||||||
license=('MIT')
|
|
||||||
# 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,
|
|
||||||
# causing undefined-symbol errors. Disable LTO.
|
|
||||||
options=(!lto !debug)
|
|
||||||
depends=('gtk4' 'glib2' 'gtk4-layer-shell' 'hicolor-icon-theme')
|
|
||||||
optdepends=(
|
|
||||||
'snapper: create-backup one-click fix'
|
|
||||||
)
|
|
||||||
makedepends=('rust' 'cargo')
|
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
|
||||||
sha256sums=('SKIP')
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo build --release --locked -p breadhelp
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo test --release --locked -p breadhelp
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
install -Dm755 target/release/breadhelp "${pkgdir}/usr/bin/breadhelp"
|
|
||||||
install -Dm644 packaging/arch/breadhelp/breadhelp.desktop \
|
|
||||||
"${pkgdir}/usr/share/applications/breadhelp.desktop"
|
|
||||||
install -d "${pkgdir}/usr/share/breadhelp"
|
|
||||||
cp -r breadhelp/content "${pkgdir}/usr/share/breadhelp/content"
|
|
||||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[Desktop Entry]
|
|
||||||
Name=BOS Help
|
|
||||||
Comment=Your personal guide to the Bread desktop
|
|
||||||
Exec=breadhelp
|
|
||||||
Icon=help-browser
|
|
||||||
Terminal=false
|
|
||||||
Type=Application
|
|
||||||
Categories=Help;System;
|
|
||||||
StartupWMClass=com.breadway.breadhelp
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue