mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df465ef26 | ||
|
|
a397334242 | ||
|
|
4bedb04f77 | ||
|
|
e65a9722d9 | ||
|
|
d2b091fa8b | ||
|
|
620941e18b | ||
|
|
63610d57df | ||
|
|
84a5eac79f | ||
|
|
31ce3a51c4 | ||
|
|
c6150755d2 | ||
|
|
c0ae1350f3 | ||
|
|
98e75ecdc8 | ||
|
|
6f9696e791 | ||
|
|
3c1a092e51 | ||
|
|
4ba11d1bb5 | ||
|
|
08028e6f2c | ||
|
|
178dfbec1a | ||
|
|
563fdc7330 | ||
|
|
b8b5ce546d | ||
|
|
b89e4be10a | ||
|
|
bae7c5b0ff | ||
|
|
316fb843c3 | ||
|
|
362f324755 | ||
|
|
1d3252aabd | ||
|
|
50e6c1948c | ||
|
|
9933ff2415 | ||
|
|
cd99893b33 | ||
|
|
a9400d0550 | ||
|
|
272ec0c5d2 | ||
|
|
a32596216a | ||
|
|
e46514cbab | ||
|
|
10c5cc73d4 | ||
|
|
19927bc00b | ||
|
|
214a77ea18 | ||
|
|
8d21c0d084 | ||
|
|
e56ffe8465 | ||
|
|
1d1909baaf | ||
|
|
dee24ac30a | ||
|
|
09f90f65de | ||
|
|
39008709e3 | ||
|
|
0ce7ec30ac | ||
|
|
652b4c45f1 | ||
|
|
a483257d31 | ||
|
|
6eceadf650 | ||
|
|
0098ab4d8f | ||
|
|
3e34c8e746 | ||
|
|
0d128858ad | ||
|
|
f846fcb546 | ||
|
|
43bcd5266f | ||
|
|
a21edb170b | ||
|
|
ed1607856b | ||
|
|
4412bb00d7 | ||
|
|
6169da68ca | ||
|
|
d103b64024 | ||
|
|
9af2199729 | ||
|
|
9e8ac99bcb | ||
|
|
efe2c51b53 | ||
|
|
9b0e61c54b | ||
|
|
f5fa41194e | ||
|
|
f9abdc105d | ||
|
|
dee71b6f25 | ||
|
|
d955da69d4 | ||
|
|
5dcb211692 | ||
|
|
e13e24b54e | ||
|
|
dafb7bb1fa | ||
|
|
13ea973991 | ||
|
|
8722d5507c | ||
|
|
e4edeeca3f | ||
|
|
7106172bcd | ||
|
|
0972aa1396 | ||
|
|
8f98c11b8c | ||
|
|
da3cf5ee1e | ||
|
|
508b7fe8e6 | ||
|
|
39c24b0b4e | ||
|
|
cb3fce4635 | ||
|
|
366bf74f49 | ||
|
|
3c128a6c52 | ||
|
|
ce6bcdb54d | ||
|
|
87391a426f | ||
|
|
8cd00decac | ||
|
|
221a9856f3 | ||
|
|
ea32f6c30b | ||
|
|
102efba5ae | ||
|
|
3ede740ebd | ||
|
|
f15e88c4c6 | ||
|
|
e9a8b4be7f | ||
|
|
6fb8b35cf5 | ||
|
|
39ea8cb129 | ||
|
|
759fb8efea | ||
|
|
cba864f0cb | ||
|
|
b027c4fc10 | ||
|
|
b8d349fd33 | ||
|
|
d539f2b331 | ||
|
|
a07aecf6a2 | ||
|
|
534c1c132f | ||
|
|
547479a1c5 | ||
|
|
5c1ab6d9eb | ||
|
|
55489ceffb | ||
|
|
efe2106ca2 | ||
|
|
b6011670e3 | ||
|
|
c4cd5a4950 | ||
|
|
9e13a3e47a | ||
|
|
b50a37bedb | ||
|
|
f9c45ca65b | ||
|
|
f55b95f67e | ||
|
|
cdc45ee666 | ||
|
|
8e7e5bbc0b | ||
|
|
be97a211e1 | ||
|
|
ba96994e7d | ||
|
|
f8a0f29675 | ||
|
|
85a48f0f53 | ||
|
|
5c4fa4287e | ||
|
|
afcb876be2 | ||
|
|
5ed56ee132 | ||
|
|
2e7a373ff2 | ||
|
|
8145b20798 |
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. Pop!_OS 24.04]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,61 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: cosmic-applets
|
||||
url: https://github.com/pop-os/cosmic-applets/issues
|
||||
about: Concern all the applets living in the dock or the panel.
|
||||
|
||||
- name: cosmic-comp
|
||||
url: https://github.com/pop-os/cosmic-comp/issues
|
||||
about: Responsible for composing your windows on the screen. If you see issues with the tilling features or your multiple displays, this is likely the place to go.
|
||||
|
||||
- name: cosmic-edit
|
||||
url: https://github.com/pop-os/cosmic-edit/issues
|
||||
about: This is the issue tracker for COSMIC Text Editor. If you see issues with the text editor, report them here.
|
||||
|
||||
- name: cosmic-files
|
||||
url: https://github.com/pop-os/cosmic-files/issues
|
||||
about: This is the issue tracker for COSMIC Files. If you see issues while navigating files, this is the place to go.
|
||||
|
||||
- name: cosmic-settings
|
||||
url: https://github.com/pop-os/cosmic-settings/issues
|
||||
about: This is the issue tracker for COSMIC Settings. If you see issues while changing settings, this is the place to go.
|
||||
|
||||
- name: cosmic-store
|
||||
url: https://github.com/pop-os/cosmic-store/issues
|
||||
about: This is the issue tracker for COSMIC Store. If you see issues with the app store, report them here.
|
||||
|
||||
- name: cosmic-term
|
||||
url: https://github.com/pop-os/cosmic-term/issues
|
||||
about: This is the issue tracker for COSMIC Terminal. If you see issues with the terminal, report them here.
|
||||
|
||||
- name: cosmic-panel
|
||||
url: https://github.com/pop-os/cosmic-panel/issues
|
||||
about: Contains the code to manage the dock and panel applets. You can see it as the compositor of applets. It is also responsible to draw the panel and dock at the right place (bottom, left, ect..).
|
||||
|
||||
- name: cosmic-screenshot
|
||||
url: https://github.com/pop-os/cosmic-screenshot/issues
|
||||
about: This is the tool that help you take screenshots.
|
||||
|
||||
- name: cosmic-workspaces-epoch
|
||||
url: https://github.com/pop-os/cosmic-workspaces-epoch/issues
|
||||
about: Responsible for the workspace overview. If you have an issue with workspaces, this is the place to go.
|
||||
|
||||
- name: cosmic-greeter
|
||||
url: https://github.com/pop-os/cosmic-greeter/issues
|
||||
about: When your session is locked, or not opened yet, this is the window that will be shown. Basically the login screen.
|
||||
|
||||
- name: cosmic-osd
|
||||
url: https://github.com/pop-os/cosmic-osd/issues
|
||||
about: Cosmic on screen display. This is for example, the audio notification when you change the volume, or the brightness one.
|
||||
|
||||
- name: cosmic-applibrary
|
||||
url: https://github.com/pop-os/cosmic-applibrary/issues
|
||||
about: It is the application launched with super-a, that let you see what app are in your system, and launch them.
|
||||
|
||||
- name: cosmic-launcher
|
||||
url: https://github.com/pop-os/cosmic-launcher/issues
|
||||
about: Launched with the super key. It lets you search in your application, and display the result in a list.
|
||||
|
||||
- name: cosmic-bg
|
||||
url: https://github.com/pop-os/cosmic-bg/issues
|
||||
about: If you notice issues with the wallpaper, this is the place to go.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,8 @@
|
||||
- [ ] I have disclosed use of any AI generated code in my commit messages.
|
||||
- If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR.
|
||||
- In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment.
|
||||
- [ ] I understand these changes in full and will be able to respond to review comments.
|
||||
- [ ] My change is accurately described in the commit message.
|
||||
- [ ] My contribution is tested and working as described.
|
||||
- [ ] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions.
|
||||
|
||||
@@ -17,13 +17,12 @@ jobs:
|
||||
Test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
image: archlinux:base-devel
|
||||
volumes:
|
||||
- /:/host
|
||||
steps:
|
||||
- run: >
|
||||
pacman --noconfirm -Syu
|
||||
base-devel
|
||||
pacman --noconfirm --needed -Syu
|
||||
cargo
|
||||
clang
|
||||
desktop-file-utils
|
||||
@@ -31,6 +30,7 @@ jobs:
|
||||
gtk3
|
||||
gtk4
|
||||
just
|
||||
libdisplay-info
|
||||
libinput
|
||||
libxkbcommon
|
||||
llvm
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
flatpak
|
||||
nasm
|
||||
- run: rm -rf /host/usr/local/lib/android # Free space
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
# Safe directory behavior seems to have issues with `container:`
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# 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.
|
||||
- name: Syntax-check the shell scripts
|
||||
run: |
|
||||
set -eux
|
||||
bash -n config/bin/hyprcosmic-powermenu
|
||||
bash -n tools/install-assets.sh
|
||||
|
||||
# Both scripts are 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-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,404 @@
|
||||
# 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 staged tree is what all three packages wrap, so it is worth failing
|
||||
# here rather than shipping a package that is missing the compositor. The
|
||||
# negative assertion is the one that would rot quietly: nothing may return
|
||||
# to the private libexec layout this fork used to install into, because a
|
||||
# copy there is a second compositor that nothing runs and no uninstall
|
||||
# removes.
|
||||
- name: Assert the staged tree is a complete desktop
|
||||
run: |
|
||||
set -eux
|
||||
test -x stage/usr/bin/cosmic-comp
|
||||
test -x stage/usr/bin/cosmic-session
|
||||
test -x stage/usr/bin/cosmic-conf
|
||||
test -x stage/usr/bin/start-hyprcosmic
|
||||
test -x stage/usr/bin/start-cosmic
|
||||
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||
test -f stage/usr/share/wayland-sessions/cosmic.desktop
|
||||
test -f stage/usr/lib/systemd/user/cosmic-session.target
|
||||
test -f stage/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1/defaults
|
||||
test -d stage/usr/share/hyprcosmic
|
||||
test ! -e stage/usr/libexec/hyprcosmic
|
||||
echo "staged files: $(find stage -type f | wc -l)"
|
||||
|
||||
# ---- 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.
|
||||
pacman -Qlp dist/*.pkg.tar.zst | head -20
|
||||
|
||||
# ---- 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
|
||||
dpkg-deb --contents dist/*.deb | head -20
|
||||
# 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 packages conflict with the
|
||||
# distribution's cosmic-comp and cosmic-session, so installing one
|
||||
# replaces the machine's desktop. That is worth a human reading the
|
||||
# notes before it is published rather than a tag push making it
|
||||
# available.
|
||||
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.
|
||||
|
||||
+18
-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
|
||||
@@ -98,3 +98,19 @@
|
||||
path = cosmic-player
|
||||
url = https://github.com/pop-os/cosmic-player.git
|
||||
branch = master
|
||||
[submodule "cosmic-initial-setup"]
|
||||
path = cosmic-initial-setup
|
||||
url = https://github.com/pop-os/cosmic-initial-setup
|
||||
branch = master
|
||||
[submodule "pop-launcher"]
|
||||
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,211 +1,287 @@
|
||||
# COSMIC Desktop
|
||||
# HyprCosmic
|
||||
|
||||
Currently an incomplete **alpha**. Testing instructions below for various distributions.
|
||||
COSMIC's compositor, driven the way Hyprland is configured, wearing a HyDE
|
||||
shell.
|
||||
|
||||
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.
|
||||
|
||||
## 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-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)
|
||||
Three things distinguish a HyprCosmic session from a COSMIC one:
|
||||
|
||||
### COSMIC libraries/crates
|
||||
- **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 replaces COSMIC rather than sitting next to it.** The binaries install as
|
||||
`/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, the paths a cosmic-comp
|
||||
and a cosmic-session go to, and the packages conflict with the distribution's
|
||||
accordingly. Both session entries are installed, so the greeter still offers a
|
||||
stock COSMIC shell for the day the HyDE one does not start — now served by
|
||||
these binaries rather than by a second copy on disk.
|
||||
|
||||
* [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)
|
||||
* [libcosmic](https://github.com/pop-os/libcosmic)
|
||||
## Repository layout
|
||||
|
||||
## Setup on distributions without packaging of cosmic components
|
||||
Everything in `cosmic-epoch`, plus:
|
||||
|
||||
The COSMIC desktop environment requires a few dependencies:
|
||||
(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):
|
||||
| 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 |
|
||||
|
||||
- [just](https://github.com/casey/just)
|
||||
- rustc
|
||||
- libwayland
|
||||
- mesa (or third-party libEGL/libGL implementations, though interfacing with mesa's libglvnd is generally recommended).
|
||||
- libseat
|
||||
- libxkbcommon
|
||||
- libinput
|
||||
- udev
|
||||
- dbus
|
||||
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.
|
||||
|
||||
optionally (though the build-system might currently require these libraries):
|
||||
- libsystem
|
||||
- libpulse
|
||||
- pop-launcher
|
||||
- libexpat1
|
||||
- libfontconfig
|
||||
- libfreetype
|
||||
- lld
|
||||
- cargo
|
||||
- libgbm-dev
|
||||
- libclang-dev
|
||||
- libpipewire-0.3-dev
|
||||
### What the two forks change
|
||||
|
||||
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.
|
||||
**cosmic-comp** — four patches, each independent:
|
||||
|
||||
The required ones can be installed with:
|
||||
```
|
||||
sudo apt install just rustc libglvnd-dev libwayland-dev libseat-dev libxkbcommon-dev libinput-dev udev dbus libdbus-1-dev libpam0g-dev libpixman-1-dev libssl-dev libflatpak-dev -y
|
||||
```
|
||||
- `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/cosmic-comp`, at upstream's paths and alongside
|
||||
upstream's two `.ron` defaults files, which are carried unmodified.
|
||||
|
||||
and the optional ones with:
|
||||
```
|
||||
sudo apt install libsystemd-dev libpulse-dev pop-launcher libexpat1-dev libfontconfig-dev libfreetype-dev mold cargo libgbm-dev libclang-dev libpipewire-0.3-dev -y
|
||||
```
|
||||
**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.
|
||||
|
||||
They can be installed all at once with:
|
||||
```
|
||||
sudo apt install just rustc libglvnd-dev libwayland-dev libseat-dev libxkbcommon-dev libinput-dev udev dbus libdbus-1-dev libsystemd-dev libpixman-1-dev libssl-dev libflatpak-dev libpulse-dev pop-launcher libexpat1-dev libfontconfig-dev libfreetype-dev mold cargo libgbm-dev libclang-dev libpipewire-0.3-dev libpam0g-dev -y
|
||||
```
|
||||
|
||||
### 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`, that 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
|
||||
|
||||
```shell
|
||||
sudo setenforce 0
|
||||
sudo systemctl restart gdm
|
||||
git clone --recurse-submodules https://github.com/outbackdingo/hyprcosmic
|
||||
cd hyprcosmic
|
||||
just build
|
||||
```
|
||||
|
||||
**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).
|
||||
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.
|
||||
|
||||
**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`)
|
||||
## Installing
|
||||
|
||||
It is thus no proper method for long term deployment.
|
||||
|
||||
### Packaging
|
||||
|
||||
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.
|
||||
|
||||
### Versioning
|
||||
|
||||
COSMIC DE is very much still work-in-progress and thus does not follow a versioning scheme so far.
|
||||
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).
|
||||
|
||||
Notes on versioning and packaging all these components together properly will be added at a later stage once COSMIC DE gets its first release.
|
||||
|
||||
## Installing on Pop!_OS
|
||||
COSMIC DE is in its first alpha release. Using and testing the alpha is welcome. Bugs and breakage are expected.
|
||||
|
||||
#### 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
|
||||
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 nano /usr/lib/udev/rules.d/61-gdm.rules
|
||||
sudo dnf install ./hyprcosmic-*.rpm # Fedora
|
||||
sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
|
||||
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
|
||||
```
|
||||
|
||||
Look for `LABEL="gdm_prefer_xorg"` and `LABEL="gdm_disable_wayland"`. Add `#` to the `RUN` statements so they look like this:
|
||||
Expect this to fail the first time, and read what it says when it does. These
|
||||
packages provide `/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, so they
|
||||
**conflict with the distribution's `cosmic-comp` and `cosmic-session`** and your
|
||||
package manager will refuse until those are removed. That refusal is the design:
|
||||
installing HyprCosmic replaces the machine's desktop, and it should take a
|
||||
deliberate `dnf remove cosmic-comp cosmic-session` to say so rather than a
|
||||
resolver deciding on your behalf. Both session entries survive the swap, so the
|
||||
greeter still offers a stock COSMIC shell afterwards.
|
||||
|
||||
```
|
||||
LABEL="gdm_prefer_xorg"
|
||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon PreferredDisplayServer xorg"
|
||||
GOTO="gdm_end"
|
||||
|
||||
LABEL="gdm_disable_wayland"
|
||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon WaylandEnable false"
|
||||
GOTO="gdm_end"
|
||||
```
|
||||
|
||||
Restart gdm
|
||||
Building it yourself instead:
|
||||
|
||||
```shell
|
||||
sudo systemctl restart gdm
|
||||
sudo just install '' /usr
|
||||
```
|
||||
|
||||
#### Install COSMIC
|
||||
`sudo apt install cosmic-session`
|
||||
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.
|
||||
|
||||
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.
|
||||
To stage instead of install:
|
||||
|
||||
## 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 may be installed via a Fedora COPR repository.
|
||||
```
|
||||
dnf copr enable ryanabx/cosmic-epoch
|
||||
dnf install cosmic-desktop
|
||||
```shell
|
||||
just install /tmp/stage /usr
|
||||
```
|
||||
|
||||
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 [COPR page](https://copr.fedorainfracloud.org/coprs/ryanabx/cosmic-epoch/).
|
||||
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`.
|
||||
|
||||
## Installing on openSUSE tumbleweed
|
||||
Cosmic can be installed by adding X11:COSMIC:Factory repo with opi.
|
||||
`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.
|
||||
|
||||
Then log out. `HyprCosmic` appears on the greeter's session menu next to
|
||||
`COSMIC`; both work.
|
||||
|
||||
### Per-user setup
|
||||
|
||||
`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:
|
||||
|
||||
```shell
|
||||
mkdir -p ~/.config/hyprcosmic/waybar
|
||||
cp config/cosmic.conf config/autostart ~/.config/hyprcosmic/
|
||||
cp config/waybar/style.css ~/.config/hyprcosmic/waybar/
|
||||
```
|
||||
opi patterns-cosmic
|
||||
|
||||
`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.
|
||||
|
||||
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
|
||||
cosmic-conf apply # once
|
||||
cosmic-conf apply --diff # show what would change, write nothing
|
||||
cosmic-conf watch # recompile on every edit, for the whole session
|
||||
```
|
||||
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).
|
||||
`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.
|
||||
|
||||
## 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:
|
||||
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`.
|
||||
|
||||
`eselect repository add cosmic-overlay git https://github.com/fsvm88/cosmic-overlay.git`
|
||||
`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.
|
||||
|
||||
Next, install the COSMIC desktop environment and its associated themes:
|
||||
What the shipped file sets up, since the components those keys used to reach are
|
||||
no longer running:
|
||||
|
||||
`emerge cosmic-meta pop-theme-meta`
|
||||
| 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 |
|
||||
|
||||
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
|
||||
For further information, you may check the [Gentoo Wiki](https://wiki.gentoo.org/wiki/COSMIC) or [Overlay Repository](https://github.com/fsvm88/cosmic-overlay).
|
||||
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.
|
||||
|
||||
## Contact
|
||||
- [Mattermost](https://chat.pop-os.org/)
|
||||
- [Twitter](https://twitter.com/pop_os_official)
|
||||
- [Instagram](https://www.instagram.com/pop_os_official/)
|
||||
See [`config/cosmic.conf`](config/cosmic.conf); it is commented at length and is
|
||||
the reference for what is supported.
|
||||
|
||||
## Theming
|
||||
|
||||
```shell
|
||||
cosmic-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.
|
||||
cosmic-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
+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,113 @@
|
||||
# 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
|
||||
}
|
||||
|
||||
# --- 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
|
||||
@@ -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,289 @@
|
||||
// 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": ["ext/workspaces", "wlr/taskbar"],
|
||||
"modules-center": ["mpris", "clock", "privacy"],
|
||||
"modules-right": [
|
||||
"idle_inhibitor",
|
||||
"custom/swaync",
|
||||
"bluetooth",
|
||||
"pulseaudio",
|
||||
"network",
|
||||
"temperature",
|
||||
"cpu",
|
||||
"memory",
|
||||
"power-profiles-daemon",
|
||||
"battery",
|
||||
"tray",
|
||||
"custom/power"
|
||||
],
|
||||
|
||||
"ext/workspaces": {
|
||||
"format": "{name}",
|
||||
"on-click": "activate"
|
||||
},
|
||||
|
||||
"wlr/taskbar": {
|
||||
"format": "{icon}",
|
||||
"icon-size": 18,
|
||||
"tooltip-format": "{title}",
|
||||
"on-click": "activate",
|
||||
"on-click-middle": "close"
|
||||
},
|
||||
|
||||
// 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 }
|
||||
]
|
||||
},
|
||||
|
||||
"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"
|
||||
},
|
||||
|
||||
// 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"
|
||||
},
|
||||
|
||||
"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,289 @@
|
||||
// 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": ["ext/workspaces", "wlr/taskbar"],
|
||||
"modules-center": ["mpris", "clock", "privacy"],
|
||||
"modules-right": [
|
||||
"idle_inhibitor",
|
||||
"custom/swaync",
|
||||
"bluetooth",
|
||||
"pulseaudio",
|
||||
"network",
|
||||
"temperature",
|
||||
"cpu",
|
||||
"memory",
|
||||
"power-profiles-daemon",
|
||||
"battery",
|
||||
"tray",
|
||||
"custom/power"
|
||||
],
|
||||
|
||||
"ext/workspaces": {
|
||||
"format": "{name}",
|
||||
"on-click": "activate"
|
||||
},
|
||||
|
||||
"wlr/taskbar": {
|
||||
"format": "{icon}",
|
||||
"icon-size": 18,
|
||||
"tooltip-format": "{title}",
|
||||
"on-click": "activate",
|
||||
"on-click-middle": "close"
|
||||
},
|
||||
|
||||
// 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 }
|
||||
]
|
||||
},
|
||||
|
||||
"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"
|
||||
},
|
||||
|
||||
// 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"
|
||||
},
|
||||
|
||||
"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
+97
@@ -0,0 +1,97 @@
|
||||
#!/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,
|
||||
}
|
||||
|
||||
|
||||
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,155 @@
|
||||
/* 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. */
|
||||
#mpris,
|
||||
#clock,
|
||||
#privacy,
|
||||
#idle_inhibitor,
|
||||
#custom-swaync,
|
||||
#bluetooth,
|
||||
#pulseaudio,
|
||||
#network,
|
||||
#temperature,
|
||||
#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;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
#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: 2b88f35991...ec8ffdc85d
+1
-1
Submodule cosmic-applibrary updated: ebc8b462b2...172acdae07
+1
-1
Submodule cosmic-bg updated: b6adf25075...76e89e6aff
+1
-1
Submodule cosmic-comp updated: e0f78da0ae...5f176995dc
@@ -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
+481
@@ -0,0 +1,481 @@
|
||||
# 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 = "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",
|
||||
"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 = "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 = "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,23 @@
|
||||
[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"
|
||||
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 `cosmic-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 `cosmic-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,103 @@
|
||||
//! 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 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};
|
||||
|
||||
/// 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,432 @@
|
||||
//! `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};
|
||||
|
||||
const USAGE: &str = "\
|
||||
cosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||
|
||||
USAGE:
|
||||
cosmic-conf apply [--diff] [--config <path>]
|
||||
cosmic-conf watch [--config <path>]
|
||||
cosmic-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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
//! AST + schema -> validated, folded writes.
|
||||
//!
|
||||
//! Two jobs matter here:
|
||||
//!
|
||||
//! 1. **Validation is total before anything is emitted.** A malformed file must
|
||||
//! leave the desktop untouched rather than half-applied, so `resolve` returns
|
||||
//! every diagnostic it can find and `emit` never sees a partial result.
|
||||
//! 2. **Projections are folded per target.** Several conf keys can write into
|
||||
//! one composite cosmic-config value (`gaps_in` and `gaps_out` are two halves
|
||||
//! of one `(u32, u32)`). Writing them independently would let the second
|
||||
//! clobber the first, so they are merged into a single write.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::bind;
|
||||
use crate::parser::{Ast, Item, Span, Spanned};
|
||||
use crate::schema::{self, Entry, Range, Target, Ty};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Bool(bool),
|
||||
U32(u32),
|
||||
F32(f32),
|
||||
Str(String),
|
||||
/// `Option<Srgb>` target — no alpha channel.
|
||||
Rgb(u8, u8, u8),
|
||||
/// `Option<Srgba>` target — carries alpha.
|
||||
Rgba(u8, u8, u8, u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub span: Span,
|
||||
pub help: Option<String>,
|
||||
}
|
||||
|
||||
/// Identifies one cosmic-config value. Ordering is deterministic so emitted
|
||||
/// writes are stable across runs, which keeps `--diff` output readable.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct TargetKey {
|
||||
pub component: String,
|
||||
pub version: u8,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WriteKind {
|
||||
/// The conf key owns the whole value.
|
||||
Whole(Value),
|
||||
/// Field path -> value, folded from every conf key touching this target.
|
||||
Projected(BTreeMap<Vec<String>, Value>),
|
||||
/// Pre-rendered RON owning the whole value.
|
||||
///
|
||||
/// Used where the target's shape is a collection rather than a scalar, so
|
||||
/// there is no `Value` to coerce into: keybindings fold many `bind` lines
|
||||
/// into one map. Rendering happens at the point that understands the shape
|
||||
/// (`bind::render`) instead of being reconstructed in `emit`.
|
||||
Verbatim(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Write {
|
||||
pub target: TargetKey,
|
||||
pub kind: WriteKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Resolved {
|
||||
pub writes: Vec<Write>,
|
||||
}
|
||||
|
||||
/// Flatten the AST into dotted `section.key` paths, dropping `source` items —
|
||||
/// include expansion happens before `resolve` so that spans stay attributable
|
||||
/// to the file they came from.
|
||||
fn flatten(items: &[Item], prefix: &str, out: &mut Vec<(String, Spanned<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)
|
||||
};
|
||||
flatten(items, &next, out);
|
||||
}
|
||||
Item::Assign { key, value } => {
|
||||
let dotted = if prefix.is_empty() {
|
||||
key.value.clone()
|
||||
} else {
|
||||
format!("{prefix}.{}", key.value)
|
||||
};
|
||||
out.push((dotted, value.clone(), key.span));
|
||||
}
|
||||
Item::VarDef { .. } | Item::Source { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_vars(items: &[Item], out: &mut BTreeMap<String, String>) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::VarDef { name, value } => {
|
||||
out.insert(name.value.clone(), value.value.clone());
|
||||
}
|
||||
Item::Section { items, .. } => collect_vars(items, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Substitute `$name` occurrences. Longest-name-first avoids `$gap` eating the
|
||||
/// prefix of `$gaps`.
|
||||
fn expand_vars(input: &str, vars: &BTreeMap<String, String>) -> String {
|
||||
if !input.contains('$') {
|
||||
return input.to_string();
|
||||
}
|
||||
let mut names: Vec<&String> = vars.keys().collect();
|
||||
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
|
||||
|
||||
let mut out = input.to_string();
|
||||
for name in names {
|
||||
out = out.replace(&format!("${name}"), &vars[name]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluate the tiny arithmetic the format allows: `a * b`, `a + b`, `a - b`.
|
||||
/// Anything else is returned untouched for the type parser to reject.
|
||||
fn eval_arith(input: &str) -> String {
|
||||
for op in ['*', '+', '-'] {
|
||||
if let Some((l, r)) = input.split_once(op) {
|
||||
let (l, r) = (l.trim(), r.trim());
|
||||
if let (Ok(a), Ok(b)) = (l.parse::<f64>(), r.parse::<f64>()) {
|
||||
let v = match op {
|
||||
'*' => a * b,
|
||||
'+' => a + b,
|
||||
_ => a - b,
|
||||
};
|
||||
return if v.fract() == 0.0 {
|
||||
format!("{}", v as i64)
|
||||
} else {
|
||||
format!("{v}")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
input.to_string()
|
||||
}
|
||||
|
||||
/// Parse `rgb(rrggbb)` or `rgba(rrggbbaa)`.
|
||||
///
|
||||
/// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the
|
||||
/// value would be stripped before reaching here. Hyprland makes the same
|
||||
/// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost.
|
||||
fn parse_color(raw: &str) -> Option<(u8, u8, u8, u8)> {
|
||||
let s = raw.trim();
|
||||
// Both spellings take the same body; the alpha pair is optional either
|
||||
// way, so `rgb(rrggbbaa)` and `rgba(rrggbb)` are accepted too rather than
|
||||
// rejected on a technicality.
|
||||
let inner = s
|
||||
.strip_prefix("rgba(")
|
||||
.or_else(|| s.strip_prefix("rgb("))
|
||||
.and_then(|s| s.strip_suffix(')'))?;
|
||||
|
||||
let hex = inner.trim().trim_start_matches('#');
|
||||
// `len` and the slicing below are both in bytes, so a multi-byte character
|
||||
// would make `hex[i..i + 2]` split a char boundary and panic. A config
|
||||
// typo must not crash the compiler.
|
||||
if !hex.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||
match hex.len() {
|
||||
6 => Some((byte(0)?, byte(2)?, byte(4)?, 255)),
|
||||
8 => Some((byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn coerce(raw: &str, ty: Ty, span: Span) -> Result<Value, Diagnostic> {
|
||||
let bad = |expected: &str| Diagnostic {
|
||||
message: format!("expected {expected}, found `{raw}`"),
|
||||
span,
|
||||
help: None,
|
||||
};
|
||||
|
||||
match ty {
|
||||
Ty::Bool => match raw {
|
||||
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
|
||||
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
|
||||
_ => Err(bad("a boolean (true/false/yes/no/on/off)")),
|
||||
},
|
||||
Ty::U32 => raw
|
||||
.parse::<u32>()
|
||||
.map(Value::U32)
|
||||
.map_err(|_| bad("a non-negative integer")),
|
||||
Ty::F32 => raw
|
||||
.parse::<f32>()
|
||||
.map(Value::F32)
|
||||
.map_err(|_| bad("a number")),
|
||||
Ty::Str => Ok(Value::Str(raw.to_string())),
|
||||
Ty::Rgb => parse_color(raw)
|
||||
.map(|(r, g, b, _)| Value::Rgb(r, g, b))
|
||||
.ok_or_else(|| bad("a colour like rgb(6b9fed)")),
|
||||
Ty::Rgba => parse_color(raw)
|
||||
.map(|(r, g, b, a)| Value::Rgba(r, g, b, a))
|
||||
.ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")),
|
||||
Ty::Mode => match raw {
|
||||
"dark" => Ok(Value::Bool(true)),
|
||||
"light" => Ok(Value::Bool(false)),
|
||||
_ => Err(bad("`dark` or `light`")),
|
||||
},
|
||||
Ty::FollowMouse => match raw {
|
||||
"0" => Ok(Value::Bool(false)),
|
||||
"1" => Ok(Value::Bool(true)),
|
||||
// Named separately from the catch-all so the message can say why a
|
||||
// value that is valid in Hyprland does not work here.
|
||||
"2" | "3" => Err(Diagnostic {
|
||||
message: format!("`follow_mouse = {raw}` has no COSMIC equivalent"),
|
||||
span,
|
||||
help: Some(
|
||||
"cosmic-comp has a single focus rather than separate pointer and \
|
||||
keyboard focus, so it cannot detach them. Use `1` for focus follows \
|
||||
mouse or `0` for click to focus."
|
||||
.into(),
|
||||
),
|
||||
}),
|
||||
_ => Err(bad("`0` (click to focus) or `1` (focus follows mouse)")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn check_range(v: &Value, range: Option<Range>, span: Span) -> Result<(), Diagnostic> {
|
||||
let Some(r) = range else { return Ok(()) };
|
||||
let n = match v {
|
||||
Value::U32(n) => *n as f64,
|
||||
Value::F32(n) => *n as f64,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if n < r.min || n > r.max {
|
||||
return Err(Diagnostic {
|
||||
message: format!(
|
||||
"value {n} is outside the allowed range {}..={}",
|
||||
r.min, r.max
|
||||
),
|
||||
span,
|
||||
help: None,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve an AST against the registry.
|
||||
///
|
||||
/// Returns **all** diagnostics rather than the first, so a user fixing a config
|
||||
/// sees the whole picture in one pass.
|
||||
pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
||||
let mut vars = BTreeMap::new();
|
||||
collect_vars(&ast.items, &mut vars);
|
||||
|
||||
let mut flat = Vec::new();
|
||||
flatten(&ast.items, "", &mut flat);
|
||||
|
||||
let mut diags = Vec::new();
|
||||
// (target) -> folded projections, plus whole-value writes kept separate so
|
||||
// a collision between the two can be reported rather than silently resolved.
|
||||
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
|
||||
let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new();
|
||||
|
||||
// `bind` is the one repeatable key in the language: many lines fold into a
|
||||
// single map rather than the last one winning, so it cannot go through the
|
||||
// schema, which is built around one conf key naming one value.
|
||||
let mut binds: Vec<(bind::Bind, Span)> = Vec::new();
|
||||
|
||||
for (conf, raw_value, key_span) in &flat {
|
||||
if conf == "bind" {
|
||||
let expanded = expand_vars(&raw_value.value, &vars);
|
||||
match bind::parse_bind(&expanded, raw_value.span) {
|
||||
Ok(b) => {
|
||||
if let Some((prev, prev_span)) = binds
|
||||
.iter()
|
||||
.find(|(o, _)| o.mods == b.mods && o.key == b.key)
|
||||
{
|
||||
diags.push(Diagnostic {
|
||||
message: format!(
|
||||
"this key combination is already bound to `{}`",
|
||||
prev.action
|
||||
),
|
||||
span: raw_value.span,
|
||||
help: Some(format!("the earlier bind is on line {}", prev_span.line)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
binds.push((b, raw_value.span));
|
||||
}
|
||||
Err(e) => diags.push(Diagnostic {
|
||||
message: e.message,
|
||||
span: e.span,
|
||||
help: e.help,
|
||||
}),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(entry) = schema::lookup(conf) else {
|
||||
diags.push(Diagnostic {
|
||||
message: format!("unknown key `{conf}`"),
|
||||
span: *key_span,
|
||||
help: schema::suggest(conf).map(|s| format!("did you mean `{s}`?")),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
let expanded = eval_arith(&expand_vars(&raw_value.value, &vars));
|
||||
let value = match coerce(&expanded, entry.ty, raw_value.span) {
|
||||
Ok(v) => v,
|
||||
Err(d) => {
|
||||
diags.push(d);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(d) = check_range(&value, entry.validate, raw_value.span) {
|
||||
diags.push(d);
|
||||
continue;
|
||||
}
|
||||
|
||||
record(
|
||||
entry,
|
||||
value,
|
||||
raw_value.span,
|
||||
&mut projected,
|
||||
&mut whole,
|
||||
&mut diags,
|
||||
);
|
||||
}
|
||||
|
||||
if !diags.is_empty() {
|
||||
return Err(diags);
|
||||
}
|
||||
|
||||
let mut writes: Vec<Write> = whole
|
||||
.into_iter()
|
||||
.map(|(target, (v, _))| Write {
|
||||
target,
|
||||
kind: WriteKind::Whole(v),
|
||||
})
|
||||
.collect();
|
||||
|
||||
writes.extend(projected.into_iter().map(|(target, fields)| Write {
|
||||
target,
|
||||
kind: WriteKind::Projected(fields),
|
||||
}));
|
||||
|
||||
if !binds.is_empty() {
|
||||
let rendered = bind::render(&binds.iter().map(|(b, _)| b.clone()).collect::<Vec<_>>());
|
||||
writes.push(Write {
|
||||
// cosmic-comp merges `custom` over `defaults`
|
||||
// (cosmic-settings-daemon `config/src/shortcuts/mod.rs`), so writing
|
||||
// here overrides a stock shortcut without touching the system file.
|
||||
target: TargetKey {
|
||||
component: "com.system76.CosmicSettings.Shortcuts".into(),
|
||||
version: 1,
|
||||
key: "custom".into(),
|
||||
},
|
||||
kind: WriteKind::Verbatim(rendered),
|
||||
});
|
||||
}
|
||||
|
||||
writes.sort_by(|a, b| a.target.cmp(&b.target));
|
||||
Ok(Resolved { writes })
|
||||
}
|
||||
|
||||
fn record(
|
||||
entry: &Entry,
|
||||
value: Value,
|
||||
span: Span,
|
||||
projected: &mut BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>>,
|
||||
whole: &mut BTreeMap<TargetKey, (Value, Span)>,
|
||||
diags: &mut Vec<Diagnostic>,
|
||||
) {
|
||||
for target in entry.targets {
|
||||
let tk = TargetKey {
|
||||
component: target.component().to_string(),
|
||||
version: target.version(),
|
||||
key: target.key().to_string(),
|
||||
};
|
||||
|
||||
match target {
|
||||
Target::Direct { .. } => {
|
||||
if projected.contains_key(&tk) {
|
||||
diags.push(Diagnostic {
|
||||
message: format!(
|
||||
"`{}` writes all of `{}`, but another key writes one of its fields",
|
||||
entry.conf, tk.key
|
||||
),
|
||||
span,
|
||||
help: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
whole.insert(tk, (value.clone(), span));
|
||||
}
|
||||
Target::Projected { path, .. } => {
|
||||
if whole.contains_key(&tk) {
|
||||
diags.push(Diagnostic {
|
||||
message: format!(
|
||||
"`{}` writes a field of `{}`, but another key writes the whole value",
|
||||
entry.conf, tk.key
|
||||
),
|
||||
span,
|
||||
help: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let fields = projected.entry(tk).or_default();
|
||||
let path: Vec<String> = path.iter().map(|s| s.to_string()).collect();
|
||||
fields.insert(path, value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parser::parse;
|
||||
|
||||
fn resolved(src: &str) -> Resolved {
|
||||
let ast = parse(src).expect("parse failed");
|
||||
resolve(&ast).expect("resolve failed")
|
||||
}
|
||||
|
||||
fn errors(src: &str) -> Vec<Diagnostic> {
|
||||
let ast = parse(src).expect("parse failed");
|
||||
resolve(&ast).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_fold_into_one_write_against_the_shortcuts_custom_key() {
|
||||
let r = resolved("bind = SUPER, D, exec, rofi -show drun\nbind = SUPER, Q, killactive\n");
|
||||
let w: Vec<_> = r
|
||||
.writes
|
||||
.iter()
|
||||
.filter(|w| w.target.component == "com.system76.CosmicSettings.Shortcuts")
|
||||
.collect();
|
||||
assert_eq!(w.len(), 1, "every bind belongs to one map");
|
||||
assert_eq!(w[0].target.key, "custom");
|
||||
assert_eq!(w[0].target.version, 1);
|
||||
|
||||
let WriteKind::Verbatim(ron) = &w[0].kind else {
|
||||
panic!("expected verbatim RON, got {:?}", w[0].kind);
|
||||
};
|
||||
assert!(
|
||||
ron.contains(r#"(modifiers: [Super], key: "d"): Spawn("rofi -show drun")"#),
|
||||
"{ron}"
|
||||
);
|
||||
assert!(
|
||||
ron.contains(r#"(modifiers: [Super], key: "q"): Close"#),
|
||||
"{ron}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bind_expands_variables_like_the_mainmod_idiom_everyone_uses() {
|
||||
// Practically every hyprland.conf opens with `$mainMod = SUPER`.
|
||||
let r = resolved("$mainMod = SUPER\nbind = $mainMod, D, exec, rofi -show drun\n");
|
||||
let WriteKind::Verbatim(ron) = &r.writes.last().unwrap().kind else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains("modifiers: [Super]"), "{ron}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_is_not_applied_to_a_command() {
|
||||
// `eval_arith` would happily rewrite the `-` in a command line.
|
||||
let r = resolved("bind = SUPER, V, exec, pactl set-sink-volume @DEFAULT_SINK@ -5%\n");
|
||||
let WriteKind::Verbatim(ron) = &r.writes.last().unwrap().kind else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains("@DEFAULT_SINK@ -5%"), "{ron}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_the_same_combination_twice_is_an_error_not_a_silent_overwrite() {
|
||||
let d = errors("bind = SUPER, D, exec, rofi -show drun\nbind = SUPER, D, killactive\n");
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(d[0].message.contains("already bound"), "{}", d[0].message);
|
||||
assert!(
|
||||
d[0].help.as_ref().unwrap().contains("line 1"),
|
||||
"{:?}",
|
||||
d[0].help
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_binds_means_the_shortcuts_file_is_left_alone() {
|
||||
// Writing an empty map would wipe shortcuts set through COSMIC's UI for
|
||||
// anyone whose cosmic.conf simply does not mention keybindings.
|
||||
let r = resolved("general {\n gaps_in = 4\n}\n");
|
||||
assert!(r
|
||||
.writes
|
||||
.iter()
|
||||
.all(|w| w.target.component != "com.system76.CosmicSettings.Shortcuts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_bind_is_reported_with_the_rest_of_the_file() {
|
||||
let d = errors("bind = SUPER, X, frobnicate\ngeneral {\n gaps_inn = 8\n}\n");
|
||||
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
||||
}
|
||||
|
||||
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
|
||||
&r.writes
|
||||
.iter()
|
||||
.find(|w| w.target.component == component && w.target.key == key)
|
||||
.unwrap_or_else(|| panic!("no write for {component}/{key}"))
|
||||
.kind
|
||||
}
|
||||
|
||||
/// The spec's highest-value property: two conf keys writing into one
|
||||
/// composite value must fold into a single write carrying both fields.
|
||||
#[test]
|
||||
fn gaps_in_and_gaps_out_fold_into_one_write() {
|
||||
let r = resolved("general {\n gaps_in = 3\n gaps_out = 8\n}\n");
|
||||
|
||||
let gap_writes: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
|
||||
// One per theme builder — Dark and Light — and no more.
|
||||
assert_eq!(gap_writes.len(), 2, "expected one folded write per builder");
|
||||
|
||||
for w in gap_writes {
|
||||
match &w.kind {
|
||||
WriteKind::Projected(fields) => {
|
||||
assert_eq!(fields.len(), 2, "both halves must survive folding");
|
||||
assert_eq!(fields[&vec!["1".to_string()]], Value::U32(3), "inner");
|
||||
assert_eq!(fields[&vec!["0".to_string()]], Value::U32(8), "outer");
|
||||
}
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_land_on_the_verified_tuple_indices() {
|
||||
// (outer, inner) per theme.rs:895 — swapping these silently ruins the
|
||||
// user's layout, so assert the concrete indices.
|
||||
let r = resolved("general {\n gaps_in = 3\n gaps_out = 8\n}\n");
|
||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
|
||||
WriteKind::Projected(f) => {
|
||||
assert_eq!(f[&vec!["0".to_string()]], Value::U32(8));
|
||||
assert_eq!(f[&vec!["1".to_string()]], Value::U32(3));
|
||||
}
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_keys_produce_whole_writes() {
|
||||
let r = resolved("general {\n autotile = true\n}\n");
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicComp", "autotile"),
|
||||
&WriteKind::Whole(Value::Bool(true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_expand() {
|
||||
let r = resolved("$gap = 5\ngeneral {\n gaps_in = $gap\n}\n");
|
||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
|
||||
WriteKind::Projected(f) => assert_eq!(f[&vec!["1".to_string()]], Value::U32(5)),
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_on_variables_works() {
|
||||
let r = resolved("$gap = 4\ngeneral {\n gaps_out = $gap * 2\n}\n");
|
||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
|
||||
WriteKind::Projected(f) => assert_eq!(f[&vec!["0".to_string()]], Value::U32(8)),
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_variable_names_win() {
|
||||
// `$gap` must not eat the prefix of `$gaps`.
|
||||
let r = resolved("$gap = 1\n$gaps = 7\ngeneral {\n gaps_in = $gaps\n}\n");
|
||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
|
||||
WriteKind::Projected(f) => assert_eq!(f[&vec!["1".to_string()]], Value::U32(7)),
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_colors_parse_and_drop_alpha() {
|
||||
// `accent` is Option<Srgb> — theme.rs:856 — so alpha must not appear.
|
||||
let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n");
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent"),
|
||||
&WriteKind::Whole(Value::Rgb(0x6b, 0x9f, 0xed))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_mode_maps_to_is_dark() {
|
||||
let r = resolved("theme {\n mode = dark\n}\n");
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicTheme.Mode", "is_dark"),
|
||||
&WriteKind::Whole(Value::Bool(true))
|
||||
);
|
||||
let r = resolved("theme {\n mode = light\n}\n");
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicTheme.Mode", "is_dark"),
|
||||
&WriteKind::Whole(Value::Bool(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_theme_mode_is_rejected() {
|
||||
let d = errors("theme {\n mode = purple\n}\n");
|
||||
assert!(
|
||||
d[0].message.contains("`dark` or `light`"),
|
||||
"{}",
|
||||
d[0].message
|
||||
);
|
||||
}
|
||||
|
||||
/// `#` always begins a comment, so a bare hex colour is stripped before it
|
||||
/// reaches the value parser. This must fail loudly rather than silently
|
||||
/// yield an empty value.
|
||||
#[test]
|
||||
fn bare_hex_color_is_rejected_because_hash_is_a_comment() {
|
||||
let d = errors("theme {\n accent = #6b9fed\n}\n");
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(d[0].message.contains("colour"), "{}", d[0].message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgba_keeps_alpha() {
|
||||
// `bg_color` is Option<Srgba> — theme.rs:852 — so alpha survives.
|
||||
let r = resolved("theme {\n bg_color = rgba(6b9fed80)\n}\n");
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicTheme.Dark.Builder", "bg_color"),
|
||||
&WriteKind::Whole(Value::Rgba(0x6b, 0x9f, 0xed, 0x80))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multibyte_character_in_a_colour_is_an_error_not_a_panic() {
|
||||
// `hex.len()` and the slicing that follows it are both in bytes, so
|
||||
// "€abc" is six bytes and would have been sliced mid-character.
|
||||
for raw in ["rgb(€abc)", "rgba(ff€€ff00)", "rgb(αβγ)"] {
|
||||
let d = errors(&format!("theme {{\n accent = {raw}\n}}\n"));
|
||||
assert_eq!(d.len(), 1, "{raw}");
|
||||
assert!(d[0].message.contains("colour"), "{}", d[0].message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_suggests_the_near_miss() {
|
||||
let d = errors("general {\n gaps_inn = 8\n}\n");
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(d[0].message.contains("unknown key"), "{}", d[0].message);
|
||||
assert_eq!(
|
||||
d[0].help.as_deref(),
|
||||
Some("did you mean `general.gaps_in`?")
|
||||
);
|
||||
assert_eq!(d[0].span.line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_errors_are_reported_against_the_value() {
|
||||
let d = errors("general {\n gaps_in = purple\n}\n");
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(
|
||||
d[0].message.contains("non-negative integer"),
|
||||
"{}",
|
||||
d[0].message
|
||||
);
|
||||
}
|
||||
|
||||
/// The alias has to land on the same cosmic-config key as COSMIC's own
|
||||
/// spelling, or the two would be separate settings that merely look alike.
|
||||
#[test]
|
||||
fn follow_mouse_is_the_same_write_as_focus_follows_cursor() {
|
||||
let hypr = resolved("input {\n follow_mouse = 1\n}\n");
|
||||
let cosmic = resolved("general {\n focus_follows_cursor = true\n}\n");
|
||||
assert_eq!(hypr.writes, cosmic.writes);
|
||||
|
||||
assert_eq!(hypr.writes.len(), 1);
|
||||
assert_eq!(hypr.writes[0].target.key, "focus_follows_cursor");
|
||||
assert_eq!(hypr.writes[0].kind, WriteKind::Whole(Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_mouse_zero_is_click_to_focus() {
|
||||
let r = resolved("input {\n follow_mouse = 0\n}\n");
|
||||
assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::Bool(false)));
|
||||
}
|
||||
|
||||
/// Hyprland accepts 2 and 3, which detach pointer focus from keyboard
|
||||
/// focus. cosmic-comp has one focus and cannot, so the values are refused.
|
||||
/// Rounding them to 1 would hand click-to-focus users the opposite of what
|
||||
/// they asked for and never say so.
|
||||
#[test]
|
||||
fn follow_mouse_rejects_the_modes_cosmic_cannot_express() {
|
||||
for v in ["2", "3"] {
|
||||
let d = errors(&format!("input {{\n follow_mouse = {v}\n}}\n"));
|
||||
assert_eq!(d.len(), 1, "{v}: {d:?}");
|
||||
assert!(
|
||||
d[0].message.contains("no COSMIC equivalent"),
|
||||
"{v}: {}",
|
||||
d[0].message
|
||||
);
|
||||
assert!(
|
||||
d[0].help.as_deref().unwrap_or_default().contains("Use `1`"),
|
||||
"{v}: help should say what to write instead, got {:?}",
|
||||
d[0].help
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// It is an integer setting in Hyprland, so `true` is not one of its
|
||||
/// spellings even though the value it resolves to is a boolean.
|
||||
#[test]
|
||||
fn follow_mouse_does_not_quietly_accept_boolean_spellings() {
|
||||
let d = errors("input {\n follow_mouse = true\n}\n");
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(
|
||||
d[0].message.contains("`0`") && d[0].message.contains("`1`"),
|
||||
"{}",
|
||||
d[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_mouse_delay_shares_the_delay_key_and_its_range() {
|
||||
let r = resolved("input {\n follow_mouse_delay = 400\n}\n");
|
||||
assert_eq!(r.writes[0].target.key, "focus_follows_cursor_delay");
|
||||
assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::U32(400)));
|
||||
|
||||
let d = errors("input {\n follow_mouse_delay = 9001\n}\n");
|
||||
assert!(
|
||||
d[0].message.contains("outside the allowed range"),
|
||||
"{}",
|
||||
d[0].message
|
||||
);
|
||||
}
|
||||
|
||||
/// Both spellings in one file is not an error: the file's own rule is that
|
||||
/// the last assignment wins, and these are two names for one target.
|
||||
#[test]
|
||||
fn the_last_spelling_in_the_file_wins() {
|
||||
let r = resolved(
|
||||
"general {\n focus_follows_cursor = true\n}\ninput {\n follow_mouse = 0\n}\n",
|
||||
);
|
||||
assert_eq!(r.writes.len(), 1, "one target, not two: {:?}", r.writes);
|
||||
assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::Bool(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_values_are_rejected() {
|
||||
let d = errors("general {\n gaps_in = 9999\n}\n");
|
||||
assert!(
|
||||
d[0].message.contains("outside the allowed range"),
|
||||
"{}",
|
||||
d[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_diagnostics_are_reported_not_just_the_first() {
|
||||
let d = errors("general {\n gaps_inn = 8\n autotile = maybe\n}\n");
|
||||
assert_eq!(d.len(), 2, "expected both errors, got {d:?}");
|
||||
}
|
||||
|
||||
/// Transactionality: any error means zero writes escape.
|
||||
#[test]
|
||||
fn a_single_error_produces_no_writes() {
|
||||
let ast = parse("general {\n autotile = true\n gaps_in = nope\n}\n").unwrap();
|
||||
assert!(resolve(&ast).is_err(), "must not partially apply");
|
||||
}
|
||||
}
|
||||
@@ -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,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: 4b75c32bff...4ac0da3af9
+1
-1
Submodule cosmic-files updated: cd43d722d6...24e34eaa0f
+1
-1
Submodule cosmic-greeter updated: 5527c9b866...d39915ae23
+1
-1
Submodule cosmic-icons updated: 52ad55cba5...b78b059636
+1
-1
Submodule cosmic-idle updated: 7899fccbbe...c95d066b5b
Submodule
+1
Submodule cosmic-initial-setup added at b5ac4182bb
+1
-1
Submodule cosmic-launcher updated: b617a9dc68...8799503120
Submodule
+1
Submodule cosmic-monitor added at 70e6cff168
+1
-1
Submodule cosmic-notifications updated: ea717d7938...7c723b7705
+1
-1
Submodule cosmic-osd updated: 5dd5f23148...20a2055dfc
+1
-1
Submodule cosmic-panel updated: dc429bc571...d6699ffc42
+1
-1
Submodule cosmic-player updated: 5796e09704...23d59445af
+1
-1
Submodule cosmic-randr updated: c247019230...6e8e795970
+1
-1
Submodule cosmic-screenshot updated: 10a564d9db...fc778df20f
+1
-1
Submodule cosmic-session updated: 6d16513d77...73867a5d6a
+1
-1
Submodule cosmic-settings updated: d853267ef1...7287257ec9
+1
-1
Submodule cosmic-settings-daemon updated: 58ff27f972...21a9692b53
Submodule
+1
Submodule cosmic-sound-theme added at 7aabe44909
+1
-1
Submodule cosmic-store updated: 28039ef6fc...f56cb48aa1
+1
-1
Submodule cosmic-term updated: b05076be65...543cbb0cb8
+1
-1
Submodule cosmic-wallpapers updated: cb8e6d653b...3c59953e7e
+1
-1
Submodule cosmic-workspaces-epoch updated: 7f877f72a3...8faab4c2a9
+2
-2
@@ -34,8 +34,8 @@ https://gitlab.freedesktop.org/wlroots/wlroots/-/wikis/DRM-Debugging documents h
|
||||
|
||||
## Frozen desktop
|
||||
|
||||
If the desktop is frozen and `ctrl+alt+f*` don't work to change to a TTY, [the magic SysRq key](https://www.kernel.org/doc/html/latest/admin-guide/sysrq.html) with `r` can be used to swich input in raw mode, so the kernel will handle the tty switch key binding. /etc/sysctl.conf` or `/etc/sysctl.d` may need to be edited first to set `kernel.sysrq=1` or another value that allows this command.
|
||||
If the desktop is frozen and `ctrl+alt+f*` don't work to change to a TTY, [the magic SysRq key](https://www.kernel.org/doc/html/latest/admin-guide/sysrq.html) with `r` can be used to switch input into raw mode, so the kernel will handle the tty switch key binding. `/etc/sysctl.conf` or `/etc/sysctl.d` may need to be edited first to set `kernel.sysrq=1` or another value that allows this command.
|
||||
|
||||
It is also possible to connect over `ssh` from another computer.
|
||||
|
||||
Then `pidof cosmic-comp` can be used to get the PID of the compositor. Then it is possible to investigate further with tools like `gdb`, using `sudo gdb` then `attach <pid>`.
|
||||
Then `pidof cosmic-comp` can be used to get the PID of the compositor. Then it is possible to investigate further with tools like `gdb`, using `sudo gdb` and then `attach <pid>`.
|
||||
|
||||
@@ -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,51 +8,85 @@ 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
|
||||
{{ make }} -C cosmic-osd all
|
||||
{{ just }} cosmic-osd/build-release
|
||||
{{ just }} cosmic-panel/build-release
|
||||
{{ just }} cosmic-player/build-release
|
||||
{{ just }} cosmic-randr/build-release
|
||||
{{ just }} cosmic-screenshot/build-release
|
||||
{{ just }} cosmic-settings/build-release
|
||||
{{ make }} -C cosmic-settings-daemon all
|
||||
{{ just }} cosmic-session/all
|
||||
{{ just }} cosmic-session/build-release
|
||||
{{ just }} cosmic-store/build-release
|
||||
{{ just }} cosmic-term/build-release
|
||||
{{ make }} -C cosmic-wallpapers all
|
||||
{{ make }} -C cosmic-workspaces-epoch all
|
||||
{{ make }} -C xdg-desktop-portal-cosmic all
|
||||
{{ just }} pop-launcher/build-release
|
||||
# `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
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-icons/install
|
||||
{{ 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
|
||||
{{ make }} -C cosmic-osd install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-osd/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-panel/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-player/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-randr/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-screenshot/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-settings/install
|
||||
{{ make }} -C cosmic-settings-daemon install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ just }} rootdir={{rootdir}} prefix={{rootdir + prefix}} cosmic-session/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-session/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-store/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-term/install
|
||||
{{ make }} -C cosmic-wallpapers install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ make }} -C cosmic-workspaces-epoch install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ make }} -C xdg-desktop-portal-cosmic install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ just }} rootdir={{rootdir}} pop-launcher/install
|
||||
# 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
|
||||
@@ -74,11 +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
|
||||
@@ -92,4 +129,5 @@ clean:
|
||||
{{ just }} cosmic-term/clean
|
||||
{{ make }} -C cosmic-wallpapers clean
|
||||
rm -rf cosmic-workspaces-epoch/target
|
||||
{{ just }} pop-launcher/clean
|
||||
rm -rf xdg-desktop-portal-cosmic/target
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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')
|
||||
|
||||
# The HyDE shell. Without these the session starts to a blank screen: no bar,
|
||||
# no launcher, no wallpaper.
|
||||
depends=('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')
|
||||
|
||||
# What "complete replacement" means in packaging terms. Every path this writes
|
||||
# under /usr/bin is one cosmic-comp and cosmic-session also own, so the two
|
||||
# cannot coexist -- which is correct, they are two builds of the same programs.
|
||||
#
|
||||
# conflicts without replaces/provides-driven auto-removal: pacman stops and
|
||||
# names the conflict rather than quietly removing the desktop the machine is
|
||||
# currently running.
|
||||
conflicts=('cosmic-comp' 'cosmic-session')
|
||||
provides=("cosmic-comp=$pkgver" "cosmic-session=$pkgver")
|
||||
|
||||
options=('!strip' '!debug')
|
||||
|
||||
package() {
|
||||
# Set by make-packages.sh. Failing loudly here beats producing an empty
|
||||
# package, which is what a bare `cp -a "$unset/."` would do.
|
||||
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
|
||||
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see tools/make-packages.sh" >&2
|
||||
return 1
|
||||
fi
|
||||
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
|
||||
|
||||
# The two files a broken install shows up in first: a bad Exec line puts an
|
||||
# entry on the greeter's menu that fails silently when it is chosen.
|
||||
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/hyprcosmic.desktop"
|
||||
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/cosmic.desktop"
|
||||
}
|
||||
@@ -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
|
||||
Conflicts: cosmic-comp, cosmic-session
|
||||
Provides: cosmic-comp, cosmic-session
|
||||
Replaces: cosmic-comp, cosmic-session
|
||||
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
|
||||
cosmic-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 replaces the distribution's COSMIC. It installs both session
|
||||
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
|
||||
both served by these binaries.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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 NOT TWENTY-SEVEN
|
||||
# ------------------------------------------
|
||||
# Fedora splits COSMIC into a package per component, which is right for a
|
||||
# distribution tracking upstream. This is a fork that replaces the desktop as a
|
||||
# unit: the compositor, the session and the config compiler are versioned and
|
||||
# tested together, and there is no supported combination in which you take the
|
||||
# HyprCosmic cosmic-comp and the distribution's cosmic-session. One package is
|
||||
# an accurate description of what is actually supported.
|
||||
|
||||
%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
|
||||
|
||||
# These are what "complete replacement" means in packaging terms. Every path
|
||||
# this package writes under /usr/bin and /usr/share/cosmic is owned by one of
|
||||
# these on a stock Fedora, so the two cannot be installed at once -- which is
|
||||
# correct, because they are two builds of the same programs.
|
||||
#
|
||||
# Conflicts rather than Obsoletes, deliberately. Obsoletes would let a routine
|
||||
# `dnf install hyprcosmic` quietly remove the desktop the machine is currently
|
||||
# running. Conflicts stops and says so, and removing the COSMIC packages stays
|
||||
# something a person decides to do rather than something a resolver does on
|
||||
# their behalf.
|
||||
Conflicts: cosmic-comp
|
||||
Conflicts: cosmic-session
|
||||
|
||||
# What it stands in for, so anything depending on a COSMIC session is satisfied.
|
||||
Provides: cosmic-comp = %{version}-%{release}
|
||||
Provides: cosmic-session = %{version}-%{release}
|
||||
|
||||
# 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
|
||||
cosmic-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 replaces the distribution's COSMIC. It installs both session
|
||||
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
|
||||
both served by these binaries.
|
||||
|
||||
%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 desktop entries are the two files a broken install shows up in first, so
|
||||
# they are validated rather than assumed. A .desktop with a bad Exec line puts
|
||||
# an entry on the greeter's menu that fails silently when chosen.
|
||||
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/hyprcosmic.desktop"
|
||||
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
|
||||
|
||||
# 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
|
||||
* Sun Aug 10 2026 dingo <[email protected]> - 0.1.0-1
|
||||
- First package of the fork: COSMIC replaced as a unit, HyDE shell, cosmic-conf.
|
||||
Submodule
+1
Submodule pop-launcher added at a332a3a733
+7
-6
@@ -2,14 +2,15 @@
|
||||
|
||||
set -e
|
||||
|
||||
TAG="$1"
|
||||
|
||||
if [ -z "$TAG" ]
|
||||
if [ -z "$1" ]
|
||||
then
|
||||
echo "$0 [tag]" >&2
|
||||
echo "$0 [epoch version]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
TAG="epoch-$1"
|
||||
|
||||
echo "Do you want to tag the current state of cosmic-epoch with the tag $TAG? (y/N)"
|
||||
read answer
|
||||
if [ "$answer" != "y" ]
|
||||
@@ -22,8 +23,8 @@ set -x
|
||||
|
||||
git fetch --recurse-submodules
|
||||
|
||||
git tag --force "$TAG"
|
||||
git submodule foreach git tag --force "$TAG"
|
||||
git tag --force --annotate "$TAG" --message "Epoch ${VERSION}"
|
||||
git submodule foreach git tag --force --annotate "$TAG" --message "Epoch ${VERSION}"
|
||||
|
||||
git push --force origin tag "$TAG"
|
||||
git submodule foreach git push --force origin tag "$TAG"
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# This should be the _next_ epoch version
|
||||
version=1.5.0
|
||||
subject="Epoch ${version} version update"
|
||||
description="Generated by cosmic-epoch scripts/version-update.sh"
|
||||
|
||||
repos=(
|
||||
cosmic-edit
|
||||
cosmic-files
|
||||
cosmic-monitor
|
||||
cosmic-player
|
||||
cosmic-store
|
||||
cosmic-term
|
||||
)
|
||||
|
||||
for repo in "${repos[@]}"
|
||||
do
|
||||
git submodule update --recursive "${repo}"
|
||||
pushd "${repo}"
|
||||
git fetch origin
|
||||
git checkout -B epoch-update origin/master
|
||||
git reset --hard
|
||||
if [ "$(dpkg-parsechangelog --show-field Version)" != "${version}" ]
|
||||
then
|
||||
dch --newversion "${version}" --distribution noble "${subject}"
|
||||
fi
|
||||
cargo set-version "${version}"
|
||||
cargo update
|
||||
git commit -a -m "${subject}" -m "${description}"
|
||||
git push -f -u origin epoch-update
|
||||
pr_url="$(gh pr list --head epoch-update --state open --json url --jq '.[].url')"
|
||||
if [ -n "${pr_url}" ]
|
||||
then
|
||||
echo "PR already open: ${pr_url}"
|
||||
else
|
||||
gh pr create \
|
||||
--title "${subject}" \
|
||||
--body "${description}" \
|
||||
--assignee "@me" \
|
||||
--reviewer "pop-os/engineering" \
|
||||
--reviewer "pop-os/quality-assurance"
|
||||
fi
|
||||
popd
|
||||
done
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/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"
|
||||
)
|
||||
|
||||
# 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")
|
||||
done < <(find "$REPO/config" -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: a821b5a778...f211aa37d4
Reference in New Issue
Block a user