11 Commits
Author SHA1 Message Date
nvrl 6addb504e6 some qs fixes 2026-10-09 04:17:36 +02:00
nvrl 32d5683fad feat(quickshell): group the notification log by sender
The log lists one entry per app, ordered by each app's newest
notification. An app with several shows a header with its name, a
count and a clear mark for that app alone, then its newest entry and a
'+ N more' line; clicking either unfolds the rest. Entries under a
header drop their own sender line and keep the urgency dot.
2026-10-04 23:45:57 +02:00
nvrl ec76f8dec3 chore(hypr): drop screenshot_window.sh, replaced by shot.sh 2026-10-04 23:40:22 +02:00
nvrl 299291eaf6 chore(quickshell): drop the unused halftone, noise and stripe tiles
Only dots.png and scanline.png are referenced (Theme.texDots and
Theme.texScanline).
2026-10-04 23:40:11 +02:00
nvrl add12ccc76 refactor(quickshell): drop the Persona names from the primitives
P5Panel, P5Button, P5Slider and P5Tooltip become Panel, ActionButton,
LevelSlider and Tooltip. ActionButton and LevelSlider avoid clashing
with QtQuick.Controls, which the ask panel imports.
2026-10-04 23:39:51 +02:00
nvrl 7b7cd3e575 style: one sans and one mono, and no stretched text
GTK, Qt and Hyprland's own text move to the shell's pair: Noto Sans for
interface text and JetBrainsMono Nerd Font for figures and code. They
had been Adwaita Sans, Sans Serif and FiraCode.

The tray menu now slides and fades in without scaling, and a pressed
button sinks one pixel instead of shrinking, so no label is ever
stretched mid-animation.
2026-10-04 23:38:46 +02:00
nvrl 31b57f4f8c feat: screenshots answer with a thumbnail notification
All four Print binds go through hypr/scripts/shot.sh. Every capture
lands on the clipboard and posts a notification showing the picture,
with Open, Save (for clipboard captures, which wait in the runtime dir)
and Edit when satty is installed. Cancelling the region picker does
nothing.

Notification cards now load images passed as a path hint: Quickshell
routes those through the icon provider, which returned a placeholder.
2026-10-04 23:36:56 +02:00
nvrl 373584f34e feat(quickshell): quick switches under the dashboard
A row of pill switches under the dashboard's columns: Wi-Fi,
Bluetooth, do not disturb and stay awake, plus night light through
hyprsunset when it is installed. On is the light slab with ink type;
off is a hairline.
2026-10-04 23:34:27 +02:00
nvrl f7551006b3 feat(quickshell): privacy indicator at the head of the rail
A lit icon tops the rail while another program records from a
microphone, uses a camera, or shares the screen. It is read from
PipeWire: capture streams for the mic, links out of video sources for
the camera and screencasts. The tooltip names the recording apps.
2026-10-04 23:31:39 +02:00
nvrl dc2a8fc883 feat(quickshell): scratchpad ring in the bar, keyboard layout in the OSD
The Super+S scratchpad gets a ring after the workspace pips while it
holds windows, filled while it is open; clicking it toggles it.
Switching keyboard layout flashes the OSD with the short name (US,
Dvorak) and the full keymap underneath.
2026-10-04 23:29:42 +02:00
nvrl b69f4bcf0d feat(quickshell): Alt+Tab overview of every monitor and workspace
Each monitor is a row of workspace cards at its own aspect ratio, with
live captures of the windows where they sit, including windows on
hidden workspaces. Alt+Tab opens it with the previous window picked,
further Tabs walk the focus history, and releasing Alt switches.
Super+W opens it without the hold. Hover, click, Enter and the
workspace digits work too.

Group cycling moves from Alt+Tab to Super+Tab and Super+Shift+Tab.
2026-10-04 23:27:48 +02:00
59 changed files with 1789 additions and 192 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
[Settings] [Settings]
gtk-theme-name=adw-gtk3-dark gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Papirus-Dark gtk-icon-theme-name=Papirus-Dark
gtk-font-name=Adwaita Sans 11 gtk-font-name=Noto Sans 11
gtk-cursor-theme-name=Bibata-Modern-Classic gtk-cursor-theme-name=Bibata-Modern-Classic
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1 gtk-application-prefer-dark-theme=1
+1 -1
View File
@@ -1,7 +1,7 @@
[Settings] [Settings]
gtk-theme-name=adw-gtk3-dark gtk-theme-name=adw-gtk3-dark
gtk-icon-theme-name=Papirus-Dark gtk-icon-theme-name=Papirus-Dark
gtk-font-name=Adwaita Sans 11 gtk-font-name=Noto Sans 11
gtk-cursor-theme-name=Bibata-Modern-Classic gtk-cursor-theme-name=Bibata-Modern-Classic
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-application-prefer-dark-theme=1 gtk-application-prefer-dark-theme=1
+7 -3
View File
@@ -4,9 +4,10 @@
-- lua/, and each require() is its own scope: an error in one file does not stop -- lua/, and each require() is its own scope: an error in one file does not stop
-- the others from loading. -- the others from loading.
-- --
-- Order matters only in two places: palette must exist before anything paints -- Order matters only in three places: palette must exist before anything
-- with it, and monitors must exist before the lid handler binds to them. Both -- paints with it, monitors must exist before the lid handler binds to them,
-- are handled by the requires below, top to bottom. -- and game mode reads the look and the animations as configured. All three are
-- handled by the requires below, top to bottom.
-- what the desktop looks like -- what the desktop looks like
require("lua/palette") require("lua/palette")
@@ -27,6 +28,9 @@ require("lua/autostart")
require("lua/rules/windows") require("lua/rules/windows")
require("lua/rules/layers") require("lua/rules/layers")
-- after look and animations: it reads their values to put back
require("lua/gamemode")
-- how it is driven -- how it is driven
require("lua/binds/apps") require("lua/binds/apps")
require("lua/binds/windows") require("lua/binds/windows")
+7 -4
View File
@@ -5,10 +5,13 @@ local scripts = os.getenv("HOME") .. "/.config/hypr/scripts/"
-- ------------------------------------------------------------- screenshots -- ------------------------------------------------------------- screenshots
-- PRINT selects a region, CTRL grabs the focused window, SHIFT saves to disk -- PRINT selects a region, CTRL grabs the focused window, SHIFT saves to disk
-- instead of the clipboard. -- instead of the clipboard.
hl.bind("PRINT", hl.dsp.exec_cmd('grim -g "$(slurp)" - | wl-copy')) -- All four go through scripts/shot.sh, which also posts a notification with
hl.bind("SHIFT + PRINT", hl.dsp.exec_cmd('grim -g "$(slurp)" ~/Pictures/Screenshots/$(date +\'%Y-%m-%d_%H-%M-%S\').png')) -- the capture as its thumbnail (Open, Edit with satty, Save).
hl.bind("CTRL + PRINT", hl.dsp.exec_cmd(scripts .. "screenshot_window.sh copy")) local shot = scripts .. "shot.sh "
hl.bind("CTRL + SHIFT + PRINT", hl.dsp.exec_cmd(scripts .. "screenshot_window.sh save")) hl.bind("PRINT", hl.dsp.exec_cmd(shot .. "region copy"), { description = "screenshot an area to the clipboard" })
hl.bind("SHIFT + PRINT", hl.dsp.exec_cmd(shot .. "region save"), { description = "screenshot an area to a file" })
hl.bind("CTRL + PRINT", hl.dsp.exec_cmd(shot .. "window copy"), { description = "screenshot the window to the clipboard" })
hl.bind("CTRL + SHIFT + PRINT", hl.dsp.exec_cmd(shot .. "window save"), { description = "screenshot the window to a file" })
-- ------------------------------------------------------- replay and camera -- ------------------------------------------------------- replay and camera
-- On release, so the shortcut is not caught mid-chord. -- On release, so the shortcut is not caught mid-chord.
+3 -2
View File
@@ -4,8 +4,9 @@
-- Volume and brightness repeat when held. -- Volume and brightness repeat when held.
local held = { locked = true, repeating = true } local held = { locked = true, repeating = true }
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("fluxo vol up 5"), held) -- wpctl rather than `fluxo vol`, which goes up to 150%; `-l 1` stops at 100%.
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("fluxo vol down 5"), held) hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), held)
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), held)
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("fluxo vol mute"), held) hl.bind("XF86AudioMute", hl.dsp.exec_cmd("fluxo vol mute"), held)
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("fluxo mic mute"), held) hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("fluxo mic mute"), held)
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), held) hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), held)
+8
View File
@@ -10,6 +10,14 @@ shell("lock", { key = mod .. " + CTRL + L", description = "lock screen" })
shell("power", { key = mod .. " + P", description = "power menu" }) shell("power", { key = mod .. " + P", description = "power menu" })
shell("ask", { key = mod .. " + A", description = "ask claude" }) shell("ask", { key = mod .. " + A", description = "ask claude" })
shell("dashboard", { key = mod .. " + D", description = "dashboard" }) shell("dashboard", { key = mod .. " + D", description = "dashboard" })
shell("overview", { key = mod .. " + W", description = "overview of every workspace" })
-- Alt+Tab: hold Alt, tap Tab through focus history, let go to switch. The
-- overview sees the release itself; the release bind is a backstop for the
-- case where Alt comes up before the overview has the keyboard.
shell("overviewNext", { key = "ALT + Tab", description = "switch windows" })
shell("overviewPrev", { key = "ALT + SHIFT + Tab", description = "switch windows, backwards" })
hl.bind("ALT + ALT_L", hl.dsp.exec_cmd("qs ipc call shell overviewCommit"), { release = true })
shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" }) shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" })
shell("toggleStayAwake", { key = mod .. " + SHIFT + I", description = "keep the screen awake" }) shell("toggleStayAwake", { key = mod .. " + SHIFT + I", description = "keep the screen awake" })
shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" }) shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" })
+4 -2
View File
@@ -18,8 +18,10 @@ end
-- ------------------------------------------------------------------- groups -- ------------------------------------------------------------------- groups
hl.bind(mod .. " + n", hl.dsp.group.toggle()) hl.bind(mod .. " + n", hl.dsp.group.toggle())
hl.bind("ALT + Tab", hl.dsp.group.next()) -- Alt+Tab belongs to the overview (lua/binds/shell.lua), so tabs in a group
-- hl.bind("ALT + Tab", hl.dsp.group.prev()) -- cycle on Super+Tab.
hl.bind(mod .. " + Tab", hl.dsp.group.next(), { description = "next window in group" })
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.group.prev(), { description = "previous window in group" })
-- -------------------------------------------------------------------- mouse -- -------------------------------------------------------------------- mouse
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
+124
View File
@@ -0,0 +1,124 @@
-- Game mode: while a fullscreen game is on screen, neither the compositor nor
-- the shell spends anything on the desktop it covers.
--
-- on <=> some monitor shows a game, fullscreen, on its active workspace
--
-- Like lua/lid.lua, the answer is re-derived from live state on every trigger
-- and nothing is remembered, so a reload mid-game simply finds the game again.
--
-- A game is a window that says so through the content-type protocol
-- (gamescope does), or one whose class gives it away: Proton names every Steam
-- game steam_app_<id>. Add to GAME_CLASSES for anything else.
--
-- The shell is told through its IPC and pauses its own polling and animation
-- (quickshell/services/GameMode.qml).
-- Lua patterns, matched against the window class.
local GAME_CLASSES = {
"^steam_app_%d+$",
"^gamescope$",
}
-- What game mode sets. What it puts back is read from the config as loaded, so
-- lua/look.lua and lua/animations.lua stay the only place those are decided. A
-- reload resets every option before the config runs, so this never reads game
-- mode's own values back.
local GAME = {
-- Nothing under a fullscreen window is worth animating, and leaving the
-- game should be instant.
["animations.enabled"] = false,
["decoration.shadow.enabled"] = false,
-- Hand the game's frames straight to the display instead of compositing
-- them every refresh. Only while playing: with it on all the time, some
-- ordinary fullscreen apps flicker or show the wrong colours.
["render.direct_scanout"] = 1,
}
local NORMAL = {}
for option in pairs(GAME) do
NORMAL[option] = hl.get_config(option)
end
-- ------------------------------------------------------------- reading state
local function is_game(win)
if win.content_type == "game" then
return true
end
local class = win.class or ""
for _, pattern in ipairs(GAME_CLASSES) do
if class:match(pattern) then
return true
end
end
return false
end
-- `ignore` is a window being closed: during window.close it can still be
-- listed, and counting it would keep game mode on after the game has gone.
-- Fullscreen 2 is real fullscreen; a maximised game (1) still shows the hull.
local function playing(ignore)
for _, win in ipairs(hl.get_windows()) do
if win ~= ignore
and win.mapped
and win.fullscreen >= 2
and win.monitor
and win.monitor.active_workspace == win.workspace
and is_game(win)
then
return true
end
end
return false
end
-- ---------------------------------------------------------------- reconciling
-- A fresh config is the normal one, so only a game found at load needs
-- applying. The shell heard the last "false" before the reload.
local active = false
local function tell_shell()
hl.exec_cmd("qs ipc call shell gameMode " .. tostring(active))
end
local function reconcile(ignore)
local want = playing(ignore)
if want == active then
return
end
active = want
hl.config(active and GAME or NORMAL)
tell_shell()
end
-- ------------------------------------------------------------------ triggers
for _, event in ipairs({
"window.open",
"window.fullscreen",
"window.move_to_workspace",
-- Proton sets the class after the window opens.
"window.class",
"workspace.active",
"monitor.removed",
}) do
hl.on(event, function()
reconcile()
end)
end
hl.on("window.close", function(win)
reconcile(win)
end)
-- A shell started mid-game (Super+Shift+R, or a crash) begins with game mode
-- off. Its bar mapping is the sign it is up and listening.
hl.on("layer.opened", function(layer)
if active and layer.namespace == "takemi-bar" then
tell_shell()
end
end)
reconcile()
+1 -1
View File
@@ -71,7 +71,7 @@ hl.config({
disable_hyprland_logo = true, disable_hyprland_logo = true,
-- 0 = off, 1 = always, 2 = fullscreen only (G7 is a 240Hz VRR panel) -- 0 = off, 1 = always, 2 = fullscreen only (G7 is a 240Hz VRR panel)
vrr = 0, vrr = 0,
font_family = "FiraCode Nerd Font", font_family = "JetBrainsMono Nerd Font",
background_color = c.base, background_color = c.base,
focus_on_activate = true, focus_on_activate = true,
-- The shell powers displays down after idle (quickshell/services/Idle.qml) -- The shell powers displays down after idle (quickshell/services/Idle.qml)
+5
View File
@@ -30,6 +30,11 @@ local GAMESCOPE = "^(gamescope)$"
-- }) -- })
hl.window_rule({ match = { class = "^$", title = "^$" }, no_blur = true }) hl.window_rule({ match = { class = "^$", title = "^$" }, no_blur = true })
-- Hold idle off while any window is fullscreen. Players do not all ask for
-- that themselves (Brave goes through D-Bus, which nothing here listens to),
-- and the shell's dim and lock (quickshell/services/Idle.qml) respect inhibitors.
hl.window_rule({ match = { class = ".*" }, idle_inhibit = "fullscreen" })
-- ========================================== -- ==========================================
-- FLOATING & CENTERED DIALOGS -- FLOATING & CENTERED DIALOGS
-- ========================================== -- ==========================================
-49
View File
@@ -1,49 +0,0 @@
#!/bin/sh
# Get active window info in JSON format
WINDOW_INFO=$(hyprctl activewindow -j)
# Extract window class name (app name)
APP_NAME=$(echo "$WINDOW_INFO" | jq -r '.class')
# If app name is empty, null, or invalid, default to "unknown"
if [ -z "$APP_NAME" ] || [ "$APP_NAME" = "null" ]; then
APP_NAME="unknown"
fi
# Sanitize app name: convert to lowercase and replace characters that aren't letters, numbers, hyphens, or underscores with underscores
APP_NAME_SAFE=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/_/g')
# Extract geometry: "x,y wxh"
GEOMETRY=$(echo "$WINDOW_INFO" | jq -r '"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')
# Check if geometry is valid
if [ -z "$GEOMETRY" ] || [ "$GEOMETRY" = "null,null nullxnull" ]; then
notify-send -u normal "Screenshot Error" "No active window found to screenshot."
exit 1
fi
ACTION=$1
case "$ACTION" in
copy)
grim -g "$GEOMETRY" - | wl-copy
notify-send -u low "Screenshot" "Active window ($APP_NAME) copied to clipboard"
;;
save)
SCREENSHOTS_DIR="$HOME/Pictures/Screenshots"
APP_DIR="$SCREENSHOTS_DIR/$APP_NAME_SAFE"
mkdir -p "$APP_DIR"
FILE_NAME="$(date +'%Y-%m-%d_%H-%M-%S').png"
FILE_PATH="$APP_DIR/$FILE_NAME"
grim -g "$GEOMETRY" "$FILE_PATH"
notify-send -u low "Screenshot" "Saved active window to $APP_NAME_SAFE/$FILE_NAME"
;;
*)
echo "Usage: $0 {copy|save}"
exit 1
;;
esac
exit 0
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# Screenshots with a receipt: every capture lands on the clipboard and comes
# back as a notification with its own thumbnail, so a keypress that used to
# vanish silently now shows what it took.
#
# shot.sh region copy pick an area, copy it
# shot.sh region save pick an area, save it to ~/Pictures/Screenshots
# shot.sh window copy the focused window, copy it
# shot.sh window save the focused window, save it
#
# A copy is kept in $XDG_RUNTIME_DIR only so the notification has a picture
# to show; its Save action moves it into ~/Pictures/Screenshots. Edit opens
# satty when it is installed. Cancelling the region picker does nothing.
set -eu
target=${1:-region}
mode=${2:-copy}
dir="$HOME/Pictures/Screenshots"
stamp=$(date +'%Y-%m-%d_%H-%M-%S')
case "$target" in
region)
geometry=$(slurp) || exit 0
name="$stamp.png"
;;
window)
info=$(hyprctl activewindow -j)
geometry=$(printf '%s' "$info" | jq -r 'if .at then "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])" else empty end')
if [ -z "$geometry" ]; then
notify-send -a Screenshot "Screenshot" "No window is focused."
exit 1
fi
app=$(printf '%s' "$info" | jq -r '.class // "unknown"' | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/_/g')
name="${app}_$stamp.png"
;;
*)
echo "usage: shot.sh region|window copy|save" >&2
exit 2
;;
esac
if [ "$mode" = save ]; then
mkdir -p "$dir"
file="$dir/$name"
else
file="${XDG_RUNTIME_DIR:-/tmp}/shot-$name"
fi
grim -g "$geometry" "$file"
wl-copy --type image/png < "$file"
title=$([ "$mode" = save ] && echo "Screenshot saved" || echo "Screenshot copied")
body=$([ "$mode" = save ] && echo "${file#"$HOME"/}" || echo "On the clipboard")
set -- --action=open=Open
command -v satty >/dev/null 2>&1 && set -- "$@" --action=edit=Edit
[ "$mode" = copy ] && set -- "$@" --action=save=Save
# notify-send waits for the notification to close and prints the action
# that was chosen, so run it in the background and act on the answer.
(
choice=$(notify-send -a Screenshot -i "$file" -h "string:image-path:$file" \
"$@" "$title" "$body") || exit 0
case "$choice" in
open) xdg-open "$file" ;;
edit) satty --filename "$file" --output-filename "$dir/edited_$name" --copy-command wl-copy ;;
save) mkdir -p "$dir" && mv "$file" "$dir/$name" \
&& notify-send -a Screenshot -i "$dir/$name" -h "string:image-path:$dir/$name" \
"Screenshot saved" "${dir#"$HOME"/}/$name" ;;
esac
) >/dev/null 2>&1 &
+2 -2
View File
@@ -6,8 +6,8 @@ standard_dialogs=default
style=Darkly style=Darkly
[Fonts] [Fonts]
fixed="Sans Serif,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1" fixed="JetBrainsMono Nerd Font,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
general="Adwaita Sans,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1" general="Noto Sans,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
[Interface] [Interface]
activate_item_on_single_click=1 activate_item_on_single_click=1
+66 -6
View File
@@ -39,10 +39,13 @@ cost 4.4% of the height on top of what the bar already takes.
- **hull** — `bar/Frame.qml`: one surface round the whole screen, with the - **hull** — `bar/Frame.qml`: one surface round the whole screen, with the
desktop as a rounded opening. The bar and the rail sit on it. desktop as a rounded opening. The bar and the rail sit on it.
- **bar** — identity and workspaces on the left; the focused window, the - **bar** — identity and workspaces on the left (a ring after them while the
`Super+S` scratchpad holds windows, filled while it is open); the focused window, the
player, the CPU/MEM/GPU readout and the clock on the right. player, the CPU/MEM/GPU readout and the clock on the right.
- **rail** — network, bluetooth, audio, mic, backlight, battery, tray, - **rail** — network, bluetooth, audio, mic, backlight, battery, tray,
notifications, power. Popouts grow leftward out of it, into the screen. notifications, power. While something records the mic, uses a camera or
shares the screen, a lit icon heads the rail (`services/Privacy.qml`, read
from PipeWire). Popouts grow leftward out of it, into the screen.
Modules are divided by hairlines. `BarPill` is chromeless by default — a module Modules are divided by hairlines. `BarPill` is chromeless by default — a module
is *content inside* the hull, not a surface of its own — and marks hover with is *content inside* the hull, not a surface of its own — and marks hover with
@@ -64,13 +67,14 @@ components/ the primitives: panels, sliders, list rows, display text
services/ data sources, all singletons services/ data sources, all singletons
bar/ the two surfaces and their modules bar/ the two surfaces and their modules
popouts/ hover panels hanging off rail and bar modules popouts/ hover panels hanging off rail and bar modules
overlays/ power menu, notification toasts, volume/brightness OSD overlays/ power menu, notification toasts, the volume/brightness/keyboard OSD, idle dim
lock/ session lock lock/ session lock
overview/ every monitor, workspace and window at once
backdrop/ the wallpaper, drawn by the shell backdrop/ the wallpaper, drawn by the shell
assets/ halftone and stripe tiles, generated with ImageMagick assets/ the dot and scanline tiles, generated with ImageMagick
``` ```
`P5Panel` is the base surface everything sits on. It does not auto-size — `Panel` is the base surface everything sits on. It does not auto-size —
callers set implicit sizes from their own layout (`row.implicitWidth + callers set implicit sizes from their own layout (`row.implicitWidth +
contentPad * 2`), which keeps the shape maths out of binding loops. contentPad * 2`), which keeps the shape maths out of binding loops.
@@ -90,6 +94,15 @@ corner instead, attached to the bar and the rail at once (`dock` is `top`,
`right`, `topRight` or `bottomRight`). Without docking, a panel near the rail `right`, `topRight` or `bottomRight`). Without docking, a panel near the rail
left a thin strip of opening between itself and the rail. left a thin strip of opening between itself and the rail.
## Notifications
The shell is the notification server (`services/Notifs.qml`). New ones arrive
as toasts in a corner drawer (`overlays/Toasts.qml`); the bell on the rail
opens the log (`popouts/NotifPopout.qml`), which is grouped by sender. A
sender with one notification is just that entry. A sender with several gets a
header — its name, a count, a mark to clear only that sender — over its
newest entry and a `+ 2 more` line; a click on either opens the rest.
## Dashboard ## Dashboard
`dashboard/Dashboard.qml` is a drawer that slides down out of the underside of `dashboard/Dashboard.qml` is a drawer that slides down out of the underside of
@@ -101,6 +114,25 @@ meters, and what is playing. Escape or focusing anything else puts it away; a
`HyprlandFocusGrab` handles the latter, and `Actions.dashboardClosedAt` stops `HyprlandFocusGrab` handles the latter, and `Actions.dashboardClosedAt` stops
the clock click that cleared the grab from opening it straight back up. the clock click that cleared the grab from opening it straight back up.
Under the columns, past a hairline, a row of switches: Wi-Fi, Bluetooth, do
not disturb and stay awake, plus night light when `hyprsunset` is installed
(it runs at 4200 K for as long as the switch is on). On is the light slab
with ink type, the same as a selection everywhere else; off is a hairline.
## Game mode
While a fullscreen game is on screen, Hyprland switches off its animations and
shadows and turns on direct scanout (`~/.config/hypr/lua/gamemode.lua`). It
also tells the shell through `qs ipc call shell gameMode true`. The game covers
the hull, but covered surfaces still render, so the shell stops what it does by
itself: the fluxo stream, the backlight poll, the media tick and equaliser, and
the rail and workspace pulses. Toasts wait in the log, except critical ones.
Both sides come back when the game leaves fullscreen, closes or is switched
away from.
A game is a window with the `game` content type, or one whose class matches
`GAME_CLASSES` in `gamemode.lua` (`steam_app_<id>` and `gamescope` so far).
## Modules ## Modules
| Module | Where | Left click | Right click | Middle | Scroll | Hover | | Module | Where | Left click | Right click | Middle | Scroll | Hover |
@@ -144,6 +176,9 @@ Set in `~/.config/hypr/lua/binds/shell.lua`:
- `Super+Ctrl+L` — lock - `Super+Ctrl+L` — lock
- `Super+P` — power menu - `Super+P` — power menu
- `Super+Shift+N` — toggle do not disturb - `Super+Shift+N` — toggle do not disturb
- `Alt+Tab` / `Alt+Shift+Tab` — overview, walking focus history; let go of Alt to switch
- `Super+W` — overview, without the hold
- `Super+Tab` / `Super+Shift+Tab` — next / previous window in a group
- `Super+Shift+I` — stay awake (no dim, lock or screen-off until toggled back) - `Super+Shift+I` — stay awake (no dim, lock or screen-off until toggled back)
- `Super+Ctrl+Shift+N` — clear notifications - `Super+Ctrl+Shift+N` — clear notifications
- `Super+A` — ask panel - `Super+A` — ask panel
@@ -215,6 +250,27 @@ NetworkManager for Wi-Fi, UPower for battery, MPRIS for media, and Quickshell's
own notification server. These are event-driven, so the bar and OSD react the own notification server. These are event-driven, so the bar and OSD react the
instant a volume key is pressed instead of on the next poll. instant a volume key is pressed instead of on the next poll.
## Overview
`overview/Overview.qml`. Each monitor is a row of small portholes, one per
workspace at that monitor's aspect ratio, with live captures of the windows
where they sit (`ScreencopyView`, so windows on hidden workspaces show too).
The monitor's name heads its row once there is more than one. A group shows
the member in front with a `1/3` tag; picking a hidden member puts it in
front.
`Alt+Tab` opens it with the previous window picked, more Tabs walk back
through focus history, and letting go of Alt switches. Hyprland keeps the
Alt+Tab bind while the overview has the keyboard, so those presses arrive
over IPC (`overviewNext` / `overviewPrev`); the Alt release comes to the
overview itself, with a release bind on `ALT_L` as a backstop. Hover picks
only once the pointer moves, so a resting pointer cannot override the
keyboard. Enter, a click, or `1`–`9` for a workspace also go; Escape or a
click on the dark cancels.
Focus is dispatched a moment after the overview lets go of the keyboard:
Hyprland ignores focus changes while a layer holds it exclusively.
## Wallpaper ## Wallpaper
`backdrop/Backdrop.qml` draws it on the background layer; there is no `backdrop/Backdrop.qml` draws it on the background layer; there is no
@@ -372,6 +428,10 @@ network and bluetooth, but keep running as the secret and pairing agents.
Selection is the light slab with ink text; only destructive actions are red. Selection is the light slab with ink text; only destructive actions are red.
The theme name is also in `xsettingsd/xsettingsd.conf` and has to be set with The theme name is also in `xsettingsd/xsettingsd.conf` and has to be set with
`gsettings` for portal-aware apps. `gsettings` for portal-aware apps.
- **Type** — one sans and one mono everywhere: Noto Sans for interface text
and JetBrainsMono Nerd Font for figures and code, in the shell
(`Theme.fontDisplay` / `fontMono`), GTK (`settings.ini` and the gsettings
`font-name` / `monospace-font-name`), qt6ct and Hyprland's own text.
- **Qt** — qt6ct with `qt6ct/colors/yorha.conf`, the same palette in - **Qt** — qt6ct with `qt6ct/colors/yorha.conf`, the same palette in
`QPalette` roles, drawn with the Darkly style (package `darkly-bin`) for `QPalette` roles, drawn with the Darkly style (package `darkly-bin`) for
rounded controls. Fusion is the fallback if Darkly is missing. rounded controls. Fusion is the fallback if Darkly is missing.
@@ -404,7 +464,7 @@ zones itself, so every piece is placed from the screen edge.
**Soft, everywhere else.** The porthole is the shape the desktop is built **Soft, everywhere else.** The porthole is the shape the desktop is built
around, and every smaller surface is a gentler echo of it. `Theme.skew` and around, and every smaller surface is a gentler echo of it. `Theme.skew` and
`Theme.cut` are `0`; `P5Panel` and `Skew` draw `Geom.roundedRect` with `Theme.cut` are `0`; `Panel` and `Skew` draw `Geom.roundedRect` with
`Theme.radiusS` (pills, chips, meter tracks), `radiusM` (rows, buttons) or `Theme.radiusS` (pills, chips, meter tracks), `radiusM` (rows, buttons) or
`radiusL`. `radiusL` is `frameRadius - padS`: anything inset by `padS` inside `radiusL`. `radiusL` is `frameRadius - padS`: anything inset by `padS` inside
one of the frame's curves — a hovered notification in the drawer's corner — one of the frame's curves — a hovered notification in the drawer's corner —
Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 B

+1 -1
View File
@@ -73,7 +73,7 @@ Item {
anchors.fill: parent anchors.fill: parent
active: root.standalone active: root.standalone
sourceComponent: P5Panel { sourceComponent: Panel {
lean: root.lean lean: root.lean
leanLeft: root.leanLeft leanLeft: root.leanLeft
chop: root.chop chop: root.chop
+1 -1
View File
@@ -170,7 +170,7 @@ BarPill {
Timer { Timer {
interval: eq.tickMs interval: eq.tickMs
running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) && !GameMode.active
repeat: true repeat: true
onTriggered: eq.phase++ onTriggered: eq.phase++
// Restart the cycle from its trough, so playback always begins // Restart the cycle from its trough, so playback always begins
+14 -1
View File
@@ -47,6 +47,19 @@ PanelWindow {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
spacing: 0 spacing: 0
// Something is recording or watching. First on the rail and in the
// light, because it is the one thing here you did not ask to see.
RailIcon {
id: privacyIcon
visible: Privacy.active
icon: Privacy.screen ? "󰹑" : (Privacy.camera ? "󰄀" : "󰍬")
label: "In use"
statusText: Privacy.detail
live: true
}
Divider { visible: Privacy.active }
RailIcon { RailIcon {
id: netIcon id: netIcon
icon: Net.icon icon: Net.icon
@@ -117,7 +130,7 @@ PanelWindow {
statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel
suppressTooltip: battPopout.detailActive suppressTooltip: battPopout.detailActive
alarm: Battery.critical alarm: Battery.critical
pulsing: Battery.critical pulsing: Battery.critical && !GameMode.active
onClicked: battPopout.togglePinned() onClicked: battPopout.togglePinned()
} }
} }
+2 -2
View File
@@ -122,7 +122,7 @@ Item {
} }
// Leading-edge wipe. On the rail this runs vertically down the left side, // Leading-edge wipe. On the rail this runs vertically down the left side,
// mirroring the accent bar P5Panel puts on horizontal modules. // mirroring the accent bar Panel puts on horizontal modules.
// //
// Held off the edge by `wipeInset`: the icon spans the rail's full width, so // Held off the edge by `wipeInset`: the icon spans the rail's full width, so
// at x = 0 this landed exactly on the slab's own border. // at x = 0 this landed exactly on the slab's own border.
@@ -162,7 +162,7 @@ Item {
onWheel: event => root.scrolled(event.angleDelta.y) onWheel: event => root.scrolled(event.angleDelta.y)
} }
P5Tooltip { Tooltip {
anchorItem: root anchorItem: root
triggerHovered: root.hovered triggerHovered: root.hovered
title: root.label title: root.label
+55 -1
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland import Quickshell.Hyprland
import "root:/config" import "root:/config"
import "root:/components" import "root:/components"
import "root:/services"
// Workspace indicator: soft pills that stretch open when focused. Occupied // Workspace indicator: soft pills that stretch open when focused. Occupied
// workspaces fill in, urgent ones snap to the alarm colour. // workspaces fill in, urgent ones snap to the alarm colour.
@@ -104,7 +105,7 @@ Item {
// Urgent is the one pip that has to be seen peripherally. // Urgent is the one pip that has to be seen peripherally.
SequentialAnimation on opacity { SequentialAnimation on opacity {
running: pip.urgent && !pip.focused running: pip.urgent && !pip.focused && !GameMode.active
loops: Animation.Infinite loops: Animation.Infinite
alwaysRunToEnd: true alwaysRunToEnd: true
NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad } NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad }
@@ -155,5 +156,58 @@ Item {
} }
} }
} }
// The Super+S scratchpad, after a gap: a ring while it holds windows,
// filled with the light while it is open on this monitor. Absent when
// it is empty, which is most of the time.
Item {
id: scratch
readonly property var ws: {
for (const w of (Hyprland.workspaces?.values ?? []))
if (w.name === "special:virtual") return w;
return null;
}
readonly property int count: scratch.ws?.toplevels?.values?.length ?? 0
readonly property bool shown: (root.monitor?.lastIpcObject?.specialWorkspace?.name ?? "")
=== "special:virtual"
visible: scratch.count > 0
width: 8 + 10
height: 14
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 10
height: 10
radius: 5
color: scratch.shown ? Theme.accent : "transparent"
border.width: scratch.shown ? 0 : 1
border.color: scratchHover.hovered ? Theme.text : Theme.subtext
Behavior on color {
ColorAnimation { duration: Theme.durBase }
}
}
HoverHandler {
id: scratchHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: Hyprland.dispatch("hl.dsp.workspace.toggle_special(\"virtual\")")
}
}
}
// The monitor snapshot carries which special workspace is open; it is only
// refreshed on request.
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name === "activespecial" || event.name === "activespecialv2")
Hyprland.refreshMonitors();
}
} }
} }
@@ -48,7 +48,7 @@ FocusScope {
implicitWidth: wide ? 260 : (row.implicitWidth + panel.contentPad * 2) implicitWidth: wide ? 260 : (row.implicitWidth + panel.contentPad * 2)
implicitHeight: 44 implicitHeight: 44
P5Panel { Panel {
id: panel id: panel
anchors.fill: parent anchors.fill: parent
lean: root.lean lean: root.lean
@@ -142,8 +142,11 @@ FocusScope {
} }
} }
// A press sinks the button one whole pixel instead of shrinking it, so
// its label stays on the pixel grid.
transform: Translate { transform: Translate {
x: root.pressed ? 0 : (root.highlighted ? root.lunge : 0) x: root.pressed ? 0 : (root.highlighted ? root.lunge : 0)
y: root.pressed ? 1 : 0
Behavior on x { Behavior on x {
NumberAnimation { NumberAnimation {
@@ -153,10 +156,6 @@ FocusScope {
} }
} }
scale: root.pressed ? 0.97 : 1.0
Behavior on scale {
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutExpo }
}
HoverHandler { HoverHandler {
id: hover id: hover
+1 -1
View File
@@ -4,7 +4,7 @@ import "root:/components"
// Calm, permanent-chrome preset. It is deliberately flat and opaque: one // Calm, permanent-chrome preset. It is deliberately flat and opaque: one
// neutral hairline, no texture, no ticks, and no accent unless active. // neutral hairline, no texture, no ticks, and no accent unless active.
P5Panel { Panel {
property bool active: false property bool active: false
property color tone: Theme.treatmentActiveMark property color tone: Theme.treatmentActiveMark
File renamed without changes.
+20 -8
View File
@@ -22,6 +22,9 @@ Item {
required property var notif required property var notif
property bool showActions: true property bool showActions: true
// Off inside a group in the log, whose header already names the sender:
// the entry keeps only its urgency dot, the hairline and the close mark.
property bool showSender: true
readonly property bool critical: root.notif?.urgency === NotificationUrgency.Critical readonly property bool critical: root.notif?.urgency === NotificationUrgency.Critical
readonly property color tone: root.notif readonly property color tone: root.notif
@@ -79,7 +82,7 @@ Item {
Icon { Icon {
id: glyph id: glyph
visible: text !== "" visible: root.showSender && text !== ""
anchors.left: marker.right anchors.left: marker.right
anchors.leftMargin: Theme.padS anchors.leftMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -91,8 +94,10 @@ Item {
Text { Text {
id: sender id: sender
anchors.left: glyph.visible ? glyph.right : marker.right anchors.left: glyph.visible ? glyph.right : marker.right
anchors.leftMargin: Theme.padS anchors.leftMargin: root.showSender ? Theme.padS : 0
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: root.showSender ? implicitWidth : 0
visible: root.showSender
text: (root.notif?.appName || "System").toUpperCase() text: (root.notif?.appName || "System").toUpperCase()
color: root.critical ? Theme.alarm : Theme.muted color: root.critical ? Theme.alarm : Theme.muted
font.family: Theme.fontMono font.family: Theme.fontMono
@@ -185,7 +190,14 @@ Item {
id: inline id: inline
x: 1 x: 1
y: 1 y: 1
source: root.notif?.image || "" // A path given as an image hint (notify-send -h image-path:…)
// comes back routed through the icon provider, which treats
// it as an icon name and returns a placeholder. Load the file.
source: {
const img = root.notif?.image || "";
if (img.startsWith("image://icon//")) return "file://" + img.slice("image://icon/".length);
return img.startsWith("/") ? "file://" + img : img;
}
sourceSize.height: 90 sourceSize.height: 90
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
asynchronous: true asynchronous: true
@@ -194,7 +206,10 @@ Item {
Row { Row {
x: Theme.marker + Theme.padS x: Theme.marker + Theme.padS
visible: root.showActions && (root.notif?.actions?.length ?? 0) > 0 // Gone once the app has closed the notification: nothing is
// listening for the action any more.
visible: root.showActions && !Notifs.isRetracted(root.notif)
&& (root.notif?.actions?.length ?? 0) > 0
spacing: Theme.padS spacing: Theme.padS
topPadding: 4 topPadding: 4
@@ -253,10 +268,7 @@ Item {
} }
TapHandler { TapHandler {
onTapped: { onTapped: Notifs.invoke(root.notif, actionChip.modelData)
actionChip.modelData.invoke();
root.dismissed();
}
} }
} }
} }
File renamed without changes.
+3 -3
View File
@@ -3,7 +3,7 @@ import QtQuick.Shapes
import "root:/config" import "root:/config"
import "root:/components" import "root:/components"
// A small block — the small-scale counterpart to P5Panel. Upright and rounded // A small block — the small-scale counterpart to Panel. Upright and rounded
// under the YoRHa theme; it still leans if given a lean. // under the YoRHa theme; it still leans if given a lean.
// //
// Every tick, rule, meter segment and slider rail in this shell leans at // Every tick, rule, meter segment and slider rail in this shell leans at
@@ -23,12 +23,12 @@ Shape {
property color borderColor: "transparent" property color borderColor: "transparent"
property real borderWidth: 0 property real borderWidth: 0
// Lean of the vertical edges as a fraction of height, matching P5Panel. // Lean of the vertical edges as a fraction of height, matching Panel.
property real lean: Theme.skew property real lean: Theme.skew
property bool leanLeft: false property bool leanLeft: false
// Wide, short pieces — the rail's hairline rules — slant their horizontal // Wide, short pieces — the rail's hairline rules — slant their horizontal
// edges by a fraction of width instead, exactly as P5Panel does for the // edges by a fraction of width instead, exactly as Panel does for the
// rail slab. // rail slab.
property bool vertical: false property bool vertical: false
File renamed without changes.
+145 -2
View File
@@ -58,13 +58,25 @@ PanelWindow {
readonly property real pad: Theme.padL readonly property real pad: Theme.padL
readonly property real bodyWidth: columns.implicitWidth + root.pad * 2 readonly property real bodyWidth: columns.implicitWidth + root.pad * 2
readonly property real bodyHeight: columns.implicitHeight + root.pad * 2 readonly property real bodyHeight: columns.implicitHeight + toggles.implicitHeight
+ Theme.padL + 1 + root.pad * 3
implicitWidth: root.bodyWidth + root.fillet * 2 implicitWidth: root.bodyWidth + root.fillet * 2
implicitHeight: root.bodyHeight + 1 implicitHeight: root.bodyHeight + 1
color: "transparent" color: "transparent"
mask: Region { item: body } // A Region follows its item's own geometry, not its parents', so masking
// to the body, which rides on the sliding sheet, would freeze the mask
// wherever the sheet was when it was built — above the window, letting
// every click through to whatever is below and dropping the focus grab.
mask: Region { item: hitbox }
Item {
id: hitbox
x: root.fillet
width: root.bodyWidth
height: parent.height
}
HyprlandFocusGrab { HyprlandFocusGrab {
active: root.open active: root.open
@@ -549,6 +561,65 @@ PanelWindow {
} }
} }
} }
// A hairline, then the switches you reach for most.
Rectangle {
id: togglesRule
x: root.pad
y: columns.y + columns.implicitHeight + Theme.padL
width: root.bodyWidth - root.pad * 2
height: 1
color: Theme.alpha(Theme.outline, 0.5)
}
Row {
id: toggles
x: root.pad
y: togglesRule.y + 1 + root.pad
spacing: Theme.padS
readonly property int count: nightLight.available ? 5 : 4
readonly property real chipWidth:
(root.bodyWidth - root.pad * 2 - toggles.spacing * (toggles.count - 1)) / toggles.count
Toggle {
width: toggles.chipWidth
glyph: Net.wifiEnabled ? "󰖩" : "󰖪"
label: "Wi-Fi"
on: Net.wifiEnabled
onClicked: Net.toggleWifi()
}
Toggle {
width: toggles.chipWidth
enabled: Bt.available
glyph: Bt.enabled ? "󰂯" : "󰂲"
label: "Bluetooth"
on: Bt.enabled
onClicked: Bt.toggle()
}
Toggle {
width: toggles.chipWidth
glyph: Notifs.dnd ? "󰂛" : "󰂚"
label: "Do not disturb"
on: Notifs.dnd
onClicked: Notifs.toggleDnd()
}
Toggle {
width: toggles.chipWidth
glyph: "󰅶"
label: "Stay awake"
on: Idle.stayAwake
onClicked: Idle.toggleStayAwake()
}
Toggle {
width: toggles.chipWidth
visible: nightLight.available
glyph: "󰖔"
label: "Night light"
on: nightLight.running
onClicked: nightLight.running = !nightLight.running
}
}
} }
} }
} }
@@ -572,6 +643,78 @@ PanelWindow {
readonly property real columnHeight: 214 readonly property real columnHeight: 214
// A warm screen for the evening, through hyprsunset if it is installed.
// It runs for as long as night light is on; stopping it restores the
// screen. The chip only appears when the binary exists.
Process {
id: nightLight
property bool available: false
command: ["hyprsunset", "--temperature", "4200"]
}
Process {
running: true
command: ["sh", "-c", "command -v hyprsunset"]
onExited: code => nightLight.available = code === 0
}
// A switch: a pill with a glyph and a name. On is the light slab with ink
// type, the same as a selection everywhere else; off is a hairline.
component Toggle: Item {
id: tg
property string glyph: ""
property string label: ""
property bool on: false
signal clicked()
height: 38
opacity: tg.enabled ? 1 : 0.35
Rectangle {
anchors.fill: parent
radius: height / 2
color: tg.on ? Theme.accentSoft : (tgHover.hovered ? Theme.treatmentHoverFill : "transparent")
border.width: tg.on ? 0 : 1
border.color: tgHover.hovered ? Theme.treatmentHoverMark : Theme.overlay
Behavior on color { ColorAnimation { duration: Theme.durBase } }
}
Row {
anchors.centerIn: parent
spacing: Theme.padS
Icon {
anchors.verticalCenter: parent.verticalCenter
text: tg.glyph
font.pixelSize: Theme.fsLarge
color: tg.on ? Theme.ink : (tgHover.hovered ? Theme.accent : Theme.text)
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: tg.label.toUpperCase()
color: tg.on ? Theme.ink : Theme.subtext
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsMicro
font.weight: Font.DemiBold
font.letterSpacing: Theme.tracking
renderType: Text.NativeRendering
}
}
HoverHandler {
id: tgHover
enabled: tg.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: tg.enabled
onTapped: tg.clicked()
}
}
// A label and its reading on one line, label left, reading right. // A label and its reading on one line, label left, reading right.
component Fact: Item { component Fact: Item {
property string label: "" property string label: ""
+1
View File
@@ -13,6 +13,7 @@ WlSessionLock {
id: lock id: lock
locked: Actions.lockRequested locked: Actions.lockRequested
onSecureChanged: Actions.lockSecure = lock.secure
WlSessionLockSurface { WlSessionLockSurface {
id: lockSurface id: lockSurface
+18 -5
View File
@@ -27,13 +27,26 @@ PanelWindow {
id: shade id: shade
anchors.fill: parent anchors.fill: parent
color: Theme.ink color: Theme.ink
opacity: Idle.dimmed ? 0.6 : 0 opacity: 0
Behavior on opacity { // States, not a Behavior: a Behavior whose duration is bound to
NumberAnimation { // Idle.dimmed can start before that binding updates, so waking up
duration: Idle.dimmed ? 8000 : Theme.durBase // would replay the slow fade in reverse.
easing.type: Idle.dimmed ? Easing.InOutSine : Easing.OutCubic states: State {
name: "dimmed"
when: Idle.dimmed
PropertyChanges { shade.opacity: 0.6 }
} }
transitions: [
Transition {
to: "dimmed"
NumberAnimation { property: "opacity"; duration: 8000; easing.type: Easing.InOutSine }
},
Transition {
from: "dimmed"
NumberAnimation { property: "opacity"; duration: Theme.durBase; easing.type: Easing.OutCubic }
} }
]
} }
} }
+40 -2
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland import Quickshell.Wayland
import "root:/config" import "root:/config"
import "root:/components" import "root:/components"
@@ -44,6 +45,7 @@ PanelWindow {
switch (root.kind) { switch (root.kind) {
case "mic": return Audio.micIcon; case "mic": return Audio.micIcon;
case "brightness": return Brightness.icon; case "brightness": return Brightness.icon;
case "layout": return "󰌌";
default: return Audio.icon; default: return Audio.icon;
} }
} }
@@ -52,6 +54,7 @@ PanelWindow {
switch (root.kind) { switch (root.kind) {
case "mic": return Audio.micMuted ? "Mic muted" : "Microphone"; case "mic": return Audio.micMuted ? "Mic muted" : "Microphone";
case "brightness": return "Brightness"; case "brightness": return "Brightness";
case "layout": return "Keyboard";
default: return Audio.muted ? "Muted" : "Volume"; default: return Audio.muted ? "Muted" : "Volume";
} }
} }
@@ -71,6 +74,7 @@ PanelWindow {
// silenced speaker flashed the same colour as a live one. // silenced speaker flashed the same colour as a live one.
case "mic": return Audio.micMuted ? Theme.treatmentOffText : Theme.accent; case "mic": return Audio.micMuted ? Theme.treatmentOffText : Theme.accent;
case "brightness": return Theme.accent; case "brightness": return Theme.accent;
case "layout": return Theme.accent;
default: return Audio.muted ? Theme.treatmentOffText : Theme.accent; default: return Audio.muted ? Theme.treatmentOffText : Theme.accent;
} }
} }
@@ -100,7 +104,27 @@ PanelWindow {
function onBumped() { root.flash("brightness"); } function onBumped() { root.flash("brightness"); }
} }
P5Panel { // The keymap Hyprland just switched to, e.g. "English (Dvorak, intl.,
// with dead keys)", and a short name for the readout: the variant in the
// brackets if there is one, otherwise the language.
property string keymap: ""
readonly property string keymapShort: {
const m = /\(([^,)]+)/.exec(root.keymap);
return (m ? m[1] : root.keymap).trim();
}
Connections {
target: Hyprland
function onRawEvent(event) {
if (event.name !== "activelayout") return;
// "keyboard-name,Layout name" — the layout name may hold commas.
const at = event.data.indexOf(",");
root.keymap = at < 0 ? event.data : event.data.slice(at + 1);
root.flash("layout");
}
}
Panel {
id: panel id: panel
anchors.fill: parent anchors.fill: parent
anchors.margins: 4 anchors.margins: 4
@@ -178,12 +202,26 @@ PanelWindow {
// width and right-aligning the text would flatten the // width and right-aligning the text would flatten the
// glitch flicker away. The title's left edge is anchored, so a // glitch flicker away. The title's left edge is anchored, so a
// widening readout only moves its own left edge. // widening readout only moves its own left edge.
text: Math.round(root.level * 100) + "%" text: root.kind === "layout" ? root.keymapShort : Math.round(root.level * 100) + "%"
pixelSize: Theme.fsBody pixelSize: Theme.fsBody
} }
} }
// The layout has no level; its full name stands where the meter
// would be.
Text {
visible: root.kind === "layout"
width: parent.width
text: root.keymap
color: Theme.muted
elide: Text.ElideRight
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
MeterBar { MeterBar {
visible: root.kind !== "layout"
width: parent.width width: parent.width
height: 4 height: 4
segments: 20 segments: 20
+33 -9
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Shapes import QtQuick.Shapes
import Quickshell import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import "root:/config" import "root:/config"
@@ -15,6 +16,10 @@ import "root:/services"
// with each notification an entry in it divided by its own header hairline. // with each notification an entry in it divided by its own header hairline.
// The drawer grows and shrinks with the entries and slides back behind the // The drawer grows and shrinks with the entries and slides back behind the
// frame when the last one goes. // frame when the last one goes.
//
// A fullscreen window covers the bar and the rail, so there is no frame to
// lower the drawer out of. Then it floats free in the corner as a plain
// rounded panel instead.
PanelWindow { PanelWindow {
id: root id: root
@@ -30,17 +35,20 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen)
readonly property bool floating: root.monitor?.activeWorkspace?.hasFullscreen ?? false
// One pixel over the bar and the rail, so the drawer covers the rim the // One pixel over the bar and the rail, so the drawer covers the rim the
// hull draws along both edges instead of sitting inside it. // hull draws along both edges instead of sitting inside it.
anchors { top: true; right: true } anchors { top: true; right: true }
margins { margins {
top: Theme.barHeight - 1 top: root.floating ? Theme.padM : Theme.barHeight - 1
right: Theme.railWidth - 1 right: root.floating ? Theme.padM : Theme.railWidth - 1
} }
readonly property real fillet: Theme.frameRadius readonly property real fillet: root.floating ? 0 : Theme.frameRadius
readonly property real corner: Theme.frameRadius readonly property real corner: Theme.frameRadius
readonly property real seam: 1 readonly property real seam: root.floating ? 0 : 1
readonly property real drawerWidth: 400 readonly property real drawerWidth: 400
// Height of the entries, eased, so the drawer grows rather than jumps. // Height of the entries, eased, so the drawer grows rather than jumps.
@@ -75,6 +83,10 @@ PanelWindow {
if (Actions.focusedScreenName() !== root.modelData.name) return; if (Actions.focusedScreenName() !== root.modelData.name) return;
root.live = [notif, ...root.live].slice(0, root.maxLive); root.live = [notif, ...root.live].slice(0, root.maxLive);
} }
// Closed by its app or from the log: take it off screen too.
function onNotifRetracted(notif) {
root.retire(notif);
}
} }
function retire(notif) { function retire(notif) {
@@ -99,6 +111,7 @@ PanelWindow {
// onto the rail through the bottom fillet — Popout's "topRight". // onto the rail through the bottom fillet — Popout's "topRight".
Shape { Shape {
id: silhouette id: silhouette
visible: !root.floating
anchors.fill: parent anchors.fill: parent
preferredRendererType: Shape.CurveRenderer preferredRendererType: Shape.CurveRenderer
@@ -125,6 +138,15 @@ PanelWindow {
} }
} }
Rectangle {
visible: root.floating
anchors.fill: parent
radius: root.corner
color: Theme.hull
border.width: 1
border.color: Theme.rim
}
Column { Column {
id: stack id: stack
x: root.fillet x: root.fillet
@@ -132,8 +154,11 @@ PanelWindow {
width: root.drawerWidth width: root.drawerWidth
bottomPadding: Theme.padS bottomPadding: Theme.padS
// A ScriptModel, not the bare list: it hands the Repeater only
// what changed, so a new toast does not rebuild the others and
// restart their fuses.
Repeater { Repeater {
model: root.live model: ScriptModel { values: [...root.live] }
Item { Item {
id: toast id: toast
@@ -146,10 +171,9 @@ PanelWindow {
id: card id: card
width: parent.width width: parent.width
notif: toast.modelData notif: toast.modelData
onDismissed: { // Closing it retracts it, which retires the toast;
Notifs.dismiss(toast.modelData); // nothing may run here after that, the entry is gone.
root.retire(toast.modelData); onDismissed: Notifs.dismiss(toast.modelData)
}
} }
// Countdown: a hairline draining along the entry's // Countdown: a hairline draining along the entry's
+366
View File
@@ -0,0 +1,366 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import "root:/config"
import "root:/components"
import "root:/services"
// Every monitor, every workspace, every window, at once. Alt+Tab opens it on
// the focused screen with the previous window picked; more Tabs walk back
// through focus history, and letting go of Alt goes there. Hover and click
// work too, as do Enter, Escape and the workspace digits.
//
// It is a scene, like the power menu: no panel, the desktop dims, and each
// monitor is laid out as a row of small portholes — its workspaces, at its
// own aspect ratio, with live captures of the windows where they sit. The
// picked window is ringed in the light and named underneath.
PanelWindow {
id: root
required property var modelData
screen: modelData
readonly property bool open: Actions.overviewOpen
&& Actions.overviewScreen === modelData.name
visible: root.open || fade.running
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "takemi-overview"
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
// ----------------------------------------------------------------- model
// Rebuilt from Hyprland's IPC snapshots on open and whenever windows move
// while open. Plain arrays rather than live models: everything here is a
// picture of one moment.
property var monitors: [] // [{ mon, ipc, workspaces: [{ ws, windows: [...] }] }]
property var mru: [] // window addresses, most recent first
property string picked: "" // address of the picked window
readonly property var pickedWindow: {
for (const m of root.monitors)
for (const w of m.workspaces)
for (const win of w.windows)
if (win.address === root.picked) return win;
return null;
}
function rebuild() {
const mons = Hyprland.monitors.values.slice()
.filter(m => m.lastIpcObject && m.lastIpcObject.width)
.sort((a, b) => a.lastIpcObject.x - b.lastIpcObject.x);
const all = [];
const out = [];
for (const mon of mons) {
const mi = mon.lastIpcObject;
const wss = Hyprland.workspaces.values
.filter(ws => ws.monitor === mon)
.filter(ws => ws.id > 0 || ws.toplevels.values.length > 0)
.sort((a, b) => (a.id < 0) - (b.id < 0) || a.id - b.id);
const rows = [];
for (const ws of wss) {
const wins = [];
for (const tl of ws.toplevels.values) {
const ipc = tl.lastIpcObject;
if (!ipc || !ipc.at || !ipc.mapped) continue;
const win = {
toplevel: tl,
address: "0x" + String(tl.address).replace(/^0x/, ""),
x: ipc.at[0] - mi.x, y: ipc.at[1] - mi.y,
w: ipc.size[0], h: ipc.size[1],
title: ipc.title || "",
appClass: ipc.class || "",
hidden: ipc.hidden === true,
floating: ipc.floating === true,
group: (ipc.grouped || []).map(a => "0x" + String(a).replace(/^0x/, "")),
history: ipc.focusHistoryID ?? 999,
};
wins.push(win);
all.push(win);
}
// Tiled first, floating on top, as Hyprland draws them.
wins.sort((a, b) => a.floating - b.floating);
rows.push({ ws: ws, windows: wins });
}
out.push({ mon: mon, ipc: mi, workspaces: rows });
}
root.monitors = out;
root.mru = all.sort((a, b) => a.history - b.history).map(w => w.address);
if (!root.touched || root.mru.indexOf(root.picked) < 0)
root.picked = root.mru.length > 1 ? root.mru[1] : (root.mru[0] ?? "");
}
function step(delta: int) {
root.touched = true;
const n = root.mru.length;
if (n === 0) return;
const at = Math.max(0, root.mru.indexOf(root.picked));
root.picked = root.mru[(at + delta + n) % n];
}
// Hyprland will not move focus while a layer holds the keyboard
// exclusively, so let go of it first and dispatch a moment later.
property string pendingDispatch: ""
function commit() {
root.pendingDispatch = root.picked !== ""
? "hl.dsp.focus({ window = \"address:" + root.picked + "\" })"
: "";
Actions.closeOverview();
if (root.pendingDispatch !== "") release.restart();
}
function goToWorkspace(id: int) {
root.pendingDispatch = "hl.dsp.focus({ workspace = " + id + " })";
Actions.closeOverview();
release.restart();
}
Timer {
id: release
interval: 40
onTriggered: Hyprland.dispatch(root.pendingDispatch)
}
// Whether the pointer has moved since the overview opened. A pointer that
// happens to rest over a card would otherwise pick that window the moment
// the cards appear under it, overriding Alt+Tab's choice.
property bool pointerLive: false
property point pointerStart: Qt.point(-1, -1)
// Whether the pick came from a person rather than from the first snapshot.
property bool touched: false
onOpenChanged: {
if (root.open) {
PopupCoordinator.dismissAll();
Hyprland.refreshMonitors();
Hyprland.refreshWorkspaces();
Hyprland.refreshToplevels();
root.picked = "";
root.touched = false;
root.pointerLive = false;
root.pointerStart = Qt.point(-1, -1);
root.rebuild();
// The IPC snapshots land a moment after the refresh request.
settle.restart();
keys.forceActiveFocus();
}
}
Timer {
id: settle
interval: 60
onTriggered: root.rebuild()
}
Connections {
target: Actions
function onOverviewStepped(delta) { if (root.open) root.step(delta); }
function onOverviewCommitted() { if (root.open) root.commit(); }
}
Connections {
target: Hyprland
enabled: root.open
function onRawEvent(event) {
const n = event.name;
if (n === "openwindow" || n === "closewindow" || n === "movewindowv2"
|| n === "changefloatingmode" || n === "fullscreen") {
Hyprland.refreshToplevels();
settle.restart();
}
}
}
// ------------------------------------------------------------- backdrop
Rectangle {
anchors.fill: parent
color: Theme.ink
opacity: root.open ? 0.92 : 0
Behavior on opacity {
NumberAnimation { id: fade; duration: Theme.durBase; easing.type: Easing.OutCubic }
}
TapHandler { onTapped: Actions.closeOverview() }
}
// ---------------------------------------------------------------- scene
Item {
id: keys
anchors.fill: parent
focus: true
HoverHandler {
onPointChanged: {
const p = point.position;
if (root.pointerStart.x < 0) { root.pointerStart = p; return; }
if (Math.abs(p.x - root.pointerStart.x) + Math.abs(p.y - root.pointerStart.y) > 6)
root.pointerLive = true;
}
}
Keys.onPressed: event => {
const k = event.key;
if (k === Qt.Key_Escape) Actions.closeOverview();
else if (k === Qt.Key_Return || k === Qt.Key_Enter || k === Qt.Key_Space) root.commit();
else if (k === Qt.Key_Tab || k === Qt.Key_Right || k === Qt.Key_Down
|| k === Qt.Key_L || k === Qt.Key_J) root.step(1);
else if (k === Qt.Key_Backtab || k === Qt.Key_Left || k === Qt.Key_Up
|| k === Qt.Key_H || k === Qt.Key_K) root.step(-1);
else if (k >= Qt.Key_1 && k <= Qt.Key_9) root.goToWorkspace(k - Qt.Key_0);
else return;
event.accepted = true;
}
// Alt+Tab is a hold: letting go of Alt is the choice.
Keys.onReleased: event => {
if (event.key === Qt.Key_Alt && Actions.overviewHold) {
event.accepted = true;
root.commit();
}
}
}
Column {
id: scene
anchors.centerIn: parent
spacing: Theme.padXL
opacity: root.open ? 1 : 0
transform: Translate {
y: root.open ? 0 : 10
Behavior on y {
NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutCubic }
}
}
Behavior on opacity {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutCubic }
}
// The widest a row of cards may be, and how tall all rows together.
readonly property real maxRowWidth: root.width * 0.86
readonly property real maxRowsHeight: root.height * 0.62
// One scale for every card on every monitor, so a window is the same
// size wherever it sits: the largest that fits both the widest row and
// the summed heights.
readonly property real k: {
let k = 0.25;
let totalH = 0;
for (const m of root.monitors) {
const w = m.ipc.width / m.ipc.scale;
const h = m.ipc.height / m.ipc.scale;
const n = Math.max(1, m.workspaces.length);
k = Math.min(k, (scene.maxRowWidth - (n - 1) * Theme.padL) / (n * w));
totalH += h;
}
if (totalH > 0) k = Math.min(k, scene.maxRowsHeight / totalH);
return Math.max(0.05, k);
}
Repeater {
model: root.monitors
Column {
id: monRow
required property var modelData
readonly property real logicalW: modelData.ipc.width / modelData.ipc.scale
readonly property real logicalH: modelData.ipc.height / modelData.ipc.scale
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.padS
// The monitor's name only matters once there is more than one.
Text {
visible: root.monitors.length > 1
text: (monRow.modelData.mon.name + " · "
+ Math.round(monRow.logicalW) + "×" + Math.round(monRow.logicalH)).toUpperCase()
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
Row {
spacing: Theme.padL
Repeater {
model: monRow.modelData.workspaces
WorkspaceCard {
required property var modelData
ws: modelData.ws
windows: modelData.windows
k: scene.k
logicalW: monRow.logicalW
logicalH: monRow.logicalH
current: monRow.modelData.mon.activeWorkspace === modelData.ws
|| (modelData.ws.id < 0 && monRow.modelData.ipc.specialWorkspace?.id === modelData.ws.id)
picked: root.picked
onPick: address => {
if (!root.pointerLive) return;
root.touched = true;
root.picked = address;
}
onActivate: address => { root.picked = address; root.commit(); }
onActivateWorkspace: id => root.goToWorkspace(id)
}
}
}
}
}
// ------------------------------------------------------------ caption
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 4
width: scene.maxRowWidth * 0.7
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.pickedWindow ? root.pickedWindow.title : "No windows"
color: Theme.accent
elide: Text.ElideRight
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsTitle
font.weight: Font.Light
renderType: Text.NativeRendering
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
visible: root.pickedWindow !== null
text: root.pickedWindow
? (root.pickedWindow.appClass + " · workspace "
+ (root.pickedWindow.toplevel.workspace?.name ?? "?")).toUpperCase()
: ""
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "TAB NEXT ENTER GO 1–9 WORKSPACE ESC CANCEL"
color: Theme.overlay
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
}
}
+191
View File
@@ -0,0 +1,191 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import "root:/config"
import "root:/services"
// One workspace in the overview: a small porthole at its monitor's aspect
// ratio, the wallpaper faint behind it, and its windows as live captures where
// they sit. Under it, the workspace's number in the bar's pill when it is the
// one on screen.
Item {
id: card
required property HyprlandWorkspace ws
required property var windows
required property real k // logical px -> card px
required property real logicalW
required property real logicalH
property bool current: false
property string picked: ""
signal pick(string address)
signal activate(string address)
signal activateWorkspace(int id)
readonly property bool special: card.ws.id < 0
// The picked window, if it is one of the hidden members of a group here:
// the group's slot shows it instead of the member that is in front.
readonly property var pickedGroup: {
for (const w of card.windows)
if (w.address === card.picked && w.group.length > 1) return w.group;
return [];
}
implicitWidth: Math.round(card.logicalW * card.k)
implicitHeight: porthole.height + label.anchors.topMargin + label.height
Rectangle {
id: porthole
width: card.implicitWidth
height: Math.round(card.logicalH * card.k)
radius: Theme.radiusM
color: Theme.mantle
border.width: 1
border.color: card.current ? Theme.outline : Theme.rim
clip: true
Image {
anchors.fill: parent
anchors.margins: 1
source: Wallpaper.source
sourceSize: Qt.size(width, height)
fillMode: Image.PreserveAspectCrop
asynchronous: true
opacity: 0.35
}
// Click on bare wallpaper: go to the workspace itself.
TapHandler {
onTapped: if (!card.special) card.activateWorkspace(card.ws.id)
}
Repeater {
model: card.windows
Rectangle {
id: tile
required property var modelData
readonly property bool isPicked: tile.modelData.address === card.picked
// A group draws one member: the picked one if it is in this
// group, otherwise whichever Hyprland has in front.
visible: card.pickedGroup.length > 0 && card.pickedGroup.indexOf(tile.modelData.address) >= 0
? tile.isPicked
: !tile.modelData.hidden
x: Math.round(tile.modelData.x * card.k)
y: Math.round(tile.modelData.y * card.k)
width: Math.max(8, Math.round(tile.modelData.w * card.k))
height: Math.max(8, Math.round(tile.modelData.h * card.k))
z: tile.isPicked ? 2 : (tile.modelData.floating ? 1 : 0)
radius: Theme.radiusS
color: Theme.surface
clip: true
// The app's icon, under the capture, for the moment before the
// first frame lands or for a window that cannot be captured.
Image {
anchors.centerIn: parent
width: Math.min(32, parent.width * 0.4)
height: width
sourceSize: Qt.size(width, height)
source: {
const e = DesktopEntries.heuristicLookup(tile.modelData.appClass);
return e && e.icon ? Quickshell.iconPath(e.icon, true) : "";
}
visible: !capture.hasContent
}
ScreencopyView {
id: capture
anchors.fill: parent
captureSource: tile.modelData.toplevel.wayland
live: true
constraintSize: Qt.size(tile.width, tile.height)
}
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.width: tile.isPicked ? 2 : 1
border.color: tile.isPicked ? Theme.accent : Theme.alpha(Theme.ink, 0.6)
Behavior on border.color {
ColorAnimation { duration: Theme.durFast }
}
}
// Everything else sinks back a little.
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.ink
opacity: tile.isPicked ? 0 : 0.28
Behavior on opacity {
NumberAnimation { duration: Theme.durFast }
}
}
// How many windows share this slot.
Rectangle {
visible: tile.modelData.group.length > 1
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 4
width: groupText.implicitWidth + 8
height: groupText.implicitHeight + 2
radius: height / 2
color: Theme.alpha(Theme.ink, 0.75)
Text {
id: groupText
anchors.centerIn: parent
text: (tile.modelData.group.indexOf(tile.modelData.address) + 1)
+ "/" + tile.modelData.group.length
color: Theme.subtext
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
}
HoverHandler {
onHoveredChanged: if (hovered) card.pick(tile.modelData.address)
// Hovered from the start, then the pointer moves: still a pick.
onPointChanged: if (hovered) card.pick(tile.modelData.address)
}
TapHandler {
onTapped: card.activate(tile.modelData.address)
}
}
}
}
// The workspace's name: the focused one wears it in the bar's light pill,
// the rest in grey.
Rectangle {
id: label
anchors.top: porthole.bottom
anchors.topMargin: Theme.padS
anchors.horizontalCenter: porthole.horizontalCenter
width: Math.max(height * 1.6, labelText.implicitWidth + 14)
height: labelText.implicitHeight + 4
radius: height / 2
color: card.current ? Theme.accent : "transparent"
Text {
id: labelText
anchors.centerIn: parent
text: card.special ? card.ws.name.replace(/^special:/, "") : String(card.ws.id)
color: card.current ? Theme.ink : Theme.muted
font.family: card.special ? Theme.fontMono : Theme.fontDisplay
font.pixelSize: Theme.fsSmall
font.weight: Theme.weightDisplay
renderType: Text.NativeRendering
}
}
}
+2 -2
View File
@@ -50,7 +50,7 @@ Item {
} }
} }
P5Button { ActionButton {
id: muteButton id: muteButton
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
@@ -59,7 +59,7 @@ Item {
onClicked: Audio.toggleMicMute() onClicked: Audio.toggleMicMute()
} }
P5Slider { LevelSlider {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
+1 -1
View File
@@ -57,7 +57,7 @@ Item {
} }
} }
P5Slider { LevelSlider {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
+3 -3
View File
@@ -50,7 +50,7 @@ Popout {
width: parent.width width: parent.width
spacing: Theme.padS spacing: Theme.padS
P5Button { ActionButton {
id: outputMuteButton id: outputMuteButton
width: parent.width width: parent.width
text: Audio.muted ? "Unmute output" : "Mute output" text: Audio.muted ? "Unmute output" : "Mute output"
@@ -58,7 +58,7 @@ Popout {
onClicked: Audio.toggleMute() onClicked: Audio.toggleMute()
} }
P5Button { ActionButton {
width: parent.width width: parent.width
text: "Open audio mixer" text: "Open audio mixer"
icon: "󰕾" icon: "󰕾"
@@ -178,7 +178,7 @@ Popout {
} }
} }
P5Slider { LevelSlider {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
+1 -1
View File
@@ -123,7 +123,7 @@ Popout {
// Follows the detail line rather than being pinned to the bottom of a // Follows the detail line rather than being pinned to the bottom of a
// fixed-height panel, which is what left a dead band above it. // fixed-height panel, which is what left a dead band above it.
P5Button { ActionButton {
id: suspendButton id: suspendButton
anchors.top: detailText.bottom anchors.top: detailText.bottom
anchors.topMargin: Theme.padL anchors.topMargin: Theme.padL
+2 -2
View File
@@ -40,14 +40,14 @@ Popout {
anchors.left: parent.left anchors.left: parent.left
spacing: Theme.padS spacing: Theme.padS
P5Button { ActionButton {
text: Bt.enabled ? "On" : "Off" text: Bt.enabled ? "On" : "Off"
icon: Bt.enabled ? "󰂯" : "󰂲" icon: Bt.enabled ? "󰂯" : "󰂲"
accent: Bt.enabled ? Theme.accent : Theme.muted accent: Bt.enabled ? Theme.accent : Theme.muted
onClicked: Bt.toggle() onClicked: Bt.toggle()
} }
P5Button { ActionButton {
text: Bt.discovering ? "Scanning" : "Scan" text: Bt.discovering ? "Scanning" : "Scan"
icon: "󰐷" icon: "󰐷"
accent: Bt.discovering ? Theme.accent : Theme.primary accent: Bt.discovering ? Theme.accent : Theme.primary
+1 -1
View File
@@ -172,7 +172,7 @@ Popout {
renderType: Text.NativeRendering renderType: Text.NativeRendering
} }
P5Slider { LevelSlider {
anchors.left: elapsed.right anchors.left: elapsed.right
anchors.right: total.left anchors.right: total.left
anchors.leftMargin: Theme.padS anchors.leftMargin: Theme.padS
+43 -7
View File
@@ -5,12 +5,12 @@ import "root:/config"
import "root:/components" import "root:/components"
import "root:/services" import "root:/services"
// Link state, throughput, and the Wi-Fi picker. // Link state, throughput, the wired ports, and the Wi-Fi picker.
Popout { Popout {
id: root id: root
contentWidth: 360 contentWidth: 360
contentHeight: 300 contentHeight: 300 + (wired.visible ? wired.height + Theme.padM : 0)
// Scan only while the popout is actually on screen. // Scan only while the popout is actually on screen.
onShownChanged: Net.setScanning(shown) onShownChanged: Net.setScanning(shown)
@@ -62,27 +62,63 @@ Popout {
anchors.left: parent.left anchors.left: parent.left
spacing: Theme.padS spacing: Theme.padS
P5Button { ActionButton {
text: Net.wifiEnabled ? "Wi-Fi On" : "Wi-Fi Off" text: Net.wifiEnabled ? "Wi-Fi On" : "Wi-Fi Off"
icon: Net.wifiEnabled ? "󰖩" : "󰖪" icon: Net.wifiEnabled ? "󰖩" : "󰖪"
accent: Net.wifiEnabled ? Theme.primary : Theme.muted accent: Net.wifiEnabled ? Theme.primary : Theme.muted
onClicked: Net.toggleWifi() onClicked: Net.toggleWifi()
} }
P5Button { ActionButton {
text: "Settings" text: "Settings"
icon: "󰒓" icon: "󰒓"
onClicked: Actions.openNetworkSettings() onClicked: Actions.openNetworkSettings()
} }
} }
SectionRule { // One row per Ethernet port; a click brings the link up or down.
id: apsLabel Column {
id: wired
anchors.top: controls.bottom anchors.top: controls.bottom
anchors.topMargin: Theme.padM anchors.topMargin: Theme.padM
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
text: Net.scanning ? "Scanning…" : "Networks" visible: Net.wiredDevices.length > 0
spacing: 2
SectionRule {
width: parent.width
text: "Wired"
}
Item {
width: parent.width
height: Theme.padS - 2
}
Repeater {
model: Net.wiredDevices
delegate: ListRow {
required property var modelData
width: wired.width
icon: modelData.hasLink ? "󰈀" : "󰈂"
label: "Ethernet"
detail: Net.wiredDetail(modelData)
current: modelData.connected
onClicked: Net.toggleWired(modelData)
}
}
}
SectionRule {
id: apsLabel
anchors.top: wired.visible ? wired.bottom : controls.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
anchors.right: parent.right
text: Net.scanning ? "Scanning…" : "Wi-Fi"
} }
ListView { ListView {
+176 -5
View File
@@ -34,14 +34,14 @@ Popout {
anchors.left: parent.left anchors.left: parent.left
spacing: Theme.padS spacing: Theme.padS
P5Button { ActionButton {
text: Notifs.dnd ? "Silenced" : "Silence" text: Notifs.dnd ? "Silenced" : "Silence"
icon: Notifs.dnd ? "󰂛" : "󰂚" icon: Notifs.dnd ? "󰂛" : "󰂚"
accent: Notifs.dnd ? Theme.treatmentOffText : Theme.primary accent: Notifs.dnd ? Theme.treatmentOffText : Theme.primary
onClicked: Notifs.toggleDnd() onClicked: Notifs.toggleDnd()
} }
P5Button { ActionButton {
text: "Clear" text: "Clear"
icon: "󰎟" icon: "󰎟"
destructive: true destructive: true
@@ -61,23 +61,194 @@ Popout {
renderType: Text.NativeRendering renderType: Text.NativeRendering
} }
// The log, by sender. A sender with one notification is just that
// entry. A sender with several gets a header — its name, how many, a
// clear-all mark — over its newest entry, and opens to show the rest.
ListView { ListView {
id: log
anchors.top: controls.bottom anchors.top: controls.bottom
anchors.topMargin: Theme.padM anchors.topMargin: Theme.padM
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
clip: true clip: true
// Entries carry their own header hairline, so they sit flush.
spacing: 0 spacing: 0
model: Notifs.history model: Notifs.groups
delegate: NotifCard { // Which senders are open, by app name. Survives the list being
// rebuilt when a notification arrives.
property var expanded: ({})
function toggle(app: string) {
const next = Object.assign({}, log.expanded);
next[app] = !next[app];
log.expanded = next;
}
delegate: Column {
id: group
required property var modelData required property var modelData
readonly property string app: group.modelData.app
readonly property var items: group.modelData.items
readonly property bool many: group.items.length > 1
readonly property bool open: log.expanded[group.app] === true
width: ListView.view.width width: ListView.view.width
Item {
id: groupHeader
visible: group.many
width: parent.width
height: group.many ? 30 : 0
Rectangle {
anchors.fill: parent
anchors.margins: Theme.padS / 2
radius: Theme.radiusL
color: headerHover.hovered ? Theme.treatmentHoverFill : "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
}
Row {
id: headerRow
anchors.left: parent.left
anchors.leftMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.padS
Icon {
anchors.verticalCenter: parent.verticalCenter
text: Notifs.appIconFor(group.items[0])
color: Theme.subtext
font.pixelSize: Theme.fsBody
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: group.app.toUpperCase()
color: Theme.text
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
// The count, in a small grey pill.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: countText.implicitWidth + 10
height: countText.implicitHeight + 2
radius: height / 2
color: Theme.surfaceAlt
Text {
id: countText
anchors.centerIn: parent
text: group.items.length
color: Theme.subtext
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
}
}
Rectangle {
anchors.left: headerRow.right
anchors.leftMargin: Theme.padS
anchors.right: chevron.left
anchors.rightMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Theme.alpha(Theme.outline, 0.5)
}
Icon {
id: chevron
anchors.right: clearGroup.left
anchors.rightMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter
text: "󰅀"
rotation: group.open ? 180 : 0
color: headerHover.hovered ? Theme.accent : Theme.muted
font.pixelSize: Theme.fsBody
Behavior on rotation {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutCubic }
}
}
Icon {
id: clearGroup
anchors.right: parent.right
anchors.rightMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
text: "󰎟"
opacity: headerHover.hovered ? 1 : 0
color: clearHover.hovered ? Theme.alarm : Theme.muted
font.pixelSize: Theme.fsBody
Behavior on opacity {
NumberAnimation { duration: Theme.durFast }
}
HoverHandler {
id: clearHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: Notifs.dismissApp(group.app)
}
}
HoverHandler {
id: headerHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: log.toggle(group.app)
}
}
Repeater {
model: group.open ? group.items : group.items.slice(0, 1)
NotifCard {
required property var modelData
width: group.width
notif: modelData notif: modelData
showSender: !group.many
onDismissed: Notifs.dismiss(modelData) onDismissed: Notifs.dismiss(modelData)
} }
}
// Folded: say how many more are behind the newest.
Text {
visible: group.many && !group.open
leftPadding: Theme.padM + Theme.marker + Theme.padS
bottomPadding: Theme.padS
text: "+ " + (group.items.length - 1) + " more"
color: moreHover.hovered ? Theme.accent : Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 1
renderType: Text.NativeRendering
HoverHandler {
id: moreHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: log.toggle(group.app)
}
}
}
add: Transition { add: Transition {
NumberAnimation { NumberAnimation {
+3 -3
View File
@@ -3,8 +3,8 @@ import "root:/config"
import "root:/components" import "root:/components"
// The slab every popout sits on. Pure preset — it declares no children of its // The slab every popout sits on. Pure preset — it declares no children of its
// own, so P5Panel's default content property stays usable by callers. // own, so Panel's default content property stays usable by callers.
P5Panel { Panel {
property color tone: Theme.primary property color tone: Theme.primary
property bool textured: false property bool textured: false
@@ -14,7 +14,7 @@ P5Panel {
readonly property bool attached: parent?.frameAttached ?? false readonly property bool attached: parent?.frameAttached ?? false
fill: attached ? "transparent" : Theme.mantle fill: attached ? "transparent" : Theme.mantle
// Zero, not a transparent fill: P5Panel re-applies this opacity to the // Zero, not a transparent fill: Panel re-applies this opacity to the
// fill colour, which turned "transparent" into 97% black. // fill colour, which turned "transparent" into 97% black.
fillOpacity: attached ? 0 : 0.97 fillOpacity: attached ? 0 : 0.97
// The frame is structure: a neutral hairline. A free-standing panel takes // The frame is structure: a neutral hairline. A free-standing panel takes
+3 -12
View File
@@ -10,7 +10,7 @@ import "root:/components"
// implementation, but that draws a stock widget popup — square corners, system // implementation, but that draws a stock widget popup — square corners, system
// palette, no lean — hanging off the corner of a shell that has none of those // palette, no lean — hanging off the corner of a shell that has none of those
// things. This walks the menu with QsMenuOpener instead and draws it as a // things. This walks the menu with QsMenuOpener instead and draws it as a
// P5Panel, so a right click on a tray icon produces the same object language as // Panel, so a right click on a tray icon produces the same object language as
// everything else on the rail. // everything else on the rail.
// //
// Dismissal is a Hyprland focus grab: click anywhere outside and the compositor // Dismissal is a Hyprland focus grab: click anywhere outside and the compositor
@@ -79,7 +79,6 @@ PopupWindow {
closeAnim.stop(); closeAnim.stop();
shown = false; shown = false;
holder.opacity = 0; holder.opacity = 0;
holder.scale = 0.94;
holder.x = root.bleed; holder.x = root.bleed;
PopupCoordinator.release(root); PopupCoordinator.release(root);
} }
@@ -106,9 +105,9 @@ PopupWindow {
width: root.contentWidth width: root.contentWidth
height: root.contentHeight height: root.contentHeight
// It slides in from the tray and fades, and never scales: scaling
// stretches the menu's text while it moves.
opacity: 0 opacity: 0
scale: 0.94
transformOrigin: Item.Right
PopoutSurface { PopoutSurface {
anchors.fill: parent anchors.fill: parent
@@ -136,10 +135,6 @@ PopupWindow {
target: holder; property: "opacity" target: holder; property: "opacity"
to: 1.0; duration: Theme.durBase; easing.type: Easing.OutExpo to: 1.0; duration: Theme.durBase; easing.type: Easing.OutExpo
} }
NumberAnimation {
target: holder; property: "scale"
to: 1.0; duration: Theme.durSlow; easing.type: Easing.OutCubic
}
NumberAnimation { NumberAnimation {
target: holder; property: "x" target: holder; property: "x"
from: root.bleed + 14; to: root.bleed from: root.bleed + 14; to: root.bleed
@@ -153,10 +148,6 @@ PopupWindow {
target: holder; property: "opacity" target: holder; property: "opacity"
to: 0.0; duration: Theme.durFast; easing.type: Easing.InQuad to: 0.0; duration: Theme.durFast; easing.type: Easing.InQuad
} }
NumberAnimation {
target: holder; property: "scale"
to: 0.96; duration: Theme.durFast; easing.type: Easing.InQuad
}
onFinished: { onFinished: {
root.shown = false; root.shown = false;
holder.x = root.bleed; holder.x = root.bleed;
+71 -2
View File
@@ -19,6 +19,9 @@ Singleton {
property bool launcherOpen: false property bool launcherOpen: false
property bool askOpen: false property bool askOpen: false
property bool dashboardOpen: false property bool dashboardOpen: false
property bool overviewOpen: false
// Opened by Alt+Tab, so letting go of Alt picks the window.
property bool overviewHold: false
// Seed text for the next launcher open. Lets a keybind or a script hand the // Seed text for the next launcher open. Lets a keybind or a script hand the
// launcher a query it did not have to type — `qs ipc call shell prompt // launcher a query it did not have to type — `qs ipc call shell prompt
@@ -40,6 +43,9 @@ Singleton {
// …and for the dashboard drawer. // …and for the dashboard drawer.
property string dashboardScreen: "" property string dashboardScreen: ""
// …and for the overview.
property string overviewScreen: ""
// When the dashboard was last closed from outside. A click on the bar's // When the dashboard was last closed from outside. A click on the bar's
// clock first clears the drawer's focus grab, which closes it, and then // clock first clears the drawer's focus grab, which closes it, and then
// lands on the clock, which would toggle it straight back open. A toggle // lands on the clock, which would toggle it straight back open. A toggle
@@ -51,6 +57,8 @@ Singleton {
property string askSeed: "" property string askSeed: ""
signal osdRequested(string kind) signal osdRequested(string kind)
signal overviewStepped(int delta)
signal overviewCommitted()
function togglePowerMenu() { function togglePowerMenu() {
if (!root.powerMenuOpen) if (!root.powerMenuOpen)
@@ -88,6 +96,29 @@ Singleton {
root.dashboardClosedAt = Date.now(); root.dashboardClosedAt = Date.now();
} }
function toggleOverview() {
if (!root.overviewOpen) {
root.overviewHold = false;
root.overviewScreen = root.focusedScreenName();
}
root.overviewOpen = !root.overviewOpen;
}
function closeOverview() { root.overviewOpen = false; }
// Alt+Tab and Alt+Shift+Tab. The first press opens the overview with the
// previous window picked; Hyprland keeps the bind while it is open, so
// later presses arrive here too and walk the focus history.
function overviewStep(delta: int) {
if (!root.overviewOpen) {
root.overviewHold = true;
root.overviewScreen = root.focusedScreenName();
root.overviewOpen = true;
if (delta < 0) root.overviewStepped(-2);
return;
}
root.overviewStepped(delta);
}
function toggleAsk() { function toggleAsk() {
if (!root.askOpen) { if (!root.askOpen) {
root.askSeed = ""; root.askSeed = "";
@@ -137,8 +168,41 @@ Singleton {
function logout() { root.run(["uwsm", "stop"]); } function logout() { root.run(["uwsm", "stop"]); }
function reboot() { root.run(["systemctl", "reboot"]); } function reboot() { root.run(["systemctl", "reboot"]); }
function shutdown() { root.run(["systemctl", "poweroff"]); } function shutdown() { root.run(["systemctl", "poweroff"]); }
function suspend() { root.lock(); root.run(["systemctl", "suspend"]); } function suspend() { root.sleep(["systemctl", "suspend"]); }
function hibernate() { root.lock(); root.run(["systemctl", "hibernate"]); } function hibernate() { root.sleep(["systemctl", "hibernate"]); }
// Set by lock/Lock.qml once the compositor has the lock surfaces up.
property bool lockSecure: false
// A sleep waiting for the lock. Suspending first would leave the desktop
// on screen for a moment on wake, before the lock had drawn.
property var pendingSleep: null
function sleep(argv: var) {
root.lock();
if (root.lockSecure) {
root.run(argv);
return;
}
root.pendingSleep = argv;
sleepFallback.restart();
}
function _sleepNow() {
if (!root.pendingSleep) return;
const argv = root.pendingSleep;
root.pendingSleep = null;
sleepFallback.stop();
root.run(argv);
}
onLockSecureChanged: if (root.lockSecure) root._sleepNow()
// A lock that never comes up must not keep the machine awake on battery.
Timer {
id: sleepFallback
interval: 3000
onTriggered: root._sleepNow()
}
function reloadShell() { Quickshell.reload(true); } function reloadShell() { Quickshell.reload(true); }
// ------------------------------------------------------------- helpers // ------------------------------------------------------------- helpers
@@ -164,6 +228,11 @@ Singleton {
function toggleDnd(): void { Notifs.toggleDnd(); } function toggleDnd(): void { Notifs.toggleDnd(); }
function clearNotifications(): void { Notifs.clearAll(); } function clearNotifications(): void { Notifs.clearAll(); }
function toggleStayAwake(): void { Idle.toggleStayAwake(); } function toggleStayAwake(): void { Idle.toggleStayAwake(); }
function gameMode(on: bool): void { GameMode.active = on; }
function overview(): void { root.toggleOverview(); }
function overviewNext(): void { root.overviewStep(1); }
function overviewPrev(): void { root.overviewStep(-1); }
function overviewCommit(): void { if (root.overviewHold) root.overviewCommitted(); }
function wallpaper(path: string): void { Wallpaper.set(path); } function wallpaper(path: string): void { Wallpaper.set(path); }
} }
} }
+15 -1
View File
@@ -107,8 +107,15 @@ Singleton {
root.lastCost = 0; root.lastCost = 0;
} }
// Set while a run is being killed on purpose, so its exit is not reported
// as a failure.
property bool stopping: false
function stop(): void { function stop(): void {
if (proc.running) proc.running = false; if (proc.running) {
root.stopping = true;
proc.running = false;
}
root.busy = false; root.busy = false;
} }
@@ -125,7 +132,10 @@ Singleton {
root.exitCode = 0; root.exitCode = 0;
root.stderrText = ""; root.stderrText = "";
if (proc.running) {
root.stopping = true;
proc.running = false; proc.running = false;
}
// stdin is closed explicitly. Inherited from the shell it never ends, // stdin is closed explicitly. Inherited from the shell it never ends,
// and the CLI waits three seconds for input that is never coming // and the CLI waits three seconds for input that is never coming
// before it starts on the question. // before it starts on the question.
@@ -236,6 +246,10 @@ Singleton {
} }
onExited: (code, status) => { onExited: (code, status) => {
if (root.stopping) {
root.stopping = false;
return;
}
root.busy = false; root.busy = false;
root.exitCode = code; root.exitCode = code;
if (code === 0 || root.error !== "") return; if (code === 0 || root.error !== "") return;
+3 -2
View File
@@ -49,9 +49,10 @@ Singleton {
onMicPercentChanged: micBumped() onMicPercentChanged: micBumped()
onMicMutedChanged: micBumped() onMicMutedChanged: micBumped()
// Capped at 100%: past it PipeWire only amplifies in software, and clips.
function setVolume(v: real) { function setVolume(v: real) {
if (!sink?.audio) return; if (!sink?.audio) return;
sink.audio.volume = Math.max(0, Math.min(1.5, v)); sink.audio.volume = Math.max(0, Math.min(1, v));
} }
function changeVolume(delta: real) { function changeVolume(delta: real) {
@@ -65,7 +66,7 @@ Singleton {
function setMicVolume(v: real) { function setMicVolume(v: real) {
if (!source?.audio) return; if (!source?.audio) return;
source.audio.volume = Math.max(0, Math.min(1.5, v)); source.audio.volume = Math.max(0, Math.min(1, v));
} }
function toggleMicMute() { function toggleMicMute() {
+27 -5
View File
@@ -37,6 +37,8 @@ Singleton {
const v = parseInt(text().trim()); const v = parseInt(text().trim());
if (!isNaN(v) && v !== root.raw) { if (!isNaN(v) && v !== root.raw) {
root.raw = v; root.raw = v;
// Settled, or moved by something else: step from here again.
if (!setter.running && root.pending < 0) root.target = -1;
root.bumped(); root.bumped();
} }
} }
@@ -45,24 +47,44 @@ Singleton {
// sysfs writes do not always fire inotify for backlight, so poll gently too. // sysfs writes do not always fire inotify for backlight, so poll gently too.
Timer { Timer {
interval: 1000 interval: 1000
running: true running: !GameMode.active
repeat: true repeat: true
onTriggered: currentFile.reload() onTriggered: currentFile.reload()
} }
// The level last asked for, until the backlight reports it. Steps build on
// it rather than on `percent`, which only catches up on the next read, so
// a fast scroll adds up instead of repeating one step.
property int target: -1
// A level asked for while a write is still running. Restarting the write
// would kill it half done, so the newest request waits and the rest drop.
property int pending: -1
Process { Process {
id: setter id: setter
onExited: {
currentFile.reload();
if (root.pending < 0) return;
const next = root.pending;
root.pending = -1;
root._write(next);
}
}
function _write(p: int) {
setter.command = ["brightnessctl", "-d", root.device, "-q", "set", p + "%"];
setter.running = true;
} }
function setPercent(p: real) { function setPercent(p: real) {
const clamped = Math.max(1, Math.min(100, Math.round(p))); const clamped = Math.max(1, Math.min(100, Math.round(p)));
setter.running = false; root.target = clamped;
setter.command = ["brightnessctl", "-d", root.device, "-q", "set", clamped + "%"]; if (setter.running) root.pending = clamped;
setter.running = true; else root._write(clamped);
} }
function change(delta: real) { function change(delta: real) {
setPercent(percent + delta); setPercent((root.target >= 0 ? root.target : percent) + delta);
} }
readonly property string icon: { readonly property string icon: {
+24
View File
@@ -0,0 +1,24 @@
pragma Singleton
import QtQuick
import Quickshell
// A fullscreen game is on screen. Hyprland decides (~/.config/hypr/lua/gamemode.lua)
// and says so with `qs ipc call shell gameMode true|false`.
//
// The game covers the hull, but a covered surface still renders: Hyprland keeps
// sending it frame callbacks, so anything that moves keeps redrawing at the
// panel's refresh rate with nobody watching. While a game runs the shell stops
// what it does on its own:
//
// Sys the fluxo stream, so no telemetry wakes the shell
// Brightness the backlight poll
// Media the position tick, and the equaliser on the bar
// Rail the critical battery's pulse
// Workspaces the urgent pip's pulse
// Notifs toasts, except critical ones; everything still reaches the log
//
// Anything you ask for yourself, the OSD included, still shows.
Singleton {
property bool active: false
}
+1 -1
View File
@@ -74,7 +74,7 @@ Singleton {
// MPRIS position is pull-only; tick it so the seek bar moves. // MPRIS position is pull-only; tick it so the seek bar moves.
Timer { Timer {
interval: 1000 interval: 1000
running: root.playing && root.positionSupported running: root.playing && root.positionSupported && !GameMode.active
repeat: true repeat: true
onTriggered: if (root.active) root.active.positionChanged() onTriggered: if (root.active) root.active.positionChanged()
} }
+27 -15
View File
@@ -16,10 +16,13 @@ Singleton {
readonly property var devices: Networking.devices?.values ?? [] readonly property var devices: Networking.devices?.values ?? []
readonly property var wifiDevices: devices.filter(d => d.type === DeviceType.Wifi) readonly property var wifiDevices: devices.filter(d => d.type === DeviceType.Wifi)
readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired) // Docker's veth pairs show up as wired too; NetworkManager leaves them
// unmanaged, and so does the popout.
readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired && d.nmManaged)
readonly property WifiDevice wifi: wifiDevices.length > 0 ? wifiDevices[0] : null readonly property WifiDevice wifi: wifiDevices.length > 0 ? wifiDevices[0] : null
readonly property WiredDevice wired: wiredDevices.length > 0 ? wiredDevices[0] : null // The port in use, or failing that the first one there is.
readonly property WiredDevice wired: wiredDevices.find(d => d.connected) ?? wiredDevices[0] ?? null
readonly property bool wiredUp: wired?.connected ?? false readonly property bool wiredUp: wired?.connected ?? false
@@ -55,14 +58,8 @@ Singleton {
if (!root.wifiEnabled) return "󰤭"; if (!root.wifiEnabled) return "󰤭";
if (!root.activeWifi) return "󰤯"; if (!root.activeWifi) return "󰤯";
const s = root.activeWifi.signalStrength ?? 0; const s = root.activeWifi.signalStrength ?? 0;
// Strength is only sampled while a scan is running, which the shell // Connected but not yet sampled should read as full, not dead.
// does on demand. Connected-but-unsampled should read as full, not dead. return s <= 0 ? "󰤨" : root.signalIcon(s);
if (s <= 0) return "󰤨";
if (s >= 80) return "󰤨";
if (s >= 60) return "󰤥";
if (s >= 40) return "󰤢";
if (s >= 20) return "󰤟";
return "󰤯";
} }
readonly property string label: { readonly property string label: {
@@ -72,14 +69,29 @@ Singleton {
return "Offline"; return "Offline";
} }
function signalIcon(strength: int): string { // Strength is a fraction, 0 to 1, not a percentage.
if (strength >= 80) return "󰤨"; function signalIcon(strength: real): string {
if (strength >= 60) return "󰤥"; if (strength >= 0.8) return "󰤨";
if (strength >= 40) return "󰤢"; if (strength >= 0.6) return "󰤥";
if (strength >= 20) return "󰤟"; if (strength >= 0.4) return "󰤢";
if (strength >= 0.2) return "󰤟";
return "󰤯"; return "󰤯";
} }
// "1 Gb/s" when up, otherwise why not. NetworkManager reports Mb/s.
function wiredDetail(d): string {
if (!d.hasLink) return d.name + " · no cable";
if (!d.connected) return d.name + " · disconnected";
const mbps = d.linkSpeed ?? 0;
if (mbps <= 0) return d.name;
return d.name + " · " + (mbps >= 1000 ? (mbps / 1000) + " Gb/s" : mbps + " Mb/s");
}
function toggleWired(d) {
if (d.connected) d.disconnect();
else if (d.network) d.network.connect();
}
function secured(n): bool { function secured(n): bool {
return n && n.security !== undefined return n && n.security !== undefined
&& n.security !== WifiSecurityType.Open && n.security !== WifiSecurityType.Open
+102 -15
View File
@@ -16,9 +16,29 @@ Singleton {
property list<Notification> history: [] property list<Notification> history: []
property int maxHistory: 60 property int maxHistory: 60
readonly property int count: history.length readonly property int count: history.filter(n => n).length
// The history by sender, for the log: one entry per app, newest first
// inside each, the apps ordered by their newest notification.
readonly property var groups: {
const byApp = {};
const order = [];
for (const n of root.history) {
if (!n) continue;
const app = n.appName || "System";
if (!(app in byApp)) { byApp[app] = []; order.push(app); }
byApp[app].push(n);
}
return order.map(app => ({ app: app, items: byApp[app] }));
}
// The server hands out ids in rising order, so everything in the log above
// the newest id seen when the log was last opened is unread. Derived from
// the history rather than counted, so dismissing an entry anywhere takes
// it off the count too.
property int readId: 0
readonly property int unread: history.filter(n => n && n.id > root.readId).length
readonly property bool hasUnread: unread > 0 readonly property bool hasUnread: unread > 0
property int unread: 0
NotificationServer { NotificationServer {
id: server id: server
@@ -36,36 +56,103 @@ Singleton {
onNotification: notif => { onNotification: notif => {
// Hold the notification open until we drop it ourselves. // Hold the notification open until we drop it ourselves.
notif.tracked = true; notif.tracked = true;
root.adopt(notif);
root.history = [notif, ...root.history].slice(0, root.maxHistory); const next = [notif, ...root.history.filter(n => n)];
root.unread++; for (const n of next.slice(root.maxHistory)) root.release(n);
root.history = next.slice(0, root.maxHistory);
if (!root.dnd) root.popupRequested(notif); // A game holds every toast but a critical one; the log has them all.
const held = GameMode.active && notif.urgency !== NotificationUrgency.Critical;
if (!root.dnd && !held) root.popupRequested(notif);
} }
} }
// The server outlives a reload, and keeps what it was tracking; this
// object does not. Take those back into the log, already read.
Component.onCompleted: {
const kept = server.trackedNotifications.values.slice().sort((a, b) => b.id - a.id);
for (const n of kept) root.adopt(n);
root.history = kept.slice(0, root.maxHistory);
for (const n of kept.slice(root.maxHistory)) root.release(n);
root.markRead();
}
// An app may close its own notification at any time, and the server then
// destroys the object, leaving a null in the history. The lock keeps it
// alive, frozen as it was, until the log lets it go; closing it only takes
// it off screen.
function adopt(notif: Notification) {
root.holds[notif.id] = hold.createObject(root, { object: notif });
notif.closed.connect(() => {
root.retracted = Object.assign({}, root.retracted, { [notif.id]: true });
root.notifRetracted(notif);
});
}
Component {
id: hold
RetainableLock { locked: true }
}
// One lock per notification in the history, by id.
property var holds: ({})
// Ids of the notifications whose app has closed them. They stay in the log
// but can no longer be acted on.
property var retracted: ({})
signal popupRequested(Notification notif) signal popupRequested(Notification notif)
signal notifRetracted(Notification notif)
function isRetracted(notif: Notification): bool {
return !notif || root.retracted[notif.id] === true;
}
// Close a notification if its app has not, and let the object go.
function release(notif: Notification) {
if (!notif) return;
const id = notif.id;
if (!root.isRetracted(notif)) notif.dismiss();
root.holds[id]?.destroy();
delete root.holds[id];
if (id in root.retracted) {
const next = Object.assign({}, root.retracted);
delete next[id];
root.retracted = next;
}
}
function dismiss(notif: Notification) { function dismiss(notif: Notification) {
if (!notif) return; if (!notif) return;
const idx = root.history.indexOf(notif); root.history = root.history.filter(n => n && n !== notif);
if (idx !== -1) { root.release(notif);
const next = root.history.slice();
next.splice(idx, 1);
root.history = next;
} }
notif.dismiss();
function dismissApp(app: string) {
const from = n => n.appName || "System";
const gone = root.history.filter(n => n && from(n) === app);
root.history = root.history.filter(n => n && from(n) !== app);
for (const n of gone) root.release(n);
} }
function clearAll() { function clearAll() {
const all = root.history.slice(); const all = root.history.filter(n => n);
root.history = []; root.history = [];
root.unread = 0; for (const n of all) root.release(n);
for (const n of all) n.dismiss();
} }
function markRead() { function markRead() {
root.unread = 0; root.readId = Math.max(root.readId, ...root.history.filter(n => n).map(n => n.id));
}
// Run one of a notification's actions and take it out of the log. Done
// here rather than in the card: invoking closes a non-resident
// notification, which takes its toast, and the card with it, off screen
// before the card's handler has finished.
function invoke(notif: Notification, action: NotificationAction) {
if (!notif || root.isRetracted(notif)) return;
action.invoke();
root.dismiss(notif);
} }
function toggleDnd() { function toggleDnd() {
+58
View File
@@ -0,0 +1,58 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
// Whether anything is listening or watching: an app recording from a
// microphone, a camera in use, or the screen being shared. All three show up
// in PipeWire, so this needs no polling — a capture stream exists only while
// something records, and a video source has links only while something
// consumes it.
Singleton {
id: root
// Capture streams from other programs. The shell's own level meters are
// capture streams too, and do not count.
readonly property var micUsers: Pipewire.nodes.values.filter(n =>
n.properties?.["media.class"] === "Stream/Input/Audio"
&& !root._isShell(n))
readonly property var videoLinks: Pipewire.links.values.filter(l =>
String(l.source?.properties?.["media.class"] ?? "").startsWith("Video/"))
// xdg-desktop-portal-hyprland names its screencast nodes "xdph-…".
readonly property bool screen: root.videoLinks.some(l => root._isScreencast(l.source))
readonly property bool camera: root.videoLinks.some(l => !root._isScreencast(l.source))
readonly property bool mic: root.micUsers.length > 0
readonly property bool active: root.mic || root.camera || root.screen
// Who is doing it, for the tooltip.
readonly property string detail: {
const parts = [];
if (root.screen) parts.push("Screen shared");
if (root.camera) parts.push("Camera on");
if (root.mic) {
const apps = [...new Set(root.micUsers.map(n =>
n.properties?.["application.name"] || n.name || "an app"))];
parts.push("Mic: " + apps.join(", "));
}
return parts.join(" · ");
}
function _isShell(n): bool {
const who = (String(n.properties?.["application.process.binary"] ?? "") + " "
+ String(n.properties?.["application.name"] ?? "")).toLowerCase();
return who.includes("quickshell") || /(^|\s)qs(\s|$)/.test(who);
}
function _isScreencast(n): bool {
const name = String(n?.name ?? n?.properties?.["node.name"] ?? "");
return name.startsWith("xdph");
}
// Node properties fill in only once a node is bound.
PwObjectTracker {
objects: Pipewire.nodes.values.filter(n => n.isStream || !n.isSink)
}
}
+11 -1
View File
@@ -167,7 +167,17 @@ Singleton {
id: retry id: retry
interval: 2000 interval: 2000
repeat: false repeat: false
onTriggered: source.running = true onTriggered: source.running = !GameMode.active
}
// Nothing reads the meters while a game covers the bar. Drop the stream
// and pick it up again afterwards; the first lines back refresh every value.
Connections {
target: GameMode
function onActiveChanged() {
source.running = !GameMode.active;
}
} }
// ------------------------------------------------------------ formatting // ------------------------------------------------------------ formatting
+3 -1
View File
@@ -19,8 +19,10 @@ Singleton {
// The wallpaper to use when no particular monitor is being asked about. // The wallpaper to use when no particular monitor is being asked about.
property string path: "" property string path: ""
// Encoded segment by segment: a `#` or `?` in a file name would otherwise
// be read as the start of a fragment or a query.
readonly property url source: root.path !== "" readonly property url source: root.path !== ""
? Qt.resolvedUrl("file://" + root.path) ? "file://" + root.path.split("/").map(encodeURIComponent).join("/")
: Theme.fallbackWallpaper : Theme.fallbackWallpaper
signal changed() signal changed()
+15
View File
@@ -1,3 +1,10 @@
// The Samsung on DP-1 drops off the bus when its display powers down, so DPMS
// off (services/Idle.qml) removes an output under every window on it. With
// Qt's threaded render loop the render thread can still be swapping a surface
// the GUI thread just destroyed, which segfaults in Mesa and takes the lock
// screen down with it. The basic loop renders on the GUI thread, so it cannot.
//@ pragma Env QSG_RENDER_LOOP=basic
import QtQuick import QtQuick
import Quickshell import Quickshell
import "root:/config" import "root:/config"
@@ -7,6 +14,7 @@ import "root:/bar"
import "root:/launcher" import "root:/launcher"
import "root:/dashboard" import "root:/dashboard"
import "root:/ask" import "root:/ask"
import "root:/overview"
import "root:/overlays" import "root:/overlays"
import "root:/lock" import "root:/lock"
@@ -18,6 +26,7 @@ import "root:/lock"
// popouts/ hover panels hanging off bar modules // popouts/ hover panels hanging off bar modules
// launcher/ the application launcher // launcher/ the application launcher
// dashboard/ the drawer that drops from the bar // dashboard/ the drawer that drops from the bar
// overview/ every monitor, workspace and window at once (Alt+Tab)
// ask/ the question panel, dropped from the top edge // ask/ the question panel, dropped from the top edge
// overlays/ power menu, notification toasts, OSD // overlays/ power menu, notification toasts, OSD
// lock/ session lock // lock/ session lock
@@ -84,6 +93,12 @@ ShellRoot {
AskPanel {} AskPanel {}
} }
Variants {
model: Quickshell.screens
Overview {}
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens