mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
Compare commits
54
Commits
epoch-1.0.16
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
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"]
|
||||
path = cosmic-session
|
||||
url = https://github.com/pop-os/cosmic-session
|
||||
url = https://github.com/outbackdingo/hyprcosmic-session
|
||||
branch = master
|
||||
[submodule "cosmic-comp"]
|
||||
path = cosmic-comp
|
||||
url = https://github.com/pop-os/cosmic-comp
|
||||
url = https://github.com/outbackdingo/hyprcosmic-comp
|
||||
branch = master
|
||||
[submodule "cosmic-panel"]
|
||||
path = cosmic-panel
|
||||
@@ -106,3 +106,11 @@
|
||||
path = pop-launcher
|
||||
url = https://github.com/pop-os/launcher.git
|
||||
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,344 +1,306 @@
|
||||
# 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
|
||||
* [cosmic-applets](https://github.com/pop-os/cosmic-applets)
|
||||
* [cosmic-applibrary](https://github.com/pop-os/cosmic-applibrary)
|
||||
* [cosmic-bg](https://github.com/pop-os/cosmic-bg)
|
||||
* [cosmic-comp](https://github.com/pop-os/cosmic-comp)
|
||||
* [cosmic-edit](https://github.com/pop-os/cosmic-edit)
|
||||
* [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)
|
||||
It is a fork of [cosmic-epoch](https://github.com/pop-os/cosmic-epoch), the
|
||||
meta-repository that names every COSMIC component and builds the desktop out of
|
||||
them. Two of its 29 submodules point at forks; the other 27 are System76's,
|
||||
unchanged. So this is not a re-implementation of COSMIC and not a theme pack
|
||||
sitting beside it — it is COSMIC, built from source, with a different shell on
|
||||
top and a different way of telling it what to do.
|
||||
|
||||
### COSMIC libraries/crates
|
||||
Three things distinguish a HyprCosmic session from a COSMIC one:
|
||||
|
||||
* [cosmic-protocols](https://github.com/pop-os/cosmic-protocols)
|
||||
* [cosmic-text](https://github.com/pop-os/cosmic-text)
|
||||
* [cosmic-theme](https://github.com/pop-os/cosmic-theme)
|
||||
* [cosmic-time](https://github.com/pop-os/cosmic-time)
|
||||
- **Hyprland's configuration idiom.** A single `~/.config/hyprcosmic/cosmic.conf`
|
||||
with `general { }` blocks, `bind =` lines and `$variables` is compiled into
|
||||
COSMIC's config tree. The file wins: what it names, it owns.
|
||||
- **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/).
|
||||
- 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-comp** — four patches, each independent:
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
## Building
|
||||
|
||||
```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.
|
||||
|
||||
```
|
||||
LABEL="gdm_prefer_xorg"
|
||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon PreferredDisplayServer xorg"
|
||||
GOTO="gdm_end"
|
||||
## Installing
|
||||
|
||||
LABEL="gdm_disable_wayland"
|
||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon WaylandEnable false"
|
||||
GOTO="gdm_end"
|
||||
```
|
||||
|
||||
Restart gdm
|
||||
The easiest route is a package. Every tag builds one for Fedora, Arch and Debian
|
||||
and attaches it to a draft release; `workflow_dispatch` on **Packages** builds
|
||||
them at any other time and leaves them as run artifacts.
|
||||
|
||||
```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
|
||||
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, synchronize the repository with
|
||||
`emaint sync -r cosmic-overlay`
|
||||
|
||||
and install the COSMIC desktop environment and its associated themes:
|
||||
|
||||
`emerge cosmic-meta pop-theme-meta -av`
|
||||
|
||||
Please note that the ebuilds have testing keywords and need to unmasked on stable systems for successful installation.
|
||||
|
||||
Then log out, and switch the desktop environment to COSMIC, the procedure depends on your login manager.
|
||||
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).
|
||||
Building it yourself instead:
|
||||
|
||||
```shell
|
||||
sudo setenforce 0
|
||||
sudo systemctl restart gdm
|
||||
sudo just install '' /usr
|
||||
```
|
||||
|
||||
**Note**: An extension created this way will be linked against specific libraries on your system and will not work on other distributions.
|
||||
It also requires the previously mentioned libraries/dependencies at runtime to be installed in your system (the system extension does not carry these libraries).
|
||||
The two positional arguments are `rootdir` (a staging root, for packaging) and
|
||||
`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.
|
||||
this is caused by `systemd-sysext` being enabled, when you are done testing you can disable `systemd-sysext` (`sudo systemctl disable --now systemd-sysext`)
|
||||
To stage instead of install:
|
||||
|
||||
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.
|
||||
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.
|
||||
Note that `just install` is not what the packages do. It writes upstream's whole
|
||||
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.
|
||||
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).
|
||||
Then log out. `HyprCosmic` appears on the greeter's session menu next to
|
||||
`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.
|
||||
`just install` places nothing in a home directory — under `sudo` the only home
|
||||
directory it could see is root's. Four files are yours to place:
|
||||
|
||||
## Translating
|
||||
```shell
|
||||
mkdir -p ~/.config/hyprcosmic/waybar
|
||||
cp config/cosmic.conf config/autostart ~/.config/hyprcosmic/
|
||||
cp config/waybar/style.css ~/.config/hyprcosmic/waybar/
|
||||
```
|
||||
|
||||
To submit translations for COSMIC in your language, please use Weblate: https://hosted.weblate.org/projects/pop-os/
|
||||
`style.css` is per-user rather than shared for one reason: it `@import`s a
|
||||
sibling `theme.css` holding the installed HyDE theme's palette, and a relative
|
||||
`@import` resolves against the importing file. That sibling is written by
|
||||
`import-theme --assets`, so the bar is unstyled until you have imported a theme.
|
||||
|
||||
## Contact
|
||||
- [Mattermost](https://chat.pop-os.org/)
|
||||
- [Twitter](https://twitter.com/pop_os_official)
|
||||
- [Instagram](https://www.instagram.com/pop_os_official/)
|
||||
The fourth file, `~/.config/rofi/config.rasi`, is written by `import-theme
|
||||
--assets` too, because it names per-machine paths.
|
||||
|
||||
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 bar's glyphs.
|
||||
|
||||
## 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,96 @@
|
||||
# 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.
|
||||
#
|
||||
# `sh -c` rather than a bare `awww img`, for one reason: awww-daemon above has
|
||||
# only just been forked and is not listening yet, so an immediate `awww img`
|
||||
# loses a race and fails silently. The loop waits for the daemon to answer
|
||||
# before setting the image.
|
||||
#
|
||||
# This does not weaken the no-shell rule in the header. That rule exists so a
|
||||
# file naming programs cannot be escalated into arbitrary execution; naming
|
||||
# `sh` explicitly is just naming a program, and anyone able to write this file
|
||||
# could already name any binary on the system.
|
||||
#
|
||||
# `current` is a symlink `cosmic-conf import-theme --assets` maintains beside
|
||||
# the wallpapers it copies, pointing at one of them. It is named here rather
|
||||
# than a real file so that this line 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 this pointing at a path that no
|
||||
# longer exists.
|
||||
#
|
||||
# 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
|
||||
sh -c 'until awww query >/dev/null 2>&1; do sleep 0.2; done; exec awww img "$HOME/.local/share/wallpapers/hyprcosmic/current"'
|
||||
|
||||
# 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
|
||||
@@ -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,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,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");
|
||||
+1
-1
Submodule cosmic-applets updated: 8981b0b48e...ec8ffdc85d
+1
-1
Submodule cosmic-applibrary updated: 1eb859fdd7...172acdae07
+1
-1
Submodule cosmic-bg updated: b1ca4c180a...76e89e6aff
+1
-1
Submodule cosmic-comp updated: 0312f9a201...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: 00a76ee709...4ac0da3af9
+1
-1
Submodule cosmic-files updated: 54f77f95a6...24e34eaa0f
+1
-1
Submodule cosmic-greeter updated: 7a0c083f01...d39915ae23
+1
-1
Submodule cosmic-icons updated: 2c697e8e97...b78b059636
+1
-1
Submodule cosmic-initial-setup updated: 8eabeaf648...b5ac4182bb
+1
-1
Submodule cosmic-launcher updated: a64ed9a29c...8799503120
Submodule
+1
Submodule cosmic-monitor added at 70e6cff168
+1
-1
Submodule cosmic-notifications updated: a899bfbc67...7c723b7705
+1
-1
Submodule cosmic-osd updated: 28af81d1a7...20a2055dfc
+1
-1
Submodule cosmic-panel updated: 6119bb1062...d6699ffc42
+1
-1
Submodule cosmic-player updated: c0b1bda86b...23d59445af
+1
-1
Submodule cosmic-screenshot updated: 2020fb21ac...fc778df20f
+1
-1
Submodule cosmic-session updated: 495e591dc6...d65baf688c
+1
-1
Submodule cosmic-settings updated: 3a77442dbc...7287257ec9
+1
-1
Submodule cosmic-settings-daemon updated: fa82bdf9fe...21a9692b53
Submodule
+1
Submodule cosmic-sound-theme added at 7aabe44909
+1
-1
Submodule cosmic-store updated: 211987df18...f56cb48aa1
+1
-1
Submodule cosmic-term updated: 8c042673e7...543cbb0cb8
+1
-1
Submodule cosmic-workspaces-epoch updated: 5e62de143a...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-bg/build-release
|
||||
{{ 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-files/build-release
|
||||
{{ just }} cosmic-greeter/build-release
|
||||
{{ just }} cosmic-idle/build-release
|
||||
{{ just }} cosmic-initial-setup/build-release
|
||||
{{ just }} cosmic-launcher/build-release
|
||||
{{ just }} cosmic-monitor/build-release
|
||||
{{ just }} cosmic-notifications/build-release
|
||||
{{ just }} cosmic-osd/build-release
|
||||
{{ just }} cosmic-panel/build-release
|
||||
@@ -28,13 +33,25 @@ build:
|
||||
{{ make }} -C cosmic-wallpapers all
|
||||
{{ make }} -C cosmic-workspaces-epoch all
|
||||
{{ 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
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applets/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applibrary/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-bg/install
|
||||
{{ 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-files/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-initial-setup/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-osd/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-workspaces-epoch install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ 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 -p dir
|
||||
@@ -78,12 +108,14 @@ clean:
|
||||
rm -rf cosmic-applibrary/target
|
||||
rm -rf cosmic-bg/target
|
||||
rm -rf cosmic-comp/target
|
||||
rm -rf cosmic-conf/target
|
||||
rm -rf cosmic-edit/target
|
||||
{{ just }} cosmic-files/clean
|
||||
rm -rf cosmic-greeter/target
|
||||
{{ just }} cosmic-idle/clean
|
||||
{{ just }} cosmic-initial-setup/clean
|
||||
rm -rf cosmic-launcher/target
|
||||
{{ just }} cosmic-monitor/clean
|
||||
rm -rf cosmic-panel/target
|
||||
rm -rf cosmic-player/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,136 @@
|
||||
# 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
|
||||
|
||||
# Nerd Font glyphs are most of what the bar draws.
|
||||
Recommends: nerd-fonts
|
||||
|
||||
%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
|
||||
* 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: 5b86851071...a332a3a733
@@ -3,13 +3,14 @@
|
||||
set -e
|
||||
|
||||
# This should be the _next_ epoch version
|
||||
version=1.0.16
|
||||
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
|
||||
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/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"
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Files under config/ that are deliberately NOT installed here, each with the
|
||||
# thing that does install it. This list is not decoration: the audit below
|
||||
# refuses to run unless every file under config/ appears in exactly one of the
|
||||
# three lists, so adding a file forces a decision about where it belongs instead
|
||||
# of letting it be quietly left out of all of them.
|
||||
PER_USER=(
|
||||
"config/autostart" # ~/.config/hyprcosmic/autostart, by hand
|
||||
"config/cosmic.conf" # ~/.config/hyprcosmic/cosmic.conf, by hand
|
||||
"config/waybar/style.css" # ~/.config/hyprcosmic/waybar/style.css; @imports a sibling theme.css
|
||||
"config/rofi/config.rasi" # ~/.config/rofi/config.rasi, by `cosmic-conf import-theme --assets`
|
||||
)
|
||||
|
||||
audit_config_tree() {
|
||||
local f rel known=" ${PER_USER[*]} ${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
|
||||
|
||||
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 or per-user: ${unclassified[*]}
|
||||
Add each to SHARED or PER_USER 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/ 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"
|
||||
}
|
||||
|
||||
# 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[@]}")
|
||||
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: 4bf00a2a89...f211aa37d4
Reference in New Issue
Block a user