Build the Panama Hyprland desktop
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// The month grid from GNOME's date menu.
|
||||
//
|
||||
// Six week rows always, so the grid height never changes as you page through
|
||||
// months and the panel below it never jumps. Days belonging to the neighbouring
|
||||
// months fill the edges, dimmed. Today wears the accent.
|
||||
//
|
||||
// Deliberately non-interactive apart from the header: there is no calendar
|
||||
// backend behind this, so a day cell that highlighted on hover would be
|
||||
// promising a click that does nothing.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitHeight: body.implicitHeight
|
||||
|
||||
// Cells divide the available width evenly; the height is fixed so the grid
|
||||
// stays roughly square regardless of how wide the panel gets.
|
||||
readonly property real cellWidth: root.width / 7
|
||||
readonly property int cellHeight: 32
|
||||
|
||||
// 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. DateMenu calls showToday() every time the
|
||||
// panel opens, so it never comes back showing wherever you paged off to.
|
||||
property int viewYear: root.todayYear
|
||||
property int viewMonth: root.todayMonth
|
||||
|
||||
readonly property bool onCurrentMonth: root.viewYear === root.todayYear && root.viewMonth === root.todayMonth
|
||||
|
||||
function showToday(): void {
|
||||
root.viewYear = root.todayYear;
|
||||
root.viewMonth = root.todayMonth;
|
||||
}
|
||||
|
||||
function stepMonth(delta: int): void {
|
||||
// Built through Date rather than by hand so December -> January rolls
|
||||
// the year over for us.
|
||||
const d = new Date(root.viewYear, root.viewMonth + delta, 1);
|
||||
root.viewYear = d.getFullYear();
|
||||
root.viewMonth = d.getMonth();
|
||||
}
|
||||
|
||||
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
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
spacing: 6
|
||||
|
||||
// ── Month header ────────────────────────────────────────────────────
|
||||
// Both arrows sit on the right so the left slot is free for the "Today"
|
||||
// affordance, which only exists while you are looking at another month.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 30
|
||||
|
||||
Rectangle {
|
||||
id: todayPill
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: todayLabel.implicitWidth + 18
|
||||
height: 24
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: !root.onCurrentMonth
|
||||
color: todayMouse.containsMouse ? Theme.alpha(Theme.accent, 0.30) : Theme.alpha(Theme.accent, 0.16)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: todayMouse.containsMouse ? Theme.durFast : Theme.durNormal
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: todayLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Today"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: todayMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.showToday()
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the row rather than between the pill and the arrows,
|
||||
// so it does not shift sideways when the pill appears.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatDateTime(new Date(root.viewYear, root.viewMonth, 1), "MMMM yyyy")
|
||||
font.family: Theme.fontFamily
|
||||
// The year ticks in place when you page across a boundary.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
MonthArrow {
|
||||
glyph: "\u{F0141}" // md-chevron_left
|
||||
onActivated: root.stepMonth(-1)
|
||||
}
|
||||
|
||||
MonthArrow {
|
||||
glyph: "\u{F0142}" // md-chevron_right
|
||||
onActivated: root.stepMonth(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekday header ──────────────────────────────────────────────────
|
||||
Row {
|
||||
width: parent.width
|
||||
|
||||
Repeater {
|
||||
model: ["S", "M", "T", "W", "T", "F", "S"]
|
||||
|
||||
delegate: Text {
|
||||
required property string modelData
|
||||
|
||||
width: root.cellWidth
|
||||
height: 22
|
||||
text: modelData
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day grid ────────────────────────────────────────────────────────
|
||||
Grid {
|
||||
width: parent.width
|
||||
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.onCurrentMonth
|
||||
|
||||
width: root.cellWidth
|
||||
height: root.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: root.cellHeight - 4
|
||||
height: root.cellHeight - 4
|
||||
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.features: Theme.tabularFigures
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// GNOME's date/time menu: the calendar and the message tray in one panel,
|
||||
// dropping from the top centre because that is where GNOME put it and the
|
||||
// muscle memory is the whole point.
|
||||
//
|
||||
// A layer-shell surface rather than a PopupWindow because the inline reply
|
||||
// fields have to be typeable, and because the clock it hangs from is owned by a
|
||||
// different module.
|
||||
//
|
||||
// Layer-shell centres a surface on whichever axis it is not anchored to, so
|
||||
// anchoring only `top` is what puts it in the middle.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
// "notifications" is the existing overlay key, so Super+B, the notification
|
||||
// IPC target and the clock all land here.
|
||||
visible: ShellState.notificationsOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: panel.implicitWidth
|
||||
implicitHeight: panel.implicitHeight
|
||||
|
||||
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
||||
WlrLayershell.namespace: "qs-popover-datemenu"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// OnDemand rather than Exclusive: the panel must be able to take typing for
|
||||
// inline replies, but must not steal it while merely visible.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible) {
|
||||
// Opening the tray is what marks everything read, same as GNOME.
|
||||
Notifs.markAllRead();
|
||||
panel.prepare();
|
||||
openAnim.restart();
|
||||
} else {
|
||||
panel.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Closes the panel when a click lands anywhere else on the desktop.
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: ShellState.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
|
||||
// border so the two don't fight for the same row of pixels.
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
// Grows out of the top edge it is anchored to, rather than out of its
|
||||
// own centre — the panel should look like it came from the bar.
|
||||
transform: Scale {
|
||||
id: openScale
|
||||
origin.x: surface.width / 2
|
||||
origin.y: 0
|
||||
xScale: 1
|
||||
yScale: 1
|
||||
}
|
||||
|
||||
// Escape reaches here by propagating up from whatever holds focus
|
||||
// (usually a reply field), so it works in every sub-state.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
DateMenuPanel {
|
||||
id: panel
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: openAnim
|
||||
target: openScale
|
||||
property: "yScale"
|
||||
from: 0.94
|
||||
to: 1.0
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// The contents of the date menu, in GNOME's order: date header, notifications,
|
||||
// calendar, weather.
|
||||
//
|
||||
// Kept separate from DateMenu.qml (the PanelWindow) so it can be dropped into a
|
||||
// FloatingWindow for testing — layer-shell surfaces cannot map outside a
|
||||
// wlroots compositor.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitWidth: 480
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
|
||||
// Called by the window when it opens and closes, so the panel is always in
|
||||
// a known state rather than wherever the last visit left it.
|
||||
function prepare(): void {
|
||||
calendar.showToday();
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
notifications.reset();
|
||||
}
|
||||
|
||||
// Minutes precision: the header only shows a date, so this exists purely to
|
||||
// roll it over at midnight without a per-second wake.
|
||||
SystemClock {
|
||||
id: clock
|
||||
precision: SystemClock.Minutes
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.sectionSpacing
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 34
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.right: headerActions.left
|
||||
anchors.rightMargin: Theme.itemSpacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDateTime(clock.date, "dddd, MMMM d")
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
// The day number changes in place at midnight.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
id: headerActions
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
// Labelled rather than icon-only: an unexplained crossed-out
|
||||
// bell is exactly the kind of thing a normal person has to
|
||||
// click to find out about.
|
||||
Rectangle {
|
||||
id: dndPill
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: dndRow.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
|
||||
color: {
|
||||
if (Notifs.doNotDisturb)
|
||||
return dndMouse.containsMouse ? Theme.alpha(Theme.accent, 0.42) : Theme.alpha(Theme.accent, 0.28);
|
||||
return dndMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08);
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: dndMouse.containsMouse ? Theme.durFast : Theme.durNormal
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: dndRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 14
|
||||
icon: "notifications-disabled-symbolic"
|
||||
iconFallback: "user-invisible-symbolic"
|
||||
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Do Not Disturb"
|
||||
color: Notifs.doNotDisturb ? Theme.accent : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: dndMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: clearPill
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: clearLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
// Hidden rather than disabled: there is nothing ambiguous
|
||||
// about an empty tray, so the control has no job.
|
||||
visible: Notifs.hasNotifications
|
||||
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: clearMouse.containsMouse ? Theme.durFast : Theme.durNormal
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clearLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Clear all"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: clearMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Notifs.dismissAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
MediaCard {
|
||||
id: media
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
border.width: 0
|
||||
visible: media.visible
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
NotificationList {
|
||||
id: notifications
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
// ── Calendar ────────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
CalendarGrid {
|
||||
id: calendar
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────
|
||||
// Separator and card share one condition so the panel does not end on a
|
||||
// rule with nothing under it.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
border.width: 0
|
||||
visible: Weather.available
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
WeatherCard {
|
||||
width: parent.width
|
||||
visible: Weather.available
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Compact media controls in the date menu: artwork, useful metadata, and the
|
||||
// three controls people expect. It disappears completely without a track.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
readonly property MprisPlayer player: {
|
||||
const players = Mpris.players ? Mpris.players.values : [];
|
||||
return players.find(p => p.isPlaying) ?? players.find(p => p.trackTitle) ?? null;
|
||||
}
|
||||
|
||||
visible: root.player !== null
|
||||
implicitHeight: visible ? 76 : 0
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
id: artwork
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 56
|
||||
height: 56
|
||||
radius: 10
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.accentAlt, 0.16)
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.player?.trackArtUrl ?? ""
|
||||
asynchronous: true
|
||||
cache: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 24
|
||||
icon: "audio-x-generic-symbolic"
|
||||
iconFallback: "multimedia-player-symbolic"
|
||||
tint: Theme.accentAlt
|
||||
visible: !root.player?.trackArtUrl
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: artwork.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: controls.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.player?.trackTitle || "Unknown track"
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.player?.trackArtist || root.player?.identity || "Media"
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: controls
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
MediaButton {
|
||||
icon: "media-skip-backward-symbolic"
|
||||
enabled: root.player?.canGoPrevious ?? false
|
||||
onClicked: root.player?.previous()
|
||||
}
|
||||
|
||||
MediaButton {
|
||||
icon: root.player?.isPlaying ? "media-playback-pause-symbolic" : "media-playback-start-symbolic"
|
||||
enabled: root.player?.canTogglePlaying ?? false
|
||||
emphasized: true
|
||||
onClicked: root.player?.togglePlaying()
|
||||
}
|
||||
|
||||
MediaButton {
|
||||
icon: "media-skip-forward-symbolic"
|
||||
enabled: root.player?.canGoNext ?? false
|
||||
onClicked: root.player?.next()
|
||||
}
|
||||
}
|
||||
|
||||
component MediaButton: Rectangle {
|
||||
id: button
|
||||
|
||||
required property string icon
|
||||
property bool emphasized: false
|
||||
signal clicked
|
||||
|
||||
width: emphasized ? 34 : 30
|
||||
height: width
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
opacity: enabled ? 1 : 0.35
|
||||
color: {
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(emphasized ? Theme.accent : Theme.fg, 0.34);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(emphasized ? Theme.accent : Theme.fg, 0.22);
|
||||
return emphasized ? Theme.alpha(Theme.accent, 0.14) : "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 15
|
||||
icon: button.icon
|
||||
tint: button.emphasized ? Theme.accent : Theme.fg
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
enabled: button.enabled
|
||||
hoverEnabled: true
|
||||
cursorShape: button.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: button.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Month-stepping arrow in CalendarGrid's header. A round hover target sized
|
||||
// like GNOME's calendar arrows rather than a bare glyph, so it reads as a
|
||||
// button before the pointer is anywhere near it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal activated
|
||||
|
||||
required property string glyph
|
||||
|
||||
implicitWidth: 26
|
||||
implicitHeight: 26
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: mouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
|
||||
|
||||
// Fast in, relaxed out — the arrow answers the pointer, so arriving should
|
||||
// feel immediate and leaving should not snap.
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: mouse.containsMouse ? Theme.durFast : Theme.durNormal
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.glyph
|
||||
// fontMono is the Nerd Font: used here to draw an icon, never text.
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: mouse.containsMouse ? Theme.fg : Theme.fgDim
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activated()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// One app's worth of notifications in the date menu, GNOME-style: a header
|
||||
// carrying the app's identity and a clear button, over a collapsible stack of
|
||||
// cards.
|
||||
//
|
||||
// The header is the disclosure control in its entirety — a 4px chevron is a
|
||||
// poor click target, and GNOME lets you hit the whole row.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.notifications
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// One entry from Notifs.groups: { app, icon, desktopEntry, items }.
|
||||
required property var group
|
||||
|
||||
property bool expanded: true
|
||||
|
||||
signal toggled
|
||||
signal cleared
|
||||
|
||||
spacing: 4
|
||||
|
||||
// The app icon the notifications themselves declared, falling back to the
|
||||
// desktop entry — same resolution order NotificationCard uses.
|
||||
readonly property string iconSource: {
|
||||
if (root.group.icon)
|
||||
return Quickshell.iconPath(root.group.icon, true);
|
||||
const entry = DesktopEntries.byId(root.group.desktopEntry);
|
||||
return entry ? Quickshell.iconPath(entry.icon, true) : "";
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: header
|
||||
width: parent.width
|
||||
height: 28
|
||||
radius: Theme.cardRadius - 4
|
||||
border.width: 0
|
||||
color: headerMouse.containsMouse ? Theme.alpha(Theme.fg, 0.07) : "transparent"
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: headerMouse.containsMouse ? Theme.durFast : Theme.durNormal
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: headerMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled()
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 6
|
||||
anchors.right: clearButton.left
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitSize: 15
|
||||
asynchronous: true
|
||||
source: root.iconSource
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.group.app
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
// A bare count next to a collapsed group is the only way to know
|
||||
// how much is hidden, so it is always shown once there is more
|
||||
// than one.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: countLabel.implicitWidth + 12
|
||||
height: 16
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: root.group.items.length > 1
|
||||
color: Theme.alpha(Theme.fg, 0.12)
|
||||
|
||||
Text {
|
||||
id: countLabel
|
||||
anchors.centerIn: parent
|
||||
text: root.group.items.length
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: clearButton
|
||||
anchors.right: chevron.left
|
||||
anchors.rightMargin: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 22
|
||||
iconSize: 12
|
||||
tint: Theme.fgMuted
|
||||
icon: "edit-clear-all-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: root.cleared()
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
id: chevron
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 12
|
||||
icon: "pan-down-symbolic"
|
||||
iconFallback: "go-down-symbolic"
|
||||
tint: Theme.fgMuted
|
||||
|
||||
// Pointing down when open, right when closed — the standard
|
||||
// disclosure direction, animated only on the click that caused it.
|
||||
rotation: root.expanded ? 0 : -90
|
||||
|
||||
Behavior on rotation {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Section animates its own height and hides itself at zero, which also
|
||||
// collapses the spacing the parent Column would otherwise leave behind.
|
||||
Section {
|
||||
width: parent.width
|
||||
expanded: root.expanded
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 4
|
||||
|
||||
Repeater {
|
||||
model: root.group.items
|
||||
|
||||
NotificationCard {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
compact: true
|
||||
notification: modelData
|
||||
onDismissed: Notifs.dismiss(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// The message tray inside the date menu: everything that has arrived, newest
|
||||
// first, grouped by app, scrolling once it outgrows `maxHeight`.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int maxHeight: 420
|
||||
|
||||
implicitHeight: Notifs.hasNotifications ? list.implicitHeight : empty.implicitHeight
|
||||
|
||||
// Which app groups are collapsed, keyed by app name. Kept here rather than
|
||||
// on the group itself because Notifs.groups is rebuilt from scratch on
|
||||
// every change to history, which destroys and recreates the delegates.
|
||||
property var collapsedApps: ({})
|
||||
|
||||
function toggleApp(app: string): void {
|
||||
// Reassigned rather than mutated: a property holding a JS object only
|
||||
// re-evaluates bindings when the reference itself changes.
|
||||
const next = Object.assign({}, root.collapsedApps);
|
||||
if (next[app])
|
||||
delete next[app];
|
||||
else
|
||||
next[app] = true;
|
||||
root.collapsedApps = next;
|
||||
}
|
||||
|
||||
// Called when the panel closes, so it always reopens fully expanded.
|
||||
function reset(): void {
|
||||
root.collapsedApps = ({});
|
||||
}
|
||||
|
||||
Item {
|
||||
id: empty
|
||||
anchors.fill: parent
|
||||
implicitHeight: 132
|
||||
visible: !Notifs.hasNotifications
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Notifs.doNotDisturb ? "Do Not Disturb is on" : "No notifications"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
maxHeight: root.maxHeight
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Notifs.hasNotifications
|
||||
|
||||
Repeater {
|
||||
model: Notifs.groups
|
||||
|
||||
NotificationGroup {
|
||||
required property var modelData
|
||||
|
||||
width: list.width
|
||||
group: modelData
|
||||
expanded: !root.collapsedApps[modelData.app]
|
||||
onToggled: root.toggleApp(modelData.app)
|
||||
onCleared: Notifs.dismissApp(modelData.app)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Current conditions, at the foot of the date menu — GNOME's weather section.
|
||||
//
|
||||
// The whole card disappears when services/Weather.qml has nothing: a weather
|
||||
// widget that cannot reach the network must go quiet, not show an error. The
|
||||
// caller collapses the separator above it off the same `Weather.available`.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
implicitHeight: 62
|
||||
radius: Theme.cardRadius
|
||||
// Cards inside an already-glass panel do not get a prism edge — the edge
|
||||
// marks a pane of glass, not every item sitting on one.
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
Text {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Weather.icon
|
||||
// fontMono is the Nerd Font, used here purely to draw an icon.
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 26
|
||||
color: Theme.accentAlt
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 14
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
// The temperature changes in place on every refresh.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
// Description is empty for weather codes we don't have a label for,
|
||||
// in which case the row is just the location.
|
||||
text: Weather.description === "" ? Settings.weatherLocation : Weather.description + " · " + Settings.weatherLocation
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module qs.modules.datemenu
|
||||
CalendarGrid 1.0 CalendarGrid.qml
|
||||
DateMenu 1.0 DateMenu.qml
|
||||
DateMenuPanel 1.0 DateMenuPanel.qml
|
||||
MediaCard 1.0 MediaCard.qml
|
||||
MonthArrow 1.0 MonthArrow.qml
|
||||
NotificationGroup 1.0 NotificationGroup.qml
|
||||
NotificationList 1.0 NotificationList.qml
|
||||
WeatherCard 1.0 WeatherCard.qml
|
||||
Reference in New Issue
Block a user