Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6addb504e6 | ||
|
|
32d5683fad | ||
|
|
ec76f8dec3 | ||
|
|
299291eaf6 | ||
|
|
add12ccc76 | ||
|
|
7b7cd3e575 | ||
|
|
31b57f4f8c | ||
|
|
373584f34e | ||
|
|
f7551006b3 | ||
|
|
dc2a8fc883 | ||
|
|
b69f4bcf0d |
No files matched your search
@@ -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,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
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
|
|||||||
@@ -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
|
|
||||||
Executable
+73
@@ -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
@@ -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
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
@@ -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,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.
@@ -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: ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 = "";
|
||||||
proc.running = false;
|
if (proc.running) {
|
||||||
|
root.stopping = true;
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+103
-16
@@ -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;
|
function dismissApp(app: string) {
|
||||||
}
|
const from = n => n.appName || "System";
|
||||||
notif.dismiss();
|
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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user