mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80566768c | ||
|
|
06f974f09f | ||
|
|
e006e95e55 | ||
|
|
111f3ade7b | ||
|
|
0e6dcdc606 | ||
|
|
28042b9879 | ||
|
|
5bb057cb71 | ||
|
|
43507504f4 | ||
|
|
49d5f8b20e | ||
|
|
fc8c2a727d | ||
|
|
5f521529a8 | ||
|
|
7df465ef26 | ||
|
|
a397334242 | ||
|
|
4bedb04f77 | ||
|
|
e65a9722d9 | ||
|
|
d2b091fa8b | ||
|
|
620941e18b | ||
|
|
63610d57df | ||
|
|
84a5eac79f | ||
|
|
31ce3a51c4 | ||
|
|
c6150755d2 | ||
|
|
c0ae1350f3 | ||
|
|
98e75ecdc8 | ||
|
|
6f9696e791 | ||
|
|
3c1a092e51 | ||
|
|
4ba11d1bb5 | ||
|
|
08028e6f2c | ||
|
|
178dfbec1a | ||
|
|
563fdc7330 | ||
|
|
b8b5ce546d | ||
|
|
b89e4be10a | ||
|
|
bae7c5b0ff | ||
|
|
316fb843c3 | ||
|
|
362f324755 | ||
|
|
1d3252aabd | ||
|
|
50e6c1948c | ||
|
|
9933ff2415 | ||
|
|
cd99893b33 | ||
|
|
a9400d0550 | ||
|
|
272ec0c5d2 | ||
|
|
a32596216a | ||
|
|
e46514cbab | ||
|
|
10c5cc73d4 | ||
|
|
19927bc00b | ||
|
|
214a77ea18 | ||
|
|
8d21c0d084 | ||
|
|
e56ffe8465 | ||
|
|
1d1909baaf | ||
|
|
dee24ac30a | ||
|
|
09f90f65de | ||
|
|
39008709e3 | ||
|
|
0ce7ec30ac | ||
|
|
652b4c45f1 | ||
|
|
a483257d31 | ||
|
|
6eceadf650 | ||
|
|
0098ab4d8f | ||
|
|
3e34c8e746 | ||
|
|
0d128858ad | ||
|
|
f846fcb546 | ||
|
|
43bcd5266f | ||
|
|
a21edb170b | ||
|
|
ed1607856b | ||
|
|
4412bb00d7 | ||
|
|
6169da68ca | ||
|
|
d103b64024 | ||
|
|
9af2199729 | ||
|
|
9e8ac99bcb | ||
|
|
efe2c51b53 | ||
|
|
9b0e61c54b | ||
|
|
f5fa41194e | ||
|
|
f9abdc105d | ||
|
|
dee71b6f25 | ||
|
|
d955da69d4 | ||
|
|
5dcb211692 | ||
|
|
e13e24b54e | ||
|
|
dafb7bb1fa | ||
|
|
13ea973991 | ||
|
|
8722d5507c | ||
|
|
e4edeeca3f | ||
|
|
7106172bcd | ||
|
|
0972aa1396 |
@@ -0,0 +1,8 @@
|
|||||||
|
- [ ] I have disclosed use of any AI generated code in my commit messages.
|
||||||
|
- If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR.
|
||||||
|
- In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment.
|
||||||
|
- [ ] I understand these changes in full and will be able to respond to review comments.
|
||||||
|
- [ ] My change is accurately described in the commit message.
|
||||||
|
- [ ] My contribution is tested and working as described.
|
||||||
|
- [ ] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions.
|
||||||
|
|
||||||
@@ -17,13 +17,12 @@ jobs:
|
|||||||
Test:
|
Test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: archlinux:latest
|
image: archlinux:base-devel
|
||||||
volumes:
|
volumes:
|
||||||
- /:/host
|
- /:/host
|
||||||
steps:
|
steps:
|
||||||
- run: >
|
- run: >
|
||||||
pacman --noconfirm -Syu
|
pacman --noconfirm --needed -Syu
|
||||||
base-devel
|
|
||||||
cargo
|
cargo
|
||||||
clang
|
clang
|
||||||
desktop-file-utils
|
desktop-file-utils
|
||||||
@@ -31,6 +30,7 @@ jobs:
|
|||||||
gtk3
|
gtk3
|
||||||
gtk4
|
gtk4
|
||||||
just
|
just
|
||||||
|
libdisplay-info
|
||||||
libinput
|
libinput
|
||||||
libxkbcommon
|
libxkbcommon
|
||||||
llvm
|
llvm
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
flatpak
|
flatpak
|
||||||
nasm
|
nasm
|
||||||
- run: rm -rf /host/usr/local/lib/android # Free space
|
- run: rm -rf /host/usr/local/lib/android # Free space
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
# Safe directory behavior seems to have issues with `container:`
|
# Safe directory behavior seems to have issues with `container:`
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Build and check the parts of HyprCosmic that are not one of the two forks:
|
||||||
|
# cosmic-conf, the shared waybar/rofi assets, and the installer that places them.
|
||||||
|
#
|
||||||
|
# Two jobs with different shapes on purpose. cosmic-conf is compiled code and
|
||||||
|
# gets the same per-distribution matrix the forks do. The assets are text, and
|
||||||
|
# text does not care which distribution it is on -- what matters there is whether
|
||||||
|
# generated files are still in step with their generator, which is a single
|
||||||
|
# question with a single answer.
|
||||||
|
# The name is not "CI", which is upstream's ci.yml. Two workflows sharing a name
|
||||||
|
# share `${{ github.workflow }}`, and the concurrency group below is built from
|
||||||
|
# it -- they would cancel each other on every push, and whichever started second
|
||||||
|
# would be the only one that ever reported.
|
||||||
|
name: HyprCosmic
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cosmic-conf:
|
||||||
|
name: cosmic-conf (${{ matrix.distro }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: ${{ matrix.image }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- distro: fedora
|
||||||
|
image: fedora:latest
|
||||||
|
- distro: debian
|
||||||
|
image: debian:bookworm
|
||||||
|
- distro: arch
|
||||||
|
image: archlinux:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Before checkout: actions/checkout needs git and these images are bare.
|
||||||
|
- name: Install build dependencies (fedora)
|
||||||
|
if: matrix.distro == 'fedora'
|
||||||
|
run: dnf -y install --setopt=install_weak_deps=False git curl gcc
|
||||||
|
|
||||||
|
- name: Install build dependencies (debian)
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
git curl ca-certificates build-essential
|
||||||
|
|
||||||
|
- name: Install build dependencies (arch)
|
||||||
|
if: matrix.distro == 'arch'
|
||||||
|
run: pacman -Syu --noconfirm --needed git curl base-devel
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --default-toolchain stable --profile minimal \
|
||||||
|
--component clippy
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: cosmic-conf
|
||||||
|
key: ${{ matrix.distro }}
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
working-directory: cosmic-conf
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
# Warnings are errors here because the alternative is a build log nobody
|
||||||
|
# reads and a lint that has been failing for six months.
|
||||||
|
- name: Clippy
|
||||||
|
working-directory: cosmic-conf
|
||||||
|
run: cargo clippy --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: cosmic-conf
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
# The end-to-end question the unit tests cannot ask: does the cosmic.conf
|
||||||
|
# this repository actually ships still parse and resolve? A schema change
|
||||||
|
# that invalidates the shipped config would pass every test in the crate
|
||||||
|
# and break every user on first login.
|
||||||
|
#
|
||||||
|
# HOME is redirected so the resolution reports against an empty config
|
||||||
|
# tree rather than the runner's own. The binary is run directly instead of
|
||||||
|
# through `cargo run`: cargo keys its registry cache on HOME, so moving
|
||||||
|
# HOME would send it off to re-download every dependency into a directory
|
||||||
|
# the cache action does not know about.
|
||||||
|
#
|
||||||
|
# --diff writes nothing, and the last two lines hold it to that.
|
||||||
|
- name: The shipped cosmic.conf still resolves
|
||||||
|
working-directory: cosmic-conf
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
fake="$RUNNER_TEMP/fakehome"
|
||||||
|
rm -rf "$fake"
|
||||||
|
mkdir -p "$fake/.config"
|
||||||
|
env HOME="$fake" XDG_CONFIG_HOME="$fake/.config" \
|
||||||
|
./target/release/cosmic-conf apply --diff --config ../config/cosmic.conf
|
||||||
|
test -z "$(find "$fake" -type f -print -quit)"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cosmic-conf-${{ matrix.distro }}
|
||||||
|
path: cosmic-conf/target/release/cosmic-conf
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
assets:
|
||||||
|
name: assets and installer
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Not `submodules: recursive`. This repository names 29 of them, 27 of
|
||||||
|
# which are the rest of COSMIC and none of which this job touches -- the
|
||||||
|
# only one needed is cosmic-session, because tools/install-assets.sh also
|
||||||
|
# places the session entry point that lives there. Cloning the other 27 to
|
||||||
|
# lint some CSS would cost several gigabytes per run.
|
||||||
|
#
|
||||||
|
# The script degrades gracefully when that checkout is absent -- it warns
|
||||||
|
# and skips those two files -- so a failure to fetch would quietly reduce
|
||||||
|
# what is covered rather than fail. Hence the assertion after it.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Fetch only the session submodule
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
git submodule update --init --depth 1 cosmic-session
|
||||||
|
test -f cosmic-session/data/start-hyprcosmic
|
||||||
|
|
||||||
|
# config.jsonc is generated, and the repository's rule is that it is never
|
||||||
|
# hand-edited: every Nerd Font glyph in it comes from a codepoint table in
|
||||||
|
# generate-config.py, because Private Use Area characters are destroyed by
|
||||||
|
# being retyped and indistinguishable in a diff. That rule is currently
|
||||||
|
# enforced by remembering it. This enforces it instead -- regenerate, and
|
||||||
|
# the file must not move.
|
||||||
|
- name: The generated waybar config is in step with its generator
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
python3 config/waybar/generate-config.py \
|
||||||
|
config/waybar/config.jsonc.in config/waybar/config.jsonc
|
||||||
|
git diff --exit-code -- config/waybar/config.jsonc
|
||||||
|
|
||||||
|
# No PUA character may reach the template or the generator's own source.
|
||||||
|
# The generator refuses to emit non-ASCII, but nothing stopped one being
|
||||||
|
# pasted into its inputs until here.
|
||||||
|
#
|
||||||
|
# Written as an `if` rather than `! grep ...` because grep exits 1 for "no
|
||||||
|
# match" and 2 for "no such file", and negating it would turn a vanished
|
||||||
|
# file into a pass -- the check would quietly stop checking anything.
|
||||||
|
- name: The template and generator stay pure ASCII
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for f in config/waybar/config.jsonc.in config/waybar/generate-config.py; do
|
||||||
|
test -f "$f"
|
||||||
|
if LC_ALL=C grep -Pn '[^\x00-\x7F]' "$f"; then
|
||||||
|
echo "$f: non-ASCII above. Glyphs belong in the codepoint table," \
|
||||||
|
"not in the template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# A login-time script with a syntax error is a black screen with nowhere to
|
||||||
|
# print the reason.
|
||||||
|
# Each script is checked with the shell its shebang names rather than with
|
||||||
|
# bash across the board: hyprcosmic-fan is POSIX sh on purpose, and `bash
|
||||||
|
# -n` would happily accept a bashism that fails on a system where /bin/sh
|
||||||
|
# is dash.
|
||||||
|
- name: Syntax-check the shell scripts
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
sh -n config/bin/hyprcosmic-fan
|
||||||
|
bash -n config/bin/hyprcosmic-keybinds
|
||||||
|
bash -n config/bin/hyprcosmic-powermenu
|
||||||
|
bash -n tools/install-assets.sh
|
||||||
|
|
||||||
|
# Every script is shellcheck-clean today, so this starts as a ratchet
|
||||||
|
# rather than a backlog. The runner image ships shellcheck; the install is
|
||||||
|
# there so that stopping to be true is a slow step and not a broken job.
|
||||||
|
- name: Shellcheck
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
command -v shellcheck >/dev/null || {
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends shellcheck
|
||||||
|
}
|
||||||
|
shellcheck config/bin/hyprcosmic-fan \
|
||||||
|
config/bin/hyprcosmic-keybinds \
|
||||||
|
config/bin/hyprcosmic-powermenu \
|
||||||
|
tools/install-assets.sh
|
||||||
|
|
||||||
|
# A round trip. Installing into a staging root and then asking --check to
|
||||||
|
# confirm it exercises both halves of the script against each other, and
|
||||||
|
# the audit it runs first refuses to proceed at all unless every file under
|
||||||
|
# config/ is classified as shared, per-user or a generator input. That
|
||||||
|
# audit is the real test: it is what stops a new file being silently left
|
||||||
|
# out of the install.
|
||||||
|
- name: Install into a staging root, then verify it
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
DESTDIR="$PWD/stage" ./tools/install-assets.sh
|
||||||
|
DESTDIR="$PWD/stage" ./tools/install-assets.sh --check
|
||||||
|
find stage -type f -printf '%M %10s %P\n'
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: hyprcosmic-assets
|
||||||
|
path: stage/
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
# Build installable HyprCosmic packages for Fedora, Arch and Debian.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A SEPARATE WORKFLOW FROM hyprcosmic.yml
|
||||||
|
# --------------------------------------------------
|
||||||
|
# hyprcosmic.yml beside it answers "does the fork still build and do the assets
|
||||||
|
# still install where they claim to", on every push, in a few minutes. This one
|
||||||
|
# compiles 27 Rust components three times over and takes hours. Sharing a file
|
||||||
|
# would mean either running the slow thing on every push or never running the
|
||||||
|
# fast thing on a tag, and a `if:` guard threaded through a shared matrix to
|
||||||
|
# avoid that is harder to read than two files.
|
||||||
|
#
|
||||||
|
# WHY IT DOES NOT RUN ON EVERY PUSH
|
||||||
|
# ---------------------------------
|
||||||
|
# Three full desktop builds per commit is hours of runner time to produce
|
||||||
|
# artifacts nobody downloads. Tags get packages because that is when a package
|
||||||
|
# means something; workflow_dispatch covers wanting one at any other time.
|
||||||
|
#
|
||||||
|
# WHY THE WHOLE JOB RUNS IN A CONTAINER
|
||||||
|
# -------------------------------------
|
||||||
|
# Nothing here is statically linked, so a package is only valid on the
|
||||||
|
# distribution that built it -- an RPM built on Ubuntu's runner would name
|
||||||
|
# Ubuntu's sonames and refuse to install on Fedora. `container:` puts the
|
||||||
|
# compile, the staging and the package build all inside an image of the target,
|
||||||
|
# so the sonames recorded are the ones that will exist on the machine installing
|
||||||
|
# it. This is also why no step here runs on a developer's workstation: the
|
||||||
|
# distribution being targeted is rarely the one being typed at.
|
||||||
|
name: Packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to stamp on the packages'
|
||||||
|
required: false
|
||||||
|
default: '0.1.0'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package:
|
||||||
|
name: ${{ matrix.distro }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: ${{ matrix.image }}
|
||||||
|
# GitHub defaults `run:` to `sh -e {0}` inside a container, and on Debian
|
||||||
|
# that is dash. Several steps below use brace expansion and process
|
||||||
|
# substitution, which dash does not have and which bash-invoked-as-sh
|
||||||
|
# disables. Naming bash once here is better than writing POSIX around a
|
||||||
|
# constraint no target actually imposes -- all three images ship bash.
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
# One distribution failing on a package name is worth seeing on its own,
|
||||||
|
# and the other two artifacts are still worth having.
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- distro: fedora
|
||||||
|
image: fedora:44
|
||||||
|
- distro: arch
|
||||||
|
image: archlinux:base-devel
|
||||||
|
# trixie rather than the bookworm the other workflows use. bookworm has
|
||||||
|
# no `just` package -- it arrived in trixie -- and its rustc is 1.63,
|
||||||
|
# so `just` would have to be compiled by a toolchain installed before
|
||||||
|
# the thing that installs toolchains. CI pays that to prove the crate
|
||||||
|
# builds on the oldest supported Debian; a package has no such point
|
||||||
|
# to make.
|
||||||
|
- distro: debian
|
||||||
|
image: debian:trixie
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Before checkout, deliberately: actions/checkout needs git in the image
|
||||||
|
# and these are bare.
|
||||||
|
#
|
||||||
|
# The library lists are the union of Build-Depends across all 27 upstream
|
||||||
|
# components' debian/control files, translated per distribution rather
|
||||||
|
# than trimmed. Derived mechanically rather than assembled by hand, after
|
||||||
|
# a hand-assembled list built for eighteen minutes and then failed on
|
||||||
|
# dav1d -- a dependency of cosmic-bg that nothing in the obvious set names.
|
||||||
|
#
|
||||||
|
# The Fedora and Arch translations were checked against dnf repoquery and
|
||||||
|
# archlinux.org's package API rather than guessed, because a name that
|
||||||
|
# does not exist fails the whole step: it is libdav1d-devel on Fedora, not
|
||||||
|
# dav1d-devel, and ttf-opensans on Arch, not otf-opensans.
|
||||||
|
#
|
||||||
|
# The Fedora list names a set of basic tools the other two get for free.
|
||||||
|
# Fedora's container image is deliberately minimal: no `which` (deprecated
|
||||||
|
# there in favour of `command -v`), no make, no findutils, no tar. Arch's
|
||||||
|
# base-devel and Debian's build-essential plus its essential set include
|
||||||
|
# all of them, which is why this only ever broke on one of the three.
|
||||||
|
#
|
||||||
|
# These are not guesses. The top-level justfile opens with
|
||||||
|
# `make := \`which make\``, and the submodules' own recipes shell out to
|
||||||
|
# find, xargs, tar and sed -- 114 tar invocations and 18 find between
|
||||||
|
# them. Several steps in this workflow use find as well.
|
||||||
|
- name: Install build dependencies (fedora)
|
||||||
|
if: matrix.distro == 'fedora'
|
||||||
|
run: |
|
||||||
|
dnf -y install --setopt=install_weak_deps=False \
|
||||||
|
git curl ca-certificates just \
|
||||||
|
gcc gcc-c++ make which findutils tar gzip sed diffutils \
|
||||||
|
cmake pkgconf-pkg-config nasm lld mold \
|
||||||
|
clang-devel llvm-devel \
|
||||||
|
desktop-file-utils rpm-build intltool ImageMagick open-sans-fonts \
|
||||||
|
dbus-devel expat-devel fontconfig-devel freetype-devel \
|
||||||
|
libinput-devel libseat-devel libxkbcommon-devel \
|
||||||
|
mesa-libgbm-devel mesa-libEGL-devel libglvnd-devel \
|
||||||
|
wayland-devel libdisplay-info-devel libdav1d-devel \
|
||||||
|
pixman-devel cairo-devel pango-devel glib2-devel gtk3-devel gtk4-devel \
|
||||||
|
pipewire-devel pulseaudio-libs-devel \
|
||||||
|
gstreamer1-devel gstreamer1-plugins-base-devel \
|
||||||
|
flatpak-devel systemd-devel libgudev-devel \
|
||||||
|
openssl-devel pam-devel libxml2-devel xkeyboard-config-devel
|
||||||
|
|
||||||
|
# Shorter than the others, and not by omission: Arch ships headers in the
|
||||||
|
# main package rather than splitting a -devel, so `wayland` here is
|
||||||
|
# `wayland-devel` on Fedora.
|
||||||
|
- name: Install build dependencies (arch)
|
||||||
|
if: matrix.distro == 'arch'
|
||||||
|
run: |
|
||||||
|
pacman -Syu --noconfirm --needed \
|
||||||
|
git curl just cmake pkgconf nasm lld mold clang llvm \
|
||||||
|
desktop-file-utils sudo intltool imagemagick ttf-opensans \
|
||||||
|
dbus expat fontconfig freetype2 \
|
||||||
|
libinput seatd libxkbcommon \
|
||||||
|
mesa libglvnd wayland libdisplay-info dav1d \
|
||||||
|
pixman cairo pango glib2 gtk3 gtk4 \
|
||||||
|
pipewire libpipewire libpulse gst-plugins-base-libs \
|
||||||
|
flatpak systemd-libs libgudev \
|
||||||
|
openssl pam libxml2 xkeyboard-config
|
||||||
|
|
||||||
|
- name: Install build dependencies (debian)
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
git curl ca-certificates just \
|
||||||
|
build-essential cmake pkg-config nasm lld mold \
|
||||||
|
clang libclang-dev llvm-dev \
|
||||||
|
desktop-file-utils dpkg-dev fakeroot file \
|
||||||
|
intltool imagemagick fonts-open-sans \
|
||||||
|
libdbus-1-dev libexpat1-dev libfontconfig-dev libfreetype-dev \
|
||||||
|
libinput-dev libseat-dev libxkbcommon-dev \
|
||||||
|
libgbm-dev libegl-dev libegl1-mesa-dev libgles-dev \
|
||||||
|
libwayland-dev libdisplay-info-dev libdav1d-dev \
|
||||||
|
libpixman-1-dev libcairo2-dev libpango1.0-dev libglib2.0-dev \
|
||||||
|
libgtk-3-dev libgtk-4-dev \
|
||||||
|
libpipewire-0.3-dev libspa-0.2-dev libpulse-dev \
|
||||||
|
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
|
||||||
|
libflatpak-dev libsystemd-dev libudev-dev libgudev-1.0-dev \
|
||||||
|
libssl-dev libpam0g-dev libxml2-dev xkb-data libxcb1-dev
|
||||||
|
|
||||||
|
# submodules: recursive is the whole point -- this repository is 27
|
||||||
|
# components plus the two forks, and a checkout without them builds
|
||||||
|
# nothing.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# stable as the default, not `none`.
|
||||||
|
#
|
||||||
|
# The two fork workflows use --default-toolchain none and let
|
||||||
|
# rust-toolchain.toml decide, which is right there because the checkout
|
||||||
|
# root is the crate and the pin sits in it. Here it does not: the pin is
|
||||||
|
# cosmic-comp/rust-toolchain.toml, one level down, and this job's other 27
|
||||||
|
# components have no pin at all. With `none` there is no default to fall
|
||||||
|
# back to and the first cargo invocation at the repository root fails
|
||||||
|
# before anything is built.
|
||||||
|
#
|
||||||
|
# Naming stable does not weaken the pin. rustup applies a directory-local
|
||||||
|
# rust-toolchain.toml whenever it enters that directory and installs it on
|
||||||
|
# demand, so cosmic-comp still compiles with the 1.93 it asks for while
|
||||||
|
# everything else uses stable.
|
||||||
|
- name: Install Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
|
| sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Show toolchain
|
||||||
|
run: |
|
||||||
|
rustup show
|
||||||
|
cargo --version
|
||||||
|
just --version
|
||||||
|
|
||||||
|
# No Swatinem/rust-cache here, unlike the two fork workflows.
|
||||||
|
#
|
||||||
|
# It was tried and it fails on this repository specifically: the action
|
||||||
|
# runs `cargo metadata` at the workspace root to work out what to cache,
|
||||||
|
# and this root is a meta-repository with no Cargo.toml -- it is 29
|
||||||
|
# submodules, each its own crate with its own target/. The action reported
|
||||||
|
# `could not find Cargo.toml in /__w/hyprcosmic/hyprcosmic` and cached
|
||||||
|
# nothing.
|
||||||
|
#
|
||||||
|
# Listing all 29 workspaces would fix the error and create a worse
|
||||||
|
# problem: their target directories come to roughly 17 GB, against a 10 GB
|
||||||
|
# per-repository cache limit, so the jobs would evict each other's entries
|
||||||
|
# every run and pay upload time for the privilege. A cold build is the
|
||||||
|
# honest cost of a workflow that only runs on tags and on demand.
|
||||||
|
- name: Determine version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
# A tag is authoritative; a manual run uses its input; anything else
|
||||||
|
# falls back so the job is still testable from a branch.
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
v="${GITHUB_REF_NAME#v}"
|
||||||
|
else
|
||||||
|
v="${{ inputs.version || '0.1.0' }}"
|
||||||
|
fi
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: just build
|
||||||
|
|
||||||
|
# prefix=/usr, not the justfile's /usr/local default. Both .desktop files
|
||||||
|
# name an absolute Exec under /usr/bin -- a desktop entry cannot
|
||||||
|
# interpolate a prefix -- so any other prefix stages entries pointing at
|
||||||
|
# paths this step did not write.
|
||||||
|
- name: Stage the install
|
||||||
|
run: just install "$PWD/stage" /usr
|
||||||
|
|
||||||
|
# The three binaries this fork actually changes, under their own names.
|
||||||
|
#
|
||||||
|
# Done on every distribution, because start-hyprcosmic and the session
|
||||||
|
# entry name these paths and there is no reason for those to differ per
|
||||||
|
# distribution. cosmic-session takes the compositor as argv[1], so the
|
||||||
|
# pair is arranged in start-hyprcosmic and needs no source change.
|
||||||
|
- name: Give this fork's binaries their own names
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
mv stage/usr/bin/cosmic-comp stage/usr/bin/hyprcosmic-comp
|
||||||
|
mv stage/usr/bin/cosmic-session stage/usr/bin/hyprcosmic-session
|
||||||
|
mv stage/usr/bin/cosmic-conf stage/usr/bin/hyprcosmic-conf
|
||||||
|
|
||||||
|
# The stock session entry goes with it, on Debian only.
|
||||||
|
#
|
||||||
|
# start-cosmic execs /usr/bin/cosmic-session, which the rename above just
|
||||||
|
# took away, so cosmic.desktop would sit on the greeter's menu and die
|
||||||
|
# silently when chosen. Everywhere else the reduction below removes both
|
||||||
|
# and the distribution's own cosmic-session package supplies a working
|
||||||
|
# pair; on Debian there is no such package, so they are removed here and
|
||||||
|
# the greeter offers the HyprCosmic entry alone.
|
||||||
|
- name: Drop the stock session entry it can no longer start
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
rm -f stage/usr/bin/start-cosmic
|
||||||
|
rm -f stage/usr/share/wayland-sessions/cosmic.desktop
|
||||||
|
|
||||||
|
# HyprCosmic installs beside COSMIC rather than over it, and this is the
|
||||||
|
# step that makes that true.
|
||||||
|
#
|
||||||
|
# `just install` stages the whole desktop, because it builds the whole
|
||||||
|
# tree. Nearly all of it is byte-identical to what the distribution
|
||||||
|
# already ships, and the parts that are not are file conflicts that stop
|
||||||
|
# the install outright -- 62 of them on a stock Fedora COSMIC, across 25
|
||||||
|
# packages, which rpm reports only in the transaction check, long after
|
||||||
|
# dnf's dependency solving has said the transaction is fine.
|
||||||
|
#
|
||||||
|
# The alternative to reducing the tree is to claim all 25 packages with
|
||||||
|
# Conflicts, which means erasing them to install this, which on Fedora
|
||||||
|
# includes cosmic-greeter -- the display manager. A fork you can only try
|
||||||
|
# by removing the desktop you would fall back to is a fork with no way
|
||||||
|
# back. So everything the distribution already owns is dropped, and the
|
||||||
|
# package depends on the distribution's COSMIC for it.
|
||||||
|
#
|
||||||
|
# Not on Debian, which has no COSMIC to depend on: neither cosmic-session
|
||||||
|
# nor cosmic-comp is packaged there, in any suite. Reducing the tree there
|
||||||
|
# would produce a package whose dependency can never be satisfied, so the
|
||||||
|
# Debian build keeps the whole desktop it just compiled and stands alone.
|
||||||
|
# Revisit when Debian packages COSMIC.
|
||||||
|
- name: Reduce the staged tree to this fork's own files
|
||||||
|
if: matrix.distro != 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
# /usr/share/cosmic goes too, all of it. Those are the defaults the
|
||||||
|
# compositor reads at first run, and this fork carries upstream's
|
||||||
|
# copies unmodified -- every one of them is byte-identical to a file a
|
||||||
|
# distribution package already owns. rpm permits two packages to own
|
||||||
|
# an identical file, so keeping them would install today and then
|
||||||
|
# collide the first time the distribution changed one. They arrive
|
||||||
|
# with the cosmic-comp this package depends on.
|
||||||
|
( cd stage && find . \( -type f -o -type l \) -printf '%P\n' ) |
|
||||||
|
while read -r p; do
|
||||||
|
case $p in
|
||||||
|
usr/bin/hyprcosmic-*|usr/bin/start-hyprcosmic) continue ;;
|
||||||
|
usr/share/hyprcosmic/*) continue ;;
|
||||||
|
usr/share/wayland-sessions/hyprcosmic.desktop) continue ;;
|
||||||
|
esac
|
||||||
|
rm -f "stage/$p"
|
||||||
|
done
|
||||||
|
find stage -type d -empty -delete
|
||||||
|
|
||||||
|
# Worth failing here rather than shipping a package that is missing the
|
||||||
|
# compositor. The negative assertions are the ones that would rot quietly:
|
||||||
|
# nothing may return to the private libexec layout this fork used to
|
||||||
|
# install into, and no cosmic-* name may come back, because either one is
|
||||||
|
# a file conflict that only shows up on a machine that has COSMIC
|
||||||
|
# installed -- which is every machine this is meant for.
|
||||||
|
- name: Assert the staged tree is this fork and nothing else
|
||||||
|
if: matrix.distro != 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
test -x stage/usr/bin/hyprcosmic-comp
|
||||||
|
test -x stage/usr/bin/hyprcosmic-session
|
||||||
|
test -x stage/usr/bin/hyprcosmic-conf
|
||||||
|
test -x stage/usr/bin/start-hyprcosmic
|
||||||
|
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||||
|
test -d stage/usr/share/hyprcosmic
|
||||||
|
test ! -e stage/usr/libexec/hyprcosmic
|
||||||
|
test ! -e stage/usr/bin/cosmic-comp
|
||||||
|
test ! -e stage/usr/bin/cosmic-session
|
||||||
|
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
|
||||||
|
test ! -e stage/usr/share/cosmic
|
||||||
|
test -z "$(find stage/usr/bin -mindepth 1 ! -name 'hyprcosmic-*' ! -name 'start-hyprcosmic')"
|
||||||
|
echo "staged files: $(find stage -type f | wc -l)"
|
||||||
|
|
||||||
|
# Debian is not reduced, so the assertion is the opposite one: the package
|
||||||
|
# stands alone there and has to carry a desktop that starts. The renamed
|
||||||
|
# three must be present under their new names, and the components the
|
||||||
|
# session launches must still be in the tree rather than assumed to arrive
|
||||||
|
# from a distribution package that does not exist.
|
||||||
|
- name: Assert the staged tree is a complete desktop
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
test -x stage/usr/bin/hyprcosmic-comp
|
||||||
|
test -x stage/usr/bin/hyprcosmic-session
|
||||||
|
test -x stage/usr/bin/hyprcosmic-conf
|
||||||
|
test -x stage/usr/bin/start-hyprcosmic
|
||||||
|
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||||
|
test -d stage/usr/share/hyprcosmic
|
||||||
|
test -d stage/usr/share/cosmic
|
||||||
|
test ! -e stage/usr/libexec/hyprcosmic
|
||||||
|
test ! -e stage/usr/bin/cosmic-comp
|
||||||
|
test ! -e stage/usr/bin/cosmic-session
|
||||||
|
test ! -e stage/usr/bin/start-cosmic
|
||||||
|
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
|
||||||
|
for c in cosmic-settings cosmic-settings-daemon cosmic-osd cosmic-notifications; do
|
||||||
|
test -x "stage/usr/bin/$c" || { echo "missing $c" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
echo "staged files: $(find stage -type f | wc -l)"
|
||||||
|
|
||||||
|
# Checked here, once, rather than in each of the three packaging recipes,
|
||||||
|
# and not with desktop-file-validate.
|
||||||
|
#
|
||||||
|
# desktop-file-validate rejects DesktopNames -- "keys extending the format
|
||||||
|
# should start with X-" -- because the Desktop Entry Specification
|
||||||
|
# registers keys for application launchers, and these are session files.
|
||||||
|
# DesktopNames is what a display manager reads to set XDG_CURRENT_DESKTOP,
|
||||||
|
# so the session needs it. cosmic.desktop is upstream cosmic-session's
|
||||||
|
# file, unchanged here apart from the Exec path, and the copy Fedora ships
|
||||||
|
# as cosmic-session-1.5.0-1.fc44 fails the identical check: the validator
|
||||||
|
# has no entry for the key, and every distribution ships the file anyway.
|
||||||
|
#
|
||||||
|
# What the validator would not have caught is the failure that actually
|
||||||
|
# matters: an Exec naming a binary this package does not install puts an
|
||||||
|
# entry on the greeter's menu that dies silently when chosen. So that is
|
||||||
|
# what is checked, against the tree about to be packaged.
|
||||||
|
- name: Check the session entries
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for f in stage/usr/share/wayland-sessions/*.desktop; do
|
||||||
|
echo "== $f"
|
||||||
|
cat "$f"
|
||||||
|
test "$(sed -n 1p "$f")" = '[Desktop Entry]' || {
|
||||||
|
echo "$f: first line is not [Desktop Entry]" >&2; exit 1; }
|
||||||
|
for key in Name Type Exec DesktopNames; do
|
||||||
|
grep -q "^${key}=" "$f" || { echo "$f: no $key=" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
grep -qx 'Type=Application' "$f" || {
|
||||||
|
echo "$f: Type is not Application; a greeter will ignore it" >&2; exit 1; }
|
||||||
|
exec_path=$(sed -n '0,/^Exec=/s/^Exec=//p' "$f" | cut -d' ' -f1)
|
||||||
|
case $exec_path in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "$f: Exec=$exec_path is not absolute" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
test -x "stage${exec_path}" || {
|
||||||
|
echo "$f: Exec=$exec_path is not an executable this package installs" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " Exec -> stage${exec_path} ok"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- Fedora -------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The file list is generated rather than written into the spec. Across 27
|
||||||
|
# components a hand-maintained %files would be stale within a week, and
|
||||||
|
# stale in the direction that omits files nobody misses until a login
|
||||||
|
# fails.
|
||||||
|
#
|
||||||
|
# Directories need care. A %dir line for every staged directory would
|
||||||
|
# have the package claim /usr, /usr/bin and /usr/share, which the
|
||||||
|
# `filesystem` package owns -- the RPM would build fine and then refuse to
|
||||||
|
# install, or worse, take those directories with it on uninstall. So a
|
||||||
|
# directory is only claimed if no package on the build system already owns
|
||||||
|
# it, which leaves exactly the ones this fork creates
|
||||||
|
# (/usr/share/hyprcosmic and friends).
|
||||||
|
- name: Build the RPM
|
||||||
|
if: matrix.distro == 'fedora'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||||
|
: > files.list
|
||||||
|
find stage -mindepth 1 -type d -printf '%P\n' | while read -r d; do
|
||||||
|
rpm -qf --quiet "/$d" || printf '%%%%dir "/%s"\n' "$d" >> files.list
|
||||||
|
done
|
||||||
|
find stage -mindepth 1 \! -type d -printf '"/%P"\n' >> files.list
|
||||||
|
wc -l files.list
|
||||||
|
rpmbuild -bb packaging/fedora/hyprcosmic.spec \
|
||||||
|
--define "_topdir $PWD/rpmbuild" \
|
||||||
|
--define "stagedir $PWD/stage" \
|
||||||
|
--define "filelist $PWD/files.list" \
|
||||||
|
--define "ver ${{ steps.ver.outputs.version }}"
|
||||||
|
mkdir -p dist
|
||||||
|
find rpmbuild/RPMS -name '*.rpm' -exec cp -v {} dist/ \;
|
||||||
|
|
||||||
|
# A package that installs is the claim being made, so it is tested rather
|
||||||
|
# than assumed. --setopt=tsflags=test does the whole resolution and
|
||||||
|
# conflict check without writing to the container.
|
||||||
|
- name: Verify the RPM
|
||||||
|
if: matrix.distro == 'fedora'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
rpm -qpi dist/*.rpm
|
||||||
|
rpm -qp --requires dist/*.rpm
|
||||||
|
dnf -y install --setopt=tsflags=test dist/*.rpm
|
||||||
|
|
||||||
|
# ---- Arch ---------------------------------------------------------
|
||||||
|
#
|
||||||
|
# makepkg refuses to run as root, and a container is root by default, so
|
||||||
|
# the build runs as an unprivileged user that owns the tree it reads.
|
||||||
|
# --nodeps because the depends array names a running system's runtime
|
||||||
|
# libraries, which this image has no reason to hold, and nothing is being
|
||||||
|
# compiled at this point anyway.
|
||||||
|
- name: Build the Arch package
|
||||||
|
if: matrix.distro == 'arch'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
useradd -m builder
|
||||||
|
mkdir -p dist
|
||||||
|
cp packaging/arch/PKGBUILD .
|
||||||
|
chown -R builder:builder .
|
||||||
|
sudo -u builder \
|
||||||
|
HYPRCOSMIC_STAGEDIR="$PWD/stage" \
|
||||||
|
HYPRCOSMIC_VERSION="${{ steps.ver.outputs.version }}" \
|
||||||
|
PKGDEST="$PWD/dist" \
|
||||||
|
makepkg --nodeps --noconfirm
|
||||||
|
|
||||||
|
- name: Verify the Arch package
|
||||||
|
if: matrix.distro == 'arch'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
pacman -Qip dist/*.pkg.tar.zst
|
||||||
|
# Contents rather than an install: pacman has no dry run that resolves
|
||||||
|
# dependencies without touching the filesystem, and this image is not
|
||||||
|
# a desktop, so a real install would fail on runtime libraries that
|
||||||
|
# say nothing about whether the package is well formed.
|
||||||
|
# To a file and then head: `| head` would kill pacman with SIGPIPE
|
||||||
|
# once head has its 20 lines, and these steps run with pipefail.
|
||||||
|
pacman -Qlp dist/*.pkg.tar.zst > contents.txt
|
||||||
|
echo "entries: $(wc -l < contents.txt)"
|
||||||
|
head -20 contents.txt
|
||||||
|
|
||||||
|
# ---- Debian -------------------------------------------------------
|
||||||
|
#
|
||||||
|
# dpkg-deb --build over a staged tree, rather than a full source package.
|
||||||
|
# The Depends line is computed by dpkg-shlibdeps from the binaries
|
||||||
|
# themselves rather than written by hand -- 27 components link against
|
||||||
|
# more libraries than anyone will keep an accurate list of, and a hand
|
||||||
|
# list is wrong in the direction that installs and then fails to start.
|
||||||
|
#
|
||||||
|
# dpkg-shlibdeps insists on a debian/control in the working directory even
|
||||||
|
# when invoked outside a source package, hence the stub.
|
||||||
|
- name: Build the Debian package
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
mkdir -p debian
|
||||||
|
printf 'Source: hyprcosmic\n\nPackage: hyprcosmic\nArchitecture: amd64\n' > debian/control
|
||||||
|
binaries=$(find stage -type f -perm -100 -exec sh -c 'file -b "$1" | grep -q ELF && echo "$1"' _ {} \;)
|
||||||
|
dpkg-shlibdeps -O --ignore-missing-info $binaries > shlibdeps.txt
|
||||||
|
deps=$(sed 's/^shlibs:Depends=//' shlibdeps.txt)
|
||||||
|
size=$(du -sk stage | cut -f1)
|
||||||
|
|
||||||
|
mkdir -p stage/DEBIAN
|
||||||
|
sed -e "s|@VERSION@|${{ steps.ver.outputs.version }}|" \
|
||||||
|
-e "s|@INSTALLED_SIZE@|$size|" \
|
||||||
|
-e "s|@SHLIB_DEPENDS@|$deps|" \
|
||||||
|
packaging/debian/control.in > stage/DEBIAN/control
|
||||||
|
cat stage/DEBIAN/control
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
dpkg-deb --build --root-owner-group stage \
|
||||||
|
"dist/hyprcosmic_${{ steps.ver.outputs.version }}_amd64.deb"
|
||||||
|
|
||||||
|
- name: Verify the Debian package
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
dpkg-deb --info dist/*.deb
|
||||||
|
# To a file and then head, not `| head`. Actions runs these steps with
|
||||||
|
# pipefail, and head closing the pipe after 20 lines kills dpkg-deb
|
||||||
|
# with SIGPIPE, which pipefail reports as a failed step -- a green
|
||||||
|
# 217 MB package failed here on nothing but that.
|
||||||
|
dpkg-deb --contents dist/*.deb > contents.txt
|
||||||
|
echo "entries: $(wc -l < contents.txt)"
|
||||||
|
head -20 contents.txt
|
||||||
|
# lintian is not installed and would fail this package on a dozen
|
||||||
|
# policy points that do not apply to a desktop fork shipped outside
|
||||||
|
# the archive. What matters here is that dpkg can read it back.
|
||||||
|
dpkg-deb --fsys-tarfile dist/*.deb | tar -tf - >/dev/null
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: hyprcosmic-${{ matrix.distro }}
|
||||||
|
path: dist/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# Only on a tag. A dispatch run is for getting artifacts to try, and turning
|
||||||
|
# one into a public release would make every experiment look like a shipped
|
||||||
|
# version.
|
||||||
|
release:
|
||||||
|
needs: package
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Attach the packages to the release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/*
|
||||||
|
# Draft, deliberately. These install beside the distribution's COSMIC
|
||||||
|
# rather than over it, so a bad one costs a logout rather than a
|
||||||
|
# desktop -- but a tag push is still not a decision to publish. The
|
||||||
|
# generated notes are written by a machine reading commit subjects,
|
||||||
|
# and the Debian package differs from the other two in what it
|
||||||
|
# carries; both are worth a human reading before anyone downloads.
|
||||||
|
draft: true
|
||||||
|
generate_release_notes: true
|
||||||
+13
-1
@@ -1,3 +1,15 @@
|
|||||||
|
# Upstream cosmic-epoch's entries
|
||||||
.vscode
|
.vscode
|
||||||
cosmic-sysext
|
cosmic-sysext
|
||||||
*_build
|
*_build
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
target/
|
||||||
|
**/*.rs.bk
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# OMC operational artifacts
|
||||||
|
.omc/
|
||||||
|
|
||||||
|
# The two forks are NOT ignored. They are submodules -- see .gitmodules -- and
|
||||||
|
# ignoring them would only hide the commit each one is pinned to.
|
||||||
|
|||||||
+10
-2
@@ -1,10 +1,10 @@
|
|||||||
[submodule "cosmic-session"]
|
[submodule "cosmic-session"]
|
||||||
path = cosmic-session
|
path = cosmic-session
|
||||||
url = https://github.com/pop-os/cosmic-session
|
url = https://github.com/outbackdingo/hyprcosmic-session
|
||||||
branch = master
|
branch = master
|
||||||
[submodule "cosmic-comp"]
|
[submodule "cosmic-comp"]
|
||||||
path = cosmic-comp
|
path = cosmic-comp
|
||||||
url = https://github.com/pop-os/cosmic-comp
|
url = https://github.com/outbackdingo/hyprcosmic-comp
|
||||||
branch = master
|
branch = master
|
||||||
[submodule "cosmic-panel"]
|
[submodule "cosmic-panel"]
|
||||||
path = cosmic-panel
|
path = cosmic-panel
|
||||||
@@ -106,3 +106,11 @@
|
|||||||
path = pop-launcher
|
path = pop-launcher
|
||||||
url = https://github.com/pop-os/launcher.git
|
url = https://github.com/pop-os/launcher.git
|
||||||
branch = master
|
branch = master
|
||||||
|
[submodule "cosmic-monitor"]
|
||||||
|
path = cosmic-monitor
|
||||||
|
url = https://github.com/pop-os/cosmic-monitor.git
|
||||||
|
branch = master
|
||||||
|
[submodule "cosmic-sound-theme"]
|
||||||
|
path = cosmic-sound-theme
|
||||||
|
url = https://github.com/pop-os/cosmic-sound-theme.git
|
||||||
|
branch = master
|
||||||
|
|||||||
@@ -1,339 +1,365 @@
|
|||||||
# COSMIC Desktop
|
# HyprCosmic
|
||||||
|
|
||||||
[COSMIC](https://system76.com/cosmic) is a desktop environment offering performance, efficiency, and personalization to empower a wide variety of use cases.
|
COSMIC's compositor, driven the way Hyprland is configured, wearing a HyDE
|
||||||
|
shell.
|
||||||
|
|
||||||
## Components of COSMIC Desktop
|
It is a fork of [cosmic-epoch](https://github.com/pop-os/cosmic-epoch), the
|
||||||
* [cosmic-applets](https://github.com/pop-os/cosmic-applets)
|
meta-repository that names every COSMIC component and builds the desktop out of
|
||||||
* [cosmic-applibrary](https://github.com/pop-os/cosmic-applibrary)
|
them. Two of its 29 submodules point at forks; the other 27 are System76's,
|
||||||
* [cosmic-bg](https://github.com/pop-os/cosmic-bg)
|
unchanged. So this is not a re-implementation of COSMIC and not a theme pack
|
||||||
* [cosmic-comp](https://github.com/pop-os/cosmic-comp)
|
sitting beside it — it is COSMIC, built from source, with a different shell on
|
||||||
* [cosmic-edit](https://github.com/pop-os/cosmic-edit)
|
top and a different way of telling it what to do.
|
||||||
* [cosmic-files](https://github.com/pop-os/cosmic-files)
|
|
||||||
* [cosmic-greeter](https://github.com/pop-os/cosmic-greeter)
|
|
||||||
* [cosmic-icons](https://github.com/pop-os/cosmic-icons)
|
|
||||||
* [cosmic-idle](https://github.com/pop-os/cosmic-idle)
|
|
||||||
* [cosmic-initial-setup](https://github.com/pop-os/cosmic-initial-setup)
|
|
||||||
* [cosmic-launcher](https://github.com/pop-os/cosmic-launcher)
|
|
||||||
* [cosmic-notifications](https://github.com/pop-os/cosmic-notifications)
|
|
||||||
* [cosmic-osd](https://github.com/pop-os/cosmic-osd)
|
|
||||||
* [cosmic-panel](https://github.com/pop-os/cosmic-panel)
|
|
||||||
* [cosmic-player](https://github.com/pop-os/cosmic-player)
|
|
||||||
* [cosmic-randr](https://github.com/pop-os/cosmic-randr)
|
|
||||||
* [cosmic-screenshot](https://github.com/pop-os/cosmic-screenshot)
|
|
||||||
* [cosmic-session](https://github.com/pop-os/cosmic-session)
|
|
||||||
* [cosmic-settings](https://github.com/pop-os/cosmic-settings)
|
|
||||||
* [cosmic-settings-daemon](https://github.com/pop-os/cosmic-settings-daemon)
|
|
||||||
* [cosmic-store](https://github.com/pop-os/cosmic-store)
|
|
||||||
* [cosmic-term](https://github.com/pop-os/cosmic-term)
|
|
||||||
* [cosmic-theme-editor](https://github.com/pop-os/cosmic-theme-editor)
|
|
||||||
* [cosmic-workspaces-epoch](https://github.com/pop-os/cosmic-workspaces-epoch)
|
|
||||||
* [xdg-desktop-portal-cosmic](https://github.com/pop-os/xdg-desktop-portal-cosmic)
|
|
||||||
* [pop-launcher](https://github.com/pop-os/launcher)
|
|
||||||
|
|
||||||
### COSMIC libraries/crates
|
Three things distinguish a HyprCosmic session from a COSMIC one:
|
||||||
|
|
||||||
* [cosmic-protocols](https://github.com/pop-os/cosmic-protocols)
|
- **Hyprland's configuration idiom.** A single `~/.config/hyprcosmic/cosmic.conf`
|
||||||
* [cosmic-text](https://github.com/pop-os/cosmic-text)
|
with `general { }` blocks, `bind =` lines and `$variables` is compiled into
|
||||||
* [cosmic-theme](https://github.com/pop-os/cosmic-theme)
|
COSMIC's config tree. The file wins: what it names, it owns.
|
||||||
* [cosmic-time](https://github.com/pop-os/cosmic-time)
|
- **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of
|
||||||
|
cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported
|
||||||
|
directly, palette and wallpapers and all.
|
||||||
|
- **It installs next to COSMIC rather than over it.** The binaries are
|
||||||
|
`/usr/bin/hyprcosmic-comp`, `/usr/bin/hyprcosmic-session` and
|
||||||
|
`/usr/bin/hyprcosmic-conf`, and nothing here writes a path the distribution
|
||||||
|
owns. The stock COSMIC entry stays on the greeter's menu, served by the
|
||||||
|
distribution's own binaries, so the day the HyDE session does not start is one
|
||||||
|
logout away from a desktop that does. (On Debian, where COSMIC is not
|
||||||
|
packaged, the `.deb` carries the desktop itself — see [Installing](#installing).)
|
||||||
|
|
||||||
### COSMIC toolkit for apps and applets
|
## Repository layout
|
||||||
|
|
||||||
* [libcosmic](https://github.com/pop-os/libcosmic)
|
Everything in `cosmic-epoch`, plus:
|
||||||
|
|
||||||
## Installing on Pop!\_OS
|
| Path | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `cosmic-comp/` | submodule → [outbackdingo/hyprcosmic-comp](https://github.com/outbackdingo/hyprcosmic-comp) |
|
||||||
|
| `cosmic-session/` | submodule → [outbackdingo/hyprcosmic-session](https://github.com/outbackdingo/hyprcosmic-session) |
|
||||||
|
| `cosmic-conf/` | the config compiler and HyDE theme importer. A crate in this repository, not a submodule |
|
||||||
|
| `config/` | the shipped `cosmic.conf`, `autostart`, waybar and rofi assets, and the power menu |
|
||||||
|
| `tools/install-assets.sh` | installs the parts of `config/` that live outside `$HOME`, and `--check`s them for drift |
|
||||||
|
| `docs/` | the design spec, a debugging guide, and one written-up bug that is still open |
|
||||||
|
|
||||||
### Pop!\_OS 24.04
|
The other 27 submodules stay on `pop-os`. Nothing about them needs to change,
|
||||||
|
and pinning them to copies nobody maintains would be a promise to keep 27 forks
|
||||||
|
current.
|
||||||
|
|
||||||
COSMIC DE's first release (Epoch 1) is included in Pop!\_OS 24.04. There are two ways to get the 24.04 release:
|
### What the two forks change
|
||||||
|
|
||||||
- Install it from the [latest release ISO](https://system76.com/cosmic/).
|
**cosmic-comp** — four patches, each independent:
|
||||||
- Upgrade an existing Pop!\_OS 22.04 installation using the following command: `pop-upgrade release upgrade -f`
|
|
||||||
- If you experience problems during the upgrade, please open an issue in the [pop-upgrade GitHub repository](https://github.com/pop-os/upgrade) or join the [Pop!\_OS Mattermost chat server](https://chat.pop-os.org) for assistance.
|
|
||||||
|
|
||||||
COSMIC users, including Pop!_OS users, are welcome to join the [Pop!\_OS Mattermost chat server](https://chat.pop-os.org) to receive news about development. Join the [COSMIC Epoch channel](https://chat.pop-os.org/pop-os/channels/cosmic-epoch) for COSMIC user discussion, or the [Development channel](https://chat.pop-os.org/pop-os/channels/development) for developer-oriented discussion.
|
- `zwlr_foreign_toplevel_management_v1`, which is the protocol waybar's window
|
||||||
|
list and rofi's window mode read. Without it the taskbar is empty.
|
||||||
|
- A Hyprland-compatible IPC socket (`.socket.sock` and the `.socket2.sock` event
|
||||||
|
stream) under the names Hyprland clients actually open, so HyDE's scripts and
|
||||||
|
waybar's `hyprland/*` modules work unmodified. The write surface is
|
||||||
|
deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
|
||||||
|
because this is the surface any process that can open the socket gets.
|
||||||
|
- New windows open *beside* the focused window rather than inside it.
|
||||||
|
- The install goes to `/usr/bin/hyprcosmic-comp`, alongside upstream's two
|
||||||
|
`.ron` defaults files, which are carried unmodified. The distribution's
|
||||||
|
`cosmic-comp` is left where it is, for the stock session to keep using.
|
||||||
|
|
||||||
### Pop!\_OS 22.04
|
**cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by
|
||||||
|
`hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library,
|
||||||
|
cosmic-workspaces, cosmic-bg and cosmic-files-applet, then starts whatever
|
||||||
|
`~/.config/hyprcosmic/autostart` names. cosmic-greeter is deliberately *not*
|
||||||
|
skippable — a display manager is the easiest thing to lock yourself out of. The
|
||||||
|
fork installs three files where upstream installs seven; the four it drops are
|
||||||
|
owned by the distribution's own `cosmic-session` package and writing them would
|
||||||
|
make the two conflict.
|
||||||
|
|
||||||
Due to dependency requirements, **COSMIC Epoch is no longer receiving updates on Pop!\_OS 22.04 LTS.** It's no longer recommended to test COSMIC Epoch on Pop!\_OS 22.04 because the latest bug fixes and features are only available on newer distributions such as Pop!\_OS 24.04.
|
## Building
|
||||||
|
|
||||||
Individual COSMIC applications work in the default GNOME session of Pop!\_OS 22.04. You can install individual COSMIC applications using the following command:
|
|
||||||
|
|
||||||
```
|
|
||||||
sudo apt install cosmic-edit cosmic-files cosmic-player cosmic-store cosmic-term
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Old Release on 22.04
|
|
||||||
|
|
||||||
An **older release** of the COSMIC Epoch desktop environment alpha is still available on Pop!\_OS 22.04 LTS. If you encounter bugs while testing COSMIC Epoch on Pop!\_OS 22.04, please check if they exist in Pop!\_OS 24.04 before reporting them. You can install the older release on 22.04 with these instructions:
|
|
||||||
|
|
||||||
##### Enable Wayland
|
|
||||||
|
|
||||||
`sudo nano /etc/gdm3/custom.conf`
|
|
||||||
|
|
||||||
Change `WaylandEnable` to `true`:
|
|
||||||
```
|
|
||||||
WaylandEnable=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Reboot for this change to take effect.
|
|
||||||
|
|
||||||
##### Update udev rules for NVIDIA users
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo nano /usr/lib/udev/rules.d/61-gdm.rules
|
git clone --recurse-submodules https://github.com/outbackdingo/hyprcosmic
|
||||||
|
cd hyprcosmic
|
||||||
|
just build
|
||||||
```
|
```
|
||||||
|
|
||||||
Look for `LABEL="gdm_prefer_xorg"` and `LABEL="gdm_disable_wayland"`. Add `#` to the `RUN` statements so they look like this:
|
Build dependencies are COSMIC's — see [upstream's list](https://github.com/pop-os/cosmic-epoch#setup-on-distributions-without-packaging-of-cosmic-components),
|
||||||
|
which is long and distribution-specific. `rustup` is recommended over the
|
||||||
|
distribution's rustc: `cosmic-comp` is edition 2024 and pins Rust 1.93 in its
|
||||||
|
`rust-toolchain.toml`, which is newer than several stable distributions ship —
|
||||||
|
Debian bookworm's rustc is 1.63. `just` is likewise absent before Debian
|
||||||
|
trixie; `cargo install just --locked` covers it.
|
||||||
|
|
||||||
```
|
## Installing
|
||||||
LABEL="gdm_prefer_xorg"
|
|
||||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon PreferredDisplayServer xorg"
|
|
||||||
GOTO="gdm_end"
|
|
||||||
|
|
||||||
LABEL="gdm_disable_wayland"
|
The easiest route is a package. Every tag builds one for Fedora, Arch and Debian
|
||||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon WaylandEnable false"
|
and attaches it to a draft release; `workflow_dispatch` on **Packages** builds
|
||||||
GOTO="gdm_end"
|
them at any other time and leaves them as run artifacts.
|
||||||
```
|
|
||||||
|
|
||||||
Restart gdm
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo systemctl restart gdm
|
sudo dnf install ./hyprcosmic-*.rpm # Fedora
|
||||||
|
sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
|
||||||
|
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Install COSMIC
|
Nothing is removed and nothing conflicts. COSMIC is a dependency rather than a
|
||||||
|
casualty: the package installs `hyprcosmic-comp`, `hyprcosmic-session` and
|
||||||
|
`hyprcosmic-conf` beside the distribution's, and takes cosmic-settings, the
|
||||||
|
portal, the OSD and the rest from the distribution at the version it tested
|
||||||
|
them at. Log out and pick **HyprCosmic** from the greeter; pick **COSMIC** to go
|
||||||
|
back.
|
||||||
|
|
||||||
`sudo apt install cosmic-session`
|
An earlier revision did take the `cosmic-*` names, and it could not be
|
||||||
|
installed. Its files collided with 25 distribution packages, and the only way to
|
||||||
|
satisfy that was to erase them — including cosmic-greeter, which on a Fedora
|
||||||
|
COSMIC install *is* the display manager. A desktop you can only try by removing
|
||||||
|
the desktop you would fall back to is not one worth shipping.
|
||||||
|
|
||||||
After logging out, click on your user and there will be a sprocket at the bottom right. Change the setting to COSMIC. Proceed to log in.
|
**Debian is the exception**, because COSMIC is not packaged there — no
|
||||||
|
`cosmic-session`, no `cosmic-comp`, in any suite. There is nothing to depend on
|
||||||
|
and nothing to install beside, so the `.deb` carries the whole desktop it
|
||||||
|
compiled and stands alone, and the greeter offers **HyprCosmic** only. The
|
||||||
|
Fedora and Arch packages ship this fork's three binaries and nothing else.
|
||||||
|
|
||||||
## Installing on Arch Linux
|
Building it yourself instead:
|
||||||
Install via [cosmic-session](https://archlinux.org/packages/extra/x86_64/cosmic-session/) or the [cosmic](https://archlinux.org/groups/x86_64/cosmic/) group, e.g.:
|
|
||||||
`pacman -S cosmic-session` or `pacman -S cosmic`
|
|
||||||
|
|
||||||
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
|
|
||||||
For a more detailed discussion, consider the [relevant section in the Arch wiki](https://wiki.archlinux.org/title/COSMIC).
|
|
||||||
|
|
||||||
## Installing on Fedora Linux
|
|
||||||
|
|
||||||
COSMIC can be installed from the built-in repositories on Fedora 41+:
|
|
||||||
|
|
||||||
```
|
|
||||||
sudo dnf install @cosmic-desktop-environment
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, for more up-to-date COSMIC packages (but less quality control), you can use the nightly COPR builds:
|
|
||||||
|
|
||||||
```
|
|
||||||
sudo dnf copr enable ryanabx/cosmic-epoch && sudo dnf install cosmic-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
After installing, log out, click on your user, and use the sprocket in the bottom right to select the COSMIC desktop environment before logging in.
|
|
||||||
For more information, check the [Fedora Wiki COSMIC SIG page](https://fedoraproject.org/wiki/SIGs/COSMIC) or the [COPR page](https://copr.fedorainfracloud.org/coprs/ryanabx/cosmic-epoch/).
|
|
||||||
|
|
||||||
## Installing on NixOS
|
|
||||||
The COSMIC module on NixOS can be enabled by adding the following lines to
|
|
||||||
your NixOS configuration file (`configuration.nix` or in your Flake):
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
# Enable the COSMIC login manager
|
|
||||||
services.displayManager.cosmic-greeter.enable = true;
|
|
||||||
|
|
||||||
# Enable the COSMIC desktop environment
|
|
||||||
services.desktopManager.cosmic.enable = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
While some packages like `cosmic-session` might be present in prior versions,
|
|
||||||
the modules that add full support for COSMIC were added in **NixOS 25.05**.
|
|
||||||
|
|
||||||
You can find more details on the [NixOS Wiki](https://wiki.nixos.org/wiki/COSMIC).
|
|
||||||
|
|
||||||
|
|
||||||
## Installing on openSUSE tumbleweed
|
|
||||||
Cosmic can be installed by adding X11:COSMIC:Factory repo with opi.
|
|
||||||
```
|
|
||||||
opi patterns-cosmic
|
|
||||||
```
|
|
||||||
Select X11:COSMIC:Factory, after installing keep the repo.
|
|
||||||
|
|
||||||
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
|
|
||||||
For further information, you may check the [OBS page](https://build.opensuse.org/project/show/X11:COSMIC:Factory).
|
|
||||||
|
|
||||||
## Installing on Gentoo Linux
|
|
||||||
COSMIC can be installed on Gentoo via a custom overlay. Add the overlay using your preferred overlay manager (such as eselect), and then install the desktop environment:
|
|
||||||
|
|
||||||
`eselect repository add cosmic-overlay git https://github.com/fsvm88/cosmic-overlay.git`
|
|
||||||
|
|
||||||
Next, install the COSMIC desktop environment and its associated themes:
|
|
||||||
|
|
||||||
`emerge cosmic-meta pop-theme-meta`
|
|
||||||
|
|
||||||
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
|
|
||||||
For further information, you may check the [Gentoo Wiki](https://wiki.gentoo.org/wiki/COSMIC) or [Overlay Repository](https://github.com/fsvm88/cosmic-overlay).
|
|
||||||
|
|
||||||
## Setup on distributions without packaging of COSMIC components
|
|
||||||
|
|
||||||
The COSMIC desktop environment requires a few dependencies. The rustc and just packages of your distro may be too old, so we recommend installing rustc and cargo with rustup, and installing just with cargo.
|
|
||||||
(This list does not try to be exhaustive, but rather tries to provide a decent starting point. For detailed instructions, check out the individual projects):
|
|
||||||
|
|
||||||
- [just](https://github.com/casey/just)
|
|
||||||
- rustc
|
|
||||||
- cargo
|
|
||||||
- c compiler (cc)
|
|
||||||
- make
|
|
||||||
- git
|
|
||||||
- libwayland
|
|
||||||
- mesa (or third-party libEGL/libGL implementations, though interfacing with mesa's libglvnd is generally recommended).
|
|
||||||
- libseat
|
|
||||||
- libxkbcommon
|
|
||||||
- libinput
|
|
||||||
- udev
|
|
||||||
- dbus
|
|
||||||
- libdisplay-info-dev
|
|
||||||
- libgstreamer1.0-dev
|
|
||||||
- libgstreamer-plugins-base1.0-dev
|
|
||||||
|
|
||||||
optionally (though the build-system might currently require these libraries):
|
|
||||||
- libsystem
|
|
||||||
- libpulse
|
|
||||||
- libexpat1
|
|
||||||
- libfontconfig
|
|
||||||
- libfreetype
|
|
||||||
- lld
|
|
||||||
- libgbm-dev
|
|
||||||
- libclang-dev
|
|
||||||
- libpipewire-0.3-dev
|
|
||||||
|
|
||||||
Note: `libfontconfig`, `libfreetype`, and `lld` are packages specific to Linux distributions. You may need to find the equivalent version for your distribution if you are not using Pop!_OS.
|
|
||||||
|
|
||||||
The required ones can be installed with:
|
|
||||||
```
|
|
||||||
sudo apt install -y \
|
|
||||||
build-essential \
|
|
||||||
dbus \
|
|
||||||
git \
|
|
||||||
libdbus-1-dev \
|
|
||||||
libdisplay-info-dev \
|
|
||||||
libflatpak-dev \
|
|
||||||
libglvnd-dev \
|
|
||||||
libgstreamer-plugins-base1.0-dev \
|
|
||||||
libgstreamer1.0-dev \
|
|
||||||
libinput-dev \
|
|
||||||
libpam0g-dev \
|
|
||||||
libpixman-1-dev \
|
|
||||||
libseat-dev \
|
|
||||||
libssl-dev \
|
|
||||||
libwayland-dev \
|
|
||||||
libxkbcommon-dev \
|
|
||||||
rustup \
|
|
||||||
udev
|
|
||||||
|
|
||||||
rustup toolchain install stable
|
|
||||||
cargo install just
|
|
||||||
```
|
|
||||||
|
|
||||||
and the optional ones with:
|
|
||||||
```
|
|
||||||
sudo apt install -y \
|
|
||||||
libclang-dev \
|
|
||||||
libexpat1-dev \
|
|
||||||
libfontconfig-dev \
|
|
||||||
libfreetype-dev \
|
|
||||||
libgbm-dev \
|
|
||||||
libpipewire-0.3-dev \
|
|
||||||
libpulse-dev \
|
|
||||||
libsystemd-dev \
|
|
||||||
lld \
|
|
||||||
mold
|
|
||||||
```
|
|
||||||
|
|
||||||
They can be installed all at once with:
|
|
||||||
```
|
|
||||||
sudo apt install -y \
|
|
||||||
build-essential \
|
|
||||||
dbus \
|
|
||||||
git \
|
|
||||||
libclang-dev \
|
|
||||||
libdbus-1-dev \
|
|
||||||
libdisplay-info-dev \
|
|
||||||
libexpat1-dev \
|
|
||||||
libflatpak-dev \
|
|
||||||
libfontconfig-dev \
|
|
||||||
libfreetype-dev \
|
|
||||||
libgbm-dev \
|
|
||||||
libglvnd-dev \
|
|
||||||
libgstreamer-plugins-base1.0-dev \
|
|
||||||
libgstreamer1.0-dev \
|
|
||||||
libinput-dev \
|
|
||||||
libpam0g-dev \
|
|
||||||
libpipewire-0.3-dev \
|
|
||||||
libpixman-1-dev \
|
|
||||||
libpulse-dev \
|
|
||||||
libseat-dev \
|
|
||||||
libssl-dev \
|
|
||||||
libsystemd-dev \
|
|
||||||
libwayland-dev \
|
|
||||||
libxkbcommon-dev \
|
|
||||||
lld \
|
|
||||||
mold \
|
|
||||||
rustup \
|
|
||||||
udev
|
|
||||||
|
|
||||||
rustup toolchain install stable
|
|
||||||
cargo install just
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
The easiest way to test COSMIC DE currently is by building a systemd system extension (see `man systemd-sysext`).
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone --recurse-submodules https://github.com/pop-os/cosmic-epoch
|
|
||||||
cd cosmic-epoch
|
|
||||||
just sysext
|
|
||||||
```
|
|
||||||
|
|
||||||
This will create a system-extension called `cosmic-sysext`, which you can move (without renaming!) into e.g. `/var/lib/extensions`.
|
|
||||||
After starting systemd-sysext.service (`sudo systemctl enable --now systemd-sysext`) and refreshing (`sudo systemd-sysext refresh`) or rebooting,
|
|
||||||
COSMIC will be an available option in your favorite display manager.
|
|
||||||
|
|
||||||
If you have SELinux enabled (e.g. on Fedora), the installed extension won't have the correct labels applied.
|
|
||||||
To test COSMIC, you can temporarily disable it and restart `gdm` (note that this will close your running programs).
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo setenforce 0
|
sudo just install '' /usr
|
||||||
sudo systemctl restart gdm
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: An extension created this way will be linked against specific libraries on your system and will not work on other distributions.
|
The two positional arguments are `rootdir` (a staging root, for packaging) and
|
||||||
It also requires the previously mentioned libraries/dependencies at runtime to be installed in your system (the system extension does not carry these libraries).
|
`prefix`. **Use `/usr`, not the `/usr/local` default.** Several files name
|
||||||
|
`/usr/share/hyprcosmic` as a literal because they have no way to interpolate a
|
||||||
|
prefix — a rofi `.rasi` has no variables, `hyprcosmic.desktop` has no way to
|
||||||
|
expand one into `Exec=`, and `autostart` is deliberately not a shell.
|
||||||
|
`install-assets.sh` prints the exact list when you use another prefix.
|
||||||
|
|
||||||
**Read-Only Filesystem**: If you're not on an immutable distro you may notice that `/usr/` and `/opt/` are read-only.
|
To stage instead of install:
|
||||||
this is caused by `systemd-sysext` being enabled, when you are done testing you can disable `systemd-sysext` (`sudo systemctl disable --now systemd-sysext`)
|
|
||||||
|
|
||||||
It is thus not a proper method for long term deployment.
|
```shell
|
||||||
|
just install /tmp/stage /usr
|
||||||
|
```
|
||||||
|
|
||||||
### Packaging
|
This installs all of COSMIC — the 27 unmodified components as well — plus
|
||||||
|
`cosmic-conf` at `$prefix/bin/cosmic-conf`, the shared waybar and rofi assets
|
||||||
|
under `$prefix/share/hyprcosmic/`, and `hyprcosmic-powermenu`.
|
||||||
|
|
||||||
COSMIC DE is packaged for Pop!_OS. For reference, look at the `debian` folders in the projects repositories.
|
Note that `just install` is not what the packages do. It writes upstream's whole
|
||||||
These and the `justfile` inside this repository may be used as references on how to package COSMIC DE, though no backwards-compatibility guarantees are provided at this stage.
|
desktop at upstream's names, so run against `/usr` on a machine that has COSMIC
|
||||||
|
packaged it will overwrite files your package manager owns. The packages are
|
||||||
|
built from this same tree and then reduced to this fork's own files and renamed;
|
||||||
|
that step lives in `.github/workflows/packages.yml`, not in the justfile, which
|
||||||
|
is upstream's. Stage to a directory and inspect it, or install a package.
|
||||||
|
|
||||||
### Versioning
|
`install` depends on `build`, which is upstream's arrangement and means `sudo
|
||||||
|
just install` compiles as root. That is inherited, not chosen; if you would
|
||||||
|
rather not, build into a staging root as your own user and copy it into place.
|
||||||
|
|
||||||
COSMIC DE is a work in progress with many moving pieces.
|
Then log out. `HyprCosmic` appears on the greeter's session menu next to
|
||||||
We do our best to keep the referenced submodule commits in this repository building and working together; as a consequence, they might not contain the latest updates and features from these repositories (yet).
|
`COSMIC`; both work.
|
||||||
|
|
||||||
The commits corresponding with the current release are tagged `epoch-X.Y.Z`, where `X` is the major release and the last two numbers denote incremental minor releases. (During development of new major versions, an additional `-alpha.Y.Z` or `-beta.Y.Z` may be appended.)
|
### Per-user setup
|
||||||
|
|
||||||
COSMIC Epoch version numbers are mainly for the benefit of non-Pop!_OS distributions; Pop!_OS uses its own build system, and typically receives updates to individual submodules before they're tagged as part of a COSMIC Epoch release.
|
Nothing to do. The session seeds `~/.config` from `/usr/share/hyprcosmic/skel`
|
||||||
|
at login, copying only what is missing:
|
||||||
|
|
||||||
## Translating
|
```
|
||||||
|
~/.config/hyprcosmic/autostart
|
||||||
|
~/.config/hyprcosmic/cosmic.conf
|
||||||
|
~/.config/hyprcosmic/waybar/style.css
|
||||||
|
~/.config/hyprcosmic/waybar/theme.css
|
||||||
|
~/.config/rofi/config.rasi
|
||||||
|
~/.config/rofi/theme.rasi
|
||||||
|
~/.config/rofi/local.rasi
|
||||||
|
```
|
||||||
|
|
||||||
To submit translations for COSMIC in your language, please use Weblate: https://hosted.weblate.org/projects/pop-os/
|
An existing file is never touched, not even when the skeleton is newer — the
|
||||||
|
file wins, one way, and a login is not an invitation to edit your config. Two
|
||||||
|
things follow: your edits survive every login and upgrade, and deleting a file
|
||||||
|
is how you ask for the default back. `~/.cache/hyprcosmic/session.log` records
|
||||||
|
what was seeded.
|
||||||
|
|
||||||
## Contact
|
The cost of that is real and worth stating: an upgrade that improves a shipped
|
||||||
- [Mattermost](https://chat.pop-os.org/)
|
default will not reach a file you already have. If a release changes something
|
||||||
- [Twitter](https://twitter.com/pop_os_official)
|
you want — the `autostart` line that sets the wallpaper did change once — take
|
||||||
- [Instagram](https://www.instagram.com/pop_os_official/)
|
it deliberately, after reading what you would lose:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
diff -u ~/.config/hyprcosmic/autostart /usr/share/hyprcosmic/skel/hyprcosmic/autostart
|
||||||
|
cp /usr/share/hyprcosmic/skel/hyprcosmic/autostart ~/.config/hyprcosmic/
|
||||||
|
```
|
||||||
|
|
||||||
|
`autostart` is the one that matters most, because it is what starts waybar, the
|
||||||
|
wallpaper daemon and `hyprcosmic-conf watch`. Until the session seeded it, a
|
||||||
|
machine that had never run HyprCosmic logged in to a bare compositor: running,
|
||||||
|
holding the display and accepting input, with nothing drawn on the screen and no
|
||||||
|
binding that opened anything.
|
||||||
|
|
||||||
|
`style.css` and `config.rasi` are per-user rather than shared for one reason:
|
||||||
|
each `@import`s a sibling holding the installed HyDE theme's palette, and a
|
||||||
|
relative `@import` resolves against the importing file. Those siblings —
|
||||||
|
`theme.css`, `theme.rasi` and `local.rasi` — arrive empty and are written by
|
||||||
|
`import-theme --assets`. They ship empty rather than not at all because a
|
||||||
|
missing `@import` is fatal to both consumers rather than a warning they skip:
|
||||||
|
GTK fails the entire stylesheet, and rofi reports the error in place of the
|
||||||
|
launcher.
|
||||||
|
|
||||||
|
Working from a git checkout, `just install` still places nothing in a home
|
||||||
|
directory — under `sudo` the only home directory it could see is root's — but it
|
||||||
|
does install the skeleton, so logging in seeds the same seven files.
|
||||||
|
|
||||||
|
Runtime dependencies of the shell itself are not COSMIC's and are not built
|
||||||
|
here: `waybar`, `rofi` (wayland build), `awww` (formerly `swww`), and a Nerd
|
||||||
|
Font for the glyphs the bar and the launcher draw with.
|
||||||
|
|
||||||
|
The wallpaper is `awww`'s job, and the image it draws is
|
||||||
|
`~/.local/share/wallpapers/hyprcosmic/current` — a symlink `import-theme
|
||||||
|
--assets` maintains. Before you have imported a theme there is no such link, so
|
||||||
|
`hyprcosmic-wallpaper` falls back to whatever the distribution ships,
|
||||||
|
`/usr/share/backgrounds/cosmic` first; the Fedora package recommends
|
||||||
|
`cosmic-wallpapers` so there is something there. With no `awww` installed it
|
||||||
|
says so on the session log and leaves the background alone, rather than waiting
|
||||||
|
for a daemon that is never coming.
|
||||||
|
|
||||||
|
The font is the one thing the packaging cannot do for you on Fedora, which has
|
||||||
|
no package that provides a Nerd Font at all: `texlive-inconsolata-nerd-font`
|
||||||
|
installs under `texmf-dist` and kitty's `SymbolsNerdFont` under
|
||||||
|
`/usr/lib64/kitty`, and fontconfig scans neither. Arch has
|
||||||
|
`ttf-nerd-fonts-symbols`. Otherwise, install one into your own font directory:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mkdir -p ~/.local/share/fonts/JetBrainsMonoNerdFont
|
||||||
|
# unpack JetBrainsMono.zip from github.com/ryanoasis/nerd-fonts/releases there
|
||||||
|
fc-cache -f
|
||||||
|
fc-list ":charset=e0b0" family | grep -i nerd # non-empty: the glyphs resolve
|
||||||
|
```
|
||||||
|
|
||||||
|
Query the charset on its own, as above. Adding a family filter —
|
||||||
|
`":charset=e0b0:family=JetBrainsMono Nerd Font"` — reports nothing even when the
|
||||||
|
font does cover the codepoint, because the family string is a comma-separated
|
||||||
|
alias list (`JetBrainsMono Nerd Font,JetBrainsMono NF`). Without the font the
|
||||||
|
bar still works; every icon is a tofu box.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`~/.config/hyprcosmic/cosmic.conf`, in Hyprland's idiom, compiled into
|
||||||
|
`cosmic-config` by:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
hyprcosmic-conf apply # once
|
||||||
|
hyprcosmic-conf apply --diff # show what would change, write nothing
|
||||||
|
hyprcosmic-conf watch # recompile on every edit, for the whole session
|
||||||
|
```
|
||||||
|
|
||||||
|
`watch` is the first line of the shipped `autostart`, which is what makes "the
|
||||||
|
file wins" true at login and not only when you last ran `apply` by hand:
|
||||||
|
whatever COSMIC's settings UI stored since then is overwritten before the
|
||||||
|
desktop settles. A malformed edit is reported to the session log and the last
|
||||||
|
good configuration stays in place, so a typo cannot leave you at a broken
|
||||||
|
desktop.
|
||||||
|
|
||||||
|
The rule is one-way and deliberate. Keys this file names are overwritten from
|
||||||
|
it on every login; keys it does not name are left entirely alone, so
|
||||||
|
cosmic-settings remains the right place to change anything the file is silent
|
||||||
|
about. There is no write-back — the GUI never edits `cosmic.conf`.
|
||||||
|
|
||||||
|
`bind` lines go to the Shortcuts `custom` key, which cosmic-comp merges over
|
||||||
|
`defaults`, so the system defaults file is never touched and reverting is a
|
||||||
|
matter of deleting the lines and re-applying. Hyprland spellings and COSMIC
|
||||||
|
spellings are both accepted for the same setting (`input:follow_mouse` and
|
||||||
|
`general:focus_follows_cursor`), and the last assignment wins. Where a Hyprland
|
||||||
|
value has no COSMIC equivalent — `follow_mouse = 2` and `3`, which separate
|
||||||
|
pointer focus from keyboard focus — it is rejected with an explanation rather
|
||||||
|
than quietly rounded.
|
||||||
|
|
||||||
|
What the shipped file sets up, since the components those keys used to reach are
|
||||||
|
no longer running:
|
||||||
|
|
||||||
|
| Binding | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| `Super` (tap), `Super+/`, `Super+A` | `rofi -show drun` |
|
||||||
|
| `Super+W` | `rofi -show window`, in place of the workspace overview |
|
||||||
|
| `Super+Return` | `cosmic-term` (`Super+T` still works — cosmic-comp handles that one itself) |
|
||||||
|
| `Super+Shift+E` | `hyprcosmic-powermenu`: lock, suspend, log out, reboot, shut down |
|
||||||
|
|
||||||
|
The power menu is there because cosmic-panel hosts COSMIC's power applet, and
|
||||||
|
without the panel a session had no way out short of `systemctl reboot` from a
|
||||||
|
terminal. The same script backs waybar's power button, so the two cannot drift
|
||||||
|
apart, and it confirms before anything that ends the session.
|
||||||
|
|
||||||
|
See [`config/cosmic.conf`](config/cosmic.conf); it is commented at length and is
|
||||||
|
the reference for what is supported.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
```shell
|
||||||
|
hyprcosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
|
||||||
|
--out ~/.config/hyprcosmic/theme.conf --report --assets
|
||||||
|
```
|
||||||
|
|
||||||
|
This translates a HyDE theme into conf keys, and with `--assets` also installs
|
||||||
|
the wallpapers, GTK and icon themes, and the waybar/rofi/kitty theme files that
|
||||||
|
sit beside `hypr.theme`. `--report` prints everything that did not translate
|
||||||
|
cleanly, which is the honest half of the output.
|
||||||
|
|
||||||
|
`theme.conf` is written as a separate file and `source`d from `cosmic.conf`
|
||||||
|
rather than pasted into it. That keeps re-importing from touching your
|
||||||
|
keybindings, and anything you want to override can simply be repeated later in
|
||||||
|
`cosmic.conf`, since the last assignment to a key wins. The `source` line ships
|
||||||
|
commented out — a `source` naming a file that does not exist is a hard error,
|
||||||
|
and no theme is imported on a fresh install. Uncomment it once you have run the
|
||||||
|
command above; `import-theme` says so as well.
|
||||||
|
|
||||||
|
Change the wallpaper by repointing the `current` symlink that `--assets`
|
||||||
|
maintains, not by editing `autostart`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
|
||||||
|
~/.local/share/wallpapers/hyprcosmic/current
|
||||||
|
```
|
||||||
|
|
||||||
|
## Continuous integration
|
||||||
|
|
||||||
|
Two workflows, on purpose:
|
||||||
|
|
||||||
|
- `.github/workflows/ci.yml` is upstream's, unmodified. It builds the entire
|
||||||
|
desktop on Arch via `just sysext`, which is exactly the check a meta-repo
|
||||||
|
wants and is not made less useful by forking.
|
||||||
|
- `.github/workflows/hyprcosmic.yml` covers what upstream's does not:
|
||||||
|
`cosmic-conf` built, tested and clippy-clean on Fedora, Debian and Arch; a
|
||||||
|
check that the shipped `cosmic.conf` still parses and resolves against the
|
||||||
|
current schema; that `config/waybar/config.jsonc` is still in step with the
|
||||||
|
generator that produces it; that the template and generator stay pure ASCII;
|
||||||
|
and an `install-assets.sh` round trip into a staging root, verified with
|
||||||
|
`--check`.
|
||||||
|
|
||||||
|
The two forks carry a `hyprcosmic.yml` of the same shape, each building on
|
||||||
|
Fedora, Debian and Arch and asserting that its install landed at upstream's
|
||||||
|
paths — and that nothing landed in the private `/usr/libexec/hyprcosmic/` this
|
||||||
|
fork used to use, which is the assertion that would otherwise rot quietly.
|
||||||
|
|
||||||
|
`packages.yml` in this repository builds installable packages for the same three
|
||||||
|
distributions: an RPM, a `.pkg.tar.zst` and a `.deb`, each compiled inside a
|
||||||
|
container of the distribution it targets so the sonames it records are the ones
|
||||||
|
the installing machine will have. It runs on tags and on demand, not on every
|
||||||
|
push — three full desktop builds is hours of runner time. Tags additionally open
|
||||||
|
a **draft** release with the packages attached; drafts rather than published,
|
||||||
|
because installing one of these replaces the machine's desktop.
|
||||||
|
|
||||||
|
The waybar generator deserves its own note. `config.jsonc` is generated from
|
||||||
|
`config.jsonc.in` and a codepoint table in `generate-config.py`, and is never
|
||||||
|
hand-edited: Nerd Font glyphs live in the Private Use Area, where they are
|
||||||
|
destroyed by being retyped and indistinguishable from each other in a diff. CI
|
||||||
|
regenerates the file and fails if it moves.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- The `/usr/share/hyprcosmic` literals described under [Installing](#installing).
|
||||||
|
- `docs/unreproducible-dead-input-2026-08-10.md` records a session that came up
|
||||||
|
without input and has not been reproduced since. It is written down rather
|
||||||
|
than closed.
|
||||||
|
|
||||||
|
## Trademark
|
||||||
|
|
||||||
|
COSMIC is a System76 trademark. This fork is not affiliated with or endorsed by
|
||||||
|
System76. See [TRADEMARK.md](TRADEMARK.md), which is upstream's policy and
|
||||||
|
applies here.
|
||||||
|
|
||||||
|
## Upstream
|
||||||
|
|
||||||
|
For COSMIC itself — the component list, packaging status, translations, and how
|
||||||
|
to install it on your distribution rather than building it — see
|
||||||
|
[pop-os/cosmic-epoch](https://github.com/pop-os/cosmic-epoch).
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Programs the HyprCosmic session starts after COSMIC's own components.
|
||||||
|
#
|
||||||
|
# Installed to ~/.config/hyprcosmic/autostart and read by cosmic-session's
|
||||||
|
# profile module. One command per line. Arguments and quoting work, but this is
|
||||||
|
# NOT a shell: no $VAR, no ~, no globs, no $(...). Paths must be absolute.
|
||||||
|
#
|
||||||
|
# `#` starts a comment where a word would start, so `--color=#1a1b26` is fine
|
||||||
|
# but `--color #1a1b26` is not; quote it as '#1a1b26' if you need the latter.
|
||||||
|
|
||||||
|
# Keep cosmic-config in step with cosmic.conf for the whole session: compile
|
||||||
|
# once at login, then again on every edit to it or to anything it sources.
|
||||||
|
#
|
||||||
|
# First in the file because its startup pass is what makes "the file wins" true
|
||||||
|
# at login rather than only when you last ran `apply` by hand -- whatever
|
||||||
|
# COSMIC's settings UI stored since then is overwritten before the desktop
|
||||||
|
# settles. The bar does not read cosmic-config, so the ordering is for the
|
||||||
|
# compositor's benefit, not waybar's.
|
||||||
|
#
|
||||||
|
# No --config: the default is derived from XDG_CONFIG_HOME (or HOME) inside the
|
||||||
|
# process, so unlike the waybar line below this needs no shell to find a home
|
||||||
|
# directory for it.
|
||||||
|
#
|
||||||
|
# A malformed edit is not fatal. It is reported to the session log and the last
|
||||||
|
# good configuration stays in place, so a typo cannot leave you at a broken
|
||||||
|
# desktop -- fix the file and the next save applies.
|
||||||
|
hyprcosmic-conf watch
|
||||||
|
|
||||||
|
# The bar. The layout is shared and lives under /usr/share, but the stylesheet
|
||||||
|
# has to be per-user: it imports a sibling theme.css holding the installed HyDE
|
||||||
|
# theme's palette, and a relative @import resolves against the importing file.
|
||||||
|
#
|
||||||
|
# So the style path has to name a home directory, and this file is not a shell:
|
||||||
|
# `~` and `$HOME` on a bare command line here are literal text, not expansions.
|
||||||
|
# `sh -c` is what gets them expanded, for the same reason and under the same
|
||||||
|
# terms as the wallpaper line below -- naming `sh` is naming a program, which
|
||||||
|
# this file was always allowed to do.
|
||||||
|
#
|
||||||
|
# `exec` matters. Without it sh stays in the process tree as waybar's parent,
|
||||||
|
# and cosmic-session would be supervising the shell rather than the bar: a
|
||||||
|
# waybar that died would leave sh alive, so the restart that should have
|
||||||
|
# happened never would.
|
||||||
|
sh -c 'exec waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s "$HOME/.config/hyprcosmic/waybar/style.css"'
|
||||||
|
|
||||||
|
# rofi is not a daemon. It is launched on demand by a keybinding, which COSMIC
|
||||||
|
# stores in com.system76.CosmicSettings.Shortcuts rather than here. Set those
|
||||||
|
# with `bind` lines in cosmic.conf and `cosmic-conf apply`; see config/cosmic.conf.
|
||||||
|
|
||||||
|
# Wallpaper. The hyprcosmic profile does not start cosmic-bg, so without this
|
||||||
|
# there is nothing drawing a background at all.
|
||||||
|
#
|
||||||
|
# HyDE calls this swww; upstream renamed it to awww at 0.12 and the Fedora
|
||||||
|
# package Obsoletes swww < 0.12.0. /usr/bin/swww is a shim that prints a
|
||||||
|
# deprecation warning and is documented as going away, so use the real name.
|
||||||
|
# Packaged in the alebastr/sway-extras COPR.
|
||||||
|
#
|
||||||
|
# The daemon only holds the surface -- it shows nothing until an image is set.
|
||||||
|
awww-daemon
|
||||||
|
|
||||||
|
# ...which is what this does. Without it the daemon runs, draws nothing, and you
|
||||||
|
# get a blank screen below the bar with no error anywhere: the failure is that
|
||||||
|
# nobody asked for a wallpaper, so nothing reports one missing.
|
||||||
|
#
|
||||||
|
# It sets ~/.local/share/wallpapers/hyprcosmic/current, a symlink
|
||||||
|
# `hyprcosmic-conf import-theme --assets` maintains beside the wallpapers it
|
||||||
|
# copies. The link is used rather than a real path so that this and rofi's
|
||||||
|
# local.rasi -- which shows the same image in the launcher's sidebar -- cannot
|
||||||
|
# drift apart, and so that importing a different theme does not leave either
|
||||||
|
# pointing at a file that no longer exists.
|
||||||
|
#
|
||||||
|
# Until a theme has been imported there is no such link, so on a machine that
|
||||||
|
# has just installed the package it falls back to whatever the distribution
|
||||||
|
# ships, /usr/share/backgrounds/cosmic first. A first login gets a desktop.
|
||||||
|
#
|
||||||
|
# Change the wallpaper by repointing the link, not by editing this file:
|
||||||
|
#
|
||||||
|
# ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
|
||||||
|
# ~/.local/share/wallpapers/hyprcosmic/current
|
||||||
|
#
|
||||||
|
# A program rather than the `sh -c` this used to be, because waiting for the
|
||||||
|
# daemon needs a bound: awww is a Recommends in a COPR, and the old unbounded
|
||||||
|
# `until awww query` loop spun for the whole session on a machine without it.
|
||||||
|
hyprcosmic-wallpaper
|
||||||
|
|
||||||
|
# A terminal, unconditionally, as the way back in.
|
||||||
|
#
|
||||||
|
# Everything above assumes the keybindings work. If they do not -- a bad
|
||||||
|
# `bind` line, a shortcut COSMIC declines to register, a compositor that came
|
||||||
|
# up without input -- then there is no way to launch rofi, no way to launch a
|
||||||
|
# terminal, and the only remaining option is a VT switch. Starting one terminal
|
||||||
|
# at login costs a window you can close and removes that entire failure class.
|
||||||
|
#
|
||||||
|
# Last in the file so it is the most recently mapped window, and therefore on
|
||||||
|
# top of anything else that opened during startup.
|
||||||
|
cosmic-term
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Report fan speeds for waybar's custom/fan module, as JSON on stdout.
|
||||||
|
#
|
||||||
|
# waybar has no fan module, and its temperature module cannot be borrowed for
|
||||||
|
# this: it divides by 1000 to turn millidegrees into degrees, which would render
|
||||||
|
# 2700 rpm as 2 C.
|
||||||
|
#
|
||||||
|
# Nor can the sensor be named the way "temperature" names its own, with
|
||||||
|
# hwmon-path-abs. That option points at a *parent* directory and takes the one
|
||||||
|
# hwmonN inside it, which works for k10temp and amdgpu because each has exactly
|
||||||
|
# one. The asus platform device has two --
|
||||||
|
#
|
||||||
|
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon9 name=asus
|
||||||
|
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon10 name=asus_custom_fan_curve
|
||||||
|
#
|
||||||
|
# -- and only the first has fan*_input; the second holds the curve's set points.
|
||||||
|
# Which of the two waybar picked would be down to readdir order.
|
||||||
|
#
|
||||||
|
# So this resolves by content instead of by path: any hwmon with a fan*_input.
|
||||||
|
# That also makes it portable off this laptop, which a hardcoded asus path would
|
||||||
|
# not be. Fans on a desktop's it87 or a thinkpad's thinkpad_hwmon are found the
|
||||||
|
# same way.
|
||||||
|
#
|
||||||
|
# Prints nothing at all when the machine has no readable fan -- a VM, or a
|
||||||
|
# passively cooled box. waybar renders an empty custom module as nothing, so the
|
||||||
|
# bar loses the item rather than showing a dead zero.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
max=0
|
||||||
|
tooltip=''
|
||||||
|
|
||||||
|
for hwmon in /sys/class/hwmon/hwmon*; do
|
||||||
|
[ -d "$hwmon" ] || continue
|
||||||
|
|
||||||
|
# Not every hwmon has a name, and a chip is worth naming in the tooltip
|
||||||
|
# when it does: "cpu_fan" alone does not say which controller reported it.
|
||||||
|
chip=$(cat "$hwmon/name" 2>/dev/null) || chip=''
|
||||||
|
[ -n "$chip" ] || chip=$(basename "$hwmon")
|
||||||
|
|
||||||
|
for input in "$hwmon"/fan*_input; do
|
||||||
|
# The glob is literal when nothing matches, which is the common case:
|
||||||
|
# most hwmons here are temperature-only.
|
||||||
|
[ -e "$input" ] || continue
|
||||||
|
|
||||||
|
rpm=$(cat "$input" 2>/dev/null) || continue
|
||||||
|
# A fan that is stopped reads 0, and a fan that has been unbound reads
|
||||||
|
# nothing. Neither is an error, but neither belongs in the tooltip.
|
||||||
|
case $rpm in
|
||||||
|
'' | *[!0-9]*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# fanN_label when the driver supplies one -- "cpu_fan", "gpu_fan" --
|
||||||
|
# and fanN otherwise.
|
||||||
|
label=$(cat "${input%_input}_label" 2>/dev/null) || label=''
|
||||||
|
[ -n "$label" ] || label=$(basename "${input%_input}")
|
||||||
|
|
||||||
|
[ "$rpm" -gt "$max" ] && max=$rpm
|
||||||
|
|
||||||
|
line="$chip $label: $rpm rpm"
|
||||||
|
if [ -n "$tooltip" ]; then
|
||||||
|
tooltip="$tooltip\\n$line"
|
||||||
|
else
|
||||||
|
tooltip=$line
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# No fan anywhere. Say nothing rather than reporting a confident 0 rpm.
|
||||||
|
[ -n "$tooltip" ] || exit 0
|
||||||
|
|
||||||
|
# The bar shows the fastest fan, because that is the one you can hear and the
|
||||||
|
# one that says whether the machine is working. The rest are in the tooltip.
|
||||||
|
#
|
||||||
|
# The class drives the colour in rules.css. 4000 rpm is where this laptop's fans
|
||||||
|
# become audible over a quiet room; below 1 rpm every fan is stopped, which is
|
||||||
|
# worth showing differently from a slow one.
|
||||||
|
if [ "$max" -ge 4000 ]; then
|
||||||
|
class=high
|
||||||
|
elif [ "$max" -eq 0 ]; then
|
||||||
|
class=idle
|
||||||
|
else
|
||||||
|
class=normal
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"text":"%s","tooltip":"%s","class":"%s"}\n' "$max" "$tooltip" "$class"
|
||||||
Executable
+205
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
#
|
||||||
|
# Show every keyboard shortcut in the session, and what each one runs.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# COSMIC has no keyboard reference. cosmic-settings lists shortcuts in a
|
||||||
|
# scrolling pane behind three clicks, which is where you go to *change* one, not
|
||||||
|
# where you go to remember one mid-task. Hyprland desktops all ship a cheatsheet
|
||||||
|
# on a key and a bar click for exactly that reason, and this is that.
|
||||||
|
#
|
||||||
|
# WHERE THE BINDINGS COME FROM, AND WHY NOT cosmic.conf
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# The obvious implementation reads cosmic.conf, and it would be wrong. This
|
||||||
|
# machine's cosmic.conf declares six bindings. The session answers to 122. The
|
||||||
|
# other 116 are COSMIC's own defaults, which the fork does not restate because
|
||||||
|
# it has no reason to -- Super+Q closes a window whether or not anybody wrote it
|
||||||
|
# down. A cheatsheet showing six entries would not look broken; it would look
|
||||||
|
# complete, and it would be missing every window, workspace and media key on the
|
||||||
|
# machine. That is a worse failure than not having the feature.
|
||||||
|
#
|
||||||
|
# So the sources are the three RON files the compositor actually reads:
|
||||||
|
#
|
||||||
|
# /usr/share/cosmic/.../Shortcuts/v1/defaults 116 COSMIC built-ins
|
||||||
|
# ~/.config/cosmic/.../Shortcuts/v1/custom 6 ours, projected from
|
||||||
|
# cosmic.conf by cosmic-conf
|
||||||
|
# /usr/share/cosmic/.../Shortcuts/v1/system_actions
|
||||||
|
# what System(X) actually runs
|
||||||
|
#
|
||||||
|
# custom wins over defaults on a collision, matching the order the compositor
|
||||||
|
# merges them, so a rebound key is listed once with the binding in force.
|
||||||
|
#
|
||||||
|
# system_actions is what makes this answer the question that was asked. A
|
||||||
|
# default reads `System(Screenshot)`, which names an action and not a program;
|
||||||
|
# system_actions maps that to `cosmic-screenshot`. Without it a third of the
|
||||||
|
# list would name internal actions rather than the applications they launch.
|
||||||
|
#
|
||||||
|
# THE PARSER
|
||||||
|
# ----------
|
||||||
|
# These are RON files, and this reads them with a regex, which deserves a
|
||||||
|
# defence. The alternative is a `cosmic-conf binds` subcommand, which would be
|
||||||
|
# the principled home for this -- cosmic-conf already has the schema and the
|
||||||
|
# bind grammar. That is a compiled change and a full package build, and this
|
||||||
|
# feature does not need one: the files are one binding per line, and the shape
|
||||||
|
# has not moved. The cost of being wrong here is a mangled row in a reference
|
||||||
|
# window, not a broken keybinding, because nothing downstream consumes this.
|
||||||
|
#
|
||||||
|
# If the format does move, the failure is visible immediately and loudly: the
|
||||||
|
# count check below refuses to show a list it could not parse.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SYS_DIR=/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1
|
||||||
|
USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic/com.system76.CosmicSettings.Shortcuts/v1"
|
||||||
|
|
||||||
|
command -v rofi >/dev/null 2>&1 || {
|
||||||
|
printf 'hyprcosmic-keybinds: rofi is not installed\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Errors go to a rofi dialog as well as stderr: the two ways in are a keybinding
|
||||||
|
# and a bar click, and neither has a terminal to read stderr from. Same reasoning
|
||||||
|
# as hyprcosmic-powermenu.
|
||||||
|
fail() {
|
||||||
|
printf 'hyprcosmic-keybinds: %s\n' "$*" >&2
|
||||||
|
rofi -e "$*" >/dev/null 2>&1 || :
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -r "$SYS_DIR/defaults" ]] ||
|
||||||
|
fail "cannot read $SYS_DIR/defaults, so the built-in shortcuts are unknown"
|
||||||
|
|
||||||
|
# Resolve System(X) to the command it runs, as "X<tab>command" pairs.
|
||||||
|
#
|
||||||
|
# system_actions carries /// doc comments and commented-out entries for actions
|
||||||
|
# that exist in the enum but are not wired up (KeyboardBrightnessUp is one), so
|
||||||
|
# this matches only lines that are a bare name followed by a quoted string.
|
||||||
|
system_actions() {
|
||||||
|
[[ -r "$SYS_DIR/system_actions" ]] || return 0
|
||||||
|
sed -nE 's/^[[:space:]]*([A-Za-z]+):[[:space:]]*"(.*)",?[[:space:]]*$/\1\t\2/p' \
|
||||||
|
"$SYS_DIR/system_actions"
|
||||||
|
}
|
||||||
|
|
||||||
|
# One binding per line, as "modifiers<tab>key<tab>action".
|
||||||
|
#
|
||||||
|
# Two shapes appear. The usual one carries a key:
|
||||||
|
# (modifiers: [Super, Shift], key: "Escape"): System(LogOut),
|
||||||
|
# and a modifier-only binding has none at all:
|
||||||
|
# (modifiers: [Super]): Spawn("rofi -show drun"),
|
||||||
|
# The second is how Super alone opens the launcher, so dropping it would lose
|
||||||
|
# the single most used binding on the machine.
|
||||||
|
bindings() {
|
||||||
|
sed -nE \
|
||||||
|
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\],[[:space:]]*key:[[:space:]]*"([^"]*)"\):[[:space:]]*(.*),[[:space:]]*$/\1\t\2\t\3/p' \
|
||||||
|
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\]\):[[:space:]]*(.*),[[:space:]]*$/\1\t\t\2/p' \
|
||||||
|
"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
actions_tsv="$(system_actions)"
|
||||||
|
|
||||||
|
# custom is read second so that its rows overwrite defaults in the map below.
|
||||||
|
# Missing is normal, not an error: a session with no custom bindings has no such
|
||||||
|
# file, and every default still applies.
|
||||||
|
raw="$(bindings "$SYS_DIR/defaults")"
|
||||||
|
if [[ -r "$USER_DIR/custom" ]]; then
|
||||||
|
raw+=$'\n'"$(bindings "$USER_DIR/custom")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "${raw//[[:space:]]/}" ]] ||
|
||||||
|
fail "parsed no shortcuts at all from $SYS_DIR/defaults; the file format has changed"
|
||||||
|
|
||||||
|
rendered="$(
|
||||||
|
LC_ALL=C awk -F'\t' -v actions="$actions_tsv" '
|
||||||
|
BEGIN {
|
||||||
|
# System(X) -> command, from system_actions.
|
||||||
|
n = split(actions, lines, "\n")
|
||||||
|
for (i = 1; i <= n; i++) {
|
||||||
|
if (split(lines[i], kv, "\t") == 2) cmd[kv[1]] = kv[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Turn CamelCase into words so an action reads as a description rather than
|
||||||
|
# an identifier: MoveToWorkspace -> "move to workspace". Applied only to the
|
||||||
|
# action name, never to a command, which must stay verbatim to be typed.
|
||||||
|
function words(s, out) {
|
||||||
|
out = s
|
||||||
|
gsub(/([a-z0-9])([A-Z])/, "\\1 \\2", out)
|
||||||
|
return tolower(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Super is written first and Shift last, so that chords sort and read the
|
||||||
|
# way they are pressed rather than the way the RON happened to list them.
|
||||||
|
function order(m) {
|
||||||
|
if (m == "Super") return 1
|
||||||
|
if (m == "Ctrl") return 2
|
||||||
|
if (m == "Alt") return 3
|
||||||
|
if (m == "Shift") return 4
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
mods = $1; key = $2; action = $3
|
||||||
|
if (action == "") next
|
||||||
|
|
||||||
|
# "Super, Alt" -> "Super + Alt", in a fixed order.
|
||||||
|
cnt = split(mods, m, /,[[:space:]]*/)
|
||||||
|
for (i = 1; i <= cnt; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", m[i])
|
||||||
|
for (i = 1; i < cnt; i++)
|
||||||
|
for (j = i + 1; j <= cnt; j++)
|
||||||
|
if (order(m[j]) < order(m[i])) { t = m[i]; m[i] = m[j]; m[j] = t }
|
||||||
|
|
||||||
|
chord = ""
|
||||||
|
for (i = 1; i <= cnt; i++) chord = chord (chord == "" ? "" : " + ") m[i]
|
||||||
|
if (key != "") chord = chord (chord == "" ? "" : " + ") key
|
||||||
|
|
||||||
|
# Spawn("cmd") is a command as written in cosmic.conf.
|
||||||
|
# System(X) is an action name that system_actions turns into a command.
|
||||||
|
# Everything else is internal to the compositor and has no command; the
|
||||||
|
# action itself is the honest answer, and inventing a program name for
|
||||||
|
# Focus(Left) would be a lie.
|
||||||
|
what = ""
|
||||||
|
if (action ~ /^Spawn\(".*"\)$/) {
|
||||||
|
what = substr(action, 8, length(action) - 9)
|
||||||
|
} else if (action ~ /^System\(/) {
|
||||||
|
name = action
|
||||||
|
sub(/^System\(/, "", name); sub(/\)$/, "", name)
|
||||||
|
what = (name in cmd) ? cmd[name] : words(name)
|
||||||
|
} else if (action ~ /\(/) {
|
||||||
|
name = action; arg = action
|
||||||
|
sub(/\(.*$/, "", name)
|
||||||
|
sub(/^[^(]*\(/, "", arg); sub(/\)$/, "", arg)
|
||||||
|
what = words(name) " " arg
|
||||||
|
} else {
|
||||||
|
what = words(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last write wins, which is why custom is appended after defaults.
|
||||||
|
seen[chord] = what
|
||||||
|
if (!(chord in orderkey)) orderkey[chord] = ++seq
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
for (c in seen) printf "%d\t%s\t%s\n", orderkey[c], c, seen[c]
|
||||||
|
}
|
||||||
|
' <<<"$raw" | sort -n | cut -f2-
|
||||||
|
)"
|
||||||
|
|
||||||
|
count="$(grep -c . <<<"$rendered")"
|
||||||
|
((count > 0)) || fail "every shortcut line failed to parse; the RON format has changed"
|
||||||
|
|
||||||
|
# Column-aligned so the chords form a readable left edge. Done here rather than
|
||||||
|
# in awk because the width has to be measured across the whole set first.
|
||||||
|
width="$(cut -f1 <<<"$rendered" | LC_ALL=C awk '{ if (length($0) > w) w = length($0) } END { print w }')"
|
||||||
|
|
||||||
|
# -dmenu rather than a static window, so the list is filterable: 122 bindings is
|
||||||
|
# more than fits on a screen, and typing "workspace" is how you actually use
|
||||||
|
# this. No theme arguments, so ~/.config/rofi/config.rasi applies and it matches
|
||||||
|
# the launcher and the power menu.
|
||||||
|
#
|
||||||
|
# The selection is discarded. This is a reference, not a menu -- there is no
|
||||||
|
# sensible action for "you picked Super + Q", and running the bound command on
|
||||||
|
# Return would make an accidental keypress close a window from a help screen.
|
||||||
|
LC_ALL=C awk -F'\t' -v w="$width" '{ printf "%-*s %s\n", w, $1, $2 }' <<<"$rendered" |
|
||||||
|
rofi -dmenu -i -no-custom -no-show-icons -p "keys" \
|
||||||
|
-mesg "$count shortcuts" >/dev/null 2>&1 || :
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
#
|
||||||
|
# Power menu for a HyprCosmic session: lock, suspend, log out, reboot, shut down.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# Stock COSMIC puts all of these behind the power applet in cosmic-panel.
|
||||||
|
# HyprCosmic replaces the panel with waybar, so that applet never starts and the
|
||||||
|
# session had no exit at all -- not even a logout -- short of `systemctl reboot`
|
||||||
|
# typed into a terminal. This is the exit. It is reached from a keybinding and
|
||||||
|
# from the bar, and both run this same script so the two can never disagree.
|
||||||
|
#
|
||||||
|
# Logging out goes through cosmic-session's own D-Bus interface rather than
|
||||||
|
# killing anything. That is the method the panel applet called, and it is the
|
||||||
|
# only one that lets the session stop its clients in order instead of pulling
|
||||||
|
# the compositor out from under them; see `com.system76.CosmicSession.Exit` in
|
||||||
|
# cosmic-session/src/service.rs.
|
||||||
|
#
|
||||||
|
# Reboot and poweroff go to systemd directly. polkit already authorises both for
|
||||||
|
# an active local session without a prompt, which is why no pkexec is involved.
|
||||||
|
#
|
||||||
|
# NO GLYPHS HERE, DELIBERATELY
|
||||||
|
# ----------------------------
|
||||||
|
# Every entry is a plain word. Nerd Font icons live in the Private Use Area,
|
||||||
|
# where they are indistinguishable from each other in a diff and are silently
|
||||||
|
# destroyed by anything that retypes rather than copies them. waybar's power
|
||||||
|
# icon is the only glyph in this feature and it comes from generate-config.py,
|
||||||
|
# which checks each codepoint against the installed font.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SESSION_DEST=com.system76.CosmicSession
|
||||||
|
SESSION_PATH=/com/system76/CosmicSession
|
||||||
|
|
||||||
|
command -v rofi >/dev/null 2>&1 || {
|
||||||
|
printf 'hyprcosmic-powermenu: rofi is not installed\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Errors go to a rofi dialog, not just stderr. The two ways in are a keybinding
|
||||||
|
# and a bar click, and neither has a terminal attached to read stderr from.
|
||||||
|
fail() {
|
||||||
|
printf 'hyprcosmic-powermenu: %s\n' "$*" >&2
|
||||||
|
rofi -e "$*" >/dev/null 2>&1 || :
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# No theme arguments, so ~/.config/rofi/config.rasi applies and this matches the
|
||||||
|
# launcher. Icons off because these entries have none and the reserved space
|
||||||
|
# would sit there empty.
|
||||||
|
#
|
||||||
|
# -no-custom is what stops a typed line from being returned as if it were a
|
||||||
|
# choice: without it, Return on an empty filter hands back whatever was typed,
|
||||||
|
# and the case below would fall through to no branch at all. With it, anything
|
||||||
|
# that is not one of the offered entries is refused.
|
||||||
|
menu() {
|
||||||
|
local prompt="$1"
|
||||||
|
shift
|
||||||
|
printf '%s\n' "$@" | rofi -dmenu -i -no-custom -no-show-icons -p "$prompt"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only for the three that end the session. Locking and suspending undo
|
||||||
|
# themselves with a keypress, so a confirmation there is pure friction; logging
|
||||||
|
# out, rebooting and shutting down each throw away every unsaved thing on the
|
||||||
|
# desktop, and this menu is one keystroke away at all times.
|
||||||
|
#
|
||||||
|
# "No" is listed first so that it is the selected row when the dialog opens.
|
||||||
|
confirm() {
|
||||||
|
local answer
|
||||||
|
answer="$(menu "$1?" "No" "Yes")" || return 1
|
||||||
|
[[ "$answer" == "Yes" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
command -v busctl >/dev/null 2>&1 ||
|
||||||
|
fail "busctl is not installed, so the session cannot be asked to exit"
|
||||||
|
|
||||||
|
busctl --user call "$SESSION_DEST" "$SESSION_PATH" "$SESSION_DEST" Exit && return 0
|
||||||
|
|
||||||
|
# Reaching here means cosmic-session is not answering on the bus. Say so
|
||||||
|
# rather than silently doing nothing: being unable to leave the session is
|
||||||
|
# the exact problem this script was written for, so a dead end here is
|
||||||
|
# worse than a blunt instrument. loginctl is that blunt instrument, and it
|
||||||
|
# is named explicitly so the choice to use it is the user's.
|
||||||
|
fail "cosmic-session did not answer on D-Bus.
|
||||||
|
To force the session to end, run:
|
||||||
|
loginctl terminate-session ${XDG_SESSION_ID:-\$XDG_SESSION_ID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
choice="$(menu "Power" "Lock" "Suspend" "Log out" "Reboot" "Shut down")" || exit 0
|
||||||
|
|
||||||
|
case "$choice" in
|
||||||
|
"Lock") exec loginctl lock-session ;;
|
||||||
|
"Suspend") exec systemctl suspend ;;
|
||||||
|
"Log out") confirm "Log out" && logout ;;
|
||||||
|
"Reboot") confirm "Reboot" && exec systemctl reboot ;;
|
||||||
|
"Shut down") confirm "Shut down" && exec systemctl poweroff ;;
|
||||||
|
"") ;;
|
||||||
|
*) fail "unrecognised choice: ${choice}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Reached only by answering "No" to a confirmation, or dismissing it. The four
|
||||||
|
# branches that act replace this process outright, and `fail` exits on its own,
|
||||||
|
# so nothing else arrives here -- and changing your mind is not a failure.
|
||||||
|
exit 0
|
||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Set the desktop wallpaper once the wallpaper daemon is listening.
|
||||||
|
#
|
||||||
|
# The hyprcosmic profile does not start cosmic-bg, so nothing else draws a
|
||||||
|
# background: without this there is a black screen below the bar, and no error
|
||||||
|
# anywhere, because the failure is that nobody asked for a wallpaper.
|
||||||
|
#
|
||||||
|
# This was three shell fragments on one autostart line. It became a program for
|
||||||
|
# two reasons, both of which cost a first login its desktop:
|
||||||
|
#
|
||||||
|
# - `current` is a symlink `import-theme --assets` maintains, so it does not
|
||||||
|
# exist until a theme has been imported. Naming it directly meant a machine
|
||||||
|
# that had just installed the package set no wallpaper at all.
|
||||||
|
# - The wait for the daemon was `until awww query; do sleep 0.2; done`, with
|
||||||
|
# nothing to stop it. awww is a Recommends and lives in a COPR, so on a
|
||||||
|
# machine that never enabled that repository the loop spun at 5 Hz for the
|
||||||
|
# length of the session.
|
||||||
|
#
|
||||||
|
# Both are now bounded and both say so on the session log.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
link="${XDG_DATA_HOME:-$HOME/.local/share}/wallpapers/hyprcosmic/current"
|
||||||
|
|
||||||
|
# Searched in order when the link is not there yet. The first is cosmic-wallpapers,
|
||||||
|
# which the Fedora package recommends for exactly this; the other two are where
|
||||||
|
# freedesktop and KDE put theirs, so a machine with neither still has a chance.
|
||||||
|
fallbacks='/usr/share/backgrounds/cosmic /usr/share/backgrounds /usr/share/wallpapers'
|
||||||
|
|
||||||
|
if ! command -v awww >/dev/null 2>&1; then
|
||||||
|
echo "hyprcosmic-wallpaper: awww is not installed; no wallpaper set" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# awww-daemon is started immediately above this in autostart and has only just
|
||||||
|
# been forked, so an immediate `awww img` loses the race and fails silently.
|
||||||
|
# Ten seconds is far longer than the daemon has ever taken and short enough that
|
||||||
|
# a daemon which is never coming does not leave a process spinning all session.
|
||||||
|
tries=0
|
||||||
|
until awww query >/dev/null 2>&1; do
|
||||||
|
tries=$((tries + 1))
|
||||||
|
if [ "$tries" -ge 50 ]; then
|
||||||
|
echo "hyprcosmic-wallpaper: awww-daemon did not answer in 10s; no wallpaper set" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
# -e rather than -L, deliberately: it follows the link, so a `current` left
|
||||||
|
# dangling by a deleted theme falls through to the defaults instead of being
|
||||||
|
# handed to awww as a path that is not there.
|
||||||
|
img=''
|
||||||
|
if [ -e "$link" ]; then
|
||||||
|
img=$link
|
||||||
|
else
|
||||||
|
for dir in $fallbacks; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
for candidate in "$dir"/*.jpg "$dir"/*.jpeg "$dir"/*.png; do
|
||||||
|
# The glob is literal when nothing matches, which is why
|
||||||
|
# this tests the file rather than trusting the expansion.
|
||||||
|
[ -f "$candidate" ] || continue
|
||||||
|
img=$candidate
|
||||||
|
break
|
||||||
|
done
|
||||||
|
# An if, not `[ -n "$img" ] && break`: under set -e a trailing
|
||||||
|
# false at the end of the loop body would take the script with it.
|
||||||
|
if [ -n "$img" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$img" ]; then
|
||||||
|
echo "hyprcosmic-wallpaper: no $link yet, falling back to $img" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$img" ]; then
|
||||||
|
echo "hyprcosmic-wallpaper: no wallpaper found in $link or $fallbacks" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec awww img "$img"
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# HyprCosmic configuration, in Hyprland's idiom.
|
||||||
|
#
|
||||||
|
# Compiled into COSMIC's config tree by `cosmic-conf apply`. The file wins:
|
||||||
|
# every key here overwrites whatever COSMIC's own settings UI last stored, so
|
||||||
|
# edit this rather than the GUI for anything it covers.
|
||||||
|
|
||||||
|
# --- Theme --------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# `cosmic-conf import-theme <theme>/hypr.theme --out ~/.config/hyprcosmic/theme.conf`
|
||||||
|
# turns a HyDE theme into conf keys. Sourcing it rather than pasting it in
|
||||||
|
# keeps the two apart: re-importing overwrites theme.conf and cannot touch the
|
||||||
|
# keybindings below, and anything you want to override can simply be repeated
|
||||||
|
# later in this file, since the last assignment to a key wins.
|
||||||
|
#
|
||||||
|
# Commented out because a `source` pointing at a file that does not exist is a
|
||||||
|
# hard error, and no theme is imported yet. Uncomment it once you have run the
|
||||||
|
# command above -- import-theme will remind you.
|
||||||
|
#
|
||||||
|
# source = ~/.config/hyprcosmic/theme.conf
|
||||||
|
|
||||||
|
$mainMod = SUPER
|
||||||
|
|
||||||
|
# --- Tiling -------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# COSMIC ships with autotile off, so a new window opens floating, at whatever
|
||||||
|
# size the application asked for, on top of what you were already looking at.
|
||||||
|
# This is the setting that makes window placement automatic: each new window
|
||||||
|
# takes a share of the screen instead.
|
||||||
|
#
|
||||||
|
# It applies immediately and everywhere. `autotile_behavior` is a separate
|
||||||
|
# COSMIC key that defaults to Global, which retiles workspaces that already
|
||||||
|
# exist; the other value, PerWorkspace, arms only workspaces created from now
|
||||||
|
# on. There is no conf key for it because the default is the one worth having
|
||||||
|
# -- if you want the other, set it in cosmic-settings and this file will not
|
||||||
|
# fight you over it.
|
||||||
|
#
|
||||||
|
# Super+Y still toggles tiling for the current workspace on its own, so a
|
||||||
|
# workspace you want to keep floating does not need this turned off.
|
||||||
|
#
|
||||||
|
# Gaps are here rather than in the theme block because they are only visible
|
||||||
|
# once windows tile: with autotile off nothing is laid out, so nothing has a
|
||||||
|
# gap. gaps_out is doubled so the screen edge reads as deliberate margin
|
||||||
|
# rather than as one more seam.
|
||||||
|
$gap = 4
|
||||||
|
|
||||||
|
general {
|
||||||
|
autotile = true
|
||||||
|
preserve_split = true
|
||||||
|
gaps_in = $gap
|
||||||
|
gaps_out = $gap * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Workspaces ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# COSMIC's workspaces are dynamic and there is no setting that turns that off:
|
||||||
|
# it keeps exactly one trailing empty workspace and collects every other empty
|
||||||
|
# one the moment its last window closes. So "workspace 4 is the browser" is only
|
||||||
|
# true while the browser is open, and the number moves under you as workspaces
|
||||||
|
# come and go.
|
||||||
|
#
|
||||||
|
# These lines declare a fixed set instead, which is what a Hyprland user expects.
|
||||||
|
# A declared workspace is pinned, and pinning is precisely what exempts it from
|
||||||
|
# that collection, so it stays at its number whether or not anything is on it.
|
||||||
|
#
|
||||||
|
# The dynamic workspace is not lost. Declaring four leaves you with 1-4 always
|
||||||
|
# present and a fifth appearing the moment you use it, then a sixth after that,
|
||||||
|
# exactly as Hyprland does.
|
||||||
|
#
|
||||||
|
# Two things worth knowing before you edit:
|
||||||
|
#
|
||||||
|
# * The index is required and workspaces below it are created too, because
|
||||||
|
# COSMIC restores them by position rather than by number. `workspace = 4`
|
||||||
|
# on its own gives you four workspaces, three of them unnamed.
|
||||||
|
# * This is the one key in this file that is not live. The compositor reads
|
||||||
|
# it once at startup, so an edit applies at your next login. Everything
|
||||||
|
# else here takes effect as soon as you save.
|
||||||
|
#
|
||||||
|
# `name:` is what waybar and anything else reading ext-workspace will show.
|
||||||
|
# `tiling:` overrides `general:autotile` for that workspace alone; leave it out
|
||||||
|
# and the workspace follows the setting above. Hyprland's `monitor:` is not
|
||||||
|
# accepted -- COSMIC matches a workspace to a monitor by EDID rather than by
|
||||||
|
# name, and there is no way to write an EDID down here. Saying so is better
|
||||||
|
# than accepting the parameter and quietly ignoring it.
|
||||||
|
#
|
||||||
|
# Commented out because a fixed set is a preference, not an improvement, and
|
||||||
|
# leaving these off gives you COSMIC's stock behaviour. Uncomment to opt in.
|
||||||
|
#
|
||||||
|
# workspace = 1, name:term
|
||||||
|
# workspace = 2, name:web
|
||||||
|
# workspace = 3, name:code
|
||||||
|
# workspace = 4, name:chat
|
||||||
|
|
||||||
|
# --- Window rules --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Where an application opens, decided from what it is rather than from where
|
||||||
|
# you happened to be standing. `windowrule = workspace 4, class:^(vivaldi)$`
|
||||||
|
# means the browser lands on workspace 4 no matter which one is in front of you.
|
||||||
|
#
|
||||||
|
# Rules are matched once, as the window opens, and the first one that matches
|
||||||
|
# wins -- so put the specific ones above the general ones. `class:` and `title:`
|
||||||
|
# are regular expressions; give both and both have to match. A rule with neither
|
||||||
|
# is refused, since it would match every window in the session.
|
||||||
|
#
|
||||||
|
# These pair with the workspace lines above. `workspace name:web` is worth
|
||||||
|
# preferring over `workspace 2`: a name follows the workspace if you renumber
|
||||||
|
# it, and only exists because you declared it, so a typo fails loudly rather
|
||||||
|
# than sending the window to whatever happens to be second.
|
||||||
|
#
|
||||||
|
# A rule never switches you to the workspace it used -- Hyprland's `silent`,
|
||||||
|
# always on. The word is accepted so pasted-in configs keep working.
|
||||||
|
#
|
||||||
|
# Only `workspace` is supported. Hyprland's float, size, move, opacity and the
|
||||||
|
# rest have nothing in COSMIC to project onto, and matchers that ask about
|
||||||
|
# window state -- floating:, fullscreen:, onworkspace: -- cannot be answered at
|
||||||
|
# the moment a window opens. Both fail with an explanation rather than parsing
|
||||||
|
# and doing nothing. Floating for a particular application is a tiling
|
||||||
|
# exception, which lives in COSMIC's own settings rather than in this file.
|
||||||
|
#
|
||||||
|
# Unlike the workspace lines, these are live: save the file and the next window
|
||||||
|
# to open obeys them.
|
||||||
|
#
|
||||||
|
# windowrule = workspace name:web, class:^(vivaldi|firefox)$
|
||||||
|
# windowrule = workspace name:code, class:^(codium|code)$
|
||||||
|
# windowrule = workspace name:chat, class:^(discord|Element)$
|
||||||
|
# windowrule = workspace 1, class:^(kitty)$, title:^(dev)$
|
||||||
|
|
||||||
|
# --- Input ---------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland
|
||||||
|
# spells it `input:follow_mouse`, and that spelling is what this file accepts;
|
||||||
|
# `general:focus_follows_cursor` is the same setting under COSMIC's own name,
|
||||||
|
# and setting both is not an error -- whichever comes last in the file wins.
|
||||||
|
#
|
||||||
|
# Autoraise comes with it and is not a separate key. cosmic-comp raises a
|
||||||
|
# window as part of focusing it, so a floating window under the pointer comes
|
||||||
|
# to the front on its own. Tiled windows do not overlap, so there is nothing
|
||||||
|
# there to raise.
|
||||||
|
#
|
||||||
|
# The delay is what stops the focus from skating across every window between
|
||||||
|
# where the pointer started and where it stopped -- moving the mouse to a menu
|
||||||
|
# on the far side of the screen should not hand focus to whatever it crossed on
|
||||||
|
# the way. 250ms is COSMIC's own default and is kept rather than shortened,
|
||||||
|
# because the failure it prevents is more annoying than the wait.
|
||||||
|
#
|
||||||
|
# Only 0 and 1 mean anything here. Hyprland's 2 and 3 separate pointer focus
|
||||||
|
# from keyboard focus, which cosmic-comp cannot do -- it has one focus. Those
|
||||||
|
# values are rejected with an explanation rather than rounded to 1.
|
||||||
|
input {
|
||||||
|
follow_mouse = 1
|
||||||
|
follow_mouse_delay = 250
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Launcher -----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The hyprcosmic profile does not start cosmic-launcher or cosmic-app-library,
|
||||||
|
# which leaves COSMIC's stock Super, Super+/ and Super+A bindings pointing at
|
||||||
|
# nothing. These take them over with rofi. Written to the Shortcuts `custom`
|
||||||
|
# key, which cosmic-comp merges over `defaults`, so the system file is not
|
||||||
|
# touched and reverting is a matter of deleting these lines and re-applying.
|
||||||
|
|
||||||
|
# Tap Super on its own to open the launcher.
|
||||||
|
# The key field is deliberately empty: COSMIC supports
|
||||||
|
# modifier-only bindings, which Hyprland's `bind` cannot express.
|
||||||
|
bind = $mainMod, , exec, rofi -show drun
|
||||||
|
bind = $mainMod, slash, exec, rofi -show drun
|
||||||
|
bind = $mainMod, A, exec, rofi -show drun
|
||||||
|
|
||||||
|
# Was System(WorkspaceOverview); cosmic-workspaces is not running either.
|
||||||
|
# rofi's window mode is the nearest thing that still shows every open window.
|
||||||
|
bind = $mainMod, W, exec, rofi -show window
|
||||||
|
|
||||||
|
# Terminal, in the Hyprland idiom. Super+T also still works — cosmic-comp
|
||||||
|
# handles System(Terminal) itself, so that binding never went dead.
|
||||||
|
bind = $mainMod, Return, exec, cosmic-term
|
||||||
|
|
||||||
|
# --- Session ------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The hyprcosmic profile disables cosmic-panel, and COSMIC's power applet lives
|
||||||
|
# in that panel, so a HyprCosmic session had no logout, reboot or shutdown
|
||||||
|
# anywhere -- the only way out was `systemctl reboot` from a terminal. This is
|
||||||
|
# that way out. The same script backs waybar's power button, so the two cannot
|
||||||
|
# drift apart, and it asks for confirmation before anything that ends the
|
||||||
|
# session.
|
||||||
|
#
|
||||||
|
# Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers
|
||||||
|
# lock and suspend, which is why the binding is not named after logout.
|
||||||
|
bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu
|
||||||
|
|
||||||
|
# --- Help ---------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Every shortcut in the session, in one searchable window, with the command
|
||||||
|
# each one runs. cosmic-settings can show these, but it is where you go to
|
||||||
|
# change a binding, not where you go to remember one -- and the hyprcosmic
|
||||||
|
# profile does not put it one click away.
|
||||||
|
#
|
||||||
|
# The script reads COSMIC's own Shortcuts files rather than this one. That is
|
||||||
|
# deliberate and it matters: this file declares six bindings and the session
|
||||||
|
# answers to 122, the rest being COSMIC defaults there is no reason to restate
|
||||||
|
# here. A reference built from this file alone would look complete and be
|
||||||
|
# missing every window, workspace and media key on the machine.
|
||||||
|
#
|
||||||
|
# Super+Shift+/ is Super+? on this keyboard, which is the usual spelling for
|
||||||
|
# help, and it is one of the few chords in that corner COSMIC leaves free --
|
||||||
|
# Super+K and Super+I are both focus actions in the defaults. The same script
|
||||||
|
# backs waybar's keyboard button.
|
||||||
|
bind = $mainMod SHIFT, slash, exec, hyprcosmic-keybinds
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* rofi configuration and theme entry point for a HyprCosmic session.
|
||||||
|
*
|
||||||
|
* Below the configuration block this file is only an import list, because the
|
||||||
|
* order is the whole design:
|
||||||
|
*
|
||||||
|
* 1. palette.rasi default colours, under the names HyDE themes use
|
||||||
|
* 2. theme.rasi the installed HyDE theme's rofi.theme, if any
|
||||||
|
* 3. rules.rasi geometry and layout; no colour literals at all
|
||||||
|
* 4. local.rasi per-machine bits: the sidebar wallpaper, the icon theme
|
||||||
|
*
|
||||||
|
* Imports are read strictly in sequence and a later definition of a property
|
||||||
|
* wins over an earlier one. That is what lets step 2 recolour the launcher
|
||||||
|
* without step 3 knowing a theme exists, and step 4 point at a wallpaper
|
||||||
|
* without either of them knowing the path.
|
||||||
|
*
|
||||||
|
* There is no bridge step, unlike waybar: a HyDE rofi.theme defines exactly the
|
||||||
|
* names rules.rasi already references, so nothing needs mapping.
|
||||||
|
*
|
||||||
|
* Steps 2 and 4 are relative imports, which rofi resolves against the directory
|
||||||
|
* of the importing file. That is why this file belongs at
|
||||||
|
* ~/.config/rofi/config.rasi rather than under /usr/share: both are per-user.
|
||||||
|
* It is also the only name rofi loads on its own, so `rofi -show drun` from a
|
||||||
|
* keybinding picks all of this up with no arguments and no launcher script.
|
||||||
|
*
|
||||||
|
* Both relative imports must exist. A missing @import is an error rofi reports
|
||||||
|
* in place of the launcher, not a warning it skips, so `cosmic-conf` writes
|
||||||
|
* theme.rasi and local.rasi even when there is nothing to put in them -- empty,
|
||||||
|
* in which case the defaults from step 1 stand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
configuration {
|
||||||
|
modi: "drun,run,window,filebrowser";
|
||||||
|
show-icons: true;
|
||||||
|
|
||||||
|
/* Nerd Font glyphs, as HyDE's style_1 has them: apps, terminal, files,
|
||||||
|
* windows. They render as tofu without a Nerd Font installed; see the font
|
||||||
|
* list in rules.rasi. */
|
||||||
|
display-drun: " ";
|
||||||
|
display-run: " ";
|
||||||
|
display-filebrowser: " ";
|
||||||
|
display-window: " ";
|
||||||
|
|
||||||
|
drun-display-format: "{name}";
|
||||||
|
window-format: "{w}{t}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@import "/usr/share/hyprcosmic/rofi/palette.rasi"
|
||||||
|
@import "theme.rasi"
|
||||||
|
@import "/usr/share/hyprcosmic/rofi/rules.rasi"
|
||||||
|
@import "local.rasi"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Per-machine launcher settings for HyprCosmic.
|
||||||
|
*
|
||||||
|
* Empty until `hyprcosmic-conf import-theme --assets` writes it. It exists from
|
||||||
|
* the first session because config.rasi imports it, and a missing @import is an
|
||||||
|
* error rofi reports in place of the launcher rather than a warning it skips.
|
||||||
|
*
|
||||||
|
* It is the last of the four imports in config.rasi, so anything here wins; it
|
||||||
|
* is also overwritten by the next import run with --overwrite, so keep hand
|
||||||
|
* edits somewhere else.
|
||||||
|
*
|
||||||
|
* Two things belong in this file and nothing else does: values that name a path
|
||||||
|
* or a package on this particular machine, which neither /usr/share/hyprcosmic
|
||||||
|
* nor a HyDE theme file can know. Concretely, that is the icon theme and the
|
||||||
|
* sidebar image:
|
||||||
|
*
|
||||||
|
* configuration {
|
||||||
|
* icon-theme: "Tela-circle-purple", "Adwaita";
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* dummywall {
|
||||||
|
* background-image: url("/home/you/.local/share/wallpapers/hyprcosmic/current", height);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Both are shown commented out rather than set, because a default that named
|
||||||
|
* an icon theme nobody has installed, or a wallpaper path under someone else's
|
||||||
|
* home, would be worse than no default at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* Default colours for the HyprCosmic launcher.
|
||||||
|
*
|
||||||
|
* Only one name set is needed here, unlike waybar's palette.css. A HyDE theme's
|
||||||
|
* rofi.theme is a list of exactly these names -- main-bg, select-fg and so on --
|
||||||
|
* and rules.rasi references the same names directly, so there is nothing to
|
||||||
|
* bridge between. Importing a theme after this file simply re-points them.
|
||||||
|
*
|
||||||
|
* Defining them here is what makes a missing theme harmless: rules.rasi always
|
||||||
|
* resolves against something, so an unthemed launcher renders with these values
|
||||||
|
* instead of every widget falling back to rofi's stock grey.
|
||||||
|
*
|
||||||
|
* Values are Tokyo Night, matching cosmic-conf's own defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
/* Window and text. main-br is the window border, main-ex an accent HyDE
|
||||||
|
* uses for secondary text; rofi themes vary in how much of it they set. */
|
||||||
|
main-bg: #1a1b26e6;
|
||||||
|
main-fg: #c0caf5ff;
|
||||||
|
main-br: #bb9af7ff;
|
||||||
|
main-ex: #7dcfffff;
|
||||||
|
|
||||||
|
/* The highlighted row. */
|
||||||
|
select-bg: #7aa2f7ff;
|
||||||
|
select-fg: #1a1b26ff;
|
||||||
|
|
||||||
|
/* Named by HyDE's themes but unused by this layout. Defined so a theme that
|
||||||
|
* sets them parses, and so one that does not still resolves. */
|
||||||
|
separatorcolor: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/* Geometry and layout for the HyprCosmic launcher.
|
||||||
|
*
|
||||||
|
* Adapted from HyDE's style_1 ("Background image with a sidebar list"), which
|
||||||
|
* is the layout its rofilaunch.sh uses by default. Two deliberate differences:
|
||||||
|
*
|
||||||
|
* - HyDE injects the border radii, border width and font at the command line,
|
||||||
|
* as three -theme-str arguments computed from Hyprland's own gaps and
|
||||||
|
* border settings. We have no launcher script -- the keybinding in
|
||||||
|
* cosmic.conf runs plain `rofi -show drun` -- so those values are written
|
||||||
|
* out here, at the defaults rofilaunch.sh would have produced.
|
||||||
|
* - The sidebar image lives in local.rasi rather than here, because the
|
||||||
|
* wallpaper path is per-user. HyDE points at ~/.cache/hyde/wall.thmb, a
|
||||||
|
* thumbnail its wallpaper scripts generate; we do not run those, so
|
||||||
|
* pointing there would render an empty panel on every machine.
|
||||||
|
*
|
||||||
|
* No colour literals: every colour is a name from palette.rasi, which a theme
|
||||||
|
* may have re-pointed. That separation is what lets `cosmic-conf import-theme`
|
||||||
|
* recolour the launcher without touching a line of layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Pango takes a comma-separated family list, so the Nerd Font is a preference
|
||||||
|
* rather than a requirement -- the glyphs in config.rasi's mode labels need it,
|
||||||
|
* but everything else still renders without it. */
|
||||||
|
* {
|
||||||
|
font: "JetBrainsMono Nerd Font, Noto Sans Mono 10";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Main //
|
||||||
|
window {
|
||||||
|
height: 33em;
|
||||||
|
width: 63em;
|
||||||
|
transparency: "real";
|
||||||
|
fullscreen: false;
|
||||||
|
enabled: true;
|
||||||
|
cursor: "default";
|
||||||
|
spacing: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
border: 2px;
|
||||||
|
border-radius: 30px;
|
||||||
|
border-color: @main-br;
|
||||||
|
background-color: @main-bg;
|
||||||
|
}
|
||||||
|
mainbox {
|
||||||
|
enabled: true;
|
||||||
|
spacing: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
orientation: horizontal;
|
||||||
|
children: [ "dummywall" , "listbox" ];
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The sidebar. Solid colour here so that a machine with no wallpaper override
|
||||||
|
* gets a panel in the theme's own background, not a hole. local.rasi paints an
|
||||||
|
* image over the top when there is one. */
|
||||||
|
dummywall {
|
||||||
|
spacing: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
width: 37em;
|
||||||
|
expand: false;
|
||||||
|
orientation: horizontal;
|
||||||
|
children: [ "mode-switcher" , "inputbar" ];
|
||||||
|
background-color: @main-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Modes //
|
||||||
|
mode-switcher {
|
||||||
|
orientation: vertical;
|
||||||
|
enabled: true;
|
||||||
|
width: 3.8em;
|
||||||
|
padding: 9.2em 0.5em 9.2em 0.5em;
|
||||||
|
spacing: 1.2em;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2em;
|
||||||
|
background-color: @main-bg;
|
||||||
|
text-color: @main-fg;
|
||||||
|
}
|
||||||
|
button selected {
|
||||||
|
background-color: @main-fg;
|
||||||
|
text-color: @main-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Inputs //
|
||||||
|
/* The entry is hidden: typing filters the list without a visible prompt, which
|
||||||
|
* is what gives style_1 its uncluttered look. inputbar still has to exist for
|
||||||
|
* keystrokes to reach the filter. */
|
||||||
|
inputbar {
|
||||||
|
enabled: true;
|
||||||
|
children: [ "entry" ];
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
entry {
|
||||||
|
enabled: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Lists //
|
||||||
|
listbox {
|
||||||
|
spacing: 0em;
|
||||||
|
padding: 2em;
|
||||||
|
children: [ "dummy" , "listview" , "dummy" ];
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
listview {
|
||||||
|
enabled: true;
|
||||||
|
spacing: 0em;
|
||||||
|
padding: 0em;
|
||||||
|
columns: 1;
|
||||||
|
lines: 8;
|
||||||
|
cycle: true;
|
||||||
|
dynamic: true;
|
||||||
|
scrollbar: false;
|
||||||
|
layout: vertical;
|
||||||
|
reverse: false;
|
||||||
|
expand: false;
|
||||||
|
fixed-height: true;
|
||||||
|
fixed-columns: true;
|
||||||
|
cursor: "default";
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @main-fg;
|
||||||
|
}
|
||||||
|
dummy {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Elements //
|
||||||
|
element {
|
||||||
|
enabled: true;
|
||||||
|
spacing: 0.8em;
|
||||||
|
padding: 0.4em 0.4em 0.4em 1.5em;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: @main-fg;
|
||||||
|
}
|
||||||
|
element selected.normal {
|
||||||
|
background-color: @select-bg;
|
||||||
|
text-color: @select-fg;
|
||||||
|
}
|
||||||
|
element-icon {
|
||||||
|
size: 2.8em;
|
||||||
|
cursor: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
}
|
||||||
|
element-text {
|
||||||
|
vertical-align: 0.5;
|
||||||
|
horizontal-align: 0.0;
|
||||||
|
cursor: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
text-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Error message //
|
||||||
|
error-message {
|
||||||
|
text-color: @main-fg;
|
||||||
|
background-color: @main-bg;
|
||||||
|
text-transform: capitalize;
|
||||||
|
children: [ "textbox" ];
|
||||||
|
}
|
||||||
|
textbox {
|
||||||
|
text-color: inherit;
|
||||||
|
background-color: inherit;
|
||||||
|
vertical-align: 0.5;
|
||||||
|
horizontal-align: 0.5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* Step 2 of the import chain in config.rasi: the installed HyDE theme's rofi
|
||||||
|
* colours.
|
||||||
|
*
|
||||||
|
* Empty on purpose, for the same reason as waybar's theme.css: a missing
|
||||||
|
* @import is an error rofi reports in place of the launcher rather than a
|
||||||
|
* warning it skips, so config.rasi can only import a file that is guaranteed
|
||||||
|
* to exist. palette.rasi already defines every name rules.rasi reads, so
|
||||||
|
* defining nothing here leaves a complete theme rather than an unresolved
|
||||||
|
* reference.
|
||||||
|
*
|
||||||
|
* `hyprcosmic-conf import-theme` overwrites this with the imported theme's
|
||||||
|
* rofi colours. Hand-written overrides belong in local.rasi, which is imported
|
||||||
|
* after the rules and is left alone unless --overwrite is passed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* Map HyDE's waybar colour names onto the ones rules.css uses.
|
||||||
|
*
|
||||||
|
* Imported after both palette.css and the theme's own waybar.theme, so it sees
|
||||||
|
* whichever definition of each HyDE name is in force and does not care which
|
||||||
|
* file supplied it. This indirection is the whole reason a HyDE theme can
|
||||||
|
* recolour this bar without any of the rules changing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@define-color bar-fg @main-fg;
|
||||||
|
@define-color accent @wb-act-bg;
|
||||||
|
@define-color muted @wb-hvr-fg;
|
||||||
|
|
||||||
|
/* `bar-bg` IS remapped, and this is a deliberate departure from the theme.
|
||||||
|
*
|
||||||
|
* HyDE themes set bar-bg to something like rgba(0, 0, 0, 0.1) and rely on the
|
||||||
|
* compositor blurring whatever is behind the bar. cosmic-comp has no
|
||||||
|
* rule-driven blur -- `import-theme --report` lists decoration.blur.* as
|
||||||
|
* needing a compositor patch -- so honouring that value literally gives a
|
||||||
|
* ~90% transparent bar with unblurred desktop showing through and text that
|
||||||
|
* cannot be read.
|
||||||
|
*
|
||||||
|
* So the theme's own background colour is used at an opacity that works
|
||||||
|
* without blur. Delete these two lines to get the theme's literal value back,
|
||||||
|
* or once blur lands.
|
||||||
|
*/
|
||||||
|
@define-color bar-bg alpha(@main-bg, 0.85);
|
||||||
|
|
||||||
|
/* The pill behind each module. Derived rather than themed: HyDE has no name
|
||||||
|
* for it, because in HyDE the pill IS the bar -- its modules sit on a
|
||||||
|
* transparent strip and the compositor blurs the desktop behind them. Without
|
||||||
|
* blur that reads as text floating on the wallpaper, so here the bar keeps a
|
||||||
|
* background and the pills are a light lift off it.
|
||||||
|
*
|
||||||
|
* Derived from the foreground, not the background, so it stays visible whether
|
||||||
|
* the theme is dark or light. */
|
||||||
|
@define-color module-bg alpha(@main-fg, 0.08);
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
// Waybar configuration for a HyprCosmic session.
|
||||||
|
//
|
||||||
|
// Waybar's shipped default (/etc/xdg/waybar/config.jsonc) is built entirely
|
||||||
|
// around sway/* modules and shows nothing at all under cosmic-comp, so this
|
||||||
|
// exists rather than a handful of overrides.
|
||||||
|
//
|
||||||
|
// Module choices are constrained by what cosmic-comp actually advertises on the
|
||||||
|
// Wayland registry, which was checked against a live session:
|
||||||
|
//
|
||||||
|
// ext/workspaces ext_workspace_manager_v1 -- advertised by stock cosmic-comp.
|
||||||
|
// wlr/taskbar zwlr_foreign_toplevel_management_v1 -- NOT in stock
|
||||||
|
// cosmic-comp; provided by the HyprCosmic fork's Patch A.
|
||||||
|
// Under a stock compositor this module stays empty and the
|
||||||
|
// rest of the bar still works.
|
||||||
|
// tray Needs a StatusNotifierWatcher. cosmic-panel normally
|
||||||
|
// supplies one via cosmic-applet-status-area, and the
|
||||||
|
// hyprcosmic profile disables the panel, so waybar hosts the
|
||||||
|
// watcher itself here.
|
||||||
|
//
|
||||||
|
// ICONS. Every glyph below is a \uXXXX escape, never a literal character, and
|
||||||
|
// this file is generated from config.jsonc.in for exactly that reason:
|
||||||
|
//
|
||||||
|
// - These are Private Use Area codepoints. They survive being copied, but
|
||||||
|
// anything that re-types rather than copies them silently flattens them to
|
||||||
|
// spaces or drops them next to a neighbour, and the damage is invisible in
|
||||||
|
// a diff and indistinguishable from a font problem at runtime.
|
||||||
|
// - An escape is plain ASCII, so it cannot be damaged that way, and it names
|
||||||
|
// the intended glyph instead of leaving a box you have to identify.
|
||||||
|
//
|
||||||
|
// Every codepoint was checked against the installed JetBrainsMono Nerd Font
|
||||||
|
// with `fc-list :charset=<cp>` before use. That check is worth doing rather
|
||||||
|
// than copying HyDE's values: HyDE's pulseaudio module uses U+FA80 for the
|
||||||
|
// muted state, an old Material Design Icons codepoint that Nerd Fonts v3
|
||||||
|
// moved. It is absent from the installed font and would render as tofu, so
|
||||||
|
// U+F075F is used instead.
|
||||||
|
//
|
||||||
|
// NOT INCLUDED, deliberately:
|
||||||
|
//
|
||||||
|
// backlight The only device is acpi_video0, a firmware stub reporting
|
||||||
|
// brightness 49 of max 49 -- permanently 100% -- whose sysfs
|
||||||
|
// node is not writable by this user, with brightnessctl absent.
|
||||||
|
// The module would render a readout that never changes and a
|
||||||
|
// scroll action that never works: visible, plausible, and dead.
|
||||||
|
// That is the same failure class as the `pavucontrol` click this
|
||||||
|
// file used to carry, and it is not worth re-introducing for a
|
||||||
|
// percentage that is always the same number.
|
||||||
|
{
|
||||||
|
"layer": "top",
|
||||||
|
"position": "top",
|
||||||
|
"height": 34,
|
||||||
|
"spacing": 4,
|
||||||
|
|
||||||
|
"modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
|
||||||
|
"modules-center": ["mpris", "clock", "privacy"],
|
||||||
|
"modules-right": [
|
||||||
|
"systemd-failed-units",
|
||||||
|
"idle_inhibitor",
|
||||||
|
"custom/swaync",
|
||||||
|
"bluetooth",
|
||||||
|
"pulseaudio",
|
||||||
|
"network",
|
||||||
|
"disk",
|
||||||
|
"temperature",
|
||||||
|
"temperature#gpu",
|
||||||
|
"custom/fan",
|
||||||
|
"cpu",
|
||||||
|
"memory",
|
||||||
|
"power-profiles-daemon",
|
||||||
|
"battery",
|
||||||
|
"tray",
|
||||||
|
"custom/power"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Opens the keyboard reference. First on the bar because it is the thing
|
||||||
|
// you look for when you do not yet know where anything is.
|
||||||
|
//
|
||||||
|
// No "exec": this is a button, not a readout. A custom module with only a
|
||||||
|
// format and an on-click runs nothing on an interval, so it costs a static
|
||||||
|
// glyph and nothing else.
|
||||||
|
//
|
||||||
|
// The same script is on Super + Shift + / -- the binding is named in the
|
||||||
|
// tooltip because a help button you have to reach for with the mouse has
|
||||||
|
// not finished helping.
|
||||||
|
"custom/keybinds": {
|
||||||
|
"format": "\udb80\udf0c",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
|
||||||
|
"on-click": "hyprcosmic-keybinds"
|
||||||
|
},
|
||||||
|
|
||||||
|
"ext/workspaces": {
|
||||||
|
"format": "{name}",
|
||||||
|
"on-click": "activate"
|
||||||
|
},
|
||||||
|
|
||||||
|
"wlr/taskbar": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"icon-size": 18,
|
||||||
|
"tooltip-format": "{title}",
|
||||||
|
"on-click": "activate",
|
||||||
|
"on-click-middle": "close"
|
||||||
|
},
|
||||||
|
|
||||||
|
// The taskbar above shows which windows exist; this shows which one has the
|
||||||
|
// keyboard. Nothing else on the bar answered that.
|
||||||
|
//
|
||||||
|
// This is the one module here that talks to the fork's Hyprland IPC rather
|
||||||
|
// than to a Wayland protocol, and it needs no compositor change: waybar
|
||||||
|
// reads `j/activewindow` on startup and then follows `activewindow>>` on
|
||||||
|
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
|
||||||
|
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
|
||||||
|
// focused window as (class, title) and emits on any difference, which is
|
||||||
|
// why upstream's separate `windowtitle` event is not needed here. Upstream
|
||||||
|
// Hyprland emits from call sites and so must announce that case
|
||||||
|
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
|
||||||
|
//
|
||||||
|
// `separate-outputs` is left off deliberately: there is one bar on one
|
||||||
|
// output, and with it on, the module reports that monitor's active window
|
||||||
|
// rather than the focused one, which on a single head is the same answer by
|
||||||
|
// a longer route.
|
||||||
|
"hyprland/window": {
|
||||||
|
"format": "\uf2d0 {title}",
|
||||||
|
"max-length": 60,
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "{title}\n\nclass: {class}",
|
||||||
|
// Titles are written for a window's own title bar, not for a 60-column
|
||||||
|
// slot on a bar, so the worst offenders are trimmed to the part that
|
||||||
|
// identifies the window. ECMAScript regex; the value is a replacement
|
||||||
|
// string where $1 is the first capture.
|
||||||
|
//
|
||||||
|
// The separator is an em dash, and it is written as an escape
|
||||||
|
// for the same reason every glyph in this file is: the template is read
|
||||||
|
// as ASCII and the generator refuses to emit anything that is not, so a
|
||||||
|
// literal em dash pasted here fails the build rather than reaching the
|
||||||
|
// bar. COSMIC's applications really do use an em dash and not a hyphen
|
||||||
|
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
|
||||||
|
// a rule written with a hyphen matches nothing.
|
||||||
|
//
|
||||||
|
// There is deliberately no catch-all for the empty title. waybar's
|
||||||
|
// documentation does not say whether a rule is applied on a full match
|
||||||
|
// or a search, so an empty pattern is either "matches nothing but the
|
||||||
|
// empty title" or "matches every title", and the difference is the
|
||||||
|
// whole bar. The no-window case is handled in rules.css instead, the
|
||||||
|
// same way #mpris already handles having nothing to say.
|
||||||
|
"rewrite": {
|
||||||
|
"(.*) [-\u2014] Vivaldi": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Terminal": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Text Editor": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Files": "$1",
|
||||||
|
"(.*) [-\u2014] Mozilla Firefox": "$1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Renders nothing at all when no player is running, which is what we want
|
||||||
|
// from a centre module: it takes no space until there is something to say.
|
||||||
|
// playerctl backs every action here and is installed.
|
||||||
|
"mpris": {
|
||||||
|
"format": "{player_icon} {dynamic}",
|
||||||
|
"format-paused": "{status_icon} <i>{dynamic}</i>",
|
||||||
|
"dynamic-order": ["title", "artist"],
|
||||||
|
"dynamic-separator": " ",
|
||||||
|
"max-length": 40,
|
||||||
|
"interval": 1,
|
||||||
|
"player-icons": { "default": "\uf001" },
|
||||||
|
"status-icons": {
|
||||||
|
"playing": "\uf04b",
|
||||||
|
"paused": "\uf04c",
|
||||||
|
"stopped": "\uf04d"
|
||||||
|
},
|
||||||
|
"on-click": "playerctl play-pause",
|
||||||
|
"on-click-middle": "playerctl previous",
|
||||||
|
"on-click-right": "playerctl next",
|
||||||
|
"on-scroll-up": "playerctl position 5+",
|
||||||
|
"on-scroll-down": "playerctl position 5-",
|
||||||
|
"tooltip-format": "{title}\nby {artist}\n{position} / {length}\n\nplayer: {player}"
|
||||||
|
},
|
||||||
|
|
||||||
|
"clock": {
|
||||||
|
"format": "{:%a %d %b %H:%M}",
|
||||||
|
"tooltip-format": "<tt><small>{calendar}</small></tt>"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Only appears while something is actually capturing. Nothing is ignored:
|
||||||
|
// an exception list is how a privacy indicator stops being one.
|
||||||
|
"privacy": {
|
||||||
|
"icon-spacing": 6,
|
||||||
|
"transition-duration": 200,
|
||||||
|
"modules": [
|
||||||
|
{ "type": "screenshare", "tooltip": true },
|
||||||
|
{ "type": "audio-in", "tooltip": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// First on the right, and silent unless something is wrong. A unit that
|
||||||
|
// failed at boot otherwise stays failed until the day you happen to run
|
||||||
|
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||||
|
// session, which has no equivalent of GNOME's abrt notification.
|
||||||
|
//
|
||||||
|
// Both scopes are watched. The user scope is where a session's own units
|
||||||
|
// fail, which is the half that matters here and the half a system-only
|
||||||
|
// check misses.
|
||||||
|
"systemd-failed-units": {
|
||||||
|
"format": "\udb80\udc26 {nr_failed}",
|
||||||
|
"format-ok": "",
|
||||||
|
"system": true,
|
||||||
|
"user": true,
|
||||||
|
"hide-on-ok": true,
|
||||||
|
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||||
|
},
|
||||||
|
|
||||||
|
"idle_inhibitor": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"activated": "\udb80\udd76",
|
||||||
|
"deactivated": "\udb81\udeca"
|
||||||
|
},
|
||||||
|
"tooltip-format-activated": "Caffeine on -- idling and blanking suppressed",
|
||||||
|
"tooltip-format-deactivated": "Caffeine off -- normal power settings apply"
|
||||||
|
},
|
||||||
|
|
||||||
|
// swaync is this session's notification daemon: it owns
|
||||||
|
// org.freedesktop.Notifications and is D-Bus activated through
|
||||||
|
// swaync.service, so it needs no autostart entry and this module starts it
|
||||||
|
// on first contact if nothing else has.
|
||||||
|
//
|
||||||
|
// It is here because the hyprcosmic profile disables cosmic-panel and
|
||||||
|
// COSMIC's notification applet lives in that panel. Without this there is
|
||||||
|
// no way to reach notification history at all.
|
||||||
|
"custom/swaync": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"none": "\uf0f3",
|
||||||
|
"notification": "\udb80\udc9a",
|
||||||
|
"dnd-none": "\uf1f6",
|
||||||
|
"dnd-notification": "\uf1f6",
|
||||||
|
"inhibited-none": "\uf0f3",
|
||||||
|
"inhibited-notification": "\udb80\udc9a",
|
||||||
|
"dnd-inhibited-none": "\uf1f6",
|
||||||
|
"dnd-inhibited-notification": "\uf1f6"
|
||||||
|
},
|
||||||
|
"return-type": "json",
|
||||||
|
"exec": "swaync-client -swb",
|
||||||
|
"on-click": "swaync-client -t -sw",
|
||||||
|
"on-click-right": "swaync-client -d -sw",
|
||||||
|
"tooltip": true,
|
||||||
|
"escape": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// bluez is running with an unblocked hci0. The click target is COSMIC's own
|
||||||
|
// settings page rather than blueman or overskride, neither of which is
|
||||||
|
// installed here.
|
||||||
|
"bluetooth": {
|
||||||
|
"format": "\uf293",
|
||||||
|
"format-disabled": "\uf294",
|
||||||
|
"format-off": "\udb80\udcb2",
|
||||||
|
"format-connected": "\uf293 {num_connections}",
|
||||||
|
"tooltip-format": "{controller_alias}\n{num_connections} connected",
|
||||||
|
"tooltip-format-connected": "{controller_alias}\n\n{device_enumerate}",
|
||||||
|
"tooltip-format-enumerate-connected": "{device_alias}",
|
||||||
|
"on-click": "cosmic-settings bluetooth"
|
||||||
|
},
|
||||||
|
|
||||||
|
// The click used to be `pavucontrol`, which is not installed here: a button
|
||||||
|
// that looked live and did nothing. cosmic-settings ships with the desktop,
|
||||||
|
// so it cannot go missing the same way. Scroll and middle-click need no
|
||||||
|
// helper -- waybar changes volume itself and wpctl comes with pipewire.
|
||||||
|
"pulseaudio": {
|
||||||
|
"format": "{icon} {volume}%",
|
||||||
|
"format-muted": "\udb81\udf5f",
|
||||||
|
"format-bluetooth": "{icon} {volume}%",
|
||||||
|
"format-bluetooth-muted": "\udb81\udf5f",
|
||||||
|
"format-icons": {
|
||||||
|
"headphone": "\uf025",
|
||||||
|
"headset": "\uf025",
|
||||||
|
"hands-free": "\uf025",
|
||||||
|
"phone": "\uf095",
|
||||||
|
"car": "\uf1b9",
|
||||||
|
"default": ["\uf026", "\uf027", "\uf028"]
|
||||||
|
},
|
||||||
|
"scroll-step": 5,
|
||||||
|
"tooltip-format": "{desc}\n{icon} {volume}%",
|
||||||
|
"on-click": "cosmic-settings sound",
|
||||||
|
"on-click-middle": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||||
|
},
|
||||||
|
|
||||||
|
"network": {
|
||||||
|
"format-wifi": "\uf1eb {signalStrength}%",
|
||||||
|
"format-ethernet": "\udb80\ude00 wired",
|
||||||
|
"format-linked": "\udb80\ude00 {ifname}",
|
||||||
|
"format-disconnected": "\udb82\udd2f offline",
|
||||||
|
"tooltip-format": "{ifname}: {ipaddr}",
|
||||||
|
"tooltip-format-wifi": "{essid} ({signalStrength}%)\n{ifname}: {ipaddr}",
|
||||||
|
"on-click": "cosmic-settings network"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Root only. /home is on the same filesystem here, and a second entry for
|
||||||
|
// it would be the same number twice.
|
||||||
|
//
|
||||||
|
// No warning threshold is set: waybar's disk module has no `states`, so the
|
||||||
|
// colour comes from rules.css, which cannot see the percentage. The number
|
||||||
|
// is the warning.
|
||||||
|
"disk": {
|
||||||
|
"path": "/",
|
||||||
|
"format": "\udb80\udeca {percentage_used}%",
|
||||||
|
"interval": 60,
|
||||||
|
"tooltip-format": "{used} used of {total} on {path}, {free} free"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
|
||||||
|
// each other here, but the hwmon path names the CPU sensor explicitly.
|
||||||
|
//
|
||||||
|
// It is given as the PCI device's hwmon directory rather than
|
||||||
|
// /sys/class/hwmon/hwmon6 because hwmonN numbering is assigned in probe
|
||||||
|
// order and is not stable across boots. The PCI address is.
|
||||||
|
"temperature": {
|
||||||
|
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:18.3/hwmon",
|
||||||
|
"input-filename": "temp1_input",
|
||||||
|
"format": "\uf2ca {temperatureC} C",
|
||||||
|
"critical-threshold": 90,
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "CPU package: {temperatureC} C"
|
||||||
|
},
|
||||||
|
|
||||||
|
// A second instance of the same module. `temperature#gpu` is waybar's alias
|
||||||
|
// syntax: everything before the # selects the module, everything after is
|
||||||
|
// just a name, and it becomes #temperature.gpu in CSS.
|
||||||
|
//
|
||||||
|
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
|
||||||
|
// 30 degrees under load, so one reading cannot stand for both.
|
||||||
|
//
|
||||||
|
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
|
||||||
|
// exposes on this card. There is no junction or memory sensor here to
|
||||||
|
// prefer over it.
|
||||||
|
//
|
||||||
|
// hwmon-path-abs is safe for this one: the PCI device has exactly one
|
||||||
|
// hwmonN inside it, unlike the asus platform device that custom/fan below
|
||||||
|
// has to work around.
|
||||||
|
"temperature#gpu": {
|
||||||
|
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
|
||||||
|
"input-filename": "temp1_input",
|
||||||
|
"format": "\udb82\udcae {temperatureC} C",
|
||||||
|
"critical-threshold": 95,
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "GPU edge: {temperatureC} C"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Speed of the fastest fan, with every fan in the tooltip. See the script
|
||||||
|
// for why this is not a temperature module with a different divisor and not
|
||||||
|
// an hwmon-path-abs: both were tried and neither can name this sensor.
|
||||||
|
//
|
||||||
|
// Emits nothing on a machine with no readable fan, and waybar draws an
|
||||||
|
// empty custom module as nothing, so this costs no space on hardware that
|
||||||
|
// has none.
|
||||||
|
"custom/fan": {
|
||||||
|
"exec": "hyprcosmic-fan",
|
||||||
|
"return-type": "json",
|
||||||
|
"format": "\udb80\ude10 {} rpm",
|
||||||
|
"interval": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"cpu": {
|
||||||
|
"format": "\udb80\udf5b {usage}%",
|
||||||
|
"interval": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"memory": {
|
||||||
|
"format": "\udb83\udee0 {percentage}%",
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "{used:0.1f}G of {total:0.1f}G"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backed by tuned-ppd, which owns net.hadess.PowerProfiles on this system.
|
||||||
|
"power-profiles-daemon": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"default": "\uf0e7",
|
||||||
|
"performance": "\uf135",
|
||||||
|
"balanced": "\uf24e",
|
||||||
|
"power-saver": "\uf06c"
|
||||||
|
},
|
||||||
|
"tooltip-format": "power profile: {profile}",
|
||||||
|
"tooltip": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"battery": {
|
||||||
|
"states": { "warning": 30, "critical": 15 },
|
||||||
|
"format": "{icon} {capacity}%",
|
||||||
|
"format-charging": "\uf1e6 {capacity}%",
|
||||||
|
"format-plugged": "\uf1e6 {capacity}%",
|
||||||
|
"format-icons": [
|
||||||
|
"\udb80\udc8e",
|
||||||
|
"\udb80\udc7a",
|
||||||
|
"\udb80\udc7b",
|
||||||
|
"\udb80\udc7c",
|
||||||
|
"\udb80\udc7d",
|
||||||
|
"\udb80\udc7e",
|
||||||
|
"\udb80\udc7f",
|
||||||
|
"\udb80\udc80",
|
||||||
|
"\udb80\udc81",
|
||||||
|
"\udb80\udc82",
|
||||||
|
"\udb80\udc79"
|
||||||
|
],
|
||||||
|
"tooltip-format": "{timeTo}\n{power:0.1f}W",
|
||||||
|
"on-click": "cosmic-settings power"
|
||||||
|
},
|
||||||
|
|
||||||
|
"tray": { "icon-size": 18, "spacing": 8 },
|
||||||
|
|
||||||
|
// Last on the bar, because it is the one button here that can end the
|
||||||
|
// session or the uptime and the far corner is the hardest place to hit by
|
||||||
|
// accident.
|
||||||
|
//
|
||||||
|
// It is here for the same reason "custom/swaync" is: the hyprcosmic profile
|
||||||
|
// disables cosmic-panel, and COSMIC's power applet lives in that panel.
|
||||||
|
// Without this there is no logout, reboot or shutdown anywhere in the
|
||||||
|
// session -- the only way out was `systemctl reboot` from a terminal.
|
||||||
|
//
|
||||||
|
// The click runs the same script as the $mainMod SHIFT E binding rather
|
||||||
|
// than calling systemctl here, so the confirmation step cannot be bypassed
|
||||||
|
// by using the mouse.
|
||||||
|
"custom/power": {
|
||||||
|
"format": "\uf011",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "Lock, suspend, log out, reboot or shut down",
|
||||||
|
"on-click": "hyprcosmic-powermenu"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
// Waybar configuration for a HyprCosmic session.
|
||||||
|
//
|
||||||
|
// Waybar's shipped default (/etc/xdg/waybar/config.jsonc) is built entirely
|
||||||
|
// around sway/* modules and shows nothing at all under cosmic-comp, so this
|
||||||
|
// exists rather than a handful of overrides.
|
||||||
|
//
|
||||||
|
// Module choices are constrained by what cosmic-comp actually advertises on the
|
||||||
|
// Wayland registry, which was checked against a live session:
|
||||||
|
//
|
||||||
|
// ext/workspaces ext_workspace_manager_v1 -- advertised by stock cosmic-comp.
|
||||||
|
// wlr/taskbar zwlr_foreign_toplevel_management_v1 -- NOT in stock
|
||||||
|
// cosmic-comp; provided by the HyprCosmic fork's Patch A.
|
||||||
|
// Under a stock compositor this module stays empty and the
|
||||||
|
// rest of the bar still works.
|
||||||
|
// tray Needs a StatusNotifierWatcher. cosmic-panel normally
|
||||||
|
// supplies one via cosmic-applet-status-area, and the
|
||||||
|
// hyprcosmic profile disables the panel, so waybar hosts the
|
||||||
|
// watcher itself here.
|
||||||
|
//
|
||||||
|
// ICONS. Every glyph below is a \uXXXX escape, never a literal character, and
|
||||||
|
// this file is generated from config.jsonc.in for exactly that reason:
|
||||||
|
//
|
||||||
|
// - These are Private Use Area codepoints. They survive being copied, but
|
||||||
|
// anything that re-types rather than copies them silently flattens them to
|
||||||
|
// spaces or drops them next to a neighbour, and the damage is invisible in
|
||||||
|
// a diff and indistinguishable from a font problem at runtime.
|
||||||
|
// - An escape is plain ASCII, so it cannot be damaged that way, and it names
|
||||||
|
// the intended glyph instead of leaving a box you have to identify.
|
||||||
|
//
|
||||||
|
// Every codepoint was checked against the installed JetBrainsMono Nerd Font
|
||||||
|
// with `fc-list :charset=<cp>` before use. That check is worth doing rather
|
||||||
|
// than copying HyDE's values: HyDE's pulseaudio module uses U+FA80 for the
|
||||||
|
// muted state, an old Material Design Icons codepoint that Nerd Fonts v3
|
||||||
|
// moved. It is absent from the installed font and would render as tofu, so
|
||||||
|
// U+F075F is used instead.
|
||||||
|
//
|
||||||
|
// NOT INCLUDED, deliberately:
|
||||||
|
//
|
||||||
|
// backlight The only device is acpi_video0, a firmware stub reporting
|
||||||
|
// brightness 49 of max 49 -- permanently 100% -- whose sysfs
|
||||||
|
// node is not writable by this user, with brightnessctl absent.
|
||||||
|
// The module would render a readout that never changes and a
|
||||||
|
// scroll action that never works: visible, plausible, and dead.
|
||||||
|
// That is the same failure class as the `pavucontrol` click this
|
||||||
|
// file used to carry, and it is not worth re-introducing for a
|
||||||
|
// percentage that is always the same number.
|
||||||
|
{
|
||||||
|
"layer": "top",
|
||||||
|
"position": "top",
|
||||||
|
"height": 34,
|
||||||
|
"spacing": 4,
|
||||||
|
|
||||||
|
"modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
|
||||||
|
"modules-center": ["mpris", "clock", "privacy"],
|
||||||
|
"modules-right": [
|
||||||
|
"systemd-failed-units",
|
||||||
|
"idle_inhibitor",
|
||||||
|
"custom/swaync",
|
||||||
|
"bluetooth",
|
||||||
|
"pulseaudio",
|
||||||
|
"network",
|
||||||
|
"disk",
|
||||||
|
"temperature",
|
||||||
|
"temperature#gpu",
|
||||||
|
"custom/fan",
|
||||||
|
"cpu",
|
||||||
|
"memory",
|
||||||
|
"power-profiles-daemon",
|
||||||
|
"battery",
|
||||||
|
"tray",
|
||||||
|
"custom/power"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Opens the keyboard reference. First on the bar because it is the thing
|
||||||
|
// you look for when you do not yet know where anything is.
|
||||||
|
//
|
||||||
|
// No "exec": this is a button, not a readout. A custom module with only a
|
||||||
|
// format and an on-click runs nothing on an interval, so it costs a static
|
||||||
|
// glyph and nothing else.
|
||||||
|
//
|
||||||
|
// The same script is on Super + Shift + / -- the binding is named in the
|
||||||
|
// tooltip because a help button you have to reach for with the mouse has
|
||||||
|
// not finished helping.
|
||||||
|
"custom/keybinds": {
|
||||||
|
"format": "@@KEYBOARD@@",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
|
||||||
|
"on-click": "hyprcosmic-keybinds"
|
||||||
|
},
|
||||||
|
|
||||||
|
"ext/workspaces": {
|
||||||
|
"format": "{name}",
|
||||||
|
"on-click": "activate"
|
||||||
|
},
|
||||||
|
|
||||||
|
"wlr/taskbar": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"icon-size": 18,
|
||||||
|
"tooltip-format": "{title}",
|
||||||
|
"on-click": "activate",
|
||||||
|
"on-click-middle": "close"
|
||||||
|
},
|
||||||
|
|
||||||
|
// The taskbar above shows which windows exist; this shows which one has the
|
||||||
|
// keyboard. Nothing else on the bar answered that.
|
||||||
|
//
|
||||||
|
// This is the one module here that talks to the fork's Hyprland IPC rather
|
||||||
|
// than to a Wayland protocol, and it needs no compositor change: waybar
|
||||||
|
// reads `j/activewindow` on startup and then follows `activewindow>>` on
|
||||||
|
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
|
||||||
|
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
|
||||||
|
// focused window as (class, title) and emits on any difference, which is
|
||||||
|
// why upstream's separate `windowtitle` event is not needed here. Upstream
|
||||||
|
// Hyprland emits from call sites and so must announce that case
|
||||||
|
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
|
||||||
|
//
|
||||||
|
// `separate-outputs` is left off deliberately: there is one bar on one
|
||||||
|
// output, and with it on, the module reports that monitor's active window
|
||||||
|
// rather than the focused one, which on a single head is the same answer by
|
||||||
|
// a longer route.
|
||||||
|
"hyprland/window": {
|
||||||
|
"format": "@@WINDOW@@ {title}",
|
||||||
|
"max-length": 60,
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "{title}\n\nclass: {class}",
|
||||||
|
// Titles are written for a window's own title bar, not for a 60-column
|
||||||
|
// slot on a bar, so the worst offenders are trimmed to the part that
|
||||||
|
// identifies the window. ECMAScript regex; the value is a replacement
|
||||||
|
// string where $1 is the first capture.
|
||||||
|
//
|
||||||
|
// The separator is an em dash, and it is written as an escape
|
||||||
|
// for the same reason every glyph in this file is: the template is read
|
||||||
|
// as ASCII and the generator refuses to emit anything that is not, so a
|
||||||
|
// literal em dash pasted here fails the build rather than reaching the
|
||||||
|
// bar. COSMIC's applications really do use an em dash and not a hyphen
|
||||||
|
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
|
||||||
|
// a rule written with a hyphen matches nothing.
|
||||||
|
//
|
||||||
|
// There is deliberately no catch-all for the empty title. waybar's
|
||||||
|
// documentation does not say whether a rule is applied on a full match
|
||||||
|
// or a search, so an empty pattern is either "matches nothing but the
|
||||||
|
// empty title" or "matches every title", and the difference is the
|
||||||
|
// whole bar. The no-window case is handled in rules.css instead, the
|
||||||
|
// same way #mpris already handles having nothing to say.
|
||||||
|
"rewrite": {
|
||||||
|
"(.*) [-\u2014] Vivaldi": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Terminal": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Text Editor": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Files": "$1",
|
||||||
|
"(.*) [-\u2014] Mozilla Firefox": "$1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Renders nothing at all when no player is running, which is what we want
|
||||||
|
// from a centre module: it takes no space until there is something to say.
|
||||||
|
// playerctl backs every action here and is installed.
|
||||||
|
"mpris": {
|
||||||
|
"format": "{player_icon} {dynamic}",
|
||||||
|
"format-paused": "{status_icon} <i>{dynamic}</i>",
|
||||||
|
"dynamic-order": ["title", "artist"],
|
||||||
|
"dynamic-separator": " ",
|
||||||
|
"max-length": 40,
|
||||||
|
"interval": 1,
|
||||||
|
"player-icons": { "default": "@@NOTE@@" },
|
||||||
|
"status-icons": {
|
||||||
|
"playing": "@@PLAY@@",
|
||||||
|
"paused": "@@PAUSE@@",
|
||||||
|
"stopped": "@@STOP@@"
|
||||||
|
},
|
||||||
|
"on-click": "playerctl play-pause",
|
||||||
|
"on-click-middle": "playerctl previous",
|
||||||
|
"on-click-right": "playerctl next",
|
||||||
|
"on-scroll-up": "playerctl position 5+",
|
||||||
|
"on-scroll-down": "playerctl position 5-",
|
||||||
|
"tooltip-format": "{title}\nby {artist}\n{position} / {length}\n\nplayer: {player}"
|
||||||
|
},
|
||||||
|
|
||||||
|
"clock": {
|
||||||
|
"format": "{:%a %d %b %H:%M}",
|
||||||
|
"tooltip-format": "<tt><small>{calendar}</small></tt>"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Only appears while something is actually capturing. Nothing is ignored:
|
||||||
|
// an exception list is how a privacy indicator stops being one.
|
||||||
|
"privacy": {
|
||||||
|
"icon-spacing": 6,
|
||||||
|
"transition-duration": 200,
|
||||||
|
"modules": [
|
||||||
|
{ "type": "screenshare", "tooltip": true },
|
||||||
|
{ "type": "audio-in", "tooltip": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// First on the right, and silent unless something is wrong. A unit that
|
||||||
|
// failed at boot otherwise stays failed until the day you happen to run
|
||||||
|
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||||
|
// session, which has no equivalent of GNOME's abrt notification.
|
||||||
|
//
|
||||||
|
// Both scopes are watched. The user scope is where a session's own units
|
||||||
|
// fail, which is the half that matters here and the half a system-only
|
||||||
|
// check misses.
|
||||||
|
"systemd-failed-units": {
|
||||||
|
"format": "@@ALERT@@ {nr_failed}",
|
||||||
|
"format-ok": "",
|
||||||
|
"system": true,
|
||||||
|
"user": true,
|
||||||
|
"hide-on-ok": true,
|
||||||
|
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||||
|
},
|
||||||
|
|
||||||
|
"idle_inhibitor": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"activated": "@@CAFFEINE_ON@@",
|
||||||
|
"deactivated": "@@CAFFEINE_OFF@@"
|
||||||
|
},
|
||||||
|
"tooltip-format-activated": "Caffeine on -- idling and blanking suppressed",
|
||||||
|
"tooltip-format-deactivated": "Caffeine off -- normal power settings apply"
|
||||||
|
},
|
||||||
|
|
||||||
|
// swaync is this session's notification daemon: it owns
|
||||||
|
// org.freedesktop.Notifications and is D-Bus activated through
|
||||||
|
// swaync.service, so it needs no autostart entry and this module starts it
|
||||||
|
// on first contact if nothing else has.
|
||||||
|
//
|
||||||
|
// It is here because the hyprcosmic profile disables cosmic-panel and
|
||||||
|
// COSMIC's notification applet lives in that panel. Without this there is
|
||||||
|
// no way to reach notification history at all.
|
||||||
|
"custom/swaync": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"none": "@@BELL@@",
|
||||||
|
"notification": "@@BELL_DOT@@",
|
||||||
|
"dnd-none": "@@BELL_OFF@@",
|
||||||
|
"dnd-notification": "@@BELL_OFF@@",
|
||||||
|
"inhibited-none": "@@BELL@@",
|
||||||
|
"inhibited-notification": "@@BELL_DOT@@",
|
||||||
|
"dnd-inhibited-none": "@@BELL_OFF@@",
|
||||||
|
"dnd-inhibited-notification": "@@BELL_OFF@@"
|
||||||
|
},
|
||||||
|
"return-type": "json",
|
||||||
|
"exec": "swaync-client -swb",
|
||||||
|
"on-click": "swaync-client -t -sw",
|
||||||
|
"on-click-right": "swaync-client -d -sw",
|
||||||
|
"tooltip": true,
|
||||||
|
"escape": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// bluez is running with an unblocked hci0. The click target is COSMIC's own
|
||||||
|
// settings page rather than blueman or overskride, neither of which is
|
||||||
|
// installed here.
|
||||||
|
"bluetooth": {
|
||||||
|
"format": "@@BT_ON@@",
|
||||||
|
"format-disabled": "@@BT_DISABLED@@",
|
||||||
|
"format-off": "@@BT_OFF@@",
|
||||||
|
"format-connected": "@@BT_ON@@ {num_connections}",
|
||||||
|
"tooltip-format": "{controller_alias}\n{num_connections} connected",
|
||||||
|
"tooltip-format-connected": "{controller_alias}\n\n{device_enumerate}",
|
||||||
|
"tooltip-format-enumerate-connected": "{device_alias}",
|
||||||
|
"on-click": "cosmic-settings bluetooth"
|
||||||
|
},
|
||||||
|
|
||||||
|
// The click used to be `pavucontrol`, which is not installed here: a button
|
||||||
|
// that looked live and did nothing. cosmic-settings ships with the desktop,
|
||||||
|
// so it cannot go missing the same way. Scroll and middle-click need no
|
||||||
|
// helper -- waybar changes volume itself and wpctl comes with pipewire.
|
||||||
|
"pulseaudio": {
|
||||||
|
"format": "{icon} {volume}%",
|
||||||
|
"format-muted": "@@VOL_MUTE@@",
|
||||||
|
"format-bluetooth": "{icon} {volume}%",
|
||||||
|
"format-bluetooth-muted": "@@VOL_MUTE@@",
|
||||||
|
"format-icons": {
|
||||||
|
"headphone": "@@VOL_HEADPHONE@@",
|
||||||
|
"headset": "@@VOL_HEADSET@@",
|
||||||
|
"hands-free": "@@VOL_HANDSFREE@@",
|
||||||
|
"phone": "@@VOL_PHONE@@",
|
||||||
|
"car": "@@VOL_CAR@@",
|
||||||
|
"default": ["@@VOL_LO@@", "@@VOL_MID@@", "@@VOL_HI@@"]
|
||||||
|
},
|
||||||
|
"scroll-step": 5,
|
||||||
|
"tooltip-format": "{desc}\n{icon} {volume}%",
|
||||||
|
"on-click": "cosmic-settings sound",
|
||||||
|
"on-click-middle": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||||
|
},
|
||||||
|
|
||||||
|
"network": {
|
||||||
|
"format-wifi": "@@WIFI@@ {signalStrength}%",
|
||||||
|
"format-ethernet": "@@ETH@@ wired",
|
||||||
|
"format-linked": "@@ETH@@ {ifname}",
|
||||||
|
"format-disconnected": "@@NET_OFF@@ offline",
|
||||||
|
"tooltip-format": "{ifname}: {ipaddr}",
|
||||||
|
"tooltip-format-wifi": "{essid} ({signalStrength}%)\n{ifname}: {ipaddr}",
|
||||||
|
"on-click": "cosmic-settings network"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Root only. /home is on the same filesystem here, and a second entry for
|
||||||
|
// it would be the same number twice.
|
||||||
|
//
|
||||||
|
// No warning threshold is set: waybar's disk module has no `states`, so the
|
||||||
|
// colour comes from rules.css, which cannot see the percentage. The number
|
||||||
|
// is the warning.
|
||||||
|
"disk": {
|
||||||
|
"path": "/",
|
||||||
|
"format": "@@DISK@@ {percentage_used}%",
|
||||||
|
"interval": 60,
|
||||||
|
"tooltip-format": "{used} used of {total} on {path}, {free} free"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
|
||||||
|
// each other here, but the hwmon path names the CPU sensor explicitly.
|
||||||
|
//
|
||||||
|
// It is given as the PCI device's hwmon directory rather than
|
||||||
|
// /sys/class/hwmon/hwmon6 because hwmonN numbering is assigned in probe
|
||||||
|
// order and is not stable across boots. The PCI address is.
|
||||||
|
"temperature": {
|
||||||
|
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:18.3/hwmon",
|
||||||
|
"input-filename": "temp1_input",
|
||||||
|
"format": "@@TEMP@@ {temperatureC} C",
|
||||||
|
"critical-threshold": 90,
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "CPU package: {temperatureC} C"
|
||||||
|
},
|
||||||
|
|
||||||
|
// A second instance of the same module. `temperature#gpu` is waybar's alias
|
||||||
|
// syntax: everything before the # selects the module, everything after is
|
||||||
|
// just a name, and it becomes #temperature.gpu in CSS.
|
||||||
|
//
|
||||||
|
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
|
||||||
|
// 30 degrees under load, so one reading cannot stand for both.
|
||||||
|
//
|
||||||
|
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
|
||||||
|
// exposes on this card. There is no junction or memory sensor here to
|
||||||
|
// prefer over it.
|
||||||
|
//
|
||||||
|
// hwmon-path-abs is safe for this one: the PCI device has exactly one
|
||||||
|
// hwmonN inside it, unlike the asus platform device that custom/fan below
|
||||||
|
// has to work around.
|
||||||
|
"temperature#gpu": {
|
||||||
|
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
|
||||||
|
"input-filename": "temp1_input",
|
||||||
|
"format": "@@GPU@@ {temperatureC} C",
|
||||||
|
"critical-threshold": 95,
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "GPU edge: {temperatureC} C"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Speed of the fastest fan, with every fan in the tooltip. See the script
|
||||||
|
// for why this is not a temperature module with a different divisor and not
|
||||||
|
// an hwmon-path-abs: both were tried and neither can name this sensor.
|
||||||
|
//
|
||||||
|
// Emits nothing on a machine with no readable fan, and waybar draws an
|
||||||
|
// empty custom module as nothing, so this costs no space on hardware that
|
||||||
|
// has none.
|
||||||
|
"custom/fan": {
|
||||||
|
"exec": "hyprcosmic-fan",
|
||||||
|
"return-type": "json",
|
||||||
|
"format": "@@FAN@@ {} rpm",
|
||||||
|
"interval": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"cpu": {
|
||||||
|
"format": "@@CPU@@ {usage}%",
|
||||||
|
"interval": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"memory": {
|
||||||
|
"format": "@@MEM@@ {percentage}%",
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "{used:0.1f}G of {total:0.1f}G"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backed by tuned-ppd, which owns net.hadess.PowerProfiles on this system.
|
||||||
|
"power-profiles-daemon": {
|
||||||
|
"format": "{icon}",
|
||||||
|
"format-icons": {
|
||||||
|
"default": "@@PROFILE@@",
|
||||||
|
"performance": "@@PERF@@",
|
||||||
|
"balanced": "@@BALANCED@@",
|
||||||
|
"power-saver": "@@SAVER@@"
|
||||||
|
},
|
||||||
|
"tooltip-format": "power profile: {profile}",
|
||||||
|
"tooltip": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"battery": {
|
||||||
|
"states": { "warning": 30, "critical": 15 },
|
||||||
|
"format": "{icon} {capacity}%",
|
||||||
|
"format-charging": "@@PLUG@@ {capacity}%",
|
||||||
|
"format-plugged": "@@PLUG@@ {capacity}%",
|
||||||
|
"format-icons": [
|
||||||
|
"@@BAT_00@@",
|
||||||
|
"@@BAT_10@@",
|
||||||
|
"@@BAT_20@@",
|
||||||
|
"@@BAT_30@@",
|
||||||
|
"@@BAT_40@@",
|
||||||
|
"@@BAT_50@@",
|
||||||
|
"@@BAT_60@@",
|
||||||
|
"@@BAT_70@@",
|
||||||
|
"@@BAT_80@@",
|
||||||
|
"@@BAT_90@@",
|
||||||
|
"@@BAT_100@@"
|
||||||
|
],
|
||||||
|
"tooltip-format": "{timeTo}\n{power:0.1f}W",
|
||||||
|
"on-click": "cosmic-settings power"
|
||||||
|
},
|
||||||
|
|
||||||
|
"tray": { "icon-size": 18, "spacing": 8 },
|
||||||
|
|
||||||
|
// Last on the bar, because it is the one button here that can end the
|
||||||
|
// session or the uptime and the far corner is the hardest place to hit by
|
||||||
|
// accident.
|
||||||
|
//
|
||||||
|
// It is here for the same reason "custom/swaync" is: the hyprcosmic profile
|
||||||
|
// disables cosmic-panel, and COSMIC's power applet lives in that panel.
|
||||||
|
// Without this there is no logout, reboot or shutdown anywhere in the
|
||||||
|
// session -- the only way out was `systemctl reboot` from a terminal.
|
||||||
|
//
|
||||||
|
// The click runs the same script as the $mainMod SHIFT E binding rather
|
||||||
|
// than calling systemctl here, so the confirmation step cannot be bypassed
|
||||||
|
// by using the mouse.
|
||||||
|
"custom/power": {
|
||||||
|
"format": "@@POWER@@",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "Lock, suspend, log out, reboot or shut down",
|
||||||
|
"on-click": "hyprcosmic-powermenu"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate waybar config.jsonc with \\uXXXX escapes from verified codepoints.
|
||||||
|
|
||||||
|
Every codepoint here was confirmed present in JetBrainsMono Nerd Font with
|
||||||
|
`fc-list :charset=<cp>`. The template is pure ASCII and uses @TOKEN@
|
||||||
|
placeholders so that no PUA character is ever typed by hand.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# name -> codepoint. Verified present in the installed Nerd Font.
|
||||||
|
ICONS = {
|
||||||
|
"VOL_LO": 0xF026,
|
||||||
|
"VOL_MID": 0xF027,
|
||||||
|
"VOL_HI": 0xF028,
|
||||||
|
"VOL_HEADPHONE": 0xF025,
|
||||||
|
"VOL_HEADSET": 0xF025,
|
||||||
|
"VOL_HANDSFREE": 0xF025,
|
||||||
|
"VOL_PHONE": 0xF095,
|
||||||
|
"VOL_CAR": 0xF1B9,
|
||||||
|
"VOL_MUTE": 0xF075F,
|
||||||
|
"BT_ON": 0xF293,
|
||||||
|
"BT_DISABLED": 0xF294,
|
||||||
|
"BT_OFF": 0xF00B2,
|
||||||
|
"CPU": 0xF035B,
|
||||||
|
"MEM": 0xF0EE0,
|
||||||
|
"TEMP": 0xF2CA,
|
||||||
|
"WIFI": 0xF1EB,
|
||||||
|
"ETH": 0xF0200,
|
||||||
|
"NET_OFF": 0xF092F,
|
||||||
|
"PLUG": 0xF1E6,
|
||||||
|
"CAFFEINE_ON": 0xF0176,
|
||||||
|
"CAFFEINE_OFF": 0xF06CA,
|
||||||
|
"PLAY": 0xF04B,
|
||||||
|
"PAUSE": 0xF04C,
|
||||||
|
"STOP": 0xF04D,
|
||||||
|
"NOTE": 0xF001,
|
||||||
|
"BELL": 0xF0F3,
|
||||||
|
"BELL_DOT": 0xF009A,
|
||||||
|
"BELL_OFF": 0xF1F6,
|
||||||
|
"PERF": 0xF135,
|
||||||
|
"SAVER": 0xF06C,
|
||||||
|
"BALANCED": 0xF24E,
|
||||||
|
"PROFILE": 0xF0E7,
|
||||||
|
"POWER": 0xF011,
|
||||||
|
"BAT_00": 0xF008E,
|
||||||
|
"BAT_10": 0xF007A,
|
||||||
|
"BAT_20": 0xF007B,
|
||||||
|
"BAT_30": 0xF007C,
|
||||||
|
"BAT_40": 0xF007D,
|
||||||
|
"BAT_50": 0xF007E,
|
||||||
|
"BAT_60": 0xF007F,
|
||||||
|
"BAT_70": 0xF0080,
|
||||||
|
"BAT_80": 0xF0081,
|
||||||
|
"BAT_90": 0xF0082,
|
||||||
|
"BAT_100": 0xF0079,
|
||||||
|
"WINDOW": 0xF2D0,
|
||||||
|
"ALERT": 0xF0026,
|
||||||
|
"DISK": 0xF02CA,
|
||||||
|
"GPU": 0xF08AE,
|
||||||
|
"FAN": 0xF0210,
|
||||||
|
"KEYBOARD": 0xF030C,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def escape(cp: int) -> str:
|
||||||
|
"""JSON escape for one codepoint, surrogate pair when above the BMP."""
|
||||||
|
return json.dumps(chr(cp), ensure_ascii=True)[1:-1]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
template_path, out_path = sys.argv[1], sys.argv[2]
|
||||||
|
text = open(template_path, encoding="ascii").read()
|
||||||
|
|
||||||
|
unknown = {t for t in re.findall(r"@@([A-Z0-9_]+)@@", text) if t not in ICONS}
|
||||||
|
if unknown:
|
||||||
|
print("unknown tokens:", sorted(unknown), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
used = set()
|
||||||
|
|
||||||
|
def sub(m):
|
||||||
|
used.add(m.group(1))
|
||||||
|
return escape(ICONS[m.group(1)])
|
||||||
|
|
||||||
|
out = re.sub(r"@@([A-Z0-9_]+)@@", sub, text)
|
||||||
|
|
||||||
|
if not out.isascii():
|
||||||
|
print("generated file is not pure ASCII", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
open(out_path, "w", encoding="ascii").write(out)
|
||||||
|
|
||||||
|
unused = sorted(set(ICONS) - used)
|
||||||
|
print(f"wrote {out_path}: {len(out.splitlines())} lines, {len(used)} icons used")
|
||||||
|
if unused:
|
||||||
|
print("unused icon names:", unused)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* Default colours for the HyprCosmic bar.
|
||||||
|
*
|
||||||
|
* Two name sets are defined here, and both matter:
|
||||||
|
*
|
||||||
|
* - `main-bg`, `wb-act-bg` and friends are HyDE's names. A HyDE theme's
|
||||||
|
* waybar.theme is nothing but a list of these, so defining them here means
|
||||||
|
* a theme file can override them by being imported after this one.
|
||||||
|
* - `bar-bg`, `accent` and friends are the names rules.css actually uses.
|
||||||
|
*
|
||||||
|
* bridge-hyde.css maps the first set onto the second. Defining HyDE's names
|
||||||
|
* here as well is what makes a missing theme file harmless: the bridge always
|
||||||
|
* has something to resolve against, so an unthemed bar renders with these
|
||||||
|
* values instead of failing to parse.
|
||||||
|
*
|
||||||
|
* Values are Tokyo Night, matching cosmic-conf's own defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* HyDE's names. Overridden by ~/.config/waybar/theme.css when a theme is in. */
|
||||||
|
@define-color main-bg #1a1b26;
|
||||||
|
@define-color main-fg #c0caf5;
|
||||||
|
@define-color wb-act-bg #7aa2f7;
|
||||||
|
@define-color wb-act-fg #1a1b26;
|
||||||
|
@define-color wb-hvr-bg #7aa2f7;
|
||||||
|
@define-color wb-hvr-fg #565f89;
|
||||||
|
|
||||||
|
/* Names with no HyDE equivalent, so never themed and always these values. */
|
||||||
|
@define-color warning #e0af68;
|
||||||
|
@define-color critical #f7768e;
|
||||||
|
|
||||||
|
/* The bar's own background. HyDE themes define this one under the same name
|
||||||
|
* we use, and typically as a low-alpha rgba() so the compositor's blur shows
|
||||||
|
* through. */
|
||||||
|
@define-color bar-bg rgba(26, 27, 38, 0.85);
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* Geometry and layout for the HyprCosmic bar.
|
||||||
|
*
|
||||||
|
* No colour literals: every colour here is a name defined by palette.css and
|
||||||
|
* possibly re-pointed by bridge-hyde.css. That separation is what lets a theme
|
||||||
|
* change the palette without touching a single rule.
|
||||||
|
*
|
||||||
|
* The look is HyDE's: a strip carrying rounded pills rather than a flat row of
|
||||||
|
* text. HyDE gets its pills to float by making the bar itself transparent and
|
||||||
|
* letting the compositor blur the desktop behind them; cosmic-comp has no
|
||||||
|
* rule-driven blur, so the bar keeps a background here and the pills lift off
|
||||||
|
* it instead. See bridge-hyde.css for the two colours that implements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {
|
||||||
|
/* JetBrainsMono Nerd Font first, and it has to be first.
|
||||||
|
*
|
||||||
|
* This previously named "FontAwesome 6 Free", which is not even the
|
||||||
|
* installed family's real name ("Font Awesome 6 Free") -- fontconfig's
|
||||||
|
* fuzzy matching resolved it anyway, to the Regular face, which carries
|
||||||
|
* only a small subset of the icon set. Every glyph outside that subset
|
||||||
|
* then came from whatever fallback fontconfig picked per character, so the
|
||||||
|
* bar's icons were only accidentally consistent.
|
||||||
|
*
|
||||||
|
* The Nerd Font carries the whole icon set and the text, so naming it
|
||||||
|
* first makes one font answer for both. Every codepoint in config.jsonc
|
||||||
|
* was checked against this family specifically. */
|
||||||
|
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
window#waybar {
|
||||||
|
background: @bar-bg;
|
||||||
|
color: @bar-fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspaces: pills, with the active one filled rather than underlined. */
|
||||||
|
#workspaces {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button {
|
||||||
|
padding: 0 10px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: @muted;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button.active {
|
||||||
|
color: @wb-act-fg;
|
||||||
|
background: @accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#workspaces button:hover {
|
||||||
|
color: @wb-hvr-fg;
|
||||||
|
background: @wb-hvr-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
#taskbar button {
|
||||||
|
padding: 0 6px;
|
||||||
|
margin: 4px 1px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#taskbar button.active {
|
||||||
|
background: @module-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
#taskbar button:hover {
|
||||||
|
background: @wb-hvr-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every status module gets the same pill. Listed one per line because waybar
|
||||||
|
* takes the CSS id from the module name with `/` turned into `-`, so these are
|
||||||
|
* not guessable from the config and are worth being able to read down. */
|
||||||
|
#custom-keybinds,
|
||||||
|
#window,
|
||||||
|
#mpris,
|
||||||
|
#clock,
|
||||||
|
#privacy,
|
||||||
|
#systemd-failed-units,
|
||||||
|
#idle_inhibitor,
|
||||||
|
#custom-swaync,
|
||||||
|
#bluetooth,
|
||||||
|
#pulseaudio,
|
||||||
|
#network,
|
||||||
|
#disk,
|
||||||
|
#temperature,
|
||||||
|
#custom-fan,
|
||||||
|
#cpu,
|
||||||
|
#memory,
|
||||||
|
#power-profiles-daemon,
|
||||||
|
#battery,
|
||||||
|
#tray,
|
||||||
|
#custom-power {
|
||||||
|
padding: 0 10px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: @module-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The only module here that is a button rather than a readout, so it is the
|
||||||
|
* only one that gains anything from a hover state. Colour rather than
|
||||||
|
* background, to match how the status colours below signal without moving
|
||||||
|
* anything. */
|
||||||
|
#custom-power:hover {
|
||||||
|
color: @critical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The other button on the bar. Accent rather than critical, because this one is
|
||||||
|
* safe to press. */
|
||||||
|
#custom-keybinds:hover {
|
||||||
|
color: @accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clock {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mpris renders nothing when no player is running. Without this it would still
|
||||||
|
* draw an empty pill, so the padding goes away with the content. */
|
||||||
|
#mpris {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mpris.playing,
|
||||||
|
#mpris.paused {
|
||||||
|
padding: 0 10px;
|
||||||
|
background: @module-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same reasoning: privacy is only present while something is capturing. */
|
||||||
|
#privacy {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#privacy-item {
|
||||||
|
padding: 0 8px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: @module-bg;
|
||||||
|
color: @warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same reasoning as mpris, but the class is not on the widget. waybar puts
|
||||||
|
* `empty` on the whole bar rather than on #window, so the selector has to
|
||||||
|
* descend into it -- waybar-hyprland-window(5) gives it as exactly this:
|
||||||
|
*
|
||||||
|
* window#waybar.empty #window When no windows are in the workspace
|
||||||
|
*
|
||||||
|
* The first `window` is the GTK window, the second is our module; they are
|
||||||
|
* unrelated names that collide, which is why this reads so oddly. */
|
||||||
|
window#waybar.empty #window {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States. These recolour the text inside the pill rather than the pill, so a
|
||||||
|
* warning cannot make a module unreadable against its own background. */
|
||||||
|
#battery.warning { color: @warning; }
|
||||||
|
#battery.critical { color: @critical; }
|
||||||
|
#temperature.critical { color: @critical; }
|
||||||
|
#network.disconnected { color: @critical; }
|
||||||
|
|
||||||
|
/* No state class needed: hide-on-ok removes the module entirely at zero, so it
|
||||||
|
* is on screen only when there is something to report and can be coloured
|
||||||
|
* unconditionally. */
|
||||||
|
#systemd-failed-units { color: @critical; }
|
||||||
|
|
||||||
|
/* Set by hyprcosmic-fan: `high` above 4000 rpm, `idle` when every fan has
|
||||||
|
* stopped. `normal` is left alone. */
|
||||||
|
#custom-fan.high { color: @warning; }
|
||||||
|
#custom-fan.idle { color: @muted; }
|
||||||
|
#pulseaudio.muted { color: @muted; }
|
||||||
|
#idle_inhibitor.activated { color: @accent; }
|
||||||
|
#custom-swaync.notification { color: @accent; }
|
||||||
|
#custom-swaync.dnd-none,
|
||||||
|
#custom-swaync.dnd-notification { color: @muted; }
|
||||||
|
|
||||||
|
/* No `#tray:empty` rule to hide the pill when nothing is in the tray: GTK's
|
||||||
|
* CSS has no :empty pseudo-class and rejects the whole stylesheet for it,
|
||||||
|
* which is fatal rather than cosmetic. waybar exits and the bar never appears.
|
||||||
|
*/
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Waybar styling for a HyprCosmic session.
|
||||||
|
*
|
||||||
|
* This file is only an import list, because the order is the whole design:
|
||||||
|
*
|
||||||
|
* 1. palette.css default colours, under both our names and HyDE's
|
||||||
|
* 2. theme.css the installed HyDE theme's waybar.theme, if any
|
||||||
|
* 3. bridge-hyde.css maps HyDE's colour names onto the ones the rules use
|
||||||
|
* 4. rules.css geometry and layout; no colour literals at all
|
||||||
|
*
|
||||||
|
* Every entry is an @import, so they are read strictly in sequence and a later
|
||||||
|
* definition of a colour wins over an earlier one. That is what lets step 2
|
||||||
|
* recolour the bar without step 4 knowing a theme exists.
|
||||||
|
*
|
||||||
|
* Step 2 is a copy of the installed theme's waybar.theme, kept as a sibling of
|
||||||
|
* this file rather than read from HyDE's own ~/.config/waybar/theme.css.
|
||||||
|
*
|
||||||
|
* That copy exists because a missing @import is fatal in GTK, not a warning:
|
||||||
|
* pointing at HyDE's path directly means the whole stylesheet fails to load on
|
||||||
|
* any machine where no theme has been imported. A sibling file we create at
|
||||||
|
* install time is always present -- empty when there is no theme, in which
|
||||||
|
* case the defaults from step 1 stand. palette.css defines HyDE's names too,
|
||||||
|
* so the bridge in step 3 resolves either way.
|
||||||
|
*
|
||||||
|
* Consequently this file belongs at ~/.config/hyprcosmic/waybar/style.css, not
|
||||||
|
* under /usr/share: a relative @import resolves against the importing file,
|
||||||
|
* and theme.css is per-user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url("file:///usr/share/hyprcosmic/waybar/palette.css");
|
||||||
|
@import url("theme.css");
|
||||||
|
@import url("file:///usr/share/hyprcosmic/waybar/bridge-hyde.css");
|
||||||
|
@import url("file:///usr/share/hyprcosmic/waybar/rules.css");
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* Step 2 of the import chain in style.css: the installed HyDE theme's
|
||||||
|
* waybar.theme.
|
||||||
|
*
|
||||||
|
* This copy is deliberately empty of rules. It is not a stub waiting to be
|
||||||
|
* written -- an empty file is the correct content when no theme has been
|
||||||
|
* imported, and it is what makes the other three steps work:
|
||||||
|
*
|
||||||
|
* - A missing @import is fatal in GTK, so style.css cannot name a file that
|
||||||
|
* might not exist. This one always exists, from the moment the session
|
||||||
|
* first starts.
|
||||||
|
* - Every colour the rules consume already has a default from palette.css
|
||||||
|
* and bridge-hyde.css, so a file that defines nothing leaves a complete
|
||||||
|
* stylesheet rather than an undefined name.
|
||||||
|
*
|
||||||
|
* `hyprcosmic-conf import-theme` overwrites this with the waybar.theme of the
|
||||||
|
* theme being imported. Anything written here by hand is lost at that point,
|
||||||
|
* which is why the per-user override belongs in style.css instead.
|
||||||
|
*/
|
||||||
+1
-1
Submodule cosmic-applets updated: 50d931b191...ec8ffdc85d
+1
-1
Submodule cosmic-applibrary updated: 1b8918d005...172acdae07
+1
-1
Submodule cosmic-bg updated: ff506f91f7...76e89e6aff
+1
-1
Submodule cosmic-comp updated: 8a5d78dbb0...a73734edb0
@@ -0,0 +1,4 @@
|
|||||||
|
# Single-core builds: this crate is developed on a machine where parallel
|
||||||
|
# rustc jobs are not wanted.
|
||||||
|
[build]
|
||||||
|
jobs = 1
|
||||||
Generated
+526
@@ -0,0 +1,526 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cosmic-conf"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"flate2",
|
||||||
|
"notify",
|
||||||
|
"regex",
|
||||||
|
"ron",
|
||||||
|
"serde",
|
||||||
|
"tar",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fsevent-sys"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"inotify-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inotify-sys"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
|
||||||
|
dependencies = [
|
||||||
|
"kqueue-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kqueue-sys"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify"
|
||||||
|
version = "8.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"fsevent-sys",
|
||||||
|
"inotify",
|
||||||
|
"kqueue",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"mio",
|
||||||
|
"notify-types",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-types"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ron"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"typeid",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "cosmic-conf"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
flate2 = "1.1.9"
|
||||||
|
notify = "8.2.0"
|
||||||
|
# Only to reject a bad `windowrule` expression at apply time rather than
|
||||||
|
# letting it fail silently in the compositor. Pinned to the same major the
|
||||||
|
# compositor matches with, so what compiles here compiles there.
|
||||||
|
regex = "1"
|
||||||
|
ron = "0.12"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
tar = "0.4.46"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# `emit` links cosmic-config and the component crates. Off by default so the
|
||||||
|
# pure units (parser, schema, resolve) build and test without the libcosmic
|
||||||
|
# dependency graph.
|
||||||
|
default = []
|
||||||
|
emit = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.27.0"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
|||||||
|
//! Hyprland `bind` lines -> COSMIC shortcut bindings.
|
||||||
|
//!
|
||||||
|
//! `bind = SUPER, D, exec, rofi -show drun` is the single most recognisable
|
||||||
|
//! line in a hyprland.conf, so it is the one piece of the idiom that has to
|
||||||
|
//! feel native rather than translated.
|
||||||
|
//!
|
||||||
|
//! The target is the `custom` key of `com.system76.CosmicSettings.Shortcuts`,
|
||||||
|
//! which the compositor merges over `defaults`, letting a bind here override a
|
||||||
|
//! stock COSMIC shortcut without touching the system file
|
||||||
|
//! (cosmic-settings-daemon `config/src/shortcuts/mod.rs`: `shortcuts()` reads
|
||||||
|
//! `defaults`, then extends with `custom`).
|
||||||
|
//!
|
||||||
|
//! Actions are rendered as RON text rather than modelled as an enum. COSMIC's
|
||||||
|
//! `Action` has forty-odd variants and this crate deliberately does not link
|
||||||
|
//! the cosmic crates; mirroring the enum would mean re-copying it every time
|
||||||
|
//! upstream adds a variant, and the mapping table below only ever needs a few.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use crate::parser::Span;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Bind {
|
||||||
|
/// COSMIC modifier names, deduplicated and in COSMIC's own order.
|
||||||
|
pub mods: Vec<&'static str>,
|
||||||
|
/// xkb keysym name. `None` is a modifier-only binding, which COSMIC
|
||||||
|
/// supports and its defaults use for the launcher on bare Super.
|
||||||
|
pub key: Option<String>,
|
||||||
|
/// Pre-rendered RON, e.g. `Spawn("rofi -show drun")` or `Focus(Left)`.
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BindError {
|
||||||
|
pub message: String,
|
||||||
|
pub help: Option<String>,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> BindError {
|
||||||
|
BindError {
|
||||||
|
message: message.into(),
|
||||||
|
help: help.map(str::to_string),
|
||||||
|
span,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifier spellings Hyprland accepts, longest first so that the greedy scan
|
||||||
|
/// below consumes `SUPERSHIFT` correctly rather than stopping at a prefix.
|
||||||
|
const MODIFIERS: &[(&str, &str)] = &[
|
||||||
|
("SUPERKEY", "Super"),
|
||||||
|
("CONTROL", "Ctrl"),
|
||||||
|
("SHIFT", "Shift"),
|
||||||
|
("SUPER", "Super"),
|
||||||
|
("LOGO", "Super"),
|
||||||
|
("MOD4", "Super"),
|
||||||
|
("MOD1", "Alt"),
|
||||||
|
("CTRL", "Ctrl"),
|
||||||
|
("META", "Super"),
|
||||||
|
("ALT", "Alt"),
|
||||||
|
("WIN", "Super"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// COSMIC writes modifiers in this order in its own defaults; matching it keeps
|
||||||
|
/// generated files diffable against hand-written ones.
|
||||||
|
const MODIFIER_ORDER: &[&str] = &["Super", "Ctrl", "Alt", "Shift"];
|
||||||
|
|
||||||
|
/// Hyprland allows `SUPER SHIFT`, `SUPER+SHIFT` and bare `SUPERSHIFT`, so
|
||||||
|
/// separators are stripped and the remainder is consumed greedily.
|
||||||
|
fn parse_modifiers(raw: &str, span: Span) -> Result<Vec<&'static str>, BindError> {
|
||||||
|
let mut rest: String = raw
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && *c != '+' && *c != '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
|
||||||
|
let mut found: Vec<&'static str> = Vec::new();
|
||||||
|
'outer: while !rest.is_empty() {
|
||||||
|
for (spelling, cosmic) in MODIFIERS {
|
||||||
|
if let Some(tail) = rest.strip_prefix(spelling) {
|
||||||
|
if !found.contains(cosmic) {
|
||||||
|
found.push(cosmic);
|
||||||
|
}
|
||||||
|
rest = tail.to_string();
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unknown modifier `{rest}`"),
|
||||||
|
Some("known modifiers: SUPER, CTRL, ALT, SHIFT"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort_by_key(|m| {
|
||||||
|
MODIFIER_ORDER
|
||||||
|
.iter()
|
||||||
|
.position(|o| o == m)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
});
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named keys whose xkb spelling differs from what a Hyprland user types.
|
||||||
|
///
|
||||||
|
/// Anything absent falls through unchanged, so exact keysyms such as
|
||||||
|
/// `XF86AudioRaiseVolume` keep working without needing an entry here.
|
||||||
|
const KEY_NAMES: &[(&str, &str)] = &[
|
||||||
|
("return", "Return"),
|
||||||
|
("enter", "Return"),
|
||||||
|
("escape", "Escape"),
|
||||||
|
("esc", "Escape"),
|
||||||
|
("tab", "Tab"),
|
||||||
|
("backspace", "BackSpace"),
|
||||||
|
("delete", "Delete"),
|
||||||
|
("insert", "Insert"),
|
||||||
|
("home", "Home"),
|
||||||
|
("end", "End"),
|
||||||
|
("pageup", "Prior"),
|
||||||
|
("pagedown", "Next"),
|
||||||
|
("left", "Left"),
|
||||||
|
("right", "Right"),
|
||||||
|
("up", "Up"),
|
||||||
|
("down", "Down"),
|
||||||
|
("print", "Print"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// COSMIC's defaults spell letters lowercase (`key: "q"`) and punctuation by
|
||||||
|
/// keysym name (`key: "slash"`), so normalise toward that.
|
||||||
|
fn normalize_key(raw: &str) -> Option<String> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
|
||||||
|
if let Some((_, name)) = KEY_NAMES.iter().find(|(k, _)| *k == lower) {
|
||||||
|
return Some((*name).to_string());
|
||||||
|
}
|
||||||
|
// Function keys are uppercase-F in xkb.
|
||||||
|
if let Some(n) = lower.strip_prefix('f') {
|
||||||
|
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return Some(format!("F{n}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trimmed.len() == 1 && trimmed.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||||
|
return Some(lower);
|
||||||
|
}
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ron_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direction(arg: &str, span: Span, dispatcher: &str) -> Result<&'static str, BindError> {
|
||||||
|
Ok(match arg.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"l" | "left" => "Left",
|
||||||
|
"r" | "right" => "Right",
|
||||||
|
"u" | "up" => "Up",
|
||||||
|
"d" | "down" => "Down",
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{dispatcher}` needs a direction, got `{other}`"),
|
||||||
|
Some("use l, r, u or d"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_index(arg: &str, span: Span, dispatcher: &str) -> Result<u8, BindError> {
|
||||||
|
arg.trim().parse::<u8>().map_err(|_| {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!(
|
||||||
|
"`{dispatcher}` needs a workspace number, got `{}`",
|
||||||
|
arg.trim()
|
||||||
|
),
|
||||||
|
Some("COSMIC addresses workspaces 1-255 by index"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a Hyprland dispatcher and its argument into RON for COSMIC's
|
||||||
|
/// `Action`.
|
||||||
|
///
|
||||||
|
/// Only dispatchers with a genuine COSMIC equivalent are mapped. A dispatcher
|
||||||
|
/// that merely looks similar is rejected instead of approximated, because a
|
||||||
|
/// keybinding that silently does the wrong thing is worse than one that fails
|
||||||
|
/// to compile.
|
||||||
|
fn action(dispatcher: &str, arg: &str, span: Span) -> Result<String, BindError> {
|
||||||
|
let d = dispatcher.trim().to_ascii_lowercase();
|
||||||
|
Ok(match d.as_str() {
|
||||||
|
"exec" => {
|
||||||
|
let cmd = arg.trim();
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return Err(err(span, "`exec` needs a command", None));
|
||||||
|
}
|
||||||
|
// cosmic-comp runs this through `/bin/sh -c`
|
||||||
|
// (`src/input/actions.rs`: `spawn_command`), so a full command line
|
||||||
|
// with arguments and quoting behaves as written.
|
||||||
|
format!("Spawn({})", ron_string(cmd))
|
||||||
|
}
|
||||||
|
"killactive" => "Close".into(),
|
||||||
|
"fullscreen" => "Fullscreen".into(),
|
||||||
|
"togglefloating" => "ToggleWindowFloating".into(),
|
||||||
|
"togglesplit" => "ToggleOrientation".into(),
|
||||||
|
"togglegroup" => "ToggleStacking".into(),
|
||||||
|
"pin" => "ToggleSticky".into(),
|
||||||
|
"exit" => "System(LogOut)".into(),
|
||||||
|
"movefocus" => format!("Focus({})", direction(arg, span, &d)?),
|
||||||
|
"movewindow" => format!("Move({})", direction(arg, span, &d)?),
|
||||||
|
"workspace" => format!("Workspace({})", workspace_index(arg, span, &d)?),
|
||||||
|
"movetoworkspace" => format!("MoveToWorkspace({})", workspace_index(arg, span, &d)?),
|
||||||
|
"movetoworkspacesilent" => format!("SendToWorkspace({})", workspace_index(arg, span, &d)?),
|
||||||
|
"focusmonitor" => format!("SwitchOutput({})", direction(arg, span, &d)?),
|
||||||
|
"movewindowtomonitor" => format!("MoveToOutput({})", direction(arg, span, &d)?),
|
||||||
|
|
||||||
|
// Present in Hyprland, absent from COSMIC. Named explicitly so the
|
||||||
|
// error says why rather than "unknown".
|
||||||
|
"pseudo" | "forcerendererreload" | "submap" | "toggleopaque" | "centerwindow"
|
||||||
|
| "splitratio" | "cyclenext" | "swapnext" => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{d}` has no COSMIC equivalent"),
|
||||||
|
Some("remove the bind, or use `exec` to run a program instead"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unknown dispatcher `{other}`"),
|
||||||
|
Some("supported: exec, killactive, fullscreen, togglefloating, togglesplit, movefocus, movewindow, workspace, movetoworkspace, exit"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the value of one `bind = ...` line.
|
||||||
|
///
|
||||||
|
/// Shape is `MODS, KEY, dispatcher, args`, with args keeping any further
|
||||||
|
/// commas, since `exec` commands routinely contain them.
|
||||||
|
pub fn parse_bind(value: &str, span: Span) -> Result<Bind, BindError> {
|
||||||
|
let parts: Vec<&str> = value.splitn(4, ',').collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
"a bind needs at least MODS, KEY and a dispatcher",
|
||||||
|
Some("for example: bind = SUPER, D, exec, rofi -show drun"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mods = parse_modifiers(parts[0], span)?;
|
||||||
|
let key = normalize_key(parts[1]);
|
||||||
|
if mods.is_empty() && key.is_none() {
|
||||||
|
return Err(err(span, "a bind needs a modifier or a key", None));
|
||||||
|
}
|
||||||
|
|
||||||
|
let arg = parts.get(3).copied().unwrap_or("");
|
||||||
|
let action = action(parts[2], arg, span)?;
|
||||||
|
|
||||||
|
Ok(Bind { mods, key, action })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the collected binds as the RON map COSMIC stores in `custom`.
|
||||||
|
pub fn render(binds: &[Bind]) -> String {
|
||||||
|
let mut out = String::from("{\n");
|
||||||
|
for b in binds {
|
||||||
|
let mods = b
|
||||||
|
.mods
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
match &b.key {
|
||||||
|
// `key` is `skip_serializing_if = "Option::is_none"` on COSMIC's
|
||||||
|
// `Binding`, and its own defaults omit it for `(modifiers: [Super])`.
|
||||||
|
Some(k) => {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" (modifiers: [{mods}], key: {}): {},",
|
||||||
|
ron_string(k),
|
||||||
|
b.action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = writeln!(out, " (modifiers: [{mods}]): {},", b.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str("}\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const S: Span = Span {
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
len: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn bind(v: &str) -> Bind {
|
||||||
|
parse_bind(v, S).expect("should parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_canonical_hyprland_launcher_bind() {
|
||||||
|
let b = bind("SUPER, D, exec, rofi -show drun");
|
||||||
|
assert_eq!(b.mods, vec!["Super"]);
|
||||||
|
assert_eq!(b.key.as_deref(), Some("d"));
|
||||||
|
assert_eq!(b.action, r#"Spawn("rofi -show drun")"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_accept_every_separator_hyprland_does() {
|
||||||
|
for spelling in ["SUPER SHIFT", "SUPER+SHIFT", "SUPERSHIFT", "super shift"] {
|
||||||
|
assert_eq!(
|
||||||
|
bind(&format!("{spelling}, Q, killactive")).mods,
|
||||||
|
vec!["Super", "Shift"],
|
||||||
|
"failed for `{spelling}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_are_ordered_like_cosmics_own_defaults() {
|
||||||
|
assert_eq!(
|
||||||
|
bind("SHIFT ALT CTRL SUPER, Q, killactive").mods,
|
||||||
|
vec!["Super", "Ctrl", "Alt", "Shift"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bind_with_no_key_is_modifier_only() {
|
||||||
|
// COSMIC's defaults bind bare Super to the launcher this way.
|
||||||
|
let b = bind("SUPER, , exec, rofi -show drun");
|
||||||
|
assert_eq!(b.key, None);
|
||||||
|
assert_eq!(
|
||||||
|
render(&[b]),
|
||||||
|
"{\n (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_normalise_to_xkb_spelling() {
|
||||||
|
assert_eq!(bind("SUPER, Q, killactive").key.as_deref(), Some("q"));
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER, Return, killactive").key.as_deref(),
|
||||||
|
Some("Return")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER, enter, killactive").key.as_deref(),
|
||||||
|
Some("Return")
|
||||||
|
);
|
||||||
|
assert_eq!(bind("SUPER, f5, killactive").key.as_deref(), Some("F5"));
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER, slash, killactive").key.as_deref(),
|
||||||
|
Some("slash")
|
||||||
|
);
|
||||||
|
// Unknown names pass through so exact keysyms stay usable.
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER, XF86AudioRaiseVolume, killactive")
|
||||||
|
.key
|
||||||
|
.as_deref(),
|
||||||
|
Some("XF86AudioRaiseVolume")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exec_keeps_commas_in_the_command() {
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER, E, exec, sh -c 'echo a, b'").action,
|
||||||
|
r#"Spawn("sh -c 'echo a, b'")"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quotes_in_a_command_are_escaped_not_emitted_raw() {
|
||||||
|
// Otherwise the generated RON would not parse.
|
||||||
|
assert_eq!(
|
||||||
|
bind(r#"SUPER, E, exec, echo "hi""#).action,
|
||||||
|
r#"Spawn("echo \"hi\"")"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatchers_map_to_cosmic_actions() {
|
||||||
|
assert_eq!(bind("SUPER, Q, killactive").action, "Close");
|
||||||
|
assert_eq!(bind("SUPER, F, fullscreen").action, "Fullscreen");
|
||||||
|
assert_eq!(bind("SUPER, left, movefocus, l").action, "Focus(Left)");
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER SHIFT, left, movewindow, l").action,
|
||||||
|
"Move(Left)"
|
||||||
|
);
|
||||||
|
assert_eq!(bind("SUPER, 1, workspace, 1").action, "Workspace(1)");
|
||||||
|
assert_eq!(
|
||||||
|
bind("SUPER SHIFT, 1, movetoworkspace, 1").action,
|
||||||
|
"MoveToWorkspace(1)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dispatcher_without_an_equivalent_is_refused_not_approximated() {
|
||||||
|
let e = parse_bind("SUPER, P, pseudo", S).unwrap_err();
|
||||||
|
assert!(e.message.contains("no COSMIC equivalent"), "{}", e.message);
|
||||||
|
assert!(e.help.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_dispatchers_and_modifiers_are_reported() {
|
||||||
|
assert!(parse_bind("SUPER, X, frobnicate", S)
|
||||||
|
.unwrap_err()
|
||||||
|
.message
|
||||||
|
.contains("unknown dispatcher"));
|
||||||
|
assert!(parse_bind("HYPER, X, killactive", S)
|
||||||
|
.unwrap_err()
|
||||||
|
.message
|
||||||
|
.contains("unknown modifier"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_bind_says_what_shape_is_expected() {
|
||||||
|
let e = parse_bind("SUPER, D", S).unwrap_err();
|
||||||
|
assert!(e.help.unwrap().contains("rofi -show drun"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendering_matches_the_shape_cosmic_writes_in_its_defaults() {
|
||||||
|
let out = render(&[
|
||||||
|
bind("SUPER, , exec, rofi -show drun"),
|
||||||
|
bind("SUPER, slash, exec, rofi -show drun"),
|
||||||
|
bind("SUPER SHIFT, Q, killactive"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
concat!(
|
||||||
|
"{\n",
|
||||||
|
" (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n",
|
||||||
|
" (modifiers: [Super], key: \"slash\"): Spawn(\"rofi -show drun\"),\n",
|
||||||
|
" (modifiers: [Super, Shift], key: \"q\"): Close,\n",
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
//! Resolved writes -> the cosmic-config tree.
|
||||||
|
//!
|
||||||
|
//! Spike 2 established the mechanism (see the spec's verified-findings table):
|
||||||
|
//! cosmic-config is a filesystem key-value store at
|
||||||
|
//! `$XDG_CONFIG_HOME/cosmic/<component>/v<n>/<key>`, each file holding one RON
|
||||||
|
//! literal. `Config::watch` (`cosmic-config/src/lib.rs:377`) is a `notify`
|
||||||
|
//! inotify watch on that directory which derives changed keys from file paths,
|
||||||
|
//! so a plain atomic write is observed exactly like a write from the typed API.
|
||||||
|
//! That is why this module needs `ron` rather than the whole libcosmic graph.
|
||||||
|
//!
|
||||||
|
//! Emission is two-stage on purpose. `plan` reads current state and renders
|
||||||
|
//! every file's new contents without touching disk; `apply` then writes. A
|
||||||
|
//! failure while planning therefore leaves the desktop untouched, preserving
|
||||||
|
//! the transactional guarantee `resolve` starts.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::resolve::{Resolved, TargetKey, Value, Write, WriteKind};
|
||||||
|
|
||||||
|
/// Mirror of `cosmic_theme::CornerRadii` (`cosmic-theme/src/model/corner.rs:5`).
|
||||||
|
///
|
||||||
|
/// Duplicated rather than depended upon so this crate stays free of the
|
||||||
|
/// libcosmic build graph. The field set and defaults are pinned by tests; if
|
||||||
|
/// upstream adds a radius, round-tripping would silently drop it, so
|
||||||
|
/// `deny_unknown_fields` turns that into a loud parse error instead.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CornerRadii {
|
||||||
|
radius_0: [f32; 4],
|
||||||
|
radius_xs: [f32; 4],
|
||||||
|
radius_s: [f32; 4],
|
||||||
|
radius_m: [f32; 4],
|
||||||
|
radius_l: [f32; 4],
|
||||||
|
radius_xl: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CornerRadii {
|
||||||
|
/// `corner.rs:20-31`.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
radius_0: [0.0; 4],
|
||||||
|
radius_xs: [4.0; 4],
|
||||||
|
radius_s: [8.0; 4],
|
||||||
|
radius_m: [16.0; 4],
|
||||||
|
radius_l: [32.0; 4],
|
||||||
|
radius_xl: [160.0; 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CornerRadii {
|
||||||
|
fn field_mut(&mut self, name: &str) -> Option<&mut [f32; 4]> {
|
||||||
|
Some(match name {
|
||||||
|
"radius_0" => &mut self.radius_0,
|
||||||
|
"radius_xs" => &mut self.radius_xs,
|
||||||
|
"radius_s" => &mut self.radius_s,
|
||||||
|
"radius_m" => &mut self.radius_m,
|
||||||
|
"radius_l" => &mut self.radius_l,
|
||||||
|
"radius_xl" => &mut self.radius_xl,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum EmitError {
|
||||||
|
Io(io::Error),
|
||||||
|
/// A projected target whose composite shape this emitter cannot rebuild.
|
||||||
|
UnsupportedComposite {
|
||||||
|
key: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
/// An existing file could not be parsed, so read-modify-write is unsafe.
|
||||||
|
Unreadable {
|
||||||
|
path: PathBuf,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
NoConfigDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EmitError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
EmitError::Io(e) => write!(f, "io error: {e}"),
|
||||||
|
EmitError::UnsupportedComposite { key, detail } => {
|
||||||
|
write!(f, "cannot write `{key}`: {detail}")
|
||||||
|
}
|
||||||
|
EmitError::Unreadable { path, detail } => {
|
||||||
|
write!(f, "cannot parse existing `{}`: {detail}", path.display())
|
||||||
|
}
|
||||||
|
EmitError::NoConfigDirectory => write!(f, "no config directory available"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for EmitError {}
|
||||||
|
|
||||||
|
impl From<io::Error> for EmitError {
|
||||||
|
fn from(e: io::Error) -> Self {
|
||||||
|
EmitError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file's worth of pending change. `previous` powers `apply --diff`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Planned {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub contents: String,
|
||||||
|
pub previous: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Planned {
|
||||||
|
/// A write that would not change anything on disk.
|
||||||
|
pub fn is_noop(&self) -> bool {
|
||||||
|
self.previous.as_deref() == Some(self.contents.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Emitter {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Emitter {
|
||||||
|
/// Locate the cosmic-config root the same way cosmic-config does:
|
||||||
|
/// `$XDG_CONFIG_HOME/cosmic`, falling back to `$HOME/.config/cosmic`.
|
||||||
|
pub fn from_env() -> Result<Self, EmitError> {
|
||||||
|
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||||
|
_ => {
|
||||||
|
let home = std::env::var_os("HOME").ok_or(EmitError::NoConfigDirectory)?;
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
root: base.join("cosmic"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_root(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { root: root.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, target: &TargetKey) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join(&target.component)
|
||||||
|
.join(format!("v{}", target.version))
|
||||||
|
.join(&target.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render every write without touching disk.
|
||||||
|
///
|
||||||
|
/// Returns all errors rather than the first, matching `resolve`'s behaviour
|
||||||
|
/// so a user sees the whole picture in one pass.
|
||||||
|
pub fn plan(&self, resolved: &Resolved) -> Result<Vec<Planned>, Vec<EmitError>> {
|
||||||
|
let mut planned = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for write in &resolved.writes {
|
||||||
|
match self.plan_one(write) {
|
||||||
|
Ok(p) => planned.push(p),
|
||||||
|
Err(e) => errors.push(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(planned)
|
||||||
|
} else {
|
||||||
|
Err(errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_one(&self, write: &Write) -> Result<Planned, EmitError> {
|
||||||
|
let path = self.path_for(&write.target);
|
||||||
|
let previous = match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let contents = match &write.kind {
|
||||||
|
WriteKind::Whole(v) => render(v),
|
||||||
|
WriteKind::Projected(fields) => {
|
||||||
|
composite(&write.target, fields, previous.as_deref(), &path)?
|
||||||
|
}
|
||||||
|
// No merge with `previous`: cosmic.conf owns this value outright,
|
||||||
|
// which is the whole point of the one-way model. Anything set in
|
||||||
|
// COSMIC's own settings UI is replaced, not accumulated.
|
||||||
|
WriteKind::Verbatim(s) => s.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Planned {
|
||||||
|
path,
|
||||||
|
contents,
|
||||||
|
previous,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the plan. Callers should `plan` first so that failures surface
|
||||||
|
/// before any file is touched.
|
||||||
|
pub fn apply(&self, planned: &[Planned]) -> Result<usize, EmitError> {
|
||||||
|
let mut written = 0;
|
||||||
|
for p in planned {
|
||||||
|
if p.is_noop() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(dir) = p.path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
atomic_write(&p.path, &p.contents)?;
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write via temp-file + rename so a reader never observes a partial file.
|
||||||
|
///
|
||||||
|
/// The temp name carries cosmic-config's `.atomicwrite` prefix
|
||||||
|
/// (`cosmic-config/src/lib.rs:408`) so its watcher ignores the intermediate
|
||||||
|
/// file and reacts only to the final rename.
|
||||||
|
fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
|
||||||
|
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let tmp = dir.join(format!(".atomicwrite.{name}"));
|
||||||
|
|
||||||
|
fs::write(&tmp, contents)?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a scalar as the RON literal cosmic-config expects.
|
||||||
|
///
|
||||||
|
/// Exact formatting is not load-bearing — cosmic-config reads with
|
||||||
|
/// `ron::from_str` (`lib.rs:468`) — but the *shape* is: `Option<Srgb>` has three
|
||||||
|
/// components, `Option<Srgba>` four.
|
||||||
|
fn render(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::U32(n) => n.to_string(),
|
||||||
|
Value::F32(n) => render_f32(*n),
|
||||||
|
Value::Str(s) => format!("{s:?}"),
|
||||||
|
Value::Rgb(r, g, b) => format!(
|
||||||
|
"Some((red: {}, green: {}, blue: {}))",
|
||||||
|
render_f32(byte_to_f32(*r)),
|
||||||
|
render_f32(byte_to_f32(*g)),
|
||||||
|
render_f32(byte_to_f32(*b)),
|
||||||
|
),
|
||||||
|
Value::Rgba(r, g, b, a) => format!(
|
||||||
|
"Some((red: {}, green: {}, blue: {}, alpha: {}))",
|
||||||
|
render_f32(byte_to_f32(*r)),
|
||||||
|
render_f32(byte_to_f32(*g)),
|
||||||
|
render_f32(byte_to_f32(*b)),
|
||||||
|
render_f32(byte_to_f32(*a)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_to_f32(b: u8) -> f32 {
|
||||||
|
b as f32 / 255.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RON needs floats to look like floats: a bare `10` would deserialize as an
|
||||||
|
/// integer and fail a `f32` field.
|
||||||
|
fn render_f32(n: f32) -> String {
|
||||||
|
if n.fract() == 0.0 {
|
||||||
|
format!("{n:.1}")
|
||||||
|
} else {
|
||||||
|
format!("{n}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild a composite value from folded projections plus whatever is already
|
||||||
|
/// on disk.
|
||||||
|
///
|
||||||
|
/// Only shapes that can be reconstructed correctly are supported. Anything else
|
||||||
|
/// is a hard error rather than a partial write, because silently writing an
|
||||||
|
/// incomplete composite would drop the user's other fields.
|
||||||
|
fn composite(
|
||||||
|
target: &TargetKey,
|
||||||
|
fields: &BTreeMap<Vec<String>, Value>,
|
||||||
|
previous: Option<&str>,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<String, EmitError> {
|
||||||
|
match target.key.as_str() {
|
||||||
|
// ThemeBuilder.gaps: (u32, u32) ordered (outer, inner) — theme.rs:895,
|
||||||
|
// default (0, 8) — theme.rs:939.
|
||||||
|
"gaps" => {
|
||||||
|
let (mut outer, mut inner) = match previous {
|
||||||
|
Some(text) => {
|
||||||
|
ron::from_str::<(u32, u32)>(text).map_err(|e| EmitError::Unreadable {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
None => (0, 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (p, v) in fields {
|
||||||
|
let Value::U32(n) = v else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("expected an integer for index {p:?}"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
match p.first().map(String::as_str) {
|
||||||
|
Some("0") => outer = *n,
|
||||||
|
Some("1") => inner = *n,
|
||||||
|
other => {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("unknown tuple index {other:?}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(format!("({outer}, {inner})"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeBuilder.corner_radii: six [f32; 4] fields — corner.rs:5.
|
||||||
|
"corner_radii" => {
|
||||||
|
let mut radii = match previous {
|
||||||
|
Some(text) => {
|
||||||
|
ron::from_str::<CornerRadii>(text).map_err(|e| EmitError::Unreadable {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
None => CornerRadii::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (p, v) in fields {
|
||||||
|
let Value::F32(n) = v else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("expected a number for {p:?}"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(name) = p.first() else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: "missing radius name".into(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(slot) = radii.field_mut(name) else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("unknown radius `{name}`"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// A single `rounding` value applies to all four corners.
|
||||||
|
*slot = [*n; 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
ron::ser::to_string_pretty(&radii, ron::ser::PrettyConfig::new()).map_err(|e| {
|
||||||
|
EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
other => Err(EmitError::UnsupportedComposite {
|
||||||
|
key: other.to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"composite shape not modelled yet; {} field(s) would be written blind",
|
||||||
|
fields.len()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{parse, resolve};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn plan_for(src: &str, root: &Path) -> Result<Vec<Planned>, Vec<EmitError>> {
|
||||||
|
let ast = parse(src).expect("parse");
|
||||||
|
let resolved = resolve(&ast).expect("resolve");
|
||||||
|
Emitter::with_root(root).plan(&resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(root: &Path, component: &str, key: &str) -> String {
|
||||||
|
fs::read_to_string(root.join(component).join("v1").join(key))
|
||||||
|
.unwrap_or_else(|e| panic!("reading {component}/v1/{key}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_land_on_the_cosmic_config_path_layout() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
let e = Emitter::with_root(tmp.path());
|
||||||
|
e.apply(&planned).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read(tmp.path(), "com.system76.CosmicComp", "autotile"),
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalars_render_as_ron_literals() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let src = "general {\n autotile = true\n edge_snap_threshold = 12\n}\n\
|
||||||
|
theme {\n icon_theme = Tela-circle-dracula\n}\n";
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let root = tmp.path();
|
||||||
|
assert_eq!(read(root, "com.system76.CosmicComp", "autotile"), "true");
|
||||||
|
assert_eq!(
|
||||||
|
read(root, "com.system76.CosmicComp", "edge_snap_threshold"),
|
||||||
|
"12"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(root, "com.system76.CosmicTk", "icon_theme"),
|
||||||
|
"\"Tela-circle-dracula\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end-to-end form of the folding property: both halves must reach disk
|
||||||
|
/// in one tuple.
|
||||||
|
#[test]
|
||||||
|
fn both_gaps_reach_disk_in_one_tuple() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned =
|
||||||
|
plan_for("general {\n gaps_in = 3\n gaps_out = 8\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
// (outer, inner) — theme.rs:895
|
||||||
|
for builder in [
|
||||||
|
"com.system76.CosmicTheme.Dark.Builder",
|
||||||
|
"com.system76.CosmicTheme.Light.Builder",
|
||||||
|
] {
|
||||||
|
assert_eq!(read(tmp.path(), builder, "gaps"), "(8, 3)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cosmic-config is sparse: an unset key has no file, so a partial
|
||||||
|
/// projection must fall back to the verified default rather than zero.
|
||||||
|
#[test]
|
||||||
|
fn partial_projection_uses_the_verified_default() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n gaps_in = 5\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
// Default is (0, 8); only inner was set, so outer stays 0.
|
||||||
|
assert_eq!(
|
||||||
|
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
|
||||||
|
"(0, 5)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write must preserve the half the user did not mention.
|
||||||
|
#[test]
|
||||||
|
fn partial_projection_preserves_existing_sibling() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp
|
||||||
|
.path()
|
||||||
|
.join("com.system76.CosmicTheme.Dark.Builder")
|
||||||
|
.join("v1");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("gaps"), "(20, 4)").unwrap();
|
||||||
|
|
||||||
|
let planned = plan_for("general {\n gaps_in = 7\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
// Outer 20 survives; inner becomes 7.
|
||||||
|
assert_eq!(
|
||||||
|
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
|
||||||
|
"(20, 7)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colors_render_with_the_right_component_count() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let src = "theme {\n accent = rgb(ff0000)\n bg_color = rgba(00ff0080)\n}\n";
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let b = "com.system76.CosmicTheme.Dark.Builder";
|
||||||
|
// Option<Srgb>: three components, no alpha.
|
||||||
|
assert_eq!(
|
||||||
|
read(tmp.path(), b, "accent"),
|
||||||
|
"Some((red: 1.0, green: 0.0, blue: 0.0))"
|
||||||
|
);
|
||||||
|
// Option<Srgba>: four.
|
||||||
|
let bg = read(tmp.path(), b, "bg_color");
|
||||||
|
assert!(
|
||||||
|
bg.starts_with("Some((red: 0.0, green: 1.0, blue: 0.0, alpha: "),
|
||||||
|
"{bg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounding_sets_all_four_corners_of_radius_m() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let text = read(
|
||||||
|
tmp.path(),
|
||||||
|
"com.system76.CosmicTheme.Dark.Builder",
|
||||||
|
"corner_radii",
|
||||||
|
);
|
||||||
|
let radii: CornerRadii = ron::from_str(&text).expect("round-trips as CornerRadii");
|
||||||
|
assert_eq!(radii.radius_m, [10.0; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounding_preserves_sibling_radii() {
|
||||||
|
// The other five radii must survive a read-modify-write untouched.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let text = read(
|
||||||
|
tmp.path(),
|
||||||
|
"com.system76.CosmicTheme.Dark.Builder",
|
||||||
|
"corner_radii",
|
||||||
|
);
|
||||||
|
let radii: CornerRadii = ron::from_str(&text).unwrap();
|
||||||
|
let d = CornerRadii::default();
|
||||||
|
assert_eq!(radii.radius_0, d.radius_0);
|
||||||
|
assert_eq!(radii.radius_xs, d.radius_xs);
|
||||||
|
assert_eq!(radii.radius_s, d.radius_s);
|
||||||
|
assert_eq!(radii.radius_l, d.radius_l);
|
||||||
|
assert_eq!(radii.radius_xl, d.radius_xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corner_radii_defaults_match_upstream() {
|
||||||
|
// Pinned against cosmic-theme/src/model/corner.rs:20-31. If upstream
|
||||||
|
// changes these, writing a sparse config would silently shift the theme.
|
||||||
|
let d = CornerRadii::default();
|
||||||
|
assert_eq!(d.radius_0, [0.0; 4]);
|
||||||
|
assert_eq!(d.radius_xs, [4.0; 4]);
|
||||||
|
assert_eq!(d.radius_s, [8.0; 4]);
|
||||||
|
assert_eq!(d.radius_m, [16.0; 4]);
|
||||||
|
assert_eq!(d.radius_l, [32.0; 4]);
|
||||||
|
assert_eq!(d.radius_xl, [160.0; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn genuinely_unmodelled_composite_is_still_refused() {
|
||||||
|
// A projected target with no shape handler must error rather than
|
||||||
|
// write a partial value.
|
||||||
|
let write = Write {
|
||||||
|
target: TargetKey {
|
||||||
|
component: "com.system76.Whatever".into(),
|
||||||
|
version: 1,
|
||||||
|
key: "palette".into(),
|
||||||
|
},
|
||||||
|
kind: WriteKind::Projected(BTreeMap::from([(
|
||||||
|
vec!["bright_red".to_string()],
|
||||||
|
Value::U32(1),
|
||||||
|
)])),
|
||||||
|
};
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let e = Emitter::with_root(tmp.path());
|
||||||
|
let res = e.plan(&Resolved {
|
||||||
|
writes: vec![write],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
res.unwrap_err()[0],
|
||||||
|
EmitError::UnsupportedComposite { .. }
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unparseable_existing_value_is_refused() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp
|
||||||
|
.path()
|
||||||
|
.join("com.system76.CosmicTheme.Dark.Builder")
|
||||||
|
.join("v1");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("gaps"), "not ron at all").unwrap();
|
||||||
|
|
||||||
|
let errs = plan_for("general {\n gaps_in = 3\n}\n", tmp.path()).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(errs[0], EmitError::Unreadable { .. }),
|
||||||
|
"{:?}",
|
||||||
|
errs[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Planning must not touch disk — that is what makes emission transactional.
|
||||||
|
#[test]
|
||||||
|
fn plan_does_not_write() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let _ = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
||||||
|
"plan must leave the tree untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_writes_are_skipped() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let src = "general {\n autotile = true\n}\n";
|
||||||
|
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 1);
|
||||||
|
|
||||||
|
// Second run sees identical contents and writes nothing.
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
assert!(planned[0].is_noop());
|
||||||
|
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previous_contents_are_captured_for_diffing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let first = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&first).unwrap();
|
||||||
|
|
||||||
|
let second = plan_for("general {\n autotile = false\n}\n", tmp.path()).unwrap();
|
||||||
|
assert_eq!(second[0].previous.as_deref(), Some("true"));
|
||||||
|
assert_eq!(second[0].contents, "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_leaves_no_temp_file() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let dir = tmp.path().join("com.system76.CosmicComp").join("v1");
|
||||||
|
let leftovers: Vec<_> = fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.filter(|n| n.starts_with(".atomicwrite"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
leftovers.is_empty(),
|
||||||
|
"temp files left behind: {leftovers:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_honours_xdg_config_home() {
|
||||||
|
// Uses the documented fallback chain rather than a hardcoded path.
|
||||||
|
let prev = std::env::var_os("XDG_CONFIG_HOME");
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-probe");
|
||||||
|
let e = Emitter::from_env().unwrap();
|
||||||
|
assert_eq!(e.root(), Path::new("/tmp/xdg-probe/cosmic"));
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||||
|
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
//! HyDE `hypr.theme` -> `cosmic.conf`.
|
||||||
|
//!
|
||||||
|
//! One-way, and into the conf file rather than straight into cosmic-config, so
|
||||||
|
//! the result is readable and editable before it touches the desktop.
|
||||||
|
//!
|
||||||
|
//! The guiding rule is that **nothing is dropped silently**. A HyDE theme
|
||||||
|
//! contains a good deal that COSMIC has no equivalent for — gradient borders,
|
||||||
|
//! blur tuning, layer rules — and a converter that quietly ignored them would
|
||||||
|
//! leave the user wondering why their desktop looks wrong. Every unhandled key
|
||||||
|
//! is reported with a reason.
|
||||||
|
|
||||||
|
use crate::parser::{parse, Item, ParseError, Span};
|
||||||
|
|
||||||
|
/// Why a source key did not make it into the output.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Reason {
|
||||||
|
/// COSMIC has no equivalent concept.
|
||||||
|
NoEquivalent(&'static str),
|
||||||
|
/// Needs a compositor patch that does not exist yet (spec Phase 2).
|
||||||
|
NeedsCompositorPatch(&'static str),
|
||||||
|
/// Belongs to another program entirely; copied verbatim, not translated.
|
||||||
|
DifferentProgram(&'static str),
|
||||||
|
/// Translated, but with a loss worth knowing about.
|
||||||
|
Lossy(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reason {
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Reason::NoEquivalent(d) => format!("no COSMIC equivalent: {d}"),
|
||||||
|
Reason::NeedsCompositorPatch(d) => format!("needs a cosmic-comp patch: {d}"),
|
||||||
|
Reason::DifferentProgram(d) => format!("handled by another program: {d}"),
|
||||||
|
Reason::Lossy(d) => format!("translated with loss: {d}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Note {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub reason: Reason,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Import {
|
||||||
|
/// Generated `cosmic.conf` text.
|
||||||
|
pub conf: String,
|
||||||
|
/// Everything that did not translate cleanly.
|
||||||
|
pub notes: Vec<Note>,
|
||||||
|
/// The theme's `$ICON_THEME`, if it names one.
|
||||||
|
///
|
||||||
|
/// Also present in `conf` as `theme.icon_theme`, but repeated here as a
|
||||||
|
/// field because `assets.rs` needs it to generate rofi's `local.rasi` and
|
||||||
|
/// re-parsing the text this function just rendered to get it back would be
|
||||||
|
/// absurd.
|
||||||
|
pub icon_theme: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Import {
|
||||||
|
/// Keys that produced no output at all, as opposed to lossy translations.
|
||||||
|
pub fn dropped(&self) -> impl Iterator<Item = &Note> {
|
||||||
|
self.notes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HyDE prefixes each `.theme` file with a destination line such as
|
||||||
|
/// `$HOME/.config/hypr/themes/theme.conf|> $HOME/.../colors.conf`.
|
||||||
|
///
|
||||||
|
/// It is metadata for HyDE's own installer, not config, and it has no `=`, so
|
||||||
|
/// the parser would reject the file outright. Strip it before parsing.
|
||||||
|
fn strip_hyde_header(src: &str) -> &str {
|
||||||
|
let mut lines = src.lines();
|
||||||
|
let Some(first) = lines.next() else {
|
||||||
|
return src;
|
||||||
|
};
|
||||||
|
let is_destination_header =
|
||||||
|
!first.contains('=') && (first.contains("|>") || first.contains('|'));
|
||||||
|
if is_destination_header {
|
||||||
|
// Preserve line numbering by keeping the newline count intact: callers
|
||||||
|
// report spans against the stripped text, so re-add a blank line.
|
||||||
|
match src.find('\n') {
|
||||||
|
Some(i) => &src[i + 1..],
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First colour of a possibly-gradient Hyprland border spec.
|
||||||
|
/// `rgba(ca9ee6ff) rgba(f2d5cfff) 45deg` -> `ca9ee6`.
|
||||||
|
fn first_color_rgb(value: &str) -> Option<String> {
|
||||||
|
let token = value.split_whitespace().next()?;
|
||||||
|
let inner = token
|
||||||
|
.strip_prefix("rgba(")
|
||||||
|
.or_else(|| token.strip_prefix("rgb("))?
|
||||||
|
.strip_suffix(')')?;
|
||||||
|
let hex = inner.trim_start_matches('#');
|
||||||
|
if hex.len() >= 6 && hex[..6].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
Some(hex[..6].to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_gradient(value: &str) -> bool {
|
||||||
|
value.split_whitespace().count() > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten to dotted keys, keeping variables separate — HyDE carries
|
||||||
|
/// `$GTK_THEME` / `$ICON_THEME` as variables rather than config keys.
|
||||||
|
fn walk(
|
||||||
|
items: &[Item],
|
||||||
|
prefix: &str,
|
||||||
|
out: &mut Vec<(String, String, Span)>,
|
||||||
|
vars: &mut Vec<(String, String, Span)>,
|
||||||
|
) {
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
Item::Section { name, items } => {
|
||||||
|
let next = if prefix.is_empty() {
|
||||||
|
name.value.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}.{}", name.value)
|
||||||
|
};
|
||||||
|
walk(items, &next, out, vars);
|
||||||
|
}
|
||||||
|
Item::Assign { key, value } => {
|
||||||
|
let dotted = if prefix.is_empty() {
|
||||||
|
key.value.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}.{}", key.value)
|
||||||
|
};
|
||||||
|
out.push((dotted, value.value.clone(), key.span));
|
||||||
|
}
|
||||||
|
Item::VarDef { name, value } => {
|
||||||
|
vars.push((name.value.clone(), value.value.clone(), name.span));
|
||||||
|
}
|
||||||
|
Item::Source { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a HyDE `hypr.theme` into a `cosmic.conf`.
|
||||||
|
pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result<Import, ParseError> {
|
||||||
|
let body = strip_hyde_header(src);
|
||||||
|
let ast = parse(body)?;
|
||||||
|
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
let mut vars = Vec::new();
|
||||||
|
walk(&ast.items, "", &mut keys, &mut vars);
|
||||||
|
|
||||||
|
let mut general: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut decoration: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut theme: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
let mut icon_theme = None;
|
||||||
|
|
||||||
|
for (name, value, span) in &vars {
|
||||||
|
match name.as_str() {
|
||||||
|
"ICON_THEME" => {
|
||||||
|
theme.push(("icon_theme".into(), value.clone()));
|
||||||
|
icon_theme = Some(value.clone());
|
||||||
|
}
|
||||||
|
"COLOR_SCHEME" => {
|
||||||
|
let mode = if value.contains("light") {
|
||||||
|
"light"
|
||||||
|
} else {
|
||||||
|
"dark"
|
||||||
|
};
|
||||||
|
theme.push(("mode".into(), mode.into()));
|
||||||
|
}
|
||||||
|
"GTK_THEME" => notes.push(Note {
|
||||||
|
key: format!("${name}"),
|
||||||
|
value: value.clone(),
|
||||||
|
reason: Reason::DifferentProgram(
|
||||||
|
"GTK theme applies to GTK apps directly; COSMIC apps use cosmic-theme",
|
||||||
|
),
|
||||||
|
span: *span,
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value, span) in &keys {
|
||||||
|
let note = |reason| Note {
|
||||||
|
key: key.clone(),
|
||||||
|
value: value.clone(),
|
||||||
|
reason,
|
||||||
|
span: *span,
|
||||||
|
};
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"general.gaps_in" => general.push(("gaps_in".into(), value.clone())),
|
||||||
|
"general.gaps_out" => general.push(("gaps_out".into(), value.clone())),
|
||||||
|
"decoration.rounding" => decoration.push(("rounding".into(), value.clone())),
|
||||||
|
|
||||||
|
// Border colour is the closest thing a HyDE theme has to an accent.
|
||||||
|
"general.col.active_border" => match first_color_rgb(value) {
|
||||||
|
Some(hex) => {
|
||||||
|
theme.push(("accent".into(), format!("rgb({hex})")));
|
||||||
|
if is_gradient(value) {
|
||||||
|
notes.push(note(Reason::Lossy(format!(
|
||||||
|
"used first stop rgb({hex}) as the accent; COSMIC's active_hint \
|
||||||
|
is a solid colour with no gradient or angle"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => notes.push(note(Reason::NoEquivalent("unrecognised colour syntax"))),
|
||||||
|
},
|
||||||
|
|
||||||
|
"general.col.inactive_border"
|
||||||
|
| "group.col.border_active"
|
||||||
|
| "group.col.border_inactive"
|
||||||
|
| "group.col.border_locked_active"
|
||||||
|
| "group.col.border_locked_inactive" => {
|
||||||
|
notes.push(note(Reason::NoEquivalent(
|
||||||
|
"COSMIC draws a single active hint; per-state border colours do not exist",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
"general.border_size" => notes.push(note(Reason::NoEquivalent(
|
||||||
|
"COSMIC's active_hint is a boolean, not a width",
|
||||||
|
))),
|
||||||
|
"general.layout" => notes.push(note(Reason::NoEquivalent(
|
||||||
|
"cosmic-comp uses a BSP tiler; dwindle/master are not available",
|
||||||
|
))),
|
||||||
|
"general.resize_on_border" => {
|
||||||
|
notes.push(note(Reason::NoEquivalent("no equivalent setting")))
|
||||||
|
}
|
||||||
|
|
||||||
|
k if k.starts_with("decoration.blur") => {
|
||||||
|
notes.push(note(Reason::NeedsCompositorPatch(
|
||||||
|
"COSMIC blur is client-requested via \
|
||||||
|
ext-background-effect; rule-driven blur is spec Phase 2",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
k if k.starts_with("decoration.shadow") => notes.push(note(
|
||||||
|
Reason::NeedsCompositorPatch("shadow.frag exists but is not configurable yet"),
|
||||||
|
)),
|
||||||
|
"decoration.active_opacity" | "decoration.inactive_opacity" => notes.push(note(
|
||||||
|
Reason::NeedsCompositorPatch("window opacity is not configurable yet"),
|
||||||
|
)),
|
||||||
|
|
||||||
|
"layerrule" => notes.push(note(Reason::DifferentProgram(
|
||||||
|
"layer rules target the bar; waybar is configured directly",
|
||||||
|
))),
|
||||||
|
"exec" => notes.push(note(Reason::DifferentProgram(
|
||||||
|
"HyDE runs gsettings here; icon and GTK themes are handled above",
|
||||||
|
))),
|
||||||
|
|
||||||
|
_ => notes.push(note(Reason::NoEquivalent("unrecognised key"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Import {
|
||||||
|
conf: render_conf(theme_name, &general, &decoration, &theme, ¬es),
|
||||||
|
notes,
|
||||||
|
icon_theme,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_conf(
|
||||||
|
theme_name: &str,
|
||||||
|
general: &[(String, String)],
|
||||||
|
decoration: &[(String, String)],
|
||||||
|
theme: &[(String, String)],
|
||||||
|
notes: &[Note],
|
||||||
|
) -> String {
|
||||||
|
let mut out = format!(
|
||||||
|
"# Generated by `hyprcosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
|
||||||
|
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
|
||||||
|
# are overwritten on the next `hyprcosmic-conf apply`.\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let dropped: Vec<&Note> = notes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
|
||||||
|
.collect();
|
||||||
|
if !dropped.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"#\n# {} setting(s) from the source theme were not translated.\n\
|
||||||
|
# Run with --report to see them.\n",
|
||||||
|
dropped.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let section = |name: &str, rows: &[(String, String)], out: &mut String| {
|
||||||
|
if rows.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push_str(&format!("\n{name} {{\n"));
|
||||||
|
let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||||
|
for (k, v) in rows {
|
||||||
|
out.push_str(&format!(" {k:<width$} = {v}\n"));
|
||||||
|
}
|
||||||
|
out.push_str("}\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
section("general", general, &mut out);
|
||||||
|
section("decoration", decoration, &mut out);
|
||||||
|
section("theme", theme, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable report of everything that did not translate cleanly.
|
||||||
|
pub fn render_report(import: &Import) -> String {
|
||||||
|
if import.notes.is_empty() {
|
||||||
|
return "Everything in the source theme translated cleanly.\n".into();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let lossy: Vec<&Note> = import
|
||||||
|
.notes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
|
||||||
|
.collect();
|
||||||
|
let dropped: Vec<&Note> = import.dropped().collect();
|
||||||
|
|
||||||
|
if !lossy.is_empty() {
|
||||||
|
out.push_str("Translated with loss:\n");
|
||||||
|
for n in &lossy {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} = {}\n {}\n",
|
||||||
|
n.key,
|
||||||
|
n.value,
|
||||||
|
n.reason.describe()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dropped.is_empty() {
|
||||||
|
if !lossy.is_empty() {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str("Not translated:\n");
|
||||||
|
for n in &dropped {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} = {}\n {}\n",
|
||||||
|
n.key,
|
||||||
|
n.value,
|
||||||
|
n.reason.describe()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The genuine Catppuccin-Mocha `hypr.theme` from HyDE-Project/hyde-themes,
|
||||||
|
/// verbatim including its destination header. Synthetic fixtures would miss
|
||||||
|
/// the header, the colon-keys and the gradient syntax.
|
||||||
|
const HYDE_CATPPUCCIN: &str = r#"$HOME/.config/hypr/themes/theme.conf|> $HOME/.config/hypr/themes/colors.conf
|
||||||
|
# // P̳r̳a̳s̳a̳n̳t̳h̳ R̳a̳n̳g̳a̳n̳
|
||||||
|
|
||||||
|
$GTK_THEME=Catppuccin-Mocha
|
||||||
|
$ICON_THEME = Tela-circle-dracula
|
||||||
|
$COLOR_SCHEME = prefer-dark
|
||||||
|
|
||||||
|
exec = gsettings set org.gnome.desktop.interface icon-theme $ICON_THEME
|
||||||
|
|
||||||
|
general {
|
||||||
|
gaps_in = 3
|
||||||
|
gaps_out = 8
|
||||||
|
border_size = 2
|
||||||
|
col.active_border = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
|
||||||
|
col.inactive_border = rgba(b4befecc) rgba(6c7086cc) 45deg
|
||||||
|
layout = dwindle
|
||||||
|
resize_on_border = true
|
||||||
|
}
|
||||||
|
|
||||||
|
group {
|
||||||
|
col.border_active = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
|
||||||
|
col.border_inactive = rgba(b4befecc) rgba(6c7086cc) 45deg
|
||||||
|
}
|
||||||
|
|
||||||
|
decoration {
|
||||||
|
rounding = 10
|
||||||
|
shadow:enabled = false
|
||||||
|
|
||||||
|
blur {
|
||||||
|
enabled = yes
|
||||||
|
size = 6
|
||||||
|
passes = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerrule = blur,waybar
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn import() -> Import {
|
||||||
|
import_hypr_theme(HYDE_CATPPUCCIN, "Catppuccin Mocha").expect("real HyDE theme must parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_hyde_theme_parses_despite_its_destination_header() {
|
||||||
|
// The header has no `=` and would otherwise be a parse error.
|
||||||
|
let _ = import();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geometry_is_translated() {
|
||||||
|
let c = import().conf;
|
||||||
|
assert!(c.contains("gaps_in = 3"), "{c}");
|
||||||
|
assert!(c.contains("gaps_out = 8"), "{c}");
|
||||||
|
assert!(c.contains("rounding = 10"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accent_comes_from_the_first_gradient_stop() {
|
||||||
|
let c = import().conf;
|
||||||
|
assert!(c.contains("accent"), "{c}");
|
||||||
|
assert!(c.contains("rgb(ca9ee6)"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gradient_loss_is_reported_not_silent() {
|
||||||
|
let i = import();
|
||||||
|
let lossy: Vec<_> = i
|
||||||
|
.notes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(lossy.len(), 1, "{:?}", i.notes);
|
||||||
|
assert_eq!(lossy[0].key, "general.col.active_border");
|
||||||
|
assert!(lossy[0].reason.describe().contains("gradient"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_theme_and_color_scheme_come_from_variables() {
|
||||||
|
let c = import().conf;
|
||||||
|
assert!(c.contains("icon_theme = Tela-circle-dracula"), "{c}");
|
||||||
|
assert!(c.contains("mode"), "{c}");
|
||||||
|
assert!(c.contains("dark"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_is_flagged_as_needing_a_compositor_patch() {
|
||||||
|
let i = import();
|
||||||
|
let blur: Vec<_> = i
|
||||||
|
.notes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| n.key.starts_with("decoration.blur"))
|
||||||
|
.collect();
|
||||||
|
assert!(!blur.is_empty(), "blur settings must be reported");
|
||||||
|
assert!(blur
|
||||||
|
.iter()
|
||||||
|
.all(|n| matches!(n.reason, Reason::NeedsCompositorPatch(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_concepts_are_each_reported() {
|
||||||
|
let i = import();
|
||||||
|
let keys: Vec<&str> = i.dropped().map(|n| n.key.as_str()).collect();
|
||||||
|
for expected in [
|
||||||
|
"general.border_size",
|
||||||
|
"general.layout",
|
||||||
|
"general.col.inactive_border",
|
||||||
|
"group.col.border_active",
|
||||||
|
"layerrule",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
keys.contains(&expected),
|
||||||
|
"`{expected}` missing from {keys:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_is_dropped_without_a_note() {
|
||||||
|
// Every source key must either appear in the output or carry a note.
|
||||||
|
let i = import();
|
||||||
|
let translated = ["general.gaps_in", "general.gaps_out", "decoration.rounding"];
|
||||||
|
let noted: Vec<&str> = i.notes.iter().map(|n| n.key.as_str()).collect();
|
||||||
|
|
||||||
|
let body = strip_hyde_header(HYDE_CATPPUCCIN);
|
||||||
|
let ast = parse(body).unwrap();
|
||||||
|
let (mut keys, mut vars) = (Vec::new(), Vec::new());
|
||||||
|
walk(&ast.items, "", &mut keys, &mut vars);
|
||||||
|
|
||||||
|
for (k, _, _) in &keys {
|
||||||
|
assert!(
|
||||||
|
translated.contains(&k.as_str()) || noted.contains(&k.as_str()),
|
||||||
|
"`{k}` was neither translated nor reported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_conf_is_valid_input_to_our_own_parser() {
|
||||||
|
// The importer must not emit something `apply` cannot read.
|
||||||
|
let c = import().conf;
|
||||||
|
parse(&c).expect("generated cosmic.conf must parse");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_conf_resolves_against_the_registry() {
|
||||||
|
// Stronger: every key it emits must actually exist in the schema.
|
||||||
|
let c = import().conf;
|
||||||
|
let ast = parse(&c).unwrap();
|
||||||
|
crate::resolve(&ast).expect("generated conf must resolve cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_separates_lossy_from_dropped() {
|
||||||
|
let r = render_report(&import());
|
||||||
|
assert!(r.contains("Translated with loss:"), "{r}");
|
||||||
|
assert!(r.contains("Not translated:"), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_without_pipe_is_not_stripped() {
|
||||||
|
let src = "general {\n gaps_in = 4\n}\n";
|
||||||
|
let i = import_hypr_theme(src, "t").unwrap();
|
||||||
|
// Single key, so no alignment padding.
|
||||||
|
assert!(i.conf.contains("gaps_in = 4"), "{}", i.conf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Compiles a single Hyprland-idiom config file into the cosmic-config tree.
|
||||||
|
//!
|
||||||
|
//! The pipeline is `parser -> schema -> resolve -> emit`, and it is transactional:
|
||||||
|
//! `resolve` validates everything before `emit` writes anything, so a malformed
|
||||||
|
//! file leaves the desktop untouched rather than half-applied.
|
||||||
|
//!
|
||||||
|
//! `parser`, `schema` and `resolve` are pure and depend on nothing
|
||||||
|
//! COSMIC-specific, which keeps the hard logic testable without a compositor
|
||||||
|
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
|
pub mod bind;
|
||||||
|
pub mod emit;
|
||||||
|
pub mod import;
|
||||||
|
pub mod parser;
|
||||||
|
pub mod resolve;
|
||||||
|
pub mod schema;
|
||||||
|
pub mod watch;
|
||||||
|
pub mod windowrule;
|
||||||
|
pub mod workspace;
|
||||||
|
|
||||||
|
pub use bind::{parse_bind, Bind};
|
||||||
|
pub use emit::{EmitError, Emitter, Planned};
|
||||||
|
pub use import::{import_hypr_theme, render_report, Import};
|
||||||
|
pub use parser::{parse, Ast, ParseError, Span};
|
||||||
|
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
|
||||||
|
pub use windowrule::{parse_window_rule, WindowRuleDecl};
|
||||||
|
pub use workspace::{parse_workspace, WorkspaceDecl};
|
||||||
|
|
||||||
|
/// Render a diagnostic against source text, cargo-style.
|
||||||
|
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
|
||||||
|
let line = source
|
||||||
|
.lines()
|
||||||
|
.nth(span.line.saturating_sub(1))
|
||||||
|
.unwrap_or("");
|
||||||
|
let gutter = span.line.to_string().len();
|
||||||
|
let pad = " ".repeat(gutter);
|
||||||
|
let caret = " ".repeat(span.col.saturating_sub(1)) + &"^".repeat(span.len.max(1));
|
||||||
|
|
||||||
|
let mut out = format!(
|
||||||
|
"error: {message}\n\
|
||||||
|
{pad}--> cosmic.conf:{}:{}\n\
|
||||||
|
{pad} |\n\
|
||||||
|
{} | {line}\n\
|
||||||
|
{pad} | {caret}",
|
||||||
|
span.line, span.col, span.line
|
||||||
|
);
|
||||||
|
if let Some(help) = help {
|
||||||
|
out.push_str(&format!(" {help}"));
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_to_end_valid_config_produces_writes() {
|
||||||
|
let src = "\
|
||||||
|
$accent = rgb(6b9fed)
|
||||||
|
$gap = 4
|
||||||
|
|
||||||
|
general {
|
||||||
|
gaps_in = $gap
|
||||||
|
gaps_out = $gap * 2
|
||||||
|
autotile = true
|
||||||
|
}
|
||||||
|
|
||||||
|
theme {
|
||||||
|
accent = $accent
|
||||||
|
}
|
||||||
|
";
|
||||||
|
let ast = parse(src).expect("parse");
|
||||||
|
let r = resolve(&ast).expect("resolve");
|
||||||
|
assert!(!r.writes.is_empty());
|
||||||
|
|
||||||
|
// gaps fold per builder; accent fans out to both; autotile is direct.
|
||||||
|
let gaps: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
|
||||||
|
assert_eq!(gaps.len(), 2);
|
||||||
|
let accent: Vec<_> = r
|
||||||
|
.writes
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.target.key == "accent")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(accent.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagnostic_rendering_points_at_the_offending_token() {
|
||||||
|
let src = "general {\n gaps_inn = 8\n}\n";
|
||||||
|
let ast = parse(src).unwrap();
|
||||||
|
let diags = resolve(&ast).unwrap_err();
|
||||||
|
let out = render_diagnostic(
|
||||||
|
src,
|
||||||
|
diags[0].span,
|
||||||
|
&diags[0].message,
|
||||||
|
diags[0].help.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(out.contains("unknown key"), "{out}");
|
||||||
|
assert!(out.contains("cosmic.conf:2:5"), "{out}");
|
||||||
|
assert!(out.contains("^^^^^^^^"), "{out}");
|
||||||
|
assert!(out.contains("did you mean"), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
//! `cosmic-conf` — compile a Hyprland-idiom config file into cosmic-config.
|
||||||
|
//!
|
||||||
|
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
|
||||||
|
|
||||||
|
// Names the installed binary, hyprcosmic-conf, not the crate. The package
|
||||||
|
// installs beside COSMIC and keeps every path it owns under a hyprcosmic-*
|
||||||
|
// name, so a usage line saying `cosmic-conf` would name something that is not
|
||||||
|
// on the system.
|
||||||
|
const USAGE: &str = "\
|
||||||
|
hyprcosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
hyprcosmic-conf apply [--diff] [--config <path>]
|
||||||
|
hyprcosmic-conf watch [--config <path>]
|
||||||
|
hyprcosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
|
||||||
|
[--assets [--source <dir>] [--overwrite] [--dry-run]]
|
||||||
|
|
||||||
|
COMMANDS:
|
||||||
|
apply Compile the config once and exit
|
||||||
|
watch Stay running and recompile on every edit, to the config
|
||||||
|
and to anything it sources. A malformed edit is reported
|
||||||
|
and waited past, not fatal.
|
||||||
|
import-theme Translate a HyDE theme into config keys
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--diff Show what would change without writing anything
|
||||||
|
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
|
||||||
|
--out <path> Write the generated cosmic.conf here (default: stdout)
|
||||||
|
--report Print everything that did not translate cleanly
|
||||||
|
--assets Also install wallpapers, GTK/icon themes and the
|
||||||
|
waybar/rofi/kitty theme files that sit beside hypr.theme
|
||||||
|
--source <dir> The theme repo's Source/ directory holding the GTK and
|
||||||
|
icon tarballs (default: found by searching upward)
|
||||||
|
--overwrite Replace assets that are already installed
|
||||||
|
--dry-run With --assets, list what would be installed and stop
|
||||||
|
-h, --help Show this help
|
||||||
|
";
|
||||||
|
|
||||||
|
/// HyDE keeps GTK and icon tarballs in a `Source/` directory at the root of
|
||||||
|
/// the theme repo, four levels above the theme folder
|
||||||
|
/// (`Configs/.config/hyde/themes/<Name>/`). Searching upward rather than
|
||||||
|
/// hardcoding that depth means a theme unpacked at a different depth, or one
|
||||||
|
/// vendored into another tree, still works.
|
||||||
|
fn find_source_dir(theme_dir: &std::path::Path) -> Option<PathBuf> {
|
||||||
|
theme_dir
|
||||||
|
.ancestors()
|
||||||
|
.take(6)
|
||||||
|
.map(|a| a.join("Source"))
|
||||||
|
.find(|c| c.is_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse arguments the caller does not understand.
|
||||||
|
///
|
||||||
|
/// `apply` writes to the config tree, so an argument it does not recognise has
|
||||||
|
/// to stop it rather than be skipped: `--diff-only` instead of `--diff`, or a
|
||||||
|
/// config path given positionally, would otherwise apply to the *default*
|
||||||
|
/// config while looking like it had done what was asked. This is a
|
||||||
|
/// hand-rolled check rather than a dependency because the whole surface is six
|
||||||
|
/// flags, and an argument parser that silently ignores the unknown is exactly
|
||||||
|
/// the behaviour being removed.
|
||||||
|
///
|
||||||
|
/// `flags` take no value; `valued` consume the argument after them.
|
||||||
|
fn reject_unknown(args: &[String], flags: &[&str], valued: &[&str]) -> Result<(), String> {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
let a = &args[i];
|
||||||
|
if valued.contains(&a.as_str()) {
|
||||||
|
i += 2;
|
||||||
|
} else if flags.contains(&a.as_str()) || a == "-h" || a == "--help" {
|
||||||
|
// `--help` is accepted by every subcommand, so callers do not have
|
||||||
|
// to list it; `main` has already acted on it by this point.
|
||||||
|
i += 1;
|
||||||
|
} else if let Some(name) = a.strip_prefix("--") {
|
||||||
|
return Err(format!("error: unknown option `--{name}`"));
|
||||||
|
} else {
|
||||||
|
return Err(format!("error: unexpected argument `{a}`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_config_path() -> Option<PathBuf> {
|
||||||
|
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||||
|
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
|
||||||
|
};
|
||||||
|
Some(base.join("hyprcosmic").join("cosmic.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
if args[0] == "import-theme" {
|
||||||
|
return match run_import(&args[1..]) {
|
||||||
|
Ok(msg) => {
|
||||||
|
print!("{msg}");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
eprint!("{msg}");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let command = args[0].as_str();
|
||||||
|
if !matches!(command, "apply" | "watch") {
|
||||||
|
eprintln!("error: unknown command `{command}`\n\n{USAGE}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--diff` belongs to `apply` alone: a daemon whose whole job is to notice
|
||||||
|
// a change and write it has nothing to do with a mode that declines to
|
||||||
|
// write. Passing it to `watch` is an error rather than a no-op, for the
|
||||||
|
// same reason `--diff-only` is.
|
||||||
|
let flags: &[&str] = if command == "apply" { &["--diff"] } else { &[] };
|
||||||
|
if let Err(msg) = reject_unknown(&args[1..], flags, &["--config"]) {
|
||||||
|
eprintln!("{msg}\n\n{USAGE}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff_only = args.iter().any(|a| a == "--diff");
|
||||||
|
let config_path = match args.iter().position(|a| a == "--config") {
|
||||||
|
Some(i) => match args.get(i + 1) {
|
||||||
|
Some(p) => PathBuf::from(p),
|
||||||
|
None => {
|
||||||
|
eprintln!("error: --config needs a path");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => match default_config_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
eprintln!("error: cannot determine config path (no HOME or XDG_CONFIG_HOME)");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if command == "watch" {
|
||||||
|
return match run_watch(&config_path) {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(msg) => {
|
||||||
|
eprint!("{msg}");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match run(&config_path, diff_only) {
|
||||||
|
Ok(msg) => {
|
||||||
|
println!("{msg}");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
eprint!("{msg}");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(config_path: &Path, diff_only: bool) -> Result<String, String> {
|
||||||
|
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
|
||||||
|
// Through `watch::compile` rather than parse/resolve/plan inline, because
|
||||||
|
// that is the only path that expands `source`. Doing it by hand here meant
|
||||||
|
// `resolve` never saw the included text -- `flatten` drops `Item::Source`
|
||||||
|
// -- so a sourced file was silently ignored by `apply` while `watch`
|
||||||
|
// honoured it. An include that works in one and vanishes in the other is
|
||||||
|
// worse than one that is unsupported in both.
|
||||||
|
let compiled = watch::compile(config_path, &emitter).map_err(|e| e.to_string())?;
|
||||||
|
let planned = compiled.planned;
|
||||||
|
|
||||||
|
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
|
||||||
|
|
||||||
|
if diff_only {
|
||||||
|
if changes.is_empty() {
|
||||||
|
return Ok("No changes.".into());
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
for p in &changes {
|
||||||
|
let rel = p
|
||||||
|
.path
|
||||||
|
.strip_prefix(emitter.root())
|
||||||
|
.unwrap_or(&p.path)
|
||||||
|
.display();
|
||||||
|
out.push_str(&format!("~ {rel}\n"));
|
||||||
|
match &p.previous {
|
||||||
|
Some(prev) => out.push_str(&format!(" - {}\n", prev.trim())),
|
||||||
|
None => out.push_str(" - (unset)\n"),
|
||||||
|
}
|
||||||
|
out.push_str(&format!(" + {}\n", p.contents.trim()));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("\n{} file(s) would change.", changes.len()));
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = emitter
|
||||||
|
.apply(&planned)
|
||||||
|
.map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
Ok(format!(
|
||||||
|
"Applied {written} change(s) to {}.",
|
||||||
|
emitter.root().display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block, recompiling on every edit, until the watcher itself stops.
|
||||||
|
///
|
||||||
|
/// Returns nothing to print on success because there is no success to report
|
||||||
|
/// until it is over: progress goes to stderr as it happens, from inside the
|
||||||
|
/// loop. A config error is not an error here either -- `watch` reports a
|
||||||
|
/// malformed edit and waits for the next one, which is the whole point of
|
||||||
|
/// leaving it running -- so the only failure that reaches this function is the
|
||||||
|
/// notify machinery failing to start.
|
||||||
|
fn run_watch(config_path: &Path) -> Result<(), String> {
|
||||||
|
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
watch::watch(config_path, &emitter).map_err(|e| format!("{e}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_import(args: &[String]) -> Result<String, String> {
|
||||||
|
let Some(src_path) = args.first().filter(|a| !a.starts_with("--")) else {
|
||||||
|
return Err(format!("error: import-theme needs a path\n\n{USAGE}"));
|
||||||
|
};
|
||||||
|
reject_unknown(
|
||||||
|
&args[1..],
|
||||||
|
&["--report", "--assets", "--overwrite", "--dry-run"],
|
||||||
|
&["--out", "--source"],
|
||||||
|
)
|
||||||
|
.map_err(|msg| format!("{msg}\n\n{USAGE}"))?;
|
||||||
|
let out_path = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--out")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.map(PathBuf::from);
|
||||||
|
let want_report = args.iter().any(|a| a == "--report");
|
||||||
|
|
||||||
|
let source = std::fs::read_to_string(src_path)
|
||||||
|
.map_err(|e| format!("error: cannot read {src_path}: {e}\n"))?;
|
||||||
|
|
||||||
|
// HyDE names a theme by its containing directory.
|
||||||
|
let name = PathBuf::from(src_path)
|
||||||
|
.parent()
|
||||||
|
.and_then(|p| p.file_name())
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "imported".into());
|
||||||
|
|
||||||
|
let imported = import::import_hypr_theme(&source, &name)
|
||||||
|
.map_err(|e| render_diagnostic(&source, e.span, &e.message, None))?;
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
match out_path {
|
||||||
|
Some(p) => {
|
||||||
|
if let Some(dir) = p.parent() {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| format!("error: cannot create {}: {e}\n", dir.display()))?;
|
||||||
|
}
|
||||||
|
std::fs::write(&p, &imported.conf)
|
||||||
|
.map_err(|e| format!("error: cannot write {}: {e}\n", p.display()))?;
|
||||||
|
out.push_str(&format!("Wrote {}\n", p.display()));
|
||||||
|
if let Some(hint) = unsourced_hint(&p) {
|
||||||
|
out.push_str(&hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => out.push_str(&imported.conf),
|
||||||
|
}
|
||||||
|
|
||||||
|
let dropped = imported.dropped().count();
|
||||||
|
if want_report {
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&import::render_report(&imported));
|
||||||
|
} else if dropped > 0 {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\n{dropped} setting(s) did not translate. Re-run with --report for details.\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.iter().any(|a| a == "--assets") {
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&install_assets(
|
||||||
|
src_path,
|
||||||
|
&name,
|
||||||
|
imported.icon_theme.as_deref(),
|
||||||
|
args,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warn when the file just written is not reachable from its sibling
|
||||||
|
/// cosmic.conf, and say what to add.
|
||||||
|
///
|
||||||
|
/// A theme lives in its own file so that re-importing cannot clobber the
|
||||||
|
/// keybindings around it, but that only works if something sources it.
|
||||||
|
/// Writing an inert file and reporting success is the worst of both: the tool
|
||||||
|
/// looks like it worked and the desktop does not change.
|
||||||
|
///
|
||||||
|
/// The match is textual and deliberately loose -- it is looking for evidence
|
||||||
|
/// that the user already knows about the file, not parsing the config. A false
|
||||||
|
/// negative costs one redundant hint; a false positive would hide a real
|
||||||
|
/// problem, so the substring searched for is the filename itself.
|
||||||
|
fn unsourced_hint(written: &Path) -> Option<String> {
|
||||||
|
let dir = written.parent()?;
|
||||||
|
let name = written.file_name()?.to_string_lossy().to_string();
|
||||||
|
let main = dir.join("cosmic.conf");
|
||||||
|
|
||||||
|
// Nothing to say when the theme *is* the config, or there is no config yet
|
||||||
|
// to add a line to: `apply` will be pointed at this file directly.
|
||||||
|
if main == written || !main.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(&main).ok()?;
|
||||||
|
if text
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.trim_start().starts_with("source") && l.contains(&name))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(format!(
|
||||||
|
"\nNothing sources it yet, so `apply` will ignore it. Add this to {}:\n\n source = {}\n",
|
||||||
|
main.display(),
|
||||||
|
written.display(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, the
|
||||||
|
/// `.theme` files belonging to waybar, rofi and kitty, and the small rofi
|
||||||
|
/// files HyprCosmic has to compose itself.
|
||||||
|
///
|
||||||
|
/// Separate from the conf translation because it is separate in kind — almost
|
||||||
|
/// none of it is translated, only placed — and because it writes outside the
|
||||||
|
/// cosmic-config tree, which every other path in this tool does not.
|
||||||
|
///
|
||||||
|
/// `icon_theme` comes back out of the conf translation rather than being read
|
||||||
|
/// from the theme directory again, because that is where the `$ICON_THEME`
|
||||||
|
/// variable was already resolved.
|
||||||
|
fn install_assets(
|
||||||
|
src_path: &str,
|
||||||
|
name: &str,
|
||||||
|
icon_theme: Option<&str>,
|
||||||
|
args: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let theme_dir = PathBuf::from(src_path)
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.ok_or_else(|| format!("error: {src_path} has no parent directory\n"))?;
|
||||||
|
|
||||||
|
let source_dir = match args.iter().position(|a| a == "--source") {
|
||||||
|
Some(i) => match args.get(i + 1) {
|
||||||
|
Some(p) => Some(PathBuf::from(p)),
|
||||||
|
None => return Err(format!("error: --source needs a path\n\n{USAGE}")),
|
||||||
|
},
|
||||||
|
None => find_source_dir(&theme_dir),
|
||||||
|
};
|
||||||
|
|
||||||
|
let installer = assets::Installer::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
let plan = installer
|
||||||
|
.plan(
|
||||||
|
&theme_dir,
|
||||||
|
source_dir.as_deref(),
|
||||||
|
name,
|
||||||
|
icon_theme,
|
||||||
|
args.iter().any(|a| a == "--overwrite"),
|
||||||
|
)
|
||||||
|
.map_err(|errors| {
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("error: {e}\n"))
|
||||||
|
.collect::<String>()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if args.iter().any(|a| a == "--dry-run") {
|
||||||
|
return Ok(assets::render_plan(&plan));
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = installer
|
||||||
|
.apply(&plan)
|
||||||
|
.map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
Ok(assets::render_report(&plan, &report))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn args(s: &[&str]) -> Vec<String> {
|
||||||
|
s.iter().map(|a| a.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_flags_and_their_values_are_accepted() {
|
||||||
|
let a = args(&["--diff", "--config", "/etc/cosmic.conf"]);
|
||||||
|
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_misspelt_flag_is_refused_rather_than_skipped() {
|
||||||
|
// The bug this exists to prevent: `--diff-only` used to be ignored, so
|
||||||
|
// `apply` wrote for real while the caller believed it was a dry run.
|
||||||
|
let a = args(&["--diff-only"]);
|
||||||
|
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
|
||||||
|
assert!(err.contains("--diff-only"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_positional_path_is_refused_because_it_would_be_ignored() {
|
||||||
|
let a = args(&["/home/me/.config/hyprcosmic/cosmic.conf"]);
|
||||||
|
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
|
||||||
|
assert!(err.contains("unexpected argument"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_value_that_looks_like_a_flag_is_still_a_value() {
|
||||||
|
// `--config --diff` is a user error, but it is the *next* argument's
|
||||||
|
// job to be a path; consuming it here keeps the rule simple and
|
||||||
|
// matches what the position-based lookup below actually does.
|
||||||
|
let a = args(&["--config", "--diff"]);
|
||||||
|
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_trailing_valued_flag_with_no_value_is_not_a_panic() {
|
||||||
|
let a = args(&["--config"]);
|
||||||
|
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
//! `cosmic.conf` text -> AST.
|
||||||
|
//!
|
||||||
|
//! Hyprland-idiom, line-based grammar:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! # comment
|
||||||
|
//! $var = value
|
||||||
|
//! section {
|
||||||
|
//! key = value
|
||||||
|
//! nested { key = value }
|
||||||
|
//! }
|
||||||
|
//! bind = SUPER, Q, close # repeatable keys are kept in order
|
||||||
|
//! source = ~/other.conf
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Values are kept as raw strings here; typing happens in `resolve`, which needs
|
||||||
|
//! the schema to know what a value should be.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Byte-independent source position. Line and column are 1-based so they match
|
||||||
|
/// what an editor shows.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Span {
|
||||||
|
pub line: usize,
|
||||||
|
pub col: usize,
|
||||||
|
pub len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Span {
|
||||||
|
pub fn new(line: usize, col: usize, len: usize) -> Self {
|
||||||
|
Self { line, col, len }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Spanned<T> {
|
||||||
|
pub value: T,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Spanned<T> {
|
||||||
|
pub fn new(value: T, span: Span) -> Self {
|
||||||
|
Self { value, span }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Item {
|
||||||
|
/// `$name = value`
|
||||||
|
VarDef {
|
||||||
|
name: Spanned<String>,
|
||||||
|
value: Spanned<String>,
|
||||||
|
},
|
||||||
|
/// `key = value` inside the current section
|
||||||
|
Assign {
|
||||||
|
key: Spanned<String>,
|
||||||
|
value: Spanned<String>,
|
||||||
|
},
|
||||||
|
/// `name { .. }`
|
||||||
|
Section {
|
||||||
|
name: Spanned<String>,
|
||||||
|
items: Vec<Item>,
|
||||||
|
},
|
||||||
|
/// `source = path`
|
||||||
|
Source { path: Spanned<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Ast {
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParseError {
|
||||||
|
pub message: String,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ParseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}:{}: {}", self.span.line, self.span.col, self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ParseError {}
|
||||||
|
|
||||||
|
/// Strip a trailing `#` comment, respecting nothing else — the grammar has no
|
||||||
|
/// string literals, so there is no quoting to honour.
|
||||||
|
fn strip_comment(line: &str) -> &str {
|
||||||
|
match line.find('#') {
|
||||||
|
Some(i) => &line[..i],
|
||||||
|
None => line,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column (1-based) of the first non-whitespace byte.
|
||||||
|
fn indent_col(line: &str) -> usize {
|
||||||
|
line.len() - line.trim_start().len() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(input: &str) -> Result<Ast, ParseError> {
|
||||||
|
let mut cursor = Cursor {
|
||||||
|
lines: input.lines().collect(),
|
||||||
|
idx: 0,
|
||||||
|
};
|
||||||
|
let items = parse_items(&mut cursor, 0)?;
|
||||||
|
Ok(Ast { items })
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cursor<'a> {
|
||||||
|
lines: Vec<&'a str>,
|
||||||
|
idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse items until EOF (`depth == 0`) or a closing brace.
|
||||||
|
fn parse_items(cur: &mut Cursor, depth: usize) -> Result<Vec<Item>, ParseError> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
|
||||||
|
while cur.idx < cur.lines.len() {
|
||||||
|
let raw = cur.lines[cur.idx];
|
||||||
|
let line_no = cur.idx + 1;
|
||||||
|
let content = strip_comment(raw).trim_end();
|
||||||
|
let trimmed = content.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
cur.idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed == "}" {
|
||||||
|
if depth == 0 {
|
||||||
|
return Err(ParseError {
|
||||||
|
message: "unmatched `}`".into(),
|
||||||
|
span: Span::new(line_no, indent_col(content), 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cur.idx += 1;
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `name {` opens a section. A one-line `name { .. }` is not supported;
|
||||||
|
// keeping the grammar strictly line-based keeps spans honest.
|
||||||
|
if let Some(name) = trimmed.strip_suffix('{') {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ParseError {
|
||||||
|
message: "section is missing a name".into(),
|
||||||
|
span: Span::new(line_no, indent_col(content), 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let span = Span::new(line_no, indent_col(content), name.len());
|
||||||
|
cur.idx += 1;
|
||||||
|
let inner = parse_items(cur, depth + 1)?;
|
||||||
|
items.push(Item::Section {
|
||||||
|
name: Spanned::new(name.to_string(), span),
|
||||||
|
items: inner,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(eq) = content.find('=') else {
|
||||||
|
return Err(ParseError {
|
||||||
|
message: format!("expected `key = value`, found `{trimmed}`"),
|
||||||
|
span: Span::new(line_no, indent_col(content), trimmed.len()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_raw = &content[..eq];
|
||||||
|
let val_raw = &content[eq + 1..];
|
||||||
|
let key = key_raw.trim();
|
||||||
|
let value = val_raw.trim();
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(ParseError {
|
||||||
|
message: "assignment is missing a key".into(),
|
||||||
|
span: Span::new(line_no, 1, eq.max(1)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_col = indent_col(content);
|
||||||
|
let key_span = Span::new(line_no, key_col, key.len());
|
||||||
|
// Column of the value = everything before it, plus its own leading trim.
|
||||||
|
let val_col = eq + 2 + (val_raw.len() - val_raw.trim_start().len());
|
||||||
|
let val_span = Span::new(line_no, val_col, value.len());
|
||||||
|
|
||||||
|
let item = if let Some(var) = key.strip_prefix('$') {
|
||||||
|
if var.is_empty() {
|
||||||
|
return Err(ParseError {
|
||||||
|
message: "variable is missing a name after `$`".into(),
|
||||||
|
span: key_span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Item::VarDef {
|
||||||
|
name: Spanned::new(var.to_string(), key_span),
|
||||||
|
value: Spanned::new(value.to_string(), val_span),
|
||||||
|
}
|
||||||
|
} else if key == "source" {
|
||||||
|
Item::Source {
|
||||||
|
path: Spanned::new(value.to_string(), val_span),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Item::Assign {
|
||||||
|
key: Spanned::new(key.to_string(), key_span),
|
||||||
|
value: Spanned::new(value.to_string(), val_span),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
cur.idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if depth != 0 {
|
||||||
|
let last = cur.lines.len().max(1);
|
||||||
|
return Err(ParseError {
|
||||||
|
message: "unclosed section: expected `}`".into(),
|
||||||
|
span: Span::new(last, 1, 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assign(items: &[Item], key: &str) -> String {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.find_map(|i| match i {
|
||||||
|
Item::Assign { key: k, value } if k.value == key => Some(value.value.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| panic!("no assignment named `{key}`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section<'a>(items: &'a [Item], name: &str) -> &'a [Item] {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.find_map(|i| match i {
|
||||||
|
Item::Section { name: n, items } if n.value == name => Some(items.as_slice()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| panic!("no section named `{name}`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_flat_assignments() {
|
||||||
|
let ast = parse("autotile = true\nrounding = 10\n").unwrap();
|
||||||
|
assert_eq!(assign(&ast.items, "autotile"), "true");
|
||||||
|
assert_eq!(assign(&ast.items, "rounding"), "10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_variables() {
|
||||||
|
let ast = parse("$accent = rgb(6b9fed)\n").unwrap();
|
||||||
|
match &ast.items[0] {
|
||||||
|
Item::VarDef { name, value } => {
|
||||||
|
assert_eq!(name.value, "accent");
|
||||||
|
assert_eq!(value.value, "rgb(6b9fed)");
|
||||||
|
}
|
||||||
|
other => panic!("expected VarDef, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_nested_sections() {
|
||||||
|
let src = "decoration {\n rounding = 10\n blur {\n size = 6\n }\n}\n";
|
||||||
|
let ast = parse(src).unwrap();
|
||||||
|
let deco = section(&ast.items, "decoration");
|
||||||
|
assert_eq!(assign(deco, "rounding"), "10");
|
||||||
|
assert_eq!(assign(section(deco, "blur"), "size"), "6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_comments_but_keeps_values() {
|
||||||
|
let ast = parse("gaps_in = 3 # inner gap\n# whole line\n").unwrap();
|
||||||
|
assert_eq!(assign(&ast.items, "gaps_in"), "3");
|
||||||
|
assert_eq!(ast.items.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_is_its_own_item() {
|
||||||
|
let ast = parse("source = ~/.config/hyprcosmic/monitors.conf\n").unwrap();
|
||||||
|
match &ast.items[0] {
|
||||||
|
Item::Source { path } => assert_eq!(path.value, "~/.config/hyprcosmic/monitors.conf"),
|
||||||
|
other => panic!("expected Source, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeatable_keys_are_preserved_in_order() {
|
||||||
|
let ast = parse("bind = SUPER, Return, spawn, kitty\nbind = SUPER, Q, close\n").unwrap();
|
||||||
|
let binds: Vec<_> = ast
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|i| match i {
|
||||||
|
Item::Assign { key, value } if key.value == "bind" => Some(value.value.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
binds,
|
||||||
|
vec!["SUPER, Return, spawn, kitty", "SUPER, Q, close"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spans_point_at_the_key() {
|
||||||
|
let ast = parse("general {\n gaps_inn = 8\n}\n").unwrap();
|
||||||
|
let inner = section(&ast.items, "general");
|
||||||
|
match &inner[0] {
|
||||||
|
Item::Assign { key, .. } => {
|
||||||
|
assert_eq!(key.span.line, 2);
|
||||||
|
assert_eq!(key.span.col, 5);
|
||||||
|
assert_eq!(key.span.len, "gaps_inn".len());
|
||||||
|
}
|
||||||
|
other => panic!("expected Assign, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unclosed_section() {
|
||||||
|
let err = parse("general {\n gaps_in = 3\n").unwrap_err();
|
||||||
|
assert!(err.message.contains("unclosed section"), "{}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unmatched_brace() {
|
||||||
|
let err = parse("}\n").unwrap_err();
|
||||||
|
assert!(err.message.contains("unmatched"), "{}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_line_without_equals() {
|
||||||
|
let err = parse("this is not valid\n").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.message.contains("expected `key = value`"),
|
||||||
|
"{}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
assert_eq!(err.span.line, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
|||||||
|
//! Declarative registry mapping `cosmic.conf` keys onto cosmic-config targets.
|
||||||
|
//!
|
||||||
|
//! This is data, not code: adding a knob is a table row. Every fact encoded here
|
||||||
|
//! was verified against a checkout rather than assumed — see the spec's
|
||||||
|
//! "Verified findings" table for file:line evidence.
|
||||||
|
|
||||||
|
/// Scalar types a conf value can carry.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Ty {
|
||||||
|
Bool,
|
||||||
|
U32,
|
||||||
|
F32,
|
||||||
|
Str,
|
||||||
|
/// `rgb(rrggbb)` -> `Option<Srgb>` (no alpha). Bare `#rrggbb` is not
|
||||||
|
/// accepted: `#` begins a comment.
|
||||||
|
Rgb,
|
||||||
|
/// `rgb(rrggbb)`/`rgba(rrggbbaa)` -> `Option<Srgba>` (with alpha).
|
||||||
|
Rgba,
|
||||||
|
/// `dark`/`light` -> the `is_dark` boolean.
|
||||||
|
Mode,
|
||||||
|
/// Hyprland's `input:follow_mouse`, `0`-`3` -> `focus_follows_cursor`.
|
||||||
|
///
|
||||||
|
/// COSMIC's setting is a plain boolean, so only `0` and `1` have a meaning
|
||||||
|
/// here. Hyprland's `2` and `3` split pointer focus from keyboard focus,
|
||||||
|
/// which cosmic-comp cannot express -- it has one focus and moves it or
|
||||||
|
/// does not. They are rejected rather than rounded to `1`: silently
|
||||||
|
/// granting click-to-focus to someone who asked for the opposite is worse
|
||||||
|
/// than telling them the setting does not exist here.
|
||||||
|
FollowMouse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a conf key's value lands in the cosmic-config tree.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Target {
|
||||||
|
/// The conf key owns the entire cosmic-config value.
|
||||||
|
Direct {
|
||||||
|
component: &'static str,
|
||||||
|
version: u8,
|
||||||
|
key: &'static str,
|
||||||
|
},
|
||||||
|
/// The conf key owns one field within a composite value. Requires
|
||||||
|
/// read-modify-write, and multiple conf keys may share one target.
|
||||||
|
Projected {
|
||||||
|
component: &'static str,
|
||||||
|
version: u8,
|
||||||
|
key: &'static str,
|
||||||
|
path: &'static [&'static str],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target {
|
||||||
|
pub fn component(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Target::Direct { component, .. } | Target::Projected { component, .. } => component,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn version(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Target::Direct { version, .. } | Target::Projected { version, .. } => *version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Target::Direct { key, .. } | Target::Projected { key, .. } => key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive numeric bounds, checked during `resolve`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Range {
|
||||||
|
pub min: f64,
|
||||||
|
pub max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Entry {
|
||||||
|
/// Dotted path as written in the file, e.g. `general.gaps_in`.
|
||||||
|
pub conf: &'static str,
|
||||||
|
/// One conf key may fan out to several components — Dark and Light theme
|
||||||
|
/// builders are separate cosmic-config components holding the same field.
|
||||||
|
pub targets: &'static [Target],
|
||||||
|
pub ty: Ty,
|
||||||
|
pub validate: Option<Range>,
|
||||||
|
/// Generates `cosmic.conf.default`, so the reference file cannot drift.
|
||||||
|
pub doc: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DARK_BUILDER: &str = "com.system76.CosmicTheme.Dark.Builder";
|
||||||
|
const LIGHT_BUILDER: &str = "com.system76.CosmicTheme.Light.Builder";
|
||||||
|
const COMP: &str = "com.system76.CosmicComp";
|
||||||
|
const TK: &str = "com.system76.CosmicTk";
|
||||||
|
const THEME_MODE: &str = "com.system76.CosmicTheme.Mode";
|
||||||
|
|
||||||
|
/// `ThemeBuilder.gaps` is `(u32, u32)` ordered **(outer, inner)** —
|
||||||
|
/// `cosmic-theme/src/model/theme.rs:895`. Index 0 is the outer gap.
|
||||||
|
const GAPS_OUTER_IDX: &str = "0";
|
||||||
|
const GAPS_INNER_IDX: &str = "1";
|
||||||
|
|
||||||
|
macro_rules! both_themes {
|
||||||
|
($key:literal, $path:expr) => {
|
||||||
|
&[
|
||||||
|
Target::Projected {
|
||||||
|
component: DARK_BUILDER,
|
||||||
|
version: 1,
|
||||||
|
key: $key,
|
||||||
|
path: $path,
|
||||||
|
},
|
||||||
|
Target::Projected {
|
||||||
|
component: LIGHT_BUILDER,
|
||||||
|
version: 1,
|
||||||
|
key: $key,
|
||||||
|
path: $path,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole-value fan-out across both theme builders. An empty projection path
|
||||||
|
/// would be a lie: these fields are `Option<..>` written in full.
|
||||||
|
macro_rules! both_themes_direct {
|
||||||
|
($key:literal) => {
|
||||||
|
&[
|
||||||
|
Target::Direct {
|
||||||
|
component: DARK_BUILDER,
|
||||||
|
version: 1,
|
||||||
|
key: $key,
|
||||||
|
},
|
||||||
|
Target::Direct {
|
||||||
|
component: LIGHT_BUILDER,
|
||||||
|
version: 1,
|
||||||
|
key: $key,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const REGISTRY: &[Entry] = &[
|
||||||
|
// ---- general ---------------------------------------------------------
|
||||||
|
Entry {
|
||||||
|
conf: "general.gaps_in",
|
||||||
|
targets: both_themes!("gaps", &[GAPS_INNER_IDX]),
|
||||||
|
ty: Ty::U32,
|
||||||
|
validate: Some(Range {
|
||||||
|
min: 0.0,
|
||||||
|
max: 128.0,
|
||||||
|
}),
|
||||||
|
doc: "Gap between adjacent tiled windows, in px",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.gaps_out",
|
||||||
|
targets: both_themes!("gaps", &[GAPS_OUTER_IDX]),
|
||||||
|
ty: Ty::U32,
|
||||||
|
validate: Some(Range {
|
||||||
|
min: 0.0,
|
||||||
|
max: 256.0,
|
||||||
|
}),
|
||||||
|
doc: "Gap between tiled windows and the screen edge, in px",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.autotile",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "autotile",
|
||||||
|
}],
|
||||||
|
ty: Ty::Bool,
|
||||||
|
validate: None,
|
||||||
|
doc: "Automatically tile new windows",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.preserve_split",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "preserve_split",
|
||||||
|
}],
|
||||||
|
ty: Ty::Bool,
|
||||||
|
validate: None,
|
||||||
|
doc: "Open new windows alongside the focused one instead of splitting it",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.active_hint",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "active_hint",
|
||||||
|
}],
|
||||||
|
ty: Ty::Bool,
|
||||||
|
validate: None,
|
||||||
|
doc: "Draw a hint around the focused window",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.focus_follows_cursor",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "focus_follows_cursor",
|
||||||
|
}],
|
||||||
|
ty: Ty::Bool,
|
||||||
|
validate: None,
|
||||||
|
doc: "Move keyboard focus when the cursor enters a window",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.focus_follows_cursor_delay",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "focus_follows_cursor_delay",
|
||||||
|
}],
|
||||||
|
ty: Ty::U32,
|
||||||
|
validate: Some(Range {
|
||||||
|
min: 0.0,
|
||||||
|
max: 5000.0,
|
||||||
|
}),
|
||||||
|
doc: "Delay in ms before focus follows the cursor",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.cursor_follows_focus",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "cursor_follows_focus",
|
||||||
|
}],
|
||||||
|
ty: Ty::Bool,
|
||||||
|
validate: None,
|
||||||
|
doc: "Warp the cursor to the window that gains keyboard focus",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "general.edge_snap_threshold",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "edge_snap_threshold",
|
||||||
|
}],
|
||||||
|
ty: Ty::U32,
|
||||||
|
validate: Some(Range {
|
||||||
|
min: 0.0,
|
||||||
|
max: 256.0,
|
||||||
|
}),
|
||||||
|
doc: "Distance in px at which windows snap to output edges",
|
||||||
|
},
|
||||||
|
// ---- decoration ------------------------------------------------------
|
||||||
|
Entry {
|
||||||
|
conf: "decoration.rounding",
|
||||||
|
targets: both_themes!("corner_radii", &["radius_m"]),
|
||||||
|
ty: Ty::F32,
|
||||||
|
validate: Some(Range {
|
||||||
|
min: 0.0,
|
||||||
|
max: 64.0,
|
||||||
|
}),
|
||||||
|
doc: "Window corner radius in px (maps to the theme's radius_m)",
|
||||||
|
},
|
||||||
|
// ---- input -----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Aliases, not new settings: both of these land on the same cosmic-config
|
||||||
|
// keys as `general.focus_follows_cursor` and its delay, which stay for
|
||||||
|
// anyone who prefers COSMIC's own naming. They exist because Hyprland puts
|
||||||
|
// this in `input` under a different name, and accepting the Hyprland
|
||||||
|
// spelling is the point of the fork.
|
||||||
|
//
|
||||||
|
// Two spellings writing one target is safe here only because the last
|
||||||
|
// assignment wins: setting both in one file is not an error, it just means
|
||||||
|
// whichever comes last is what the compositor gets. That is the same rule
|
||||||
|
// the rest of the file follows, so it needs no special handling.
|
||||||
|
Entry {
|
||||||
|
conf: "input.follow_mouse",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "focus_follows_cursor",
|
||||||
|
}],
|
||||||
|
ty: Ty::FollowMouse,
|
||||||
|
// No `Range`: `check_range` only inspects numeric values and this
|
||||||
|
// resolves to a bool, so a range here would be silently ignored. The
|
||||||
|
// accepted values are enforced by `Ty::FollowMouse` itself.
|
||||||
|
validate: None,
|
||||||
|
doc: "1 for focus follows mouse, 0 for click to focus",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "input.follow_mouse_delay",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: COMP,
|
||||||
|
version: 1,
|
||||||
|
key: "focus_follows_cursor_delay",
|
||||||
|
}],
|
||||||
|
ty: Ty::U32,
|
||||||
|
validate: Some(Range {
|
||||||
|
min: 0.0,
|
||||||
|
max: 5000.0,
|
||||||
|
}),
|
||||||
|
doc: "Delay in ms before focus follows the mouse",
|
||||||
|
},
|
||||||
|
// ---- theme -----------------------------------------------------------
|
||||||
|
Entry {
|
||||||
|
conf: "theme.mode",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: THEME_MODE,
|
||||||
|
version: 1,
|
||||||
|
key: "is_dark",
|
||||||
|
}],
|
||||||
|
ty: Ty::Mode,
|
||||||
|
validate: None,
|
||||||
|
doc: "`dark` or `light`",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "theme.accent",
|
||||||
|
targets: both_themes_direct!("accent"),
|
||||||
|
ty: Ty::Rgb,
|
||||||
|
validate: None,
|
||||||
|
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "theme.bg_color",
|
||||||
|
targets: both_themes_direct!("bg_color"),
|
||||||
|
ty: Ty::Rgba,
|
||||||
|
validate: None,
|
||||||
|
doc: "Background base colour",
|
||||||
|
},
|
||||||
|
Entry {
|
||||||
|
conf: "theme.icon_theme",
|
||||||
|
targets: &[Target::Direct {
|
||||||
|
component: TK,
|
||||||
|
version: 1,
|
||||||
|
key: "icon_theme",
|
||||||
|
}],
|
||||||
|
ty: Ty::Str,
|
||||||
|
validate: None,
|
||||||
|
doc: "Icon theme name, e.g. Tela-circle-dracula",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Exact lookup by dotted conf path.
|
||||||
|
pub fn lookup(conf: &str) -> Option<&'static Entry> {
|
||||||
|
REGISTRY.iter().find(|e| e.conf == conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nearest known key by edit distance, for "did you mean" diagnostics.
|
||||||
|
/// Only suggests when the candidate is close enough to be plausible.
|
||||||
|
pub fn suggest(conf: &str) -> Option<&'static str> {
|
||||||
|
let budget = match conf.len() {
|
||||||
|
0..=4 => 1,
|
||||||
|
5..=8 => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
REGISTRY
|
||||||
|
.iter()
|
||||||
|
.map(|e| (edit_distance(conf, e.conf), e.conf))
|
||||||
|
.filter(|(d, _)| *d <= budget)
|
||||||
|
.min_by_key(|(d, _)| *d)
|
||||||
|
.map(|(_, c)| c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Levenshtein distance, two-row variant.
|
||||||
|
fn edit_distance(a: &str, b: &str) -> usize {
|
||||||
|
let a: Vec<char> = a.chars().collect();
|
||||||
|
let b: Vec<char> = b.chars().collect();
|
||||||
|
if a.is_empty() {
|
||||||
|
return b.len();
|
||||||
|
}
|
||||||
|
if b.is_empty() {
|
||||||
|
return a.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||||
|
let mut cur = vec![0usize; b.len() + 1];
|
||||||
|
|
||||||
|
for (i, ca) in a.iter().enumerate() {
|
||||||
|
cur[0] = i + 1;
|
||||||
|
for (j, cb) in b.iter().enumerate() {
|
||||||
|
let cost = usize::from(ca != cb);
|
||||||
|
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut cur);
|
||||||
|
}
|
||||||
|
prev[b.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gaps_use_the_verified_tuple_order() {
|
||||||
|
// ThemeBuilder.gaps is (outer, inner) — theme.rs:895. Getting this
|
||||||
|
// backwards silently swaps the user's gaps, so pin it.
|
||||||
|
let inner = lookup("general.gaps_in").unwrap();
|
||||||
|
let outer = lookup("general.gaps_out").unwrap();
|
||||||
|
|
||||||
|
for t in inner.targets {
|
||||||
|
match t {
|
||||||
|
Target::Projected { path, .. } => assert_eq!(*path, &["1"]),
|
||||||
|
other => panic!("gaps_in should project, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in outer.targets {
|
||||||
|
match t {
|
||||||
|
Target::Projected { path, .. } => assert_eq!(*path, &["0"]),
|
||||||
|
other => panic!("gaps_out should project, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_keys_fan_out_to_dark_and_light() {
|
||||||
|
let e = lookup("general.gaps_in").unwrap();
|
||||||
|
let comps: Vec<_> = e.targets.iter().map(|t| t.component()).collect();
|
||||||
|
assert!(comps.contains(&"com.system76.CosmicTheme.Dark.Builder"));
|
||||||
|
assert!(comps.contains(&"com.system76.CosmicTheme.Light.Builder"));
|
||||||
|
assert_eq!(comps.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comp_keys_do_not_fan_out() {
|
||||||
|
let e = lookup("general.autotile").unwrap();
|
||||||
|
assert_eq!(e.targets.len(), 1);
|
||||||
|
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserve_split_targets_the_forks_own_comp_key() {
|
||||||
|
// Unlike every other `general` key this one does not exist upstream --
|
||||||
|
// it is a field this fork adds to `CosmicCompConfig`. If a rebase ever
|
||||||
|
// drops that field the compositor silently ignores the key, so pin the
|
||||||
|
// exact target here rather than trusting the generic registry checks.
|
||||||
|
let e = lookup("general.preserve_split").unwrap();
|
||||||
|
assert_eq!(e.targets.len(), 1);
|
||||||
|
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
|
||||||
|
assert!(matches!(e.ty, Ty::Bool));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `input.*` is an alias layer, so what matters is that it points at the
|
||||||
|
/// same place COSMIC's own naming does. If a rebase renames either target
|
||||||
|
/// key, one spelling would keep working and the other would go quietly
|
||||||
|
/// dead; pinning them together here makes that a test failure instead.
|
||||||
|
#[test]
|
||||||
|
fn the_input_section_aliases_the_general_focus_keys() {
|
||||||
|
for (hypr, cosmic) in [
|
||||||
|
("input.follow_mouse", "general.focus_follows_cursor"),
|
||||||
|
(
|
||||||
|
"input.follow_mouse_delay",
|
||||||
|
"general.focus_follows_cursor_delay",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let a = lookup(hypr).unwrap();
|
||||||
|
let b = lookup(cosmic).unwrap();
|
||||||
|
assert_eq!(a.targets, b.targets, "{hypr} and {cosmic} have drifted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alias is not a plain bool: Hyprland writes it as a number, and
|
||||||
|
/// `Ty::FollowMouse` is what turns the accepted numbers into one.
|
||||||
|
#[test]
|
||||||
|
fn follow_mouse_uses_the_hyprland_numeric_type() {
|
||||||
|
assert!(matches!(
|
||||||
|
lookup("input.follow_mouse").unwrap().ty,
|
||||||
|
Ty::FollowMouse
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
lookup("general.focus_follows_cursor").unwrap().ty,
|
||||||
|
Ty::Bool
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_entry_has_at_least_one_target() {
|
||||||
|
for e in REGISTRY {
|
||||||
|
assert!(!e.targets.is_empty(), "`{}` has no targets", e.conf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conf_paths_are_unique() {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for e in REGISTRY {
|
||||||
|
assert!(seen.insert(e.conf), "duplicate registry entry `{}`", e.conf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_entry_is_documented() {
|
||||||
|
// `doc` generates cosmic.conf.default; an empty one would ship a blank
|
||||||
|
// reference line.
|
||||||
|
for e in REGISTRY {
|
||||||
|
assert!(!e.doc.trim().is_empty(), "`{}` has no doc", e.conf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggests_near_misses() {
|
||||||
|
assert_eq!(suggest("general.gaps_inn"), Some("general.gaps_in"));
|
||||||
|
assert_eq!(suggest("general.autotil"), Some("general.autotile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_suggest_nonsense() {
|
||||||
|
assert_eq!(suggest("completely.unrelated.nonsense.key"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
//! Filesystem watch: re-apply `cosmic.conf` on every edit.
|
||||||
|
//!
|
||||||
|
//! Two problems make this more than "call `notify` and re-run `main`'s
|
||||||
|
//! pipeline":
|
||||||
|
//!
|
||||||
|
//! 1. **`source` fans out the watch set.** `parser::Item::Source` lets a
|
||||||
|
//! config pull in other files (`parser.rs:66`), but `resolve` treats
|
||||||
|
//! `Source` as inert (`resolve.rs:87`) — nothing upstream actually expands
|
||||||
|
//! it yet, even though `resolve.rs:66` already assumes an "include
|
||||||
|
//! expansion" pass ran first. This module is that pass: `merge_text`
|
||||||
|
//! textually splices a sourced file's contents in place of its `source`
|
||||||
|
//! line, the same way Hyprland treats `source` as literal inclusion. Doing
|
||||||
|
//! it as text rather than AST-splicing means the merged string is one
|
||||||
|
//! coherent document, so `Span`s (which are just line/col, with no file
|
||||||
|
//! identity — `parser.rs:24`) stay correct for `render_diagnostic`
|
||||||
|
//! regardless of which physical file a line came from. It also means the
|
||||||
|
//! watch set has to be recomputed after every successful compile, since
|
||||||
|
//! editing a `source` line can add or remove files from it.
|
||||||
|
//!
|
||||||
|
//! 2. **A bad edit must not kill the daemon or half-apply.** `Emitter::plan`
|
||||||
|
//! already keeps `apply` transactional (`emit.rs:11-14`); this module's
|
||||||
|
//! job is to keep that guarantee across an unbounded stream of edits by
|
||||||
|
//! treating every compile failure as "log it and keep watching" rather
|
||||||
|
//! than propagating it out of the loop.
|
||||||
|
//!
|
||||||
|
//! The event loop itself (`watch`) is intentionally thin. Everything with
|
||||||
|
//! interesting logic — merging sources, debouncing — is a free function
|
||||||
|
//! usable without a real inotify watcher, per the module's tests.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
|
|
||||||
|
use crate::emit::{EmitError, Emitter, Planned};
|
||||||
|
use crate::parser::{self, Item, ParseError};
|
||||||
|
use crate::render_diagnostic;
|
||||||
|
use crate::resolve::{self, Diagnostic};
|
||||||
|
|
||||||
|
/// Editors commonly write a save as several syscalls (truncate, write,
|
||||||
|
/// rename); this is long enough to collapse those into one recompile without
|
||||||
|
/// making a real edit feel laggy.
|
||||||
|
const DEBOUNCE: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
/// Everything that can go wrong compiling `config` (and whatever it sources)
|
||||||
|
/// into a plan. Every variant renders a complete, human-readable report —
|
||||||
|
/// `watch` just prints `Display` and moves on.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CompileError {
|
||||||
|
/// `config`, or something it `source`s, could not be read.
|
||||||
|
Read { path: PathBuf, error: io::Error },
|
||||||
|
/// A `source` chain refers back to a file already being expanded.
|
||||||
|
/// Splicing it would recurse forever, so this is reported instead.
|
||||||
|
Cycle { path: PathBuf },
|
||||||
|
/// A single file failed to parse on its own, before merging — `source`
|
||||||
|
/// and `error` are both that file's, so the line number is exact.
|
||||||
|
Parse {
|
||||||
|
path: PathBuf,
|
||||||
|
source: String,
|
||||||
|
error: ParseError,
|
||||||
|
},
|
||||||
|
/// The merged document failed to resolve. `source` is the full merged
|
||||||
|
/// text, so `diagnostics`' spans point at the right physical line no
|
||||||
|
/// matter which file contributed it.
|
||||||
|
Resolve {
|
||||||
|
source: String,
|
||||||
|
diagnostics: Vec<Diagnostic>,
|
||||||
|
},
|
||||||
|
/// Resolved cleanly but could not be turned into file contents.
|
||||||
|
Emit(Vec<EmitError>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CompileError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CompileError::Read { path, error } => {
|
||||||
|
writeln!(f, "error: cannot read {}: {error}", path.display())
|
||||||
|
}
|
||||||
|
CompileError::Cycle { path } => {
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
"error: `source` cycle detected while expanding {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompileError::Parse {
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
error,
|
||||||
|
} => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"in {}:\n{}",
|
||||||
|
path.display(),
|
||||||
|
render_diagnostic(source, error.span, &error.message, None)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CompileError::Resolve {
|
||||||
|
source,
|
||||||
|
diagnostics,
|
||||||
|
} => {
|
||||||
|
let mut out = String::new();
|
||||||
|
for d in diagnostics {
|
||||||
|
out.push_str(&render_diagnostic(
|
||||||
|
source,
|
||||||
|
d.span,
|
||||||
|
&d.message,
|
||||||
|
d.help.as_deref(),
|
||||||
|
));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"error: {} problem(s) found; nothing was written\n",
|
||||||
|
diagnostics.len()
|
||||||
|
));
|
||||||
|
write!(f, "{out}")
|
||||||
|
}
|
||||||
|
CompileError::Emit(errs) => {
|
||||||
|
let mut out = String::new();
|
||||||
|
for e in errs {
|
||||||
|
out.push_str(&format!("error: {e}\n"));
|
||||||
|
}
|
||||||
|
out.push_str("error: nothing was written\n");
|
||||||
|
write!(f, "{out}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CompileError {}
|
||||||
|
|
||||||
|
/// A completed compile: what to write, and what to watch.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Compiled {
|
||||||
|
pub planned: Vec<Planned>,
|
||||||
|
/// Every file that contributed content, `config` first. This is exactly
|
||||||
|
/// the set `watch` needs to be subscribed to for the *next* edit to be
|
||||||
|
/// noticed, and it can change from one compile to the next as `source`
|
||||||
|
/// lines are added, removed, or edited.
|
||||||
|
pub sources: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `config` — following `source` directives — resolve, and plan writes
|
||||||
|
/// against `emitter`, without touching disk.
|
||||||
|
///
|
||||||
|
/// Pulled out of `watch` so the compile pipeline is unit-testable without a
|
||||||
|
/// filesystem watcher: every test in this module drives `compile` directly.
|
||||||
|
pub fn compile(config: &Path, emitter: &Emitter) -> Result<Compiled, CompileError> {
|
||||||
|
let mut ancestors = Vec::new();
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
let merged = merge_text(config, &mut ancestors, &mut sources)?;
|
||||||
|
|
||||||
|
let ast = parser::parse(&merged).map_err(|error| CompileError::Parse {
|
||||||
|
path: config.to_path_buf(),
|
||||||
|
source: merged.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let resolved = resolve::resolve(&ast).map_err(|diagnostics| CompileError::Resolve {
|
||||||
|
source: merged,
|
||||||
|
diagnostics,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let planned = emitter.plan(&resolved).map_err(CompileError::Emit)?;
|
||||||
|
|
||||||
|
Ok(Compiled { planned, sources })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `path`, then replace every `source = <path>` line with the
|
||||||
|
/// (recursively expanded) text of the sourced file, so the result is one
|
||||||
|
/// document `parser::parse` can consume in a single pass — see the module
|
||||||
|
/// doc for why textual splicing rather than AST splicing.
|
||||||
|
///
|
||||||
|
/// `ancestors` is the current inclusion chain (for cycle detection);
|
||||||
|
/// `watched` accumulates every file visited, in the order first seen.
|
||||||
|
fn merge_text(
|
||||||
|
path: &Path,
|
||||||
|
ancestors: &mut Vec<PathBuf>,
|
||||||
|
watched: &mut Vec<PathBuf>,
|
||||||
|
) -> Result<String, CompileError> {
|
||||||
|
let key = path.to_path_buf();
|
||||||
|
if ancestors.contains(&key) {
|
||||||
|
return Err(CompileError::Cycle { path: key });
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = fs::read_to_string(path).map_err(|error| CompileError::Read {
|
||||||
|
path: key.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
watched.push(key.clone());
|
||||||
|
|
||||||
|
// Parsing here (rather than scanning text for `source =` ourselves) means
|
||||||
|
// we inherit the grammar's exact rules for comments and whitespace, so
|
||||||
|
// the line we splice at is always the one the real parser would call a
|
||||||
|
// `Source` item.
|
||||||
|
let ast = parser::parse(&raw).map_err(|error| CompileError::Parse {
|
||||||
|
path: key.clone(),
|
||||||
|
source: raw.clone(),
|
||||||
|
error,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
collect_sources(&ast.items, &mut targets);
|
||||||
|
if targets.is_empty() {
|
||||||
|
return Ok(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splicing changes line counts, so process bottom-up: replacing a later
|
||||||
|
// line first leaves every earlier line number still valid.
|
||||||
|
targets.sort_by_key(|t| std::cmp::Reverse(t.0));
|
||||||
|
|
||||||
|
ancestors.push(key);
|
||||||
|
let mut lines: Vec<String> = raw.lines().map(String::from).collect();
|
||||||
|
for (line_no, raw_path) in targets {
|
||||||
|
let target_path = resolve_source_path(path, &raw_path);
|
||||||
|
let included = merge_text(&target_path, ancestors, watched)?;
|
||||||
|
lines.splice(line_no - 1..line_no, included.lines().map(String::from));
|
||||||
|
}
|
||||||
|
ancestors.pop();
|
||||||
|
|
||||||
|
let mut out = lines.join("\n");
|
||||||
|
out.push('\n');
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-first walk collecting every `source` item's `(line, raw path)`.
|
||||||
|
/// Sections are recursed into: a `source` nested inside `general { .. }`
|
||||||
|
/// splices its contents into that section, matching Hyprland's textual
|
||||||
|
/// `source` semantics rather than only supporting top-level includes.
|
||||||
|
fn collect_sources(items: &[Item], out: &mut Vec<(usize, String)>) {
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
Item::Source { path } => out.push((path.span.line, path.value.clone())),
|
||||||
|
Item::Section { items, .. } => collect_sources(items, out),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `source` value the way a shell prompt would: `~/` against
|
||||||
|
/// `$HOME`, everything else relative to the directory of the file doing the
|
||||||
|
/// sourcing (not the process's cwd), so a config tree keeps working wherever
|
||||||
|
/// it is checked out.
|
||||||
|
fn resolve_source_path(containing_file: &Path, raw: &str) -> PathBuf {
|
||||||
|
let expanded = if raw == "~" {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(raw))
|
||||||
|
} else if let Some(rest) = raw.strip_prefix("~/") {
|
||||||
|
match std::env::var_os("HOME") {
|
||||||
|
Some(home) => PathBuf::from(home).join(rest),
|
||||||
|
None => PathBuf::from(raw),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PathBuf::from(raw)
|
||||||
|
};
|
||||||
|
|
||||||
|
if expanded.is_absolute() {
|
||||||
|
expanded
|
||||||
|
} else {
|
||||||
|
containing_file
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.join(expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block for the first item on `rx`, then keep draining anything that
|
||||||
|
/// arrives within `window` of the previous one. Returns `None` once the
|
||||||
|
/// sender side has been dropped and nothing more will ever come.
|
||||||
|
///
|
||||||
|
/// This is the whole debounce policy, factored out of `watch`'s loop so it
|
||||||
|
/// can be tested against a plain channel instead of real filesystem events —
|
||||||
|
/// editors write a save as several syscalls, and without this a single save
|
||||||
|
/// would trigger several redundant recompiles.
|
||||||
|
fn collect_batch<T>(rx: &mpsc::Receiver<T>, window: Duration) -> Option<Vec<T>> {
|
||||||
|
let first = rx.recv().ok()?;
|
||||||
|
let mut batch = vec![first];
|
||||||
|
while let Ok(next) = rx.recv_timeout(window) {
|
||||||
|
batch.push(next);
|
||||||
|
}
|
||||||
|
Some(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring `watcher`'s subscriptions in line with `wanted`, diffing against
|
||||||
|
/// `current` so files that stopped being sourced are actually unwatched
|
||||||
|
/// (otherwise the watch set only ever grows).
|
||||||
|
///
|
||||||
|
/// Best-effort: a `watch`/`unwatch` failure (e.g. a sourced file that does
|
||||||
|
/// not exist yet) is not fatal — the next successful compile will retry with
|
||||||
|
/// whatever the config asks for at that point.
|
||||||
|
fn sync_watches(
|
||||||
|
watcher: &mut RecommendedWatcher,
|
||||||
|
current: &mut HashSet<PathBuf>,
|
||||||
|
wanted: &[PathBuf],
|
||||||
|
) {
|
||||||
|
let wanted: HashSet<PathBuf> = wanted.iter().cloned().collect();
|
||||||
|
|
||||||
|
for stale in current.difference(&wanted) {
|
||||||
|
let _ = watcher.unwatch(stale);
|
||||||
|
}
|
||||||
|
for fresh in wanted.difference(current) {
|
||||||
|
let _ = watcher.watch(fresh, RecursiveMode::NonRecursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
*current = wanted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything that stops the daemon outright. Deliberately small: a broken
|
||||||
|
/// `cosmic.conf` is *not* one of these — see the module doc — so this is
|
||||||
|
/// only the notify plumbing itself failing to start.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WatchError {
|
||||||
|
Notify(notify::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WatchError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
WatchError::Notify(e) => write!(f, "watch error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WatchError {}
|
||||||
|
|
||||||
|
impl From<notify::Error> for WatchError {
|
||||||
|
fn from(e: notify::Error) -> Self {
|
||||||
|
WatchError::Notify(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch `config` — and everything it currently `source`s — reapplying on
|
||||||
|
/// every change until the watcher itself fails to start or stops delivering
|
||||||
|
/// events. A malformed edit is reported to stderr and waited past: see the
|
||||||
|
/// module doc for why that, not propagating the error, is the contract here.
|
||||||
|
pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
|
||||||
|
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
|
||||||
|
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
|
||||||
|
let mut watched: HashSet<PathBuf> = HashSet::new();
|
||||||
|
|
||||||
|
// The last diagnostic printed, so an unchanged one is not printed again.
|
||||||
|
// A single save arrives as several inotify events -- modify, then
|
||||||
|
// close_write, sometimes a rename when the editor writes atomically -- and
|
||||||
|
// they do not all land inside one debounce window, so a broken config
|
||||||
|
// otherwise reports itself three or four times per keystroke-save. Cleared
|
||||||
|
// on every successful compile, so the same error reappearing after a good
|
||||||
|
// one is still news and still printed.
|
||||||
|
let mut last_error: Option<String> = None;
|
||||||
|
|
||||||
|
// Compile once up front: the desktop should reflect the config the
|
||||||
|
// moment the daemon starts, and this also tells us the initial watch
|
||||||
|
// set. If it fails, fall back to watching just `config` — that is the
|
||||||
|
// one file guaranteed to exist, and a later successful compile will
|
||||||
|
// widen the watch set to whatever it actually sources.
|
||||||
|
match compile(config, emitter) {
|
||||||
|
Ok(compiled) => {
|
||||||
|
if let Err(e) = emitter.apply(&compiled.planned) {
|
||||||
|
eprintln!("{}", CompileError::Emit(vec![e]));
|
||||||
|
}
|
||||||
|
sync_watches(&mut watcher, &mut watched, &compiled.sources);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let text = e.to_string();
|
||||||
|
eprintln!("{text}");
|
||||||
|
last_error = Some(text);
|
||||||
|
sync_watches(
|
||||||
|
&mut watcher,
|
||||||
|
&mut watched,
|
||||||
|
std::slice::from_ref(&config.to_path_buf()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(batch) = collect_batch(&rx, DEBOUNCE) else {
|
||||||
|
// The sender was dropped, which only happens if `watcher` itself
|
||||||
|
// was torn down — nothing more will ever arrive.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
for event in &batch {
|
||||||
|
if let Err(e) = event {
|
||||||
|
eprintln!("watch error: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match compile(config, emitter) {
|
||||||
|
Ok(compiled) => {
|
||||||
|
last_error = None;
|
||||||
|
if let Err(e) = emitter.apply(&compiled.planned) {
|
||||||
|
eprintln!("{}", CompileError::Emit(vec![e]));
|
||||||
|
}
|
||||||
|
sync_watches(&mut watcher, &mut watched, &compiled.sources);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Leave `watched` alone: the fix for a bad edit might land in
|
||||||
|
// an already-sourced file, and dropping back to watching
|
||||||
|
// only `config` would miss that.
|
||||||
|
let text = e.to_string();
|
||||||
|
if last_error.as_deref() != Some(text.as_str()) {
|
||||||
|
eprintln!("{text}");
|
||||||
|
last_error = Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write(dir: &Path, name: &str, contents: &str) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(&path, contents).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- compile ----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_with_no_source_directives_watches_just_the_config() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n autotile = true\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(compiled.sources, vec![config]);
|
||||||
|
assert_eq!(compiled.planned.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_follows_a_source_directive_and_lists_it_as_a_watch_target() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
let included = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"extra.conf",
|
||||||
|
"general {\n autotile = true\n}\n",
|
||||||
|
);
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = extra.conf\n");
|
||||||
|
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(compiled.sources, vec![config, included]);
|
||||||
|
assert_eq!(compiled.planned.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_expands_a_source_nested_inside_a_section() {
|
||||||
|
// The sourced file's contents become part of the enclosing section,
|
||||||
|
// the same way Hyprland's `source` is a literal text substitution.
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "gaps.conf", "gaps_in = 5\ngaps_out = 10\n");
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n source = gaps.conf\n autotile = true\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let planned = compile(&config, &Emitter::with_root(root_dir.path()))
|
||||||
|
.unwrap()
|
||||||
|
.planned;
|
||||||
|
let gaps = planned
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.path.ends_with("gaps"))
|
||||||
|
.expect("gaps planned");
|
||||||
|
assert_eq!(gaps.contents, "(10, 5)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_resolves_relative_sources_against_the_including_files_directory() {
|
||||||
|
// The including file lives in a subdirectory; `nested.conf` must be
|
||||||
|
// found relative to it, not relative to the process's cwd.
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let sub = conf_dir.path().join("sub");
|
||||||
|
fs::create_dir_all(&sub).unwrap();
|
||||||
|
write(&sub, "nested.conf", "general {\n autotile = true\n}\n");
|
||||||
|
let config = write(&sub, "cosmic.conf", "source = nested.conf\n");
|
||||||
|
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
assert_eq!(compiled.sources.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_expands_tilde_against_home() {
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
write(
|
||||||
|
home.path(),
|
||||||
|
"shared.conf",
|
||||||
|
"general {\n autotile = true\n}\n",
|
||||||
|
);
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = ~/shared.conf\n");
|
||||||
|
|
||||||
|
let prev = std::env::var_os("HOME");
|
||||||
|
std::env::set_var("HOME", home.path());
|
||||||
|
let result = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path()));
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("HOME", v),
|
||||||
|
None => std::env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let compiled = result.unwrap();
|
||||||
|
assert!(compiled.sources.contains(&home.path().join("shared.conf")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_follows_a_chain_of_nested_sources() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "c.conf", "autotile = true\n");
|
||||||
|
write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"b.conf",
|
||||||
|
"general {\n source = c.conf\n}\n",
|
||||||
|
);
|
||||||
|
let config = write(conf_dir.path(), "a.conf", "source = b.conf\n");
|
||||||
|
|
||||||
|
let compiled =
|
||||||
|
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap();
|
||||||
|
assert_eq!(compiled.sources.len(), 3);
|
||||||
|
assert_eq!(compiled.planned.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_detects_a_source_cycle() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let a = conf_dir.path().join("a.conf");
|
||||||
|
let b = conf_dir.path().join("b.conf");
|
||||||
|
fs::write(&a, "source = b.conf\n").unwrap();
|
||||||
|
fs::write(&b, "source = a.conf\n").unwrap();
|
||||||
|
|
||||||
|
let err = compile(&a, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
assert!(matches!(err, CompileError::Cycle { .. }), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_reports_a_missing_source_file_without_panicking() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"source = does-not-exist.conf\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err =
|
||||||
|
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
assert!(matches!(err, CompileError::Read { .. }), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_surfaces_a_syntax_error_in_a_sourced_file() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "broken.conf", "this is not valid\n");
|
||||||
|
let config = write(conf_dir.path(), "cosmic.conf", "source = broken.conf\n");
|
||||||
|
|
||||||
|
let err =
|
||||||
|
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
match err {
|
||||||
|
CompileError::Parse { path, .. } => {
|
||||||
|
assert_eq!(path, conf_dir.path().join("broken.conf"))
|
||||||
|
}
|
||||||
|
other => panic!("expected Parse, got {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_diagnostic_line_number_points_at_the_merged_document_not_the_fragment() {
|
||||||
|
// The offending line is line 1 of `bad.conf`, but after splicing it
|
||||||
|
// sits at line 2 of the merged document — the diagnostic must report
|
||||||
|
// the merged position so the caret lands on the right physical line.
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
write(conf_dir.path(), "bad.conf", "gaps_inn = 8\n");
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n source = bad.conf\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err =
|
||||||
|
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
match err {
|
||||||
|
CompileError::Resolve { diagnostics, .. } => {
|
||||||
|
assert_eq!(diagnostics[0].span.line, 2);
|
||||||
|
}
|
||||||
|
other => panic!("expected Resolve, got {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compile_surfaces_resolve_diagnostics_for_an_unknown_key() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n gaps_inn = 8\n}\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err =
|
||||||
|
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||||
|
assert!(matches!(err, CompileError::Resolve { .. }), "{err}");
|
||||||
|
assert!(err.to_string().contains("unknown key"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `emit.rs`'s `plan_does_not_write`: `compile` only plans, so it
|
||||||
|
/// must leave the cosmic-config tree untouched.
|
||||||
|
#[test]
|
||||||
|
fn compile_does_not_write_to_the_config_root() {
|
||||||
|
let conf_dir = TempDir::new().unwrap();
|
||||||
|
let config = write(
|
||||||
|
conf_dir.path(),
|
||||||
|
"cosmic.conf",
|
||||||
|
"general {\n autotile = true\n}\n",
|
||||||
|
);
|
||||||
|
let root_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let _ = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(root_dir.path()).unwrap().next().is_none(),
|
||||||
|
"compile must leave the tree untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolve_source_path -----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_source_path_is_relative_to_the_including_file_not_the_cwd() {
|
||||||
|
let including = Path::new("/somewhere/deep/cosmic.conf");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_source_path(including, "extra.conf"),
|
||||||
|
Path::new("/somewhere/deep/extra.conf")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_source_path_leaves_absolute_paths_alone() {
|
||||||
|
let including = Path::new("/somewhere/deep/cosmic.conf");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_source_path(including, "/etc/other.conf"),
|
||||||
|
Path::new("/etc/other.conf")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tilde expansion against `$HOME` is covered end-to-end by
|
||||||
|
// `compile_expands_tilde_against_home` below rather than here too:
|
||||||
|
// `std::env::set_var` mutates process-global state, and the default
|
||||||
|
// test runner is multi-threaded, so two tests racing to set `HOME`
|
||||||
|
// would be a real source of flakiness rather than a hypothetical one.
|
||||||
|
|
||||||
|
// ---- collect_batch (debounce) -------------------------------------
|
||||||
|
//
|
||||||
|
// These exercise the debounce policy directly against a plain channel,
|
||||||
|
// with no filesystem or notify involvement at all, so they are fast and
|
||||||
|
// cannot flake on OS-level event timing.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_batch_drains_everything_already_sent_before_it_was_called() {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
for i in 0..5 {
|
||||||
|
tx.send(i).unwrap();
|
||||||
|
}
|
||||||
|
let batch = collect_batch(&rx, Duration::from_millis(30)).unwrap();
|
||||||
|
assert_eq!(batch, vec![0, 1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_batch_returns_none_once_the_sender_is_dropped() {
|
||||||
|
let (tx, rx) = mpsc::channel::<i32>();
|
||||||
|
drop(tx);
|
||||||
|
assert!(collect_batch(&rx, Duration::from_millis(30)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_batch_starts_a_fresh_batch_after_the_quiet_window_elapses() {
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let window = Duration::from_millis(20);
|
||||||
|
|
||||||
|
tx.send(1).unwrap();
|
||||||
|
let first = collect_batch(&rx, window).unwrap();
|
||||||
|
assert_eq!(first, vec![1]);
|
||||||
|
|
||||||
|
// Send the second burst from another thread after the window has
|
||||||
|
// safely elapsed (10x margin), so the main thread's blocking `recv`
|
||||||
|
// in the next `collect_batch` call has something to wake it up.
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(window * 10);
|
||||||
|
tx.send(2).unwrap();
|
||||||
|
});
|
||||||
|
let second = collect_batch(&rx, window).unwrap();
|
||||||
|
assert_eq!(second, vec![2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sync_watches ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// Exercises the real notify watch/unwatch bookkeeping — but only ever
|
||||||
|
// registers watches on files that already exist; no event is triggered
|
||||||
|
// or waited for, so this cannot flake on inotify timing.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_watches_adds_and_then_removes_a_watch() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let a = write(dir.path(), "a.conf", "");
|
||||||
|
let b = write(dir.path(), "b.conf", "");
|
||||||
|
|
||||||
|
let (tx, _rx) = mpsc::channel::<notify::Result<Event>>();
|
||||||
|
let mut watcher = notify::recommended_watcher(tx).unwrap();
|
||||||
|
let mut current = HashSet::new();
|
||||||
|
|
||||||
|
sync_watches(&mut watcher, &mut current, &[a.clone(), b.clone()]);
|
||||||
|
assert_eq!(current, HashSet::from([a.clone(), b.clone()]));
|
||||||
|
|
||||||
|
// Dropping `b` from the wanted set must unwatch it, not just stop
|
||||||
|
// tracking it, or the watch set would only ever grow.
|
||||||
|
sync_watches(&mut watcher, &mut current, std::slice::from_ref(&a));
|
||||||
|
assert_eq!(current, HashSet::from([a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
//! Hyprland `windowrule` lines -> COSMIC window rules.
|
||||||
|
//!
|
||||||
|
//! Hyprland's `windowrule` is a large surface: an action, a match, and around
|
||||||
|
//! forty possible actions ranging from `float` to `bordercolor`. Exactly one of
|
||||||
|
//! them is implemented here, `workspace`, because it is the one with a real
|
||||||
|
//! COSMIC counterpart -- a window can be mapped onto a workspace other than the
|
||||||
|
//! active one, which is what `windowrule = workspace 4, class:...` means.
|
||||||
|
//!
|
||||||
|
//! Everything else is refused with an explanation rather than accepted and
|
||||||
|
//! dropped. A rule that parses and then does nothing is the worst outcome
|
||||||
|
//! available: the config looks right, the window opens in the wrong place, and
|
||||||
|
//! there is nothing to read that says why.
|
||||||
|
//!
|
||||||
|
//! The match half is `class:` and `title:`, both regular expressions, both
|
||||||
|
//! compiled here so a broken one is a diagnostic against the line that wrote it
|
||||||
|
//! rather than a warning in the compositor log nobody reads.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use crate::parser::Span;
|
||||||
|
|
||||||
|
/// Where a matching window opens.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum WorkspaceTarget {
|
||||||
|
/// 1-based, as the user counts them.
|
||||||
|
Index(u32),
|
||||||
|
/// Matched against the workspace name, which is what a `workspace` line
|
||||||
|
/// sets.
|
||||||
|
Name(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `windowrule = ...` line.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WindowRuleDecl {
|
||||||
|
/// Regular expression for the window's app id. Empty matches anything.
|
||||||
|
pub class: String,
|
||||||
|
/// Regular expression for the window's title. Empty matches anything.
|
||||||
|
pub title: String,
|
||||||
|
pub workspace: WorkspaceTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WindowRuleError {
|
||||||
|
pub message: String,
|
||||||
|
pub help: Option<String>,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WindowRuleError {
|
||||||
|
WindowRuleError {
|
||||||
|
message: message.into(),
|
||||||
|
help: help.map(str::to_string),
|
||||||
|
span,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actions Hyprland has that this cannot do, and why saying so beats
|
||||||
|
/// guessing. `float` and `tile` are called out separately because they are the
|
||||||
|
/// next most likely thing to be reached for and COSMIC does have a mechanism --
|
||||||
|
/// just not one cosmic.conf owns.
|
||||||
|
const FLOAT_HELP: &str = "cosmic-comp decides floating from its tiling exceptions, which belong to \
|
||||||
|
cosmic-settings (com.system76.CosmicSettings.WindowRules) rather than to \
|
||||||
|
cosmic.conf. Add the application there and it will float on every \
|
||||||
|
workspace.";
|
||||||
|
|
||||||
|
const ACTION_HELP: &str = "only `workspace` is supported. Hyprland's other rules -- float, size, \
|
||||||
|
move, opacity, bordercolor and the rest -- have no COSMIC equivalent to \
|
||||||
|
project onto.";
|
||||||
|
|
||||||
|
/// Matchers Hyprland has that depend on window state at match time. Ours runs
|
||||||
|
/// once, when the window is mapped, so none of these can be answered.
|
||||||
|
const MATCHER_HELP: &str = "rules are matched once, as the window opens, so only what the window \
|
||||||
|
arrives with can be tested: class and title.";
|
||||||
|
|
||||||
|
/// Parse the body of a `windowrule` line.
|
||||||
|
///
|
||||||
|
/// `windowrule = workspace 4, class:^(vivaldi.*)$`
|
||||||
|
///
|
||||||
|
/// The first field is the action, the rest are matchers. `windowrulev2` is the
|
||||||
|
/// same thing under an older name -- Hyprland merged v2's syntax into
|
||||||
|
/// `windowrule` and kept the alias, and HyDE-era configs are full of it.
|
||||||
|
pub fn parse_window_rule(value: &str, span: Span) -> Result<WindowRuleDecl, WindowRuleError> {
|
||||||
|
let mut parts = value.split(',');
|
||||||
|
|
||||||
|
let action = parts
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
"a window rule needs an action",
|
||||||
|
Some("for example: windowrule = workspace 4, class:^(vivaldi)$"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let workspace = parse_action(action, span)?;
|
||||||
|
|
||||||
|
let mut class = String::new();
|
||||||
|
let mut title = String::new();
|
||||||
|
let mut matched_on = false;
|
||||||
|
|
||||||
|
for raw in parts {
|
||||||
|
let param = raw.trim();
|
||||||
|
if param.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, arg)) = param.split_once(':') else {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("expected `key:value`, found `{param}`"),
|
||||||
|
Some("known matchers: class, title"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let arg = arg.trim();
|
||||||
|
// `initialClass`/`initialTitle` are accepted as spellings of the same
|
||||||
|
// thing rather than as approximations of it: the match happens as the
|
||||||
|
// window is mapped, so the title being tested *is* the initial one.
|
||||||
|
match key.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"class" | "initialclass" => {
|
||||||
|
check_regex(arg, "class", span)?;
|
||||||
|
class = arg.to_string();
|
||||||
|
matched_on = true;
|
||||||
|
}
|
||||||
|
"title" | "initialtitle" => {
|
||||||
|
check_regex(arg, "title", span)?;
|
||||||
|
title = arg.to_string();
|
||||||
|
matched_on = true;
|
||||||
|
}
|
||||||
|
// Named rather than swept into the catch-all so the message can say
|
||||||
|
// why a matcher Hyprland does have is not accepted here.
|
||||||
|
"floating" | "fullscreen" | "pinned" | "focus" | "workspace" | "onworkspace"
|
||||||
|
| "xwayland" | "tag" | "fullscreenstate" => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{}:` cannot be matched on", key.trim()),
|
||||||
|
Some(MATCHER_HELP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unknown matcher `{other}`"),
|
||||||
|
Some("known matchers: class, title"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched_on {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
"a window rule needs something to match on",
|
||||||
|
Some(
|
||||||
|
"without a class or a title the rule matches every window, which \
|
||||||
|
would send the whole session to one workspace.",
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WindowRuleDecl {
|
||||||
|
class,
|
||||||
|
title,
|
||||||
|
workspace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `workspace 4`, `workspace name:web`, either with a trailing `silent`.
|
||||||
|
fn parse_action(action: &str, span: Span) -> Result<WorkspaceTarget, WindowRuleError> {
|
||||||
|
let (verb, rest) = match action.split_once(char::is_whitespace) {
|
||||||
|
Some((verb, rest)) => (verb, rest.trim()),
|
||||||
|
None => (action, ""),
|
||||||
|
};
|
||||||
|
if !verb.eq_ignore_ascii_case("workspace") {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unsupported window rule `{verb}`"),
|
||||||
|
Some(
|
||||||
|
if verb.eq_ignore_ascii_case("float") || verb.eq_ignore_ascii_case("tile") {
|
||||||
|
FLOAT_HELP
|
||||||
|
} else {
|
||||||
|
ACTION_HELP
|
||||||
|
},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's `silent` means "put it there without switching to it". That is
|
||||||
|
// unconditionally what happens here -- a rule places its window and leaves
|
||||||
|
// the focus alone -- so the word is accepted as a description of the
|
||||||
|
// behaviour rather than ignored as a request that went unheard.
|
||||||
|
//
|
||||||
|
// Stripped from the end rather than parsed as one word among several,
|
||||||
|
// because a workspace name may contain spaces: `workspace = 2, name:web and
|
||||||
|
// mail` is a legal declaration, so `workspace name:web and mail` has to be a
|
||||||
|
// legal rule.
|
||||||
|
let target = rest
|
||||||
|
.rsplit_once(char::is_whitespace)
|
||||||
|
.filter(|(_, last)| last.eq_ignore_ascii_case("silent"))
|
||||||
|
.map_or(rest, |(head, _)| head.trim_end());
|
||||||
|
|
||||||
|
if target.is_empty() {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
"`workspace` needs a workspace to send the window to",
|
||||||
|
Some("for example: workspace 4, or workspace name:web"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = target.strip_prefix("name:") {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(err(span, "`name:` needs a workspace name", None));
|
||||||
|
}
|
||||||
|
return Ok(WorkspaceTarget::Name(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits checked before parsing, not after: `u32::from_str` accepts a
|
||||||
|
// leading `+`, so `workspace +1` -- Hyprland's "one to the right" -- would
|
||||||
|
// otherwise parse as the absolute workspace 1 and send the window somewhere
|
||||||
|
// the rule never asked for.
|
||||||
|
if target.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
match target.parse::<u32>() {
|
||||||
|
Ok(0) => return Err(err(span, "workspaces are numbered from 1", None)),
|
||||||
|
Ok(index) => return Ok(WorkspaceTarget::Index(index)),
|
||||||
|
// Only reachable by overflow, which the message below covers.
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else Hyprland accepts here is relative to where you are --
|
||||||
|
// `+1`, `previous`, `empty`, `special` -- and a rule fires when a window
|
||||||
|
// opens, so "the next workspace" would mean a different one every time.
|
||||||
|
Err(err(
|
||||||
|
span,
|
||||||
|
format!("cannot send a window to `{target}`"),
|
||||||
|
Some(
|
||||||
|
"a rule names one fixed workspace: a number, or `name:` and the name \
|
||||||
|
from a `workspace` line, optionally followed by `silent`. Relative \
|
||||||
|
and special workspaces have no COSMIC equivalent.",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_regex(pattern: &str, field: &str, span: Span) -> Result<(), WindowRuleError> {
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{field}:` needs a value"),
|
||||||
|
Some("an empty expression matches every window; leave the matcher out instead."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
regex::Regex::new(pattern).map_err(|e| {
|
||||||
|
// The crate's own message is multi-line and already points at the
|
||||||
|
// offending character, which is more useful than anything paraphrased.
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!("`{field}:` is not a valid expression"),
|
||||||
|
Some(&e.to_string().replace('\n', " ")),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ron_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the declarations as the RON `Vec<WindowRule>` cosmic-comp stores in
|
||||||
|
/// `window_rules`.
|
||||||
|
///
|
||||||
|
/// Order is preserved because it is meaningful: the compositor takes the first
|
||||||
|
/// rule that matches, and a file reads top to bottom, so the earlier line is
|
||||||
|
/// the one a person expects to win.
|
||||||
|
pub fn render(decls: &[WindowRuleDecl]) -> String {
|
||||||
|
let mut out = String::from("[\n");
|
||||||
|
for decl in decls {
|
||||||
|
let workspace = match &decl.workspace {
|
||||||
|
WorkspaceTarget::Index(n) => format!("Index({n})"),
|
||||||
|
WorkspaceTarget::Name(name) => format!("Name({})", ron_string(name)),
|
||||||
|
};
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" (app_id: {}, title: {}, workspace: {workspace}),",
|
||||||
|
ron_string(&decl.class),
|
||||||
|
ron_string(&decl.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push_str("]\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn span() -> Span {
|
||||||
|
Span {
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
len: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(s: &str) -> WindowRuleDecl {
|
||||||
|
parse_window_rule(s, span()).expect(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(s: &str) -> WindowRuleError {
|
||||||
|
parse_window_rule(s, span()).expect_err(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hyprland_form_parses() {
|
||||||
|
let r = ok("workspace 4, class:^(vivaldi.*)$");
|
||||||
|
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
|
||||||
|
assert_eq!(r.class, "^(vivaldi.*)$");
|
||||||
|
assert_eq!(r.title, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_workspace_is_carried_through() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace name:web, class:vivaldi").workspace,
|
||||||
|
WorkspaceTarget::Name("web".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn class_and_title_can_both_be_given() {
|
||||||
|
let r = ok("workspace 2, class:^(firefox)$, title:.*Mail.*");
|
||||||
|
assert_eq!(r.class, "^(firefox)$");
|
||||||
|
assert_eq!(r.title, ".*Mail.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_alone_is_enough_to_match_on() {
|
||||||
|
let r = ok("workspace 2, title:.*Mail.*");
|
||||||
|
assert_eq!(r.class, "", "an empty class matches every app id");
|
||||||
|
assert_eq!(r.title, ".*Mail.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_spellings_are_the_same_matchers() {
|
||||||
|
let r = ok("workspace 1, initialClass:foo, initialTitle:bar");
|
||||||
|
assert_eq!(r.class, "foo");
|
||||||
|
assert_eq!(r.title, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silent_is_accepted_because_it_describes_what_happens() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace 3 silent, class:foo").workspace,
|
||||||
|
WorkspaceTarget::Index(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_does_not_matter_for_keywords() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("Workspace 3 SILENT, CLASS:foo").workspace,
|
||||||
|
WorkspaceTarget::Index(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_around_everything_is_tolerated() {
|
||||||
|
let r = ok(" workspace 4 , class : ^foo$ ");
|
||||||
|
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
|
||||||
|
assert_eq!(r.class, "^foo$");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_with_nothing_to_match_on_is_refused() {
|
||||||
|
let e = fail("workspace 4");
|
||||||
|
assert!(e.message.contains("something to match on"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsupported_action_says_which_one_is_supported() {
|
||||||
|
let e = fail("size 100 100, class:foo");
|
||||||
|
assert!(e.message.contains("unsupported window rule"), "{e:?}");
|
||||||
|
assert!(e.help.unwrap().contains("only `workspace`"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_points_at_the_tiling_exceptions_instead() {
|
||||||
|
let e = fail("float, class:foo");
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("cosmic-settings"),
|
||||||
|
"{e:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_state_matcher_explains_that_matching_happens_once() {
|
||||||
|
let e = fail("workspace 4, class:foo, floating:1");
|
||||||
|
assert!(e.message.contains("cannot be matched on"), "{e:?}");
|
||||||
|
assert!(e.help.unwrap().contains("as the window opens"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_matcher_lists_the_known_ones() {
|
||||||
|
let e = fail("workspace 4, klass:foo");
|
||||||
|
assert!(e.message.contains("unknown matcher"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `u32::from_str` accepts a leading sign, so `+1` would silently become
|
||||||
|
/// the absolute workspace 1 if the digits were not checked first.
|
||||||
|
#[test]
|
||||||
|
fn a_relative_workspace_is_refused_with_a_reason() {
|
||||||
|
for target in ["+1", "-1", "previous", "empty", "e+1"] {
|
||||||
|
let e = fail(&format!("workspace {target}, class:foo"));
|
||||||
|
assert!(
|
||||||
|
e.message.contains("cannot send a window to"),
|
||||||
|
"{target}: {}",
|
||||||
|
e.message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("one fixed workspace"),
|
||||||
|
"{target}: {:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_special_workspace_is_refused() {
|
||||||
|
assert!(fail("workspace special:magic, class:foo")
|
||||||
|
.message
|
||||||
|
.contains("cannot send a window to"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_zero_is_refused() {
|
||||||
|
assert!(fail("workspace 0, class:foo")
|
||||||
|
.message
|
||||||
|
.contains("numbered from 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_expression_is_caught_here_not_in_the_compositor() {
|
||||||
|
let e = fail("workspace 4, class:^(unclosed");
|
||||||
|
assert!(e.message.contains("not a valid expression"), "{e:?}");
|
||||||
|
assert!(e.help.is_some(), "the regex crate's own message is passed on");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_matcher_is_refused_rather_than_matching_everything() {
|
||||||
|
let e = fail("workspace 4, class:");
|
||||||
|
assert!(e.message.contains("needs a value"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `silent` is stripped off the end, so anything else trailing an index is
|
||||||
|
/// part of the target and fails as one rather than being quietly dropped.
|
||||||
|
#[test]
|
||||||
|
fn a_modifier_that_is_not_silent_is_refused() {
|
||||||
|
let e = fail("workspace 4 loud, class:foo");
|
||||||
|
assert!(e.message.contains("cannot send a window to `4 loud`"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `workspace` line accepts a name with spaces in it, so a rule aiming at
|
||||||
|
/// that workspace has to as well.
|
||||||
|
#[test]
|
||||||
|
fn a_workspace_name_may_contain_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace name:web and mail, class:foo").workspace,
|
||||||
|
WorkspaceTarget::Name("web and mail".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace name:web and mail silent, class:foo").workspace,
|
||||||
|
WorkspaceTarget::Name("web and mail".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_with_nothing_after_it_says_so() {
|
||||||
|
let e = fail("workspace, class:foo");
|
||||||
|
assert!(e.message.contains("needs a workspace"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendering_matches_the_ron_shape_cosmic_comp_reads() {
|
||||||
|
let out = render(&[
|
||||||
|
WindowRuleDecl {
|
||||||
|
class: "^(vivaldi)$".into(),
|
||||||
|
title: String::new(),
|
||||||
|
workspace: WorkspaceTarget::Name("web".into()),
|
||||||
|
},
|
||||||
|
WindowRuleDecl {
|
||||||
|
class: "^(kitty)$".into(),
|
||||||
|
title: String::new(),
|
||||||
|
workspace: WorkspaceTarget::Index(1),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
concat!(
|
||||||
|
"[\n",
|
||||||
|
" (app_id: \"^(vivaldi)$\", title: \"\", workspace: Name(\"web\")),\n",
|
||||||
|
" (app_id: \"^(kitty)$\", title: \"\", workspace: Index(1)),\n",
|
||||||
|
"]\n",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_renders_as_an_empty_list() {
|
||||||
|
assert_eq!(render(&[]), "[\n]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_in_an_expression_cannot_break_out_of_the_ron() {
|
||||||
|
let out = render(&[WindowRuleDecl {
|
||||||
|
class: r#"^(say "hi")$"#.into(),
|
||||||
|
title: String::new(),
|
||||||
|
workspace: WorkspaceTarget::Index(1),
|
||||||
|
}]);
|
||||||
|
assert!(out.contains(r#"\"hi\""#), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
//! Hyprland `workspace` lines -> COSMIC pinned workspaces.
|
||||||
|
//!
|
||||||
|
//! COSMIC's workspaces are dynamic and unconditionally so: `ensure_last_empty`
|
||||||
|
//! keeps exactly one trailing empty workspace and garbage-collects every other
|
||||||
|
//! empty one, and there is no setting anywhere that turns that off. A Hyprland
|
||||||
|
//! user expects the opposite -- a fixed set of workspaces that exist whether or
|
||||||
|
//! not anything is on them, so that "workspace 4 is the browser" stays true
|
||||||
|
//! across a reboot.
|
||||||
|
//!
|
||||||
|
//! The primitive that bridges the two already exists in the compositor.
|
||||||
|
//! `Workspace::can_auto_remove` is `is_empty() && !has_activation_token() &&
|
||||||
|
//! !pinned`, so a pinned workspace survives being emptied, and
|
||||||
|
//! `CosmicCompConfig::pinned_workspaces` is a persisted key that
|
||||||
|
//! `Workspaces::add_output` drains into the first output that appears. Nothing
|
||||||
|
//! in cosmic-comp needs to change: declaring workspaces here is enough.
|
||||||
|
//!
|
||||||
|
//! Three consequences of that restore path shape this module:
|
||||||
|
//!
|
||||||
|
//! 1. **Restore is positional.** `PinnedWorkspace` has no index field -- the
|
||||||
|
//! order of the Vec becomes the order of the workspaces. So `workspace = 4`
|
||||||
|
//! cannot emit one entry; it has to emit four, with 1..3 unnamed, or the
|
||||||
|
//! declared workspace would land at index 1.
|
||||||
|
//! 2. **The trailing dynamic workspace is kept.** Pinned workspaces are pushed
|
||||||
|
//! into an empty `WorkspaceSet`, and `ensure_last_empty` then appends the
|
||||||
|
//! usual empty one. Declaring four leaves you on 1-4 with a fifth appearing
|
||||||
|
//! when you use it, which is Hyprland's behaviour rather than a compromise.
|
||||||
|
//! 3. **It lands at the next login, not on apply.** `Workspaces::new` reads the
|
||||||
|
//! key once when the compositor starts and there is no reload path for it,
|
||||||
|
//! while `hyprcosmic-conf watch` is started from the autostart file *after*
|
||||||
|
//! COSMIC's own components. So an edit is written immediately and takes
|
||||||
|
//! effect the next time the session starts. Every other key in cosmic.conf
|
||||||
|
//! is live, so this one is worth saying out loud.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use crate::parser::Span;
|
||||||
|
|
||||||
|
/// One `workspace = ...` line.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkspaceDecl {
|
||||||
|
/// 1-based, as written. Workspaces below this one are materialised too.
|
||||||
|
pub index: u32,
|
||||||
|
/// Shown by anything reading ext-workspace, waybar included. `None` leaves
|
||||||
|
/// COSMIC to label it by number.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Per-workspace tiling. `None` inherits the session default.
|
||||||
|
pub tiling: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkspaceError {
|
||||||
|
pub message: String,
|
||||||
|
pub help: Option<String>,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WorkspaceError {
|
||||||
|
WorkspaceError {
|
||||||
|
message: message.into(),
|
||||||
|
help: help.map(str::to_string),
|
||||||
|
span,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declaring index N materialises N workspaces, so a typo like `workspace = 100`
|
||||||
|
/// would silently produce a hundred of them. Nobody drives a hundred
|
||||||
|
/// workspaces; the cap turns a fat-finger into a diagnostic.
|
||||||
|
const MAX_INDEX: u32 = 32;
|
||||||
|
|
||||||
|
/// Why `monitor:` is refused rather than accepted and ignored.
|
||||||
|
///
|
||||||
|
/// A `PinnedWorkspace` names its output through `OutputMatch { name, edid }`,
|
||||||
|
/// and cosmic-comp's `output_matches` compares the EDID *first*: a match with
|
||||||
|
/// `edid: None` is rejected outright against any output that reports one, and
|
||||||
|
/// only falls through to the name when neither side has an EDID. Every real
|
||||||
|
/// panel reports one, so a name-only match would work on a VM and nowhere else.
|
||||||
|
///
|
||||||
|
/// cosmic.conf cannot supply the EDID -- it is a manufacturer triple, product
|
||||||
|
/// id, serial and manufacture date read off the wire by the DRM backend, not
|
||||||
|
/// something a user can write down. Accepting `monitor:` would give a parameter
|
||||||
|
/// that silently does nothing on the hardware people actually have.
|
||||||
|
const MONITOR_HELP: &str = "COSMIC matches a workspace to a monitor by EDID rather than by name, \
|
||||||
|
and cosmic.conf has no way to spell an EDID. Pinned workspaces are created \
|
||||||
|
on the first output the session sees; move them with the workspace \
|
||||||
|
shortcuts once they exist.";
|
||||||
|
|
||||||
|
/// Parse the body of a `workspace` line.
|
||||||
|
///
|
||||||
|
/// `workspace = 4, name:web, tiling:true`
|
||||||
|
///
|
||||||
|
/// The index comes first and is required. Hyprland also allows a leading
|
||||||
|
/// `name:` with no index, for its special workspaces; COSMIC has no equivalent
|
||||||
|
/// and the restore is positional besides, so that form is rejected with an
|
||||||
|
/// explanation rather than guessed at.
|
||||||
|
pub fn parse_workspace(value: &str, span: Span) -> Result<WorkspaceDecl, WorkspaceError> {
|
||||||
|
let mut parts = value.split(',');
|
||||||
|
|
||||||
|
let head = parts
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
"a workspace needs an index",
|
||||||
|
Some("for example: workspace = 4, name:web"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let index = head.parse::<u32>().map_err(|_| {
|
||||||
|
if head.contains(':') {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!("a workspace starts with its index, found `{head}`"),
|
||||||
|
Some(
|
||||||
|
"COSMIC restores pinned workspaces by position, so every one \
|
||||||
|
needs a number. Write `workspace = 4, name:web` rather than \
|
||||||
|
`workspace = name:web`.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!("expected a workspace index, found `{head}`"),
|
||||||
|
Some("for example: workspace = 4, name:web"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if index == 0 || index > MAX_INDEX {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("workspace index {index} is outside 1..={MAX_INDEX}"),
|
||||||
|
Some(
|
||||||
|
"workspaces are numbered from 1, and declaring one materialises \
|
||||||
|
every workspace below it, so the highest index is capped.",
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decl = WorkspaceDecl {
|
||||||
|
index,
|
||||||
|
name: None,
|
||||||
|
tiling: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for raw in parts {
|
||||||
|
let param = raw.trim();
|
||||||
|
if param.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, arg)) = param.split_once(':') else {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("expected `key:value`, found `{param}`"),
|
||||||
|
Some("known parameters: name, tiling"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let arg = arg.trim();
|
||||||
|
match key.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"name" => {
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(err(span, "`name:` needs a value", None));
|
||||||
|
}
|
||||||
|
decl.name = Some(arg.to_string());
|
||||||
|
}
|
||||||
|
// Named rather than swept into the catch-all so the message can say
|
||||||
|
// why a parameter Hyprland does have is not accepted here.
|
||||||
|
"monitor" | "output" => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{}:` has no COSMIC equivalent", key.trim()),
|
||||||
|
Some(MONITOR_HELP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
"tiling" => {
|
||||||
|
decl.tiling = Some(match arg.to_ascii_lowercase().as_str() {
|
||||||
|
"true" | "yes" | "on" | "1" => true,
|
||||||
|
"false" | "no" | "off" | "0" => false,
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`tiling:` expects a boolean, found `{other}`"),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unknown workspace parameter `{other}`"),
|
||||||
|
Some("known parameters: name, tiling"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(decl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ron_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a, written out rather than reached for.
|
||||||
|
///
|
||||||
|
/// `DefaultHasher` is explicitly documented as not stable across Rust releases,
|
||||||
|
/// which would make workspace ids change under the user when the toolchain
|
||||||
|
/// moves -- and the id is what ties a window's saved workspace to the workspace
|
||||||
|
/// it reappears on. Twelve lines of FNV is cheaper than that class of bug.
|
||||||
|
fn fnv1a(s: &str) -> u32 {
|
||||||
|
let mut h: u32 = 0x811c9dc5;
|
||||||
|
for b in s.as_bytes() {
|
||||||
|
h ^= *b as u32;
|
||||||
|
h = h.wrapping_mul(0x01000193);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable id for the workspace at `index`.
|
||||||
|
///
|
||||||
|
/// cosmic-comp's own `random_workspace_id` is `format!("{:x}", rand(0..2<<24))`,
|
||||||
|
/// which is at most seven hex digits. Forcing the high nibble on here makes ours
|
||||||
|
/// exactly eight, so a generated id and a compositor-generated one cannot
|
||||||
|
/// collide by construction rather than by being unlikely to.
|
||||||
|
fn workspace_id(index: u32) -> String {
|
||||||
|
let h = fnv1a(&format!("hyprcosmic:workspace:{index}"));
|
||||||
|
format!("{:08x}", 0x1000_0000 | (h & 0x0fff_ffff))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the declarations as the RON `Vec<PinnedWorkspace>` cosmic-comp stores
|
||||||
|
/// in `pinned_workspaces`.
|
||||||
|
///
|
||||||
|
/// `default_tiling` is the session-wide `general:autotile`, used for any
|
||||||
|
/// workspace that did not say. Without it, declaring workspaces would silently
|
||||||
|
/// turn tiling off on all of them for a user who had asked for it globally.
|
||||||
|
pub fn render(decls: &[WorkspaceDecl], default_tiling: bool) -> String {
|
||||||
|
let highest = decls.iter().map(|d| d.index).max().unwrap_or(0);
|
||||||
|
|
||||||
|
let mut out = String::from("[\n");
|
||||||
|
for index in 1..=highest {
|
||||||
|
// Gaps are filled rather than skipped: the restore is positional, so an
|
||||||
|
// absent workspace 3 would put the declared workspace 4 at position 3.
|
||||||
|
let decl = decls.iter().find(|d| d.index == index);
|
||||||
|
|
||||||
|
let tiling = decl.and_then(|d| d.tiling).unwrap_or(default_tiling);
|
||||||
|
let name = match decl.and_then(|d| d.name.as_deref()) {
|
||||||
|
Some(n) => format!("Some({})", ron_string(n)),
|
||||||
|
None => "None".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// An empty `OutputMatch` is "no preference", not a guess. It is the
|
||||||
|
// front of the workspace's `output_stack`, which `prefers_output`
|
||||||
|
// consults when an output appears; a name nothing can match leaves the
|
||||||
|
// workspace where `add_output` created it. See MONITOR_HELP.
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
r#" (output: (name: "", edid: None), tiling_enabled: {tiling}, id: Some({}), name: {name}),"#,
|
||||||
|
ron_string(&workspace_id(index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push_str("]\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
const S: Span = Span {
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
len: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ws(v: &str) -> WorkspaceDecl {
|
||||||
|
parse_workspace(v, S).expect("should parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad(v: &str) -> WorkspaceError {
|
||||||
|
parse_workspace(v, S).expect_err("should not parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shortest_useful_declaration_is_a_bare_index() {
|
||||||
|
assert_eq!(
|
||||||
|
ws("4"),
|
||||||
|
WorkspaceDecl {
|
||||||
|
index: 4,
|
||||||
|
name: None,
|
||||||
|
tiling: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_parameter_together() {
|
||||||
|
assert_eq!(
|
||||||
|
ws("4, name:web, tiling:true"),
|
||||||
|
WorkspaceDecl {
|
||||||
|
index: 4,
|
||||||
|
name: Some("web".into()),
|
||||||
|
tiling: Some(true),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameters_are_order_independent_and_tolerate_spacing() {
|
||||||
|
assert_eq!(ws("2,tiling:off,name:mail"), ws("2, name:mail, tiling:off"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hyprland's `monitor:` is the parameter a user is most likely to reach
|
||||||
|
/// for, and the one COSMIC cannot honour. Accepting and ignoring it would
|
||||||
|
/// be the worst of the three options, so it must fail and say why.
|
||||||
|
#[test]
|
||||||
|
fn monitor_is_refused_with_the_reason_rather_than_silently_ignored() {
|
||||||
|
for spelling in ["monitor", "output"] {
|
||||||
|
let e = bad(&format!("1, {spelling}:eDP-1"));
|
||||||
|
assert!(
|
||||||
|
e.message.contains("no COSMIC equivalent"),
|
||||||
|
"{spelling}: {}",
|
||||||
|
e.message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("EDID"),
|
||||||
|
"{spelling}: {:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_may_contain_spaces() {
|
||||||
|
assert_eq!(ws("1, name:web and mail").name.as_deref(), Some("web and mail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hyprland's named-workspace form has no COSMIC equivalent, and the failure
|
||||||
|
/// if it were guessed at would be workspaces in the wrong order.
|
||||||
|
#[test]
|
||||||
|
fn the_hyprland_name_first_form_is_refused_with_the_reason() {
|
||||||
|
let e = bad("name:web, tiling:true");
|
||||||
|
assert!(e.message.contains("starts with its index"), "{}", e.message);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("by position"),
|
||||||
|
"{:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_zero_does_not_exist() {
|
||||||
|
let e = bad("0");
|
||||||
|
assert!(e.message.contains("outside 1..="), "{}", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cap is the whole reason this check exists: without it the typo below
|
||||||
|
/// creates a thousand workspaces rather than reporting anything.
|
||||||
|
#[test]
|
||||||
|
fn an_absurd_index_is_a_diagnostic_not_a_thousand_workspaces() {
|
||||||
|
let e = bad("1000");
|
||||||
|
assert!(e.message.contains("outside 1..=32"), "{}", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_parameter_lists_the_known_ones() {
|
||||||
|
let e = bad("1, gapsin:0");
|
||||||
|
assert!(e.message.contains("gapsin"), "{}", e.message);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("name, tiling"),
|
||||||
|
"{:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_parameter_without_a_colon_is_rejected() {
|
||||||
|
let e = bad("1, web");
|
||||||
|
assert!(e.message.contains("key:value"), "{}", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiling_takes_the_same_boolean_spellings_as_the_rest_of_the_file() {
|
||||||
|
for v in ["true", "yes", "on", "1"] {
|
||||||
|
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(true), "{v}");
|
||||||
|
}
|
||||||
|
for v in ["false", "no", "off", "0"] {
|
||||||
|
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(false), "{v}");
|
||||||
|
}
|
||||||
|
assert!(bad("1, tiling:sometimes").message.contains("boolean"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the positional restore turns on: declaring only 4 must still
|
||||||
|
/// emit 1, 2 and 3, or the browser workspace comes back as workspace 1.
|
||||||
|
#[test]
|
||||||
|
fn declaring_one_high_index_materialises_every_workspace_below_it() {
|
||||||
|
let ron = render(&[ws("4, name:web")], false);
|
||||||
|
assert_eq!(ron.lines().filter(|l| l.contains("output:")).count(), 4, "{ron}");
|
||||||
|
|
||||||
|
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
|
||||||
|
assert!(lines[0].contains("name: None"), "{}", lines[0]);
|
||||||
|
assert!(lines[1].contains("name: None"), "{}", lines[1]);
|
||||||
|
assert!(lines[2].contains("name: None"), "{}", lines[2]);
|
||||||
|
assert!(lines[3].contains(r#"name: Some("web")"#), "{}", lines[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gaps_between_declarations_are_filled_in_order() {
|
||||||
|
let ron = render(&[ws("3, name:code"), ws("1, name:term")], false);
|
||||||
|
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert!(lines[0].contains(r#"name: Some("term")"#), "{}", lines[0]);
|
||||||
|
assert!(lines[1].contains("name: None"), "{}", lines[1]);
|
||||||
|
assert!(lines[2].contains(r#"name: Some("code")"#), "{}", lines[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_declarations_render_as_an_empty_list() {
|
||||||
|
assert_eq!(render(&[], false), "[\n]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workspace that did not mention tiling must not quietly contradict
|
||||||
|
/// `general:autotile`.
|
||||||
|
#[test]
|
||||||
|
fn unset_tiling_follows_the_session_default_both_ways() {
|
||||||
|
assert!(render(&[ws("1")], true).contains("tiling_enabled: true"));
|
||||||
|
assert!(render(&[ws("1")], false).contains("tiling_enabled: false"));
|
||||||
|
// ...and an explicit value still wins over it.
|
||||||
|
assert!(render(&[ws("1, tiling:false")], true).contains("tiling_enabled: false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not a placeholder: an unmatchable `OutputMatch` is how a workspace says
|
||||||
|
/// it has no output preference, which is the only thing cosmic.conf can
|
||||||
|
/// truthfully express. See MONITOR_HELP.
|
||||||
|
#[test]
|
||||||
|
fn the_output_match_is_always_empty() {
|
||||||
|
assert!(render(&[ws("1")], false).contains(r#"output: (name: "", edid: None)"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids have to survive a re-apply, or every `hyprcosmic-conf apply` would hand
|
||||||
|
/// the same workspaces new identities.
|
||||||
|
#[test]
|
||||||
|
fn ids_are_stable_across_runs_and_unique_per_index() {
|
||||||
|
assert_eq!(workspace_id(4), workspace_id(4));
|
||||||
|
let ids: BTreeSet<String> = (1..=MAX_INDEX).map(workspace_id).collect();
|
||||||
|
assert_eq!(ids.len(), MAX_INDEX as usize, "an index collided");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cosmic-comp's `random_workspace_id` is `{:x}` of a number below 2<<24,
|
||||||
|
/// so it is never more than seven hex digits. Ours are always eight, which
|
||||||
|
/// is what makes a collision impossible rather than unlikely.
|
||||||
|
#[test]
|
||||||
|
fn ids_cannot_collide_with_a_compositor_generated_one() {
|
||||||
|
for i in 1..=MAX_INDEX {
|
||||||
|
let id = workspace_id(i);
|
||||||
|
assert_eq!(id.len(), 8, "{id}");
|
||||||
|
assert!(
|
||||||
|
u32::from_str_radix(&id, 16).unwrap() >= 0x1000_0000,
|
||||||
|
"{id} is inside the compositor's own range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_in_a_name_cannot_break_out_of_the_ron() {
|
||||||
|
let ron = render(&[ws(r#"1, name:say "hi""#)], false);
|
||||||
|
assert!(ron.contains(r#"name: Some("say \"hi\"")"#), "{ron}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Adversarial checks on theme-archive extraction.
|
||||||
|
//!
|
||||||
|
//! These are deliberately independent of `assets.rs`'s own unit tests. Theme
|
||||||
|
//! tarballs are downloaded from third-party repositories and extracted into the
|
||||||
|
//! user's home directory, so "a test named `path_traversal_is_rejected` passes"
|
||||||
|
//! is not sufficient evidence — these assert on the *filesystem* afterwards,
|
||||||
|
//! proving nothing escaped rather than trusting a returned error.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use cosmic_conf::assets::Installer;
|
||||||
|
use flate2::write::GzEncoder;
|
||||||
|
use flate2::Compression;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Build a `.tar.gz` containing arbitrary entries, including hostile ones a
|
||||||
|
/// well-behaved archiver would refuse to produce.
|
||||||
|
fn hostile_tarball(path: &Path, entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) {
|
||||||
|
let file = fs::File::create(path).unwrap();
|
||||||
|
let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
|
||||||
|
|
||||||
|
for (name, kind, data, link_target) in entries {
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_entry_type(*kind);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_size(if link_target.is_some() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
data.len() as u64
|
||||||
|
});
|
||||||
|
|
||||||
|
// `append_data`/`set_path` reject `..` and absolute paths, so a hostile
|
||||||
|
// archive cannot be produced through the safe API. Write the raw name
|
||||||
|
// bytes into the GNU header directly — this is precisely what a
|
||||||
|
// malicious archiver does, and the only way to test the guard honestly.
|
||||||
|
write_raw_name(&mut header, name);
|
||||||
|
if let Some(target) = link_target {
|
||||||
|
write_raw_link(&mut header, target);
|
||||||
|
}
|
||||||
|
header.set_cksum();
|
||||||
|
|
||||||
|
builder.append(&header, *data).unwrap();
|
||||||
|
}
|
||||||
|
builder.into_inner().unwrap().finish().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrite the GNU header's `name` field with arbitrary bytes, bypassing the
|
||||||
|
/// validation `Header::set_path` performs.
|
||||||
|
fn write_raw_name(header: &mut tar::Header, name: &str) {
|
||||||
|
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
|
||||||
|
gnu.name = [0u8; 100];
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
assert!(bytes.len() < 100, "fixture name too long for a GNU header");
|
||||||
|
gnu.name[..bytes.len()].copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same, for the `linkname` field.
|
||||||
|
fn write_raw_link(header: &mut tar::Header, target: &str) {
|
||||||
|
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
|
||||||
|
gnu.linkname = [0u8; 100];
|
||||||
|
let bytes = target.as_bytes();
|
||||||
|
assert!(bytes.len() < 100, "fixture link target too long");
|
||||||
|
gnu.linkname[..bytes.len()].copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A theme directory just complete enough for `plan` to consider the archive.
|
||||||
|
fn theme_with_archive(
|
||||||
|
entries: &[(&str, tar::EntryType, &[u8], Option<&str>)],
|
||||||
|
) -> (TempDir, PathBuf, PathBuf) {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let theme_dir = tmp.path().join("Configs/.config/hyde/themes/Evil");
|
||||||
|
let source_dir = tmp.path().join("Source");
|
||||||
|
fs::create_dir_all(&theme_dir).unwrap();
|
||||||
|
fs::create_dir_all(&source_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
theme_dir.join("hypr.theme"),
|
||||||
|
"general {\n gaps_in = 3\n}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
hostile_tarball(&source_dir.join("Gtk_Evil.tar.gz"), entries);
|
||||||
|
(tmp, theme_dir, source_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything created outside the sandbox root is an escape.
|
||||||
|
fn assert_nothing_outside(canary: &Path) {
|
||||||
|
assert!(
|
||||||
|
!canary.exists(),
|
||||||
|
"archive extraction escaped its destination and wrote {}",
|
||||||
|
canary.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_dir_traversal_never_writes_outside_destination() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||||
|
"../../../../../../tmp/cosmic_conf_escape_canary",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"pwned",
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true);
|
||||||
|
|
||||||
|
// Whether it is rejected at plan time or apply time, the invariant is the
|
||||||
|
// same: nothing lands outside the destination.
|
||||||
|
if let Ok(plan) = result {
|
||||||
|
let _ = installer.apply(&plan);
|
||||||
|
}
|
||||||
|
assert_nothing_outside(Path::new("/tmp/cosmic_conf_escape_canary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_path_entry_never_writes_outside_destination() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||||
|
"/tmp/cosmic_conf_abs_canary",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"pwned",
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
|
||||||
|
let _ = installer.apply(&plan);
|
||||||
|
}
|
||||||
|
assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subtle one: neither entry path contains `..`, so a naive check passes.
|
||||||
|
/// The symlink redirects a later, innocent-looking write outside the tree.
|
||||||
|
#[test]
|
||||||
|
fn symlink_indirection_never_writes_outside_destination() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[
|
||||||
|
("escape", tar::EntryType::Symlink, b"", Some("/tmp")),
|
||||||
|
(
|
||||||
|
"escape/cosmic_conf_symlink_canary",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"pwned",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
|
||||||
|
let _ = installer.apply(&plan);
|
||||||
|
}
|
||||||
|
assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A benign archive must still install, or the guard is uselessly strict.
|
||||||
|
#[test]
|
||||||
|
fn well_formed_archive_still_installs() {
|
||||||
|
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||||
|
"Evil-Theme/index.theme",
|
||||||
|
tar::EntryType::Regular,
|
||||||
|
b"[Desktop Entry]\n",
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
let data = home.join(".local/share");
|
||||||
|
let installer = Installer::with_paths(&data, &home);
|
||||||
|
|
||||||
|
let plan = installer
|
||||||
|
.plan(&theme_dir, Some(&source_dir), "Evil", None, true)
|
||||||
|
.expect("a well-formed archive must plan cleanly");
|
||||||
|
installer.apply(&plan).expect("and must apply");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
home.join(".themes/Evil-Theme/index.theme").exists(),
|
||||||
|
"benign archive did not install; guard is too strict"
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
Submodule cosmic-edit updated: 4f791088ef...4ac0da3af9
+1
-1
Submodule cosmic-files updated: 6d3abf431c...24e34eaa0f
+1
-1
Submodule cosmic-greeter updated: 4d74121ac1...d39915ae23
+1
-1
Submodule cosmic-icons updated: cd30ffd509...b78b059636
+1
-1
Submodule cosmic-idle updated: 983d34ad96...c95d066b5b
+1
-1
Submodule cosmic-initial-setup updated: d467d9b26c...b5ac4182bb
+1
-1
Submodule cosmic-launcher updated: bb0ed1c584...8799503120
Submodule
+1
Submodule cosmic-monitor added at 70e6cff168
+1
-1
Submodule cosmic-notifications updated: 98bf8c5987...7c723b7705
+1
-1
Submodule cosmic-osd updated: 5f35cebe8a...20a2055dfc
+1
-1
Submodule cosmic-panel updated: 8eb8a1b630...d6699ffc42
+1
-1
Submodule cosmic-player updated: 8251827c9c...23d59445af
+1
-1
Submodule cosmic-randr updated: 741089cf5e...6e8e795970
+1
-1
Submodule cosmic-screenshot updated: f04ad27177...fc778df20f
+1
-1
Submodule cosmic-session updated: 1dee7d1a94...75267c5977
+1
-1
Submodule cosmic-settings updated: 079a1fbb55...7287257ec9
+1
-1
Submodule cosmic-settings-daemon updated: 9111bf0dfc...21a9692b53
Submodule
+1
Submodule cosmic-sound-theme added at 7aabe44909
+1
-1
Submodule cosmic-store updated: 26a0f35f6a...f56cb48aa1
+1
-1
Submodule cosmic-term updated: 6a1151c830...543cbb0cb8
+1
-1
Submodule cosmic-wallpapers updated: 189c2c63d3...3c59953e7e
+1
-1
Submodule cosmic-workspaces-epoch updated: 43ef945a92...8faab4c2a9
@@ -0,0 +1,334 @@
|
|||||||
|
# HyprCosmic — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-09
|
||||||
|
**Status:** Approved for implementation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Run a HyDE-style desktop on COSMIC's compositor: HyDE themes apply end to end — palette,
|
||||||
|
wallpaper, gaps, rounding, bar, launcher, notifications — with all configuration driven from a
|
||||||
|
single commented, version-controllable text file in Hyprland's idiom.
|
||||||
|
|
||||||
|
The user reviewed the cost of the full-rice target and chose it explicitly over the cheaper
|
||||||
|
palette-only option.
|
||||||
|
|
||||||
|
### What this actually is
|
||||||
|
|
||||||
|
HyprCosmic is **HyDE with cosmic-comp as the compositor**, not COSMIC restyled to look like HyDE.
|
||||||
|
COSMIC's own shell surface — cosmic-panel applets, the workspace overview, and cosmic-settings'
|
||||||
|
appearance controls — is replaced, not themed. This framing is the honest description and should
|
||||||
|
appear in the project README.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Bit-compatibility with Hyprland's config parser. Syntax is familiar; an existing
|
||||||
|
`hyprland.conf` will not work, because COSMIC's key names and concepts differ throughout.
|
||||||
|
- Preserving cosmic-settings as a working appearance editor. Configuration is one-way: the file
|
||||||
|
wins, and GUI edits are overwritten on next apply.
|
||||||
|
- Gradient window borders. COSMIC's `active_hint` is a solid hint with no gradient support, and
|
||||||
|
adding one is out of scope.
|
||||||
|
- Matching Hyprland's window-management semantics (dwindle/master layouts). cosmic-comp's BSP
|
||||||
|
tiler stays.
|
||||||
|
|
||||||
|
## Verified findings
|
||||||
|
|
||||||
|
Everything below was read from the tree at `/home/dingo/cosmic-epoch`, not assumed.
|
||||||
|
|
||||||
|
| Finding | Evidence |
|
||||||
|
|---|---|
|
||||||
|
| cosmic-comp is GPL-3.0-only; forking is permitted | `cosmic-comp/src/lib.rs:7` |
|
||||||
|
| `COSMIC_SESSION_SOCK` is optional — cosmic-comp runs standalone | `cosmic-comp/src/session.rs:76,90` |
|
||||||
|
| wlr-layer-shell is implemented (foreign bars can render) | `cosmic-comp/src/wayland/handlers/layer_shell.rs` |
|
||||||
|
| ext-session-lock is implemented | `cosmic-comp/src/wayland/handlers/session_lock.rs` |
|
||||||
|
| ext-workspace-v1 is implemented, plus a cosmic v2 extension | `cosmic-comp/src/wayland/protocols/workspace/ext.rs` |
|
||||||
|
| `zwlr_foreign_toplevel_management_v1` is **absent** — only ext-foreign-toplevel-list exists | grep across `cosmic-comp/src/`; `handlers/foreign_toplevel_list.rs` |
|
||||||
|
| Gaps are real and theme-driven, `(u32, u32)` | `layout/tiling/mod.rs:4305`, `layout/floating/mod.rs:1689` |
|
||||||
|
| Blur exists but is **client-requested** via `ext-background-effect`, not compositor rule | `handlers/background_effect.rs`, `backend/render/wayland/blur_effect.rs`, `shaders/blur_{downsample,upsample}.frag` |
|
||||||
|
| Shadow and rounded-corner shaders exist | `backend/render/shaders/{shadow,rounded_rectangle,rounded_outline}.frag` |
|
||||||
|
| Animation engine exists; durations hardcoded | `src/lib.rs:197,209`; `shell/workspace.rs:75` |
|
||||||
|
| Window rules cover **tiling exceptions only** | `cosmic-settings-daemon/config/src/window_rules/mod.rs:41` |
|
||||||
|
| Compositor config surface is ~20 flat fields | `cosmic-comp/cosmic-comp-config/src/lib.rs:71-105` |
|
||||||
|
| cosmic-config is a filesystem KV store, one file per key, sparse (only changed keys materialise) | `~/.config/cosmic/`, 136 files across ~25 components |
|
||||||
|
| cosmic-config live-reloads via inotify | `cosmic-comp/src/config/mod.rs:173,219,251` |
|
||||||
|
| cosmic-panel has **zero** CSS/stylesheet support; renders via iced | grep across `cosmic-panel/` |
|
||||||
|
| Upstream velocity: 46 commits in 30 days | `git log --since="30 days ago"` in cosmic-comp |
|
||||||
|
|
||||||
|
### What a HyDE theme actually contains
|
||||||
|
|
||||||
|
Measured from `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha` (25 files):
|
||||||
|
|
||||||
|
| File | Size | Contents |
|
||||||
|
|---|---|---|
|
||||||
|
| `hypr.theme` | 1,316 B | gaps 3/8, `rounding 10`, `border_size 2`, gradient borders, `blur {size 6, passes 3}`, GTK/icon theme names |
|
||||||
|
| `waybar.theme` | 358 B | 7 `@define-color` lines — **not** a stylesheet |
|
||||||
|
| `rofi.theme` | 320 B | ~7 colour variables |
|
||||||
|
| `kitty.theme` | 1,536 B | palette |
|
||||||
|
| GTK + icon tarballs | 4.6 MB | standard themes |
|
||||||
|
| wallpapers | ~95 MB | the bulk |
|
||||||
|
|
||||||
|
The theme is ~3.5 KB of text. The bespoke widget styling belongs to HyDE itself, not to any
|
||||||
|
individual theme — which is why running HyDE's own bar and launcher is the shortest path to
|
||||||
|
fidelity.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three repositories. Upstream components not listed are consumed unmodified.
|
||||||
|
|
||||||
|
| Repo | Kind | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `hyprcosmic/cosmic-comp` | Fork (GPL-3.0) | Protocol patches, then blur/animation config |
|
||||||
|
| `hyprcosmic/cosmic-conf` | New (GPL-3.0) | Config compiler + theme importer |
|
||||||
|
| `hyprcosmic/hyprcosmic` | New meta | Submodule pins, session definition, docs |
|
||||||
|
|
||||||
|
Runtime composition:
|
||||||
|
|
||||||
|
| Layer | Component | Modified? |
|
||||||
|
|---|---|---|
|
||||||
|
| Compositor | `hyprcosmic/cosmic-comp` | Yes — patches A, B, then polish |
|
||||||
|
| Bar | waybar (upstream, MIT) | No — consumes HyDE config + CSS directly |
|
||||||
|
| Launcher | rofi (upstream) | No — HyDE `.rasi` works |
|
||||||
|
| Notifications | swaync (upstream) | No |
|
||||||
|
| Wallpaper | swww (upstream) | No — matches HyDE; `CosmicBackground` unused |
|
||||||
|
| Session | forked cosmic-session | Yes — gate `start_component` calls |
|
||||||
|
| Config | `cosmic-conf` | New |
|
||||||
|
|
||||||
|
## Phase 1 — `cosmic-conf`
|
||||||
|
|
||||||
|
A Rust binary that compiles one text file into the cosmic-config tree. It is a compiler, not a
|
||||||
|
daemon owning state: COSMIC components keep reading cosmic-config and keep live-reloading through
|
||||||
|
their existing `ConfigWatchSource`. Nothing in COSMIC learns about `cosmic.conf`.
|
||||||
|
|
||||||
|
### Units
|
||||||
|
|
||||||
|
| Unit | Responsibility | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| `parser` | text → AST with byte spans. Sections, `$variables`, `source=`, `#` comments | — |
|
||||||
|
| `schema` | Declarative registry: conf key → cosmic-config target + type + validator + doc | — |
|
||||||
|
| `resolve` | AST + schema → typed values. Variable expansion, type/range checking, diagnostics | `parser`, `schema` |
|
||||||
|
| `emit` | Typed values → cosmic-config writes | `resolve`, `cosmic-config` |
|
||||||
|
| `watch` | inotify on the conf file and its includes → re-run pipeline | all |
|
||||||
|
|
||||||
|
`parser`, `schema` and `resolve` are pure and touch nothing COSMIC-specific, so the hard logic is
|
||||||
|
unit-testable without a compositor running. Only `emit` binds to `cosmic-config`, and it is the
|
||||||
|
only unit Phase 2 modifies when new keys land.
|
||||||
|
|
||||||
|
Entry points: `cosmic-conf apply` (one-shot, non-zero exit on error), `cosmic-conf watch`,
|
||||||
|
`cosmic-conf apply --diff` (show what would be overwritten).
|
||||||
|
|
||||||
|
### File format
|
||||||
|
|
||||||
|
Hyprland-style syntax, hand-written recursive-descent parser (~500 lines). Chosen over KDL and
|
||||||
|
TOML because the authoring experience is the product requirement; a better-engineered format that
|
||||||
|
feels wrong fails the goal.
|
||||||
|
|
||||||
|
```
|
||||||
|
$accent = rgb(6b9fed)
|
||||||
|
$gap = 8
|
||||||
|
|
||||||
|
general {
|
||||||
|
gaps_in = $gap
|
||||||
|
gaps_out = $gap * 2
|
||||||
|
autotile = true
|
||||||
|
active_hint = true
|
||||||
|
}
|
||||||
|
|
||||||
|
decoration {
|
||||||
|
rounding = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
theme {
|
||||||
|
mode = dark
|
||||||
|
accent = $accent
|
||||||
|
}
|
||||||
|
|
||||||
|
bind = SUPER, Return, spawn, kitty
|
||||||
|
bind = SUPER, Q, close
|
||||||
|
|
||||||
|
source = ~/.config/hyprcosmic/monitors.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema registry
|
||||||
|
|
||||||
|
The mapping is not 1:1. Some conf keys own a whole cosmic-config value; others own one field
|
||||||
|
inside a composite RON value (`decoration.rounding` targets one radius among six in
|
||||||
|
`corner_radii`; `gaps_in`/`gaps_out` are two halves of one `(u32, u32)`).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
enum Target {
|
||||||
|
Direct { component: &'static str, version: u8, key: &'static str },
|
||||||
|
Projected { component: &'static str, version: u8, key: &'static str,
|
||||||
|
path: &'static [&'static str] },
|
||||||
|
}
|
||||||
|
|
||||||
|
Entry {
|
||||||
|
conf: "general.gaps_in",
|
||||||
|
// Fan-out: Dark and Light are separate cosmic-config components
|
||||||
|
targets: &[
|
||||||
|
Projected { component: "com.system76.CosmicTheme.Dark.Builder", version: 1,
|
||||||
|
key: "gaps", path: &["1"] },
|
||||||
|
Projected { component: "com.system76.CosmicTheme.Light.Builder", version: 1,
|
||||||
|
key: "gaps", path: &["1"] },
|
||||||
|
],
|
||||||
|
ty: Ty::U32,
|
||||||
|
validate: Some(range(0..=128)),
|
||||||
|
doc: "Gap between adjacent tiled windows, in px",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Spike-corrected facts** (verified in `vendor/libcosmic`):
|
||||||
|
|
||||||
|
- `gaps: (u32, u32)` lives on `ThemeBuilder` (`cosmic-theme/src/model/theme.rs:895`), **not** `CosmicTk`.
|
||||||
|
Component IDs at `theme.rs:17-26`. Default `(0, 8)`.
|
||||||
|
- Tuple order is **`(outer, inner)`** — so `gaps_out` is index `0` and `gaps_in` is index `1`.
|
||||||
|
- Dark and Light Builders are **separate components**, so one conf key fans out to two targets.
|
||||||
|
`Entry` therefore carries `targets: &[Target]`, not a single target.
|
||||||
|
- `CosmicTk` (`libcosmic/src/config/mod.rs:14`, ID `com.system76.CosmicTk`) holds
|
||||||
|
`icon_theme`, `interface_font`, `monospace_font`, `header_size`, `interface_density`,
|
||||||
|
`show_minimize`, `show_maximize`, `apply_theme_global` — `icon_theme` is needed by the HyDE
|
||||||
|
importer, which sets `$ICON_THEME`.
|
||||||
|
|
||||||
|
**`emit` writes through the typed `cosmic-config` API, not raw files.** `Config::watch`
|
||||||
|
(`cosmic-config/src/lib.rs:377`) is a `notify` inotify watch on the config directory that derives
|
||||||
|
changed keys from file paths, so raw writes would in fact be observed — but `Config::set` gives
|
||||||
|
correct RON encoding per type, atomic writes via `atomicwrites::AtomicFile` (`lib.rs:513`), and
|
||||||
|
matches the watcher's `.atomicwrite` temp-file filter (`lib.rs:408`). cosmic-conf therefore
|
||||||
|
depends on `cosmic-theme`, `cosmic-comp-config` and `cosmic-settings-config` for the concrete
|
||||||
|
types, which also buys compile-time type checking of the registry.
|
||||||
|
|
||||||
|
**Critical correctness property:** projected writes are read-modify-write, and multiple conf keys
|
||||||
|
can share one target. `emit` MUST group by target key, fold all projections, then write once.
|
||||||
|
Naïve per-key writes let `gaps_out` clobber `gaps_in`. This is directly unit-testable and is the
|
||||||
|
highest-value test in the suite.
|
||||||
|
|
||||||
|
`doc` generates `cosmic.conf.default`, so the annotated reference file cannot drift from the
|
||||||
|
schema.
|
||||||
|
|
||||||
|
### Phase 1 scope
|
||||||
|
|
||||||
|
| Section | Targets | Confidence |
|
||||||
|
|---|---|---|
|
||||||
|
| `general` | `CosmicComp`: autotile, active_hint, focus_follows_cursor(+delay), cursor_follows_focus, edge_snap_threshold, cursor_hide_timeout | Verified |
|
||||||
|
| `workspace` | `CosmicComp/workspaces`: mode, layout, wraparound, action_on_typing | Verified |
|
||||||
|
| `input` | `CosmicComp`: xkb_config, input_default, input_touchpad | Verified |
|
||||||
|
| `bind` | `CosmicSettings.Shortcuts/custom`, incl. `Spawn(String)` | Verified |
|
||||||
|
| `windowrule` | `WindowRules`: tiling exceptions only | Verified, deliberately thin |
|
||||||
|
| `theme` | `CosmicTheme.Mode/is_dark`, `.Builder/{palette,corner_radii,spacing}` | **Unverified** |
|
||||||
|
| `decoration` | `corner_radii`, `gaps` | **Unverified** |
|
||||||
|
|
||||||
|
### Spikes (must complete before schema work)
|
||||||
|
|
||||||
|
1. **Fetch libcosmic and enumerate `cosmic-theme` and `CosmicTk`.** `gaps` was inferred from its
|
||||||
|
use site (`theme.cosmic().gaps`); the struct has not been read. If `gaps` is derived rather
|
||||||
|
than stored, that row moves to Phase 2 and needs a compositor patch.
|
||||||
|
2. **Determine whether direct RON file writes trigger `ConfigWatchSource`,** or whether `emit`
|
||||||
|
must go through the typed `cosmic-config` API. Decides `emit`'s implementation.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
The pipeline is transactional: `resolve` fully validates before `emit` writes anything. A
|
||||||
|
malformed file leaves the desktop untouched rather than half-applied. Diagnostics report against
|
||||||
|
source text with spans:
|
||||||
|
|
||||||
|
```
|
||||||
|
error: unknown key `gaps_inn` in section `general`
|
||||||
|
--> cosmic.conf:7:5
|
||||||
|
|
|
||||||
|
7 | gaps_inn = 8
|
||||||
|
| ^^^^^^^^ did you mean `gaps_in`?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2 — cosmic-comp patches
|
||||||
|
|
||||||
|
Ordered by ascending risk. Each is independently shippable. Patches A and B are additive new
|
||||||
|
files that never touch `shell/layout/tiling/mod.rs` — a 235 KB file that is the most painful
|
||||||
|
thing in the tree to carry patches against.
|
||||||
|
|
||||||
|
### Patch A — `zwlr_foreign_toplevel_management_v1`
|
||||||
|
|
||||||
|
New protocol handler alongside `toplevel_info.rs` / `toplevel_management.rs`, which already hold
|
||||||
|
the required state. Unlocks waybar's `wlr/taskbar`. Plausibly upstreamable. **Rebase risk: low.**
|
||||||
|
|
||||||
|
### Patch B — Hyprland-compatible IPC socket
|
||||||
|
|
||||||
|
Implement a subset of Hyprland's IPC at
|
||||||
|
`$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock` (request/response) and `.socket2.sock`
|
||||||
|
(event stream).
|
||||||
|
|
||||||
|
- Requests: `workspaces`, `activeworkspace`, `activewindow`, `clients`, `monitors`
|
||||||
|
- Events: `workspace>>`, `activewindow>>`, `openwindow>>`, `closewindow>>`
|
||||||
|
|
||||||
|
HyDE's `hyprland/workspaces` and `hyprland/window` waybar modules then work unmodified, because
|
||||||
|
waybar cannot tell the difference. Also delivers the `hyprctl`-style IPC from the original
|
||||||
|
wishlist. New file, no entanglement with the layout engine. **Rebase risk: low.**
|
||||||
|
|
||||||
|
### Polish patches
|
||||||
|
|
||||||
|
| Patch | Where | Effort | Rebase risk |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Animation curves + durations | `shell/`, config struct | Medium | Low — replaces consts with config lookups |
|
||||||
|
| Opacity + shadow config | `backend/render/`, `shadow.frag` | Medium | Low — shader exists, needs uniforms |
|
||||||
|
| Per-monitor/workspace gaps | both layout modules | Low | Low |
|
||||||
|
| Compositor-driven blur rules | `backend/render/wayland/blur_effect.rs` | High | Medium — inverts client-request model |
|
||||||
|
| Real window rules | `shell/layout/tiling/mod.rs` | High | **High** — do last |
|
||||||
|
|
||||||
|
## Phase 3 — Session and theme importer
|
||||||
|
|
||||||
|
### Session
|
||||||
|
|
||||||
|
Fork cosmic-session and gate the hardcoded `start_component` calls (cosmic-panel,
|
||||||
|
cosmic-launcher, cosmic-app-library, cosmic-osd, cosmic-workspaces) behind config. Forking is
|
||||||
|
preferred over skipping cosmic-session entirely, because cosmic-session also propagates the
|
||||||
|
compositor environment to systemd/D-Bus and pulls up `graphical-session.target`; without it,
|
||||||
|
portals and D-Bus-activated apps break.
|
||||||
|
|
||||||
|
cosmic-greeter is retained. Display-manager changes are the easiest way to lose access to a
|
||||||
|
machine.
|
||||||
|
|
||||||
|
Ships a `hyprcosmic.desktop` session entry **alongside** the existing COSMIC session, so the
|
||||||
|
working desktop remains selectable at login throughout development.
|
||||||
|
|
||||||
|
### Theme importer
|
||||||
|
|
||||||
|
`cosmic-conf import-theme <path-or-hyde-branch>` — one-way into `cosmic.conf`, not straight into
|
||||||
|
cosmic-config, so the result is readable and editable.
|
||||||
|
|
||||||
|
1. Parse `hypr.theme` with the Phase 1 parser (same grammar — this is where the syntax choice pays off)
|
||||||
|
2. Map recognised keys through a translation table into HyprCosmic conf keys
|
||||||
|
3. Extract the palette; derive COSMIC's palette from border/accent colours via the Builder's
|
||||||
|
tinting inputs (`neutral_tint`, `accent`, `bg_color`)
|
||||||
|
4. Install GTK/icon tarballs, register wallpapers
|
||||||
|
5. Copy `waybar.theme`, `rofi.theme`, `kitty.theme` to their upstream destinations unmodified
|
||||||
|
6. **Emit an explicit unsupported-keys report** rather than silently dropping — e.g.
|
||||||
|
`col.active_border: gradient not supported (COSMIC active_hint is solid)`
|
||||||
|
|
||||||
|
The report is the honesty mechanism that keeps partial import from feeling broken.
|
||||||
|
|
||||||
|
## Rejected alternatives
|
||||||
|
|
||||||
|
| Alternative | Why rejected |
|
||||||
|
|---|---|
|
||||||
|
| **caffyne-shell as the shell** | Python/GTK3 (93% Python), **no license file** (all rights reserved), 10 weeks old at evaluation. Protocol prerequisites were verified present in cosmic-comp, so this remains technically viable if the license is resolved. |
|
||||||
|
| **CSS theming inside cosmic-panel** | Requires building a CSS cascade for a retained-mode iced UI. Large new subsystem, and the result still would not consume HyDE's `style.css` verbatim. |
|
||||||
|
| **Teach cosmic-comp to read `cosmic.conf` natively** | The config surface spans ~25 components; a file parsed inside the compositor could only configure the compositor. Also the largest fork and breaks cosmic-settings outright. |
|
||||||
|
| **Bidirectional config sync** | Round-tripping a commented file through a KV store reliably is hard; failure mode is silently mangling the user's file. |
|
||||||
|
| **Patch cosmic-comp before building the config layer** | Nothing usable until late, and the fork would be driven by 136 individual files in the meantime. |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Upstream velocity (46 commits/30 days) makes rebasing costly | Keep patches additive and in new files; defer window rules; upstream Patch A if accepted |
|
||||||
|
| Theme/decoration schema rows are unverified | Spike 1 gates schema work; rows move to Phase 2 if `gaps` proves derived |
|
||||||
|
| Losing COSMIC's shell removes the appearance GUI | Accepted and documented; `--diff` makes one-way overwrites visible |
|
||||||
|
| Compositor work is not verifiable without a real session | Phase 1 is fully testable headless; Phases 2–3 need a nested or TTY session |
|
||||||
|
| Naming leans on two projects' marks | Personal fork is fine; rename before any wide distribution |
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
1. `cosmic-conf apply` compiles a `cosmic.conf` into cosmic-config and COSMIC live-reloads it.
|
||||||
|
2. A malformed conf produces a spanned diagnostic and writes nothing.
|
||||||
|
3. `gaps_in` and `gaps_out` both land — proving projection folding works.
|
||||||
|
4. waybar runs on cosmic-comp showing workspaces and active window via Patch B, unmodified HyDE config.
|
||||||
|
5. `import-theme Catppuccin-Mocha` yields a matching palette, wallpaper, rounding and gaps, plus
|
||||||
|
an accurate unsupported-keys report.
|
||||||
|
6. `hyprcosmic.desktop` is selectable at login and the stock COSMIC session still works.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# A session that came up with no input, once, on 2026-08-10
|
||||||
|
|
||||||
|
**Status: not root-caused. Not reproduced since. Closed deliberately, not fixed.**
|
||||||
|
|
||||||
|
This is written down because the next person to hit it -- probably us -- will
|
||||||
|
otherwise start the same investigation from scratch, and because most of the
|
||||||
|
value here is the list of things it is *not*.
|
||||||
|
|
||||||
|
## What happened
|
||||||
|
|
||||||
|
One HyprCosmic session came up with a working bar and a blank desktop below it,
|
||||||
|
and no keyboard or pointer input reached the compositor at all. No binding
|
||||||
|
fired. The session had to be left via a VT switch.
|
||||||
|
|
||||||
|
After a reboot, the same configuration and the same binaries came up fine.
|
||||||
|
Super+Return, Super+A and a bare Super tap were all confirmed working by the
|
||||||
|
user, with independent evidence from a watcher process:
|
||||||
|
|
||||||
|
```
|
||||||
|
12:07:48 SPAWN pid=6264 exe=/usr/bin/rofi cmd=rofi -show drun
|
||||||
|
12:07:50 SPAWN pid=6433 exe=/usr/bin/rofi cmd=rofi -show drun
|
||||||
|
12:07:51 EVENT activewindow>>com.system76.CosmicTerm,dingo@fedora:~ - COSMIC Terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Super+Return shows as a window event rather than a spawn because cosmic-term is
|
||||||
|
single-instance: the binding fired, the existing process took the request.
|
||||||
|
|
||||||
|
## Ruled out, with the evidence
|
||||||
|
|
||||||
|
Each of these was a live hypothesis that turned out to be wrong. They are listed
|
||||||
|
so nobody re-runs them.
|
||||||
|
|
||||||
|
- **The fork's own patches.** The blank screen was a separate bug entirely (a
|
||||||
|
missing `awww img` call in autostart -- the daemon was running and drawing
|
||||||
|
nothing, so nothing reported a wallpaper missing). Patch B's IPC answered
|
||||||
|
queries correctly throughout. Neither patch touches input.
|
||||||
|
|
||||||
|
- **Events dropped by `seats.for_device()` returning `None`.** This does drop
|
||||||
|
input silently (`src/input/mod.rs:208-215`), which made it an attractive
|
||||||
|
theory. But input demonstrably works on the same build, so whatever the fault
|
||||||
|
was, it was not a permanent property of this code.
|
||||||
|
|
||||||
|
- **The modifier-only Super binding swallowing Super+Return.** It cannot. The
|
||||||
|
match loop at `src/input/mod.rs:1915-1955` sets `modifiers_shortcut_queue` on
|
||||||
|
press and fires on release, and critically it *does not early-return*, so a
|
||||||
|
modifier-only binding cannot consume a normal binding sharing its modifier.
|
||||||
|
|
||||||
|
- **A shortcuts-config race at login.** The config's mtime was 08:49; the
|
||||||
|
session started at 09:59:46. Nothing was being written during startup.
|
||||||
|
|
||||||
|
- **`Error reading from session socket` and `Unable to become drm master`.**
|
||||||
|
Both appear in the log. Both also appear in stock COSMIC logins on this
|
||||||
|
machine, so neither is a fork symptom. (An earlier claim in this project that
|
||||||
|
the DRM message was absent post-reboot was wrong; it is present twice, for
|
||||||
|
PID 1609.)
|
||||||
|
|
||||||
|
## The one thing that is suspicious
|
||||||
|
|
||||||
|
The broken session was the **fourth** compositor start on that boot: 08:06 (from
|
||||||
|
`target/debug`), 08:08, 08:19, and 09:59. Every other session on that boot, and
|
||||||
|
every session since a reboot, has been fine.
|
||||||
|
|
||||||
|
That points at accumulated per-boot session/seat state -- a previous compositor
|
||||||
|
not having fully released its seat, or logind still holding devices for a
|
||||||
|
session that had gone away -- rather than at anything in the configuration or
|
||||||
|
the code. It is a guess. It was not confirmed, and confirming it would mean
|
||||||
|
deliberately cycling compositors on a live desktop.
|
||||||
|
|
||||||
|
## If it happens again
|
||||||
|
|
||||||
|
Collect *before* rebooting, because a reboot destroys the only evidence:
|
||||||
|
|
||||||
|
1. `loginctl list-sessions` and `loginctl session-status` for each -- look for
|
||||||
|
more than one active session, or a session in state `closing`.
|
||||||
|
2. `ls -l /dev/input/by-path/` and whether the compositor's PID holds any of
|
||||||
|
them open (`ls -l /proc/<pid>/fd | grep event`).
|
||||||
|
3. The session log with `RUST_LOG=cosmic_comp::input=trace`. The logger honours
|
||||||
|
`RUST_LOG` via `EnvFilter::try_from_default_env()` before adding its own
|
||||||
|
`cosmic_comp={warn|debug}` directives, and those are less specific, so the
|
||||||
|
trace directive wins.
|
||||||
|
4. Whether `libinput debug-events` (as root, on a VT) sees the devices at all.
|
||||||
|
That splits the fault cleanly: if libinput sees nothing, it is below the
|
||||||
|
compositor and nothing in this repo can be the cause.
|
||||||
|
|
||||||
|
Do **not** try to clean up by name-matching processes. `pkill cosmic-*` and
|
||||||
|
friends have twice killed this user's live desktop. Kill by a PID captured at
|
||||||
|
spawn, or a process group after `setsid`, and confirm with
|
||||||
|
`readlink /proc/<pid>/exe` first.
|
||||||
@@ -8,12 +8,17 @@ build:
|
|||||||
{{ just }} cosmic-applibrary/build-release
|
{{ just }} cosmic-applibrary/build-release
|
||||||
{{ just }} cosmic-bg/build-release
|
{{ just }} cosmic-bg/build-release
|
||||||
{{ make }} -C cosmic-comp all
|
{{ make }} -C cosmic-comp all
|
||||||
|
# cargo directly, not `just cosmic-conf/build-release`: cosmic-conf is not a
|
||||||
|
# submodule, it is a crate in this repository, and it has no Justfile of its
|
||||||
|
# own to delegate to.
|
||||||
|
cargo build --release --manifest-path cosmic-conf/Cargo.toml
|
||||||
{{ just }} cosmic-edit/build-release
|
{{ just }} cosmic-edit/build-release
|
||||||
{{ just }} cosmic-files/build-release
|
{{ just }} cosmic-files/build-release
|
||||||
{{ just }} cosmic-greeter/build-release
|
{{ just }} cosmic-greeter/build-release
|
||||||
{{ just }} cosmic-idle/build-release
|
{{ just }} cosmic-idle/build-release
|
||||||
{{ just }} cosmic-initial-setup/build-release
|
{{ just }} cosmic-initial-setup/build-release
|
||||||
{{ just }} cosmic-launcher/build-release
|
{{ just }} cosmic-launcher/build-release
|
||||||
|
{{ just }} cosmic-monitor/build-release
|
||||||
{{ just }} cosmic-notifications/build-release
|
{{ just }} cosmic-notifications/build-release
|
||||||
{{ just }} cosmic-osd/build-release
|
{{ just }} cosmic-osd/build-release
|
||||||
{{ just }} cosmic-panel/build-release
|
{{ just }} cosmic-panel/build-release
|
||||||
@@ -22,19 +27,31 @@ build:
|
|||||||
{{ just }} cosmic-screenshot/build-release
|
{{ just }} cosmic-screenshot/build-release
|
||||||
{{ just }} cosmic-settings/build-release
|
{{ just }} cosmic-settings/build-release
|
||||||
{{ make }} -C cosmic-settings-daemon all
|
{{ make }} -C cosmic-settings-daemon all
|
||||||
{{ just }} cosmic-session/all
|
{{ just }} cosmic-session/build-release
|
||||||
{{ just }} cosmic-store/build-release
|
{{ just }} cosmic-store/build-release
|
||||||
{{ just }} cosmic-term/build-release
|
{{ just }} cosmic-term/build-release
|
||||||
{{ make }} -C cosmic-wallpapers all
|
{{ make }} -C cosmic-wallpapers all
|
||||||
{{ make }} -C cosmic-workspaces-epoch all
|
{{ make }} -C cosmic-workspaces-epoch all
|
||||||
{{ just }} pop-launcher/build-release
|
{{ just }} pop-launcher/build-release
|
||||||
{{ make }} -C xdg-desktop-portal-cosmic all
|
# `just`, not `make`. Upstream cosmic-epoch still says
|
||||||
|
# `{{ make }} -C xdg-desktop-portal-cosmic all` here, and at the submodule
|
||||||
|
# commit both it and this fork pin (f211aa37, epoch-1.5.0) the portal has no
|
||||||
|
# Makefile at all -- it moved to a justfile and the meta-repository's recipe
|
||||||
|
# was never updated. `just build` upstream therefore fails on this line
|
||||||
|
# after compiling all 26 other components, which is presumably why it went
|
||||||
|
# unnoticed: distributions build COSMIC one component at a time and never
|
||||||
|
# take this path.
|
||||||
|
#
|
||||||
|
# `build` rather than `build-release`: the portal has no build-release
|
||||||
|
# recipe. Its `build` defaults to debug='0', which selects --release.
|
||||||
|
{{ just }} xdg-desktop-portal-cosmic/build
|
||||||
|
|
||||||
install rootdir="" prefix="/usr/local": build
|
install rootdir="" prefix="/usr/local": build
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applets/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applets/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applibrary/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applibrary/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-bg/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-bg/install
|
||||||
{{ make }} -C cosmic-comp install DESTDIR={{rootdir}} prefix={{prefix}}
|
{{ make }} -C cosmic-comp install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||||
|
install -Dm0755 cosmic-conf/target/release/cosmic-conf {{rootdir}}{{prefix}}/bin/cosmic-conf
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-edit/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-edit/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-files/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-files/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-greeter/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-greeter/install
|
||||||
@@ -42,6 +59,7 @@ install rootdir="" prefix="/usr/local": build
|
|||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-idle/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-idle/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-initial-setup/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-initial-setup/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-launcher/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-launcher/install
|
||||||
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-monitor/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-notifications/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-notifications/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-osd/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-osd/install
|
||||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-panel/install
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-panel/install
|
||||||
@@ -56,7 +74,19 @@ install rootdir="" prefix="/usr/local": build
|
|||||||
{{ make }} -C cosmic-wallpapers install DESTDIR={{rootdir}} prefix={{prefix}}
|
{{ make }} -C cosmic-wallpapers install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||||
{{ make }} -C cosmic-workspaces-epoch install DESTDIR={{rootdir}} prefix={{prefix}}
|
{{ make }} -C cosmic-workspaces-epoch install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||||
{{ just }} rootdir={{rootdir}} pop-launcher/install
|
{{ just }} rootdir={{rootdir}} pop-launcher/install
|
||||||
{{ make }} -C xdg-desktop-portal-cosmic install DESTDIR={{rootdir}} prefix={{prefix}}
|
# See the note in `build`: this is a justfile, not a Makefile, so the
|
||||||
|
# arguments are rootdir/prefix rather than DESTDIR/prefix.
|
||||||
|
{{ just }} rootdir={{rootdir}} prefix={{prefix}} xdg-desktop-portal-cosmic/install
|
||||||
|
# The waybar and rofi assets, and the power menu. Last, because it is the
|
||||||
|
# only step that prints a warning worth reading: several of these files name
|
||||||
|
# /usr/share/hyprcosmic as a literal -- a .rasi has no variables and the
|
||||||
|
# autostart file is not a shell -- so a prefix other than /usr installs them
|
||||||
|
# somewhere they will not be looked for. The script says which files.
|
||||||
|
#
|
||||||
|
# --no-session because cosmic-session/install above already placed
|
||||||
|
# start-hyprcosmic and hyprcosmic.desktop, and installing them twice would
|
||||||
|
# only make it unclear which recipe owns them.
|
||||||
|
PREFIX={{prefix}} DESTDIR={{rootdir}} ./tools/install-assets.sh --no-session
|
||||||
|
|
||||||
_mkdir dir:
|
_mkdir dir:
|
||||||
mkdir -p dir
|
mkdir -p dir
|
||||||
@@ -78,12 +108,14 @@ clean:
|
|||||||
rm -rf cosmic-applibrary/target
|
rm -rf cosmic-applibrary/target
|
||||||
rm -rf cosmic-bg/target
|
rm -rf cosmic-bg/target
|
||||||
rm -rf cosmic-comp/target
|
rm -rf cosmic-comp/target
|
||||||
|
rm -rf cosmic-conf/target
|
||||||
rm -rf cosmic-edit/target
|
rm -rf cosmic-edit/target
|
||||||
{{ just }} cosmic-files/clean
|
{{ just }} cosmic-files/clean
|
||||||
rm -rf cosmic-greeter/target
|
rm -rf cosmic-greeter/target
|
||||||
{{ just }} cosmic-idle/clean
|
{{ just }} cosmic-idle/clean
|
||||||
{{ just }} cosmic-initial-setup/clean
|
{{ just }} cosmic-initial-setup/clean
|
||||||
rm -rf cosmic-launcher/target
|
rm -rf cosmic-launcher/target
|
||||||
|
{{ just }} cosmic-monitor/clean
|
||||||
rm -rf cosmic-panel/target
|
rm -rf cosmic-panel/target
|
||||||
rm -rf cosmic-player/target
|
rm -rf cosmic-player/target
|
||||||
rm -rf cosmic-notifications/target
|
rm -rf cosmic-notifications/target
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# HyprCosmic, as one Arch package.
|
||||||
|
#
|
||||||
|
# There is no build() here, and no source array. This PKGBUILD packages a tree
|
||||||
|
# that `just install` has already staged, which tools/make-packages.sh points at
|
||||||
|
# through $HYPRCOSMIC_STAGEDIR. The reasoning is in packaging/fedora/hyprcosmic.spec
|
||||||
|
# and applies identically: building 27 Rust components a second time under
|
||||||
|
# makepkg, to produce bytes that already exist, costs hours and creates a way
|
||||||
|
# for the packaged desktop and the built one to drift apart.
|
||||||
|
#
|
||||||
|
# The consequence, same as there: this package is only valid on the Arch that
|
||||||
|
# built it. make-packages.sh builds it inside archlinux:base-devel so that is a
|
||||||
|
# current Arch rather than whatever the host is.
|
||||||
|
|
||||||
|
pkgname=hyprcosmic
|
||||||
|
pkgver=${HYPRCOSMIC_VERSION:-0.1.0}
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="COSMIC configured in Hyprland's idiom, with a HyDE shell"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/outbackdingo/hyprcosmic"
|
||||||
|
license=('GPL-3.0-only')
|
||||||
|
|
||||||
|
# cosmic-session is COSMIC itself, which this runs on rather than replaces: one
|
||||||
|
# entry pulls the whole desktop, and cosmic-settings, cosmic-osd, the portal and
|
||||||
|
# the rest are taken from the repositories at the version they were tested at.
|
||||||
|
#
|
||||||
|
# The rest is the HyDE shell. Without those the session starts to a blank
|
||||||
|
# screen: no bar, no launcher, no wallpaper.
|
||||||
|
depends=('cosmic-session' 'waybar' 'rofi-wayland' 'wayland' 'libxkbcommon'
|
||||||
|
'libinput' 'seatd' 'mesa' 'pixman' 'libdisplay-info' 'systemd-libs')
|
||||||
|
|
||||||
|
# awww is in the AUR rather than in the repositories, so it cannot be a hard
|
||||||
|
# depends without making the package uninstallable for anyone who has not built
|
||||||
|
# it. Without it there is no wallpaper, which is recoverable; an unsatisfiable
|
||||||
|
# dependency is not.
|
||||||
|
optdepends=('awww: wallpaper daemon, required for HyDE theme wallpapers'
|
||||||
|
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
|
||||||
|
'qt5ct: Qt application theming to match the GTK theme')
|
||||||
|
|
||||||
|
# No conflicts and no provides, deliberately. This installs beside COSMIC: its
|
||||||
|
# binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
|
||||||
|
# writes no path that cosmic-comp or cosmic-session owns. An earlier revision
|
||||||
|
# took the cosmic-* names and could not be installed over a COSMIC system
|
||||||
|
# without erasing the desktop it forked.
|
||||||
|
|
||||||
|
options=('!strip' '!debug')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
# Points at a tree `just install` has already staged. Failing loudly here
|
||||||
|
# beats producing an empty package, which is what a bare `cp -a "$unset/."`
|
||||||
|
# would do. The workflow sets it; by hand it is
|
||||||
|
# just install "$PWD/stage" /usr
|
||||||
|
# HYPRCOSMIC_STAGEDIR="$PWD/stage" makepkg --nodeps
|
||||||
|
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
|
||||||
|
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see .github/workflows/packages.yml" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
|
||||||
|
|
||||||
|
# No desktop-file-validate on the session entry. It rejects DesktopNames,
|
||||||
|
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
|
||||||
|
# Desktop Entry Specification registers keys for application launchers and
|
||||||
|
# this is a session file. The copy Fedora ships as cosmic-session-1.5.0-1.fc44
|
||||||
|
# fails the identical check -- so this is the validator's gap, not something
|
||||||
|
# the fork introduced. The workflow checks what actually matters instead --
|
||||||
|
# that Exec names a file this package installs -- against the staged tree,
|
||||||
|
# before makepkg sees it.
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
Package: hyprcosmic
|
||||||
|
Version: @VERSION@
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: dingo <[email protected]>
|
||||||
|
Section: x11
|
||||||
|
Priority: optional
|
||||||
|
Homepage: https://github.com/outbackdingo/hyprcosmic
|
||||||
|
Installed-Size: @INSTALLED_SIZE@
|
||||||
|
Depends: @SHLIB_DEPENDS@
|
||||||
|
Recommends: waybar, rofi
|
||||||
|
Suggests: fonts-hack-ttf, qt5ct
|
||||||
|
Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||||
|
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||||
|
Hyprland's idiom and wears a HyDE-style shell.
|
||||||
|
.
|
||||||
|
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||||
|
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
||||||
|
hyprcosmic-conf. The file is the source of truth: keys it names are applied at
|
||||||
|
every login over whatever the settings UI last stored, and keys it does not
|
||||||
|
name are left alone.
|
||||||
|
.
|
||||||
|
The shell is waybar, rofi and awww in place of cosmic-panel, cosmic-launcher
|
||||||
|
and cosmic-bg, and HyDE themes are imported directly. The compositor is
|
||||||
|
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
|
||||||
|
waybar's hyprland modules work unmodified.
|
||||||
|
.
|
||||||
|
This package carries the whole desktop, which on Debian it has to: COSMIC is
|
||||||
|
not packaged there, in any suite, so there is nothing to depend on and nothing
|
||||||
|
to install beside. The Fedora and Arch packages ship only this fork's three
|
||||||
|
binaries and take the rest from the distribution. Here the forked binaries are
|
||||||
|
hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, the components they
|
||||||
|
launch are included, and the session entry is the HyprCosmic one alone.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# HyprCosmic, as one RPM.
|
||||||
|
#
|
||||||
|
# WHY THIS REPACKS RATHER THAN REBUILDS
|
||||||
|
# -------------------------------------
|
||||||
|
# There is no %build here. The spec packages a tree that `just install` has
|
||||||
|
# already produced, which .github/workflows/packages.yml stages and passes in as
|
||||||
|
# --define "stagedir ...".
|
||||||
|
#
|
||||||
|
# The alternative -- a spec that runs `just build` itself under rpmbuild -- is
|
||||||
|
# the more orthodox shape and is wrong for this project. `just build` compiles
|
||||||
|
# 27 Rust components; doing it a second time inside rpmbuild to produce bytes
|
||||||
|
# identical to the ones already sitting in the tree costs hours and buys
|
||||||
|
# nothing. Worse, it would let the packaged desktop and the `just install`
|
||||||
|
# desktop drift apart, and the whole point of shipping a package is that the
|
||||||
|
# two are the same thing.
|
||||||
|
#
|
||||||
|
# The cost of this choice, stated plainly: the resulting RPM is only valid on
|
||||||
|
# the distribution it was built on. Nothing here is statically linked, so an RPM
|
||||||
|
# built on Fedora 44 assumes Fedora 44's glibc, wayland, libinput and mesa. Build
|
||||||
|
# it on the release you intend to install it on. The workflow does that by
|
||||||
|
# running the whole job inside a container of the target distribution.
|
||||||
|
#
|
||||||
|
# WHY IT IS ONE PACKAGE, AND WHY IT IS A SMALL ONE
|
||||||
|
# ------------------------------------------------
|
||||||
|
# Fedora splits COSMIC into a package per component, which is right for a
|
||||||
|
# distribution tracking upstream. This fork changes three of them -- the
|
||||||
|
# compositor, the session and the config compiler it adds -- and they are
|
||||||
|
# versioned and tested together, so one package is an accurate description of
|
||||||
|
# what is actually supported.
|
||||||
|
#
|
||||||
|
# It is not a package per component and it is not the whole desktop either. The
|
||||||
|
# build tree produces all of COSMIC, because it is COSMIC's tree, but shipping
|
||||||
|
# all of it would mean owning files that 25 distribution packages already own.
|
||||||
|
# The workflow reduces the staged tree to what this fork actually produces
|
||||||
|
# before any of the three packaging recipes see it.
|
||||||
|
|
||||||
|
%global _hardened_build 1
|
||||||
|
|
||||||
|
# Debuginfo extraction re-links every binary in the tree and would add an hour
|
||||||
|
# to a package whose binaries were built elsewhere anyway. There is nothing to
|
||||||
|
# strip usefully here.
|
||||||
|
%global debug_package %{nil}
|
||||||
|
|
||||||
|
Name: hyprcosmic
|
||||||
|
Version: %{?ver}%{!?ver:0.1.0}
|
||||||
|
Release: %{?rel}%{!?rel:1}%{?dist}
|
||||||
|
Summary: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||||
|
|
||||||
|
License: GPL-3.0-only
|
||||||
|
URL: https://github.com/outbackdingo/hyprcosmic
|
||||||
|
BuildArch: x86_64
|
||||||
|
|
||||||
|
# COSMIC itself, which this runs on rather than replaces.
|
||||||
|
#
|
||||||
|
# One line pulls the whole desktop, because cosmic-session requires every
|
||||||
|
# component. That is exactly what is wanted: HyprCosmic forks the compositor,
|
||||||
|
# the session and adds the config compiler, and takes cosmic-settings,
|
||||||
|
# cosmic-osd, the portal and the rest from the distribution at the version the
|
||||||
|
# distribution tested them at.
|
||||||
|
#
|
||||||
|
# There is deliberately no Conflicts and no Provides here. An earlier revision
|
||||||
|
# had both, on the reading that a fork of the desktop replaces the desktop, and
|
||||||
|
# it could not be installed: this package's files collided with 25 others in
|
||||||
|
# rpm's transaction check, and satisfying that by claiming all 25 with Conflicts
|
||||||
|
# would have erased cosmic-greeter, which on a stock Fedora COSMIC is the
|
||||||
|
# display manager. Installing beside COSMIC costs three renamed binaries and
|
||||||
|
# leaves the stock session on the greeter's menu to fall back to.
|
||||||
|
Requires: cosmic-session >= 1.5.0
|
||||||
|
|
||||||
|
# The HyDE shell. These are separate programs this fork drives rather than
|
||||||
|
# builds, and without them the session starts to a blank screen with no bar and
|
||||||
|
# no launcher.
|
||||||
|
Requires: waybar
|
||||||
|
Requires: rofi-wayland
|
||||||
|
|
||||||
|
# The wallpaper daemon. Recommends rather than Requires because it lives in the
|
||||||
|
# alebastr/sway-extras COPR rather than in Fedora proper, and a hard dependency
|
||||||
|
# that cannot resolve would make this package uninstallable on a machine that
|
||||||
|
# has not enabled that repository. Without it you get no wallpaper; with it and
|
||||||
|
# no theme imported, you also get no wallpaper. Both are recoverable; an
|
||||||
|
# unsatisfiable dependency is not.
|
||||||
|
Recommends: awww
|
||||||
|
|
||||||
|
# Something for the wallpaper daemon to draw before a theme has been imported.
|
||||||
|
# hyprcosmic-wallpaper looks here first when ~/.local/share/wallpapers has no
|
||||||
|
# `current` link yet, which on a machine that has just installed this package is
|
||||||
|
# always. Nothing in the dependency chain pulls it in otherwise -- neither
|
||||||
|
# cosmic-session nor cosmic-comp requires it -- and without it a first login is
|
||||||
|
# a bar on a black screen.
|
||||||
|
Recommends: cosmic-wallpapers
|
||||||
|
|
||||||
|
# Nerd Font glyphs are most of what the bar and the launcher draw, and Fedora
|
||||||
|
# has nothing that provides them. This line used to say `nerd-fonts`, which is
|
||||||
|
# not a package in any Fedora repository -- so it could never be satisfied, and
|
||||||
|
# the fonts never arrived, silently, because that is what Recommends does when
|
||||||
|
# it cannot resolve. The two patched fonts Fedora does ship are no help either:
|
||||||
|
# texlive-inconsolata-nerd-font lands under texmf-dist and kitty's
|
||||||
|
# SymbolsNerdFont under /usr/lib64/kitty, and fontconfig scans neither path.
|
||||||
|
#
|
||||||
|
# So recommend the fallback the configuration actually names. rules.rasi asks
|
||||||
|
# for "JetBrainsMono Nerd Font, Noto Sans Mono": this is the second of the two.
|
||||||
|
# It cannot draw a single glyph, but it decides what the text is set in instead
|
||||||
|
# of leaving that to whatever fontconfig happens to pick. A virtual provide
|
||||||
|
# rather than a name, because two packages carry it and either will do.
|
||||||
|
#
|
||||||
|
# The glyphs themselves have to be installed by hand. See README.md.
|
||||||
|
Recommends: font(notosansmono)
|
||||||
|
|
||||||
|
%description
|
||||||
|
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||||
|
Hyprland's idiom and wears a HyDE-style shell.
|
||||||
|
|
||||||
|
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||||
|
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
||||||
|
hyprcosmic-conf. The file is the source of truth: keys it names are applied at every
|
||||||
|
login over whatever the settings UI last stored, and keys it does not name are
|
||||||
|
left alone.
|
||||||
|
|
||||||
|
The shell is waybar, rofi and awww in place of cosmic-panel, cosmic-launcher and
|
||||||
|
cosmic-bg, and HyDE themes are imported directly. The compositor is COSMIC's,
|
||||||
|
with a Hyprland-compatible IPC socket so that HyDE's scripts and waybar's
|
||||||
|
hyprland modules work unmodified.
|
||||||
|
|
||||||
|
This package installs beside the distribution's COSMIC rather than over it. Its
|
||||||
|
binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
|
||||||
|
adds one session entry; the stock COSMIC entry stays on the greeter's menu,
|
||||||
|
served by the distribution's own binaries, so a session that will not start is
|
||||||
|
one logout away from a desktop that will.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
# Nothing to unpack. See the note at the top of this file.
|
||||||
|
|
||||||
|
%install
|
||||||
|
test -n "%{stagedir}" || { echo "define stagedir: see .github/workflows/packages.yml" >&2; exit 1; }
|
||||||
|
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
|
||||||
|
cp -a "%{stagedir}/." "%{buildroot}/"
|
||||||
|
|
||||||
|
# The session entry is checked by the workflow against the staged tree, not
|
||||||
|
# with desktop-file-validate here. desktop-file-validate rejects DesktopNames,
|
||||||
|
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
|
||||||
|
# Desktop Entry Specification registers keys for application launchers and this
|
||||||
|
# is a session file. The copy Fedora already ships as cosmic-session-1.5.0-1.fc44
|
||||||
|
# fails the identical check -- so this is the validator's gap, not something the
|
||||||
|
# fork introduced. Dropping the key would satisfy the validator and break the
|
||||||
|
# session.
|
||||||
|
# See "Check the session entries" in .github/workflows/packages.yml, which
|
||||||
|
# tests what actually matters: that Exec names a file this package installs.
|
||||||
|
|
||||||
|
# Generated by the workflow from the staged tree rather than written out here.
|
||||||
|
# A hand-maintained list across 27 components would be wrong within a week, and
|
||||||
|
# wrong in the direction that ships a package missing files nobody notices until
|
||||||
|
# a login fails.
|
||||||
|
%files -f %{filelist}
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Wed Aug 12 2026 dingo <[email protected]> - 0.1.1-1
|
||||||
|
- Seed ~/.config from /usr/share/hyprcosmic/skel at every session start, so a
|
||||||
|
machine that has never seen HyprCosmic logs into a configured desktop rather
|
||||||
|
than a bare compositor. Existing files are never overwritten.
|
||||||
|
- Recommend font(notosansmono) in place of nerd-fonts, which is not a package
|
||||||
|
in any Fedora repository and so could never have been installed.
|
||||||
|
- Recommend cosmic-wallpapers, and add hyprcosmic-wallpaper, which falls back
|
||||||
|
to it when no theme has been imported yet.
|
||||||
|
|
||||||
|
* Mon Aug 10 2026 dingo <[email protected]> - 0.1.0-1
|
||||||
|
- First package of the fork: hyprcosmic-comp, hyprcosmic-session and
|
||||||
|
hyprcosmic-conf installed beside the distribution's COSMIC, with a HyDE shell.
|
||||||
+1
-1
Submodule pop-launcher updated: eead361cca...a332a3a733
+7
-6
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
TAG="$1"
|
if [ -z "$1" ]
|
||||||
|
|
||||||
if [ -z "$TAG" ]
|
|
||||||
then
|
then
|
||||||
echo "$0 [tag]" >&2
|
echo "$0 [epoch version]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
VERSION="$1"
|
||||||
|
TAG="epoch-$1"
|
||||||
|
|
||||||
echo "Do you want to tag the current state of cosmic-epoch with the tag $TAG? (y/N)"
|
echo "Do you want to tag the current state of cosmic-epoch with the tag $TAG? (y/N)"
|
||||||
read answer
|
read answer
|
||||||
if [ "$answer" != "y" ]
|
if [ "$answer" != "y" ]
|
||||||
@@ -22,8 +23,8 @@ set -x
|
|||||||
|
|
||||||
git fetch --recurse-submodules
|
git fetch --recurse-submodules
|
||||||
|
|
||||||
git tag --force "$TAG"
|
git tag --force --annotate "$TAG" --message "Epoch ${VERSION}"
|
||||||
git submodule foreach git tag --force "$TAG"
|
git submodule foreach git tag --force --annotate "$TAG" --message "Epoch ${VERSION}"
|
||||||
|
|
||||||
git push --force origin tag "$TAG"
|
git push --force origin tag "$TAG"
|
||||||
git submodule foreach git push --force origin tag "$TAG"
|
git submodule foreach git push --force origin tag "$TAG"
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# This should be the _next_ epoch version
|
||||||
|
version=1.5.0
|
||||||
|
subject="Epoch ${version} version update"
|
||||||
|
description="Generated by cosmic-epoch scripts/version-update.sh"
|
||||||
|
|
||||||
|
repos=(
|
||||||
|
cosmic-edit
|
||||||
|
cosmic-files
|
||||||
|
cosmic-monitor
|
||||||
|
cosmic-player
|
||||||
|
cosmic-store
|
||||||
|
cosmic-term
|
||||||
|
)
|
||||||
|
|
||||||
|
for repo in "${repos[@]}"
|
||||||
|
do
|
||||||
|
git submodule update --recursive "${repo}"
|
||||||
|
pushd "${repo}"
|
||||||
|
git fetch origin
|
||||||
|
git checkout -B epoch-update origin/master
|
||||||
|
git reset --hard
|
||||||
|
if [ "$(dpkg-parsechangelog --show-field Version)" != "${version}" ]
|
||||||
|
then
|
||||||
|
dch --newversion "${version}" --distribution noble "${subject}"
|
||||||
|
fi
|
||||||
|
cargo set-version "${version}"
|
||||||
|
cargo update
|
||||||
|
git commit -a -m "${subject}" -m "${description}"
|
||||||
|
git push -f -u origin epoch-update
|
||||||
|
pr_url="$(gh pr list --head epoch-update --state open --json url --jq '.[].url')"
|
||||||
|
if [ -n "${pr_url}" ]
|
||||||
|
then
|
||||||
|
echo "PR already open: ${pr_url}"
|
||||||
|
else
|
||||||
|
gh pr create \
|
||||||
|
--title "${subject}" \
|
||||||
|
--body "${description}" \
|
||||||
|
--assignee "@me" \
|
||||||
|
--reviewer "pop-os/engineering" \
|
||||||
|
--reviewer "pop-os/quality-assurance"
|
||||||
|
fi
|
||||||
|
popd
|
||||||
|
done
|
||||||
Executable
+258
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
#
|
||||||
|
# Install the parts of HyprCosmic that live outside a user's home directory.
|
||||||
|
#
|
||||||
|
# WHY THIS SCRIPT EXISTS
|
||||||
|
# ----------------------
|
||||||
|
# Everything here was placed by hand with `sudo install` while the desktop was
|
||||||
|
# being built, which left two problems that this script exists to close:
|
||||||
|
#
|
||||||
|
# 1. A fresh machine has none of it. rofi's config.rasi @imports
|
||||||
|
# /usr/share/hyprcosmic/rofi/{palette,rules}.rasi by absolute path, and a
|
||||||
|
# missing @import is an error rofi renders *in place of the launcher*
|
||||||
|
# rather than a warning it skips. Miss those two files and Super+A shows a
|
||||||
|
# parse error instead of a menu.
|
||||||
|
#
|
||||||
|
# 2. Hand-installed files drift. /usr/bin/start-hyprcosmic silently gained a
|
||||||
|
# session-logging block during debugging that never made it back to the
|
||||||
|
# copy under version control; nothing noticed until the two were diffed on
|
||||||
|
# a hunch. `--check` is the cure: it compares every managed file against
|
||||||
|
# its source and exits non-zero on any difference.
|
||||||
|
#
|
||||||
|
# WHAT IT DOES NOT DO
|
||||||
|
# -------------------
|
||||||
|
# Per-user files. `cosmic-conf import-theme --assets` writes those, because they
|
||||||
|
# depend on the installed theme and on $HOME; see PER_USER below for the list
|
||||||
|
# and its owner. The cosmic-conf, cosmic-session and cosmic-comp binaries are
|
||||||
|
# also out of scope. The top-level justfile installs them -- cosmic-conf from
|
||||||
|
# its own `install` line, the other two from their component's recipe -- and
|
||||||
|
# they are build outputs, so comparing them byte-for-byte here would only ever
|
||||||
|
# report a rebuild.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# tools/install-assets.sh install (needs write access to PREFIX)
|
||||||
|
# tools/install-assets.sh --check compare only; exit 1 on any drift
|
||||||
|
# tools/install-assets.sh --dry-run print what install would do
|
||||||
|
# tools/install-assets.sh --no-session skip start-hyprcosmic and the .desktop
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# PREFIX install prefix, default /usr (see the warning below)
|
||||||
|
# DESTDIR staging root for packaging, prepended to every path
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PREFIX="${PREFIX:-/usr}"
|
||||||
|
DESTDIR="${DESTDIR:-}"
|
||||||
|
|
||||||
|
MODE=install
|
||||||
|
WITH_SESSION=1
|
||||||
|
|
||||||
|
# Kept for the "re-run as sudo" hint, which has to quote the invocation the user
|
||||||
|
# actually made; $@ is long gone by the time a write fails.
|
||||||
|
ARGV=("$@")
|
||||||
|
|
||||||
|
die() { printf 'install-assets: %s\n' "$*" >&2; exit 1; }
|
||||||
|
warn() { printf 'install-assets: %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--check) MODE=check ;;
|
||||||
|
--dry-run) MODE=dry-run ;;
|
||||||
|
--no-session) WITH_SESSION=0 ;;
|
||||||
|
-h|--help) sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed 's/^# \?//;$d'; exit 0 ;;
|
||||||
|
*) die "unknown argument: $1 (try --help)" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# Files under config/ that belong to the system, as "source:destination:mode".
|
||||||
|
# Destinations are relative to $PREFIX.
|
||||||
|
SHARED=(
|
||||||
|
"config/waybar/bridge-hyde.css:share/hyprcosmic/waybar/bridge-hyde.css:644"
|
||||||
|
"config/waybar/config.jsonc:share/hyprcosmic/waybar/config.jsonc:644"
|
||||||
|
"config/waybar/palette.css:share/hyprcosmic/waybar/palette.css:644"
|
||||||
|
"config/waybar/rules.css:share/hyprcosmic/waybar/rules.css:644"
|
||||||
|
"config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644"
|
||||||
|
"config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644"
|
||||||
|
"config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755"
|
||||||
|
"config/bin/hyprcosmic-fan:bin/hyprcosmic-fan:755"
|
||||||
|
"config/bin/hyprcosmic-keybinds:bin/hyprcosmic-keybinds:755"
|
||||||
|
"config/bin/hyprcosmic-wallpaper:bin/hyprcosmic-wallpaper:755"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The session entry point. Kept apart from SHARED because it is versioned in the
|
||||||
|
# cosmic-session fork rather than in this repository -- that fork is a separate
|
||||||
|
# checkout and may simply be absent, in which case these are skipped.
|
||||||
|
SESSION=(
|
||||||
|
"cosmic-session/data/start-hyprcosmic:bin/start-hyprcosmic:755"
|
||||||
|
"cosmic-session/data/hyprcosmic.desktop:share/wayland-sessions/hyprcosmic.desktop:644"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Files under config/ that produce a SHARED file rather than being one. They are
|
||||||
|
# inputs to a generator and have no place on the system: installing the template
|
||||||
|
# would put a file full of @@TOKEN@@ placeholders next to the real config, and
|
||||||
|
# whichever one a future reader opened first would be a coin toss.
|
||||||
|
SOURCES=(
|
||||||
|
"config/waybar/config.jsonc.in" # -> config/waybar/config.jsonc
|
||||||
|
"config/waybar/generate-config.py" # the generator, and the icon table
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-user files. These cannot live in share/hyprcosmic with the rest: style.css
|
||||||
|
# and config.rasi reach their theme through relative @imports, which resolve
|
||||||
|
# against the importing file, so the importer has to sit in the same per-user
|
||||||
|
# directory as the theme it picks up.
|
||||||
|
#
|
||||||
|
# They are still installed, as a skeleton. start-hyprcosmic copies anything
|
||||||
|
# missing out of share/hyprcosmic/skel into $XDG_CONFIG_HOME when a session
|
||||||
|
# starts, and never overwrites. Destinations below therefore mirror the layout
|
||||||
|
# under ~/.config exactly -- skel/hyprcosmic/autostart becomes
|
||||||
|
# ~/.config/hyprcosmic/autostart -- because the seeding is a plain copy that
|
||||||
|
# reads the layout off this tree rather than a list it keeps in step by hand.
|
||||||
|
#
|
||||||
|
# Before this existed the answer was "by hand", which meant a machine that had
|
||||||
|
# never seen HyprCosmic logged into a bare compositor: no autostart, so no
|
||||||
|
# waybar and no wallpaper, and no keybindings, on a screen with nothing drawn on
|
||||||
|
# it. Nothing reported an error, because from the session's point of view
|
||||||
|
# nothing had gone wrong.
|
||||||
|
SKEL=(
|
||||||
|
"config/autostart:share/hyprcosmic/skel/hyprcosmic/autostart:644"
|
||||||
|
"config/cosmic.conf:share/hyprcosmic/skel/hyprcosmic/cosmic.conf:644"
|
||||||
|
"config/waybar/style.css:share/hyprcosmic/skel/hyprcosmic/waybar/style.css:644"
|
||||||
|
"config/waybar/theme.css:share/hyprcosmic/skel/hyprcosmic/waybar/theme.css:644"
|
||||||
|
"config/rofi/config.rasi:share/hyprcosmic/skel/rofi/config.rasi:644"
|
||||||
|
"config/rofi/theme.rasi:share/hyprcosmic/skel/rofi/theme.rasi:644"
|
||||||
|
"config/rofi/local.rasi:share/hyprcosmic/skel/rofi/local.rasi:644"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refuses to run unless every file under config/ appears in exactly one of
|
||||||
|
# SHARED, SKEL or SOURCES. This is not decoration: adding a file forces a
|
||||||
|
# decision about where it belongs instead of letting it be quietly left out of
|
||||||
|
# all three and never installed.
|
||||||
|
audit_config_tree() {
|
||||||
|
local f rel known=" ${SOURCES[*]} " unclassified=()
|
||||||
|
# Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix
|
||||||
|
# from the first element only and silently keeps the rest whole.
|
||||||
|
for f in "${SHARED[@]}"; do known+="${f%%:*} "; done
|
||||||
|
for f in "${SKEL[@]}"; do known+="${f%%:*} "; done
|
||||||
|
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
rel="${f#"$REPO"/}"
|
||||||
|
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
|
||||||
|
# Dot-directories are pruned. Nothing shipped lives in one, and tooling
|
||||||
|
# drops state inside the tree without asking -- .omc/ appeared under
|
||||||
|
# config/waybar/ and failed this audit with six files that are gitignored
|
||||||
|
# and are not assets. Failing on those trains you to ignore the one message
|
||||||
|
# that catches a genuinely unclassified file. Dot *files* are still walked;
|
||||||
|
# only directories are pruned.
|
||||||
|
done < <(find "$REPO/config" -name '.?*' -type d -prune -o -type f -print0 | sort -z)
|
||||||
|
|
||||||
|
((${#unclassified[@]} == 0)) || die "not listed as shared, skeleton or generator input: ${unclassified[*]}
|
||||||
|
Add each to SHARED, SKEL or SOURCES in $(basename "${BASH_SOURCE[0]}") and say which."
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prefix is only half honoured, and pretending otherwise would be worse than
|
||||||
|
# saying so. Some consumers name /usr/share/hyprcosmic as a literal because they
|
||||||
|
# have no way to interpolate one: rofi's .rasi has no variables, and the
|
||||||
|
# autostart file is explicitly not a shell. Report them by grepping rather than
|
||||||
|
# from a hardcoded list, so this warning cannot go stale.
|
||||||
|
check_prefix_assumptions() {
|
||||||
|
[[ "$PREFIX" == /usr ]] && return 0
|
||||||
|
local hits
|
||||||
|
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ cosmic-session/data/ 2>/dev/null | sort | tr '\n' ' ')"
|
||||||
|
[[ -z "$hits" ]] && return 0
|
||||||
|
warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:"
|
||||||
|
warn " $hits"
|
||||||
|
warn "they will keep reading /usr/share unless you edit them; rofi will show a parse error if it is empty"
|
||||||
|
warn "start-hyprcosmic is the exception: set HYPRCOSMIC_SKEL=$PREFIX/share/hyprcosmic/skel to point the seeding at this prefix"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fail before touching anything rather than half way through. The nearest
|
||||||
|
# existing ancestor is what matters: install(1) creates the leaf directories, so
|
||||||
|
# an absent share/hyprcosmic/rofi is fine as long as share/ can be written.
|
||||||
|
assert_writable() {
|
||||||
|
local dest="$1" dir
|
||||||
|
dir="$(dirname "$dest")"
|
||||||
|
while [[ ! -e "$dir" && "$dir" != / ]]; do dir="$(dirname "$dir")"; done
|
||||||
|
[[ -w "$dir" ]] && return 0
|
||||||
|
if ((EUID != 0)); then
|
||||||
|
die "$dir is not writable. Re-run as: sudo ${BASH_SOURCE[0]}${ARGV[*]:+ ${ARGV[*]}}"
|
||||||
|
fi
|
||||||
|
die "$dir is not writable, even as root"
|
||||||
|
}
|
||||||
|
|
||||||
|
status=0
|
||||||
|
installed=0 skipped=0 differs=0
|
||||||
|
|
||||||
|
handle() {
|
||||||
|
local src="$REPO/$1" dest="$DESTDIR$PREFIX/$2" mode="$3"
|
||||||
|
|
||||||
|
if [[ ! -f "$src" ]]; then
|
||||||
|
warn "missing source, skipped: $1"
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
check)
|
||||||
|
if [[ ! -e "$dest" ]]; then
|
||||||
|
printf ' MISSING %s\n' "$dest"
|
||||||
|
differs=$((differs + 1))
|
||||||
|
elif cmp -s "$src" "$dest"; then
|
||||||
|
printf ' ok %s\n' "$dest"
|
||||||
|
else
|
||||||
|
printf ' DIFFERS %s\n' "$dest"
|
||||||
|
differs=$((differs + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
dry-run)
|
||||||
|
printf ' would install -m %s %s -> %s\n' "$mode" "$1" "$dest"
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
assert_writable "$dest"
|
||||||
|
install -D -m "$mode" "$src" "$dest" || die "failed to install $dest"
|
||||||
|
printf ' %s\n' "$dest"
|
||||||
|
installed=$((installed + 1))
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_config_tree
|
||||||
|
[[ "$MODE" == install ]] && check_prefix_assumptions
|
||||||
|
|
||||||
|
targets=("${SHARED[@]}" "${SKEL[@]}")
|
||||||
|
if ((WITH_SESSION)); then
|
||||||
|
if [[ -d "$REPO/cosmic-session/data" ]]; then
|
||||||
|
targets+=("${SESSION[@]}")
|
||||||
|
else
|
||||||
|
warn "cosmic-session/data is absent; skipping the session entry point"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
check) echo "Checking against $DESTDIR$PREFIX:" ;;
|
||||||
|
dry-run) echo "Dry run against $DESTDIR$PREFIX:" ;;
|
||||||
|
install) echo "Installing to $DESTDIR$PREFIX:" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for spec in "${targets[@]}"; do
|
||||||
|
IFS=: read -r src dest mode <<<"$spec"
|
||||||
|
handle "$src" "$dest" "$mode"
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
check)
|
||||||
|
if ((differs)); then
|
||||||
|
echo "$differs file(s) missing or out of date; re-run without --check to fix" >&2
|
||||||
|
status=1
|
||||||
|
else
|
||||||
|
echo "All ${#targets[@]} file(s) match."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
echo "Installed $installed file(s)."
|
||||||
|
((skipped)) && echo "Skipped $skipped missing source(s)." >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit $status
|
||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
#
|
||||||
|
# Run the forked cosmic-session nested inside the live desktop, safely.
|
||||||
|
#
|
||||||
|
# WHY THIS SCRIPT EXISTS
|
||||||
|
# ----------------------
|
||||||
|
# On 2026-08-10 an ad-hoc version of this test logged the developer out of their
|
||||||
|
# own desktop. Two independent mistakes did it, and both are easy to repeat by
|
||||||
|
# hand, so the test lives in a script instead:
|
||||||
|
#
|
||||||
|
# 1. `pkill -x cosmic-session` matched the *real* session leader. The fork and
|
||||||
|
# the system COSMIC ship binaries with the same name, so no name-based
|
||||||
|
# match can distinguish them. This script therefore never uses pkill or
|
||||||
|
# pgrep; it kills the process group it created, by ID.
|
||||||
|
#
|
||||||
|
# 2. A nested cosmic-session on the shared session bus takes the well-known
|
||||||
|
# D-Bus name `com.system76.CosmicSession` away from the running session
|
||||||
|
# (journal: "Connection `:1.3` lost name `com.system76.CosmicSession`").
|
||||||
|
# That destabilises the outer desktop before anything is even killed. This
|
||||||
|
# script always runs under `dbus-run-session`, so the nested session gets a
|
||||||
|
# private bus and cannot touch the real one's names.
|
||||||
|
#
|
||||||
|
# Nesting cosmic-comp alone is safe and does not need any of this; the hazard is
|
||||||
|
# specific to running a second cosmic-session.
|
||||||
|
#
|
||||||
|
# Usage: tools/nested-session.sh [seconds] [-- extra env assignments]
|
||||||
|
# e.g. tools/nested-session.sh 12 -- HYPRCOSMIC_PROFILE=hyprcosmic
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SESSION_BIN="$REPO/cosmic-session/target/debug/cosmic-session"
|
||||||
|
COMP_BIN="$REPO/cosmic-comp/target/debug/cosmic-comp"
|
||||||
|
|
||||||
|
DURATION="${1:-12}"
|
||||||
|
shift || true
|
||||||
|
[[ "${1:-}" == "--" ]] && shift
|
||||||
|
|
||||||
|
die() { printf 'nested-session: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Refuse to run outside a Wayland session. Without a host compositor the winit
|
||||||
|
# backend would fall back to DRM and try to take over the real display.
|
||||||
|
[[ -n "${WAYLAND_DISPLAY:-}" ]] || die "no WAYLAND_DISPLAY; refusing to run (would grab the DRM device)"
|
||||||
|
[[ -x "$SESSION_BIN" ]] || die "not built: $SESSION_BIN"
|
||||||
|
[[ -x "$COMP_BIN" ]] || die "not built: $COMP_BIN"
|
||||||
|
command -v dbus-run-session >/dev/null || die "dbus-run-session is required for bus isolation"
|
||||||
|
|
||||||
|
# Record the live session's leader purely so the exit check can prove we did not
|
||||||
|
# disturb it. Asked of logind rather than matched by process name: a name-based
|
||||||
|
# lookup is what destroyed the developer's session twice, once in the very test
|
||||||
|
# written to prove name matching was unsafe. There is no `ps -C cosmic-...`
|
||||||
|
# anywhere in this file, deliberately.
|
||||||
|
OUTER_LEADER="$(loginctl show-session "${XDG_SESSION_ID:-}" -p Leader --value 2>/dev/null)"
|
||||||
|
|
||||||
|
LOG="$(mktemp -t nested-session.XXXXXX.log)"
|
||||||
|
echo "nested-session: logging to $LOG"
|
||||||
|
echo "nested-session: live session leader=$OUTER_LEADER (must survive)"
|
||||||
|
|
||||||
|
# setsid puts the whole tree in a fresh process group whose ID equals the child
|
||||||
|
# PID, so one negative kill reaps the session, the compositor and every
|
||||||
|
# component it spawned -- with no pattern matching anywhere.
|
||||||
|
setsid env \
|
||||||
|
COSMIC_BACKEND=winit \
|
||||||
|
RUST_LOG="${RUST_LOG:-info}" \
|
||||||
|
"$@" \
|
||||||
|
dbus-run-session -- "$SESSION_BIN" "$COMP_BIN" >"$LOG" 2>&1 &
|
||||||
|
PGID=$!
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
# Negative PID = process group. Never a name.
|
||||||
|
kill -TERM -"$PGID" 2>/dev/null
|
||||||
|
for _ in $(seq 20); do
|
||||||
|
kill -0 -"$PGID" 2>/dev/null || break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
kill -KILL -"$PGID" 2>/dev/null
|
||||||
|
|
||||||
|
# An abruptly-killed compositor leaves its IPC directory behind, so drop any
|
||||||
|
# whose owning PID is gone. Matching is on the PID embedded in the name.
|
||||||
|
for dir in "${XDG_RUNTIME_DIR:?}"/hypr/cosmic_*; do
|
||||||
|
[[ -d "$dir" ]] || continue
|
||||||
|
pid="${dir##*/cosmic_}"; pid="${pid%%_*}"
|
||||||
|
kill -0 "$pid" 2>/dev/null || rm -rf "$dir"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
sleep "$DURATION"
|
||||||
|
cleanup
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
|
||||||
|
# The whole point: confirm the developer still has a desktop.
|
||||||
|
status=0
|
||||||
|
if [[ -n "$OUTER_LEADER" ]] && ! kill -0 "$OUTER_LEADER" 2>/dev/null; then
|
||||||
|
echo "nested-session: FAIL - live session leader $OUTER_LEADER died during the test" >&2
|
||||||
|
status=1
|
||||||
|
else
|
||||||
|
echo "nested-session: live session survived"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- log: $LOG ---"
|
||||||
|
exit $status
|
||||||
Executable
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check that the fork's Hyprland IPC is reachable the way a real client reaches it.
|
||||||
|
|
||||||
|
Run this inside a HyprCosmic session after installing a new cosmic-comp. It
|
||||||
|
exercises the two things that are easy to get wrong and impossible to see from
|
||||||
|
the compositor's own log:
|
||||||
|
|
||||||
|
1. The socket *names*. Every Hyprland client -- waybar's hyprland/* modules,
|
||||||
|
hyprctl, eww, ags -- opens `$XDG_RUNTIME_DIR/hypr/$HIS/.socket.sock` and
|
||||||
|
gives up if it is absent. waybar reports it once, at startup, as
|
||||||
|
"Couldn't connect to ... (3)" and then disables the module, so a bar with
|
||||||
|
a silently missing workspace widget is the only symptom you get.
|
||||||
|
|
||||||
|
2. The dispatch (write) endpoint, which is what makes clicking a workspace on
|
||||||
|
the bar actually switch to it rather than just look clickable.
|
||||||
|
|
||||||
|
Nothing here changes configuration. `dispatch workspace` moves the focused
|
||||||
|
workspace, which is runtime state, so this script does disturb what you are
|
||||||
|
looking at: it returns to the workspace you started on when it finishes.
|
||||||
|
|
||||||
|
Exit status is 0 only if every check passed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
RESET, RED, GREEN, DIM = "\033[0m", "\033[31m", "\033[32m", "\033[2m"
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
|
||||||
|
def result(ok: bool, label: str, detail: str = "") -> bool:
|
||||||
|
mark = f"{GREEN}ok{RESET}" if ok else f"{RED}FAIL{RESET}"
|
||||||
|
print(f" [{mark}] {label}")
|
||||||
|
if detail:
|
||||||
|
for line in str(detail).splitlines():
|
||||||
|
print(f" {DIM}{line}{RESET}")
|
||||||
|
if not ok:
|
||||||
|
failures.append(label)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def socket_dir() -> str:
|
||||||
|
runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||||
|
base = os.path.join(runtime, "hypr")
|
||||||
|
if not os.path.isdir(base):
|
||||||
|
print(f"{RED}No {base}. Is this a HyprCosmic session?{RESET}")
|
||||||
|
sys.exit(2)
|
||||||
|
sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
|
||||||
|
if not sig:
|
||||||
|
# Newest instance, so this still works from a terminal that predates it.
|
||||||
|
entries = sorted(
|
||||||
|
(e for e in os.listdir(base) if os.path.isdir(os.path.join(base, e))),
|
||||||
|
key=lambda e: os.stat(os.path.join(base, e)).st_mtime,
|
||||||
|
)
|
||||||
|
if not entries:
|
||||||
|
print(f"{RED}No instance directory under {base}.{RESET}")
|
||||||
|
sys.exit(2)
|
||||||
|
sig = entries[-1]
|
||||||
|
print(f"{DIM}HYPRLAND_INSTANCE_SIGNATURE unset; using newest: {sig}{RESET}")
|
||||||
|
return os.path.join(base, sig)
|
||||||
|
|
||||||
|
|
||||||
|
def request(path: str, payload: str, timeout: float = 2.0) -> str:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||||
|
s.settimeout(timeout)
|
||||||
|
s.connect(path)
|
||||||
|
s.sendall(payload.encode())
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
data = s.recv(8192)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
chunks.append(data)
|
||||||
|
return b"".join(chunks).decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
d = socket_dir()
|
||||||
|
req_path = os.path.join(d, ".socket.sock")
|
||||||
|
evt_path = os.path.join(d, ".socket2.sock")
|
||||||
|
|
||||||
|
print(f"\ninstance dir: {d}\n")
|
||||||
|
|
||||||
|
print("socket names (the names clients actually open)")
|
||||||
|
have_req = result(os.path.exists(req_path), ".socket.sock exists")
|
||||||
|
result(os.path.exists(evt_path), ".socket2.sock exists")
|
||||||
|
stale = [n for n in (".socket", ".socket2") if os.path.exists(os.path.join(d, n))]
|
||||||
|
result(not stale, "no unsuffixed leftovers", ", ".join(stale) if stale else "")
|
||||||
|
|
||||||
|
if not have_req:
|
||||||
|
print(f"\n{RED}Request socket missing; cannot go further.{RESET}")
|
||||||
|
print("An old cosmic-comp is probably still running -- the rename only")
|
||||||
|
print("takes effect for a session started after installing the binary.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\nread endpoints")
|
||||||
|
active = None
|
||||||
|
for cmd in ("workspaces", "activeworkspace", "activewindow", "clients", "monitors"):
|
||||||
|
try:
|
||||||
|
reply = request(req_path, f"j/{cmd}")
|
||||||
|
ok = reply.strip().startswith(("{", "["))
|
||||||
|
result(ok, f"j/{cmd}", "" if ok else f"unexpected reply: {reply[:200]}")
|
||||||
|
if cmd == "activeworkspace" and ok:
|
||||||
|
import json
|
||||||
|
|
||||||
|
active = json.loads(reply).get("id")
|
||||||
|
except OSError as e:
|
||||||
|
result(False, f"j/{cmd}", e)
|
||||||
|
|
||||||
|
print("\nunknown commands are refused, not guessed at")
|
||||||
|
for cmd in ("bogus", "dispatch exec rofi", "dispatch killactive",
|
||||||
|
"dispatch workspace +1", "dispatch workspace 0"):
|
||||||
|
try:
|
||||||
|
reply = request(req_path, cmd).strip()
|
||||||
|
# An unparsed request gets no useful answer; what matters is that it
|
||||||
|
# is not silently treated as something else.
|
||||||
|
result(not reply.startswith("ok"), f"refuses {cmd!r}", f"reply: {reply[:120]}")
|
||||||
|
except OSError as e:
|
||||||
|
result(False, f"refuses {cmd!r}", e)
|
||||||
|
|
||||||
|
print("\nwrite endpoint")
|
||||||
|
if active is None:
|
||||||
|
result(False, "know the current workspace to return to")
|
||||||
|
else:
|
||||||
|
target = 2 if active != 2 else 1
|
||||||
|
try:
|
||||||
|
reply = request(req_path, f"dispatch workspace {target}").strip()
|
||||||
|
result(reply == "ok", f"dispatch workspace {target}", f"reply: {reply[:120]}")
|
||||||
|
back = request(req_path, f"dispatch workspace {active}").strip()
|
||||||
|
result(back == "ok", f"back to workspace {active}", f"reply: {back[:120]}")
|
||||||
|
except OSError as e:
|
||||||
|
result(False, "dispatch workspace", e)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{RED}{len(failures)} check(s) failed:{RESET}")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
return 1
|
||||||
|
print(f"{GREEN}All checks passed.{RESET}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Submodule xdg-desktop-portal-cosmic updated: 63903c9450...f211aa37d4
Reference in New Issue
Block a user