Author SHA1 Message Date
nvrl 6addb504e6 some qs fixes 2026-10-09 04:17:36 +02:00
26 changed files with 510 additions and 82 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
-- the others from loading.
--
-- Order matters only in two places: palette must exist before anything paints
-- with it, and monitors must exist before the lid handler binds to them. Both
-- are handled by the requires below, top to bottom.
-- Order matters only in three places: palette must exist before anything
-- paints with it, monitors must exist before the lid handler binds to them,
-- 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
require("lua/palette")
@@ -27,6 +28,9 @@ require("lua/autostart")
require("lua/rules/windows")
require("lua/rules/layers")
-- after look and animations: it reads their values to put back
require("lua/gamemode")
-- how it is driven
require("lua/binds/apps")
require("lua/binds/windows")
+3 -2
View File
@@ -4,8 +4,9 @@
-- Volume and brightness repeat when held.
local held = { locked = true, repeating = true }
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("fluxo vol up 5"), held)
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("fluxo vol down 5"), held)
-- wpctl rather than `fluxo vol`, which goes up to 150%; `-l 1` stops at 100%.
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("XF86AudioMicMute", hl.dsp.exec_cmd("fluxo mic mute"), 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 })
-- 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
-- ==========================================
+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
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
| Module | Where | Left click | Right click | Middle | Scroll | Hover |
+1 -1
View File
@@ -170,7 +170,7 @@ BarPill {
Timer {
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
onTriggered: eq.phase++
// 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
suppressTooltip: battPopout.detailActive
alarm: Battery.critical
pulsing: Battery.critical
pulsing: Battery.critical && !GameMode.active
onClicked: battPopout.togglePinned()
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland
import "root:/config"
import "root:/components"
import "root:/services"
// Workspace indicator: soft pills that stretch open when focused. Occupied
// 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.
SequentialAnimation on opacity {
running: pip.urgent && !pip.focused
running: pip.urgent && !pip.focused && !GameMode.active
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 0.4; duration: 520; easing.type: Easing.InOutQuad }
+5 -5
View File
@@ -206,7 +206,10 @@ Item {
Row {
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
topPadding: 4
@@ -265,10 +268,7 @@ Item {
}
TapHandler {
onTapped: {
actionChip.modelData.invoke();
root.dismissed();
}
onTapped: Notifs.invoke(root.notif, actionChip.modelData)
}
}
}
+12 -1
View File
@@ -65,7 +65,18 @@ PanelWindow {
implicitHeight: root.bodyHeight + 1
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 {
active: root.open
+1
View File
@@ -13,6 +13,7 @@ WlSessionLock {
id: lock
locked: Actions.lockRequested
onSecureChanged: Actions.lockSecure = lock.secure
WlSessionLockSurface {
id: lockSurface
+19 -6
View File
@@ -27,13 +27,26 @@ PanelWindow {
id: shade
anchors.fill: parent
color: Theme.ink
opacity: Idle.dimmed ? 0.6 : 0
opacity: 0
Behavior on opacity {
NumberAnimation {
duration: Idle.dimmed ? 8000 : Theme.durBase
easing.type: Idle.dimmed ? Easing.InOutSine : Easing.OutCubic
}
// States, not a Behavior: a Behavior whose duration is bound to
// Idle.dimmed can start before that binding updates, so waking up
// would replay the slow fade in reverse.
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.Shapes
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import Quickshell.Services.Notifications
import "root:/config"
@@ -15,6 +16,10 @@ import "root:/services"
// 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
// 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 {
id: root
@@ -30,17 +35,20 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
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
// hull draws along both edges instead of sitting inside it.
anchors { top: true; right: true }
margins {
top: Theme.barHeight - 1
right: Theme.railWidth - 1
top: root.floating ? Theme.padM : Theme.barHeight - 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 seam: 1
readonly property real seam: root.floating ? 0 : 1
readonly property real drawerWidth: 400
// Height of the entries, eased, so the drawer grows rather than jumps.
@@ -75,6 +83,10 @@ PanelWindow {
if (Actions.focusedScreenName() !== root.modelData.name) return;
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) {
@@ -99,6 +111,7 @@ PanelWindow {
// onto the rail through the bottom fillet — Popout's "topRight".
Shape {
id: silhouette
visible: !root.floating
anchors.fill: parent
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 {
id: stack
x: root.fillet
@@ -132,8 +154,11 @@ PanelWindow {
width: root.drawerWidth
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 {
model: root.live
model: ScriptModel { values: [...root.live] }
Item {
id: toast
@@ -146,10 +171,9 @@ PanelWindow {
id: card
width: parent.width
notif: toast.modelData
onDismissed: {
Notifs.dismiss(toast.modelData);
root.retire(toast.modelData);
}
// Closing it retracts it, which retires the toast;
// nothing may run here after that, the entry is gone.
onDismissed: Notifs.dismiss(toast.modelData)
}
// Countdown: a hairline draining along the entry's
+41 -5
View File
@@ -5,12 +5,12 @@ import "root:/config"
import "root:/components"
import "root:/services"
// Link state, throughput, and the Wi-Fi picker.
// Link state, throughput, the wired ports, and the Wi-Fi picker.
Popout {
id: root
contentWidth: 360
contentHeight: 300
contentHeight: 300 + (wired.visible ? wired.height + Theme.padM : 0)
// Scan only while the popout is actually on screen.
onShownChanged: Net.setScanning(shown)
@@ -76,13 +76,49 @@ Popout {
}
}
SectionRule {
id: apsLabel
// One row per Ethernet port; a click brings the link up or down.
Column {
id: wired
anchors.top: controls.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
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 {
+36 -2
View File
@@ -168,8 +168,41 @@ Singleton {
function logout() { root.run(["uwsm", "stop"]); }
function reboot() { root.run(["systemctl", "reboot"]); }
function shutdown() { root.run(["systemctl", "poweroff"]); }
function suspend() { root.lock(); root.run(["systemctl", "suspend"]); }
function hibernate() { root.lock(); root.run(["systemctl", "hibernate"]); }
function suspend() { root.sleep(["systemctl", "suspend"]); }
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); }
// ------------------------------------------------------------- helpers
@@ -195,6 +228,7 @@ Singleton {
function toggleDnd(): void { Notifs.toggleDnd(); }
function clearNotifications(): void { Notifs.clearAll(); }
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); }
+16 -2
View File
@@ -107,8 +107,15 @@ Singleton {
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 {
if (proc.running) proc.running = false;
if (proc.running) {
root.stopping = true;
proc.running = false;
}
root.busy = false;
}
@@ -125,7 +132,10 @@ Singleton {
root.exitCode = 0;
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,
// and the CLI waits three seconds for input that is never coming
// before it starts on the question.
@@ -236,6 +246,10 @@ Singleton {
}
onExited: (code, status) => {
if (root.stopping) {
root.stopping = false;
return;
}
root.busy = false;
root.exitCode = code;
if (code === 0 || root.error !== "") return;
+3 -2
View File
@@ -49,9 +49,10 @@ Singleton {
onMicPercentChanged: micBumped()
onMicMutedChanged: micBumped()
// Capped at 100%: past it PipeWire only amplifies in software, and clips.
function setVolume(v: real) {
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) {
@@ -65,7 +66,7 @@ Singleton {
function setMicVolume(v: real) {
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() {
+27 -5
View File
@@ -37,6 +37,8 @@ Singleton {
const v = parseInt(text().trim());
if (!isNaN(v) && v !== root.raw) {
root.raw = v;
// Settled, or moved by something else: step from here again.
if (!setter.running && root.pending < 0) root.target = -1;
root.bumped();
}
}
@@ -45,24 +47,44 @@ Singleton {
// sysfs writes do not always fire inotify for backlight, so poll gently too.
Timer {
interval: 1000
running: true
running: !GameMode.active
repeat: true
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 {
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) {
const clamped = Math.max(1, Math.min(100, Math.round(p)));
setter.running = false;
setter.command = ["brightnessctl", "-d", root.device, "-q", "set", clamped + "%"];
setter.running = true;
root.target = clamped;
if (setter.running) root.pending = clamped;
else root._write(clamped);
}
function change(delta: real) {
setPercent(percent + delta);
setPercent((root.target >= 0 ? root.target : percent) + delta);
}
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.
Timer {
interval: 1000
running: root.playing && root.positionSupported
running: root.playing && root.positionSupported && !GameMode.active
repeat: true
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 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 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
@@ -55,14 +58,8 @@ Singleton {
if (!root.wifiEnabled) return "󰤭";
if (!root.activeWifi) return "󰤯";
const s = root.activeWifi.signalStrength ?? 0;
// Strength is only sampled while a scan is running, which the shell
// does on demand. Connected-but-unsampled should read as full, not dead.
if (s <= 0) return "󰤨";
if (s >= 80) return "󰤨";
if (s >= 60) return "󰤥";
if (s >= 40) return "󰤢";
if (s >= 20) return "󰤟";
return "󰤯";
// Connected but not yet sampled should read as full, not dead.
return s <= 0 ? "󰤨" : root.signalIcon(s);
}
readonly property string label: {
@@ -72,14 +69,29 @@ Singleton {
return "Offline";
}
function signalIcon(strength: int): string {
if (strength >= 80) return "󰤨";
if (strength >= 60) return "󰤥";
if (strength >= 40) return "󰤢";
if (strength >= 20) return "󰤟";
// Strength is a fraction, 0 to 1, not a percentage.
function signalIcon(strength: real): string {
if (strength >= 0.8) return "󰤨";
if (strength >= 0.6) return "󰤥";
if (strength >= 0.4) return "󰤢";
if (strength >= 0.2) 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 {
return n && n.security !== undefined
&& n.security !== WifiSecurityType.Open
+87 -19
View File
@@ -16,7 +16,7 @@ Singleton {
property list<Notification> history: []
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.
@@ -24,14 +24,21 @@ Singleton {
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
property int unread: 0
NotificationServer {
id: server
@@ -49,42 +56,103 @@ Singleton {
onNotification: notif => {
// Hold the notification open until we drop it ourselves.
notif.tracked = true;
root.adopt(notif);
root.history = [notif, ...root.history].slice(0, root.maxHistory);
root.unread++;
const next = [notif, ...root.history.filter(n => n)];
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 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) {
if (!notif) return;
const idx = root.history.indexOf(notif);
if (idx !== -1) {
const next = root.history.slice();
next.splice(idx, 1);
root.history = next;
}
notif.dismiss();
root.history = root.history.filter(n => n && n !== notif);
root.release(notif);
}
function dismissApp(app: string) {
const gone = root.history.filter(n => (n.appName || "System") === app);
root.history = root.history.filter(n => (n.appName || "System") !== app);
for (const n of gone) n.dismiss();
const from = n => n.appName || "System";
const gone = root.history.filter(n => n && from(n) === app);
root.history = root.history.filter(n => n && from(n) !== app);
for (const n of gone) root.release(n);
}
function clearAll() {
const all = root.history.slice();
const all = root.history.filter(n => n);
root.history = [];
root.unread = 0;
for (const n of all) n.dismiss();
for (const n of all) root.release(n);
}
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() {
+11 -1
View File
@@ -167,7 +167,17 @@ Singleton {
id: retry
interval: 2000
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
+3 -1
View File
@@ -19,8 +19,10 @@ Singleton {
// The wallpaper to use when no particular monitor is being asked about.
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 !== ""
? Qt.resolvedUrl("file://" + root.path)
? "file://" + root.path.split("/").map(encodeURIComponent).join("/")
: Theme.fallbackWallpaper
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 Quickshell
import "root:/config"