Build the Panama Hyprland desktop
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" })');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user