1 Commits
Author SHA1 Message Date
nvrl 6addb504e6 some qs fixes 2026-10-09 04:17:36 +02:00
26 changed files with 508 additions and 80 deletions

No files matched your search

+7 -3
View File
@@ -4,9 +4,10 @@
-- lua/, and each require() is its own scope: an error in one file does not stop -- lua/, and each require() is its own scope: an error in one file does not stop
-- the others from loading. -- the others from loading.
-- --
-- Order matters only in two places: palette must exist before anything paints -- Order matters only in three places: palette must exist before anything
-- with it, and monitors must exist before the lid handler binds to them. Both -- paints with it, monitors must exist before the lid handler binds to them,
-- are handled by the requires below, top to bottom. -- and game mode reads the look and the animations as configured. All three are
-- handled by the requires below, top to bottom.
-- what the desktop looks like -- what the desktop looks like
require("lua/palette") require("lua/palette")
@@ -27,6 +28,9 @@ require("lua/autostart")
require("lua/rules/windows") require("lua/rules/windows")
require("lua/rules/layers") require("lua/rules/layers")
-- after look and animations: it reads their values to put back
require("lua/gamemode")
-- how it is driven -- how it is driven
require("lua/binds/apps") require("lua/binds/apps")
require("lua/binds/windows") require("lua/binds/windows")
+3 -2
View File
@@ -4,8 +4,9 @@
-- Volume and brightness repeat when held. -- Volume and brightness repeat when held.
local held = { locked = true, repeating = true } local held = { locked = true, repeating = true }
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("fluxo vol up 5"), held) -- wpctl rather than `fluxo vol`, which goes up to 150%; `-l 1` stops at 100%.
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("fluxo vol down 5"), held) hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), held)
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), held)
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("fluxo vol mute"), held) hl.bind("XF86AudioMute", hl.dsp.exec_cmd("fluxo vol mute"), held)
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("fluxo mic mute"), held) hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("fluxo mic mute"), held)
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), held) hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), held)
+124
View File
@@ -0,0 +1,124 @@
-- Game mode: while a fullscreen game is on screen, neither the compositor nor
-- the shell spends anything on the desktop it covers.
--
-- on <=> some monitor shows a game, fullscreen, on its active workspace
--
-- Like lua/lid.lua, the answer is re-derived from live state on every trigger
-- and nothing is remembered, so a reload mid-game simply finds the game again.
--
-- A game is a window that says so through the content-type protocol
-- (gamescope does), or one whose class gives it away: Proton names every Steam
-- game steam_app_<id>. Add to GAME_CLASSES for anything else.
--
-- The shell is told through its IPC and pauses its own polling and animation
-- (quickshell/services/GameMode.qml).
-- Lua patterns, matched against the window class.
local GAME_CLASSES = {
"^steam_app_%d+$",
"^gamescope$",
}
-- What game mode sets. What it puts back is read from the config as loaded, so
-- lua/look.lua and lua/animations.lua stay the only place those are decided. A
-- reload resets every option before the config runs, so this never reads game
-- mode's own values back.
local GAME = {
-- Nothing under a fullscreen window is worth animating, and leaving the
-- game should be instant.
["animations.enabled"] = false,
["decoration.shadow.enabled"] = false,
-- Hand the game's frames straight to the display instead of compositing
-- them every refresh. Only while playing: with it on all the time, some
-- ordinary fullscreen apps flicker or show the wrong colours.
["render.direct_scanout"] = 1,
}
local NORMAL = {}
for option in pairs(GAME) do
NORMAL[option] = hl.get_config(option)
end
-- ------------------------------------------------------------- reading state
local function is_game(win)
if win.content_type == "game" then
return true
end
local class = win.class or ""
for _, pattern in ipairs(GAME_CLASSES) do
if class:match(pattern) then
return true
end
end
return false
end
-- `ignore` is a window being closed: during window.close it can still be
-- listed, and counting it would keep game mode on after the game has gone.
-- Fullscreen 2 is real fullscreen; a maximised game (1) still shows the hull.
local function playing(ignore)
for _, win in ipairs(hl.get_windows()) do
if win ~= ignore
and win.mapped
and win.fullscreen >= 2
and win.monitor
and win.monitor.active_workspace == win.workspace
and is_game(win)
then
return true
end
end
return false
end
-- ---------------------------------------------------------------- reconciling
-- A fresh config is the normal one, so only a game found at load needs
-- applying. The shell heard the last "false" before the reload.
local active = false
local function tell_shell()
hl.exec_cmd("qs ipc call shell gameMode " .. tostring(active))
end
local function reconcile(ignore)
local want = playing(ignore)
if want == active then
return
end
active = want
hl.config(active and GAME or NORMAL)
tell_shell()
end
-- ------------------------------------------------------------------ triggers
for _, event in ipairs({
"window.open",
"window.fullscreen",
"window.move_to_workspace",
-- Proton sets the class after the window opens.
"window.class",
"workspace.active",
"monitor.removed",
}) do
hl.on(event, function()
reconcile()
end)
end
hl.on("window.close", function(win)
reconcile(win)
end)
-- A shell started mid-game (Super+Shift+R, or a crash) begins with game mode
-- off. Its bar mapping is the sign it is up and listening.
hl.on("layer.opened", function(layer)
if active and layer.namespace == "takemi-bar" then
tell_shell()
end
end)
reconcile()
+5
View File
@@ -30,6 +30,11 @@ local GAMESCOPE = "^(gamescope)$"
-- }) -- })
hl.window_rule({ match = { class = "^$", title = "^$" }, no_blur = true }) hl.window_rule({ match = { class = "^$", title = "^$" }, no_blur = true })
-- Hold idle off while any window is fullscreen. Players do not all ask for
-- that themselves (Brave goes through D-Bus, which nothing here listens to),
-- and the shell's dim and lock (quickshell/services/Idle.qml) respect inhibitors.
hl.window_rule({ match = { class = ".*" }, idle_inhibit = "fullscreen" })
-- ========================================== -- ==========================================
-- FLOATING & CENTERED DIALOGS -- FLOATING & CENTERED DIALOGS
-- ========================================== -- ==========================================
+14
View File
@@ -119,6 +119,20 @@ 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 (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. 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 |
+1 -1
View File
@@ -170,7 +170,7 @@ BarPill {
Timer { Timer {
interval: eq.tickMs interval: eq.tickMs
running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) running: Media.playing && eq.visible && (eq.Window.window?.visible ?? true) && !GameMode.active
repeat: true repeat: true
onTriggered: eq.phase++ onTriggered: eq.phase++
// Restart the cycle from its trough, so playback always begins // Restart the cycle from its trough, so playback always begins
+1 -1
View File
@@ -130,7 +130,7 @@ PanelWindow {
statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel statusText: Math.round(Battery.percent) + "% · " + Battery.timeLabel
suppressTooltip: battPopout.detailActive suppressTooltip: battPopout.detailActive
alarm: Battery.critical alarm: Battery.critical
pulsing: Battery.critical pulsing: Battery.critical && !GameMode.active
onClicked: battPopout.togglePinned() onClicked: battPopout.togglePinned()
} }
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland import Quickshell.Hyprland
import "root:/config" import "root:/config"
import "root:/components" import "root:/components"
import "root:/services"
// Workspace indicator: soft pills that stretch open when focused. Occupied // Workspace indicator: soft pills that stretch open when focused. Occupied
// workspaces fill in, urgent ones snap to the alarm colour. // workspaces fill in, urgent ones snap to the alarm colour.
@@ -104,7 +105,7 @@ Item {
// Urgent is the one pip that has to be seen peripherally. // Urgent is the one pip that has to be seen peripherally.
SequentialAnimation on opacity { SequentialAnimation on opacity {
running: pip.urgent && !pip.focused running: pip.urgent && !pip.focused && !GameMode.active
loops: Animation.Infinite loops: Animation.Infinite
alwaysRunToEnd: true alwaysRunToEnd: true
NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad } NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad }
+5 -5
View File
@@ -206,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
@@ -265,10 +268,7 @@ Item {
} }
TapHandler { TapHandler {
onTapped: { onTapped: Notifs.invoke(root.notif, actionChip.modelData)
actionChip.modelData.invoke();
root.dismissed();
}
} }
} }
} }
+12 -1
View File
@@ -65,7 +65,18 @@ PanelWindow {
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
+1
View File
@@ -13,6 +13,7 @@ WlSessionLock {
id: lock id: lock
locked: Actions.lockRequested locked: Actions.lockRequested
onSecureChanged: Actions.lockSecure = lock.secure
WlSessionLockSurface { WlSessionLockSurface {
id: lockSurface id: lockSurface
+18 -5
View File
@@ -27,13 +27,26 @@ PanelWindow {
id: shade id: shade
anchors.fill: parent anchors.fill: parent
color: Theme.ink color: Theme.ink
opacity: Idle.dimmed ? 0.6 : 0 opacity: 0
Behavior on opacity { // States, not a Behavior: a Behavior whose duration is bound to
NumberAnimation { // Idle.dimmed can start before that binding updates, so waking up
duration: Idle.dimmed ? 8000 : Theme.durBase // would replay the slow fade in reverse.
easing.type: Idle.dimmed ? Easing.InOutSine : Easing.OutCubic states: State {
name: "dimmed"
when: Idle.dimmed
PropertyChanges { shade.opacity: 0.6 }
} }
transitions: [
Transition {
to: "dimmed"
NumberAnimation { property: "opacity"; duration: 8000; easing.type: Easing.InOutSine }
},
Transition {
from: "dimmed"
NumberAnimation { property: "opacity"; duration: Theme.durBase; easing.type: Easing.OutCubic }
} }
]
} }
} }
+33 -9
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Shapes import QtQuick.Shapes
import Quickshell import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import "root:/config" import "root:/config"
@@ -15,6 +16,10 @@ import "root:/services"
// with each notification an entry in it divided by its own header hairline. // with each notification an entry in it divided by its own header hairline.
// The drawer grows and shrinks with the entries and slides back behind the // The drawer grows and shrinks with the entries and slides back behind the
// frame when the last one goes. // frame when the last one goes.
//
// A fullscreen window covers the bar and the rail, so there is no frame to
// lower the drawer out of. Then it floats free in the corner as a plain
// rounded panel instead.
PanelWindow { PanelWindow {
id: root id: root
@@ -30,17 +35,20 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen)
readonly property bool floating: root.monitor?.activeWorkspace?.hasFullscreen ?? false
// One pixel over the bar and the rail, so the drawer covers the rim the // One pixel over the bar and the rail, so the drawer covers the rim the
// hull draws along both edges instead of sitting inside it. // hull draws along both edges instead of sitting inside it.
anchors { top: true; right: true } anchors { top: true; right: true }
margins { margins {
top: Theme.barHeight - 1 top: root.floating ? Theme.padM : Theme.barHeight - 1
right: Theme.railWidth - 1 right: root.floating ? Theme.padM : Theme.railWidth - 1
} }
readonly property real fillet: Theme.frameRadius readonly property real fillet: root.floating ? 0 : Theme.frameRadius
readonly property real corner: Theme.frameRadius readonly property real corner: Theme.frameRadius
readonly property real seam: 1 readonly property real seam: root.floating ? 0 : 1
readonly property real drawerWidth: 400 readonly property real drawerWidth: 400
// Height of the entries, eased, so the drawer grows rather than jumps. // Height of the entries, eased, so the drawer grows rather than jumps.
@@ -75,6 +83,10 @@ PanelWindow {
if (Actions.focusedScreenName() !== root.modelData.name) return; if (Actions.focusedScreenName() !== root.modelData.name) return;
root.live = [notif, ...root.live].slice(0, root.maxLive); root.live = [notif, ...root.live].slice(0, root.maxLive);
} }
// Closed by its app or from the log: take it off screen too.
function onNotifRetracted(notif) {
root.retire(notif);
}
} }
function retire(notif) { function retire(notif) {
@@ -99,6 +111,7 @@ PanelWindow {
// onto the rail through the bottom fillet — Popout's "topRight". // onto the rail through the bottom fillet — Popout's "topRight".
Shape { Shape {
id: silhouette id: silhouette
visible: !root.floating
anchors.fill: parent anchors.fill: parent
preferredRendererType: Shape.CurveRenderer preferredRendererType: Shape.CurveRenderer
@@ -125,6 +138,15 @@ PanelWindow {
} }
} }
Rectangle {
visible: root.floating
anchors.fill: parent
radius: root.corner
color: Theme.hull
border.width: 1
border.color: Theme.rim
}
Column { Column {
id: stack id: stack
x: root.fillet x: root.fillet
@@ -132,8 +154,11 @@ PanelWindow {
width: root.drawerWidth width: root.drawerWidth
bottomPadding: Theme.padS bottomPadding: Theme.padS
// A ScriptModel, not the bare list: it hands the Repeater only
// what changed, so a new toast does not rebuild the others and
// restart their fuses.
Repeater { Repeater {
model: root.live model: ScriptModel { values: [...root.live] }
Item { Item {
id: toast id: toast
@@ -146,10 +171,9 @@ PanelWindow {
id: card id: card
width: parent.width width: parent.width
notif: toast.modelData notif: toast.modelData
onDismissed: { // Closing it retracts it, which retires the toast;
Notifs.dismiss(toast.modelData); // nothing may run here after that, the entry is gone.
root.retire(toast.modelData); onDismissed: Notifs.dismiss(toast.modelData)
}
} }
// Countdown: a hairline draining along the entry's // Countdown: a hairline draining along the entry's
+41 -5
View File
@@ -5,12 +5,12 @@ import "root:/config"
import "root:/components" import "root:/components"
import "root:/services" import "root:/services"
// Link state, throughput, and the Wi-Fi picker. // Link state, throughput, the wired ports, and the Wi-Fi picker.
Popout { Popout {
id: root id: root
contentWidth: 360 contentWidth: 360
contentHeight: 300 contentHeight: 300 + (wired.visible ? wired.height + Theme.padM : 0)
// Scan only while the popout is actually on screen. // Scan only while the popout is actually on screen.
onShownChanged: Net.setScanning(shown) onShownChanged: Net.setScanning(shown)
@@ -76,13 +76,49 @@ Popout {
} }
} }
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 {
+36 -2
View File
@@ -168,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
@@ -195,6 +228,7 @@ 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 overview(): void { root.toggleOverview(); }
function overviewNext(): void { root.overviewStep(1); } function overviewNext(): void { root.overviewStep(1); }
function overviewPrev(): void { root.overviewStep(-1); } function overviewPrev(): void { root.overviewStep(-1); }
+15 -1
View File
@@ -107,8 +107,15 @@ Singleton {
root.lastCost = 0; root.lastCost = 0;
} }
// Set while a run is being killed on purpose, so its exit is not reported
// as a failure.
property bool stopping: false
function stop(): void { function stop(): void {
if (proc.running) proc.running = false; if (proc.running) {
root.stopping = true;
proc.running = false;
}
root.busy = false; root.busy = false;
} }
@@ -125,7 +132,10 @@ Singleton {
root.exitCode = 0; root.exitCode = 0;
root.stderrText = ""; root.stderrText = "";
if (proc.running) {
root.stopping = true;
proc.running = false; proc.running = false;
}
// stdin is closed explicitly. Inherited from the shell it never ends, // stdin is closed explicitly. Inherited from the shell it never ends,
// and the CLI waits three seconds for input that is never coming // and the CLI waits three seconds for input that is never coming
// before it starts on the question. // before it starts on the question.
@@ -236,6 +246,10 @@ Singleton {
} }
onExited: (code, status) => { onExited: (code, status) => {
if (root.stopping) {
root.stopping = false;
return;
}
root.busy = false; root.busy = false;
root.exitCode = code; root.exitCode = code;
if (code === 0 || root.error !== "") return; if (code === 0 || root.error !== "") return;
+3 -2
View File
@@ -49,9 +49,10 @@ Singleton {
onMicPercentChanged: micBumped() onMicPercentChanged: micBumped()
onMicMutedChanged: micBumped() onMicMutedChanged: micBumped()
// Capped at 100%: past it PipeWire only amplifies in software, and clips.
function setVolume(v: real) { function setVolume(v: real) {
if (!sink?.audio) return; if (!sink?.audio) return;
sink.audio.volume = Math.max(0, Math.min(1.5, v)); sink.audio.volume = Math.max(0, Math.min(1, v));
} }
function changeVolume(delta: real) { function changeVolume(delta: real) {
@@ -65,7 +66,7 @@ Singleton {
function setMicVolume(v: real) { function setMicVolume(v: real) {
if (!source?.audio) return; if (!source?.audio) return;
source.audio.volume = Math.max(0, Math.min(1.5, v)); source.audio.volume = Math.max(0, Math.min(1, v));
} }
function toggleMicMute() { function toggleMicMute() {
+27 -5
View File
@@ -37,6 +37,8 @@ Singleton {
const v = parseInt(text().trim()); const v = parseInt(text().trim());
if (!isNaN(v) && v !== root.raw) { if (!isNaN(v) && v !== root.raw) {
root.raw = v; root.raw = v;
// Settled, or moved by something else: step from here again.
if (!setter.running && root.pending < 0) root.target = -1;
root.bumped(); root.bumped();
} }
} }
@@ -45,24 +47,44 @@ Singleton {
// sysfs writes do not always fire inotify for backlight, so poll gently too. // sysfs writes do not always fire inotify for backlight, so poll gently too.
Timer { Timer {
interval: 1000 interval: 1000
running: true running: !GameMode.active
repeat: true repeat: true
onTriggered: currentFile.reload() onTriggered: currentFile.reload()
} }
// The level last asked for, until the backlight reports it. Steps build on
// it rather than on `percent`, which only catches up on the next read, so
// a fast scroll adds up instead of repeating one step.
property int target: -1
// A level asked for while a write is still running. Restarting the write
// would kill it half done, so the newest request waits and the rest drop.
property int pending: -1
Process { Process {
id: setter id: setter
onExited: {
currentFile.reload();
if (root.pending < 0) return;
const next = root.pending;
root.pending = -1;
root._write(next);
}
}
function _write(p: int) {
setter.command = ["brightnessctl", "-d", root.device, "-q", "set", p + "%"];
setter.running = true;
} }
function setPercent(p: real) { function setPercent(p: real) {
const clamped = Math.max(1, Math.min(100, Math.round(p))); const clamped = Math.max(1, Math.min(100, Math.round(p)));
setter.running = false; root.target = clamped;
setter.command = ["brightnessctl", "-d", root.device, "-q", "set", clamped + "%"]; if (setter.running) root.pending = clamped;
setter.running = true; else root._write(clamped);
} }
function change(delta: real) { function change(delta: real) {
setPercent(percent + delta); setPercent((root.target >= 0 ? root.target : percent) + delta);
} }
readonly property string icon: { readonly property string icon: {
+24
View File
@@ -0,0 +1,24 @@
pragma Singleton
import QtQuick
import Quickshell
// A fullscreen game is on screen. Hyprland decides (~/.config/hypr/lua/gamemode.lua)
// and says so with `qs ipc call shell gameMode true|false`.
//
// The game covers the hull, but a covered surface still renders: Hyprland keeps
// sending it frame callbacks, so anything that moves keeps redrawing at the
// panel's refresh rate with nobody watching. While a game runs the shell stops
// what it does on its own:
//
// Sys the fluxo stream, so no telemetry wakes the shell
// Brightness the backlight poll
// Media the position tick, and the equaliser on the bar
// Rail the critical battery's pulse
// Workspaces the urgent pip's pulse
// Notifs toasts, except critical ones; everything still reaches the log
//
// Anything you ask for yourself, the OSD included, still shows.
Singleton {
property bool active: false
}
+1 -1
View File
@@ -74,7 +74,7 @@ Singleton {
// MPRIS position is pull-only; tick it so the seek bar moves. // MPRIS position is pull-only; tick it so the seek bar moves.
Timer { Timer {
interval: 1000 interval: 1000
running: root.playing && root.positionSupported running: root.playing && root.positionSupported && !GameMode.active
repeat: true repeat: true
onTriggered: if (root.active) root.active.positionChanged() onTriggered: if (root.active) root.active.positionChanged()
} }
+27 -15
View File
@@ -16,10 +16,13 @@ Singleton {
readonly property var devices: Networking.devices?.values ?? [] readonly property var devices: Networking.devices?.values ?? []
readonly property var wifiDevices: devices.filter(d => d.type === DeviceType.Wifi) readonly property var wifiDevices: devices.filter(d => d.type === DeviceType.Wifi)
readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired) // Docker's veth pairs show up as wired too; NetworkManager leaves them
// unmanaged, and so does the popout.
readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired && d.nmManaged)
readonly property WifiDevice wifi: wifiDevices.length > 0 ? wifiDevices[0] : null readonly property WifiDevice wifi: wifiDevices.length > 0 ? wifiDevices[0] : null
readonly property WiredDevice wired: wiredDevices.length > 0 ? wiredDevices[0] : null // The port in use, or failing that the first one there is.
readonly property WiredDevice wired: wiredDevices.find(d => d.connected) ?? wiredDevices[0] ?? null
readonly property bool wiredUp: wired?.connected ?? false readonly property bool wiredUp: wired?.connected ?? false
@@ -55,14 +58,8 @@ Singleton {
if (!root.wifiEnabled) return "󰤭"; if (!root.wifiEnabled) return "󰤭";
if (!root.activeWifi) return "󰤯"; if (!root.activeWifi) return "󰤯";
const s = root.activeWifi.signalStrength ?? 0; const s = root.activeWifi.signalStrength ?? 0;
// Strength is only sampled while a scan is running, which the shell // Connected but not yet sampled should read as full, not dead.
// does on demand. Connected-but-unsampled should read as full, not dead. return s <= 0 ? "󰤨" : root.signalIcon(s);
if (s <= 0) return "󰤨";
if (s >= 80) return "󰤨";
if (s >= 60) return "󰤥";
if (s >= 40) return "󰤢";
if (s >= 20) return "󰤟";
return "󰤯";
} }
readonly property string label: { readonly property string label: {
@@ -72,14 +69,29 @@ Singleton {
return "Offline"; return "Offline";
} }
function signalIcon(strength: int): string { // Strength is a fraction, 0 to 1, not a percentage.
if (strength >= 80) return "󰤨"; function signalIcon(strength: real): string {
if (strength >= 60) return "󰤥"; if (strength >= 0.8) return "󰤨";
if (strength >= 40) return "󰤢"; if (strength >= 0.6) return "󰤥";
if (strength >= 20) return "󰤟"; if (strength >= 0.4) return "󰤢";
if (strength >= 0.2) return "󰤟";
return "󰤯"; return "󰤯";
} }
// "1 Gb/s" when up, otherwise why not. NetworkManager reports Mb/s.
function wiredDetail(d): string {
if (!d.hasLink) return d.name + " · no cable";
if (!d.connected) return d.name + " · disconnected";
const mbps = d.linkSpeed ?? 0;
if (mbps <= 0) return d.name;
return d.name + " · " + (mbps >= 1000 ? (mbps / 1000) + " Gb/s" : mbps + " Mb/s");
}
function toggleWired(d) {
if (d.connected) d.disconnect();
else if (d.network) d.network.connect();
}
function secured(n): bool { function secured(n): bool {
return n && n.security !== undefined return n && n.security !== undefined
&& n.security !== WifiSecurityType.Open && n.security !== WifiSecurityType.Open
+87 -19
View File
@@ -16,7 +16,7 @@ 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 // The history by sender, for the log: one entry per app, newest first
// inside each, the apps ordered by their newest notification. // inside each, the apps ordered by their newest notification.
@@ -24,14 +24,21 @@ Singleton {
const byApp = {}; const byApp = {};
const order = []; const order = [];
for (const n of root.history) { for (const n of root.history) {
if (!n) continue;
const app = n.appName || "System"; const app = n.appName || "System";
if (!(app in byApp)) { byApp[app] = []; order.push(app); } if (!(app in byApp)) { byApp[app] = []; order.push(app); }
byApp[app].push(n); byApp[app].push(n);
} }
return order.map(app => ({ app: app, items: byApp[app] })); 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
@@ -49,42 +56,103 @@ Singleton {
onNotification: notif => { onNotification: notif => {
// Hold the notification open until we drop it ourselves. // Hold the notification open until we drop it ourselves.
notif.tracked = true; notif.tracked = true;
root.adopt(notif);
root.history = [notif, ...root.history].slice(0, root.maxHistory); const next = [notif, ...root.history.filter(n => n)];
root.unread++; for (const n of next.slice(root.maxHistory)) root.release(n);
root.history = next.slice(0, root.maxHistory);
if (!root.dnd) root.popupRequested(notif); // A game holds every toast but a critical one; the log has them all.
const held = GameMode.active && notif.urgency !== NotificationUrgency.Critical;
if (!root.dnd && !held) root.popupRequested(notif);
} }
} }
// The server outlives a reload, and keeps what it was tracking; this
// object does not. Take those back into the log, already read.
Component.onCompleted: {
const kept = server.trackedNotifications.values.slice().sort((a, b) => b.id - a.id);
for (const n of kept) root.adopt(n);
root.history = kept.slice(0, root.maxHistory);
for (const n of kept.slice(root.maxHistory)) root.release(n);
root.markRead();
}
// An app may close its own notification at any time, and the server then
// destroys the object, leaving a null in the history. The lock keeps it
// alive, frozen as it was, until the log lets it go; closing it only takes
// it off screen.
function adopt(notif: Notification) {
root.holds[notif.id] = hold.createObject(root, { object: notif });
notif.closed.connect(() => {
root.retracted = Object.assign({}, root.retracted, { [notif.id]: true });
root.notifRetracted(notif);
});
}
Component {
id: hold
RetainableLock { locked: true }
}
// One lock per notification in the history, by id.
property var holds: ({})
// Ids of the notifications whose app has closed them. They stay in the log
// but can no longer be acted on.
property var retracted: ({})
signal popupRequested(Notification notif) signal popupRequested(Notification notif)
signal notifRetracted(Notification notif)
function isRetracted(notif: Notification): bool {
return !notif || root.retracted[notif.id] === true;
}
// Close a notification if its app has not, and let the object go.
function release(notif: Notification) {
if (!notif) return;
const id = notif.id;
if (!root.isRetracted(notif)) notif.dismiss();
root.holds[id]?.destroy();
delete root.holds[id];
if (id in root.retracted) {
const next = Object.assign({}, root.retracted);
delete next[id];
root.retracted = next;
}
}
function dismiss(notif: Notification) { function dismiss(notif: Notification) {
if (!notif) return; if (!notif) return;
const idx = root.history.indexOf(notif); root.history = root.history.filter(n => n && n !== notif);
if (idx !== -1) { root.release(notif);
const next = root.history.slice();
next.splice(idx, 1);
root.history = next;
}
notif.dismiss();
} }
function dismissApp(app: string) { function dismissApp(app: string) {
const gone = root.history.filter(n => (n.appName || "System") === app); const from = n => n.appName || "System";
root.history = root.history.filter(n => (n.appName || "System") !== app); const gone = root.history.filter(n => n && from(n) === app);
for (const n of gone) n.dismiss(); 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() {
+11 -1
View File
@@ -167,7 +167,17 @@ Singleton {
id: retry id: retry
interval: 2000 interval: 2000
repeat: false repeat: false
onTriggered: source.running = true onTriggered: source.running = !GameMode.active
}
// Nothing reads the meters while a game covers the bar. Drop the stream
// and pick it up again afterwards; the first lines back refresh every value.
Connections {
target: GameMode
function onActiveChanged() {
source.running = !GameMode.active;
}
} }
// ------------------------------------------------------------ formatting // ------------------------------------------------------------ formatting
+3 -1
View File
@@ -19,8 +19,10 @@ Singleton {
// The wallpaper to use when no particular monitor is being asked about. // The wallpaper to use when no particular monitor is being asked about.
property string path: "" property string path: ""
// Encoded segment by segment: a `#` or `?` in a file name would otherwise
// be read as the start of a fragment or a query.
readonly property url source: root.path !== "" readonly property url source: root.path !== ""
? Qt.resolvedUrl("file://" + root.path) ? "file://" + root.path.split("/").map(encodeURIComponent).join("/")
: Theme.fallbackWallpaper : Theme.fallbackWallpaper
signal changed() signal changed()
+7
View File
@@ -1,3 +1,10 @@
// The Samsung on DP-1 drops off the bus when its display powers down, so DPMS
// off (services/Idle.qml) removes an output under every window on it. With
// Qt's threaded render loop the render thread can still be swapping a surface
// the GUI thread just destroyed, which segfaults in Mesa and takes the lock
// screen down with it. The basic loop renders on the GUI thread, so it cannot.
//@ pragma Env QSG_RENDER_LOOP=basic
import QtQuick import QtQuick
import Quickshell import Quickshell
import "root:/config" import "root:/config"