mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80566768c | ||
|
|
06f974f09f | ||
|
|
e006e95e55 | ||
|
|
111f3ade7b | ||
|
|
0e6dcdc606 | ||
|
|
28042b9879 | ||
|
|
5bb057cb71 | ||
|
|
43507504f4 | ||
|
|
49d5f8b20e | ||
|
|
fc8c2a727d | ||
|
|
5f521529a8 |
@@ -171,13 +171,19 @@ jobs:
|
|||||||
|
|
||||||
# A login-time script with a syntax error is a black screen with nowhere to
|
# A login-time script with a syntax error is a black screen with nowhere to
|
||||||
# print the reason.
|
# print the reason.
|
||||||
|
# Each script is checked with the shell its shebang names rather than with
|
||||||
|
# bash across the board: hyprcosmic-fan is POSIX sh on purpose, and `bash
|
||||||
|
# -n` would happily accept a bashism that fails on a system where /bin/sh
|
||||||
|
# is dash.
|
||||||
- name: Syntax-check the shell scripts
|
- name: Syntax-check the shell scripts
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
|
sh -n config/bin/hyprcosmic-fan
|
||||||
|
bash -n config/bin/hyprcosmic-keybinds
|
||||||
bash -n config/bin/hyprcosmic-powermenu
|
bash -n config/bin/hyprcosmic-powermenu
|
||||||
bash -n tools/install-assets.sh
|
bash -n tools/install-assets.sh
|
||||||
|
|
||||||
# Both scripts are shellcheck-clean today, so this starts as a ratchet
|
# Every script is shellcheck-clean today, so this starts as a ratchet
|
||||||
# rather than a backlog. The runner image ships shellcheck; the install is
|
# 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.
|
# there so that stopping to be true is a slow step and not a broken job.
|
||||||
- name: Shellcheck
|
- name: Shellcheck
|
||||||
@@ -187,7 +193,10 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --no-install-recommends shellcheck
|
sudo apt-get install -y --no-install-recommends shellcheck
|
||||||
}
|
}
|
||||||
shellcheck config/bin/hyprcosmic-powermenu tools/install-assets.sh
|
shellcheck config/bin/hyprcosmic-fan \
|
||||||
|
config/bin/hyprcosmic-keybinds \
|
||||||
|
config/bin/hyprcosmic-powermenu \
|
||||||
|
tools/install-assets.sh
|
||||||
|
|
||||||
# A round trip. Installing into a staging root and then asking --check to
|
# 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
|
# confirm it exercises both halves of the script against each other, and
|
||||||
|
|||||||
+173
-21
@@ -229,28 +229,169 @@ jobs:
|
|||||||
- name: Stage the install
|
- name: Stage the install
|
||||||
run: just install "$PWD/stage" /usr
|
run: just install "$PWD/stage" /usr
|
||||||
|
|
||||||
# The staged tree is what all three packages wrap, so it is worth failing
|
# The three binaries this fork actually changes, under their own names.
|
||||||
# here rather than shipping a package that is missing the compositor. The
|
#
|
||||||
# negative assertion is the one that would rot quietly: nothing may return
|
# Done on every distribution, because start-hyprcosmic and the session
|
||||||
# to the private libexec layout this fork used to install into, because a
|
# entry name these paths and there is no reason for those to differ per
|
||||||
# copy there is a second compositor that nothing runs and no uninstall
|
# distribution. cosmic-session takes the compositor as argv[1], so the
|
||||||
# removes.
|
# pair is arranged in start-hyprcosmic and needs no source change.
|
||||||
- name: Assert the staged tree is a complete desktop
|
- name: Give this fork's binaries their own names
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
test -x stage/usr/bin/cosmic-comp
|
mv stage/usr/bin/cosmic-comp stage/usr/bin/hyprcosmic-comp
|
||||||
test -x stage/usr/bin/cosmic-session
|
mv stage/usr/bin/cosmic-session stage/usr/bin/hyprcosmic-session
|
||||||
test -x stage/usr/bin/cosmic-conf
|
mv stage/usr/bin/cosmic-conf stage/usr/bin/hyprcosmic-conf
|
||||||
|
|
||||||
|
# The stock session entry goes with it, on Debian only.
|
||||||
|
#
|
||||||
|
# start-cosmic execs /usr/bin/cosmic-session, which the rename above just
|
||||||
|
# took away, so cosmic.desktop would sit on the greeter's menu and die
|
||||||
|
# silently when chosen. Everywhere else the reduction below removes both
|
||||||
|
# and the distribution's own cosmic-session package supplies a working
|
||||||
|
# pair; on Debian there is no such package, so they are removed here and
|
||||||
|
# the greeter offers the HyprCosmic entry alone.
|
||||||
|
- name: Drop the stock session entry it can no longer start
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
rm -f stage/usr/bin/start-cosmic
|
||||||
|
rm -f stage/usr/share/wayland-sessions/cosmic.desktop
|
||||||
|
|
||||||
|
# HyprCosmic installs beside COSMIC rather than over it, and this is the
|
||||||
|
# step that makes that true.
|
||||||
|
#
|
||||||
|
# `just install` stages the whole desktop, because it builds the whole
|
||||||
|
# tree. Nearly all of it is byte-identical to what the distribution
|
||||||
|
# already ships, and the parts that are not are file conflicts that stop
|
||||||
|
# the install outright -- 62 of them on a stock Fedora COSMIC, across 25
|
||||||
|
# packages, which rpm reports only in the transaction check, long after
|
||||||
|
# dnf's dependency solving has said the transaction is fine.
|
||||||
|
#
|
||||||
|
# The alternative to reducing the tree is to claim all 25 packages with
|
||||||
|
# Conflicts, which means erasing them to install this, which on Fedora
|
||||||
|
# includes cosmic-greeter -- the display manager. A fork you can only try
|
||||||
|
# by removing the desktop you would fall back to is a fork with no way
|
||||||
|
# back. So everything the distribution already owns is dropped, and the
|
||||||
|
# package depends on the distribution's COSMIC for it.
|
||||||
|
#
|
||||||
|
# Not on Debian, which has no COSMIC to depend on: neither cosmic-session
|
||||||
|
# nor cosmic-comp is packaged there, in any suite. Reducing the tree there
|
||||||
|
# would produce a package whose dependency can never be satisfied, so the
|
||||||
|
# Debian build keeps the whole desktop it just compiled and stands alone.
|
||||||
|
# Revisit when Debian packages COSMIC.
|
||||||
|
- name: Reduce the staged tree to this fork's own files
|
||||||
|
if: matrix.distro != 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
# /usr/share/cosmic goes too, all of it. Those are the defaults the
|
||||||
|
# compositor reads at first run, and this fork carries upstream's
|
||||||
|
# copies unmodified -- every one of them is byte-identical to a file a
|
||||||
|
# distribution package already owns. rpm permits two packages to own
|
||||||
|
# an identical file, so keeping them would install today and then
|
||||||
|
# collide the first time the distribution changed one. They arrive
|
||||||
|
# with the cosmic-comp this package depends on.
|
||||||
|
( cd stage && find . \( -type f -o -type l \) -printf '%P\n' ) |
|
||||||
|
while read -r p; do
|
||||||
|
case $p in
|
||||||
|
usr/bin/hyprcosmic-*|usr/bin/start-hyprcosmic) continue ;;
|
||||||
|
usr/share/hyprcosmic/*) continue ;;
|
||||||
|
usr/share/wayland-sessions/hyprcosmic.desktop) continue ;;
|
||||||
|
esac
|
||||||
|
rm -f "stage/$p"
|
||||||
|
done
|
||||||
|
find stage -type d -empty -delete
|
||||||
|
|
||||||
|
# Worth failing here rather than shipping a package that is missing the
|
||||||
|
# compositor. The negative assertions are the ones that would rot quietly:
|
||||||
|
# nothing may return to the private libexec layout this fork used to
|
||||||
|
# install into, and no cosmic-* name may come back, because either one is
|
||||||
|
# a file conflict that only shows up on a machine that has COSMIC
|
||||||
|
# installed -- which is every machine this is meant for.
|
||||||
|
- name: Assert the staged tree is this fork and nothing else
|
||||||
|
if: matrix.distro != 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
test -x stage/usr/bin/hyprcosmic-comp
|
||||||
|
test -x stage/usr/bin/hyprcosmic-session
|
||||||
|
test -x stage/usr/bin/hyprcosmic-conf
|
||||||
test -x stage/usr/bin/start-hyprcosmic
|
test -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/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 -d stage/usr/share/hyprcosmic
|
||||||
test ! -e stage/usr/libexec/hyprcosmic
|
test ! -e stage/usr/libexec/hyprcosmic
|
||||||
|
test ! -e stage/usr/bin/cosmic-comp
|
||||||
|
test ! -e stage/usr/bin/cosmic-session
|
||||||
|
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
|
||||||
|
test ! -e stage/usr/share/cosmic
|
||||||
|
test -z "$(find stage/usr/bin -mindepth 1 ! -name 'hyprcosmic-*' ! -name 'start-hyprcosmic')"
|
||||||
echo "staged files: $(find stage -type f | wc -l)"
|
echo "staged files: $(find stage -type f | wc -l)"
|
||||||
|
|
||||||
|
# Debian is not reduced, so the assertion is the opposite one: the package
|
||||||
|
# stands alone there and has to carry a desktop that starts. The renamed
|
||||||
|
# three must be present under their new names, and the components the
|
||||||
|
# session launches must still be in the tree rather than assumed to arrive
|
||||||
|
# from a distribution package that does not exist.
|
||||||
|
- name: Assert the staged tree is a complete desktop
|
||||||
|
if: matrix.distro == 'debian'
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
test -x stage/usr/bin/hyprcosmic-comp
|
||||||
|
test -x stage/usr/bin/hyprcosmic-session
|
||||||
|
test -x stage/usr/bin/hyprcosmic-conf
|
||||||
|
test -x stage/usr/bin/start-hyprcosmic
|
||||||
|
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||||
|
test -d stage/usr/share/hyprcosmic
|
||||||
|
test -d stage/usr/share/cosmic
|
||||||
|
test ! -e stage/usr/libexec/hyprcosmic
|
||||||
|
test ! -e stage/usr/bin/cosmic-comp
|
||||||
|
test ! -e stage/usr/bin/cosmic-session
|
||||||
|
test ! -e stage/usr/bin/start-cosmic
|
||||||
|
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
|
||||||
|
for c in cosmic-settings cosmic-settings-daemon cosmic-osd cosmic-notifications; do
|
||||||
|
test -x "stage/usr/bin/$c" || { echo "missing $c" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
echo "staged files: $(find stage -type f | wc -l)"
|
||||||
|
|
||||||
|
# Checked here, once, rather than in each of the three packaging recipes,
|
||||||
|
# and not with desktop-file-validate.
|
||||||
|
#
|
||||||
|
# desktop-file-validate rejects DesktopNames -- "keys extending the format
|
||||||
|
# should start with X-" -- because the Desktop Entry Specification
|
||||||
|
# registers keys for application launchers, and these are session files.
|
||||||
|
# DesktopNames is what a display manager reads to set XDG_CURRENT_DESKTOP,
|
||||||
|
# so the session needs it. cosmic.desktop is upstream cosmic-session's
|
||||||
|
# file, unchanged here apart from the Exec path, and the copy Fedora ships
|
||||||
|
# as cosmic-session-1.5.0-1.fc44 fails the identical check: the validator
|
||||||
|
# has no entry for the key, and every distribution ships the file anyway.
|
||||||
|
#
|
||||||
|
# What the validator would not have caught is the failure that actually
|
||||||
|
# matters: an Exec naming a binary this package does not install puts an
|
||||||
|
# entry on the greeter's menu that dies silently when chosen. So that is
|
||||||
|
# what is checked, against the tree about to be packaged.
|
||||||
|
- name: Check the session entries
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
for f in stage/usr/share/wayland-sessions/*.desktop; do
|
||||||
|
echo "== $f"
|
||||||
|
cat "$f"
|
||||||
|
test "$(sed -n 1p "$f")" = '[Desktop Entry]' || {
|
||||||
|
echo "$f: first line is not [Desktop Entry]" >&2; exit 1; }
|
||||||
|
for key in Name Type Exec DesktopNames; do
|
||||||
|
grep -q "^${key}=" "$f" || { echo "$f: no $key=" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
grep -qx 'Type=Application' "$f" || {
|
||||||
|
echo "$f: Type is not Application; a greeter will ignore it" >&2; exit 1; }
|
||||||
|
exec_path=$(sed -n '0,/^Exec=/s/^Exec=//p' "$f" | cut -d' ' -f1)
|
||||||
|
case $exec_path in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "$f: Exec=$exec_path is not absolute" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
test -x "stage${exec_path}" || {
|
||||||
|
echo "$f: Exec=$exec_path is not an executable this package installs" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo " Exec -> stage${exec_path} ok"
|
||||||
|
done
|
||||||
|
|
||||||
# ---- Fedora -------------------------------------------------------
|
# ---- Fedora -------------------------------------------------------
|
||||||
#
|
#
|
||||||
# The file list is generated rather than written into the spec. Across 27
|
# The file list is generated rather than written into the spec. Across 27
|
||||||
@@ -325,7 +466,11 @@ jobs:
|
|||||||
# dependencies without touching the filesystem, and this image is not
|
# dependencies without touching the filesystem, and this image is not
|
||||||
# a desktop, so a real install would fail on runtime libraries that
|
# a desktop, so a real install would fail on runtime libraries that
|
||||||
# say nothing about whether the package is well formed.
|
# say nothing about whether the package is well formed.
|
||||||
pacman -Qlp dist/*.pkg.tar.zst | head -20
|
# To a file and then head: `| head` would kill pacman with SIGPIPE
|
||||||
|
# once head has its 20 lines, and these steps run with pipefail.
|
||||||
|
pacman -Qlp dist/*.pkg.tar.zst > contents.txt
|
||||||
|
echo "entries: $(wc -l < contents.txt)"
|
||||||
|
head -20 contents.txt
|
||||||
|
|
||||||
# ---- Debian -------------------------------------------------------
|
# ---- Debian -------------------------------------------------------
|
||||||
#
|
#
|
||||||
@@ -364,7 +509,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
dpkg-deb --info dist/*.deb
|
dpkg-deb --info dist/*.deb
|
||||||
dpkg-deb --contents dist/*.deb | head -20
|
# To a file and then head, not `| head`. Actions runs these steps with
|
||||||
|
# pipefail, and head closing the pipe after 20 lines kills dpkg-deb
|
||||||
|
# with SIGPIPE, which pipefail reports as a failed step -- a green
|
||||||
|
# 217 MB package failed here on nothing but that.
|
||||||
|
dpkg-deb --contents dist/*.deb > contents.txt
|
||||||
|
echo "entries: $(wc -l < contents.txt)"
|
||||||
|
head -20 contents.txt
|
||||||
# lintian is not installed and would fail this package on a dozen
|
# lintian is not installed and would fail this package on a dozen
|
||||||
# policy points that do not apply to a desktop fork shipped outside
|
# policy points that do not apply to a desktop fork shipped outside
|
||||||
# the archive. What matters here is that dpkg can read it back.
|
# the archive. What matters here is that dpkg can read it back.
|
||||||
@@ -395,10 +546,11 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: dist/*
|
files: dist/*
|
||||||
# Draft, deliberately. These packages conflict with the
|
# Draft, deliberately. These install beside the distribution's COSMIC
|
||||||
# distribution's cosmic-comp and cosmic-session, so installing one
|
# rather than over it, so a bad one costs a logout rather than a
|
||||||
# replaces the machine's desktop. That is worth a human reading the
|
# desktop -- but a tag push is still not a decision to publish. The
|
||||||
# notes before it is published rather than a tag push making it
|
# generated notes are written by a machine reading commit subjects,
|
||||||
# available.
|
# and the Debian package differs from the other two in what it
|
||||||
|
# carries; both are worth a human reading before anyone downloads.
|
||||||
draft: true
|
draft: true
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ Three things distinguish a HyprCosmic session from a COSMIC one:
|
|||||||
- **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of
|
- **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of
|
||||||
cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported
|
cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported
|
||||||
directly, palette and wallpapers and all.
|
directly, palette and wallpapers and all.
|
||||||
- **It replaces COSMIC rather than sitting next to it.** The binaries install as
|
- **It installs next to COSMIC rather than over it.** The binaries are
|
||||||
`/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, the paths a cosmic-comp
|
`/usr/bin/hyprcosmic-comp`, `/usr/bin/hyprcosmic-session` and
|
||||||
and a cosmic-session go to, and the packages conflict with the distribution's
|
`/usr/bin/hyprcosmic-conf`, and nothing here writes a path the distribution
|
||||||
accordingly. Both session entries are installed, so the greeter still offers a
|
owns. The stock COSMIC entry stays on the greeter's menu, served by the
|
||||||
stock COSMIC shell for the day the HyDE one does not start — now served by
|
distribution's own binaries, so the day the HyDE session does not start is one
|
||||||
these binaries rather than by a second copy on disk.
|
logout away from a desktop that does. (On Debian, where COSMIC is not
|
||||||
|
packaged, the `.deb` carries the desktop itself — see [Installing](#installing).)
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -54,8 +55,9 @@ current.
|
|||||||
deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
|
deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
|
||||||
because this is the surface any process that can open the socket gets.
|
because this is the surface any process that can open the socket gets.
|
||||||
- New windows open *beside* the focused window rather than inside it.
|
- 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
|
- The install goes to `/usr/bin/hyprcosmic-comp`, alongside upstream's two
|
||||||
upstream's two `.ron` defaults files, which are carried unmodified.
|
`.ron` defaults files, which are carried unmodified. The distribution's
|
||||||
|
`cosmic-comp` is left where it is, for the stock session to keep using.
|
||||||
|
|
||||||
**cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by
|
**cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by
|
||||||
`hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library,
|
`hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library,
|
||||||
@@ -93,14 +95,24 @@ sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
|
|||||||
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
|
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
|
||||||
```
|
```
|
||||||
|
|
||||||
Expect this to fail the first time, and read what it says when it does. These
|
Nothing is removed and nothing conflicts. COSMIC is a dependency rather than a
|
||||||
packages provide `/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, so they
|
casualty: the package installs `hyprcosmic-comp`, `hyprcosmic-session` and
|
||||||
**conflict with the distribution's `cosmic-comp` and `cosmic-session`** and your
|
`hyprcosmic-conf` beside the distribution's, and takes cosmic-settings, the
|
||||||
package manager will refuse until those are removed. That refusal is the design:
|
portal, the OSD and the rest from the distribution at the version it tested
|
||||||
installing HyprCosmic replaces the machine's desktop, and it should take a
|
them at. Log out and pick **HyprCosmic** from the greeter; pick **COSMIC** to go
|
||||||
deliberate `dnf remove cosmic-comp cosmic-session` to say so rather than a
|
back.
|
||||||
resolver deciding on your behalf. Both session entries survive the swap, so the
|
|
||||||
greeter still offers a stock COSMIC shell afterwards.
|
An earlier revision did take the `cosmic-*` names, and it could not be
|
||||||
|
installed. Its files collided with 25 distribution packages, and the only way to
|
||||||
|
satisfy that was to erase them — including cosmic-greeter, which on a Fedora
|
||||||
|
COSMIC install *is* the display manager. A desktop you can only try by removing
|
||||||
|
the desktop you would fall back to is not one worth shipping.
|
||||||
|
|
||||||
|
**Debian is the exception**, because COSMIC is not packaged there — no
|
||||||
|
`cosmic-session`, no `cosmic-comp`, in any suite. There is nothing to depend on
|
||||||
|
and nothing to install beside, so the `.deb` carries the whole desktop it
|
||||||
|
compiled and stands alone, and the greeter offers **HyprCosmic** only. The
|
||||||
|
Fedora and Arch packages ship this fork's three binaries and nothing else.
|
||||||
|
|
||||||
Building it yourself instead:
|
Building it yourself instead:
|
||||||
|
|
||||||
@@ -125,6 +137,13 @@ 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
|
`cosmic-conf` at `$prefix/bin/cosmic-conf`, the shared waybar and rofi assets
|
||||||
under `$prefix/share/hyprcosmic/`, and `hyprcosmic-powermenu`.
|
under `$prefix/share/hyprcosmic/`, and `hyprcosmic-powermenu`.
|
||||||
|
|
||||||
|
Note that `just install` is not what the packages do. It writes upstream's whole
|
||||||
|
desktop at upstream's names, so run against `/usr` on a machine that has COSMIC
|
||||||
|
packaged it will overwrite files your package manager owns. The packages are
|
||||||
|
built from this same tree and then reduced to this fork's own files and renamed;
|
||||||
|
that step lives in `.github/workflows/packages.yml`, not in the justfile, which
|
||||||
|
is upstream's. Stage to a directory and inspect it, or install a package.
|
||||||
|
|
||||||
`install` depends on `build`, which is upstream's arrangement and means `sudo
|
`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
|
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.
|
rather not, build into a staging root as your own user and copy it into place.
|
||||||
@@ -134,26 +153,85 @@ Then log out. `HyprCosmic` appears on the greeter's session menu next to
|
|||||||
|
|
||||||
### Per-user setup
|
### Per-user setup
|
||||||
|
|
||||||
`just install` places nothing in a home directory — under `sudo` the only home
|
Nothing to do. The session seeds `~/.config` from `/usr/share/hyprcosmic/skel`
|
||||||
directory it could see is root's. Four files are yours to place:
|
at login, copying only what is missing:
|
||||||
|
|
||||||
```shell
|
```
|
||||||
mkdir -p ~/.config/hyprcosmic/waybar
|
~/.config/hyprcosmic/autostart
|
||||||
cp config/cosmic.conf config/autostart ~/.config/hyprcosmic/
|
~/.config/hyprcosmic/cosmic.conf
|
||||||
cp config/waybar/style.css ~/.config/hyprcosmic/waybar/
|
~/.config/hyprcosmic/waybar/style.css
|
||||||
|
~/.config/hyprcosmic/waybar/theme.css
|
||||||
|
~/.config/rofi/config.rasi
|
||||||
|
~/.config/rofi/theme.rasi
|
||||||
|
~/.config/rofi/local.rasi
|
||||||
```
|
```
|
||||||
|
|
||||||
`style.css` is per-user rather than shared for one reason: it `@import`s a
|
An existing file is never touched, not even when the skeleton is newer — the
|
||||||
sibling `theme.css` holding the installed HyDE theme's palette, and a relative
|
file wins, one way, and a login is not an invitation to edit your config. Two
|
||||||
`@import` resolves against the importing file. That sibling is written by
|
things follow: your edits survive every login and upgrade, and deleting a file
|
||||||
`import-theme --assets`, so the bar is unstyled until you have imported a theme.
|
is how you ask for the default back. `~/.cache/hyprcosmic/session.log` records
|
||||||
|
what was seeded.
|
||||||
|
|
||||||
The fourth file, `~/.config/rofi/config.rasi`, is written by `import-theme
|
The cost of that is real and worth stating: an upgrade that improves a shipped
|
||||||
--assets` too, because it names per-machine paths.
|
default will not reach a file you already have. If a release changes something
|
||||||
|
you want — the `autostart` line that sets the wallpaper did change once — take
|
||||||
|
it deliberately, after reading what you would lose:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
diff -u ~/.config/hyprcosmic/autostart /usr/share/hyprcosmic/skel/hyprcosmic/autostart
|
||||||
|
cp /usr/share/hyprcosmic/skel/hyprcosmic/autostart ~/.config/hyprcosmic/
|
||||||
|
```
|
||||||
|
|
||||||
|
`autostart` is the one that matters most, because it is what starts waybar, the
|
||||||
|
wallpaper daemon and `hyprcosmic-conf watch`. Until the session seeded it, a
|
||||||
|
machine that had never run HyprCosmic logged in to a bare compositor: running,
|
||||||
|
holding the display and accepting input, with nothing drawn on the screen and no
|
||||||
|
binding that opened anything.
|
||||||
|
|
||||||
|
`style.css` and `config.rasi` are per-user rather than shared for one reason:
|
||||||
|
each `@import`s a sibling holding the installed HyDE theme's palette, and a
|
||||||
|
relative `@import` resolves against the importing file. Those siblings —
|
||||||
|
`theme.css`, `theme.rasi` and `local.rasi` — arrive empty and are written by
|
||||||
|
`import-theme --assets`. They ship empty rather than not at all because a
|
||||||
|
missing `@import` is fatal to both consumers rather than a warning they skip:
|
||||||
|
GTK fails the entire stylesheet, and rofi reports the error in place of the
|
||||||
|
launcher.
|
||||||
|
|
||||||
|
Working from a git checkout, `just install` still places nothing in a home
|
||||||
|
directory — under `sudo` the only home directory it could see is root's — but it
|
||||||
|
does install the skeleton, so logging in seeds the same seven files.
|
||||||
|
|
||||||
Runtime dependencies of the shell itself are not COSMIC's and are not built
|
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
|
here: `waybar`, `rofi` (wayland build), `awww` (formerly `swww`), and a Nerd
|
||||||
Font for the bar's glyphs.
|
Font for the glyphs the bar and the launcher draw with.
|
||||||
|
|
||||||
|
The wallpaper is `awww`'s job, and the image it draws is
|
||||||
|
`~/.local/share/wallpapers/hyprcosmic/current` — a symlink `import-theme
|
||||||
|
--assets` maintains. Before you have imported a theme there is no such link, so
|
||||||
|
`hyprcosmic-wallpaper` falls back to whatever the distribution ships,
|
||||||
|
`/usr/share/backgrounds/cosmic` first; the Fedora package recommends
|
||||||
|
`cosmic-wallpapers` so there is something there. With no `awww` installed it
|
||||||
|
says so on the session log and leaves the background alone, rather than waiting
|
||||||
|
for a daemon that is never coming.
|
||||||
|
|
||||||
|
The font is the one thing the packaging cannot do for you on Fedora, which has
|
||||||
|
no package that provides a Nerd Font at all: `texlive-inconsolata-nerd-font`
|
||||||
|
installs under `texmf-dist` and kitty's `SymbolsNerdFont` under
|
||||||
|
`/usr/lib64/kitty`, and fontconfig scans neither. Arch has
|
||||||
|
`ttf-nerd-fonts-symbols`. Otherwise, install one into your own font directory:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mkdir -p ~/.local/share/fonts/JetBrainsMonoNerdFont
|
||||||
|
# unpack JetBrainsMono.zip from github.com/ryanoasis/nerd-fonts/releases there
|
||||||
|
fc-cache -f
|
||||||
|
fc-list ":charset=e0b0" family | grep -i nerd # non-empty: the glyphs resolve
|
||||||
|
```
|
||||||
|
|
||||||
|
Query the charset on its own, as above. Adding a family filter —
|
||||||
|
`":charset=e0b0:family=JetBrainsMono Nerd Font"` — reports nothing even when the
|
||||||
|
font does cover the codepoint, because the family string is a comma-separated
|
||||||
|
alias list (`JetBrainsMono Nerd Font,JetBrainsMono NF`). Without the font the
|
||||||
|
bar still works; every icon is a tofu box.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -161,9 +239,9 @@ Font for the bar's glyphs.
|
|||||||
`cosmic-config` by:
|
`cosmic-config` by:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cosmic-conf apply # once
|
hyprcosmic-conf apply # once
|
||||||
cosmic-conf apply --diff # show what would change, write nothing
|
hyprcosmic-conf apply --diff # show what would change, write nothing
|
||||||
cosmic-conf watch # recompile on every edit, for the whole session
|
hyprcosmic-conf watch # recompile on every edit, for the whole session
|
||||||
```
|
```
|
||||||
|
|
||||||
`watch` is the first line of the shipped `autostart`, which is what makes "the
|
`watch` is the first line of the shipped `autostart`, which is what makes "the
|
||||||
@@ -208,7 +286,7 @@ the reference for what is supported.
|
|||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
|
hyprcosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
|
||||||
--out ~/.config/hyprcosmic/theme.conf --report --assets
|
--out ~/.config/hyprcosmic/theme.conf --report --assets
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+15
-17
@@ -23,7 +23,7 @@
|
|||||||
# A malformed edit is not fatal. It is reported to the session log and the last
|
# 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
|
# good configuration stays in place, so a typo cannot leave you at a broken
|
||||||
# desktop -- fix the file and the next save applies.
|
# desktop -- fix the file and the next save applies.
|
||||||
cosmic-conf watch
|
hyprcosmic-conf watch
|
||||||
|
|
||||||
# The bar. The layout is shared and lives under /usr/share, but the stylesheet
|
# 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
|
# has to be per-user: it imports a sibling theme.css holding the installed HyDE
|
||||||
@@ -60,28 +60,26 @@ awww-daemon
|
|||||||
# get a blank screen below the bar with no error anywhere: the failure is that
|
# 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.
|
# 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
|
# It sets ~/.local/share/wallpapers/hyprcosmic/current, a symlink
|
||||||
# only just been forked and is not listening yet, so an immediate `awww img`
|
# `hyprcosmic-conf import-theme --assets` maintains beside the wallpapers it
|
||||||
# loses a race and fails silently. The loop waits for the daemon to answer
|
# copies. The link is used rather than a real path so that this and rofi's
|
||||||
# before setting the image.
|
# local.rasi -- which shows the same image in the launcher's sidebar -- cannot
|
||||||
|
# drift apart, and so that importing a different theme does not leave either
|
||||||
|
# pointing at a file that no longer exists.
|
||||||
#
|
#
|
||||||
# This does not weaken the no-shell rule in the header. That rule exists so a
|
# Until a theme has been imported there is no such link, so on a machine that
|
||||||
# file naming programs cannot be escalated into arbitrary execution; naming
|
# has just installed the package it falls back to whatever the distribution
|
||||||
# `sh` explicitly is just naming a program, and anyone able to write this file
|
# ships, /usr/share/backgrounds/cosmic first. A first login gets a desktop.
|
||||||
# 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:
|
# Change the wallpaper by repointing the link, not by editing this file:
|
||||||
#
|
#
|
||||||
# ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
|
# ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
|
||||||
# ~/.local/share/wallpapers/hyprcosmic/current
|
# ~/.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 program rather than the `sh -c` this used to be, because waiting for the
|
||||||
|
# daemon needs a bound: awww is a Recommends in a COPR, and the old unbounded
|
||||||
|
# `until awww query` loop spun for the whole session on a machine without it.
|
||||||
|
hyprcosmic-wallpaper
|
||||||
|
|
||||||
# A terminal, unconditionally, as the way back in.
|
# A terminal, unconditionally, as the way back in.
|
||||||
#
|
#
|
||||||
|
|||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Report fan speeds for waybar's custom/fan module, as JSON on stdout.
|
||||||
|
#
|
||||||
|
# waybar has no fan module, and its temperature module cannot be borrowed for
|
||||||
|
# this: it divides by 1000 to turn millidegrees into degrees, which would render
|
||||||
|
# 2700 rpm as 2 C.
|
||||||
|
#
|
||||||
|
# Nor can the sensor be named the way "temperature" names its own, with
|
||||||
|
# hwmon-path-abs. That option points at a *parent* directory and takes the one
|
||||||
|
# hwmonN inside it, which works for k10temp and amdgpu because each has exactly
|
||||||
|
# one. The asus platform device has two --
|
||||||
|
#
|
||||||
|
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon9 name=asus
|
||||||
|
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon10 name=asus_custom_fan_curve
|
||||||
|
#
|
||||||
|
# -- and only the first has fan*_input; the second holds the curve's set points.
|
||||||
|
# Which of the two waybar picked would be down to readdir order.
|
||||||
|
#
|
||||||
|
# So this resolves by content instead of by path: any hwmon with a fan*_input.
|
||||||
|
# That also makes it portable off this laptop, which a hardcoded asus path would
|
||||||
|
# not be. Fans on a desktop's it87 or a thinkpad's thinkpad_hwmon are found the
|
||||||
|
# same way.
|
||||||
|
#
|
||||||
|
# Prints nothing at all when the machine has no readable fan -- a VM, or a
|
||||||
|
# passively cooled box. waybar renders an empty custom module as nothing, so the
|
||||||
|
# bar loses the item rather than showing a dead zero.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
max=0
|
||||||
|
tooltip=''
|
||||||
|
|
||||||
|
for hwmon in /sys/class/hwmon/hwmon*; do
|
||||||
|
[ -d "$hwmon" ] || continue
|
||||||
|
|
||||||
|
# Not every hwmon has a name, and a chip is worth naming in the tooltip
|
||||||
|
# when it does: "cpu_fan" alone does not say which controller reported it.
|
||||||
|
chip=$(cat "$hwmon/name" 2>/dev/null) || chip=''
|
||||||
|
[ -n "$chip" ] || chip=$(basename "$hwmon")
|
||||||
|
|
||||||
|
for input in "$hwmon"/fan*_input; do
|
||||||
|
# The glob is literal when nothing matches, which is the common case:
|
||||||
|
# most hwmons here are temperature-only.
|
||||||
|
[ -e "$input" ] || continue
|
||||||
|
|
||||||
|
rpm=$(cat "$input" 2>/dev/null) || continue
|
||||||
|
# A fan that is stopped reads 0, and a fan that has been unbound reads
|
||||||
|
# nothing. Neither is an error, but neither belongs in the tooltip.
|
||||||
|
case $rpm in
|
||||||
|
'' | *[!0-9]*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# fanN_label when the driver supplies one -- "cpu_fan", "gpu_fan" --
|
||||||
|
# and fanN otherwise.
|
||||||
|
label=$(cat "${input%_input}_label" 2>/dev/null) || label=''
|
||||||
|
[ -n "$label" ] || label=$(basename "${input%_input}")
|
||||||
|
|
||||||
|
[ "$rpm" -gt "$max" ] && max=$rpm
|
||||||
|
|
||||||
|
line="$chip $label: $rpm rpm"
|
||||||
|
if [ -n "$tooltip" ]; then
|
||||||
|
tooltip="$tooltip\\n$line"
|
||||||
|
else
|
||||||
|
tooltip=$line
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# No fan anywhere. Say nothing rather than reporting a confident 0 rpm.
|
||||||
|
[ -n "$tooltip" ] || exit 0
|
||||||
|
|
||||||
|
# The bar shows the fastest fan, because that is the one you can hear and the
|
||||||
|
# one that says whether the machine is working. The rest are in the tooltip.
|
||||||
|
#
|
||||||
|
# The class drives the colour in rules.css. 4000 rpm is where this laptop's fans
|
||||||
|
# become audible over a quiet room; below 1 rpm every fan is stopped, which is
|
||||||
|
# worth showing differently from a slow one.
|
||||||
|
if [ "$max" -ge 4000 ]; then
|
||||||
|
class=high
|
||||||
|
elif [ "$max" -eq 0 ]; then
|
||||||
|
class=idle
|
||||||
|
else
|
||||||
|
class=normal
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"text":"%s","tooltip":"%s","class":"%s"}\n' "$max" "$tooltip" "$class"
|
||||||
Executable
+205
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
#
|
||||||
|
# Show every keyboard shortcut in the session, and what each one runs.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# COSMIC has no keyboard reference. cosmic-settings lists shortcuts in a
|
||||||
|
# scrolling pane behind three clicks, which is where you go to *change* one, not
|
||||||
|
# where you go to remember one mid-task. Hyprland desktops all ship a cheatsheet
|
||||||
|
# on a key and a bar click for exactly that reason, and this is that.
|
||||||
|
#
|
||||||
|
# WHERE THE BINDINGS COME FROM, AND WHY NOT cosmic.conf
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# The obvious implementation reads cosmic.conf, and it would be wrong. This
|
||||||
|
# machine's cosmic.conf declares six bindings. The session answers to 122. The
|
||||||
|
# other 116 are COSMIC's own defaults, which the fork does not restate because
|
||||||
|
# it has no reason to -- Super+Q closes a window whether or not anybody wrote it
|
||||||
|
# down. A cheatsheet showing six entries would not look broken; it would look
|
||||||
|
# complete, and it would be missing every window, workspace and media key on the
|
||||||
|
# machine. That is a worse failure than not having the feature.
|
||||||
|
#
|
||||||
|
# So the sources are the three RON files the compositor actually reads:
|
||||||
|
#
|
||||||
|
# /usr/share/cosmic/.../Shortcuts/v1/defaults 116 COSMIC built-ins
|
||||||
|
# ~/.config/cosmic/.../Shortcuts/v1/custom 6 ours, projected from
|
||||||
|
# cosmic.conf by cosmic-conf
|
||||||
|
# /usr/share/cosmic/.../Shortcuts/v1/system_actions
|
||||||
|
# what System(X) actually runs
|
||||||
|
#
|
||||||
|
# custom wins over defaults on a collision, matching the order the compositor
|
||||||
|
# merges them, so a rebound key is listed once with the binding in force.
|
||||||
|
#
|
||||||
|
# system_actions is what makes this answer the question that was asked. A
|
||||||
|
# default reads `System(Screenshot)`, which names an action and not a program;
|
||||||
|
# system_actions maps that to `cosmic-screenshot`. Without it a third of the
|
||||||
|
# list would name internal actions rather than the applications they launch.
|
||||||
|
#
|
||||||
|
# THE PARSER
|
||||||
|
# ----------
|
||||||
|
# These are RON files, and this reads them with a regex, which deserves a
|
||||||
|
# defence. The alternative is a `cosmic-conf binds` subcommand, which would be
|
||||||
|
# the principled home for this -- cosmic-conf already has the schema and the
|
||||||
|
# bind grammar. That is a compiled change and a full package build, and this
|
||||||
|
# feature does not need one: the files are one binding per line, and the shape
|
||||||
|
# has not moved. The cost of being wrong here is a mangled row in a reference
|
||||||
|
# window, not a broken keybinding, because nothing downstream consumes this.
|
||||||
|
#
|
||||||
|
# If the format does move, the failure is visible immediately and loudly: the
|
||||||
|
# count check below refuses to show a list it could not parse.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SYS_DIR=/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1
|
||||||
|
USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic/com.system76.CosmicSettings.Shortcuts/v1"
|
||||||
|
|
||||||
|
command -v rofi >/dev/null 2>&1 || {
|
||||||
|
printf 'hyprcosmic-keybinds: rofi is not installed\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Errors go to a rofi dialog as well as stderr: the two ways in are a keybinding
|
||||||
|
# and a bar click, and neither has a terminal to read stderr from. Same reasoning
|
||||||
|
# as hyprcosmic-powermenu.
|
||||||
|
fail() {
|
||||||
|
printf 'hyprcosmic-keybinds: %s\n' "$*" >&2
|
||||||
|
rofi -e "$*" >/dev/null 2>&1 || :
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -r "$SYS_DIR/defaults" ]] ||
|
||||||
|
fail "cannot read $SYS_DIR/defaults, so the built-in shortcuts are unknown"
|
||||||
|
|
||||||
|
# Resolve System(X) to the command it runs, as "X<tab>command" pairs.
|
||||||
|
#
|
||||||
|
# system_actions carries /// doc comments and commented-out entries for actions
|
||||||
|
# that exist in the enum but are not wired up (KeyboardBrightnessUp is one), so
|
||||||
|
# this matches only lines that are a bare name followed by a quoted string.
|
||||||
|
system_actions() {
|
||||||
|
[[ -r "$SYS_DIR/system_actions" ]] || return 0
|
||||||
|
sed -nE 's/^[[:space:]]*([A-Za-z]+):[[:space:]]*"(.*)",?[[:space:]]*$/\1\t\2/p' \
|
||||||
|
"$SYS_DIR/system_actions"
|
||||||
|
}
|
||||||
|
|
||||||
|
# One binding per line, as "modifiers<tab>key<tab>action".
|
||||||
|
#
|
||||||
|
# Two shapes appear. The usual one carries a key:
|
||||||
|
# (modifiers: [Super, Shift], key: "Escape"): System(LogOut),
|
||||||
|
# and a modifier-only binding has none at all:
|
||||||
|
# (modifiers: [Super]): Spawn("rofi -show drun"),
|
||||||
|
# The second is how Super alone opens the launcher, so dropping it would lose
|
||||||
|
# the single most used binding on the machine.
|
||||||
|
bindings() {
|
||||||
|
sed -nE \
|
||||||
|
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\],[[:space:]]*key:[[:space:]]*"([^"]*)"\):[[:space:]]*(.*),[[:space:]]*$/\1\t\2\t\3/p' \
|
||||||
|
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\]\):[[:space:]]*(.*),[[:space:]]*$/\1\t\t\2/p' \
|
||||||
|
"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
actions_tsv="$(system_actions)"
|
||||||
|
|
||||||
|
# custom is read second so that its rows overwrite defaults in the map below.
|
||||||
|
# Missing is normal, not an error: a session with no custom bindings has no such
|
||||||
|
# file, and every default still applies.
|
||||||
|
raw="$(bindings "$SYS_DIR/defaults")"
|
||||||
|
if [[ -r "$USER_DIR/custom" ]]; then
|
||||||
|
raw+=$'\n'"$(bindings "$USER_DIR/custom")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "${raw//[[:space:]]/}" ]] ||
|
||||||
|
fail "parsed no shortcuts at all from $SYS_DIR/defaults; the file format has changed"
|
||||||
|
|
||||||
|
rendered="$(
|
||||||
|
LC_ALL=C awk -F'\t' -v actions="$actions_tsv" '
|
||||||
|
BEGIN {
|
||||||
|
# System(X) -> command, from system_actions.
|
||||||
|
n = split(actions, lines, "\n")
|
||||||
|
for (i = 1; i <= n; i++) {
|
||||||
|
if (split(lines[i], kv, "\t") == 2) cmd[kv[1]] = kv[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Turn CamelCase into words so an action reads as a description rather than
|
||||||
|
# an identifier: MoveToWorkspace -> "move to workspace". Applied only to the
|
||||||
|
# action name, never to a command, which must stay verbatim to be typed.
|
||||||
|
function words(s, out) {
|
||||||
|
out = s
|
||||||
|
gsub(/([a-z0-9])([A-Z])/, "\\1 \\2", out)
|
||||||
|
return tolower(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Super is written first and Shift last, so that chords sort and read the
|
||||||
|
# way they are pressed rather than the way the RON happened to list them.
|
||||||
|
function order(m) {
|
||||||
|
if (m == "Super") return 1
|
||||||
|
if (m == "Ctrl") return 2
|
||||||
|
if (m == "Alt") return 3
|
||||||
|
if (m == "Shift") return 4
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
mods = $1; key = $2; action = $3
|
||||||
|
if (action == "") next
|
||||||
|
|
||||||
|
# "Super, Alt" -> "Super + Alt", in a fixed order.
|
||||||
|
cnt = split(mods, m, /,[[:space:]]*/)
|
||||||
|
for (i = 1; i <= cnt; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", m[i])
|
||||||
|
for (i = 1; i < cnt; i++)
|
||||||
|
for (j = i + 1; j <= cnt; j++)
|
||||||
|
if (order(m[j]) < order(m[i])) { t = m[i]; m[i] = m[j]; m[j] = t }
|
||||||
|
|
||||||
|
chord = ""
|
||||||
|
for (i = 1; i <= cnt; i++) chord = chord (chord == "" ? "" : " + ") m[i]
|
||||||
|
if (key != "") chord = chord (chord == "" ? "" : " + ") key
|
||||||
|
|
||||||
|
# Spawn("cmd") is a command as written in cosmic.conf.
|
||||||
|
# System(X) is an action name that system_actions turns into a command.
|
||||||
|
# Everything else is internal to the compositor and has no command; the
|
||||||
|
# action itself is the honest answer, and inventing a program name for
|
||||||
|
# Focus(Left) would be a lie.
|
||||||
|
what = ""
|
||||||
|
if (action ~ /^Spawn\(".*"\)$/) {
|
||||||
|
what = substr(action, 8, length(action) - 9)
|
||||||
|
} else if (action ~ /^System\(/) {
|
||||||
|
name = action
|
||||||
|
sub(/^System\(/, "", name); sub(/\)$/, "", name)
|
||||||
|
what = (name in cmd) ? cmd[name] : words(name)
|
||||||
|
} else if (action ~ /\(/) {
|
||||||
|
name = action; arg = action
|
||||||
|
sub(/\(.*$/, "", name)
|
||||||
|
sub(/^[^(]*\(/, "", arg); sub(/\)$/, "", arg)
|
||||||
|
what = words(name) " " arg
|
||||||
|
} else {
|
||||||
|
what = words(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last write wins, which is why custom is appended after defaults.
|
||||||
|
seen[chord] = what
|
||||||
|
if (!(chord in orderkey)) orderkey[chord] = ++seq
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
for (c in seen) printf "%d\t%s\t%s\n", orderkey[c], c, seen[c]
|
||||||
|
}
|
||||||
|
' <<<"$raw" | sort -n | cut -f2-
|
||||||
|
)"
|
||||||
|
|
||||||
|
count="$(grep -c . <<<"$rendered")"
|
||||||
|
((count > 0)) || fail "every shortcut line failed to parse; the RON format has changed"
|
||||||
|
|
||||||
|
# Column-aligned so the chords form a readable left edge. Done here rather than
|
||||||
|
# in awk because the width has to be measured across the whole set first.
|
||||||
|
width="$(cut -f1 <<<"$rendered" | LC_ALL=C awk '{ if (length($0) > w) w = length($0) } END { print w }')"
|
||||||
|
|
||||||
|
# -dmenu rather than a static window, so the list is filterable: 122 bindings is
|
||||||
|
# more than fits on a screen, and typing "workspace" is how you actually use
|
||||||
|
# this. No theme arguments, so ~/.config/rofi/config.rasi applies and it matches
|
||||||
|
# the launcher and the power menu.
|
||||||
|
#
|
||||||
|
# The selection is discarded. This is a reference, not a menu -- there is no
|
||||||
|
# sensible action for "you picked Super + Q", and running the bound command on
|
||||||
|
# Return would make an accidental keypress close a window from a help screen.
|
||||||
|
LC_ALL=C awk -F'\t' -v w="$width" '{ printf "%-*s %s\n", w, $1, $2 }' <<<"$rendered" |
|
||||||
|
rofi -dmenu -i -no-custom -no-show-icons -p "keys" \
|
||||||
|
-mesg "$count shortcuts" >/dev/null 2>&1 || :
|
||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Set the desktop wallpaper once the wallpaper daemon is listening.
|
||||||
|
#
|
||||||
|
# The hyprcosmic profile does not start cosmic-bg, so nothing else draws a
|
||||||
|
# background: without this there is a black screen below the bar, and no error
|
||||||
|
# anywhere, because the failure is that nobody asked for a wallpaper.
|
||||||
|
#
|
||||||
|
# This was three shell fragments on one autostart line. It became a program for
|
||||||
|
# two reasons, both of which cost a first login its desktop:
|
||||||
|
#
|
||||||
|
# - `current` is a symlink `import-theme --assets` maintains, so it does not
|
||||||
|
# exist until a theme has been imported. Naming it directly meant a machine
|
||||||
|
# that had just installed the package set no wallpaper at all.
|
||||||
|
# - The wait for the daemon was `until awww query; do sleep 0.2; done`, with
|
||||||
|
# nothing to stop it. awww is a Recommends and lives in a COPR, so on a
|
||||||
|
# machine that never enabled that repository the loop spun at 5 Hz for the
|
||||||
|
# length of the session.
|
||||||
|
#
|
||||||
|
# Both are now bounded and both say so on the session log.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
link="${XDG_DATA_HOME:-$HOME/.local/share}/wallpapers/hyprcosmic/current"
|
||||||
|
|
||||||
|
# Searched in order when the link is not there yet. The first is cosmic-wallpapers,
|
||||||
|
# which the Fedora package recommends for exactly this; the other two are where
|
||||||
|
# freedesktop and KDE put theirs, so a machine with neither still has a chance.
|
||||||
|
fallbacks='/usr/share/backgrounds/cosmic /usr/share/backgrounds /usr/share/wallpapers'
|
||||||
|
|
||||||
|
if ! command -v awww >/dev/null 2>&1; then
|
||||||
|
echo "hyprcosmic-wallpaper: awww is not installed; no wallpaper set" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# awww-daemon is started immediately above this in autostart and has only just
|
||||||
|
# been forked, so an immediate `awww img` loses the race and fails silently.
|
||||||
|
# Ten seconds is far longer than the daemon has ever taken and short enough that
|
||||||
|
# a daemon which is never coming does not leave a process spinning all session.
|
||||||
|
tries=0
|
||||||
|
until awww query >/dev/null 2>&1; do
|
||||||
|
tries=$((tries + 1))
|
||||||
|
if [ "$tries" -ge 50 ]; then
|
||||||
|
echo "hyprcosmic-wallpaper: awww-daemon did not answer in 10s; no wallpaper set" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
# -e rather than -L, deliberately: it follows the link, so a `current` left
|
||||||
|
# dangling by a deleted theme falls through to the defaults instead of being
|
||||||
|
# handed to awww as a path that is not there.
|
||||||
|
img=''
|
||||||
|
if [ -e "$link" ]; then
|
||||||
|
img=$link
|
||||||
|
else
|
||||||
|
for dir in $fallbacks; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
for candidate in "$dir"/*.jpg "$dir"/*.jpeg "$dir"/*.png; do
|
||||||
|
# The glob is literal when nothing matches, which is why
|
||||||
|
# this tests the file rather than trusting the expansion.
|
||||||
|
[ -f "$candidate" ] || continue
|
||||||
|
img=$candidate
|
||||||
|
break
|
||||||
|
done
|
||||||
|
# An if, not `[ -n "$img" ] && break`: under set -e a trailing
|
||||||
|
# false at the end of the loop body would take the script with it.
|
||||||
|
if [ -n "$img" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$img" ]; then
|
||||||
|
echo "hyprcosmic-wallpaper: no $link yet, falling back to $img" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$img" ]; then
|
||||||
|
echo "hyprcosmic-wallpaper: no wallpaper found in $link or $fallbacks" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec awww img "$img"
|
||||||
@@ -50,6 +50,80 @@ general {
|
|||||||
gaps_out = $gap * 2
|
gaps_out = $gap * 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Workspaces ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# COSMIC's workspaces are dynamic and there is no setting that turns that off:
|
||||||
|
# it keeps exactly one trailing empty workspace and collects every other empty
|
||||||
|
# one the moment its last window closes. So "workspace 4 is the browser" is only
|
||||||
|
# true while the browser is open, and the number moves under you as workspaces
|
||||||
|
# come and go.
|
||||||
|
#
|
||||||
|
# These lines declare a fixed set instead, which is what a Hyprland user expects.
|
||||||
|
# A declared workspace is pinned, and pinning is precisely what exempts it from
|
||||||
|
# that collection, so it stays at its number whether or not anything is on it.
|
||||||
|
#
|
||||||
|
# The dynamic workspace is not lost. Declaring four leaves you with 1-4 always
|
||||||
|
# present and a fifth appearing the moment you use it, then a sixth after that,
|
||||||
|
# exactly as Hyprland does.
|
||||||
|
#
|
||||||
|
# Two things worth knowing before you edit:
|
||||||
|
#
|
||||||
|
# * The index is required and workspaces below it are created too, because
|
||||||
|
# COSMIC restores them by position rather than by number. `workspace = 4`
|
||||||
|
# on its own gives you four workspaces, three of them unnamed.
|
||||||
|
# * This is the one key in this file that is not live. The compositor reads
|
||||||
|
# it once at startup, so an edit applies at your next login. Everything
|
||||||
|
# else here takes effect as soon as you save.
|
||||||
|
#
|
||||||
|
# `name:` is what waybar and anything else reading ext-workspace will show.
|
||||||
|
# `tiling:` overrides `general:autotile` for that workspace alone; leave it out
|
||||||
|
# and the workspace follows the setting above. Hyprland's `monitor:` is not
|
||||||
|
# accepted -- COSMIC matches a workspace to a monitor by EDID rather than by
|
||||||
|
# name, and there is no way to write an EDID down here. Saying so is better
|
||||||
|
# than accepting the parameter and quietly ignoring it.
|
||||||
|
#
|
||||||
|
# Commented out because a fixed set is a preference, not an improvement, and
|
||||||
|
# leaving these off gives you COSMIC's stock behaviour. Uncomment to opt in.
|
||||||
|
#
|
||||||
|
# workspace = 1, name:term
|
||||||
|
# workspace = 2, name:web
|
||||||
|
# workspace = 3, name:code
|
||||||
|
# workspace = 4, name:chat
|
||||||
|
|
||||||
|
# --- Window rules --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Where an application opens, decided from what it is rather than from where
|
||||||
|
# you happened to be standing. `windowrule = workspace 4, class:^(vivaldi)$`
|
||||||
|
# means the browser lands on workspace 4 no matter which one is in front of you.
|
||||||
|
#
|
||||||
|
# Rules are matched once, as the window opens, and the first one that matches
|
||||||
|
# wins -- so put the specific ones above the general ones. `class:` and `title:`
|
||||||
|
# are regular expressions; give both and both have to match. A rule with neither
|
||||||
|
# is refused, since it would match every window in the session.
|
||||||
|
#
|
||||||
|
# These pair with the workspace lines above. `workspace name:web` is worth
|
||||||
|
# preferring over `workspace 2`: a name follows the workspace if you renumber
|
||||||
|
# it, and only exists because you declared it, so a typo fails loudly rather
|
||||||
|
# than sending the window to whatever happens to be second.
|
||||||
|
#
|
||||||
|
# A rule never switches you to the workspace it used -- Hyprland's `silent`,
|
||||||
|
# always on. The word is accepted so pasted-in configs keep working.
|
||||||
|
#
|
||||||
|
# Only `workspace` is supported. Hyprland's float, size, move, opacity and the
|
||||||
|
# rest have nothing in COSMIC to project onto, and matchers that ask about
|
||||||
|
# window state -- floating:, fullscreen:, onworkspace: -- cannot be answered at
|
||||||
|
# the moment a window opens. Both fail with an explanation rather than parsing
|
||||||
|
# and doing nothing. Floating for a particular application is a tiling
|
||||||
|
# exception, which lives in COSMIC's own settings rather than in this file.
|
||||||
|
#
|
||||||
|
# Unlike the workspace lines, these are live: save the file and the next window
|
||||||
|
# to open obeys them.
|
||||||
|
#
|
||||||
|
# windowrule = workspace name:web, class:^(vivaldi|firefox)$
|
||||||
|
# windowrule = workspace name:code, class:^(codium|code)$
|
||||||
|
# windowrule = workspace name:chat, class:^(discord|Element)$
|
||||||
|
# windowrule = workspace 1, class:^(kitty)$, title:^(dev)$
|
||||||
|
|
||||||
# --- Input ---------------------------------------------------------------
|
# --- Input ---------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland
|
# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland
|
||||||
@@ -111,3 +185,22 @@ bind = $mainMod, Return, exec, cosmic-term
|
|||||||
# Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers
|
# 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.
|
# lock and suspend, which is why the binding is not named after logout.
|
||||||
bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu
|
bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu
|
||||||
|
|
||||||
|
# --- Help ---------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Every shortcut in the session, in one searchable window, with the command
|
||||||
|
# each one runs. cosmic-settings can show these, but it is where you go to
|
||||||
|
# change a binding, not where you go to remember one -- and the hyprcosmic
|
||||||
|
# profile does not put it one click away.
|
||||||
|
#
|
||||||
|
# The script reads COSMIC's own Shortcuts files rather than this one. That is
|
||||||
|
# deliberate and it matters: this file declares six bindings and the session
|
||||||
|
# answers to 122, the rest being COSMIC defaults there is no reason to restate
|
||||||
|
# here. A reference built from this file alone would look complete and be
|
||||||
|
# missing every window, workspace and media key on the machine.
|
||||||
|
#
|
||||||
|
# Super+Shift+/ is Super+? on this keyboard, which is the usual spelling for
|
||||||
|
# help, and it is one of the few chords in that corner COSMIC leaves free --
|
||||||
|
# Super+K and Super+I are both focus actions in the defaults. The same script
|
||||||
|
# backs waybar's keyboard button.
|
||||||
|
bind = $mainMod SHIFT, slash, exec, hyprcosmic-keybinds
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Per-machine launcher settings for HyprCosmic.
|
||||||
|
*
|
||||||
|
* Empty until `hyprcosmic-conf import-theme --assets` writes it. It exists from
|
||||||
|
* the first session because config.rasi imports it, and a missing @import is an
|
||||||
|
* error rofi reports in place of the launcher rather than a warning it skips.
|
||||||
|
*
|
||||||
|
* It is the last of the four imports in config.rasi, so anything here wins; it
|
||||||
|
* is also overwritten by the next import run with --overwrite, so keep hand
|
||||||
|
* edits somewhere else.
|
||||||
|
*
|
||||||
|
* Two things belong in this file and nothing else does: values that name a path
|
||||||
|
* or a package on this particular machine, which neither /usr/share/hyprcosmic
|
||||||
|
* nor a HyDE theme file can know. Concretely, that is the icon theme and the
|
||||||
|
* sidebar image:
|
||||||
|
*
|
||||||
|
* configuration {
|
||||||
|
* icon-theme: "Tela-circle-purple", "Adwaita";
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* dummywall {
|
||||||
|
* background-image: url("/home/you/.local/share/wallpapers/hyprcosmic/current", height);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Both are shown commented out rather than set, because a default that named
|
||||||
|
* an icon theme nobody has installed, or a wallpaper path under someone else's
|
||||||
|
* home, would be worse than no default at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* Step 2 of the import chain in config.rasi: the installed HyDE theme's rofi
|
||||||
|
* colours.
|
||||||
|
*
|
||||||
|
* Empty on purpose, for the same reason as waybar's theme.css: a missing
|
||||||
|
* @import is an error rofi reports in place of the launcher rather than a
|
||||||
|
* warning it skips, so config.rasi can only import a file that is guaranteed
|
||||||
|
* to exist. palette.rasi already defines every name rules.rasi reads, so
|
||||||
|
* defining nothing here leaves a complete theme rather than an unresolved
|
||||||
|
* reference.
|
||||||
|
*
|
||||||
|
* `hyprcosmic-conf import-theme` overwrites this with the imported theme's
|
||||||
|
* rofi colours. Hand-written overrides belong in local.rasi, which is imported
|
||||||
|
* after the rules and is left alone unless --overwrite is passed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* {}
|
||||||
+139
-1
@@ -50,15 +50,19 @@
|
|||||||
"height": 34,
|
"height": 34,
|
||||||
"spacing": 4,
|
"spacing": 4,
|
||||||
|
|
||||||
"modules-left": ["ext/workspaces", "wlr/taskbar"],
|
"modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
|
||||||
"modules-center": ["mpris", "clock", "privacy"],
|
"modules-center": ["mpris", "clock", "privacy"],
|
||||||
"modules-right": [
|
"modules-right": [
|
||||||
|
"systemd-failed-units",
|
||||||
"idle_inhibitor",
|
"idle_inhibitor",
|
||||||
"custom/swaync",
|
"custom/swaync",
|
||||||
"bluetooth",
|
"bluetooth",
|
||||||
"pulseaudio",
|
"pulseaudio",
|
||||||
"network",
|
"network",
|
||||||
|
"disk",
|
||||||
"temperature",
|
"temperature",
|
||||||
|
"temperature#gpu",
|
||||||
|
"custom/fan",
|
||||||
"cpu",
|
"cpu",
|
||||||
"memory",
|
"memory",
|
||||||
"power-profiles-daemon",
|
"power-profiles-daemon",
|
||||||
@@ -67,6 +71,23 @@
|
|||||||
"custom/power"
|
"custom/power"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Opens the keyboard reference. First on the bar because it is the thing
|
||||||
|
// you look for when you do not yet know where anything is.
|
||||||
|
//
|
||||||
|
// No "exec": this is a button, not a readout. A custom module with only a
|
||||||
|
// format and an on-click runs nothing on an interval, so it costs a static
|
||||||
|
// glyph and nothing else.
|
||||||
|
//
|
||||||
|
// The same script is on Super + Shift + / -- the binding is named in the
|
||||||
|
// tooltip because a help button you have to reach for with the mouse has
|
||||||
|
// not finished helping.
|
||||||
|
"custom/keybinds": {
|
||||||
|
"format": "\udb80\udf0c",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
|
||||||
|
"on-click": "hyprcosmic-keybinds"
|
||||||
|
},
|
||||||
|
|
||||||
"ext/workspaces": {
|
"ext/workspaces": {
|
||||||
"format": "{name}",
|
"format": "{name}",
|
||||||
"on-click": "activate"
|
"on-click": "activate"
|
||||||
@@ -80,6 +101,56 @@
|
|||||||
"on-click-middle": "close"
|
"on-click-middle": "close"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The taskbar above shows which windows exist; this shows which one has the
|
||||||
|
// keyboard. Nothing else on the bar answered that.
|
||||||
|
//
|
||||||
|
// This is the one module here that talks to the fork's Hyprland IPC rather
|
||||||
|
// than to a Wayland protocol, and it needs no compositor change: waybar
|
||||||
|
// reads `j/activewindow` on startup and then follows `activewindow>>` on
|
||||||
|
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
|
||||||
|
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
|
||||||
|
// focused window as (class, title) and emits on any difference, which is
|
||||||
|
// why upstream's separate `windowtitle` event is not needed here. Upstream
|
||||||
|
// Hyprland emits from call sites and so must announce that case
|
||||||
|
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
|
||||||
|
//
|
||||||
|
// `separate-outputs` is left off deliberately: there is one bar on one
|
||||||
|
// output, and with it on, the module reports that monitor's active window
|
||||||
|
// rather than the focused one, which on a single head is the same answer by
|
||||||
|
// a longer route.
|
||||||
|
"hyprland/window": {
|
||||||
|
"format": "\uf2d0 {title}",
|
||||||
|
"max-length": 60,
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "{title}\n\nclass: {class}",
|
||||||
|
// Titles are written for a window's own title bar, not for a 60-column
|
||||||
|
// slot on a bar, so the worst offenders are trimmed to the part that
|
||||||
|
// identifies the window. ECMAScript regex; the value is a replacement
|
||||||
|
// string where $1 is the first capture.
|
||||||
|
//
|
||||||
|
// The separator is an em dash, and it is written as an escape
|
||||||
|
// for the same reason every glyph in this file is: the template is read
|
||||||
|
// as ASCII and the generator refuses to emit anything that is not, so a
|
||||||
|
// literal em dash pasted here fails the build rather than reaching the
|
||||||
|
// bar. COSMIC's applications really do use an em dash and not a hyphen
|
||||||
|
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
|
||||||
|
// a rule written with a hyphen matches nothing.
|
||||||
|
//
|
||||||
|
// There is deliberately no catch-all for the empty title. waybar's
|
||||||
|
// documentation does not say whether a rule is applied on a full match
|
||||||
|
// or a search, so an empty pattern is either "matches nothing but the
|
||||||
|
// empty title" or "matches every title", and the difference is the
|
||||||
|
// whole bar. The no-window case is handled in rules.css instead, the
|
||||||
|
// same way #mpris already handles having nothing to say.
|
||||||
|
"rewrite": {
|
||||||
|
"(.*) [-\u2014] Vivaldi": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Terminal": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Text Editor": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Files": "$1",
|
||||||
|
"(.*) [-\u2014] Mozilla Firefox": "$1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Renders nothing at all when no player is running, which is what we want
|
// 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.
|
// from a centre module: it takes no space until there is something to say.
|
||||||
// playerctl backs every action here and is installed.
|
// playerctl backs every action here and is installed.
|
||||||
@@ -120,6 +191,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// First on the right, and silent unless something is wrong. A unit that
|
||||||
|
// failed at boot otherwise stays failed until the day you happen to run
|
||||||
|
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||||
|
// session, which has no equivalent of GNOME's abrt notification.
|
||||||
|
//
|
||||||
|
// Both scopes are watched. The user scope is where a session's own units
|
||||||
|
// fail, which is the half that matters here and the half a system-only
|
||||||
|
// check misses.
|
||||||
|
"systemd-failed-units": {
|
||||||
|
"format": "\udb80\udc26 {nr_failed}",
|
||||||
|
"format-ok": "",
|
||||||
|
"system": true,
|
||||||
|
"user": true,
|
||||||
|
"hide-on-ok": true,
|
||||||
|
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||||
|
},
|
||||||
|
|
||||||
"idle_inhibitor": {
|
"idle_inhibitor": {
|
||||||
"format": "{icon}",
|
"format": "{icon}",
|
||||||
"format-icons": {
|
"format-icons": {
|
||||||
@@ -205,6 +293,19 @@
|
|||||||
"on-click": "cosmic-settings network"
|
"on-click": "cosmic-settings network"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Root only. /home is on the same filesystem here, and a second entry for
|
||||||
|
// it would be the same number twice.
|
||||||
|
//
|
||||||
|
// No warning threshold is set: waybar's disk module has no `states`, so the
|
||||||
|
// colour comes from rules.css, which cannot see the percentage. The number
|
||||||
|
// is the warning.
|
||||||
|
"disk": {
|
||||||
|
"path": "/",
|
||||||
|
"format": "\udb80\udeca {percentage_used}%",
|
||||||
|
"interval": 60,
|
||||||
|
"tooltip-format": "{used} used of {total} on {path}, {free} free"
|
||||||
|
},
|
||||||
|
|
||||||
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
|
// 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.
|
// each other here, but the hwmon path names the CPU sensor explicitly.
|
||||||
//
|
//
|
||||||
@@ -220,6 +321,43 @@
|
|||||||
"tooltip-format": "CPU package: {temperatureC} C"
|
"tooltip-format": "CPU package: {temperatureC} C"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A second instance of the same module. `temperature#gpu` is waybar's alias
|
||||||
|
// syntax: everything before the # selects the module, everything after is
|
||||||
|
// just a name, and it becomes #temperature.gpu in CSS.
|
||||||
|
//
|
||||||
|
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
|
||||||
|
// 30 degrees under load, so one reading cannot stand for both.
|
||||||
|
//
|
||||||
|
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
|
||||||
|
// exposes on this card. There is no junction or memory sensor here to
|
||||||
|
// prefer over it.
|
||||||
|
//
|
||||||
|
// hwmon-path-abs is safe for this one: the PCI device has exactly one
|
||||||
|
// hwmonN inside it, unlike the asus platform device that custom/fan below
|
||||||
|
// has to work around.
|
||||||
|
"temperature#gpu": {
|
||||||
|
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
|
||||||
|
"input-filename": "temp1_input",
|
||||||
|
"format": "\udb82\udcae {temperatureC} C",
|
||||||
|
"critical-threshold": 95,
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "GPU edge: {temperatureC} C"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Speed of the fastest fan, with every fan in the tooltip. See the script
|
||||||
|
// for why this is not a temperature module with a different divisor and not
|
||||||
|
// an hwmon-path-abs: both were tried and neither can name this sensor.
|
||||||
|
//
|
||||||
|
// Emits nothing on a machine with no readable fan, and waybar draws an
|
||||||
|
// empty custom module as nothing, so this costs no space on hardware that
|
||||||
|
// has none.
|
||||||
|
"custom/fan": {
|
||||||
|
"exec": "hyprcosmic-fan",
|
||||||
|
"return-type": "json",
|
||||||
|
"format": "\udb80\ude10 {} rpm",
|
||||||
|
"interval": 5
|
||||||
|
},
|
||||||
|
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"format": "\udb80\udf5b {usage}%",
|
"format": "\udb80\udf5b {usage}%",
|
||||||
"interval": 5
|
"interval": 5
|
||||||
|
|||||||
@@ -50,15 +50,19 @@
|
|||||||
"height": 34,
|
"height": 34,
|
||||||
"spacing": 4,
|
"spacing": 4,
|
||||||
|
|
||||||
"modules-left": ["ext/workspaces", "wlr/taskbar"],
|
"modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
|
||||||
"modules-center": ["mpris", "clock", "privacy"],
|
"modules-center": ["mpris", "clock", "privacy"],
|
||||||
"modules-right": [
|
"modules-right": [
|
||||||
|
"systemd-failed-units",
|
||||||
"idle_inhibitor",
|
"idle_inhibitor",
|
||||||
"custom/swaync",
|
"custom/swaync",
|
||||||
"bluetooth",
|
"bluetooth",
|
||||||
"pulseaudio",
|
"pulseaudio",
|
||||||
"network",
|
"network",
|
||||||
|
"disk",
|
||||||
"temperature",
|
"temperature",
|
||||||
|
"temperature#gpu",
|
||||||
|
"custom/fan",
|
||||||
"cpu",
|
"cpu",
|
||||||
"memory",
|
"memory",
|
||||||
"power-profiles-daemon",
|
"power-profiles-daemon",
|
||||||
@@ -67,6 +71,23 @@
|
|||||||
"custom/power"
|
"custom/power"
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Opens the keyboard reference. First on the bar because it is the thing
|
||||||
|
// you look for when you do not yet know where anything is.
|
||||||
|
//
|
||||||
|
// No "exec": this is a button, not a readout. A custom module with only a
|
||||||
|
// format and an on-click runs nothing on an interval, so it costs a static
|
||||||
|
// glyph and nothing else.
|
||||||
|
//
|
||||||
|
// The same script is on Super + Shift + / -- the binding is named in the
|
||||||
|
// tooltip because a help button you have to reach for with the mouse has
|
||||||
|
// not finished helping.
|
||||||
|
"custom/keybinds": {
|
||||||
|
"format": "@@KEYBOARD@@",
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
|
||||||
|
"on-click": "hyprcosmic-keybinds"
|
||||||
|
},
|
||||||
|
|
||||||
"ext/workspaces": {
|
"ext/workspaces": {
|
||||||
"format": "{name}",
|
"format": "{name}",
|
||||||
"on-click": "activate"
|
"on-click": "activate"
|
||||||
@@ -80,6 +101,56 @@
|
|||||||
"on-click-middle": "close"
|
"on-click-middle": "close"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The taskbar above shows which windows exist; this shows which one has the
|
||||||
|
// keyboard. Nothing else on the bar answered that.
|
||||||
|
//
|
||||||
|
// This is the one module here that talks to the fork's Hyprland IPC rather
|
||||||
|
// than to a Wayland protocol, and it needs no compositor change: waybar
|
||||||
|
// reads `j/activewindow` on startup and then follows `activewindow>>` on
|
||||||
|
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
|
||||||
|
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
|
||||||
|
// focused window as (class, title) and emits on any difference, which is
|
||||||
|
// why upstream's separate `windowtitle` event is not needed here. Upstream
|
||||||
|
// Hyprland emits from call sites and so must announce that case
|
||||||
|
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
|
||||||
|
//
|
||||||
|
// `separate-outputs` is left off deliberately: there is one bar on one
|
||||||
|
// output, and with it on, the module reports that monitor's active window
|
||||||
|
// rather than the focused one, which on a single head is the same answer by
|
||||||
|
// a longer route.
|
||||||
|
"hyprland/window": {
|
||||||
|
"format": "@@WINDOW@@ {title}",
|
||||||
|
"max-length": 60,
|
||||||
|
"tooltip": true,
|
||||||
|
"tooltip-format": "{title}\n\nclass: {class}",
|
||||||
|
// Titles are written for a window's own title bar, not for a 60-column
|
||||||
|
// slot on a bar, so the worst offenders are trimmed to the part that
|
||||||
|
// identifies the window. ECMAScript regex; the value is a replacement
|
||||||
|
// string where $1 is the first capture.
|
||||||
|
//
|
||||||
|
// The separator is an em dash, and it is written as an escape
|
||||||
|
// for the same reason every glyph in this file is: the template is read
|
||||||
|
// as ASCII and the generator refuses to emit anything that is not, so a
|
||||||
|
// literal em dash pasted here fails the build rather than reaching the
|
||||||
|
// bar. COSMIC's applications really do use an em dash and not a hyphen
|
||||||
|
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
|
||||||
|
// a rule written with a hyphen matches nothing.
|
||||||
|
//
|
||||||
|
// There is deliberately no catch-all for the empty title. waybar's
|
||||||
|
// documentation does not say whether a rule is applied on a full match
|
||||||
|
// or a search, so an empty pattern is either "matches nothing but the
|
||||||
|
// empty title" or "matches every title", and the difference is the
|
||||||
|
// whole bar. The no-window case is handled in rules.css instead, the
|
||||||
|
// same way #mpris already handles having nothing to say.
|
||||||
|
"rewrite": {
|
||||||
|
"(.*) [-\u2014] Vivaldi": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Terminal": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Text Editor": "$1",
|
||||||
|
"(.*) [-\u2014] COSMIC Files": "$1",
|
||||||
|
"(.*) [-\u2014] Mozilla Firefox": "$1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Renders nothing at all when no player is running, which is what we want
|
// 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.
|
// from a centre module: it takes no space until there is something to say.
|
||||||
// playerctl backs every action here and is installed.
|
// playerctl backs every action here and is installed.
|
||||||
@@ -120,6 +191,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// First on the right, and silent unless something is wrong. A unit that
|
||||||
|
// failed at boot otherwise stays failed until the day you happen to run
|
||||||
|
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||||
|
// session, which has no equivalent of GNOME's abrt notification.
|
||||||
|
//
|
||||||
|
// Both scopes are watched. The user scope is where a session's own units
|
||||||
|
// fail, which is the half that matters here and the half a system-only
|
||||||
|
// check misses.
|
||||||
|
"systemd-failed-units": {
|
||||||
|
"format": "@@ALERT@@ {nr_failed}",
|
||||||
|
"format-ok": "",
|
||||||
|
"system": true,
|
||||||
|
"user": true,
|
||||||
|
"hide-on-ok": true,
|
||||||
|
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||||
|
},
|
||||||
|
|
||||||
"idle_inhibitor": {
|
"idle_inhibitor": {
|
||||||
"format": "{icon}",
|
"format": "{icon}",
|
||||||
"format-icons": {
|
"format-icons": {
|
||||||
@@ -205,6 +293,19 @@
|
|||||||
"on-click": "cosmic-settings network"
|
"on-click": "cosmic-settings network"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Root only. /home is on the same filesystem here, and a second entry for
|
||||||
|
// it would be the same number twice.
|
||||||
|
//
|
||||||
|
// No warning threshold is set: waybar's disk module has no `states`, so the
|
||||||
|
// colour comes from rules.css, which cannot see the percentage. The number
|
||||||
|
// is the warning.
|
||||||
|
"disk": {
|
||||||
|
"path": "/",
|
||||||
|
"format": "@@DISK@@ {percentage_used}%",
|
||||||
|
"interval": 60,
|
||||||
|
"tooltip-format": "{used} used of {total} on {path}, {free} free"
|
||||||
|
},
|
||||||
|
|
||||||
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
|
// 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.
|
// each other here, but the hwmon path names the CPU sensor explicitly.
|
||||||
//
|
//
|
||||||
@@ -220,6 +321,43 @@
|
|||||||
"tooltip-format": "CPU package: {temperatureC} C"
|
"tooltip-format": "CPU package: {temperatureC} C"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A second instance of the same module. `temperature#gpu` is waybar's alias
|
||||||
|
// syntax: everything before the # selects the module, everything after is
|
||||||
|
// just a name, and it becomes #temperature.gpu in CSS.
|
||||||
|
//
|
||||||
|
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
|
||||||
|
// 30 degrees under load, so one reading cannot stand for both.
|
||||||
|
//
|
||||||
|
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
|
||||||
|
// exposes on this card. There is no junction or memory sensor here to
|
||||||
|
// prefer over it.
|
||||||
|
//
|
||||||
|
// hwmon-path-abs is safe for this one: the PCI device has exactly one
|
||||||
|
// hwmonN inside it, unlike the asus platform device that custom/fan below
|
||||||
|
// has to work around.
|
||||||
|
"temperature#gpu": {
|
||||||
|
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
|
||||||
|
"input-filename": "temp1_input",
|
||||||
|
"format": "@@GPU@@ {temperatureC} C",
|
||||||
|
"critical-threshold": 95,
|
||||||
|
"interval": 5,
|
||||||
|
"tooltip-format": "GPU edge: {temperatureC} C"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Speed of the fastest fan, with every fan in the tooltip. See the script
|
||||||
|
// for why this is not a temperature module with a different divisor and not
|
||||||
|
// an hwmon-path-abs: both were tried and neither can name this sensor.
|
||||||
|
//
|
||||||
|
// Emits nothing on a machine with no readable fan, and waybar draws an
|
||||||
|
// empty custom module as nothing, so this costs no space on hardware that
|
||||||
|
// has none.
|
||||||
|
"custom/fan": {
|
||||||
|
"exec": "hyprcosmic-fan",
|
||||||
|
"return-type": "json",
|
||||||
|
"format": "@@FAN@@ {} rpm",
|
||||||
|
"interval": 5
|
||||||
|
},
|
||||||
|
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"format": "@@CPU@@ {usage}%",
|
"format": "@@CPU@@ {usage}%",
|
||||||
"interval": 5
|
"interval": 5
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ ICONS = {
|
|||||||
"BAT_80": 0xF0081,
|
"BAT_80": 0xF0081,
|
||||||
"BAT_90": 0xF0082,
|
"BAT_90": 0xF0082,
|
||||||
"BAT_100": 0xF0079,
|
"BAT_100": 0xF0079,
|
||||||
|
"WINDOW": 0xF2D0,
|
||||||
|
"ALERT": 0xF0026,
|
||||||
|
"DISK": 0xF02CA,
|
||||||
|
"GPU": 0xF08AE,
|
||||||
|
"FAN": 0xF0210,
|
||||||
|
"KEYBOARD": 0xF030C,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -77,15 +77,20 @@ window#waybar {
|
|||||||
/* Every status module gets the same pill. Listed one per line because waybar
|
/* 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
|
* 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. */
|
* not guessable from the config and are worth being able to read down. */
|
||||||
|
#custom-keybinds,
|
||||||
|
#window,
|
||||||
#mpris,
|
#mpris,
|
||||||
#clock,
|
#clock,
|
||||||
#privacy,
|
#privacy,
|
||||||
|
#systemd-failed-units,
|
||||||
#idle_inhibitor,
|
#idle_inhibitor,
|
||||||
#custom-swaync,
|
#custom-swaync,
|
||||||
#bluetooth,
|
#bluetooth,
|
||||||
#pulseaudio,
|
#pulseaudio,
|
||||||
#network,
|
#network,
|
||||||
|
#disk,
|
||||||
#temperature,
|
#temperature,
|
||||||
|
#custom-fan,
|
||||||
#cpu,
|
#cpu,
|
||||||
#memory,
|
#memory,
|
||||||
#power-profiles-daemon,
|
#power-profiles-daemon,
|
||||||
@@ -106,6 +111,12 @@ window#waybar {
|
|||||||
color: @critical;
|
color: @critical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The other button on the bar. Accent rather than critical, because this one is
|
||||||
|
* safe to press. */
|
||||||
|
#custom-keybinds:hover {
|
||||||
|
color: @accent;
|
||||||
|
}
|
||||||
|
|
||||||
#clock {
|
#clock {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -137,12 +148,35 @@ window#waybar {
|
|||||||
color: @warning;
|
color: @warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same reasoning as mpris, but the class is not on the widget. waybar puts
|
||||||
|
* `empty` on the whole bar rather than on #window, so the selector has to
|
||||||
|
* descend into it -- waybar-hyprland-window(5) gives it as exactly this:
|
||||||
|
*
|
||||||
|
* window#waybar.empty #window When no windows are in the workspace
|
||||||
|
*
|
||||||
|
* The first `window` is the GTK window, the second is our module; they are
|
||||||
|
* unrelated names that collide, which is why this reads so oddly. */
|
||||||
|
window#waybar.empty #window {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* States. These recolour the text inside the pill rather than the pill, so a
|
/* States. These recolour the text inside the pill rather than the pill, so a
|
||||||
* warning cannot make a module unreadable against its own background. */
|
* warning cannot make a module unreadable against its own background. */
|
||||||
#battery.warning { color: @warning; }
|
#battery.warning { color: @warning; }
|
||||||
#battery.critical { color: @critical; }
|
#battery.critical { color: @critical; }
|
||||||
#temperature.critical { color: @critical; }
|
#temperature.critical { color: @critical; }
|
||||||
#network.disconnected { color: @critical; }
|
#network.disconnected { color: @critical; }
|
||||||
|
|
||||||
|
/* No state class needed: hide-on-ok removes the module entirely at zero, so it
|
||||||
|
* is on screen only when there is something to report and can be coloured
|
||||||
|
* unconditionally. */
|
||||||
|
#systemd-failed-units { color: @critical; }
|
||||||
|
|
||||||
|
/* Set by hyprcosmic-fan: `high` above 4000 rpm, `idle` when every fan has
|
||||||
|
* stopped. `normal` is left alone. */
|
||||||
|
#custom-fan.high { color: @warning; }
|
||||||
|
#custom-fan.idle { color: @muted; }
|
||||||
#pulseaudio.muted { color: @muted; }
|
#pulseaudio.muted { color: @muted; }
|
||||||
#idle_inhibitor.activated { color: @accent; }
|
#idle_inhibitor.activated { color: @accent; }
|
||||||
#custom-swaync.notification { color: @accent; }
|
#custom-swaync.notification { color: @accent; }
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* Step 2 of the import chain in style.css: the installed HyDE theme's
|
||||||
|
* waybar.theme.
|
||||||
|
*
|
||||||
|
* This copy is deliberately empty of rules. It is not a stub waiting to be
|
||||||
|
* written -- an empty file is the correct content when no theme has been
|
||||||
|
* imported, and it is what makes the other three steps work:
|
||||||
|
*
|
||||||
|
* - A missing @import is fatal in GTK, so style.css cannot name a file that
|
||||||
|
* might not exist. This one always exists, from the moment the session
|
||||||
|
* first starts.
|
||||||
|
* - Every colour the rules consume already has a default from palette.css
|
||||||
|
* and bridge-hyde.css, so a file that defines nothing leaves a complete
|
||||||
|
* stylesheet rather than an undefined name.
|
||||||
|
*
|
||||||
|
* `hyprcosmic-conf import-theme` overwrites this with the waybar.theme of the
|
||||||
|
* theme being imported. Anything written here by hand is lost at that point,
|
||||||
|
* which is why the per-user override belongs in style.css instead.
|
||||||
|
*/
|
||||||
+1
-1
Submodule cosmic-comp updated: 5f176995dc...a73734edb0
Generated
+45
@@ -8,6 +8,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
@@ -29,6 +38,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"flate2",
|
"flate2",
|
||||||
"notify",
|
"notify",
|
||||||
|
"regex",
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"tar",
|
"tar",
|
||||||
@@ -158,6 +168,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -237,6 +253,35 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ron"
|
name = "ron"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ description = "Compiles a single Hyprland-idiom config file into the cosmic-conf
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.9"
|
||||||
notify = "8.2.0"
|
notify = "8.2.0"
|
||||||
|
# Only to reject a bad `windowrule` expression at apply time rather than
|
||||||
|
# letting it fail silently in the compositor. Pinned to the same major the
|
||||||
|
# compositor matches with, so what compiles here compiles there.
|
||||||
|
regex = "1"
|
||||||
ron = "0.12"
|
ron = "0.12"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
tar = "0.4.46"
|
tar = "0.4.46"
|
||||||
|
|||||||
@@ -630,7 +630,7 @@ fn render_local_rasi(icon_theme: Option<&str>, wallpaper_link: Option<&Path>) ->
|
|||||||
let mut out = String::from(
|
let mut out = String::from(
|
||||||
r#"/* Per-machine launcher settings for HyprCosmic.
|
r#"/* Per-machine launcher settings for HyprCosmic.
|
||||||
*
|
*
|
||||||
* Generated by `cosmic-conf import-theme --assets`. It is the last of the four
|
* Generated by `hyprcosmic-conf import-theme --assets`. It is the last of the four
|
||||||
* imports in config.rasi, so anything here wins; it is also overwritten by the
|
* imports in config.rasi, so anything here wins; it is also overwritten by the
|
||||||
* next import run with --overwrite, so keep hand edits somewhere else.
|
* next import run with --overwrite, so keep hand edits somewhere else.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -272,9 +272,9 @@ fn render_conf(
|
|||||||
notes: &[Note],
|
notes: &[Note],
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"# Generated by `cosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
|
"# Generated by `hyprcosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
|
||||||
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
|
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
|
||||||
# are overwritten on the next `cosmic-conf apply`.\n"
|
# are overwritten on the next `hyprcosmic-conf apply`.\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
let dropped: Vec<&Note> = notes
|
let dropped: Vec<&Note> = notes
|
||||||
|
|||||||
@@ -16,12 +16,16 @@ pub mod parser;
|
|||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod watch;
|
pub mod watch;
|
||||||
|
pub mod windowrule;
|
||||||
|
pub mod workspace;
|
||||||
|
|
||||||
pub use bind::{parse_bind, Bind};
|
pub use bind::{parse_bind, Bind};
|
||||||
pub use emit::{EmitError, Emitter, Planned};
|
pub use emit::{EmitError, Emitter, Planned};
|
||||||
pub use import::{import_hypr_theme, render_report, Import};
|
pub use import::{import_hypr_theme, render_report, Import};
|
||||||
pub use parser::{parse, Ast, ParseError, Span};
|
pub use parser::{parse, Ast, ParseError, Span};
|
||||||
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
|
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
|
||||||
|
pub use windowrule::{parse_window_rule, WindowRuleDecl};
|
||||||
|
pub use workspace::{parse_workspace, WorkspaceDecl};
|
||||||
|
|
||||||
/// Render a diagnostic against source text, cargo-style.
|
/// Render a diagnostic against source text, cargo-style.
|
||||||
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
|
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ use std::process::ExitCode;
|
|||||||
|
|
||||||
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
|
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
|
||||||
|
|
||||||
|
// Names the installed binary, hyprcosmic-conf, not the crate. The package
|
||||||
|
// installs beside COSMIC and keeps every path it owns under a hyprcosmic-*
|
||||||
|
// name, so a usage line saying `cosmic-conf` would name something that is not
|
||||||
|
// on the system.
|
||||||
const USAGE: &str = "\
|
const USAGE: &str = "\
|
||||||
cosmic-conf — compile cosmic.conf into the cosmic-config tree
|
hyprcosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
cosmic-conf apply [--diff] [--config <path>]
|
hyprcosmic-conf apply [--diff] [--config <path>]
|
||||||
cosmic-conf watch [--config <path>]
|
hyprcosmic-conf watch [--config <path>]
|
||||||
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
|
hyprcosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
|
||||||
[--assets [--source <dir>] [--overwrite] [--dry-run]]
|
[--assets [--source <dir>] [--overwrite] [--dry-run]]
|
||||||
|
|
||||||
COMMANDS:
|
COMMANDS:
|
||||||
apply Compile the config once and exit
|
apply Compile the config once and exit
|
||||||
|
|||||||
+258
-3
@@ -15,6 +15,8 @@ use std::collections::BTreeMap;
|
|||||||
use crate::bind;
|
use crate::bind;
|
||||||
use crate::parser::{Ast, Item, Span, Spanned};
|
use crate::parser::{Ast, Item, Span, Spanned};
|
||||||
use crate::schema::{self, Entry, Range, Target, Ty};
|
use crate::schema::{self, Entry, Range, Target, Ty};
|
||||||
|
use crate::windowrule;
|
||||||
|
use crate::workspace;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Value {
|
pub enum Value {
|
||||||
@@ -268,12 +270,61 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
|||||||
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
|
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
|
||||||
let mut whole: BTreeMap<TargetKey, (Value, Span)> = 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
|
// `bind`, `workspace` and `windowrule` are the repeatable keys in the
|
||||||
// single map rather than the last one winning, so it cannot go through the
|
// language: many lines fold into a single value rather than the last one
|
||||||
// schema, which is built around one conf key naming one value.
|
// winning, so none of them can go through the schema, which is built around
|
||||||
|
// one conf key naming one value.
|
||||||
let mut binds: Vec<(bind::Bind, Span)> = Vec::new();
|
let mut binds: Vec<(bind::Bind, Span)> = Vec::new();
|
||||||
|
let mut workspaces: Vec<(workspace::WorkspaceDecl, Span)> = Vec::new();
|
||||||
|
let mut window_rules: Vec<windowrule::WindowRuleDecl> = Vec::new();
|
||||||
|
|
||||||
for (conf, raw_value, key_span) in &flat {
|
for (conf, raw_value, key_span) in &flat {
|
||||||
|
// `windowrulev2` was Hyprland's name for this syntax before it became
|
||||||
|
// the only one; configs in the wild are still full of it.
|
||||||
|
if conf == "windowrule" || conf == "windowrulev2" {
|
||||||
|
let expanded = expand_vars(&raw_value.value, &vars);
|
||||||
|
// Not deduplicated: two rules can differ only in their title and
|
||||||
|
// both be wanted, and the compositor takes the first that matches,
|
||||||
|
// so the order they were written in is the whole semantics.
|
||||||
|
match windowrule::parse_window_rule(&expanded, raw_value.span) {
|
||||||
|
Ok(r) => window_rules.push(r),
|
||||||
|
Err(e) => diags.push(Diagnostic {
|
||||||
|
message: e.message,
|
||||||
|
span: e.span,
|
||||||
|
help: e.help,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if conf == "workspace" {
|
||||||
|
let expanded = expand_vars(&raw_value.value, &vars);
|
||||||
|
match workspace::parse_workspace(&expanded, raw_value.span) {
|
||||||
|
Ok(w) => {
|
||||||
|
if let Some((_, prev_span)) =
|
||||||
|
workspaces.iter().find(|(o, _)| o.index == w.index)
|
||||||
|
{
|
||||||
|
diags.push(Diagnostic {
|
||||||
|
message: format!("workspace {} is already declared", w.index),
|
||||||
|
span: raw_value.span,
|
||||||
|
help: Some(format!(
|
||||||
|
"the earlier declaration is on line {}",
|
||||||
|
prev_span.line
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
workspaces.push((w, raw_value.span));
|
||||||
|
}
|
||||||
|
Err(e) => diags.push(Diagnostic {
|
||||||
|
message: e.message,
|
||||||
|
span: e.span,
|
||||||
|
help: e.help,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if conf == "bind" {
|
if conf == "bind" {
|
||||||
let expanded = expand_vars(&raw_value.value, &vars);
|
let expanded = expand_vars(&raw_value.value, &vars);
|
||||||
match bind::parse_bind(&expanded, raw_value.span) {
|
match bind::parse_bind(&expanded, raw_value.span) {
|
||||||
@@ -339,6 +390,19 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
|||||||
return Err(diags);
|
return Err(diags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pinned workspace carries its own `tiling_enabled`, so one that did not
|
||||||
|
// say has to inherit the session default rather than default to off --
|
||||||
|
// otherwise declaring workspaces would quietly undo `general:autotile`.
|
||||||
|
// Read before `whole` is consumed below.
|
||||||
|
let default_tiling = whole
|
||||||
|
.get(&TargetKey {
|
||||||
|
component: "com.system76.CosmicComp".into(),
|
||||||
|
version: 1,
|
||||||
|
key: "autotile".into(),
|
||||||
|
})
|
||||||
|
.map(|(v, _)| *v == Value::Bool(true))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let mut writes: Vec<Write> = whole
|
let mut writes: Vec<Write> = whole
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(target, (v, _))| Write {
|
.map(|(target, (v, _))| Write {
|
||||||
@@ -367,6 +431,40 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !workspaces.is_empty() {
|
||||||
|
let rendered = workspace::render(
|
||||||
|
&workspaces.iter().map(|(w, _)| w.clone()).collect::<Vec<_>>(),
|
||||||
|
default_tiling,
|
||||||
|
);
|
||||||
|
writes.push(Write {
|
||||||
|
// `Workspaces::add_output` drains this into the first output that
|
||||||
|
// appears (cosmic-comp `shell/mod.rs`), and `from_pinned` sets
|
||||||
|
// `pinned: true`, which is what stops `can_auto_remove` collecting
|
||||||
|
// the workspace the moment its last window closes.
|
||||||
|
target: TargetKey {
|
||||||
|
component: "com.system76.CosmicComp".into(),
|
||||||
|
version: 1,
|
||||||
|
key: "pinned_workspaces".into(),
|
||||||
|
},
|
||||||
|
kind: WriteKind::Verbatim(rendered),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !window_rules.is_empty() {
|
||||||
|
let rendered = windowrule::render(&window_rules);
|
||||||
|
writes.push(Write {
|
||||||
|
// Read live: cosmic-comp's config watcher has a `window_rules` arm,
|
||||||
|
// so an edit applies to the next window that opens. Unlike
|
||||||
|
// `pinned_workspaces`, which waits for the next login.
|
||||||
|
target: TargetKey {
|
||||||
|
component: "com.system76.CosmicComp".into(),
|
||||||
|
version: 1,
|
||||||
|
key: "window_rules".into(),
|
||||||
|
},
|
||||||
|
kind: WriteKind::Verbatim(rendered),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
writes.sort_by(|a, b| a.target.cmp(&b.target));
|
writes.sort_by(|a, b| a.target.cmp(&b.target));
|
||||||
Ok(Resolved { writes })
|
Ok(Resolved { writes })
|
||||||
}
|
}
|
||||||
@@ -510,6 +608,163 @@ mod tests {
|
|||||||
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspaces_fold_into_one_write_against_the_comp_pinned_workspaces_key() {
|
||||||
|
let r = resolved("workspace = 1, name:term\nworkspace = 2, name:web\n");
|
||||||
|
let w: Vec<_> = r
|
||||||
|
.writes
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.target.key == "pinned_workspaces")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(w.len(), 1, "every workspace belongs to one list");
|
||||||
|
assert_eq!(w[0].target.component, "com.system76.CosmicComp");
|
||||||
|
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#"name: Some("term")"#), "{ron}");
|
||||||
|
assert!(ron.contains(r#"name: Some("web")"#), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without this the pinned workspaces would come back floating for a user
|
||||||
|
/// whose whole reason for editing the file was `autotile = true`.
|
||||||
|
#[test]
|
||||||
|
fn a_workspace_that_did_not_say_inherits_general_autotile() {
|
||||||
|
let r = resolved("general {\n autotile = true\n}\nworkspace = 1\n");
|
||||||
|
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
|
||||||
|
else {
|
||||||
|
panic!("expected verbatim RON");
|
||||||
|
};
|
||||||
|
assert!(ron.contains("tiling_enabled: true"), "{ron}");
|
||||||
|
|
||||||
|
let r = resolved("general {\n autotile = false\n}\nworkspace = 1\n");
|
||||||
|
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
|
||||||
|
else {
|
||||||
|
panic!("expected verbatim RON");
|
||||||
|
};
|
||||||
|
assert!(ron.contains("tiling_enabled: false"), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declaring_the_same_workspace_twice_is_an_error_not_a_silent_overwrite() {
|
||||||
|
let d = errors("workspace = 2, name:web\nworkspace = 2, name:mail\n");
|
||||||
|
assert_eq!(d.len(), 1);
|
||||||
|
assert!(
|
||||||
|
d[0].message.contains("already declared"),
|
||||||
|
"{}",
|
||||||
|
d[0].message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
d[0].help.as_ref().unwrap().contains("line 1"),
|
||||||
|
"{:?}",
|
||||||
|
d[0].help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric with `no_binds_means_the_shortcuts_file_is_left_alone`: writing
|
||||||
|
/// an empty list would unpin the workspaces of anyone whose cosmic.conf
|
||||||
|
/// simply does not mention them.
|
||||||
|
#[test]
|
||||||
|
fn no_workspaces_means_the_pinned_list_is_left_alone() {
|
||||||
|
let r = resolved("general {\n gaps_in = 4\n}\n");
|
||||||
|
assert!(r.writes.iter().all(|w| w.target.key != "pinned_workspaces"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_workspace_expands_variables() {
|
||||||
|
let r = resolved("$browser = web\nworkspace = 1, name:$browser\n");
|
||||||
|
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
|
||||||
|
else {
|
||||||
|
panic!("expected verbatim RON");
|
||||||
|
};
|
||||||
|
assert!(ron.contains(r#"name: Some("web")"#), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_workspace_is_reported_with_the_rest_of_the_file() {
|
||||||
|
let d = errors("workspace = 0\ngeneral {\n gaps_inn = 8\n}\n");
|
||||||
|
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_rules_fold_into_one_write_against_the_comp_window_rules_key() {
|
||||||
|
let r = resolved(
|
||||||
|
"windowrule = workspace name:web, class:^(vivaldi)$\n\
|
||||||
|
windowrule = workspace 1, class:^(kitty)$\n",
|
||||||
|
);
|
||||||
|
let w: Vec<_> = r
|
||||||
|
.writes
|
||||||
|
.iter()
|
||||||
|
.filter(|w| w.target.key == "window_rules")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(w.len(), 1, "every rule belongs to one list");
|
||||||
|
assert_eq!(w[0].target.component, "com.system76.CosmicComp");
|
||||||
|
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#"workspace: Name("web")"#), "{ron}");
|
||||||
|
assert!(ron.contains("workspace: Index(1)"), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compositor takes the first rule that matches, so a file that reads
|
||||||
|
/// top to bottom has to be emitted top to bottom.
|
||||||
|
#[test]
|
||||||
|
fn window_rules_keep_the_order_they_were_written_in() {
|
||||||
|
let r = resolved(
|
||||||
|
"windowrule = workspace 1, class:^(a)$\n\
|
||||||
|
windowrule = workspace 2, class:^(b)$\n\
|
||||||
|
windowrule = workspace 3, class:^(c)$\n",
|
||||||
|
);
|
||||||
|
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
|
||||||
|
panic!("expected verbatim RON");
|
||||||
|
};
|
||||||
|
let seen: Vec<&str> = ron
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.contains("app_id:"))
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(seen.len(), 3);
|
||||||
|
assert!(seen[0].contains("^(a)$"), "{ron}");
|
||||||
|
assert!(seen[1].contains("^(b)$"), "{ron}");
|
||||||
|
assert!(seen[2].contains("^(c)$"), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The v2 spelling is what configs in the wild are written with.
|
||||||
|
#[test]
|
||||||
|
fn windowrulev2_is_the_same_key() {
|
||||||
|
let r = resolved("windowrulev2 = workspace 2, class:^(firefox)$\n");
|
||||||
|
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
|
||||||
|
panic!("expected verbatim RON");
|
||||||
|
};
|
||||||
|
assert!(ron.contains("workspace: Index(2)"), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symmetric with the bind and workspace cases: writing an empty list would
|
||||||
|
/// be a change for someone whose cosmic.conf never mentions window rules.
|
||||||
|
#[test]
|
||||||
|
fn no_window_rules_means_the_list_is_left_alone() {
|
||||||
|
let r = resolved("general {\n gaps_in = 4\n}\n");
|
||||||
|
assert!(r.writes.iter().all(|w| w.target.key != "window_rules"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_rule_expands_variables() {
|
||||||
|
let r = resolved("$browser = vivaldi\nwindowrule = workspace 2, class:^($browser)$\n");
|
||||||
|
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
|
||||||
|
panic!("expected verbatim RON");
|
||||||
|
};
|
||||||
|
assert!(ron.contains("^(vivaldi)$"), "{ron}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_window_rule_is_reported_with_the_rest_of_the_file() {
|
||||||
|
let d = errors("windowrule = float, class:foo\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 {
|
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
|
||||||
&r.writes
|
&r.writes
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -0,0 +1,532 @@
|
|||||||
|
//! Hyprland `windowrule` lines -> COSMIC window rules.
|
||||||
|
//!
|
||||||
|
//! Hyprland's `windowrule` is a large surface: an action, a match, and around
|
||||||
|
//! forty possible actions ranging from `float` to `bordercolor`. Exactly one of
|
||||||
|
//! them is implemented here, `workspace`, because it is the one with a real
|
||||||
|
//! COSMIC counterpart -- a window can be mapped onto a workspace other than the
|
||||||
|
//! active one, which is what `windowrule = workspace 4, class:...` means.
|
||||||
|
//!
|
||||||
|
//! Everything else is refused with an explanation rather than accepted and
|
||||||
|
//! dropped. A rule that parses and then does nothing is the worst outcome
|
||||||
|
//! available: the config looks right, the window opens in the wrong place, and
|
||||||
|
//! there is nothing to read that says why.
|
||||||
|
//!
|
||||||
|
//! The match half is `class:` and `title:`, both regular expressions, both
|
||||||
|
//! compiled here so a broken one is a diagnostic against the line that wrote it
|
||||||
|
//! rather than a warning in the compositor log nobody reads.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use crate::parser::Span;
|
||||||
|
|
||||||
|
/// Where a matching window opens.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum WorkspaceTarget {
|
||||||
|
/// 1-based, as the user counts them.
|
||||||
|
Index(u32),
|
||||||
|
/// Matched against the workspace name, which is what a `workspace` line
|
||||||
|
/// sets.
|
||||||
|
Name(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `windowrule = ...` line.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WindowRuleDecl {
|
||||||
|
/// Regular expression for the window's app id. Empty matches anything.
|
||||||
|
pub class: String,
|
||||||
|
/// Regular expression for the window's title. Empty matches anything.
|
||||||
|
pub title: String,
|
||||||
|
pub workspace: WorkspaceTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WindowRuleError {
|
||||||
|
pub message: String,
|
||||||
|
pub help: Option<String>,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WindowRuleError {
|
||||||
|
WindowRuleError {
|
||||||
|
message: message.into(),
|
||||||
|
help: help.map(str::to_string),
|
||||||
|
span,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actions Hyprland has that this cannot do, and why saying so beats
|
||||||
|
/// guessing. `float` and `tile` are called out separately because they are the
|
||||||
|
/// next most likely thing to be reached for and COSMIC does have a mechanism --
|
||||||
|
/// just not one cosmic.conf owns.
|
||||||
|
const FLOAT_HELP: &str = "cosmic-comp decides floating from its tiling exceptions, which belong to \
|
||||||
|
cosmic-settings (com.system76.CosmicSettings.WindowRules) rather than to \
|
||||||
|
cosmic.conf. Add the application there and it will float on every \
|
||||||
|
workspace.";
|
||||||
|
|
||||||
|
const ACTION_HELP: &str = "only `workspace` is supported. Hyprland's other rules -- float, size, \
|
||||||
|
move, opacity, bordercolor and the rest -- have no COSMIC equivalent to \
|
||||||
|
project onto.";
|
||||||
|
|
||||||
|
/// Matchers Hyprland has that depend on window state at match time. Ours runs
|
||||||
|
/// once, when the window is mapped, so none of these can be answered.
|
||||||
|
const MATCHER_HELP: &str = "rules are matched once, as the window opens, so only what the window \
|
||||||
|
arrives with can be tested: class and title.";
|
||||||
|
|
||||||
|
/// Parse the body of a `windowrule` line.
|
||||||
|
///
|
||||||
|
/// `windowrule = workspace 4, class:^(vivaldi.*)$`
|
||||||
|
///
|
||||||
|
/// The first field is the action, the rest are matchers. `windowrulev2` is the
|
||||||
|
/// same thing under an older name -- Hyprland merged v2's syntax into
|
||||||
|
/// `windowrule` and kept the alias, and HyDE-era configs are full of it.
|
||||||
|
pub fn parse_window_rule(value: &str, span: Span) -> Result<WindowRuleDecl, WindowRuleError> {
|
||||||
|
let mut parts = value.split(',');
|
||||||
|
|
||||||
|
let action = parts
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
"a window rule needs an action",
|
||||||
|
Some("for example: windowrule = workspace 4, class:^(vivaldi)$"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let workspace = parse_action(action, span)?;
|
||||||
|
|
||||||
|
let mut class = String::new();
|
||||||
|
let mut title = String::new();
|
||||||
|
let mut matched_on = false;
|
||||||
|
|
||||||
|
for raw in parts {
|
||||||
|
let param = raw.trim();
|
||||||
|
if param.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, arg)) = param.split_once(':') else {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("expected `key:value`, found `{param}`"),
|
||||||
|
Some("known matchers: class, title"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let arg = arg.trim();
|
||||||
|
// `initialClass`/`initialTitle` are accepted as spellings of the same
|
||||||
|
// thing rather than as approximations of it: the match happens as the
|
||||||
|
// window is mapped, so the title being tested *is* the initial one.
|
||||||
|
match key.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"class" | "initialclass" => {
|
||||||
|
check_regex(arg, "class", span)?;
|
||||||
|
class = arg.to_string();
|
||||||
|
matched_on = true;
|
||||||
|
}
|
||||||
|
"title" | "initialtitle" => {
|
||||||
|
check_regex(arg, "title", span)?;
|
||||||
|
title = arg.to_string();
|
||||||
|
matched_on = true;
|
||||||
|
}
|
||||||
|
// Named rather than swept into the catch-all so the message can say
|
||||||
|
// why a matcher Hyprland does have is not accepted here.
|
||||||
|
"floating" | "fullscreen" | "pinned" | "focus" | "workspace" | "onworkspace"
|
||||||
|
| "xwayland" | "tag" | "fullscreenstate" => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{}:` cannot be matched on", key.trim()),
|
||||||
|
Some(MATCHER_HELP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unknown matcher `{other}`"),
|
||||||
|
Some("known matchers: class, title"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched_on {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
"a window rule needs something to match on",
|
||||||
|
Some(
|
||||||
|
"without a class or a title the rule matches every window, which \
|
||||||
|
would send the whole session to one workspace.",
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WindowRuleDecl {
|
||||||
|
class,
|
||||||
|
title,
|
||||||
|
workspace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `workspace 4`, `workspace name:web`, either with a trailing `silent`.
|
||||||
|
fn parse_action(action: &str, span: Span) -> Result<WorkspaceTarget, WindowRuleError> {
|
||||||
|
let (verb, rest) = match action.split_once(char::is_whitespace) {
|
||||||
|
Some((verb, rest)) => (verb, rest.trim()),
|
||||||
|
None => (action, ""),
|
||||||
|
};
|
||||||
|
if !verb.eq_ignore_ascii_case("workspace") {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unsupported window rule `{verb}`"),
|
||||||
|
Some(
|
||||||
|
if verb.eq_ignore_ascii_case("float") || verb.eq_ignore_ascii_case("tile") {
|
||||||
|
FLOAT_HELP
|
||||||
|
} else {
|
||||||
|
ACTION_HELP
|
||||||
|
},
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyprland's `silent` means "put it there without switching to it". That is
|
||||||
|
// unconditionally what happens here -- a rule places its window and leaves
|
||||||
|
// the focus alone -- so the word is accepted as a description of the
|
||||||
|
// behaviour rather than ignored as a request that went unheard.
|
||||||
|
//
|
||||||
|
// Stripped from the end rather than parsed as one word among several,
|
||||||
|
// because a workspace name may contain spaces: `workspace = 2, name:web and
|
||||||
|
// mail` is a legal declaration, so `workspace name:web and mail` has to be a
|
||||||
|
// legal rule.
|
||||||
|
let target = rest
|
||||||
|
.rsplit_once(char::is_whitespace)
|
||||||
|
.filter(|(_, last)| last.eq_ignore_ascii_case("silent"))
|
||||||
|
.map_or(rest, |(head, _)| head.trim_end());
|
||||||
|
|
||||||
|
if target.is_empty() {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
"`workspace` needs a workspace to send the window to",
|
||||||
|
Some("for example: workspace 4, or workspace name:web"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = target.strip_prefix("name:") {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(err(span, "`name:` needs a workspace name", None));
|
||||||
|
}
|
||||||
|
return Ok(WorkspaceTarget::Name(name.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits checked before parsing, not after: `u32::from_str` accepts a
|
||||||
|
// leading `+`, so `workspace +1` -- Hyprland's "one to the right" -- would
|
||||||
|
// otherwise parse as the absolute workspace 1 and send the window somewhere
|
||||||
|
// the rule never asked for.
|
||||||
|
if target.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
match target.parse::<u32>() {
|
||||||
|
Ok(0) => return Err(err(span, "workspaces are numbered from 1", None)),
|
||||||
|
Ok(index) => return Ok(WorkspaceTarget::Index(index)),
|
||||||
|
// Only reachable by overflow, which the message below covers.
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else Hyprland accepts here is relative to where you are --
|
||||||
|
// `+1`, `previous`, `empty`, `special` -- and a rule fires when a window
|
||||||
|
// opens, so "the next workspace" would mean a different one every time.
|
||||||
|
Err(err(
|
||||||
|
span,
|
||||||
|
format!("cannot send a window to `{target}`"),
|
||||||
|
Some(
|
||||||
|
"a rule names one fixed workspace: a number, or `name:` and the name \
|
||||||
|
from a `workspace` line, optionally followed by `silent`. Relative \
|
||||||
|
and special workspaces have no COSMIC equivalent.",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_regex(pattern: &str, field: &str, span: Span) -> Result<(), WindowRuleError> {
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{field}:` needs a value"),
|
||||||
|
Some("an empty expression matches every window; leave the matcher out instead."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
regex::Regex::new(pattern).map_err(|e| {
|
||||||
|
// The crate's own message is multi-line and already points at the
|
||||||
|
// offending character, which is more useful than anything paraphrased.
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!("`{field}:` is not a valid expression"),
|
||||||
|
Some(&e.to_string().replace('\n', " ")),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ron_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the declarations as the RON `Vec<WindowRule>` cosmic-comp stores in
|
||||||
|
/// `window_rules`.
|
||||||
|
///
|
||||||
|
/// Order is preserved because it is meaningful: the compositor takes the first
|
||||||
|
/// rule that matches, and a file reads top to bottom, so the earlier line is
|
||||||
|
/// the one a person expects to win.
|
||||||
|
pub fn render(decls: &[WindowRuleDecl]) -> String {
|
||||||
|
let mut out = String::from("[\n");
|
||||||
|
for decl in decls {
|
||||||
|
let workspace = match &decl.workspace {
|
||||||
|
WorkspaceTarget::Index(n) => format!("Index({n})"),
|
||||||
|
WorkspaceTarget::Name(name) => format!("Name({})", ron_string(name)),
|
||||||
|
};
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" (app_id: {}, title: {}, workspace: {workspace}),",
|
||||||
|
ron_string(&decl.class),
|
||||||
|
ron_string(&decl.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push_str("]\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn span() -> Span {
|
||||||
|
Span {
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
len: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok(s: &str) -> WindowRuleDecl {
|
||||||
|
parse_window_rule(s, span()).expect(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(s: &str) -> WindowRuleError {
|
||||||
|
parse_window_rule(s, span()).expect_err(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hyprland_form_parses() {
|
||||||
|
let r = ok("workspace 4, class:^(vivaldi.*)$");
|
||||||
|
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
|
||||||
|
assert_eq!(r.class, "^(vivaldi.*)$");
|
||||||
|
assert_eq!(r.title, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_workspace_is_carried_through() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace name:web, class:vivaldi").workspace,
|
||||||
|
WorkspaceTarget::Name("web".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn class_and_title_can_both_be_given() {
|
||||||
|
let r = ok("workspace 2, class:^(firefox)$, title:.*Mail.*");
|
||||||
|
assert_eq!(r.class, "^(firefox)$");
|
||||||
|
assert_eq!(r.title, ".*Mail.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_alone_is_enough_to_match_on() {
|
||||||
|
let r = ok("workspace 2, title:.*Mail.*");
|
||||||
|
assert_eq!(r.class, "", "an empty class matches every app id");
|
||||||
|
assert_eq!(r.title, ".*Mail.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_spellings_are_the_same_matchers() {
|
||||||
|
let r = ok("workspace 1, initialClass:foo, initialTitle:bar");
|
||||||
|
assert_eq!(r.class, "foo");
|
||||||
|
assert_eq!(r.title, "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silent_is_accepted_because_it_describes_what_happens() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace 3 silent, class:foo").workspace,
|
||||||
|
WorkspaceTarget::Index(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_does_not_matter_for_keywords() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("Workspace 3 SILENT, CLASS:foo").workspace,
|
||||||
|
WorkspaceTarget::Index(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_around_everything_is_tolerated() {
|
||||||
|
let r = ok(" workspace 4 , class : ^foo$ ");
|
||||||
|
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
|
||||||
|
assert_eq!(r.class, "^foo$");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rule_with_nothing_to_match_on_is_refused() {
|
||||||
|
let e = fail("workspace 4");
|
||||||
|
assert!(e.message.contains("something to match on"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsupported_action_says_which_one_is_supported() {
|
||||||
|
let e = fail("size 100 100, class:foo");
|
||||||
|
assert!(e.message.contains("unsupported window rule"), "{e:?}");
|
||||||
|
assert!(e.help.unwrap().contains("only `workspace`"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_points_at_the_tiling_exceptions_instead() {
|
||||||
|
let e = fail("float, class:foo");
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("cosmic-settings"),
|
||||||
|
"{e:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_state_matcher_explains_that_matching_happens_once() {
|
||||||
|
let e = fail("workspace 4, class:foo, floating:1");
|
||||||
|
assert!(e.message.contains("cannot be matched on"), "{e:?}");
|
||||||
|
assert!(e.help.unwrap().contains("as the window opens"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_matcher_lists_the_known_ones() {
|
||||||
|
let e = fail("workspace 4, klass:foo");
|
||||||
|
assert!(e.message.contains("unknown matcher"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `u32::from_str` accepts a leading sign, so `+1` would silently become
|
||||||
|
/// the absolute workspace 1 if the digits were not checked first.
|
||||||
|
#[test]
|
||||||
|
fn a_relative_workspace_is_refused_with_a_reason() {
|
||||||
|
for target in ["+1", "-1", "previous", "empty", "e+1"] {
|
||||||
|
let e = fail(&format!("workspace {target}, class:foo"));
|
||||||
|
assert!(
|
||||||
|
e.message.contains("cannot send a window to"),
|
||||||
|
"{target}: {}",
|
||||||
|
e.message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("one fixed workspace"),
|
||||||
|
"{target}: {:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_special_workspace_is_refused() {
|
||||||
|
assert!(fail("workspace special:magic, class:foo")
|
||||||
|
.message
|
||||||
|
.contains("cannot send a window to"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_zero_is_refused() {
|
||||||
|
assert!(fail("workspace 0, class:foo")
|
||||||
|
.message
|
||||||
|
.contains("numbered from 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_broken_expression_is_caught_here_not_in_the_compositor() {
|
||||||
|
let e = fail("workspace 4, class:^(unclosed");
|
||||||
|
assert!(e.message.contains("not a valid expression"), "{e:?}");
|
||||||
|
assert!(e.help.is_some(), "the regex crate's own message is passed on");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_matcher_is_refused_rather_than_matching_everything() {
|
||||||
|
let e = fail("workspace 4, class:");
|
||||||
|
assert!(e.message.contains("needs a value"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `silent` is stripped off the end, so anything else trailing an index is
|
||||||
|
/// part of the target and fails as one rather than being quietly dropped.
|
||||||
|
#[test]
|
||||||
|
fn a_modifier_that_is_not_silent_is_refused() {
|
||||||
|
let e = fail("workspace 4 loud, class:foo");
|
||||||
|
assert!(e.message.contains("cannot send a window to `4 loud`"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `workspace` line accepts a name with spaces in it, so a rule aiming at
|
||||||
|
/// that workspace has to as well.
|
||||||
|
#[test]
|
||||||
|
fn a_workspace_name_may_contain_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace name:web and mail, class:foo").workspace,
|
||||||
|
WorkspaceTarget::Name("web and mail".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok("workspace name:web and mail silent, class:foo").workspace,
|
||||||
|
WorkspaceTarget::Name("web and mail".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_with_nothing_after_it_says_so() {
|
||||||
|
let e = fail("workspace, class:foo");
|
||||||
|
assert!(e.message.contains("needs a workspace"), "{e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendering_matches_the_ron_shape_cosmic_comp_reads() {
|
||||||
|
let out = render(&[
|
||||||
|
WindowRuleDecl {
|
||||||
|
class: "^(vivaldi)$".into(),
|
||||||
|
title: String::new(),
|
||||||
|
workspace: WorkspaceTarget::Name("web".into()),
|
||||||
|
},
|
||||||
|
WindowRuleDecl {
|
||||||
|
class: "^(kitty)$".into(),
|
||||||
|
title: String::new(),
|
||||||
|
workspace: WorkspaceTarget::Index(1),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
concat!(
|
||||||
|
"[\n",
|
||||||
|
" (app_id: \"^(vivaldi)$\", title: \"\", workspace: Name(\"web\")),\n",
|
||||||
|
" (app_id: \"^(kitty)$\", title: \"\", workspace: Index(1)),\n",
|
||||||
|
"]\n",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_renders_as_an_empty_list() {
|
||||||
|
assert_eq!(render(&[]), "[\n]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_in_an_expression_cannot_break_out_of_the_ron() {
|
||||||
|
let out = render(&[WindowRuleDecl {
|
||||||
|
class: r#"^(say "hi")$"#.into(),
|
||||||
|
title: String::new(),
|
||||||
|
workspace: WorkspaceTarget::Index(1),
|
||||||
|
}]);
|
||||||
|
assert!(out.contains(r#"\"hi\""#), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
//! Hyprland `workspace` lines -> COSMIC pinned workspaces.
|
||||||
|
//!
|
||||||
|
//! COSMIC's workspaces are dynamic and unconditionally so: `ensure_last_empty`
|
||||||
|
//! keeps exactly one trailing empty workspace and garbage-collects every other
|
||||||
|
//! empty one, and there is no setting anywhere that turns that off. A Hyprland
|
||||||
|
//! user expects the opposite -- a fixed set of workspaces that exist whether or
|
||||||
|
//! not anything is on them, so that "workspace 4 is the browser" stays true
|
||||||
|
//! across a reboot.
|
||||||
|
//!
|
||||||
|
//! The primitive that bridges the two already exists in the compositor.
|
||||||
|
//! `Workspace::can_auto_remove` is `is_empty() && !has_activation_token() &&
|
||||||
|
//! !pinned`, so a pinned workspace survives being emptied, and
|
||||||
|
//! `CosmicCompConfig::pinned_workspaces` is a persisted key that
|
||||||
|
//! `Workspaces::add_output` drains into the first output that appears. Nothing
|
||||||
|
//! in cosmic-comp needs to change: declaring workspaces here is enough.
|
||||||
|
//!
|
||||||
|
//! Three consequences of that restore path shape this module:
|
||||||
|
//!
|
||||||
|
//! 1. **Restore is positional.** `PinnedWorkspace` has no index field -- the
|
||||||
|
//! order of the Vec becomes the order of the workspaces. So `workspace = 4`
|
||||||
|
//! cannot emit one entry; it has to emit four, with 1..3 unnamed, or the
|
||||||
|
//! declared workspace would land at index 1.
|
||||||
|
//! 2. **The trailing dynamic workspace is kept.** Pinned workspaces are pushed
|
||||||
|
//! into an empty `WorkspaceSet`, and `ensure_last_empty` then appends the
|
||||||
|
//! usual empty one. Declaring four leaves you on 1-4 with a fifth appearing
|
||||||
|
//! when you use it, which is Hyprland's behaviour rather than a compromise.
|
||||||
|
//! 3. **It lands at the next login, not on apply.** `Workspaces::new` reads the
|
||||||
|
//! key once when the compositor starts and there is no reload path for it,
|
||||||
|
//! while `hyprcosmic-conf watch` is started from the autostart file *after*
|
||||||
|
//! COSMIC's own components. So an edit is written immediately and takes
|
||||||
|
//! effect the next time the session starts. Every other key in cosmic.conf
|
||||||
|
//! is live, so this one is worth saying out loud.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use crate::parser::Span;
|
||||||
|
|
||||||
|
/// One `workspace = ...` line.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkspaceDecl {
|
||||||
|
/// 1-based, as written. Workspaces below this one are materialised too.
|
||||||
|
pub index: u32,
|
||||||
|
/// Shown by anything reading ext-workspace, waybar included. `None` leaves
|
||||||
|
/// COSMIC to label it by number.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Per-workspace tiling. `None` inherits the session default.
|
||||||
|
pub tiling: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkspaceError {
|
||||||
|
pub message: String,
|
||||||
|
pub help: Option<String>,
|
||||||
|
pub span: Span,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WorkspaceError {
|
||||||
|
WorkspaceError {
|
||||||
|
message: message.into(),
|
||||||
|
help: help.map(str::to_string),
|
||||||
|
span,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declaring index N materialises N workspaces, so a typo like `workspace = 100`
|
||||||
|
/// would silently produce a hundred of them. Nobody drives a hundred
|
||||||
|
/// workspaces; the cap turns a fat-finger into a diagnostic.
|
||||||
|
const MAX_INDEX: u32 = 32;
|
||||||
|
|
||||||
|
/// Why `monitor:` is refused rather than accepted and ignored.
|
||||||
|
///
|
||||||
|
/// A `PinnedWorkspace` names its output through `OutputMatch { name, edid }`,
|
||||||
|
/// and cosmic-comp's `output_matches` compares the EDID *first*: a match with
|
||||||
|
/// `edid: None` is rejected outright against any output that reports one, and
|
||||||
|
/// only falls through to the name when neither side has an EDID. Every real
|
||||||
|
/// panel reports one, so a name-only match would work on a VM and nowhere else.
|
||||||
|
///
|
||||||
|
/// cosmic.conf cannot supply the EDID -- it is a manufacturer triple, product
|
||||||
|
/// id, serial and manufacture date read off the wire by the DRM backend, not
|
||||||
|
/// something a user can write down. Accepting `monitor:` would give a parameter
|
||||||
|
/// that silently does nothing on the hardware people actually have.
|
||||||
|
const MONITOR_HELP: &str = "COSMIC matches a workspace to a monitor by EDID rather than by name, \
|
||||||
|
and cosmic.conf has no way to spell an EDID. Pinned workspaces are created \
|
||||||
|
on the first output the session sees; move them with the workspace \
|
||||||
|
shortcuts once they exist.";
|
||||||
|
|
||||||
|
/// Parse the body of a `workspace` line.
|
||||||
|
///
|
||||||
|
/// `workspace = 4, name:web, tiling:true`
|
||||||
|
///
|
||||||
|
/// The index comes first and is required. Hyprland also allows a leading
|
||||||
|
/// `name:` with no index, for its special workspaces; COSMIC has no equivalent
|
||||||
|
/// and the restore is positional besides, so that form is rejected with an
|
||||||
|
/// explanation rather than guessed at.
|
||||||
|
pub fn parse_workspace(value: &str, span: Span) -> Result<WorkspaceDecl, WorkspaceError> {
|
||||||
|
let mut parts = value.split(',');
|
||||||
|
|
||||||
|
let head = parts
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
"a workspace needs an index",
|
||||||
|
Some("for example: workspace = 4, name:web"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let index = head.parse::<u32>().map_err(|_| {
|
||||||
|
if head.contains(':') {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!("a workspace starts with its index, found `{head}`"),
|
||||||
|
Some(
|
||||||
|
"COSMIC restores pinned workspaces by position, so every one \
|
||||||
|
needs a number. Write `workspace = 4, name:web` rather than \
|
||||||
|
`workspace = name:web`.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err(
|
||||||
|
span,
|
||||||
|
format!("expected a workspace index, found `{head}`"),
|
||||||
|
Some("for example: workspace = 4, name:web"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if index == 0 || index > MAX_INDEX {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("workspace index {index} is outside 1..={MAX_INDEX}"),
|
||||||
|
Some(
|
||||||
|
"workspaces are numbered from 1, and declaring one materialises \
|
||||||
|
every workspace below it, so the highest index is capped.",
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decl = WorkspaceDecl {
|
||||||
|
index,
|
||||||
|
name: None,
|
||||||
|
tiling: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for raw in parts {
|
||||||
|
let param = raw.trim();
|
||||||
|
if param.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, arg)) = param.split_once(':') else {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("expected `key:value`, found `{param}`"),
|
||||||
|
Some("known parameters: name, tiling"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let arg = arg.trim();
|
||||||
|
match key.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"name" => {
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(err(span, "`name:` needs a value", None));
|
||||||
|
}
|
||||||
|
decl.name = Some(arg.to_string());
|
||||||
|
}
|
||||||
|
// Named rather than swept into the catch-all so the message can say
|
||||||
|
// why a parameter Hyprland does have is not accepted here.
|
||||||
|
"monitor" | "output" => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`{}:` has no COSMIC equivalent", key.trim()),
|
||||||
|
Some(MONITOR_HELP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
"tiling" => {
|
||||||
|
decl.tiling = Some(match arg.to_ascii_lowercase().as_str() {
|
||||||
|
"true" | "yes" | "on" | "1" => true,
|
||||||
|
"false" | "no" | "off" | "0" => false,
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("`tiling:` expects a boolean, found `{other}`"),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(err(
|
||||||
|
span,
|
||||||
|
format!("unknown workspace parameter `{other}`"),
|
||||||
|
Some("known parameters: name, tiling"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(decl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ron_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a, written out rather than reached for.
|
||||||
|
///
|
||||||
|
/// `DefaultHasher` is explicitly documented as not stable across Rust releases,
|
||||||
|
/// which would make workspace ids change under the user when the toolchain
|
||||||
|
/// moves -- and the id is what ties a window's saved workspace to the workspace
|
||||||
|
/// it reappears on. Twelve lines of FNV is cheaper than that class of bug.
|
||||||
|
fn fnv1a(s: &str) -> u32 {
|
||||||
|
let mut h: u32 = 0x811c9dc5;
|
||||||
|
for b in s.as_bytes() {
|
||||||
|
h ^= *b as u32;
|
||||||
|
h = h.wrapping_mul(0x01000193);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable id for the workspace at `index`.
|
||||||
|
///
|
||||||
|
/// cosmic-comp's own `random_workspace_id` is `format!("{:x}", rand(0..2<<24))`,
|
||||||
|
/// which is at most seven hex digits. Forcing the high nibble on here makes ours
|
||||||
|
/// exactly eight, so a generated id and a compositor-generated one cannot
|
||||||
|
/// collide by construction rather than by being unlikely to.
|
||||||
|
fn workspace_id(index: u32) -> String {
|
||||||
|
let h = fnv1a(&format!("hyprcosmic:workspace:{index}"));
|
||||||
|
format!("{:08x}", 0x1000_0000 | (h & 0x0fff_ffff))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the declarations as the RON `Vec<PinnedWorkspace>` cosmic-comp stores
|
||||||
|
/// in `pinned_workspaces`.
|
||||||
|
///
|
||||||
|
/// `default_tiling` is the session-wide `general:autotile`, used for any
|
||||||
|
/// workspace that did not say. Without it, declaring workspaces would silently
|
||||||
|
/// turn tiling off on all of them for a user who had asked for it globally.
|
||||||
|
pub fn render(decls: &[WorkspaceDecl], default_tiling: bool) -> String {
|
||||||
|
let highest = decls.iter().map(|d| d.index).max().unwrap_or(0);
|
||||||
|
|
||||||
|
let mut out = String::from("[\n");
|
||||||
|
for index in 1..=highest {
|
||||||
|
// Gaps are filled rather than skipped: the restore is positional, so an
|
||||||
|
// absent workspace 3 would put the declared workspace 4 at position 3.
|
||||||
|
let decl = decls.iter().find(|d| d.index == index);
|
||||||
|
|
||||||
|
let tiling = decl.and_then(|d| d.tiling).unwrap_or(default_tiling);
|
||||||
|
let name = match decl.and_then(|d| d.name.as_deref()) {
|
||||||
|
Some(n) => format!("Some({})", ron_string(n)),
|
||||||
|
None => "None".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// An empty `OutputMatch` is "no preference", not a guess. It is the
|
||||||
|
// front of the workspace's `output_stack`, which `prefers_output`
|
||||||
|
// consults when an output appears; a name nothing can match leaves the
|
||||||
|
// workspace where `add_output` created it. See MONITOR_HELP.
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
r#" (output: (name: "", edid: None), tiling_enabled: {tiling}, id: Some({}), name: {name}),"#,
|
||||||
|
ron_string(&workspace_id(index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push_str("]\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
const S: Span = Span {
|
||||||
|
line: 1,
|
||||||
|
col: 1,
|
||||||
|
len: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ws(v: &str) -> WorkspaceDecl {
|
||||||
|
parse_workspace(v, S).expect("should parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad(v: &str) -> WorkspaceError {
|
||||||
|
parse_workspace(v, S).expect_err("should not parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shortest_useful_declaration_is_a_bare_index() {
|
||||||
|
assert_eq!(
|
||||||
|
ws("4"),
|
||||||
|
WorkspaceDecl {
|
||||||
|
index: 4,
|
||||||
|
name: None,
|
||||||
|
tiling: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_parameter_together() {
|
||||||
|
assert_eq!(
|
||||||
|
ws("4, name:web, tiling:true"),
|
||||||
|
WorkspaceDecl {
|
||||||
|
index: 4,
|
||||||
|
name: Some("web".into()),
|
||||||
|
tiling: Some(true),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameters_are_order_independent_and_tolerate_spacing() {
|
||||||
|
assert_eq!(ws("2,tiling:off,name:mail"), ws("2, name:mail, tiling:off"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hyprland's `monitor:` is the parameter a user is most likely to reach
|
||||||
|
/// for, and the one COSMIC cannot honour. Accepting and ignoring it would
|
||||||
|
/// be the worst of the three options, so it must fail and say why.
|
||||||
|
#[test]
|
||||||
|
fn monitor_is_refused_with_the_reason_rather_than_silently_ignored() {
|
||||||
|
for spelling in ["monitor", "output"] {
|
||||||
|
let e = bad(&format!("1, {spelling}:eDP-1"));
|
||||||
|
assert!(
|
||||||
|
e.message.contains("no COSMIC equivalent"),
|
||||||
|
"{spelling}: {}",
|
||||||
|
e.message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("EDID"),
|
||||||
|
"{spelling}: {:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_may_contain_spaces() {
|
||||||
|
assert_eq!(ws("1, name:web and mail").name.as_deref(), Some("web and mail"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hyprland's named-workspace form has no COSMIC equivalent, and the failure
|
||||||
|
/// if it were guessed at would be workspaces in the wrong order.
|
||||||
|
#[test]
|
||||||
|
fn the_hyprland_name_first_form_is_refused_with_the_reason() {
|
||||||
|
let e = bad("name:web, tiling:true");
|
||||||
|
assert!(e.message.contains("starts with its index"), "{}", e.message);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("by position"),
|
||||||
|
"{:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_zero_does_not_exist() {
|
||||||
|
let e = bad("0");
|
||||||
|
assert!(e.message.contains("outside 1..="), "{}", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cap is the whole reason this check exists: without it the typo below
|
||||||
|
/// creates a thousand workspaces rather than reporting anything.
|
||||||
|
#[test]
|
||||||
|
fn an_absurd_index_is_a_diagnostic_not_a_thousand_workspaces() {
|
||||||
|
let e = bad("1000");
|
||||||
|
assert!(e.message.contains("outside 1..=32"), "{}", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_parameter_lists_the_known_ones() {
|
||||||
|
let e = bad("1, gapsin:0");
|
||||||
|
assert!(e.message.contains("gapsin"), "{}", e.message);
|
||||||
|
assert!(
|
||||||
|
e.help.as_deref().unwrap_or_default().contains("name, tiling"),
|
||||||
|
"{:?}",
|
||||||
|
e.help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_parameter_without_a_colon_is_rejected() {
|
||||||
|
let e = bad("1, web");
|
||||||
|
assert!(e.message.contains("key:value"), "{}", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiling_takes_the_same_boolean_spellings_as_the_rest_of_the_file() {
|
||||||
|
for v in ["true", "yes", "on", "1"] {
|
||||||
|
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(true), "{v}");
|
||||||
|
}
|
||||||
|
for v in ["false", "no", "off", "0"] {
|
||||||
|
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(false), "{v}");
|
||||||
|
}
|
||||||
|
assert!(bad("1, tiling:sometimes").message.contains("boolean"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the positional restore turns on: declaring only 4 must still
|
||||||
|
/// emit 1, 2 and 3, or the browser workspace comes back as workspace 1.
|
||||||
|
#[test]
|
||||||
|
fn declaring_one_high_index_materialises_every_workspace_below_it() {
|
||||||
|
let ron = render(&[ws("4, name:web")], false);
|
||||||
|
assert_eq!(ron.lines().filter(|l| l.contains("output:")).count(), 4, "{ron}");
|
||||||
|
|
||||||
|
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
|
||||||
|
assert!(lines[0].contains("name: None"), "{}", lines[0]);
|
||||||
|
assert!(lines[1].contains("name: None"), "{}", lines[1]);
|
||||||
|
assert!(lines[2].contains("name: None"), "{}", lines[2]);
|
||||||
|
assert!(lines[3].contains(r#"name: Some("web")"#), "{}", lines[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gaps_between_declarations_are_filled_in_order() {
|
||||||
|
let ron = render(&[ws("3, name:code"), ws("1, name:term")], false);
|
||||||
|
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert!(lines[0].contains(r#"name: Some("term")"#), "{}", lines[0]);
|
||||||
|
assert!(lines[1].contains("name: None"), "{}", lines[1]);
|
||||||
|
assert!(lines[2].contains(r#"name: Some("code")"#), "{}", lines[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_declarations_render_as_an_empty_list() {
|
||||||
|
assert_eq!(render(&[], false), "[\n]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workspace that did not mention tiling must not quietly contradict
|
||||||
|
/// `general:autotile`.
|
||||||
|
#[test]
|
||||||
|
fn unset_tiling_follows_the_session_default_both_ways() {
|
||||||
|
assert!(render(&[ws("1")], true).contains("tiling_enabled: true"));
|
||||||
|
assert!(render(&[ws("1")], false).contains("tiling_enabled: false"));
|
||||||
|
// ...and an explicit value still wins over it.
|
||||||
|
assert!(render(&[ws("1, tiling:false")], true).contains("tiling_enabled: false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not a placeholder: an unmatchable `OutputMatch` is how a workspace says
|
||||||
|
/// it has no output preference, which is the only thing cosmic.conf can
|
||||||
|
/// truthfully express. See MONITOR_HELP.
|
||||||
|
#[test]
|
||||||
|
fn the_output_match_is_always_empty() {
|
||||||
|
assert!(render(&[ws("1")], false).contains(r#"output: (name: "", edid: None)"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids have to survive a re-apply, or every `hyprcosmic-conf apply` would hand
|
||||||
|
/// the same workspaces new identities.
|
||||||
|
#[test]
|
||||||
|
fn ids_are_stable_across_runs_and_unique_per_index() {
|
||||||
|
assert_eq!(workspace_id(4), workspace_id(4));
|
||||||
|
let ids: BTreeSet<String> = (1..=MAX_INDEX).map(workspace_id).collect();
|
||||||
|
assert_eq!(ids.len(), MAX_INDEX as usize, "an index collided");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cosmic-comp's `random_workspace_id` is `{:x}` of a number below 2<<24,
|
||||||
|
/// so it is never more than seven hex digits. Ours are always eight, which
|
||||||
|
/// is what makes a collision impossible rather than unlikely.
|
||||||
|
#[test]
|
||||||
|
fn ids_cannot_collide_with_a_compositor_generated_one() {
|
||||||
|
for i in 1..=MAX_INDEX {
|
||||||
|
let id = workspace_id(i);
|
||||||
|
assert_eq!(id.len(), 8, "{id}");
|
||||||
|
assert!(
|
||||||
|
u32::from_str_radix(&id, 16).unwrap() >= 0x1000_0000,
|
||||||
|
"{id} is inside the compositor's own range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_quote_in_a_name_cannot_break_out_of_the_ron() {
|
||||||
|
let ron = render(&[ws(r#"1, name:say "hi""#)], false);
|
||||||
|
assert!(ron.contains(r#"name: Some("say \"hi\"")"#), "{ron}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
Submodule cosmic-session updated: 73867a5d6a...75267c5977
+27
-20
@@ -19,10 +19,14 @@ arch=('x86_64')
|
|||||||
url="https://github.com/outbackdingo/hyprcosmic"
|
url="https://github.com/outbackdingo/hyprcosmic"
|
||||||
license=('GPL-3.0-only')
|
license=('GPL-3.0-only')
|
||||||
|
|
||||||
# The HyDE shell. Without these the session starts to a blank screen: no bar,
|
# cosmic-session is COSMIC itself, which this runs on rather than replaces: one
|
||||||
# no launcher, no wallpaper.
|
# entry pulls the whole desktop, and cosmic-settings, cosmic-osd, the portal and
|
||||||
depends=('waybar' 'rofi-wayland' 'wayland' 'libxkbcommon' 'libinput' 'seatd'
|
# the rest are taken from the repositories at the version they were tested at.
|
||||||
'mesa' 'pixman' 'libdisplay-info' 'systemd-libs')
|
#
|
||||||
|
# The rest is the HyDE shell. Without those the session starts to a blank
|
||||||
|
# screen: no bar, no launcher, no wallpaper.
|
||||||
|
depends=('cosmic-session' 'waybar' 'rofi-wayland' 'wayland' 'libxkbcommon'
|
||||||
|
'libinput' 'seatd' 'mesa' 'pixman' 'libdisplay-info' 'systemd-libs')
|
||||||
|
|
||||||
# awww is in the AUR rather than in the repositories, so it cannot be a hard
|
# 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
|
# depends without making the package uninstallable for anyone who has not built
|
||||||
@@ -32,29 +36,32 @@ optdepends=('awww: wallpaper daemon, required for HyDE theme wallpapers'
|
|||||||
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
|
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
|
||||||
'qt5ct: Qt application theming to match the GTK theme')
|
'qt5ct: Qt application theming to match the GTK theme')
|
||||||
|
|
||||||
# What "complete replacement" means in packaging terms. Every path this writes
|
# No conflicts and no provides, deliberately. This installs beside COSMIC: its
|
||||||
# under /usr/bin is one cosmic-comp and cosmic-session also own, so the two
|
# binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
|
||||||
# cannot coexist -- which is correct, they are two builds of the same programs.
|
# writes no path that cosmic-comp or cosmic-session owns. An earlier revision
|
||||||
#
|
# took the cosmic-* names and could not be installed over a COSMIC system
|
||||||
# conflicts without replaces/provides-driven auto-removal: pacman stops and
|
# without erasing the desktop it forked.
|
||||||
# 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')
|
options=('!strip' '!debug')
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
# Set by make-packages.sh. Failing loudly here beats producing an empty
|
# Points at a tree `just install` has already staged. Failing loudly here
|
||||||
# package, which is what a bare `cp -a "$unset/."` would do.
|
# beats producing an empty package, which is what a bare `cp -a "$unset/."`
|
||||||
|
# would do. The workflow sets it; by hand it is
|
||||||
|
# just install "$PWD/stage" /usr
|
||||||
|
# HYPRCOSMIC_STAGEDIR="$PWD/stage" makepkg --nodeps
|
||||||
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
|
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
|
||||||
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see tools/make-packages.sh" >&2
|
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see .github/workflows/packages.yml" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
|
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
|
||||||
|
|
||||||
# The two files a broken install shows up in first: a bad Exec line puts an
|
# No desktop-file-validate on the session entry. It rejects DesktopNames,
|
||||||
# entry on the greeter's menu that fails silently when it is chosen.
|
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
|
||||||
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/hyprcosmic.desktop"
|
# Desktop Entry Specification registers keys for application launchers and
|
||||||
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/cosmic.desktop"
|
# this is a session file. The copy Fedora ships as cosmic-session-1.5.0-1.fc44
|
||||||
|
# fails the identical check -- so this is the validator's gap, not something
|
||||||
|
# the fork introduced. The workflow checks what actually matters instead --
|
||||||
|
# that Exec names a file this package installs -- against the staged tree,
|
||||||
|
# before makepkg sees it.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,13 @@ Installed-Size: @INSTALLED_SIZE@
|
|||||||
Depends: @SHLIB_DEPENDS@
|
Depends: @SHLIB_DEPENDS@
|
||||||
Recommends: waybar, rofi
|
Recommends: waybar, rofi
|
||||||
Suggests: fonts-hack-ttf, qt5ct
|
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
|
Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||||
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||||
Hyprland's idiom and wears a HyDE-style shell.
|
Hyprland's idiom and wears a HyDE-style shell.
|
||||||
.
|
.
|
||||||
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||||
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
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
|
hyprcosmic-conf. The file is the source of truth: keys it names are applied at
|
||||||
every login over whatever the settings UI last stored, and keys it does not
|
every login over whatever the settings UI last stored, and keys it does not
|
||||||
name are left alone.
|
name are left alone.
|
||||||
.
|
.
|
||||||
@@ -27,6 +24,9 @@ Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
|||||||
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
|
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
|
||||||
waybar's hyprland modules work unmodified.
|
waybar's hyprland modules work unmodified.
|
||||||
.
|
.
|
||||||
This package replaces the distribution's COSMIC. It installs both session
|
This package carries the whole desktop, which on Debian it has to: COSMIC is
|
||||||
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
|
not packaged there, in any suite, so there is nothing to depend on and nothing
|
||||||
both served by these binaries.
|
to install beside. The Fedora and Arch packages ship only this fork's three
|
||||||
|
binaries and take the rest from the distribution. Here the forked binaries are
|
||||||
|
hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, the components they
|
||||||
|
launch are included, and the session entry is the HyprCosmic one alone.
|
||||||
|
|||||||
@@ -20,14 +20,19 @@
|
|||||||
# it on the release you intend to install it on. The workflow does that by
|
# 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.
|
# running the whole job inside a container of the target distribution.
|
||||||
#
|
#
|
||||||
# WHY IT IS ONE PACKAGE AND NOT TWENTY-SEVEN
|
# WHY IT IS ONE PACKAGE, AND WHY IT IS A SMALL ONE
|
||||||
# ------------------------------------------
|
# ------------------------------------------------
|
||||||
# Fedora splits COSMIC into a package per component, which is right for a
|
# 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
|
# distribution tracking upstream. This fork changes three of them -- the
|
||||||
# unit: the compositor, the session and the config compiler are versioned and
|
# compositor, the session and the config compiler it adds -- and they are
|
||||||
# tested together, and there is no supported combination in which you take the
|
# versioned and tested together, so one package is an accurate description of
|
||||||
# HyprCosmic cosmic-comp and the distribution's cosmic-session. One package is
|
# what is actually supported.
|
||||||
# an accurate description of what is actually supported.
|
#
|
||||||
|
# It is not a package per component and it is not the whole desktop either. The
|
||||||
|
# build tree produces all of COSMIC, because it is COSMIC's tree, but shipping
|
||||||
|
# all of it would mean owning files that 25 distribution packages already own.
|
||||||
|
# The workflow reduces the staged tree to what this fork actually produces
|
||||||
|
# before any of the three packaging recipes see it.
|
||||||
|
|
||||||
%global _hardened_build 1
|
%global _hardened_build 1
|
||||||
|
|
||||||
@@ -45,22 +50,22 @@ License: GPL-3.0-only
|
|||||||
URL: https://github.com/outbackdingo/hyprcosmic
|
URL: https://github.com/outbackdingo/hyprcosmic
|
||||||
BuildArch: x86_64
|
BuildArch: x86_64
|
||||||
|
|
||||||
# These are what "complete replacement" means in packaging terms. Every path
|
# COSMIC itself, which this runs on rather than replaces.
|
||||||
# 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
|
# One line pulls the whole desktop, because cosmic-session requires every
|
||||||
# `dnf install hyprcosmic` quietly remove the desktop the machine is currently
|
# component. That is exactly what is wanted: HyprCosmic forks the compositor,
|
||||||
# running. Conflicts stops and says so, and removing the COSMIC packages stays
|
# the session and adds the config compiler, and takes cosmic-settings,
|
||||||
# something a person decides to do rather than something a resolver does on
|
# cosmic-osd, the portal and the rest from the distribution at the version the
|
||||||
# their behalf.
|
# distribution tested them at.
|
||||||
Conflicts: cosmic-comp
|
#
|
||||||
Conflicts: cosmic-session
|
# There is deliberately no Conflicts and no Provides here. An earlier revision
|
||||||
|
# had both, on the reading that a fork of the desktop replaces the desktop, and
|
||||||
# What it stands in for, so anything depending on a COSMIC session is satisfied.
|
# it could not be installed: this package's files collided with 25 others in
|
||||||
Provides: cosmic-comp = %{version}-%{release}
|
# rpm's transaction check, and satisfying that by claiming all 25 with Conflicts
|
||||||
Provides: cosmic-session = %{version}-%{release}
|
# would have erased cosmic-greeter, which on a stock Fedora COSMIC is the
|
||||||
|
# display manager. Installing beside COSMIC costs three renamed binaries and
|
||||||
|
# leaves the stock session on the greeter's menu to fall back to.
|
||||||
|
Requires: cosmic-session >= 1.5.0
|
||||||
|
|
||||||
# The HyDE shell. These are separate programs this fork drives rather than
|
# 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
|
# builds, and without them the session starts to a blank screen with no bar and
|
||||||
@@ -76,8 +81,30 @@ Requires: rofi-wayland
|
|||||||
# unsatisfiable dependency is not.
|
# unsatisfiable dependency is not.
|
||||||
Recommends: awww
|
Recommends: awww
|
||||||
|
|
||||||
# Nerd Font glyphs are most of what the bar draws.
|
# Something for the wallpaper daemon to draw before a theme has been imported.
|
||||||
Recommends: nerd-fonts
|
# hyprcosmic-wallpaper looks here first when ~/.local/share/wallpapers has no
|
||||||
|
# `current` link yet, which on a machine that has just installed this package is
|
||||||
|
# always. Nothing in the dependency chain pulls it in otherwise -- neither
|
||||||
|
# cosmic-session nor cosmic-comp requires it -- and without it a first login is
|
||||||
|
# a bar on a black screen.
|
||||||
|
Recommends: cosmic-wallpapers
|
||||||
|
|
||||||
|
# Nerd Font glyphs are most of what the bar and the launcher draw, and Fedora
|
||||||
|
# has nothing that provides them. This line used to say `nerd-fonts`, which is
|
||||||
|
# not a package in any Fedora repository -- so it could never be satisfied, and
|
||||||
|
# the fonts never arrived, silently, because that is what Recommends does when
|
||||||
|
# it cannot resolve. The two patched fonts Fedora does ship are no help either:
|
||||||
|
# texlive-inconsolata-nerd-font lands under texmf-dist and kitty's
|
||||||
|
# SymbolsNerdFont under /usr/lib64/kitty, and fontconfig scans neither path.
|
||||||
|
#
|
||||||
|
# So recommend the fallback the configuration actually names. rules.rasi asks
|
||||||
|
# for "JetBrainsMono Nerd Font, Noto Sans Mono": this is the second of the two.
|
||||||
|
# It cannot draw a single glyph, but it decides what the text is set in instead
|
||||||
|
# of leaving that to whatever fontconfig happens to pick. A virtual provide
|
||||||
|
# rather than a name, because two packages carry it and either will do.
|
||||||
|
#
|
||||||
|
# The glyphs themselves have to be installed by hand. See README.md.
|
||||||
|
Recommends: font(notosansmono)
|
||||||
|
|
||||||
%description
|
%description
|
||||||
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||||
@@ -85,7 +112,7 @@ Hyprland's idiom and wears a HyDE-style shell.
|
|||||||
|
|
||||||
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||||
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
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
|
hyprcosmic-conf. The file is the source of truth: keys it names are applied at every
|
||||||
login over whatever the settings UI last stored, and keys it does not name are
|
login over whatever the settings UI last stored, and keys it does not name are
|
||||||
left alone.
|
left alone.
|
||||||
|
|
||||||
@@ -94,9 +121,11 @@ 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
|
with a Hyprland-compatible IPC socket so that HyDE's scripts and waybar's
|
||||||
hyprland modules work unmodified.
|
hyprland modules work unmodified.
|
||||||
|
|
||||||
This package replaces the distribution's COSMIC. It installs both session
|
This package installs beside the distribution's COSMIC rather than over it. Its
|
||||||
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
|
binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
|
||||||
both served by these binaries.
|
adds one session entry; the stock COSMIC entry stays on the greeter's menu,
|
||||||
|
served by the distribution's own binaries, so a session that will not start is
|
||||||
|
one logout away from a desktop that will.
|
||||||
|
|
||||||
%prep
|
%prep
|
||||||
# Nothing to unpack. See the note at the top of this file.
|
# Nothing to unpack. See the note at the top of this file.
|
||||||
@@ -106,11 +135,16 @@ test -n "%{stagedir}" || { echo "define stagedir: see .github/workflows/packages
|
|||||||
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
|
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
|
||||||
cp -a "%{stagedir}/." "%{buildroot}/"
|
cp -a "%{stagedir}/." "%{buildroot}/"
|
||||||
|
|
||||||
# The desktop entries are the two files a broken install shows up in first, so
|
# The session entry is checked by the workflow against the staged tree, not
|
||||||
# they are validated rather than assumed. A .desktop with a bad Exec line puts
|
# with desktop-file-validate here. desktop-file-validate rejects DesktopNames,
|
||||||
# an entry on the greeter's menu that fails silently when chosen.
|
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
|
||||||
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/hyprcosmic.desktop"
|
# Desktop Entry Specification registers keys for application launchers and this
|
||||||
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
|
# is a session file. The copy Fedora already ships as cosmic-session-1.5.0-1.fc44
|
||||||
|
# fails the identical check -- so this is the validator's gap, not something the
|
||||||
|
# fork introduced. Dropping the key would satisfy the validator and break the
|
||||||
|
# session.
|
||||||
|
# See "Check the session entries" in .github/workflows/packages.yml, which
|
||||||
|
# tests what actually matters: that Exec names a file this package installs.
|
||||||
|
|
||||||
# Generated by the workflow from the staged tree rather than written out here.
|
# 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
|
# A hand-maintained list across 27 components would be wrong within a week, and
|
||||||
@@ -119,5 +153,15 @@ desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
|
|||||||
%files -f %{filelist}
|
%files -f %{filelist}
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
* Sun Aug 10 2026 dingo <[email protected]> - 0.1.0-1
|
* Wed Aug 12 2026 dingo <[email protected]> - 0.1.1-1
|
||||||
- First package of the fork: COSMIC replaced as a unit, HyDE shell, cosmic-conf.
|
- Seed ~/.config from /usr/share/hyprcosmic/skel at every session start, so a
|
||||||
|
machine that has never seen HyprCosmic logs into a configured desktop rather
|
||||||
|
than a bare compositor. Existing files are never overwritten.
|
||||||
|
- Recommend font(notosansmono) in place of nerd-fonts, which is not a package
|
||||||
|
in any Fedora repository and so could never have been installed.
|
||||||
|
- Recommend cosmic-wallpapers, and add hyprcosmic-wallpaper, which falls back
|
||||||
|
to it when no theme has been imported yet.
|
||||||
|
|
||||||
|
* Mon Aug 10 2026 dingo <[email protected]> - 0.1.0-1
|
||||||
|
- First package of the fork: hyprcosmic-comp, hyprcosmic-session and
|
||||||
|
hyprcosmic-conf installed beside the distribution's COSMIC, with a HyDE shell.
|
||||||
|
|||||||
+46
-16
@@ -76,6 +76,9 @@ SHARED=(
|
|||||||
"config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644"
|
"config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644"
|
||||||
"config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644"
|
"config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644"
|
||||||
"config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755"
|
"config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755"
|
||||||
|
"config/bin/hyprcosmic-fan:bin/hyprcosmic-fan:755"
|
||||||
|
"config/bin/hyprcosmic-keybinds:bin/hyprcosmic-keybinds:755"
|
||||||
|
"config/bin/hyprcosmic-wallpaper:bin/hyprcosmic-wallpaper:755"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The session entry point. Kept apart from SHARED because it is versioned in the
|
# The session entry point. Kept apart from SHARED because it is versioned in the
|
||||||
@@ -95,31 +98,57 @@ SOURCES=(
|
|||||||
"config/waybar/generate-config.py" # the generator, and the icon table
|
"config/waybar/generate-config.py" # the generator, and the icon table
|
||||||
)
|
)
|
||||||
|
|
||||||
# Files under config/ that are deliberately NOT installed here, each with the
|
# Per-user files. These cannot live in share/hyprcosmic with the rest: style.css
|
||||||
# thing that does install it. This list is not decoration: the audit below
|
# and config.rasi reach their theme through relative @imports, which resolve
|
||||||
# refuses to run unless every file under config/ appears in exactly one of the
|
# against the importing file, so the importer has to sit in the same per-user
|
||||||
# three lists, so adding a file forces a decision about where it belongs instead
|
# directory as the theme it picks up.
|
||||||
# of letting it be quietly left out of all of them.
|
#
|
||||||
PER_USER=(
|
# They are still installed, as a skeleton. start-hyprcosmic copies anything
|
||||||
"config/autostart" # ~/.config/hyprcosmic/autostart, by hand
|
# missing out of share/hyprcosmic/skel into $XDG_CONFIG_HOME when a session
|
||||||
"config/cosmic.conf" # ~/.config/hyprcosmic/cosmic.conf, by hand
|
# starts, and never overwrites. Destinations below therefore mirror the layout
|
||||||
"config/waybar/style.css" # ~/.config/hyprcosmic/waybar/style.css; @imports a sibling theme.css
|
# under ~/.config exactly -- skel/hyprcosmic/autostart becomes
|
||||||
"config/rofi/config.rasi" # ~/.config/rofi/config.rasi, by `cosmic-conf import-theme --assets`
|
# ~/.config/hyprcosmic/autostart -- because the seeding is a plain copy that
|
||||||
|
# reads the layout off this tree rather than a list it keeps in step by hand.
|
||||||
|
#
|
||||||
|
# Before this existed the answer was "by hand", which meant a machine that had
|
||||||
|
# never seen HyprCosmic logged into a bare compositor: no autostart, so no
|
||||||
|
# waybar and no wallpaper, and no keybindings, on a screen with nothing drawn on
|
||||||
|
# it. Nothing reported an error, because from the session's point of view
|
||||||
|
# nothing had gone wrong.
|
||||||
|
SKEL=(
|
||||||
|
"config/autostart:share/hyprcosmic/skel/hyprcosmic/autostart:644"
|
||||||
|
"config/cosmic.conf:share/hyprcosmic/skel/hyprcosmic/cosmic.conf:644"
|
||||||
|
"config/waybar/style.css:share/hyprcosmic/skel/hyprcosmic/waybar/style.css:644"
|
||||||
|
"config/waybar/theme.css:share/hyprcosmic/skel/hyprcosmic/waybar/theme.css:644"
|
||||||
|
"config/rofi/config.rasi:share/hyprcosmic/skel/rofi/config.rasi:644"
|
||||||
|
"config/rofi/theme.rasi:share/hyprcosmic/skel/rofi/theme.rasi:644"
|
||||||
|
"config/rofi/local.rasi:share/hyprcosmic/skel/rofi/local.rasi:644"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Refuses to run unless every file under config/ appears in exactly one of
|
||||||
|
# SHARED, SKEL or SOURCES. This is not decoration: adding a file forces a
|
||||||
|
# decision about where it belongs instead of letting it be quietly left out of
|
||||||
|
# all three and never installed.
|
||||||
audit_config_tree() {
|
audit_config_tree() {
|
||||||
local f rel known=" ${PER_USER[*]} ${SOURCES[*]} " unclassified=()
|
local f rel known=" ${SOURCES[*]} " unclassified=()
|
||||||
# Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix
|
# Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix
|
||||||
# from the first element only and silently keeps the rest whole.
|
# from the first element only and silently keeps the rest whole.
|
||||||
for f in "${SHARED[@]}"; do known+="${f%%:*} "; done
|
for f in "${SHARED[@]}"; do known+="${f%%:*} "; done
|
||||||
|
for f in "${SKEL[@]}"; do known+="${f%%:*} "; done
|
||||||
|
|
||||||
while IFS= read -r -d '' f; do
|
while IFS= read -r -d '' f; do
|
||||||
rel="${f#"$REPO"/}"
|
rel="${f#"$REPO"/}"
|
||||||
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
|
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
|
||||||
done < <(find "$REPO/config" -type f -print0 | sort -z)
|
# Dot-directories are pruned. Nothing shipped lives in one, and tooling
|
||||||
|
# drops state inside the tree without asking -- .omc/ appeared under
|
||||||
|
# config/waybar/ and failed this audit with six files that are gitignored
|
||||||
|
# and are not assets. Failing on those trains you to ignore the one message
|
||||||
|
# that catches a genuinely unclassified file. Dot *files* are still walked;
|
||||||
|
# only directories are pruned.
|
||||||
|
done < <(find "$REPO/config" -name '.?*' -type d -prune -o -type f -print0 | sort -z)
|
||||||
|
|
||||||
((${#unclassified[@]} == 0)) || die "not listed as shared or per-user: ${unclassified[*]}
|
((${#unclassified[@]} == 0)) || die "not listed as shared, skeleton or generator input: ${unclassified[*]}
|
||||||
Add each to SHARED or PER_USER in $(basename "${BASH_SOURCE[0]}") and say which."
|
Add each to SHARED, SKEL or SOURCES in $(basename "${BASH_SOURCE[0]}") and say which."
|
||||||
}
|
}
|
||||||
|
|
||||||
# The prefix is only half honoured, and pretending otherwise would be worse than
|
# The prefix is only half honoured, and pretending otherwise would be worse than
|
||||||
@@ -130,11 +159,12 @@ audit_config_tree() {
|
|||||||
check_prefix_assumptions() {
|
check_prefix_assumptions() {
|
||||||
[[ "$PREFIX" == /usr ]] && return 0
|
[[ "$PREFIX" == /usr ]] && return 0
|
||||||
local hits
|
local hits
|
||||||
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ 2>/dev/null | sort | tr '\n' ' ')"
|
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ cosmic-session/data/ 2>/dev/null | sort | tr '\n' ' ')"
|
||||||
[[ -z "$hits" ]] && return 0
|
[[ -z "$hits" ]] && return 0
|
||||||
warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:"
|
warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:"
|
||||||
warn " $hits"
|
warn " $hits"
|
||||||
warn "they will keep reading /usr/share unless you edit them; rofi will show a parse error if it is empty"
|
warn "they will keep reading /usr/share unless you edit them; rofi will show a parse error if it is empty"
|
||||||
|
warn "start-hyprcosmic is the exception: set HYPRCOSMIC_SKEL=$PREFIX/share/hyprcosmic/skel to point the seeding at this prefix"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fail before touching anything rather than half way through. The nearest
|
# Fail before touching anything rather than half way through. The nearest
|
||||||
@@ -190,7 +220,7 @@ handle() {
|
|||||||
audit_config_tree
|
audit_config_tree
|
||||||
[[ "$MODE" == install ]] && check_prefix_assumptions
|
[[ "$MODE" == install ]] && check_prefix_assumptions
|
||||||
|
|
||||||
targets=("${SHARED[@]}")
|
targets=("${SHARED[@]}" "${SKEL[@]}")
|
||||||
if ((WITH_SESSION)); then
|
if ((WITH_SESSION)); then
|
||||||
if [[ -d "$REPO/cosmic-session/data" ]]; then
|
if [[ -d "$REPO/cosmic-session/data" ]]; then
|
||||||
targets+=("${SESSION[@]}")
|
targets+=("${SESSION[@]}")
|
||||||
|
|||||||
Reference in New Issue
Block a user