10 Commits
Author SHA1 Message Date
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
42 changed files with 1287 additions and 118 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 -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.
+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 })
+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)
-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
+52 -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,11 @@ 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.
## Modules ## Modules
| Module | Where | Left click | Right click | Middle | Scroll | Hover | | Module | Where | Left click | Right click | Middle | Scroll | Hover |
@@ -144,6 +162,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 +236,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 +414,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 +450,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
+13
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
+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
+53
View File
@@ -155,5 +155,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.
+15 -3
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
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.
+133 -1
View File
@@ -58,7 +58,8 @@ 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
@@ -549,6 +550,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 +632,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: ""
+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
+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
+2 -2
View File
@@ -62,14 +62,14 @@ 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()
+178 -7
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,22 +61,193 @@ 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
notif: modelData
onDismissed: Notifs.dismiss(modelData) 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
showSender: !group.many
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 {
+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;
+35
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 = "";
@@ -164,6 +195,10 @@ 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 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); }
} }
} }
+19
View File
@@ -17,6 +17,19 @@ Singleton {
property int maxHistory: 60 property int maxHistory: 60
readonly property int count: history.length readonly property int count: history.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) {
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] }));
}
readonly property bool hasUnread: unread > 0 readonly property bool hasUnread: unread > 0
property int unread: 0 property int unread: 0
@@ -57,6 +70,12 @@ Singleton {
notif.dismiss(); notif.dismiss();
} }
function dismissApp(app: string) {
const gone = root.history.filter(n => (n.appName || "System") === app);
root.history = root.history.filter(n => (n.appName || "System") !== app);
for (const n of gone) n.dismiss();
}
function clearAll() { function clearAll() {
const all = root.history.slice(); const all = root.history.slice();
root.history = []; root.history = [];
+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)
}
}
+8
View File
@@ -7,6 +7,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 +19,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 +86,12 @@ ShellRoot {
AskPanel {} AskPanel {}
} }
Variants {
model: Quickshell.screens
Overview {}
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens