One shared QsMenuOpener meant tray-to-tray switching reassigned the handle and reloaded over DBus — the model went briefly empty, and no sizing gate downstream could hide that. Each tray icon now owns its opener, assigned when the icon appears, so every menu (Steam's slow one included) is fetched once at startup and held. Switching swaps one populated model for another in a single binding pass; contextMenu just repoints activeOpener. Hover prefetch deleted — nothing left to prefetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3638 lines
182 KiB
Nix
3638 lines
182 KiB
Nix
# settings/quickshell.nix — Quickshell desktop shell (bar, notifications, QML),
|
||
# split out of settings/hyprland.nix. The hyprland-side blur layer rule for
|
||
# the "quickshell-bar" namespace still lives there.
|
||
{ config, pkgs, lib, ... }:
|
||
let
|
||
isMacbook = config.networking.hostName == "FredOS-Macbook";
|
||
ui = import ./ui.nix; # shared corner-rounding knob
|
||
in
|
||
{
|
||
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
|
||
environment.systemPackages = with pkgs; [
|
||
quickshell
|
||
qt6.qt5compat # Qt5Compat.GraphicalEffects in Bar.qml
|
||
];
|
||
|
||
# Icon font for the shell (ligature-based: text "volume_up" renders the
|
||
# icon) — same font caelestia uses; nerd-font glyphs stay for terminals.
|
||
fonts.packages = [ pkgs.material-symbols ];
|
||
|
||
home-manager.users.fred = { config, lib, pkgs, osConfig, ... }:
|
||
let
|
||
c = config.lib.stylix.colors;
|
||
in {
|
||
systemd.user.services.quickshell = {
|
||
Unit = {
|
||
Description = "Quickshell desktop shell";
|
||
PartOf = [ "graphical-session.target" ];
|
||
After = [ "graphical-session.target" ];
|
||
};
|
||
Service = {
|
||
ExecStart = "${pkgs.quickshell}/bin/qs";
|
||
Restart = "always";
|
||
RestartSec = 2;
|
||
};
|
||
Install.WantedBy = [ "hyprland-session.target" ];
|
||
};
|
||
|
||
xdg.configFile = let
|
||
# Soft-reload quickshell in place: the process (and its DBus services —
|
||
# tray host, notification daemon) stays alive, so Electron apps with
|
||
# tray icons (vesktop) don't crash like they do on a hard restart.
|
||
# Falls back to a unit restart if the IPC socket isn't up.
|
||
qsRestart = ''
|
||
${pkgs.quickshell}/bin/qs ipc call shell reload 2>/dev/null || ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true
|
||
'';
|
||
wifiConnectScript = pkgs.writeShellScript "wifi-connect" ''
|
||
ssid="$1"
|
||
${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0
|
||
pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null)
|
||
[ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw"
|
||
'';
|
||
nmcli = "${pkgs.networkmanager}/bin/nmcli";
|
||
# Follow stylix's monospace choice so a font swap propagates to the bar
|
||
monoFont = osConfig.stylix.fonts.monospace.name;
|
||
# Shell chrome fragment shader: the bar, screen frame, dropdown panel
|
||
# and toast are one signed-distance field merged with a circular
|
||
# smooth-min (caelestia-style liquid junctions); the 2px border is the
|
||
# distance band just inside the surface, so it follows every fillet.
|
||
# Qt 6 requires shaders precompiled to .qsb — done here at build time.
|
||
chromeFragSrc = pkgs.writeText "shell-chrome.frag" ''
|
||
#version 440
|
||
|
||
layout(location = 0) in vec2 qt_TexCoord0;
|
||
layout(location = 0) out vec4 fragColor;
|
||
|
||
layout(std140, binding = 0) uniform buf {
|
||
mat4 qt_Matrix;
|
||
float qt_Opacity;
|
||
vec4 cutout; // cx, cy, hw, hh — rounded inner screen cutout
|
||
vec4 panel; // cx, cy, hw, hh — dropdown panel (hw <= 0: none)
|
||
vec4 toast; // cx, cy, hw, hh — toast (hw <= 0: none)
|
||
vec4 launcher; // cx, cy, hw, hh — bottom launcher (hw <= 0: none)
|
||
vec4 fillColor; // straight (non-premultiplied) rgba
|
||
vec4 borderColor;
|
||
vec2 res;
|
||
float cutoutR;
|
||
float panelR;
|
||
float meltK;
|
||
float borderW;
|
||
};
|
||
|
||
float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float r) {
|
||
vec2 d = abs(p - center) - halfSize + vec2(r);
|
||
return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - r;
|
||
}
|
||
|
||
// Circular smooth min: the blend fillet is a true circular arc of
|
||
// radius k tangent to both surfaces.
|
||
float smin(float a, float b, float k) {
|
||
return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0)));
|
||
}
|
||
|
||
void main() {
|
||
vec2 p = qt_TexCoord0 * res;
|
||
|
||
// Shell = bar band + frame band = everything outside the cutout
|
||
float d = -sdRoundedBox(p, cutout.xy, cutout.zw, cutoutR);
|
||
|
||
if (panel.z > 0.5)
|
||
d = smin(d, sdRoundedBox(p, panel.xy, panel.zw, panelR), meltK);
|
||
if (toast.z > 0.5)
|
||
d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK);
|
||
if (launcher.z > 0.5)
|
||
d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK);
|
||
|
||
float fw = fwidth(d);
|
||
// 1 inside the union, 0 outside (antialiased)
|
||
float edge = 1.0 - smoothstep(-fw, fw, d);
|
||
// 1 deeper than the border band, 0 within it
|
||
float inner = 1.0 - smoothstep(-borderW - fw, -borderW + fw, d);
|
||
|
||
vec4 c = mix(borderColor, fillColor, inner);
|
||
fragColor = vec4(c.rgb * c.a, c.a) * edge * qt_Opacity;
|
||
}
|
||
'';
|
||
chromeShader = pkgs.runCommand "shell-chrome.frag.qsb" {
|
||
nativeBuildInputs = [ pkgs.qt6.qtshadertools ];
|
||
} ''
|
||
qsb --glsl "300 es,330" --hlsl 50 --msl 12 -o $out ${chromeFragSrc}
|
||
'';
|
||
# 7-day forecast JSON from Open-Meteo (no API key). Location is
|
||
# auto-detected by IP via ipinfo.io, falling back to London.
|
||
weatherFetchScript = pkgs.writeShellScript "weather-fetch" ''
|
||
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
|
||
case "$loc" in
|
||
*,*) lat=''${loc%,*}; lon=''${loc#*,} ;;
|
||
*) lat=51.51; lon=-0.13 ;;
|
||
esac
|
||
${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset&timezone=auto&forecast_days=7"
|
||
'';
|
||
in {
|
||
"quickshell/qmldir" = {
|
||
onChange = qsRestart;
|
||
text = ''
|
||
singleton Theme 1.0 Theme.qml
|
||
singleton Commands 1.0 Commands.qml
|
||
Bar 1.0 Bar.qml
|
||
Lock 1.0 Lock.qml
|
||
'';
|
||
};
|
||
|
||
"quickshell/Theme.qml" = {
|
||
onChange = qsRestart;
|
||
text = ''
|
||
pragma Singleton
|
||
import QtQuick
|
||
|
||
QtObject {
|
||
readonly property color base00: "#${c.base00}"
|
||
readonly property color base01: "#${c.base01}"
|
||
readonly property color base02: "#${c.base02}"
|
||
readonly property color base03: "#${c.base03}"
|
||
readonly property color base04: "#${c.base04}"
|
||
readonly property color base05: "#${c.base05}"
|
||
readonly property color base08: "#${c.base08}"
|
||
readonly property color base09: "#${c.base09}"
|
||
readonly property color base0A: "#${c.base0A}"
|
||
readonly property color base0B: "#${c.base0B}"
|
||
readonly property color base0C: "#${c.base0C}"
|
||
readonly property color base0D: "#${c.base0D}"
|
||
readonly property color barBg: "#B3${c.base00}"
|
||
// Card surfaces are translucent so the compositor's bar-layer
|
||
// blur shows through — a lessened blur over the panel tint.
|
||
readonly property color cardBg: "#CC${c.base01}"
|
||
// Transparent base02: hover highlights fade to/from this so the
|
||
// colour animation stays in-hue instead of dipping through
|
||
// transparent *black* (which reads as an extra flash colour).
|
||
readonly property color base02t: "#00${c.base02}"
|
||
readonly property string fontFamily: "${monoFont}"
|
||
// Ligature-based icon font: text "volume_up" renders the icon
|
||
readonly property string iconFont: "Material Symbols Rounded"
|
||
// Matches hyprland general.border_size (col.inactive_border = base03)
|
||
readonly property int borderWidth: 2
|
||
// Screen frame band; sits inside hyprland's gaps_out (12)
|
||
readonly property int frameWidth: 6
|
||
|
||
// ── Layout / metric tokens (referenced, not hardcoded) ──
|
||
// Bar band thickness. The bar is a VERTICAL column on the left
|
||
// edge, so this is its width: it drives widget cell widths, the
|
||
// dropdown x-origin and the shader cutout geometry — change
|
||
// here, not in ~15 places. Wide enough for the stacked HH/mm
|
||
// clock, which is what sets the floor.
|
||
readonly property int barWidth: 46
|
||
// ── Rounding. ONE knob: settings/ui.nix `radius`. Everything
|
||
// below derives from it, and hyprland's window rounding reads
|
||
// the same value, so changing that one number rescales the
|
||
// roundness of the entire desktop.
|
||
readonly property int radius: ${toString ui.radius} // panels, dropdowns, screen cutout
|
||
// Concentric, NOT a factor: a card inside a panel needs the
|
||
// panel's radius MINUS the gap between them. Give it the same
|
||
// radius as its container and the inner corner is over-round
|
||
// for its size — the card reads as inflated/balloon-like,
|
||
// invisible at r=8 and obvious by r=32.
|
||
readonly property int radiusCard: radius - panelGap
|
||
readonly property int radiusSmall: Math.round(radius * 0.75) // pill buttons, workspace dots
|
||
readonly property int radiusTiny: Math.round(radius * 0.5) // hover rows, action chips
|
||
readonly property int cardPad: 8 // card inner padding (inset = 2×)
|
||
readonly property int panelGap: 8 // gap between panel border and cards (all dropdowns/menus/toast)
|
||
|
||
// ── Animation duration tokens (ms) ──
|
||
readonly property int animMorph: 280 // panel grow / slide (OutExpo)
|
||
readonly property int animContent: 200 // content fade / highlight move
|
||
readonly property int animFade: 120 // hover colour transitions
|
||
}
|
||
'';
|
||
};
|
||
|
||
"quickshell/Lock.qml" = {
|
||
onChange = qsRestart;
|
||
text = ''
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import Quickshell.Wayland
|
||
import Quickshell.Services.Pam
|
||
import QtQuick
|
||
import Qt5Compat.GraphicalEffects
|
||
|
||
// ext-session-lock screen, replacing hyprlock. Set `locked: true`
|
||
// and the compositor hands every screen to a LockSurface below
|
||
// until PAM says otherwise.
|
||
//
|
||
// PAM and state live in a Scope OUTSIDE WlSessionLock: its default
|
||
// property is the surface *component*, so a PamContext declared
|
||
// inside it is never instantiated — `pam` didn't resolve and
|
||
// submit() threw, which is why enter/the arrow did nothing.
|
||
Scope {
|
||
id: root
|
||
|
||
property alias locked: lock.locked
|
||
property string password: ""
|
||
property bool failed: false
|
||
|
||
function submit(pw) {
|
||
if (pam.active || pw === "") return;
|
||
failed = false;
|
||
password = pw;
|
||
pam.start();
|
||
}
|
||
|
||
// greetd autologs us in, so lock as soon as the shell comes up:
|
||
// the lockscreen is the login prompt. mkdir is the atomic
|
||
// once-per-session test — XDG_RUNTIME_DIR dies with the
|
||
// session, so quickshell restarts (rebuilds) don't re-lock.
|
||
Process {
|
||
running: true
|
||
command: ["mkdir", Quickshell.env("XDG_RUNTIME_DIR") + "/qs-boot-lock"]
|
||
onExited: code => { if (code === 0) lock.locked = true; }
|
||
}
|
||
|
||
PamContext {
|
||
id: pam
|
||
// NixOS ships a "login" pam service; pam_unix verifies the
|
||
// user's own password via the unix_chkpwd setuid wrapper.
|
||
config: "login"
|
||
onPamMessage: {
|
||
if (this.responseRequired) this.respond(root.password);
|
||
}
|
||
onCompleted: result => {
|
||
root.password = "";
|
||
if (result === PamResult.Success) lock.locked = false;
|
||
else root.failed = true;
|
||
}
|
||
}
|
||
|
||
WlSessionLock {
|
||
id: lock
|
||
|
||
WlSessionLockSurface {
|
||
id: surface
|
||
color: "black"
|
||
|
||
Image {
|
||
id: wall
|
||
anchors.fill: parent
|
||
source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png"
|
||
// cache:false — soft reload (qs ipc reload) keeps the process
|
||
// alive, and the QML pixmap cache is keyed by URL, so a new
|
||
// wallpaper behind the same path never shows without this.
|
||
cache: false
|
||
fillMode: Image.PreserveAspectCrop
|
||
visible: false
|
||
}
|
||
FastBlur {
|
||
anchors.fill: wall
|
||
source: wall
|
||
radius: 48
|
||
}
|
||
Rectangle { anchors.fill: parent; color: "black"; opacity: 0.4 }
|
||
|
||
SystemClock {
|
||
id: lockClock
|
||
precision: SystemClock.Minutes
|
||
}
|
||
|
||
Column {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.verticalCenterOffset: -60
|
||
spacing: 8
|
||
|
||
Text {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: lockClock.date.toLocaleTimeString(Qt.locale(), "HH:mm")
|
||
color: Theme.base05
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: 72
|
||
font.weight: Font.Medium
|
||
}
|
||
Text {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: lockClock.date.toLocaleDateString(Qt.locale(), "dddd, d MMMM")
|
||
color: Theme.base04
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: 16
|
||
}
|
||
|
||
Item { width: 1; height: 32 }
|
||
|
||
// Password pill: dots for typed chars; border tracks
|
||
// state (accent while checking, red on failure).
|
||
Rectangle {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
width: 280
|
||
height: 44
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
border.width: Theme.borderWidth
|
||
border.color: pam.active ? Theme.base0D
|
||
: root.failed ? Theme.base08
|
||
: Theme.base03
|
||
Behavior on border.color { ColorAnimation { duration: Theme.animFade } }
|
||
|
||
Row {
|
||
anchors.centerIn: parent
|
||
spacing: 8
|
||
Repeater {
|
||
model: Math.min(pwInput.text.length, 24)
|
||
Rectangle {
|
||
width: 8; height: 8; radius: 4
|
||
color: Theme.base05
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
visible: pwInput.text === "" && root.failed
|
||
text: "wrong password"
|
||
color: Theme.base08
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: 13
|
||
}
|
||
|
||
TextInput {
|
||
id: pwInput
|
||
anchors.fill: parent
|
||
opacity: 0
|
||
enabled: !pam.active
|
||
echoMode: TextInput.Password
|
||
focus: true
|
||
function go() {
|
||
root.submit(text);
|
||
text = "";
|
||
}
|
||
// Explicit key handlers: accepted() doesn't
|
||
// fire reliably on the lock surface.
|
||
Keys.onReturnPressed: go()
|
||
Keys.onEnterPressed: go()
|
||
onAccepted: go()
|
||
Keys.onEscapePressed: text = ""
|
||
}
|
||
|
||
// Visible confirm button (after TextInput: on top)
|
||
Rectangle {
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 32; height: 32; radius: 16
|
||
color: confirmMa.containsMouse ? Theme.base02 : "transparent"
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
visible: pwInput.text.length > 0
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: "arrow_forward"
|
||
font.family: Theme.iconFont
|
||
font.pixelSize: 18
|
||
color: Theme.base0D
|
||
}
|
||
MouseArea {
|
||
id: confirmMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: pwInput.go()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Component.onCompleted: pwInput.forceActiveFocus()
|
||
}
|
||
}
|
||
}
|
||
'';
|
||
};
|
||
|
||
"quickshell/Commands.qml" = {
|
||
onChange = qsRestart;
|
||
text = ''
|
||
pragma Singleton
|
||
import QtQuick
|
||
|
||
QtObject {
|
||
readonly property string nmcli: "${nmcli}"
|
||
readonly property string wifiConnect: "${wifiConnectScript}"
|
||
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
|
||
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
|
||
readonly property string weatherFetch: "${weatherFetchScript}"
|
||
// Recoloured to the theme accent at draw time (ColorOverlay),
|
||
// so the stock blue/grey snowflake artwork is fine here.
|
||
readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"
|
||
}
|
||
'';
|
||
};
|
||
|
||
"quickshell/shell.qml" = {
|
||
onChange = qsRestart;
|
||
text = ''
|
||
//@ pragma UseQApplication
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import Quickshell.Wayland
|
||
import Quickshell.Services.Notifications
|
||
import QtQuick
|
||
|
||
ShellRoot {
|
||
id: root
|
||
property var latestNotification: null
|
||
property var mainBar: null
|
||
signal notificationReceived()
|
||
|
||
// Bound in hyprland.nix: Super+R → launcher (bottom of the
|
||
// bar window), Super+L → session menu (right edge).
|
||
IpcHandler {
|
||
target: "launcher"
|
||
function toggle(): void { if (root.mainBar) root.mainBar.toggleLauncher(); }
|
||
function powermenu(): void { if (root.mainBar) root.mainBar.toggleSession(); }
|
||
}
|
||
|
||
// Soft reload, used by the nix onChange hook — keeps the
|
||
// process and its DBus services (tray host) alive.
|
||
IpcHandler {
|
||
target: "shell"
|
||
function reload(): void { Quickshell.reload(false); }
|
||
}
|
||
|
||
// Session lock (replaces hyprlock). `qs ipc call lock lock`
|
||
// from hypridle / loginctl lock-session; the session menu
|
||
// calls root.lock() directly. Idempotent while locked.
|
||
Lock { id: lockScreen }
|
||
function lock() { lockScreen.locked = true; }
|
||
IpcHandler {
|
||
target: "lock"
|
||
function lock(): void { root.lock(); }
|
||
// Escape hatch from a VT: qs ipc call lock unlock
|
||
function unlock(): void { lockScreen.locked = false; }
|
||
}
|
||
|
||
// Screenshot pin: the Shift+Super+S keybind brackets hyprshot
|
||
// with pin/unpin so the focus grab is suspended while slurp
|
||
// grabs input — otherwise the open menu would close like any
|
||
// click-outside. Self-heals via a watchdog if unpin is missed.
|
||
IpcHandler {
|
||
target: "screenshot"
|
||
function pin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(true); }
|
||
function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); }
|
||
}
|
||
|
||
// ── OSD brightness (replaces swayosd). Volume needs nothing
|
||
// here — the bar watches Pipewire directly. Backlight has no
|
||
// watchable change event, so the keybind routes through here:
|
||
// brightnessctl -m prints the resulting level, which becomes
|
||
// the OSD value. Bars pick this up on the focused monitor.
|
||
signal osdBrightness(string icon, real value)
|
||
|
||
Process {
|
||
id: brightProc
|
||
property string osdIcon: "brightness_6"
|
||
stdout: StdioCollector {
|
||
// machine-readable: device,class,current,percent%,max
|
||
onStreamFinished: {
|
||
let f = text.trim().split("\n")[0].split(",");
|
||
if (f.length < 5) return;
|
||
root.osdBrightness(brightProc.osdIcon, parseInt(f[3]) / 100);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setBrightness(args, icon) {
|
||
// Key repeat outpaces the process; dropping a step is
|
||
// invisible, rewriting a live command is not.
|
||
if (brightProc.running) return;
|
||
brightProc.osdIcon = icon;
|
||
brightProc.command = ["brightnessctl", "-m"].concat(args);
|
||
brightProc.running = true;
|
||
}
|
||
|
||
IpcHandler {
|
||
target: "osd"
|
||
// floor 1%: `set 0%` turns the panel fully off
|
||
function brightnessUp(): void { root.setBrightness(["set", "5%+"], "brightness_6"); }
|
||
function brightnessDown(): void { root.setBrightness(["-n", "1", "set", "5%-"], "brightness_6"); }
|
||
function kbdUp(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%+"], "keyboard"); }
|
||
function kbdDown(): void { root.setBrightness(["-d", "smc::kbd_backlight", "set", "10%-"], "keyboard"); }
|
||
}
|
||
|
||
// ── Night light: quickshell owns the schedule, the hyprsunset
|
||
// daemon just applies the gamma (hyprctl hyprsunset …).
|
||
// sunrise/sunset default to 7:00/21:00 and are replaced with
|
||
// real times by the bar's open-meteo weather fetch.
|
||
property int sunriseMins: 7 * 60
|
||
property int sunsetMins: 21 * 60
|
||
readonly property var nightlight: nlState
|
||
|
||
FileView {
|
||
id: nlFile
|
||
path: Quickshell.env("HOME") + "/.local/state/quickshell-nightlight.json"
|
||
printErrors: false
|
||
adapter: JsonAdapter {
|
||
id: nlState
|
||
property bool autoOn: true
|
||
property real warmth: 0.5 // 0 = 6500K … 1 = 2500K
|
||
}
|
||
onLoaded: root.applyNightlight()
|
||
onLoadFailed: writeAdapter() // first run: create with defaults
|
||
}
|
||
|
||
function setNightlight(autoOn, warmth) {
|
||
nlState.autoOn = autoOn;
|
||
nlState.warmth = warmth;
|
||
applyNightlight();
|
||
nlFile.writeAdapter();
|
||
}
|
||
|
||
function applyNightlight() {
|
||
let w = nlState.warmth;
|
||
if (nlState.autoOn) {
|
||
let now = new Date();
|
||
let mins = now.getHours() * 60 + now.getMinutes();
|
||
if (mins >= sunriseMins && mins < sunsetMins) w = 0; // daytime
|
||
}
|
||
nlApply.command = w < 0.02
|
||
? ["hyprctl", "hyprsunset", "identity"]
|
||
: ["hyprctl", "hyprsunset", "temperature", String(Math.round(6500 - w * 4000))];
|
||
nlApply.running = true;
|
||
}
|
||
|
||
Process { id: nlApply }
|
||
|
||
Timer {
|
||
// Re-assert once a minute: crosses the sunrise/sunset
|
||
// boundaries and heals a hyprsunset daemon that (re)started
|
||
// after us.
|
||
interval: 60000; running: true; repeat: true; triggeredOnStart: true
|
||
onTriggered: root.applyNightlight()
|
||
}
|
||
|
||
NotificationServer {
|
||
id: _notifServer
|
||
bodySupported: true
|
||
actionsSupported: true
|
||
imageSupported: true
|
||
persistenceSupported: true
|
||
keepOnReload: true
|
||
onNotification: (notification) => {
|
||
notification.tracked = true;
|
||
root.latestNotification = notification;
|
||
root.notificationReceived();
|
||
}
|
||
}
|
||
|
||
// Wallpaper — replaces the hyprpaper daemon.
|
||
Variants {
|
||
model: Quickshell.screens
|
||
|
||
PanelWindow {
|
||
required property var modelData
|
||
screen: modelData
|
||
WlrLayershell.layer: WlrLayer.Background
|
||
exclusionMode: ExclusionMode.Ignore
|
||
anchors { top: true; bottom: true; left: true; right: true }
|
||
color: "black"
|
||
|
||
Image {
|
||
anchors.fill: parent
|
||
source: "file://" + Quickshell.env("HOME") + "/.local/share/backgrounds/wallpaper.png"
|
||
cache: false // same-URL pixmap cache survives soft reload
|
||
fillMode: Image.PreserveAspectCrop
|
||
}
|
||
}
|
||
}
|
||
|
||
Variants {
|
||
model: Quickshell.screens
|
||
|
||
Bar {
|
||
notifServer: _notifServer
|
||
shellRoot: root
|
||
}
|
||
}
|
||
}
|
||
'';
|
||
};
|
||
|
||
"quickshell/Bar.qml" = {
|
||
onChange = qsRestart;
|
||
text = ''
|
||
import Quickshell
|
||
import Quickshell.Hyprland
|
||
import Quickshell.Wayland
|
||
import Quickshell.Services.SystemTray
|
||
import Quickshell.Services.Notifications
|
||
import Quickshell.Services.Pipewire
|
||
import Quickshell.Services.UPower
|
||
import Quickshell.Services.Mpris
|
||
import Quickshell.Widgets
|
||
import Quickshell.Io
|
||
import QtQuick
|
||
import Qt5Compat.GraphicalEffects
|
||
|
||
PanelWindow {
|
||
id: bar
|
||
required property var modelData
|
||
required property NotificationServer notifServer
|
||
required property var shellRoot
|
||
screen: modelData
|
||
WlrLayershell.namespace: "quickshell-bar"
|
||
// OnDemand + HyprlandFocusGrab is the working combination
|
||
// (caelestia's): the grab redirects focus to this window and
|
||
// OnDemand lets the layer surface accept it. Exclusive fights
|
||
// the grab — it self-clears and instantly closes the panel.
|
||
WlrLayershell.keyboardFocus: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||
|
||
// Vertical bar on the left edge. The surface still covers the
|
||
// whole screen (the frame band and every panel are drawn into
|
||
// it); only the reserved strip is the bar itself.
|
||
anchors {
|
||
top: true
|
||
left: true
|
||
bottom: true
|
||
}
|
||
|
||
implicitWidth: bar.screen.width
|
||
exclusiveZone: Theme.barWidth
|
||
color: "transparent"
|
||
|
||
mask: Region {
|
||
item: barBgRect
|
||
Region {
|
||
x: activeDropdown ? activeDropdown.x : 0
|
||
y: activeDropdown ? activeDropdown.y : 0
|
||
width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0
|
||
height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0
|
||
}
|
||
Region {
|
||
x: toastItem.visible ? toastItem.x : 0
|
||
y: toastItem.visible ? toastItem.y : 0
|
||
width: toastItem.visible ? toastItem.width : 0
|
||
height: toastItem.visible ? toastItem.height : 0
|
||
}
|
||
Region {
|
||
x: launcherPanel.visible ? launcherPanel.x : 0
|
||
y: launcherPanel.visible ? launcherPanel.y : 0
|
||
width: launcherPanel.visible ? launcherPanel.width : 0
|
||
height: launcherPanel.visible ? launcherPanel.height : 0
|
||
}
|
||
}
|
||
|
||
Item {
|
||
id: barBgRect
|
||
anchors.top: parent.top
|
||
anchors.left: parent.left
|
||
anchors.bottom: parent.bottom
|
||
width: Theme.barWidth
|
||
}
|
||
|
||
// Register the primary bar so shell.qml's IPC handler can
|
||
// reach the session menu.
|
||
Component.onCompleted: {
|
||
if (bar.screen === Quickshell.screens[0])
|
||
bar.shellRoot.mainBar = bar;
|
||
}
|
||
|
||
function toggleSession() {
|
||
bar.toggleDropdown(sessionMenu, function() {
|
||
let pos = powerWidget.mapToItem(bar.contentItem, 0, powerWidget.height / 2);
|
||
sessionMenu.dropdownY = pos.y;
|
||
});
|
||
}
|
||
|
||
function toggleLauncher() {
|
||
launcherPanel.toggle();
|
||
}
|
||
|
||
// ── Shared base text types: default the shell's two fonts so
|
||
// no widget repeats `font.family`. Size/colour/weight are
|
||
// overridable per use (these are just the common defaults).
|
||
component SText: Text {
|
||
color: Theme.base05
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: 13
|
||
}
|
||
component SIcon: Text {
|
||
color: Theme.base05
|
||
font.family: Theme.iconFont
|
||
font.pixelSize: 16
|
||
}
|
||
|
||
// ── VolIcon: a slider's volume glyph that toggles its audio
|
||
// node's mute on click. Glyph reflects the mute state; pair it
|
||
// with a fill that greys when `audioNode.muted`.
|
||
component VolIcon: SIcon {
|
||
property var audioNode: null
|
||
text: (audioNode && audioNode.muted) ? "volume_off" : "volume_up"
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: if (audioNode) audioNode.muted = !audioNode.muted
|
||
}
|
||
}
|
||
|
||
// ── HoverRow: a rounded clickable row that owns the shared
|
||
// base02 hover-fade + pointer cursor. Drop content inside and
|
||
// handle `onClicked`; override `radius` for non-radiusTiny rows.
|
||
component HoverRow: Rectangle {
|
||
default property alias rowData: _hrContent.data
|
||
signal clicked()
|
||
radius: Theme.radiusTiny
|
||
color: _hrMa.containsMouse ? Theme.base02 : Theme.base02t
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
Item { id: _hrContent; anchors.fill: parent }
|
||
MouseArea {
|
||
id: _hrMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: parent.clicked()
|
||
}
|
||
}
|
||
|
||
// ── Card: the rounded base01 section surface used by every
|
||
// dropdown. Children flow into a padded auto-height column,
|
||
// so callers just set `width` and drop content in.
|
||
component Card: Rectangle {
|
||
default property alias cardData: _cardCol.data
|
||
property alias cardSpacing: _cardCol.spacing
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
implicitHeight: _cardCol.height + 2 * Theme.cardPad
|
||
Column {
|
||
id: _cardCol
|
||
anchors.top: parent.top
|
||
anchors.topMargin: Theme.cardPad
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
width: parent.width - 2 * Theme.cardPad
|
||
spacing: 8
|
||
}
|
||
}
|
||
|
||
// ── PillSlider: slim rounded track + fill with a full-height
|
||
// invisible hit area. `value` is 0..1; `moved(v)` fires on drag.
|
||
component PillSlider: Item {
|
||
property real value: 0
|
||
property color fillColor: Theme.base0D
|
||
property real trackH: 6
|
||
signal moved(real v)
|
||
height: 20
|
||
Rectangle {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: parent.width
|
||
height: parent.trackH
|
||
radius: parent.trackH / 2
|
||
color: Theme.base02
|
||
}
|
||
Rectangle {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: parent.value > 0 ? Math.max(parent.trackH, parent.value * parent.width) : 0
|
||
height: parent.trackH
|
||
radius: parent.trackH / 2
|
||
color: parent.fillColor
|
||
Behavior on width { NumberAnimation { duration: 80 } }
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
function set(mouse) { parent.moved(Math.max(0, Math.min(1, mouse.x / width))); }
|
||
onPressed: (mouse) => set(mouse)
|
||
onPositionChanged: (mouse) => { if (pressed) set(mouse); }
|
||
}
|
||
}
|
||
|
||
// ── ToggleSwitch: small on/off pill.
|
||
component ToggleSwitch: Rectangle {
|
||
property bool on: false
|
||
signal toggled(bool v)
|
||
width: 34; height: 18; radius: 9
|
||
color: on ? Theme.base0D : Theme.base02
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
Rectangle {
|
||
width: 12; height: 12; radius: 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
x: parent.on ? parent.width - width - 3 : 3
|
||
color: Theme.base05
|
||
Behavior on x { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } }
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: parent.toggled(!parent.on)
|
||
}
|
||
}
|
||
|
||
// ── NightlightCard: colour-temperature control backed by
|
||
// shell.qml's night-light state. Auto on: follows
|
||
// sunrise/sunset and the slider sets the *warmest* colour the
|
||
// night reaches. Auto off: the slider is the temperature, now.
|
||
component NightlightCard: Card {
|
||
id: _nlc
|
||
cardSpacing: 6
|
||
property var nl: bar.shellRoot.nightlight
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 20
|
||
Row {
|
||
spacing: 6
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
SIcon { anchors.verticalCenter: parent.verticalCenter; text: "nightlight"; font.pixelSize: 15 }
|
||
SText { anchors.verticalCenter: parent.verticalCenter; text: "Night Light"; font.weight: Font.Medium }
|
||
}
|
||
Row {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 6
|
||
SText { anchors.verticalCenter: parent.verticalCenter; text: "Auto"; color: Theme.base04; font.pixelSize: 11 }
|
||
ToggleSwitch {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
on: _nlc.nl.autoOn
|
||
onToggled: (v) => bar.shellRoot.setNightlight(v, _nlc.nl.warmth)
|
||
}
|
||
}
|
||
}
|
||
|
||
SText {
|
||
text: _nlc.nl.autoOn ? "Warmest colour (at night)" : "Colour temperature"
|
||
color: Theme.base04
|
||
font.pixelSize: 10
|
||
}
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
PillSlider {
|
||
width: parent.width - nlTempLabel.width - 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
value: _nlc.nl.warmth
|
||
fillColor: Theme.base09
|
||
onMoved: (v) => bar.shellRoot.setNightlight(_nlc.nl.autoOn, v)
|
||
}
|
||
SText {
|
||
id: nlTempLabel
|
||
width: 38
|
||
text: Math.round(6500 - _nlc.nl.warmth * 4000) + "K"
|
||
font.pixelSize: 11
|
||
horizontalAlignment: Text.AlignRight
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── NotifContent: summary + body + action chips for one
|
||
// notification, shared by the calendar list and the toast.
|
||
// Callers supply the container, dismiss button and sizes.
|
||
component NotifContent: Column {
|
||
id: _nc
|
||
property var notif
|
||
property int summarySize: 12
|
||
property int bodySize: 11
|
||
property int bodyLines: 3
|
||
property color chipBg: Theme.base01
|
||
property color chipBgHover: Theme.base02
|
||
property color chipBorder: Theme.base02
|
||
signal actionInvoked()
|
||
spacing: 2
|
||
|
||
// App header: icon + name of the sending app, when known.
|
||
Row {
|
||
width: parent.width
|
||
spacing: 4
|
||
visible: _nc.notif && (_nc.notif.appName !== "" || _appIcon.source != "")
|
||
Image {
|
||
id: _appIcon
|
||
width: 14; height: 14
|
||
sourceSize.width: 14; sourceSize.height: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
source: _nc.notif && _nc.notif.appIcon ? Quickshell.iconPath(_nc.notif.appIcon, true) : ""
|
||
visible: source != ""
|
||
}
|
||
SText {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: _nc.notif ? _nc.notif.appName : ""
|
||
color: Theme.base04
|
||
font.pixelSize: 10
|
||
elide: Text.ElideRight
|
||
width: parent.width - (_appIcon.visible ? 18 : 0)
|
||
}
|
||
}
|
||
|
||
SText {
|
||
width: parent.width
|
||
text: _nc.notif ? (_nc.notif.summary || _nc.notif.appName) : ""
|
||
font.pixelSize: _nc.summarySize
|
||
font.weight: Font.Medium
|
||
elide: Text.ElideRight
|
||
}
|
||
SText {
|
||
width: parent.width
|
||
text: _nc.notif ? (_nc.notif.body || "") : ""
|
||
color: Theme.base04
|
||
font.pixelSize: _nc.bodySize
|
||
elide: Text.ElideRight
|
||
maximumLineCount: _nc.bodyLines
|
||
wrapMode: Text.Wrap
|
||
// ponytail: hyprshot's body is a noisy file path; the
|
||
// "Screenshot saved" summary + the preview say enough.
|
||
visible: text !== "" && !(_nc.notif && _nc.notif.appName === "Hyprshot")
|
||
}
|
||
Row {
|
||
spacing: 4
|
||
visible: _nc.notif && _nc.notif.actions.length > 0
|
||
Repeater {
|
||
model: _nc.notif ? _nc.notif.actions : []
|
||
Rectangle {
|
||
required property var modelData
|
||
width: _at.width + 12
|
||
height: _at.height + 6
|
||
radius: Theme.radiusTiny
|
||
color: _ama.containsMouse ? _nc.chipBgHover : _nc.chipBg
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
border.width: 1
|
||
border.color: _nc.chipBorder
|
||
SText { id: _at; anchors.centerIn: parent; text: modelData.text; font.pixelSize: 10 }
|
||
MouseArea {
|
||
id: _ama
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: { modelData.invoke(); _nc.actionInvoked(); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Shell chrome: bar, frame, panel and toast rendered as
|
||
// ONE signed-distance field (caelestia-style). Surfaces merge
|
||
// via circular smooth-min, and the 2px border is the distance
|
||
// band just inside the boundary — borders flow through every
|
||
// junction fillet by construction, so all of the previous
|
||
// ears / border-gaps / melt geometry lives in the math now.
|
||
ShaderEffect {
|
||
anchors.fill: parent
|
||
// Panels grow rightward out of the bar column, so the
|
||
// cross-axis is now vertical: the 8px inset that used to
|
||
// be left/right is top/bottom.
|
||
readonly property real rawTop: chrome.y + 8
|
||
readonly property real rawBottom: chrome.y + chrome.height - 8
|
||
// How deep a surface buries itself in the band it merges
|
||
// with. This MUST be >= the panel corner radius. A rounded
|
||
// corner curves away from the edge over exactly panelR
|
||
// pixels, so burying less than that leaves part of the
|
||
// curve sticking out past the joint: the outline pinches in
|
||
// where the corner turns, then the fillet bulges back out —
|
||
// the "ballooned at the connection point" look. Bury the
|
||
// whole corner and only straight edges reach the junction,
|
||
// which is what smin is meant to fillet.
|
||
readonly property real meltInto: Theme.radius
|
||
readonly property real meltTop: Theme.frameWidth - meltInto
|
||
readonly property real meltBot: bar.height - Theme.frameWidth + meltInto
|
||
// Trigger distance is about proximity, not burial depth.
|
||
readonly property real snapDist: Theme.panelGap + 4
|
||
readonly property real panelTop: rawTop <= Theme.frameWidth + snapDist
|
||
? meltTop : rawTop
|
||
readonly property real panelBottom: rawBottom >= bar.height - Theme.frameWidth - snapDist
|
||
? meltBot : rawBottom
|
||
// Left edge lands at (barWidth − meltInto): the surface
|
||
// buries a full corner radius inside the bar column.
|
||
readonly property real surfLeftX: Theme.barWidth - meltInto / 2
|
||
property vector4d cutout: Qt.vector4d(
|
||
(Theme.barWidth + bar.width - Theme.frameWidth) / 2,
|
||
bar.height / 2,
|
||
(bar.width - Theme.frameWidth - Theme.barWidth) / 2,
|
||
bar.height / 2 - Theme.frameWidth)
|
||
property vector4d panel: chrome.visible
|
||
? Qt.vector4d(surfLeftX + chrome.width / 2, (panelTop + panelBottom) / 2,
|
||
meltInto / 2 + chrome.width / 2, (panelBottom - panelTop) / 2)
|
||
: Qt.vector4d(0, 0, 0, 0)
|
||
// Toast sits under the top frame band and melts up into it.
|
||
readonly property real toastBot: toastItem.y + 8 + _toastRect.height
|
||
property vector4d toast: toastItem.visible && _toastRect.width > 0.5
|
||
? Qt.vector4d(surfLeftX + _toastRect.width / 2, (meltTop + toastBot) / 2,
|
||
meltInto / 2 + _toastRect.width / 2, (toastBot - meltTop) / 2)
|
||
: Qt.vector4d(0, 0, 0, 0)
|
||
// The launcher is the one surface that stays put: it still
|
||
// grows from the bottom frame band at screen centre.
|
||
readonly property real launchBot: bar.height - Theme.frameWidth + meltInto
|
||
property vector4d launcher: launcherPanel.visible
|
||
? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2,
|
||
launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2)
|
||
: Qt.vector4d(0, 0, 0, 0)
|
||
property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
|
||
property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
|
||
property vector2d res: Qt.vector2d(width, height)
|
||
property real cutoutR: Theme.radius
|
||
property real panelR: Theme.radius
|
||
// Half the corner radius: the fillet scales with the
|
||
// one rounding knob instead of drifting out of step.
|
||
property real meltK: Theme.radius / 2
|
||
property real borderW: Theme.borderWidth
|
||
fragmentShader: "file://${chromeShader}"
|
||
}
|
||
|
||
// ── Launcher: rises out of the bottom frame edge (Super+R).
|
||
// Lives in the SDF field, so it melts into the frame and its
|
||
// height morphs live as results filter. Results sit above the
|
||
// search box; selection uses an animated ListView highlight.
|
||
Item {
|
||
id: launcherPanel
|
||
property bool open: false
|
||
readonly property real panelW: 420
|
||
property real targetH: 36 + launcherList.contentHeight
|
||
+ (launcherList.count > 0 ? 8 + 2 * Theme.cardPad : 0) + 24
|
||
// Both axes animate: expands from a small point on the
|
||
// bottom edge (like the top dropdowns' stub seed).
|
||
property real openH: open ? targetH : 0
|
||
property real openW: open ? panelW : 80
|
||
Behavior on openH {
|
||
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
|
||
}
|
||
Behavior on openW {
|
||
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
|
||
}
|
||
|
||
x: Math.round((bar.width - openW) / 2)
|
||
y: bar.height - Theme.frameWidth - openH
|
||
width: openW
|
||
height: openH
|
||
visible: openH > 0.5
|
||
|
||
function toggle() {
|
||
open = !open;
|
||
if (open) {
|
||
searchInput.text = "";
|
||
launcherList.currentIndex = 0;
|
||
searchInput.forceActiveFocus();
|
||
}
|
||
}
|
||
|
||
function activate(item) {
|
||
if (!item) return;
|
||
item.execute();
|
||
open = false;
|
||
}
|
||
|
||
function score(name, extra, q) {
|
||
let n = name.toLowerCase();
|
||
if (n.startsWith(q)) return 5;
|
||
if (n.includes(" " + q)) return 4;
|
||
if (n.includes(q)) return 3;
|
||
if (extra && extra.toLowerCase().includes(q)) return 2;
|
||
// Fuzzy: q as an in-order subsequence of n (vktop →
|
||
// vesktop); fewer skipped characters scores higher.
|
||
let qi = 0, gaps = 0, last = -1;
|
||
for (let i = 0; i < n.length && qi < q.length; i++) {
|
||
if (n[i] === q[qi]) {
|
||
if (last >= 0) gaps += i - last - 1;
|
||
last = i;
|
||
qi++;
|
||
}
|
||
}
|
||
if (qi === q.length) return 1 / (1 + gaps);
|
||
return 0;
|
||
}
|
||
|
||
property var entries: {
|
||
let q = searchInput.text.toLowerCase().trim();
|
||
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
|
||
if (q === "") {
|
||
apps.sort((a, b) => a.name.localeCompare(b.name));
|
||
return apps.slice(0, 8);
|
||
}
|
||
let scored = [];
|
||
for (let i = 0; i < apps.length; i++) {
|
||
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q);
|
||
if (s > 0) scored.push({ app: apps[i], s: s });
|
||
}
|
||
scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name));
|
||
return scored.slice(0, 8).map(x => x.app);
|
||
}
|
||
|
||
// Content anchored to the bottom so the grow reveals upward
|
||
Item {
|
||
anchors.fill: parent
|
||
clip: true
|
||
opacity: launcherPanel.open ? 1 : 0
|
||
Behavior on opacity {
|
||
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
|
||
}
|
||
|
||
Column {
|
||
anchors.bottom: parent.bottom
|
||
anchors.bottomMargin: 12
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
width: launcherPanel.panelW - 24
|
||
spacing: 8
|
||
|
||
// Results sit in a base01 card segment, like the
|
||
// notification list and the other dropdowns.
|
||
Rectangle {
|
||
width: parent.width
|
||
height: launcherList.contentHeight + 2 * Theme.cardPad
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
visible: launcherList.count > 0
|
||
|
||
ListView {
|
||
id: launcherList
|
||
anchors.fill: parent
|
||
anchors.margins: Theme.cardPad
|
||
interactive: false
|
||
model: launcherPanel.entries
|
||
highlight: Rectangle {
|
||
radius: Theme.radiusSmall
|
||
color: Theme.base02
|
||
}
|
||
highlightMoveDuration: 200
|
||
highlightMoveVelocity: -1
|
||
highlightResizeDuration: 0
|
||
|
||
delegate: Item {
|
||
required property var modelData
|
||
required property int index
|
||
width: launcherList.width
|
||
height: 32
|
||
|
||
Row {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
spacing: 10
|
||
|
||
Image {
|
||
visible: source != ""
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 18
|
||
height: 18
|
||
sourceSize.width: 18
|
||
sourceSize.height: 18
|
||
source: Quickshell.iconPath(modelData.icon, true)
|
||
}
|
||
|
||
SText {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.name
|
||
color: Theme.base05
|
||
font.pixelSize: 13
|
||
elide: Text.ElideRight
|
||
width: 330
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onEntered: launcherList.currentIndex = index
|
||
onClicked: launcherPanel.activate(modelData)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 36
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
|
||
SIcon {
|
||
id: searchIcon
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "search"
|
||
color: Theme.base04
|
||
font.pixelSize: 16
|
||
}
|
||
|
||
TextInput {
|
||
id: searchInput
|
||
anchors.left: searchIcon.right
|
||
anchors.leftMargin: 8
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: Theme.base05
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: 13
|
||
clip: true
|
||
onTextChanged: launcherList.currentIndex = 0
|
||
|
||
Keys.onEscapePressed: launcherPanel.open = false
|
||
Keys.onUpPressed: launcherList.currentIndex = Math.max(0, launcherList.currentIndex - 1)
|
||
Keys.onDownPressed: launcherList.currentIndex = Math.min(launcherPanel.entries.length - 1, launcherList.currentIndex + 1)
|
||
Keys.onTabPressed: launcherList.currentIndex = (launcherList.currentIndex + 1) % Math.max(1, launcherPanel.entries.length)
|
||
Keys.onReturnPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
|
||
Keys.onEnterPressed: launcherPanel.activate(launcherPanel.entries[launcherList.currentIndex])
|
||
}
|
||
|
||
SText {
|
||
anchors.left: searchIcon.right
|
||
anchors.leftMargin: 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
visible: searchInput.text === ""
|
||
text: "Search"
|
||
color: Theme.base03
|
||
font.pixelSize: 13
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Click-outside dismissal for every panel — the launcher,
|
||
// session menu AND the bar dropdowns. Clicking into another
|
||
// window (or anywhere outside the bar) clears the grab and
|
||
// closes whatever is open. Suspended while screenshotPinned so
|
||
// slurp can grab input without dismissing the menu.
|
||
HyprlandFocusGrab {
|
||
active: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned
|
||
windows: [bar]
|
||
onCleared: {
|
||
if (bar.screenshotPinned) return;
|
||
launcherPanel.open = false;
|
||
bar.closeAllDropdowns();
|
||
}
|
||
}
|
||
|
||
property var activeDropdown: null
|
||
|
||
// Set by the screenshot keybind (via IPC) to hold menus open
|
||
// while a region screenshot runs. Watchdog unpins if the
|
||
// bracketing unpin call is ever missed.
|
||
property bool screenshotPinned: false
|
||
Timer {
|
||
id: _pinWatchdog
|
||
interval: 30000
|
||
onTriggered: bar.screenshotPinned = false
|
||
}
|
||
function setScreenshotPin(v) {
|
||
screenshotPinned = v;
|
||
if (v) _pinWatchdog.restart(); else _pinWatchdog.stop();
|
||
}
|
||
|
||
function closeAllDropdowns() {
|
||
if (activeDropdown && activeDropdown.visible) {
|
||
activeDropdown.animateClose();
|
||
}
|
||
}
|
||
|
||
// Preview image for a notification: its image hint, else a
|
||
// file-path appIcon (e.g. hyprshot's saved shot). "" = none.
|
||
function notifPreviewSource(notif) {
|
||
if (!notif) return "";
|
||
if (notif.image && notif.image !== "") return notif.image;
|
||
let a = notif.appIcon || "";
|
||
if (a.startsWith("file://")) return a;
|
||
if (a.startsWith("/")) return "file://" + a;
|
||
return "";
|
||
}
|
||
function toggleDropdown(dd, setupFn) {
|
||
if (dd.visible && !dd.closing) {
|
||
dd.animateClose();
|
||
} else {
|
||
if (setupFn) setupFn();
|
||
// Opening from fully closed: seed the chrome as a
|
||
// small stub on the widget so the panel grows out of
|
||
// it (reviving mid-close morphs back instead).
|
||
// width, not height: the growth axis is horizontal now.
|
||
// chrome.height is tH, which is never 0, so testing it
|
||
// meant this never fired and the panel never seeded.
|
||
if (!activeDropdown && chrome.width < 0.5) {
|
||
chrome.seedFromButton(dd);
|
||
}
|
||
// Retarget the chrome before closing the previous
|
||
// dropdown so it morphs instead of dipping closed.
|
||
const prev = activeDropdown;
|
||
activeDropdown = dd;
|
||
if (prev && prev !== dd && prev.visible) {
|
||
prev.animateClose();
|
||
}
|
||
if (dd.closing) {
|
||
dd.revive();
|
||
} else {
|
||
dd.visible = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// SystemClock ticks on the minute boundary instead of a 1 Hz
|
||
// Timer; the calendar reads clockText.now too.
|
||
SystemClock {
|
||
id: sysClock
|
||
precision: SystemClock.Minutes
|
||
}
|
||
|
||
// ── Top cluster: logo, clock, workspaces. The bar is a column
|
||
// now, so the two clusters anchor to opposite ends and the gap
|
||
// between them is the OSD slot.
|
||
Column {
|
||
id: barTop
|
||
anchors.top: parent.top
|
||
anchors.topMargin: 12
|
||
anchors.horizontalCenter: barBgRect.horizontalCenter
|
||
spacing: 12
|
||
|
||
// NixOS snowflake, flattened to the theme accent with the
|
||
// same hidden-Image + ColorOverlay trick the tray uses
|
||
// (there is no Material Symbols glyph for it).
|
||
Item {
|
||
width: Theme.barWidth
|
||
height: 24
|
||
|
||
Image {
|
||
id: nixLogoImg
|
||
anchors.centerIn: parent
|
||
width: 22
|
||
height: 22
|
||
source: "file://" + Commands.nixLogo
|
||
sourceSize.width: 22
|
||
sourceSize.height: 22
|
||
smooth: true
|
||
visible: false
|
||
}
|
||
|
||
ColorOverlay {
|
||
anchors.fill: nixLogoImg
|
||
source: nixLogoImg
|
||
color: Theme.base0D
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: bar.toggleLauncher()
|
||
}
|
||
}
|
||
|
||
// Clock — HH over mm; "HH:mm" does not fit a 46px column.
|
||
Item {
|
||
id: clockText
|
||
width: Theme.barWidth
|
||
height: clockCol.height
|
||
property date now: sysClock.date
|
||
|
||
Column {
|
||
id: clockCol
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
spacing: 0
|
||
|
||
SText {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: clockText.now.toLocaleTimeString(Qt.locale(), "HH")
|
||
font.pixelSize: 15
|
||
font.weight: Font.Medium
|
||
}
|
||
SText {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: clockText.now.toLocaleTimeString(Qt.locale(), "mm")
|
||
font.pixelSize: 15
|
||
font.weight: Font.Medium
|
||
color: Theme.base04
|
||
}
|
||
}
|
||
|
||
// Anchors to the clock like every other dropdown
|
||
// anchors to its widget; the clamp in BarDropdown
|
||
// pulls it back on screen when it overhangs.
|
||
function openCalendar() {
|
||
bar.toggleDropdown(calPopup, function() {
|
||
let pos = clockText.mapToItem(bar.contentItem, 0, clockText.height / 2);
|
||
calPopup.dropdownY = pos.y;
|
||
calPopup.resetView();
|
||
});
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: clockText.openCalendar()
|
||
onEntered: {
|
||
if (bar.activeDropdown) {
|
||
if (bar.activeDropdown !== calPopup) clockText.openCalendar();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Workspace dots: accent pill for the focused workspace,
|
||
// dim dots otherwise. Stacked vertically now, so the
|
||
// focused pill grows along HEIGHT rather than width.
|
||
Column {
|
||
spacing: 4
|
||
|
||
Repeater {
|
||
model: Hyprland.workspaces
|
||
|
||
Item {
|
||
id: wsItem
|
||
required property var modelData
|
||
visible: modelData.id > 0
|
||
width: Theme.barWidth
|
||
height: visible ? dot.height + 6 : 0
|
||
|
||
Rectangle {
|
||
id: dot
|
||
anchors.centerIn: parent
|
||
width: 6
|
||
height: wsItem.modelData.focused ? 18 : 6
|
||
radius: 3
|
||
color: wsItem.modelData.focused ? Theme.base0D
|
||
: wsMa.containsMouse ? Theme.base04 : Theme.base03
|
||
Behavior on height {
|
||
NumberAnimation { duration: 200; easing.type: Easing.OutExpo }
|
||
}
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
}
|
||
|
||
MouseArea {
|
||
id: wsMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: wsItem.modelData.activate()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Bottom cluster: status icons, tray, power button. Anchored
|
||
// to the bottom edge so the gap above it — between here and the
|
||
// workspace dots — stays free as the OSD slot.
|
||
Column {
|
||
id: barBottom
|
||
anchors.bottom: parent.bottom
|
||
anchors.bottomMargin: 12
|
||
anchors.horizontalCenter: barBgRect.horizontalCenter
|
||
spacing: 16
|
||
|
||
// Volume
|
||
Item {
|
||
id: volWidget
|
||
width: Theme.barWidth
|
||
height: 26
|
||
|
||
property PwNode sink: Pipewire.defaultAudioSink
|
||
|
||
PwObjectTracker {
|
||
objects: [volWidget.sink]
|
||
}
|
||
|
||
property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0
|
||
property bool muted: sink && sink.audio ? sink.audio.muted : false
|
||
property string volIcon: muted ? "volume_off"
|
||
: vol > 66 ? "volume_up"
|
||
: vol > 33 ? "volume_down"
|
||
: vol > 0 ? "volume_mute"
|
||
: "volume_off"
|
||
|
||
function openVolDropdown() {
|
||
bar.toggleDropdown(volDropdown, function() {
|
||
let pos = volWidget.mapToItem(bar.contentItem, 0, volWidget.height / 2);
|
||
volDropdown.dropdownY = pos.y;
|
||
});
|
||
}
|
||
|
||
// Icon only — the level lives in the OSD slot above and
|
||
// in the dropdown; "100%" does not fit the column.
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: volWidget.volIcon
|
||
color: volWidget.muted ? Theme.base03 : Theme.base05
|
||
font.pixelSize: 18
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||
onClicked: (event) => {
|
||
if (event.button === Qt.MiddleButton) {
|
||
if (volWidget.sink && volWidget.sink.audio)
|
||
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
|
||
} else {
|
||
volWidget.openVolDropdown();
|
||
}
|
||
}
|
||
onEntered: {
|
||
if (bar.activeDropdown) {
|
||
if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Network status
|
||
Item {
|
||
id: netWidget
|
||
width: Theme.barWidth
|
||
height: 26
|
||
|
||
property string netState: "disconnected"
|
||
property string netConn: ""
|
||
property string netType: ""
|
||
property string netIcon: "wifi_off"
|
||
property var wifiNetworks: []
|
||
property string netDevice: ""
|
||
|
||
property string _pendingState: "disconnected"
|
||
property string _pendingConn: ""
|
||
property string _pendingType: ""
|
||
property string _pendingDevice: ""
|
||
property var _pendingNets: []
|
||
|
||
// Event-driven: `nmcli monitor` prints a line on every
|
||
// NetworkManager event; debounce bursts into one refresh.
|
||
Process {
|
||
id: netMonitor
|
||
command: [Commands.nmcli, "monitor"]
|
||
running: true
|
||
stdout: SplitParser {
|
||
onRead: data => netRefreshDebounce.restart()
|
||
}
|
||
onRunningChanged: if (!running) netMonitorRestart.start()
|
||
}
|
||
|
||
Timer {
|
||
id: netRefreshDebounce
|
||
interval: 500
|
||
onTriggered: netWidget.refreshNet()
|
||
}
|
||
|
||
Timer {
|
||
id: netMonitorRestart
|
||
interval: 5000
|
||
onTriggered: netMonitor.running = true
|
||
}
|
||
|
||
// Slow fallback poll in case the monitor dies quietly
|
||
Timer {
|
||
interval: 60000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: netWidget.refreshNet()
|
||
}
|
||
|
||
function refreshNet() {
|
||
netWidget._pendingState = "disconnected";
|
||
netWidget._pendingConn = "";
|
||
netWidget._pendingType = "";
|
||
netProc.running = true;
|
||
}
|
||
|
||
Process {
|
||
id: netProc
|
||
command: [Commands.nmcli, "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"]
|
||
stdout: SplitParser {
|
||
onRead: data => {
|
||
let fields = data.split(":");
|
||
if (fields.length < 4) return;
|
||
let type = fields[1];
|
||
let state = fields[2];
|
||
let conn = fields[3];
|
||
if (type !== "ethernet" && type !== "wifi") return;
|
||
if (type === "wifi") {
|
||
netWidget._pendingDevice = fields[0];
|
||
}
|
||
if (state === "connected") {
|
||
netWidget._pendingState = "connected";
|
||
netWidget._pendingConn = conn;
|
||
netWidget._pendingType = type;
|
||
}
|
||
}
|
||
}
|
||
onRunningChanged: {
|
||
if (!running) {
|
||
netWidget.netState = netWidget._pendingState;
|
||
netWidget.netConn = netWidget._pendingConn;
|
||
netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType;
|
||
netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice;
|
||
if (netWidget.netState === "connected") {
|
||
netWidget.netIcon = netWidget.netType === "wifi" ? "wifi" : "lan";
|
||
} else {
|
||
netWidget.netIcon = netWidget.netType === "wifi" ? "wifi_off" : "settings_ethernet";
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: netWidget.netIcon
|
||
color: Theme.base05
|
||
font.pixelSize: 16
|
||
}
|
||
|
||
Timer {
|
||
id: netRefreshDelay
|
||
interval: 2000
|
||
onTriggered: netWidget.refreshNet()
|
||
}
|
||
|
||
Process {
|
||
id: wifiScanProc
|
||
command: [Commands.nmcli, "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"]
|
||
stdout: SplitParser {
|
||
onRead: data => {
|
||
let fields = data.split(":");
|
||
if (fields.length < 4 || fields[0] === "") return;
|
||
for (let i = 0; i < netWidget._pendingNets.length; i++) {
|
||
if (netWidget._pendingNets[i].ssid === fields[0]) return;
|
||
}
|
||
netWidget._pendingNets.push({
|
||
ssid: fields[0],
|
||
signal: parseInt(fields[1]) || 0,
|
||
security: fields[2],
|
||
active: fields[3] === "*"
|
||
});
|
||
}
|
||
}
|
||
onRunningChanged: {
|
||
if (!running) {
|
||
netWidget.wifiNetworks = netWidget._pendingNets;
|
||
netWidget._pendingNets = [];
|
||
}
|
||
}
|
||
}
|
||
|
||
Process {
|
||
id: wifiConnectProc
|
||
property string targetSsid: ""
|
||
command: [Commands.wifiConnect, targetSsid]
|
||
}
|
||
|
||
Process {
|
||
id: netDisconnectProc
|
||
property string targetDevice: ""
|
||
command: [Commands.nmcli, "device", "disconnect", targetDevice]
|
||
}
|
||
|
||
function openNetDropdown() {
|
||
bar.toggleDropdown(netDropdown, function() {
|
||
wifiScanProc.running = true;
|
||
let pos = netWidget.mapToItem(bar.contentItem, 0, netWidget.height / 2);
|
||
netDropdown.dropdownY = pos.y;
|
||
});
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: netWidget.openNetDropdown()
|
||
onEntered: {
|
||
if (bar.activeDropdown) {
|
||
if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
${lib.optionalString isMacbook ''
|
||
// Battery
|
||
Item {
|
||
id: batteryWidget
|
||
width: Theme.barWidth
|
||
height: batteryCol.height
|
||
|
||
// Live DBus-driven properties from the UPower service —
|
||
// no polling, no /sys parsing, no subprocess spawns.
|
||
property var dev: UPower.displayDevice
|
||
property int batteryLevel: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
|
||
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
|
||
property real powerDraw: dev ? Math.abs(dev.changeRate) : 0.0
|
||
property string timeRemaining: {
|
||
if (!dev) return "";
|
||
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
|
||
if (!secs || secs <= 0) return "";
|
||
let h = Math.floor(secs / 3600);
|
||
let m = Math.round((secs % 3600) / 60);
|
||
return h + "h " + m + "m";
|
||
}
|
||
property string powerProfile:
|
||
PowerProfiles.profile === PowerProfile.PowerSaver ? "power-saver"
|
||
: PowerProfiles.profile === PowerProfile.Performance ? "performance"
|
||
: "balanced"
|
||
property string batteryIcon: charging ? "battery_charging_full"
|
||
: batteryLevel >= 90 ? "battery_full"
|
||
: batteryLevel >= 70 ? "battery_6_bar"
|
||
: batteryLevel >= 50 ? "battery_5_bar"
|
||
: batteryLevel >= 30 ? "battery_3_bar"
|
||
: batteryLevel >= 15 ? "battery_2_bar"
|
||
: "battery_alert"
|
||
|
||
// Stacked: icon over percentage. Side by side no longer
|
||
// fits, but the level is worth keeping on a laptop.
|
||
Column {
|
||
id: batteryCol
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
spacing: 0
|
||
|
||
SIcon {
|
||
id: batteryIconText
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: batteryWidget.batteryIcon
|
||
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
|
||
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
|
||
: Theme.base05
|
||
font.pixelSize: 18
|
||
}
|
||
|
||
SText {
|
||
id: batteryText
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: batteryWidget.batteryLevel
|
||
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
|
||
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
|
||
: Theme.base05
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
|
||
function openBatteryDropdown() {
|
||
bar.toggleDropdown(batteryDropdown, function() {
|
||
let pos = batteryWidget.mapToItem(bar.contentItem, 0, batteryWidget.height / 2);
|
||
batteryDropdown.dropdownY = pos.y;
|
||
brightRead.running = true; // refresh sliders on open
|
||
});
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: batteryWidget.openBatteryDropdown()
|
||
onEntered: {
|
||
if (bar.activeDropdown) {
|
||
if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
''}
|
||
|
||
${lib.optionalString (!isMacbook) ''
|
||
// Quick settings (night light) — no battery widget on the
|
||
// desktop, so the card gets its own bar icon.
|
||
Item {
|
||
id: qsWidget
|
||
width: Theme.barWidth
|
||
height: 26
|
||
|
||
SIcon {
|
||
id: qsIcon
|
||
// centerIn, not verticalCenter: the cell used to be
|
||
// qsIcon.width so horizontal centring was implicit.
|
||
anchors.centerIn: parent
|
||
text: "nightlight"
|
||
font.pixelSize: 18
|
||
}
|
||
|
||
function openQsDropdown() {
|
||
bar.toggleDropdown(qsDropdown, function() {
|
||
let pos = qsWidget.mapToItem(bar.contentItem, 0, qsWidget.height / 2);
|
||
qsDropdown.dropdownY = pos.y;
|
||
});
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: qsWidget.openQsDropdown()
|
||
onEntered: {
|
||
if (bar.activeDropdown) {
|
||
if (bar.activeDropdown !== qsDropdown) qsWidget.openQsDropdown();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
''}
|
||
|
||
// Group separator: status icons / tray / power read
|
||
// as three clusters instead of one clump.
|
||
Item { width: Theme.barWidth; height: 10 }
|
||
|
||
// Tray icons — stacked, like everything else in the column
|
||
Column {
|
||
id: trayArea
|
||
spacing: 8
|
||
width: Theme.barWidth
|
||
// NO vertical anchor here: a Column refuses to position
|
||
// ANY child if one of them sets top/bottom/verticalCenter
|
||
// /fill/centerIn, and silently piles them all at y=0.
|
||
|
||
Repeater {
|
||
model: SystemTray.items
|
||
|
||
Item {
|
||
required property var modelData
|
||
width: Theme.barWidth
|
||
height: 24
|
||
|
||
Image {
|
||
id: trayIcon
|
||
anchors.centerIn: parent
|
||
width: 16
|
||
height: 16
|
||
source: modelData.icon
|
||
sourceSize.width: 16
|
||
sourceSize.height: 16
|
||
smooth: true
|
||
mipmap: true
|
||
visible: false
|
||
}
|
||
|
||
ColorOverlay {
|
||
anchors.fill: trayIcon
|
||
source: trayIcon
|
||
color: Theme.base05
|
||
}
|
||
|
||
// The icon's own menu cache: assigned from the
|
||
// moment the icon exists, so the DBus fetch
|
||
// (slow for Steam) happens at startup, not
|
||
// mid-animation. contextMenu just points at
|
||
// this — switching tray icons never reloads.
|
||
QsMenuOpener {
|
||
id: itemOpener
|
||
menu: modelData.menu
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.NoButton
|
||
onEntered: {
|
||
if (!modelData.hasMenu) return;
|
||
if (!bar.activeDropdown) return;
|
||
if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) return;
|
||
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
|
||
if (bar.activeDropdown === contextMenu) {
|
||
// Same dropdown, just switch content
|
||
contextMenu.dropdownY = pos.y;
|
||
contextMenu.trayItem = modelData;
|
||
contextMenu.activeOpener = itemOpener;
|
||
} else {
|
||
contextMenu.openFor(modelData, pos.y, itemOpener);
|
||
}
|
||
}
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
onClicked: (event) => {
|
||
if (!modelData.hasMenu) {
|
||
modelData.activate();
|
||
} else if (bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData) {
|
||
// Clicking the open menu's own icon still closes it
|
||
contextMenu.animateClose();
|
||
} else {
|
||
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
|
||
contextMenu.openFor(modelData, pos.y, itemOpener);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Group separator: status icons / tray / power read
|
||
// as three clusters instead of one clump.
|
||
Item { width: Theme.barWidth; height: 10 }
|
||
|
||
// Power menu opener — bottom-left corner. Opens the same
|
||
// session menu as Super+L, which now unfolds from here.
|
||
Item {
|
||
id: powerWidget
|
||
width: Theme.barWidth
|
||
height: 26
|
||
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: "power_settings_new"
|
||
color: powerMa.containsMouse || sessionMenu.open ? Theme.base08 : Theme.base05
|
||
font.pixelSize: 18
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
}
|
||
|
||
MouseArea {
|
||
id: powerMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: bar.toggleSession()
|
||
onEntered: {
|
||
if (bar.activeDropdown) {
|
||
if (bar.activeDropdown !== sessionMenu) bar.toggleSession();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Dropdown container — content, sizing and autoclose only.
|
||
// The background, border and ears are drawn once by the shared
|
||
// `chrome` panel below, which morphs between dropdowns.
|
||
component BarDropdown: Item {
|
||
id: dropdown
|
||
property bool open: false
|
||
property bool closing: false
|
||
// Vertical centre of the widget that owns this dropdown;
|
||
// the panel tracks it, clamped to the screen. The old
|
||
// flush-to-far-edge special case is gone — the SDF melt
|
||
// already makes a clamped panel meet the frame cleanly.
|
||
property real dropdownY: 0
|
||
property real fullWidth: 200
|
||
property real fullHeight: 200
|
||
property real dropdownWidth: open ? fullWidth : 0
|
||
default property alias content: dropdownContent.data
|
||
|
||
function animateClose() {
|
||
if (!visible || closing) return;
|
||
closing = true;
|
||
open = false;
|
||
// Collapse the chrome immediately so the content fade
|
||
// and the panel animation run together (when switching
|
||
// dropdowns, toggleDropdown retargets activeDropdown
|
||
// first, so this doesn't fire and the chrome morphs).
|
||
// The panel also shrinks back toward its widget.
|
||
if (bar.activeDropdown === dropdown) {
|
||
bar.activeDropdown = null;
|
||
chrome.shrinkToButton(dropdown);
|
||
}
|
||
_closeDelay.start();
|
||
}
|
||
|
||
// Reopen a dropdown that's mid-close: the pending hide
|
||
// timer must be cancelled, otherwise it fires later and
|
||
// closes the revived dropdown (and the whole chrome).
|
||
function revive() {
|
||
_closeDelay.stop();
|
||
closing = false;
|
||
open = true;
|
||
}
|
||
|
||
// Clamp to the frame's inner edge. A panel that lands here
|
||
// is MEANT to fuse with the frame band — the shader melts
|
||
// it in (see meltTop/meltBot). Outer max wins when a panel
|
||
// is taller than the space (yMax < yMin).
|
||
readonly property real yMin: Theme.frameWidth
|
||
readonly property real yMax: bar.height - Theme.frameWidth - height
|
||
x: Theme.barWidth
|
||
y: Math.round(Math.max(yMin, Math.min(yMax, dropdownY - height / 2)))
|
||
width: fullWidth + 4
|
||
// +24, not +16: the shader insets the panel 8px top and
|
||
// bottom, so +16 left the panel edge flush with the content
|
||
// (0 padding) while the growth axis overhung by 4. The
|
||
// extra 8 gives 4px above and below, matching the right.
|
||
height: fullHeight + 24
|
||
visible: false
|
||
|
||
onVisibleChanged: {
|
||
if (visible) {
|
||
closing = false;
|
||
open = true;
|
||
} else {
|
||
open = false;
|
||
closing = false;
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: _closeDelay
|
||
interval: 300
|
||
onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; }
|
||
}
|
||
|
||
// Content is clipped to the chrome's ANIMATED geometry —
|
||
// revealed as the panel slides/grows over it and wiped as
|
||
// the panel leaves, instead of popping in place. The inner
|
||
// item counter-offsets so content stays put while the clip
|
||
// window moves across it. Growth axis is now x (rightward);
|
||
// the clip's height is the chrome's inner cross-axis span.
|
||
Item {
|
||
id: _dropdownRect
|
||
x: 0
|
||
y: (chrome.y + 8) - dropdown.y
|
||
width: Math.min(dropdown.fullWidth, chrome.width)
|
||
height: Math.max(0, chrome.height - 16)
|
||
clip: true
|
||
opacity: dropdown.open ? 1 : 0
|
||
Behavior on opacity {
|
||
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
|
||
}
|
||
|
||
Item {
|
||
id: dropdownContent
|
||
// 12 = panel inset (8) + the 4px breathing room,
|
||
// keeping the content centred in the taller panel.
|
||
y: 12 - _dropdownRect.y
|
||
width: dropdown.fullWidth
|
||
height: dropdown.fullHeight
|
||
}
|
||
}
|
||
}
|
||
|
||
// The shared morphing panel: follows the active dropdown's
|
||
// geometry with animation (the caelestia-style morph), snaps
|
||
// instantly when opening from closed.
|
||
Item {
|
||
id: chrome
|
||
property real tY: 0
|
||
property real tH: 200
|
||
property real tW: 0
|
||
property real openW: bar.activeDropdown ? tW : 0
|
||
property bool snap: false
|
||
readonly property real stubH: 32
|
||
|
||
// Grow-from / shrink-to the widget that owns the dropdown:
|
||
// the panel opens as a small stub beside the button and
|
||
// expands rightward; closing retargets back to the stub
|
||
// while the width collapses.
|
||
function stubY(dd) {
|
||
return Math.round(Math.min(bar.height - Theme.frameWidth - stubH, Math.max(Theme.frameWidth, dd.dropdownY - stubH / 2)));
|
||
}
|
||
// Snap ONLY long enough to place the stub, then release so
|
||
// both axes animate out to the target: the panel grows
|
||
// from a small point on the widget rather than sliding out
|
||
// at full height. Holding the snap through the grow is
|
||
// what made it arrive pre-grown vertically.
|
||
function seedFromButton(dd) {
|
||
snap = true;
|
||
tY = stubY(dd);
|
||
tH = stubH;
|
||
snap = false;
|
||
}
|
||
function shrinkToButton(dd) {
|
||
tY = stubY(dd);
|
||
tH = stubH;
|
||
}
|
||
|
||
x: Theme.barWidth
|
||
y: tY
|
||
width: openW
|
||
height: tH
|
||
visible: width > 0.5
|
||
|
||
Binding {
|
||
target: chrome; property: "tY"
|
||
value: bar.activeDropdown ? bar.activeDropdown.y : 0
|
||
when: bar.activeDropdown !== null
|
||
restoreMode: Binding.RestoreNone
|
||
}
|
||
Binding {
|
||
target: chrome; property: "tH"
|
||
value: bar.activeDropdown ? bar.activeDropdown.height : 0
|
||
when: bar.activeDropdown !== null
|
||
restoreMode: Binding.RestoreNone
|
||
}
|
||
Binding {
|
||
target: chrome; property: "tW"
|
||
value: bar.activeDropdown ? bar.activeDropdown.fullWidth + 4 : 0
|
||
when: bar.activeDropdown !== null
|
||
restoreMode: Binding.RestoreNone
|
||
}
|
||
|
||
Behavior on tY {
|
||
enabled: !chrome.snap
|
||
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
|
||
}
|
||
Behavior on tH {
|
||
enabled: !chrome.snap
|
||
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
|
||
}
|
||
Behavior on openW {
|
||
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
|
||
}
|
||
|
||
}
|
||
|
||
// Context menu
|
||
BarDropdown {
|
||
id: contextMenu
|
||
property var trayItem: null
|
||
fullWidth: menuItems.width + 2 * Theme.panelGap
|
||
|
||
// While a freshly assigned menu is still loading, the Card
|
||
// is empty — and an empty Card is not height 0, it is
|
||
// 2*cardPad — so the panel would morph DOWN to a stub and
|
||
// then regrow once the items land, instead of moving
|
||
// across. Hold the last loaded height until the new items
|
||
// arrive so the morph stays continuous. The correction is
|
||
// small, and nil when it is the same menu as last time.
|
||
// Row metrics, shared by the delegate below and the height
|
||
// sum — these two must never drift apart.
|
||
readonly property int menuRowHeight: 28
|
||
readonly property int menuSepHeight: 9
|
||
|
||
// Size the panel from the MODEL, not from the laid-out
|
||
// Card. The Card only reaches its true height a frame or
|
||
// more after the model populates — the Repeater has to
|
||
// build delegates and the Column re-measure — and every
|
||
// previous attempt here read the Card during that gap,
|
||
// which is the flash. Summing the rows is exact and
|
||
// available the instant the data lands, so the height and
|
||
// its content update in the same binding pass.
|
||
readonly property real menuContentHeight: {
|
||
if (!menuModel) return 0;
|
||
const items = menuModel.values;
|
||
let h = 0;
|
||
for (let i = 0; i < items.length; i++)
|
||
h += items[i].isSeparator ? menuSepHeight : menuRowHeight;
|
||
return h;
|
||
}
|
||
|
||
// Hold the previous size across the DBus fetch of a newly
|
||
// assigned menu (the tray-to-tray path), so the switch
|
||
// morphs instead of collapsing to empty and regrowing.
|
||
property real lastMenuHeight: 0
|
||
onMenuContentHeightChanged: {
|
||
if (menuContentHeight > 0) lastMenuHeight = menuContentHeight;
|
||
}
|
||
fullHeight: (menuContentHeight > 0 ? menuContentHeight : lastMenuHeight)
|
||
+ 2 * Theme.cardPad + 2 * Theme.panelGap
|
||
|
||
// Which tray item's opener feeds the menu. Every tray icon
|
||
// owns an always-assigned QsMenuOpener (see the tray
|
||
// Repeater), fetched when the icon appears — so switching
|
||
// tray→tray swaps one POPULATED model for another in a
|
||
// single binding pass. The shared opener this replaces
|
||
// reloaded over DBus on every reassignment, and no sizing
|
||
// gate downstream could hide that the model itself went
|
||
// briefly empty (that gap was the flash).
|
||
property var activeOpener: null
|
||
readonly property var menuModel: activeOpener ? activeOpener.children : null
|
||
|
||
// Opens immediately — the item's opener has been loaded
|
||
// since its icon appeared, so there is nothing to wait on.
|
||
function openFor(item, y, opener) {
|
||
dropdownY = y;
|
||
trayItem = item;
|
||
activeOpener = opener;
|
||
bar.toggleDropdown(contextMenu, null);
|
||
}
|
||
|
||
Card {
|
||
id: menuItems
|
||
anchors.centerIn: parent
|
||
width: 200
|
||
cardSpacing: 0
|
||
|
||
Repeater {
|
||
model: contextMenu.menuModel
|
||
|
||
Rectangle {
|
||
required property var modelData
|
||
width: parent.width
|
||
height: modelData.isSeparator ? contextMenu.menuSepHeight
|
||
: contextMenu.menuRowHeight
|
||
color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled
|
||
? Theme.base02 : Theme.base02t
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
radius: modelData.isSeparator ? 0 : 4
|
||
|
||
Rectangle {
|
||
visible: modelData.isSeparator
|
||
anchors.centerIn: parent
|
||
width: parent.width - 20
|
||
height: 1
|
||
color: Theme.base03
|
||
}
|
||
|
||
Item {
|
||
visible: !modelData.isSeparator
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 10
|
||
|
||
SText {
|
||
id: menuCheck
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
visible: modelData.buttonType !== QsMenuButtonType.None
|
||
text: modelData.checkState === Qt.Checked ? "\u2713" : ""
|
||
color: Theme.base0D
|
||
font.pixelSize: 12
|
||
}
|
||
|
||
SText {
|
||
anchors.left: parent.left
|
||
anchors.right: menuCheck.visible ? menuCheck.left : parent.right
|
||
anchors.rightMargin: menuCheck.visible ? 8 : 0
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.text ?? ""
|
||
color: modelData.enabled ? Theme.base05 : Theme.base03
|
||
font.pixelSize: 12
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: itemMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
enabled: !modelData.isSeparator && modelData.enabled
|
||
onClicked: {
|
||
modelData.triggered();
|
||
bar.closeAllDropdowns();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Session menu: icon-only power controls morphing out of
|
||
// the bar at the bottom-left (Super+L, or the power button).
|
||
// Keyboard: arrows/Tab move the selection, Enter activates,
|
||
// Esc closes.
|
||
BarDropdown {
|
||
id: sessionMenu
|
||
fullWidth: 48 + 2 * Theme.panelGap
|
||
fullHeight: sessionCard.height + 2 * Theme.panelGap
|
||
property int selIdx: 0
|
||
|
||
readonly property var actions: [
|
||
{ icon: "lock", danger: false, act: "lock" },
|
||
{ icon: "logout", danger: false, act: "logout" },
|
||
{ icon: "restart_alt", danger: true, act: "reboot" },
|
||
{ icon: "power_settings_new", danger: true, act: "poweroff" }
|
||
]
|
||
|
||
function activate(act) {
|
||
animateClose();
|
||
if (act === "lock") bar.shellRoot.lock();
|
||
else if (act === "logout") Hyprland.dispatch("hl.dsp.exit()");
|
||
else if (act === "reboot") Quickshell.execDetached([Commands.systemctl, "reboot"]);
|
||
else Quickshell.execDetached([Commands.systemctl, "poweroff"]);
|
||
}
|
||
|
||
focus: open
|
||
onOpenChanged: if (open) { selIdx = 0; forceActiveFocus(); }
|
||
Keys.onEscapePressed: bar.closeAllDropdowns()
|
||
Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length
|
||
Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length
|
||
Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length
|
||
Keys.onReturnPressed: activate(actions[selIdx].act)
|
||
Keys.onEnterPressed: activate(actions[selIdx].act)
|
||
|
||
// Card backing, matching the other dropdowns. The grow /
|
||
// clip reveal is BarDropdown's job now.
|
||
Rectangle {
|
||
id: sessionCard
|
||
anchors.centerIn: parent
|
||
width: 48
|
||
height: sessionCol.height + 8
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
|
||
// Sliding selection pill — same tech as the power
|
||
// profile selector; glides between the buttons.
|
||
Rectangle {
|
||
width: 40
|
||
height: 40
|
||
radius: Theme.radiusCard
|
||
color: Theme.base02
|
||
border.width: 1
|
||
border.color: Theme.base03
|
||
x: sessionCol.x
|
||
y: sessionCol.y + sessionMenu.selIdx * 44
|
||
Behavior on y {
|
||
NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
|
||
}
|
||
}
|
||
|
||
Column {
|
||
id: sessionCol
|
||
anchors.centerIn: parent
|
||
spacing: 4
|
||
|
||
Repeater {
|
||
model: sessionMenu.actions
|
||
|
||
Item {
|
||
id: sessBtn
|
||
required property var modelData
|
||
required property int index
|
||
width: 40
|
||
height: 40
|
||
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: sessBtn.modelData.icon
|
||
// All four buttons share the logout
|
||
// setup: base05, FILL on selection.
|
||
color: Theme.base05
|
||
font.pixelSize: 20
|
||
font.weight: 600
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onEntered: sessionMenu.selIdx = sessBtn.index
|
||
onClicked: sessionMenu.activate(sessBtn.modelData.act)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Volume dropdown
|
||
BarDropdown {
|
||
id: volDropdown
|
||
fullWidth: volDropdownCol.width + 2 * Theme.panelGap
|
||
fullHeight: volDropdownCol.height + 2 * Theme.panelGap
|
||
|
||
Column {
|
||
id: volDropdownCol
|
||
anchors.centerIn: parent
|
||
width: 268
|
||
spacing: 8
|
||
|
||
// Master volume card
|
||
Card {
|
||
width: parent.width
|
||
|
||
Row {
|
||
spacing: 6
|
||
VolIcon { anchors.verticalCenter: parent.verticalCenter; audioNode: volWidget.sink ? volWidget.sink.audio : null }
|
||
SText {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Master"
|
||
font.weight: Font.Medium
|
||
}
|
||
}
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
|
||
PillSlider {
|
||
width: parent.width - masterVolLabel.width - 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
value: volWidget.sink && volWidget.sink.audio ? Math.min(1, volWidget.sink.audio.volume) : 0
|
||
fillColor: volWidget.muted ? Theme.base03 : Theme.base0D
|
||
onMoved: (v) => { if (volWidget.sink && volWidget.sink.audio) volWidget.sink.audio.volume = v; }
|
||
}
|
||
|
||
SText {
|
||
id: masterVolLabel
|
||
width: 36
|
||
text: volWidget.vol + "%"
|
||
font.pixelSize: 11
|
||
horizontalAlignment: Text.AlignRight
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
|
||
// Mute button
|
||
HoverRow {
|
||
width: parent.width
|
||
height: 28
|
||
onClicked: {
|
||
if (volWidget.sink && volWidget.sink.audio)
|
||
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
|
||
}
|
||
|
||
Row {
|
||
anchors.centerIn: parent
|
||
spacing: 6
|
||
SIcon {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: volWidget.muted ? "volume_off" : "volume_up"
|
||
font.pixelSize: 15
|
||
}
|
||
SText {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: volWidget.muted ? "Unmute" : "Mute"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Applications card
|
||
Card {
|
||
visible: appStreamsCol.childrenRect.height > 0
|
||
width: parent.width
|
||
|
||
Row {
|
||
spacing: 6
|
||
SIcon { anchors.verticalCenter: parent.verticalCenter; text: "graphic_eq" }
|
||
SText {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Applications"
|
||
font.weight: Font.Medium
|
||
}
|
||
}
|
||
|
||
// Per-app streams
|
||
Column {
|
||
id: appStreamsCol
|
||
width: parent.width
|
||
spacing: 6
|
||
|
||
Repeater {
|
||
id: appStreamsRepeater
|
||
model: Pipewire.nodes
|
||
|
||
Column {
|
||
required property var modelData
|
||
width: parent.width
|
||
spacing: 2
|
||
visible: modelData.isStream && modelData.audio !== null
|
||
|
||
PwObjectTracker {
|
||
objects: [modelData]
|
||
}
|
||
|
||
SText {
|
||
text: modelData.properties["application.name"] || modelData.name || "Unknown"
|
||
color: Theme.base04
|
||
font.pixelSize: 11
|
||
elide: Text.ElideRight
|
||
width: parent.width
|
||
}
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
|
||
VolIcon {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 18
|
||
color: Theme.base04
|
||
font.pixelSize: 15
|
||
audioNode: modelData.audio
|
||
}
|
||
|
||
PillSlider {
|
||
width: parent.width - 18 - appVolLabel.width - 16
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
height: 16
|
||
trackH: 4
|
||
value: modelData.audio ? Math.min(1, modelData.audio.volume) : 0
|
||
fillColor: modelData.audio && modelData.audio.muted ? Theme.base03 : Theme.base0C
|
||
onMoved: (v) => { if (modelData.audio) modelData.audio.volume = v; }
|
||
}
|
||
|
||
SText {
|
||
id: appVolLabel
|
||
width: 36
|
||
text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%"
|
||
color: Theme.base04
|
||
font.pixelSize: 10
|
||
horizontalAlignment: Text.AlignRight
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Network dropdown
|
||
BarDropdown {
|
||
id: netDropdown
|
||
fullWidth: netDropdownCol.width + 2 * Theme.panelGap
|
||
fullHeight: netDropdownCol.height + 2 * Theme.panelGap
|
||
|
||
Column {
|
||
id: netDropdownCol
|
||
anchors.centerIn: parent
|
||
width: 228
|
||
spacing: 8
|
||
|
||
// Connection card
|
||
Card {
|
||
width: parent.width
|
||
cardSpacing: 4
|
||
Row {
|
||
width: parent.width
|
||
spacing: 6
|
||
|
||
SIcon {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: netWidget.netState === "connected" ? "wifi" : "wifi_off"
|
||
color: Theme.base05
|
||
font.pixelSize: 16
|
||
}
|
||
|
||
SText {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: parent.width - 22
|
||
text: netWidget.netState === "connected"
|
||
? netWidget.netConn : "Not connected"
|
||
color: Theme.base05
|
||
font.pixelSize: 13
|
||
font.weight: Font.Medium
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
HoverRow {
|
||
visible: netWidget.netState === "connected"
|
||
width: parent.width
|
||
height: 28
|
||
onClicked: {
|
||
netDisconnectProc.targetDevice = netWidget.netDevice;
|
||
netDisconnectProc.running = true;
|
||
netWidget.netState = "disconnected";
|
||
netWidget.netConn = "";
|
||
netWidget.netIcon = "wifi_off";
|
||
bar.closeAllDropdowns();
|
||
netRefreshDelay.start();
|
||
}
|
||
|
||
SText {
|
||
anchors.centerIn: parent
|
||
text: "Disconnect"
|
||
color: Theme.base08
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
|
||
// Available networks card
|
||
Card {
|
||
width: parent.width
|
||
cardSpacing: 4
|
||
SText {
|
||
text: "Available networks"
|
||
color: Theme.base04
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
Repeater {
|
||
model: netWidget.wifiNetworks
|
||
|
||
HoverRow {
|
||
required property var modelData
|
||
width: parent.width
|
||
height: 32
|
||
onClicked: {
|
||
if (!modelData.active) {
|
||
wifiConnectProc.targetSsid = modelData.ssid;
|
||
wifiConnectProc.running = true;
|
||
netRefreshDelay.start();
|
||
}
|
||
bar.closeAllDropdowns();
|
||
}
|
||
|
||
Row {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 8
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 8
|
||
spacing: 8
|
||
|
||
SIcon {
|
||
text: {
|
||
let s = modelData.signal;
|
||
if (s >= 75) return "signal_wifi_4_bar";
|
||
if (s >= 50) return "network_wifi_3_bar";
|
||
if (s >= 25) return "network_wifi_2_bar";
|
||
return "network_wifi_1_bar";
|
||
}
|
||
color: modelData.active ? Theme.base0B : Theme.base04
|
||
font.pixelSize: 16
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
SText {
|
||
text: modelData.ssid
|
||
color: modelData.active ? Theme.base0B : Theme.base05
|
||
font.pixelSize: 12
|
||
elide: Text.ElideRight
|
||
width: 140
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
SIcon {
|
||
visible: modelData.security !== "" && modelData.security !== "--"
|
||
text: "lock"
|
||
color: Theme.base03
|
||
font.pixelSize: 13
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
${lib.optionalString isMacbook ''
|
||
// Battery dropdown
|
||
BarDropdown {
|
||
id: batteryDropdown
|
||
fullWidth: batteryDropdownCol.width + 2 * Theme.panelGap
|
||
fullHeight: batteryDropdownCol.height + 2 * Theme.panelGap
|
||
|
||
Column {
|
||
id: batteryDropdownCol
|
||
anchors.centerIn: parent
|
||
width: 208
|
||
spacing: 8
|
||
|
||
// Battery status card
|
||
Card {
|
||
width: parent.width
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
|
||
SIcon {
|
||
text: batteryWidget.batteryIcon
|
||
color: Theme.base05
|
||
font.pixelSize: 22
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
Column {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
SText {
|
||
text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "")
|
||
color: Theme.base05
|
||
font.pixelSize: 13
|
||
font.weight: Font.Medium
|
||
}
|
||
SText {
|
||
text: batteryWidget.powerDraw.toFixed(1) + " W"
|
||
+ (batteryWidget.timeRemaining !== "" ? " • " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "")
|
||
color: Theme.base04
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Power profile card
|
||
Card {
|
||
width: parent.width
|
||
cardSpacing: 6
|
||
SText {
|
||
text: "Power Profile"
|
||
color: Theme.base04
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 36
|
||
|
||
// Sliding selection pill — glides between
|
||
// profiles instead of each button flipping.
|
||
Rectangle {
|
||
id: profilePill
|
||
readonly property int selIdx:
|
||
batteryWidget.powerProfile === "power-saver" ? 0
|
||
: batteryWidget.powerProfile === "performance" ? 2
|
||
: 1
|
||
width: (parent.width - 8) / 3
|
||
height: 36
|
||
radius: Theme.radiusSmall
|
||
color: Theme.base02
|
||
border.width: 1
|
||
border.color: Theme.base03
|
||
x: selIdx * (width + 4)
|
||
Behavior on x {
|
||
NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
|
||
}
|
||
}
|
||
|
||
Row {
|
||
anchors.fill: parent
|
||
spacing: 4
|
||
|
||
Repeater {
|
||
model: [
|
||
{ name: "power-saver", profile: PowerProfile.PowerSaver, label: "energy_savings_leaf", tip: "Saver" },
|
||
{ name: "balanced", profile: PowerProfile.Balanced, label: "balance", tip: "Balanced" },
|
||
{ name: "performance", profile: PowerProfile.Performance, label: "speed", tip: "Performance" }
|
||
]
|
||
|
||
Rectangle {
|
||
required property var modelData
|
||
width: (parent.width - 8) / 3
|
||
height: 36
|
||
radius: Theme.radiusSmall
|
||
color: profMouse.containsMouse && batteryWidget.powerProfile !== modelData.name
|
||
? Theme.base02 : Theme.base02t
|
||
Behavior on color { ColorAnimation { duration: Theme.animFade } }
|
||
|
||
Column {
|
||
anchors.centerIn: parent
|
||
spacing: 1
|
||
SIcon {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: modelData.label
|
||
// Selected = bright, unselected = grey
|
||
color: batteryWidget.powerProfile === modelData.name
|
||
? Theme.base05 : Theme.base04
|
||
Behavior on color { ColorAnimation { duration: 200 } }
|
||
font.pixelSize: 17
|
||
}
|
||
SText {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: modelData.tip
|
||
color: batteryWidget.powerProfile === modelData.name
|
||
? Theme.base05 : Theme.base04
|
||
Behavior on color { ColorAnimation { duration: 200 } }
|
||
font.pixelSize: 9
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: profMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: PowerProfiles.profile = modelData.profile
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Brightness card — screen + keyboard backlight.
|
||
// Values are read on dropdown open (brightRead); writes
|
||
// are debounced latest-wins so a drag doesn't spawn a
|
||
// brightnessctl per pixel.
|
||
Card {
|
||
id: brightCard
|
||
width: parent.width
|
||
cardSpacing: 6
|
||
property real screenB: 0.5
|
||
property real kbdB: 0
|
||
|
||
SText { text: "Brightness"; color: Theme.base04; font.pixelSize: 11 }
|
||
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: "brightness_6"; font.pixelSize: 16 }
|
||
PillSlider {
|
||
width: parent.width - 26
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
value: brightCard.screenB
|
||
onMoved: (v) => { brightCard.screenB = v; screenBTimer.restart(); }
|
||
}
|
||
}
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: "keyboard"; font.pixelSize: 16 }
|
||
PillSlider {
|
||
width: parent.width - 26
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
value: brightCard.kbdB
|
||
onMoved: (v) => { brightCard.kbdB = v; kbdBTimer.restart(); }
|
||
}
|
||
}
|
||
|
||
Timer { id: screenBTimer; interval: 80; onTriggered: screenBSet.running = true }
|
||
// floor 1%: `set 0%` turns the panel fully off
|
||
Process { id: screenBSet; command: ["brightnessctl", "set", Math.max(1, Math.round(brightCard.screenB * 100)) + "%"] }
|
||
Timer { id: kbdBTimer; interval: 80; onTriggered: kbdBSet.running = true }
|
||
Process { id: kbdBSet; command: ["brightnessctl", "-d", "smc::kbd_backlight", "set", Math.round(brightCard.kbdB * 100) + "%"] }
|
||
|
||
Process {
|
||
id: brightRead
|
||
command: ["sh", "-c", "brightnessctl -m; brightnessctl -m -d smc::kbd_backlight"]
|
||
stdout: StdioCollector {
|
||
// machine-readable: device,class,current,percent%,max
|
||
onStreamFinished: {
|
||
for (let l of text.trim().split("\n")) {
|
||
let f = l.split(",");
|
||
if (f.length < 5) continue;
|
||
let pct = parseInt(f[3]) / 100;
|
||
if (f[0].indexOf("kbd") !== -1) brightCard.kbdB = pct;
|
||
else brightCard.screenB = pct;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
NightlightCard { width: parent.width }
|
||
}
|
||
}
|
||
''}
|
||
|
||
${lib.optionalString (!isMacbook) ''
|
||
// Quick settings dropdown (desktop) — just the night light.
|
||
BarDropdown {
|
||
id: qsDropdown
|
||
fullWidth: qsCol.width + 2 * Theme.panelGap
|
||
fullHeight: qsCol.height + 2 * Theme.panelGap
|
||
|
||
Column {
|
||
id: qsCol
|
||
anchors.centerIn: parent
|
||
width: 240
|
||
spacing: 8
|
||
NightlightCard { width: parent.width }
|
||
}
|
||
}
|
||
''}
|
||
|
||
// Calendar popup — GNOME-style two-pane panel.
|
||
// Left: navigable month calendar + 7-day weather strip.
|
||
// Right: MPRIS media controls + notification list.
|
||
BarDropdown {
|
||
id: calPopup
|
||
// ceil: Text metrics give fractional sizes; fractional rect
|
||
// edges render as soft 2px lines
|
||
fullWidth: Math.ceil(calRow.width) + 2 * Theme.panelGap
|
||
fullHeight: Math.ceil(calRow.height + 8 + notifCard.height) + 2 * Theme.panelGap
|
||
|
||
// Month being viewed; reset to today when the popup opens
|
||
// (via the setup function passed to bar.toggleDropdown).
|
||
property int viewYear: clockText.now.getFullYear()
|
||
property int viewMonth: clockText.now.getMonth()
|
||
function resetView() {
|
||
viewYear = clockText.now.getFullYear();
|
||
viewMonth = clockText.now.getMonth();
|
||
// Runs on every open: an on-screen toast is redundant
|
||
// once the notification list is visible.
|
||
if (toastItem.visible) toastItem.hideNow();
|
||
}
|
||
function shiftMonth(d) {
|
||
let m = viewMonth + d;
|
||
if (m < 0) { viewMonth = 11; viewYear--; }
|
||
else if (m > 11) { viewMonth = 0; viewYear++; }
|
||
else viewMonth = m;
|
||
}
|
||
|
||
// --- Weather: 7-day forecast, refreshed every 30 min ---
|
||
property var weatherDays: []
|
||
Process {
|
||
id: weatherProc
|
||
command: [Commands.weatherFetch]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
let j = JSON.parse(text);
|
||
let out = [];
|
||
for (let i = 0; i < j.daily.time.length && i < 7; i++) {
|
||
out.push({
|
||
day: new Date(j.daily.time[i] + "T12:00:00").toLocaleDateString(Qt.locale(), "ddd").slice(0, 2),
|
||
code: j.daily.weather_code[i],
|
||
max: Math.round(j.daily.temperature_2m_max[i]),
|
||
min: Math.round(j.daily.temperature_2m_min[i])
|
||
});
|
||
}
|
||
if (out.length > 0) calPopup.weatherDays = out;
|
||
// Feed today's real sunrise/sunset to the
|
||
// night-light schedule (shell.qml).
|
||
if (j.daily.sunrise && j.daily.sunset) {
|
||
let sr = new Date(j.daily.sunrise[0]);
|
||
let ss = new Date(j.daily.sunset[0]);
|
||
bar.shellRoot.sunriseMins = sr.getHours() * 60 + sr.getMinutes();
|
||
bar.shellRoot.sunsetMins = ss.getHours() * 60 + ss.getMinutes();
|
||
bar.shellRoot.applyNightlight();
|
||
}
|
||
} catch (e) { /* keep previous forecast */ }
|
||
}
|
||
}
|
||
}
|
||
Timer {
|
||
interval: 1800000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: weatherProc.running = true
|
||
}
|
||
// WMO weather codes → Material Symbols. Ranges per
|
||
// open-meteo: 0 clear, 1-2 partly, 3 overcast, 45-48 fog,
|
||
// 51-67 drizzle/rain, 71-77 snow, 80-82 rain showers,
|
||
// 85-86 snow showers, 95+ thunder.
|
||
function weatherGlyph(code) {
|
||
if (code === 0) return "clear_day";
|
||
if (code <= 2) return "partly_cloudy_day";
|
||
if (code === 3) return "cloud";
|
||
if (code <= 48) return "foggy";
|
||
if (code <= 67) return "rainy";
|
||
if (code <= 77) return "cloudy_snowing";
|
||
if (code <= 82) return "rainy";
|
||
if (code <= 86) return "cloudy_snowing";
|
||
return "thunderstorm";
|
||
}
|
||
|
||
// --- Media: one card per MPRIS player so Spotify, a
|
||
// browser tab, etc. stay separate instead of collapsing
|
||
// into a single combined player. ---
|
||
property var players: Mpris.players.values.filter(
|
||
p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing)
|
||
|
||
// Best-effort link from an MPRIS player to its Pipewire
|
||
// audio stream (matched by app name), so a card can carry a
|
||
// volume slider via the same per-app path as the volume
|
||
// widget. null when no stream matches.
|
||
function streamFor(player) {
|
||
if (!player) return null;
|
||
let id = (player.identity || "").toLowerCase();
|
||
let ns = Pipewire.nodes.values;
|
||
for (let i = 0; i < ns.length; i++) {
|
||
let n = ns[i];
|
||
if (!n.isStream || !n.audio) continue;
|
||
let an = (n.properties["application.name"] || "").toLowerCase();
|
||
if (an && (an === id || an.includes(id) || id.includes(an))) return n;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Row {
|
||
id: calRow
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
anchors.top: parent.top
|
||
anchors.topMargin: 12
|
||
spacing: 16
|
||
opacity: calPopup.open ? 1.0 : 0.0
|
||
|
||
Behavior on opacity {
|
||
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
|
||
}
|
||
|
||
// ── Left pane: calendar card + weather card ──
|
||
Column {
|
||
id: calLeftCol
|
||
width: 7 * 32 + 16
|
||
spacing: 8
|
||
|
||
// Calendar card
|
||
Card {
|
||
width: parent.width
|
||
// Month header: ‹ [Month Year] › — label click jumps to today
|
||
Item {
|
||
width: parent.width
|
||
height: 28
|
||
|
||
HoverRow {
|
||
width: 28; height: 28; radius: Theme.radiusSmall
|
||
anchors.left: parent.left
|
||
onClicked: calPopup.shiftMonth(-1)
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: "chevron_left"
|
||
color: Theme.base05
|
||
font.pixelSize: 18
|
||
}
|
||
}
|
||
|
||
SText {
|
||
anchors.centerIn: parent
|
||
text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy")
|
||
color: Theme.base05
|
||
font.pixelSize: 14
|
||
font.weight: Font.Medium
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: calPopup.resetView()
|
||
}
|
||
}
|
||
|
||
HoverRow {
|
||
width: 28; height: 28; radius: Theme.radiusSmall
|
||
anchors.right: parent.right
|
||
onClicked: calPopup.shiftMonth(1)
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: "chevron_right"
|
||
color: Theme.base05
|
||
font.pixelSize: 18
|
||
}
|
||
}
|
||
}
|
||
|
||
Row {
|
||
spacing: 0
|
||
Repeater {
|
||
model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||
SText {
|
||
required property var modelData
|
||
width: 32
|
||
horizontalAlignment: Text.AlignHCenter
|
||
text: modelData
|
||
color: Theme.base04
|
||
font.pixelSize: 13
|
||
}
|
||
}
|
||
}
|
||
|
||
Grid {
|
||
columns: 7
|
||
spacing: 0
|
||
Repeater {
|
||
model: 42
|
||
Rectangle {
|
||
required property int index
|
||
property int dayNum: {
|
||
let first = new Date(calPopup.viewYear, calPopup.viewMonth, 1);
|
||
let startDay = (first.getDay() + 6) % 7;
|
||
return index - startDay + 1;
|
||
}
|
||
property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate()
|
||
property bool isToday: dayNum === clockText.now.getDate()
|
||
&& calPopup.viewMonth === clockText.now.getMonth()
|
||
&& calPopup.viewYear === clockText.now.getFullYear()
|
||
width: 32
|
||
height: 26
|
||
radius: Theme.radiusTiny
|
||
color: isToday ? Theme.base03 : "transparent"
|
||
|
||
SText {
|
||
anchors.centerIn: parent
|
||
text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : ""
|
||
color: parent.isToday ? Theme.base05 : Theme.base04
|
||
font.pixelSize: 13
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Weather card
|
||
Rectangle {
|
||
width: parent.width
|
||
height: weatherRow.height + 16
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
visible: calPopup.weatherDays.length > 0
|
||
|
||
Row {
|
||
id: weatherRow
|
||
anchors.top: parent.top
|
||
anchors.topMargin: 8
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
Repeater {
|
||
model: calPopup.weatherDays
|
||
Column {
|
||
required property var modelData
|
||
width: 32
|
||
spacing: 2
|
||
SText {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: modelData.day
|
||
color: Theme.base04
|
||
font.pixelSize: 10
|
||
}
|
||
SIcon {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: calPopup.weatherGlyph(modelData.code)
|
||
color: Theme.base0C
|
||
font.pixelSize: 16
|
||
}
|
||
SText {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: modelData.max + "°"
|
||
color: Theme.base05
|
||
font.pixelSize: 10
|
||
}
|
||
SText {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: modelData.min + "°"
|
||
color: Theme.base03
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Right pane: media + notifications ──
|
||
Column {
|
||
id: calRightCol
|
||
width: 300
|
||
spacing: 8
|
||
|
||
// Media player cards — one per active MPRIS source,
|
||
// so Spotify and a browser tab stay separate.
|
||
Repeater {
|
||
model: calPopup.players
|
||
|
||
Rectangle {
|
||
id: mediaCard
|
||
required property var modelData
|
||
property var pwNode: calPopup.streamFor(modelData)
|
||
width: parent.width
|
||
height: mediaCol.height + 16
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
|
||
PwObjectTracker { objects: mediaCard.pwNode ? [mediaCard.pwNode] : [] }
|
||
|
||
Column {
|
||
id: mediaCol
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
anchors.margins: 8
|
||
spacing: 8
|
||
|
||
// Album art with title + artist to its
|
||
// right; controls and volume below.
|
||
Row {
|
||
width: parent.width
|
||
spacing: 12
|
||
|
||
ClippingRectangle {
|
||
width: 128; height: 128
|
||
radius: Theme.radiusSmall
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: Theme.base02
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
visible: albumArt.status !== Image.Ready
|
||
text: "music_note"
|
||
color: Theme.base04
|
||
font.pixelSize: 48
|
||
}
|
||
Image {
|
||
id: albumArt
|
||
anchors.fill: parent
|
||
fillMode: Image.PreserveAspectCrop
|
||
source: mediaCard.modelData.trackArtUrl
|
||
}
|
||
}
|
||
|
||
Column {
|
||
width: parent.width - 128 - 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 2
|
||
SText {
|
||
width: parent.width
|
||
text: mediaCard.modelData.trackTitle
|
||
color: Theme.base05
|
||
font.pixelSize: 13
|
||
font.weight: Font.Medium
|
||
elide: Text.ElideRight
|
||
}
|
||
SText {
|
||
width: parent.width
|
||
text: mediaCard.modelData.trackArtist
|
||
color: Theme.base04
|
||
font.pixelSize: 11
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
}
|
||
|
||
// Transport controls, centered below.
|
||
Item {
|
||
width: parent.width
|
||
height: 36
|
||
Row {
|
||
anchors.centerIn: parent
|
||
spacing: 4
|
||
Repeater {
|
||
model: [
|
||
{ glyph: "skip_previous", act: "prev" },
|
||
{ glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" },
|
||
{ glyph: "skip_next", act: "next" }
|
||
]
|
||
HoverRow {
|
||
id: mediaBtn
|
||
required property var modelData
|
||
width: 36; height: 36; radius: 18
|
||
onClicked: {
|
||
let p = mediaCard.modelData;
|
||
if (!p) return;
|
||
if (mediaBtn.modelData.act === "prev") p.previous();
|
||
else if (mediaBtn.modelData.act === "next") p.next();
|
||
else p.togglePlaying();
|
||
}
|
||
SIcon {
|
||
anchors.centerIn: parent
|
||
text: mediaBtn.modelData.glyph
|
||
color: Theme.base05
|
||
font.pixelSize: 24
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Per-source volume — same per-app path
|
||
// as the volume widget. Shown when the
|
||
// player's Pipewire stream is matched.
|
||
Row {
|
||
width: parent.width
|
||
spacing: 8
|
||
visible: mediaCard.pwNode !== null && mediaCard.pwNode.audio !== null
|
||
|
||
VolIcon {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 18
|
||
color: Theme.base04
|
||
font.pixelSize: 15
|
||
audioNode: mediaCard.pwNode ? mediaCard.pwNode.audio : null
|
||
}
|
||
|
||
PillSlider {
|
||
width: parent.width - 18 - mediaVolLabel.width - 16
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
height: 16
|
||
trackH: 4
|
||
value: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.min(1, mediaCard.pwNode.audio.volume) : 0
|
||
fillColor: mediaCard.pwNode && mediaCard.pwNode.audio && mediaCard.pwNode.audio.muted ? Theme.base03 : Theme.base0C
|
||
onMoved: (v) => { if (mediaCard.pwNode && mediaCard.pwNode.audio) mediaCard.pwNode.audio.volume = v; }
|
||
}
|
||
|
||
SText {
|
||
id: mediaVolLabel
|
||
width: 36
|
||
text: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.round(mediaCard.pwNode.audio.volume * 100) + "%" : "0%"
|
||
color: Theme.base04
|
||
font.pixelSize: 10
|
||
horizontalAlignment: Text.AlignRight
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
// ── Notifications: spans both columns, below the panes.
|
||
// Header + one Card per notification, matching the media
|
||
// cards instead of base02 blocks nested inside a big card.
|
||
Column {
|
||
id: notifCard
|
||
anchors.top: calRow.bottom
|
||
anchors.topMargin: 8
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
width: calRow.width
|
||
opacity: calRow.opacity
|
||
spacing: 8
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 20
|
||
SText {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: Theme.cardPad
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Notifications"
|
||
font.weight: Font.Medium
|
||
}
|
||
SText {
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: Theme.cardPad
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : ""
|
||
color: Theme.base04
|
||
font.pixelSize: 11
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
let notifs = bar.notifServer.trackedNotifications.values;
|
||
for (let i = notifs.length - 1; i >= 0; i--) {
|
||
notifs[i].dismiss();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SText {
|
||
visible: bar.notifServer.trackedNotifications.values.length === 0
|
||
text: "No notifications"
|
||
color: Theme.base03
|
||
font.pixelSize: 11
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
}
|
||
|
||
Repeater {
|
||
model: bar.notifServer.trackedNotifications
|
||
|
||
Card {
|
||
id: notifItem
|
||
required property var modelData
|
||
readonly property string previewSource: bar.notifPreviewSource(notifItem.modelData)
|
||
width: parent.width
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: Math.max(notifPreview.visible ? 48 : 0, ncBody.height)
|
||
|
||
// Image preview (album art, screenshot thumb…)
|
||
ClippingRectangle {
|
||
id: notifPreview
|
||
visible: notifItem.previewSource !== ""
|
||
anchors.left: parent.left
|
||
anchors.top: parent.top
|
||
width: 48; height: 48
|
||
radius: Theme.radiusTiny
|
||
color: Theme.base01
|
||
Image {
|
||
anchors.fill: parent
|
||
fillMode: Image.PreserveAspectCrop
|
||
source: notifItem.previewSource
|
||
}
|
||
}
|
||
|
||
NotifContent {
|
||
id: ncBody
|
||
notif: notifItem.modelData
|
||
anchors.left: notifPreview.visible ? notifPreview.right : parent.left
|
||
anchors.leftMargin: notifPreview.visible ? 8 : 0
|
||
anchors.right: dismissBtn.left
|
||
anchors.rightMargin: 8
|
||
anchors.top: parent.top
|
||
}
|
||
|
||
SIcon {
|
||
id: dismissBtn
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
text: "close"
|
||
color: dismissMa.containsMouse ? Theme.base05 : Theme.base03
|
||
font.pixelSize: 15
|
||
MouseArea {
|
||
id: dismissMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: notifItem.modelData.dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── OSD: volume / brightness level, shown inline in the bar's
|
||
// empty middle — the gap between the two clusters exists for
|
||
// this. Volume is reactive: Pipewire reports changes from any
|
||
// source (media keys, pavucontrol, a hardware mixer), so no IPC
|
||
// is needed. Brightness arrives via shellRoot's signal instead,
|
||
// because sysfs backlight notifies with POLLPRI, which inotify
|
||
// never sees. Focused monitor only.
|
||
Item {
|
||
id: osdItem
|
||
readonly property bool isFocused: Hyprland.focusedMonitor
|
||
&& Hyprland.focusedMonitor.name === bar.screen.name
|
||
// Every binding evaluates once at load; arm on a delay so
|
||
// a shell restart doesn't flash the slider on screen.
|
||
property bool armed: false
|
||
property bool shown: false
|
||
property string icon: "volume_up"
|
||
property real value: 0
|
||
property color fill: Theme.base0D
|
||
|
||
width: Theme.barWidth
|
||
anchors.horizontalCenter: barBgRect.horizontalCenter
|
||
anchors.top: barTop.bottom
|
||
anchors.bottom: barBottom.top
|
||
opacity: shown ? 1 : 0
|
||
visible: opacity > 0.01
|
||
|
||
Behavior on opacity {
|
||
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
|
||
}
|
||
|
||
function show(ic, v, f) {
|
||
if (!armed || !isFocused) return;
|
||
icon = ic;
|
||
value = v;
|
||
fill = f;
|
||
shown = true;
|
||
osdTimer.restart();
|
||
}
|
||
|
||
Timer { id: osdTimer; interval: 1600; onTriggered: osdItem.shown = false }
|
||
Timer { id: osdArm; interval: 1500; running: true; onTriggered: osdItem.armed = true }
|
||
|
||
function showVolume() {
|
||
// The volume dropdown already shows the level live.
|
||
if (bar.activeDropdown === volDropdown) return;
|
||
show(volWidget.volIcon, volWidget.vol / 100,
|
||
volWidget.muted ? Theme.base03 : Theme.base0D);
|
||
}
|
||
|
||
Connections {
|
||
target: volWidget.sink && volWidget.sink.audio ? volWidget.sink.audio : null
|
||
// `volume` notifies through volumesChanged, not
|
||
// volumeChanged — it is a view over the per-channel
|
||
// volumes vector.
|
||
function onVolumesChanged() { osdItem.showVolume(); }
|
||
function onMutedChanged() { osdItem.showVolume(); }
|
||
}
|
||
|
||
Connections {
|
||
target: bar.shellRoot
|
||
function onOsdBrightness(icon, value) {
|
||
osdItem.show(icon, value, Theme.base0A);
|
||
}
|
||
}
|
||
|
||
Column {
|
||
anchors.centerIn: parent
|
||
spacing: 8
|
||
|
||
// Vertical track — fill grows upward from the bottom.
|
||
// PillSlider is horizontal-only, so this is its own
|
||
// rather than a rotated instance.
|
||
Rectangle {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
width: 6
|
||
height: 96
|
||
radius: 3
|
||
color: Theme.base02
|
||
|
||
Rectangle {
|
||
anchors.bottom: parent.bottom
|
||
width: parent.width
|
||
height: Math.max(parent.width,
|
||
parent.height * Math.max(0, Math.min(1, osdItem.value)))
|
||
radius: parent.radius
|
||
color: osdItem.fill
|
||
Behavior on height { NumberAnimation { duration: 80 } }
|
||
}
|
||
}
|
||
|
||
SIcon {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: osdItem.icon
|
||
color: Theme.base05
|
||
font.pixelSize: 16
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Notification Toast (only on primary screen) ──
|
||
Item {
|
||
id: toastItem
|
||
visible: false
|
||
property var currentNotif: null
|
||
property bool toastOpen: false
|
||
readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"]
|
||
readonly property bool isPrimary: bar.screen === Quickshell.screens[0]
|
||
|
||
x: Theme.barWidth
|
||
y: Theme.frameWidth + 8
|
||
width: _toastRect.width + 4
|
||
height: _toastRect.height + 16
|
||
|
||
Process {
|
||
id: notifSoundProc
|
||
command: [Commands.notifSound, "-i", "message"]
|
||
}
|
||
|
||
Connections {
|
||
target: bar.shellRoot
|
||
function onNotificationReceived() {
|
||
if (!toastItem.isPrimary) return;
|
||
let n = bar.shellRoot.latestNotification;
|
||
// Popup open: the notification list is already on
|
||
// screen — play the sound but skip the toast.
|
||
if (calPopup.visible) {
|
||
if (!toastItem.mutedApps.includes(n.appName)) {
|
||
notifSoundProc.running = true;
|
||
}
|
||
return;
|
||
}
|
||
toastItem.showToast(n);
|
||
}
|
||
}
|
||
|
||
function showToast(notification) {
|
||
currentNotif = notification;
|
||
visible = true;
|
||
toastOpen = true;
|
||
_toastTimer.restart();
|
||
if (!mutedApps.includes(notification.appName)) {
|
||
notifSoundProc.running = true;
|
||
}
|
||
}
|
||
|
||
function dismiss() {
|
||
toastOpen = false;
|
||
_toastCloseDelay.start();
|
||
}
|
||
|
||
// Instant hide, no close animation
|
||
function hideNow() {
|
||
_toastTimer.stop();
|
||
_toastCloseDelay.stop();
|
||
toastOpen = false;
|
||
visible = false;
|
||
}
|
||
|
||
Timer {
|
||
id: _toastTimer
|
||
interval: 5000
|
||
onTriggered: toastItem.dismiss()
|
||
}
|
||
|
||
Timer {
|
||
id: _toastCloseDelay
|
||
interval: 230
|
||
onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; }
|
||
}
|
||
|
||
HoverHandler {
|
||
onHoveredChanged: {
|
||
if (hovered) _toastTimer.stop();
|
||
else _toastTimer.restart();
|
||
}
|
||
}
|
||
|
||
Item {
|
||
id: _toastRect
|
||
anchors.left: parent.left
|
||
anchors.top: parent.top
|
||
anchors.topMargin: 8
|
||
// Reveals rightward now, so width is the animated axis
|
||
// and the card inside keeps a fixed width (otherwise it
|
||
// would squash instead of being wiped in).
|
||
width: toastItem.toastOpen ? 320 : 0
|
||
height: toastCard.height + 2 * Theme.panelGap
|
||
clip: true
|
||
|
||
Behavior on width {
|
||
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
|
||
}
|
||
|
||
// Notification sits in a cardBg card like every other
|
||
// dropdown surface, inset panelGap from the melt panel.
|
||
Rectangle {
|
||
id: toastCard
|
||
anchors.top: parent.top
|
||
anchors.left: parent.left
|
||
anchors.margins: Theme.panelGap
|
||
width: 320 - 2 * Theme.panelGap
|
||
height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16
|
||
radius: Theme.radiusCard
|
||
color: Theme.cardBg
|
||
|
||
property string previewSource: bar.notifPreviewSource(toastItem.currentNotif)
|
||
|
||
// Image preview (screenshot thumb, album art…)
|
||
ClippingRectangle {
|
||
id: toastPreview
|
||
visible: toastCard.previewSource !== ""
|
||
anchors.left: parent.left
|
||
anchors.top: parent.top
|
||
anchors.margins: 8
|
||
width: 48; height: 48
|
||
radius: Theme.radiusTiny
|
||
color: Theme.base01
|
||
Image {
|
||
anchors.fill: parent
|
||
fillMode: Image.PreserveAspectCrop
|
||
source: toastCard.previewSource
|
||
}
|
||
}
|
||
|
||
NotifContent {
|
||
id: toastCol
|
||
notif: toastItem.currentNotif
|
||
anchors.left: toastPreview.visible ? toastPreview.right : parent.left
|
||
anchors.right: toastDismiss.left
|
||
anchors.top: parent.top
|
||
anchors.margins: 8
|
||
onActionInvoked: toastItem.dismiss()
|
||
}
|
||
|
||
SIcon {
|
||
id: toastDismiss
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
anchors.margins: 8
|
||
text: "close"
|
||
color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
|
||
font.pixelSize: 15
|
||
MouseArea {
|
||
id: toastDismissMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|