Build the Panama Hyprland desktop

This commit is contained in:
Gabriel Brown
2026-08-17 10:32:55 -04:00
parent 67033f2a31
commit 5248883e4b
190 changed files with 18554 additions and 34 deletions
@@ -0,0 +1,42 @@
// Persistent, bar-native privacy state. Signal Glass handles transitions; this
// remains until the underlying capture activity actually ends.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: PrivacyState.anyActive
horizontalPadding: 8
onActivated: ShellState.toggle("activity")
Text {
anchors.verticalCenter: parent.verticalCenter
text: {
if (PrivacyState.recordingActive)
return "\u{F044A}";
if (PrivacyState.screenSharingActive)
return "\u{F0379}";
if (PrivacyState.cameraActive)
return "\u{F0100}";
return "\u{F036C}";
}
color: PrivacyState.recordingActive ? Theme.danger : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 14
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: PrivacyState.activeKinds.length > 1
text: String(PrivacyState.activeKinds.length)
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
@@ -0,0 +1,218 @@
// Compact explanation and control surface for persistent privacy indicators.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
visible: ShellState.activityOpen && PrivacyState.anyActive
color: "transparent"
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
implicitWidth: 350
implicitHeight: surface.implicitHeight
WlrLayershell.namespace: "qs-popover-activity"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
readonly property var activities: {
const result = [];
if (PrivacyState.recordingActive)
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true });
if (PrivacyState.screenSharingActive)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive)
result.push({ kind: "camera", glyph: "\u{F0100}", label: "Camera", detail: PrivacyState.cameraApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.microphoneActive)
result.push({ kind: "microphone", glyph: "\u{F036C}", label: "Microphone", detail: PrivacyState.microphoneApp || "Managed by the application", tone: "warn", stoppable: false });
return result;
}
function elapsed(): string {
const total = Capture.recordingSeconds;
const seconds = String(total % 60).padStart(2, "0");
const minutes = Math.floor(total / 60) % 60;
const hours = Math.floor(total / 3600);
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
}
onVisibleChanged: {
if (root.visible)
openAnimation.restart();
}
Connections {
target: PrivacyState
function onAnyActiveChanged(): void {
if (!PrivacyState.anyActive && ShellState.activityOpen)
ShellState.close();
}
}
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
anchors.left: parent.left
anchors.right: parent.right
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
transform: Scale {
id: openScale
origin.x: surface.width
origin.y: 0
xScale: 1
yScale: 1
}
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.popoverPadding
spacing: 8
Text {
width: parent.width
text: "Privacy & activity"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Active access stays visible until it ends."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.08)
}
Repeater {
model: root.activities
Rectangle {
id: activityRow
required property var modelData
width: content.width
height: 50
radius: Theme.cardRadius
border.width: 0
color: Theme.alpha(Theme.fg, 0.045)
Text {
id: activityIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: activityRow.modelData.glyph
color: activityRow.modelData.tone === "danger" ? Theme.danger : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 17
}
Column {
anchors.left: activityIcon.right
anchors.leftMargin: 11
anchors.right: stopButton.visible ? stopButton.left : parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: activityRow.modelData.label
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: activityRow.modelData.detail
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: stopButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
visible: activityRow.modelData.stoppable
width: 54
height: 28
radius: Theme.pillRadius
border.width: 0
color: stopMouse.containsMouse ? Theme.alpha(Theme.danger, 0.26) : Theme.alpha(Theme.danger, 0.15)
Text {
anchors.centerIn: parent
text: "Stop"
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: stopMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Capture.stopRecording()
}
}
}
}
}
}
NumberAnimation {
id: openAnimation
target: openScale
property: "yScale"
from: 0.96
to: 1
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
+134
View File
@@ -0,0 +1,134 @@
// The top bar: edge-to-edge system chrome with no surface of its own. Content
// floats directly over the desktop, while individual controls still provide
// hover and press feedback. The entire bar is a hit target so the top-left
// corner remains a dependable workspace-scroll gesture.
//
// Instantiate one per screen:
//
// Variants {
// model: Quickshell.screens
// Bar {}
// }
//
// Layout mirrors GNOME's: workspaces + vitals left, clock + weather centre,
// media + tray + status cluster right.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.modules.clipboard
import qs.modules.focus
PanelWindow {
id: root
// Set by Variants. Left as a plain property (not `required`) so the bar can
// also be instantiated standalone on the default screen while testing.
property var modelData: null
// Raised when the status cluster is clicked; the shell wires this to the
// quick settings panel, which this module does not own.
signal requestQuickSettings
// Bound by the shell when an idle inhibitor is held, so the cluster can
// show the caffeine glyph.
property alias idleInhibited: statusCluster.idleInhibited
screen: root.modelData
anchors {
top: true
left: true
right: true
}
// PanelWindow defaults to an opaque white background.
color: "transparent"
implicitHeight: Theme.barHeight
exclusiveZone: Theme.barHeight
// Stable namespace for compositor inspection and shell diagnostics.
WlrLayershell.namespace: "qs-bar"
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// The full-width region is intentional: unlike a floating island, a menu
// bar owns the screen edge. In particular, Workspaces starts at x=0 and
// spans the full height so scrolling at the literal corner always works.
mask: Region {
item: barContent
}
Item {
id: barContent
anchors.fill: parent
// ── Left ────────────────────────────────────────────────────────────
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.itemSpacing
Workspaces {
anchors.verticalCenter: parent.verticalCenter
screen: root.screen
}
VitalsWidget {
anchors.verticalCenter: parent.verticalCenter
}
}
// ── Centre ──────────────────────────────────────────────────────────
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Clock {
anchors.verticalCenter: parent.verticalCenter
}
WeatherWidget {
anchors.verticalCenter: parent.verticalCenter
}
}
// ── Right ───────────────────────────────────────────────────────────
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.itemSpacing
ActivityIndicator {
anchors.verticalCenter: parent.verticalCenter
}
FocusIndicator {
anchors.verticalCenter: parent.verticalCenter
}
MediaWidget {
anchors.verticalCenter: parent.verticalCenter
}
ClipboardWidget {
anchors.verticalCenter: parent.verticalCenter
}
Tray {
anchors.verticalCenter: parent.verticalCenter
}
StatusCluster {
id: statusCluster
anchors.verticalCenter: parent.verticalCenter
onRequestQuickSettings: root.requestQuickSettings()
}
}
}
}
@@ -0,0 +1,41 @@
// Month-stepping arrow in CalendarPopup's header. Round hover target, sized
// like GNOME's calendar arrows rather than a bare glyph.
import QtQuick
import qs.config
Rectangle {
id: root
signal activated
required property string glyph
width: 24
height: 24
radius: width / 2
border.width: 0
color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Text {
anchors.centerIn: parent
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.fgDim
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
}
}
@@ -0,0 +1,222 @@
// The calendar that drops out of the clock — GNOME's date menu, minus the
// notification list (that lives in its own panel). Month grid, today marked
// with the accent, arrows to page through months, current weather at the foot.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.widgets
Popover {
id: root
// Popover's container is a plain Item, which does not derive an implicit
// size from its children, so the window has to be sized from the body.
implicitWidth: body.implicitWidth + contentPadding * 2
implicitHeight: body.implicitHeight + contentPadding * 2
readonly property int cellSize: 34
readonly property int cellHeight: 30
// Hours precision is enough: the only thing that has to change on its own
// is which cell counts as "today", and that only moves at midnight.
SystemClock {
id: clock
precision: SystemClock.Hours
}
readonly property int todayYear: clock.date.getFullYear()
readonly property int todayMonth: clock.date.getMonth()
readonly property int todayDay: clock.date.getDate()
// The month currently on screen. Reset to today every time the popover
// opens, so it never comes back showing wherever you paged off to.
property int viewYear: root.todayYear
property int viewMonth: root.todayMonth
onVisibleChanged: if (visible)
root.showToday()
function showToday(): void {
root.viewYear = root.todayYear;
root.viewMonth = root.todayMonth;
}
function stepMonth(delta: int): void {
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
root.viewYear = d.getFullYear();
root.viewMonth = d.getMonth();
}
// Six weeks of cells, so the grid height never changes as you page through
// months. Days from the neighbouring months fill the edges, dimmed.
readonly property var cells: {
const first = new Date(root.viewYear, root.viewMonth, 1);
const offset = first.getDay(); // 0 = Sunday, matching the header row
const inThisMonth = new Date(root.viewYear, root.viewMonth + 1, 0).getDate();
const inPrevMonth = new Date(root.viewYear, root.viewMonth, 0).getDate();
const out = [];
for (let i = 0; i < 42; i++) {
const n = i - offset + 1;
if (n < 1)
out.push({
day: inPrevMonth + n,
current: false
});
else if (n > inThisMonth)
out.push({
day: n - inThisMonth,
current: false
});
else
out.push({
day: n,
current: true
});
}
return out;
}
Column {
id: body
spacing: Theme.itemSpacing
// ── Month header ────────────────────────────────────────────────────
Item {
width: root.cellSize * 7
height: 28
CalendarArrow {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0141}" // md-chevron_left
onActivated: root.stepMonth(-1)
}
Text {
anchors.centerIn: parent
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
color: Theme.fg
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.showToday()
}
}
CalendarArrow {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
glyph: "\u{F0142}" // md-chevron_right
onActivated: root.stepMonth(1)
}
}
// ── Weekday header ──────────────────────────────────────────────────
Row {
Repeater {
model: ["S", "M", "T", "W", "T", "F", "S"]
delegate: Text {
required property string modelData
width: root.cellSize
height: 20
text: modelData
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgMuted
}
}
}
// ── Day grid ────────────────────────────────────────────────────────
Grid {
columns: 7
Repeater {
model: root.cells
delegate: Item {
id: cell
required property var modelData
readonly property bool isToday: cell.modelData.current && cell.modelData.day === root.todayDay && root.viewMonth === root.todayMonth && root.viewYear === root.todayYear
width: root.cellSize
height: root.cellHeight
Rectangle {
anchors.centerIn: parent
width: root.cellHeight - 2
height: root.cellHeight - 2
radius: width / 2
border.width: 0
visible: cell.isToday
color: Theme.accent
}
Text {
anchors.centerIn: parent
text: cell.modelData.day
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: cell.isToday ? Font.DemiBold : Font.Normal
color: {
if (cell.isToday)
return Theme.bgDark;
return cell.modelData.current ? Theme.fg : Theme.fgMuted;
}
}
}
}
}
// ── Weather ─────────────────────────────────────────────────────────
Rectangle {
width: root.cellSize * 7
height: 1
color: Theme.alpha(Theme.fg, 0.1)
visible: Weather.available
}
Row {
spacing: Theme.itemSpacing
visible: Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeTitle
color: Theme.accentAlt
}
Column {
Text {
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
}
Text {
text: Weather.description
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
color: Theme.fgDim
}
}
}
}
}
@@ -0,0 +1,40 @@
// The centre clock, kept in the same place and the same format GNOME had it:
// weekday, date, 12-hour time with seconds. Clicking it opens the calendar,
// exactly like GNOME's date menu.
import Quickshell
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
// Seconds are only ticked when they are actually shown — a per-second wake
// for a clock that displays minutes would be pure waste.
SystemClock {
id: clock
precision: Settings.showSeconds ? SystemClock.Seconds : SystemClock.Minutes
}
readonly property string format: {
const date = Settings.showWeekday ? "ddd MMM d " : "MMM d ";
const time = Settings.use24Hour ? (Settings.showSeconds ? "HH:mm:ss" : "HH:mm") : (Settings.showSeconds ? "h:mm:ss AP" : "h:mm AP");
return date + time;
}
onActivated: ShellState.toggle("notifications")
Text {
anchors.verticalCenter: parent.verticalCenter
text: Qt.formatDateTime(clock.date, root.format)
font.family: Theme.fontFamily
// Seconds tick once a second in the centre of the bar; without tabular
// figures the whole clock shifts sideways on every digit change.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
color: Theme.fg
}
}
@@ -0,0 +1,69 @@
// Now-playing readout. Click toggles play/pause, scroll skips tracks.
//
// "Nothing playing" means no MPRIS player is exposing a track at all — a
// paused player still shows, because otherwise there would be nothing left on
// the bar to press play on.
import QtQuick
import Quickshell.Services.Mpris
import qs.config
import qs.widgets
Pill {
id: root
// Prefer whatever is actually playing; fall back to the first player that
// has a track loaded so a paused session stays reachable. The model's
// insertion behavior is covered by the live-player verification.
readonly property MprisPlayer player: {
const players = Mpris.players ? Mpris.players.values : [];
return players.find(p => p.isPlaying) ?? players.find(p => p.trackTitle) ?? null;
}
readonly property string label: {
if (!root.player)
return "";
const title = root.player.trackTitle ?? "";
const artist = root.player.trackArtist ?? "";
return artist ? artist + " — " + title : title;
}
visible: root.player !== null
horizontalPadding: 8
onActivated: if (root.player?.canTogglePlaying)
root.player.togglePlaying()
// Scroll up = previous, down = next — the same direction as the workspace
// switcher, so the whole bar scrolls consistently.
onScrolled: delta => {
if (!root.player)
return;
if (delta > 0 && root.player.canGoPrevious)
root.player.previous();
else if (delta < 0 && root.player.canGoNext)
root.player.next();
}
Text {
anchors.verticalCenter: parent.verticalCenter
// Shows the action the click will perform, not the current state.
text: root.player?.isPlaying ? "\u{F04C}" : "\u{F04B}" // fa-pause / fa-play
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
color: Theme.accent
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.label
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
// Titles are unbounded; the bar is not. Elide rather than let one
// podcast episode push the tray off the edge.
elide: Text.ElideRight
width: Math.min(implicitWidth, 200)
}
}
@@ -0,0 +1,109 @@
// GNOME's system menu button: a small run of status glyphs — network, volume,
// bluetooth, caffeine — that opens the quick settings panel when clicked.
//
// This widget only *reports* state. The quick settings panel itself lives
// elsewhere; clicking here just raises `requestQuickSettings`.
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Networking
import Quickshell.Services.Pipewire
import QtQuick
import qs.config
import qs.widgets
Pill {
id: root
signal requestQuickSettings
// Set by the shell when an idle inhibitor is held. Lives outside this
// widget because the inhibitor itself is owned by the quick settings side.
property bool idleInhibited: false
onActivated: root.requestQuickSettings()
// ── Audio ───────────────────────────────────────────────────────────────
// Without a tracker, volume and muted silently read as zero/false.
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
// defaultAudioSink is transiently null while pipewire settles.
readonly property real volume: Pipewire.defaultAudioSink?.audio?.volume ?? 0
readonly property bool muted: Pipewire.defaultAudioSink?.audio?.muted ?? false
// ── Network ─────────────────────────────────────────────────────────────
readonly property var devices: Networking.devices ? Networking.devices.values : []
readonly property var wiredDevice: root.devices.find(d => d.type === DeviceType.Wired && d.connected) ?? null
readonly property var wifiDevice: root.devices.find(d => d.type === DeviceType.Wifi) ?? null
readonly property var wifiNetwork: {
const networks = root.wifiDevice?.networks ? root.wifiDevice.networks.values : [];
return networks.find(n => n.connected) ?? null;
}
// ── Bluetooth ───────────────────────────────────────────────────────────
readonly property bool bluetoothOn: Bluetooth.defaultAdapter?.enabled ?? false
readonly property bool bluetoothConnected: {
const devices = Bluetooth.devices ? Bluetooth.devices.values : [];
return devices.some(d => d.connected);
}
StatusGlyph {
glyph: {
if (root.wiredDevice)
return "\u{F0200}"; // md-ethernet
if (!root.wifiNetwork)
return "\u{F092D}"; // md-wifi_strength_off
// NetworkManager reports strength 0-100; some backends normalise to
// 0-1, so accept either rather than showing one bar forever.
const raw = root.wifiNetwork.signalStrength ?? 0;
const strength = raw <= 1 ? raw * 100 : raw;
if (strength >= 75)
return "\u{F0928}"; // md-wifi_strength_4
if (strength >= 50)
return "\u{F0925}"; // md-wifi_strength_3
if (strength >= 25)
return "\u{F0922}"; // md-wifi_strength_2
return "\u{F091F}"; // md-wifi_strength_1
}
color: {
if (root.wiredDevice)
return Theme.fg;
if (!root.wifiNetwork)
return Theme.fgMuted;
return Networking.connectivity === NetworkConnectivity.Full ? Theme.fg : Theme.warn;
}
}
StatusGlyph {
glyph: {
if (root.muted || root.volume <= 0)
return "\u{F075F}"; // md-volume_mute
if (root.volume < 0.34)
return "\u{F057F}"; // md-volume_low
if (root.volume < 0.67)
return "\u{F0580}"; // md-volume_medium
return "\u{F057E}"; // md-volume_high
}
color: root.muted ? Theme.fgMuted : Theme.fg
}
StatusGlyph {
// Bluetooth off is the resting state on this machine, so the glyph is
// simply absent rather than shown crossed out.
visible: root.bluetoothOn
glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth
color: root.bluetoothConnected ? Theme.accent : Theme.fg
}
StatusGlyph {
visible: root.idleInhibited
glyph: "\u{F0176}" // md-coffee
color: Theme.warn
}
}
@@ -0,0 +1,26 @@
// A single status icon in StatusCluster. Fixed width so the cluster doesn't
// shuffle sideways every time a glyph swaps for a wider one.
import QtQuick
import qs.config
Text {
id: root
required property string glyph
anchors.verticalCenter: parent?.verticalCenter ?? undefined
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
horizontalAlignment: Text.AlignHCenter
width: 18
color: Theme.fg
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
@@ -0,0 +1,84 @@
// System tray (StatusNotifierItem / AppIndicator).
//
// GNOME removed this and never replaced it; Nextcloud, RustDesk and Slack all
// still expect it, so it is a first-class part of this bar. Left click
// activates, right click opens the item's own menu — rendered in QML by
// TrayMenu so it matches the rest of the shell instead of dropping a GTK menu
// on top of it.
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import QtQuick
import qs.config
Row {
id: root
spacing: 2
Repeater {
model: SystemTray.items
delegate: Item {
id: entry
required property SystemTrayItem modelData
implicitWidth: 26
implicitHeight: Theme.barHeight - 8
Rectangle {
anchors.fill: parent
radius: Theme.pillRadius
border.width: 0
color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
IconImage {
anchors.centerIn: parent
implicitSize: 16
asynchronous: true
mipmap: true
// `icon` is already a resolved URL — running it through
// Quickshell.iconPath() would break it.
source: entry.modelData.icon
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: event => {
// onlyMenu items have no activate action at all, so a left
// click has to fall through to the menu or nothing happens.
if (event.button === Qt.RightButton || entry.modelData.onlyMenu) {
if (entry.modelData.hasMenu)
menu.visible = !menu.visible;
} else if (event.button === Qt.MiddleButton) {
entry.modelData.secondaryActivate();
} else {
entry.modelData.activate();
}
}
onWheel: event => entry.modelData.scroll(event.angleDelta.y, false)
}
TrayMenu {
id: menu
anchorItem: entry
trayItem: entry.modelData
}
}
}
}
@@ -0,0 +1,101 @@
// A tray item's DBus menu, drawn natively so it inherits the shell's theme.
//
// Submenus drill down in place rather than flying out sideways — a popover with
// a back row, the way GNOME's menus behave, and far less fiddly than chasing a
// cascade of popup windows with the mouse.
import Quickshell
import Quickshell.Services.SystemTray
import QtQuick
import qs.config
import qs.widgets
Popover {
id: root
required property SystemTrayItem trayItem
// Popover's container is a plain Item and does not size itself from its
// children, so the window dimensions come from the column's implicit size.
// Rows take their *actual* width from the window in the other direction —
// the two chains are independent, so there is no binding loop.
implicitWidth: Math.max(body.implicitWidth + contentPadding * 2, 200)
implicitHeight: body.implicitHeight + contentPadding * 2
// Stack of submenu handles; empty means we are at the top level.
property var menuStack: []
onVisibleChanged: if (!visible)
root.menuStack = []
QsMenuOpener {
id: opener
menu: root.menuStack.length > 0 ? root.menuStack[root.menuStack.length - 1] : root.trayItem.menu
}
Column {
id: body
width: parent.width
spacing: 2
// Back row, only while inside a submenu.
TrayMenuRow {
width: parent.width
visible: root.menuStack.length > 0
label: "\u{F0141} Back" // md-chevron_left
onActivated: root.menuStack = root.menuStack.slice(0, -1)
}
Repeater {
model: opener.children
delegate: Loader {
id: entryLoader
required property var modelData
width: body.width
sourceComponent: entryLoader.modelData.isSeparator ? separator : row
Component {
id: separator
Item {
implicitHeight: 7
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.1)
}
}
}
Component {
id: row
TrayMenuRow {
// QsMenuEntry calls it `text`, not `label`.
label: entryLoader.modelData.text
icon: entryLoader.modelData.icon
rowEnabled: entryLoader.modelData.enabled
buttonType: entryLoader.modelData.buttonType
checkState: entryLoader.modelData.checkState
submenu: entryLoader.modelData.hasChildren
onActivated: {
if (entryLoader.modelData.hasChildren) {
root.menuStack = root.menuStack.concat([entryLoader.modelData]);
return;
}
entryLoader.modelData.triggered();
root.visible = false;
}
}
}
}
}
}
}
@@ -0,0 +1,85 @@
// One row of a TrayMenu: optional icon, label, and either a check/radio mark
// or a submenu chevron on the right.
import Quickshell.Widgets
import QtQuick
import qs.config
Rectangle {
id: root
signal activated
property string label: ""
property string icon: ""
// Named `rowEnabled` rather than `enabled`: overriding Item.enabled would
// also disable the MouseArea, and we still want the row to swallow clicks
// rather than let them fall through to whatever is behind the popover.
property bool rowEnabled: true
property int buttonType: 0 // QsMenuButtonType: 0 None, 1 CheckBox, 2 RadioButton
property int checkState: Qt.Unchecked
property bool submenu: false
implicitWidth: rowContent.implicitWidth + 20
implicitHeight: 30
radius: Theme.cardRadius
border.width: 0
color: mouse.containsMouse && root.rowEnabled ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
Row {
id: rowContent
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 8
IconImage {
anchors.verticalCenter: parent.verticalCenter
implicitSize: 16
asynchronous: true
visible: root.icon !== ""
source: root.icon
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.label
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: root.rowEnabled ? Theme.fg : Theme.fgMuted
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.accent
text: {
if (root.submenu)
return "\u{F0142}"; // md-chevron_right
if (root.buttonType !== 0 && root.checkState === Qt.Checked)
return "\u{F012C}"; // md-check
return "";
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: root.rowEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.rowEnabled)
root.activated()
}
}
@@ -0,0 +1,53 @@
// One glyph + percentage pair inside VitalsWidget. Split out so the three
// fields cannot drift apart in spacing, width or colour thresholds.
import QtQuick
import qs.config
Row {
id: root
required property string glyph
required property real value
property int warnAt: 70
property int dangerAt: 90
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.fgDim
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Math.round(root.value) + "%"
// Tabular figures and a fixed width so the row doesn't jitter as digits
// change — a moving bar is far more distracting than a wide one. Sans,
// not mono: this is UI text, not terminal output.
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
width: 30
color: {
if (root.value >= root.dangerAt)
return Theme.danger;
if (root.value >= root.warnAt)
return Theme.warn;
return Theme.fg;
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
}
@@ -0,0 +1,43 @@
// CPU / RAM / GPU readout, in that order — the exact set and order the GNOME
// Vitals extension was configured with (_processor_usage_, _memory_usage_,
// _gpu#1_usage_).
//
// Values are colour-coded rather than graphed: at bar size a number you can
// glance at beats a sparkline you have to squint at.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
interactive: false
Row {
spacing: Theme.itemSpacing
VitalsField {
glyph: "\u{F4BC}" // oct-cpu
value: Vitals.cpu
visible: Settings.showCpu
}
VitalsField {
glyph: "\u{F035B}" // md-memory
value: Vitals.memory
visible: Settings.showMemory
// Memory sitting high is normal (page cache, browsers); only start
// complaining much later than for CPU.
warnAt: 80
dangerAt: 92
}
VitalsField {
glyph: "\u{F08AE}" // md-expansion_card
value: Vitals.gpu
visible: Settings.showGpu && Vitals.gpuAvailable
}
}
}
@@ -0,0 +1,31 @@
// Temperature and condition glyph, sitting next to the clock the way GNOME's
// weather sat inside the date menu button. Hidden entirely until a fetch has
// succeeded — an empty slot reads better than a placeholder.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
interactive: false
visible: Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
text: Weather.icon
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
color: Theme.accentAlt
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
}
}
@@ -0,0 +1,161 @@
// Workspace indicator, in the shape GNOME's workspace switcher had: a row of
// dots, the current one drawn as a wide accent pill. Hyprland workspaces are
// dynamic here, so the row grows and shrinks as workspaces come and go.
//
// Scrolling anywhere over the row switches workspace — a habit carried over
// from the GNOME top panel and explicitly worth keeping.
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config
Item {
id: root
// The QsScreen this bar instance lives on. When set, only that monitor's
// workspaces are shown — otherwise a second monitor would duplicate them.
property var screen: null
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
// Special workspaces (negative ids) are scratchpads, not places you switch
// to by index — GNOME had no equivalent, so they stay out of the row.
readonly property var workspaces: {
const all = Hyprland.workspaces ? Hyprland.workspaces.values : [];
return all.filter(ws => ws && ws.id > 0 && (!root.monitor || !ws.monitor || ws.monitor === root.monitor)).sort((a, b) => a.id - b.id);
}
// The delegate currently focused. The accent pill tracks its geometry, which
// is what makes the pill appear to slide from one workspace to the next.
property Item focusedItem: null
readonly property int dotSize: 8
readonly property int slotWidth: 16
readonly property int focusedSlotWidth: 28
implicitWidth: row.implicitWidth + 12
// Full bar height makes (0, 0) part of the wheel target. The dots stay
// optically centred by the row and active-pill geometry below.
implicitHeight: Theme.barHeight
// Scroll over the padding either side of the row, not just over a dot.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: event => root.cycle(event.angleDelta.y)
}
Rectangle {
id: activePill
// Behind the dots: the focused delegate hides its own dot, so nothing
// is ever drawn on top of the pill.
z: -1
visible: root.focusedItem !== null
height: root.dotSize
radius: Theme.pillRadius
border.width: 0 // QTBUG-137166: rounded rects can lose their corners
// The prism, as a fill rather than a hairline. This is the one element
// in the bar that is always coloured, so it is where the pair belongs.
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0.0
color: Theme.accent
}
GradientStop {
position: 1.0
color: Theme.accentSecondary
}
}
y: (root.height - height) / 2
x: row.x + (root.focusedItem ? root.focusedItem.x : 0)
width: root.focusedItem ? root.focusedItem.width : 0
Behavior on x {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
Behavior on width {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
Row {
id: row
anchors.centerIn: parent
spacing: 4
Repeater {
model: root.workspaces
delegate: Item {
id: slot
required property var modelData
readonly property bool isFocused: slot.modelData.focused
readonly property bool isOccupied: slot.modelData.toplevels ? slot.modelData.toplevels.values.length > 0 : false
implicitWidth: slot.isFocused ? root.focusedSlotWidth : root.slotWidth
implicitHeight: root.height
Behavior on implicitWidth {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
onIsFocusedChanged: if (slot.isFocused)
root.focusedItem = slot
Component.onCompleted: if (slot.isFocused)
root.focusedItem = slot
Rectangle {
anchors.centerIn: parent
width: root.dotSize
height: root.dotSize
radius: width / 2
border.width: 0
visible: !slot.isFocused
color: {
if (slot.modelData.urgent)
return Theme.urgent;
return slot.isOccupied ? Theme.fg : Theme.alpha(Theme.fg, 0.3);
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: slot.modelData.activate()
onWheel: event => root.cycle(event.angleDelta.y)
}
}
}
}
// Scroll up goes to the previous workspace, down to the next — the same
// direction GNOME used. Relative focus rather than by index so it keeps
// working with dynamic workspaces.
function cycle(angleDeltaY: int): void {
Hyprland.dispatch(angleDeltaY > 0 ? 'hl.dsp.focus({ workspace = "-1" })' : 'hl.dsp.focus({ workspace = "+1" })');
}
}