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" })');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// The floating control panel at the bottom of the capture overlay.
|
||||
//
|
||||
// Layout is a deliberate copy of GNOME 42's screenshot UI: the area segmented
|
||||
// control on top, and below it the shutter dead-centre with "Show Pointer" on
|
||||
// the left and the screenshot/screencast switch on the right.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal captureRequested
|
||||
|
||||
// Set by the overlay so the shutter can grey out when Window mode has
|
||||
// nothing pickable (no Hyprland, or an empty workspace).
|
||||
property bool captureEnabled: true
|
||||
|
||||
implicitWidth: 760
|
||||
implicitHeight: col.implicitHeight + 2 * 20
|
||||
radius: 28
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
|
||||
Column {
|
||||
id: col
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 20
|
||||
}
|
||||
spacing: 18
|
||||
|
||||
// ── Area mode ───────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitWidth: modes.implicitWidth + 8
|
||||
implicitHeight: modes.implicitHeight + 8
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
Row {
|
||||
id: modes
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Screen"
|
||||
active: Capture.mode === "screen"
|
||||
onClicked: Capture.mode = "screen"
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Window"
|
||||
active: Capture.mode === "window"
|
||||
onClicked: Capture.mode = "window"
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Selection"
|
||||
active: Capture.mode === "selection"
|
||||
onClicked: Capture.mode = "selection"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pointer toggle · shutter · capture type ─────────────────────────
|
||||
Item {
|
||||
width: col.width
|
||||
height: 68
|
||||
|
||||
// "Show Pointer". wf-recorder has no cursor switch in this build,
|
||||
// so it is disabled while the screencast type is selected instead
|
||||
// of silently lying about what it does.
|
||||
Rectangle {
|
||||
id: pointerToggle
|
||||
anchors {
|
||||
left: parent.left
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
implicitWidth: pointerRow.implicitWidth + 26
|
||||
implicitHeight: 40
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
opacity: Capture.recordMode || Capture.intelligenceMode ? 0.4 : 1.0
|
||||
color: pointerMouse.containsMouse && !Capture.recordMode && !Capture.intelligenceMode ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: pointerRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Capture.showPointer ? "" : ""
|
||||
color: Capture.showPointer ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Show Pointer"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: pointerMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !Capture.recordMode && !Capture.intelligenceMode
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Capture.showPointer = !Capture.showPointer
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shutter ─────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
id: shutter
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: 64
|
||||
implicitHeight: 64
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
opacity: root.captureEnabled ? 1.0 : 0.45
|
||||
color: shutterMouse.containsMouse && root.captureEnabled ? Theme.alpha(Theme.fg, 0.3) : Theme.alpha(Theme.fg, 0.18)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: shutterMouse.pressed ? 40 : 46
|
||||
height: width
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: Capture.recordMode ? Theme.danger : (Capture.intelligenceMode ? Theme.accent : Theme.fg)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: Capture.intelligenceMode
|
||||
text: ""
|
||||
color: Theme.bgDark
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 21
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: shutterMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: root.captureEnabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.captureRequested()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screenshot / screencast ─────────────────────────────────────
|
||||
Rectangle {
|
||||
anchors {
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
implicitWidth: types.implicitWidth + 8
|
||||
implicitHeight: types.implicitHeight + 8
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
Row {
|
||||
id: types
|
||||
anchors.centerIn: parent
|
||||
spacing: 2
|
||||
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Shot"
|
||||
active: !Capture.recordMode && !Capture.intelligenceMode
|
||||
onClicked: Capture.selectScreenshot()
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Record"
|
||||
active: Capture.recordMode
|
||||
onClicked: Capture.selectRecording()
|
||||
}
|
||||
ModeButton {
|
||||
glyph: ""
|
||||
label: "Read"
|
||||
active: Capture.intelligenceMode
|
||||
onClicked: Capture.selectIntelligence()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// The GNOME 42 screenshot UI.
|
||||
//
|
||||
// Print freezes the screen, this overlay paints that frozen frame full-bleed
|
||||
// and puts the picker on top of it. Because the frame is a still, nothing under
|
||||
// the overlay animates while the user aims, and the overlay can never end up in
|
||||
// its own screenshot.
|
||||
//
|
||||
// The grab happens in services/Capture.qml *before* ShellState flips to
|
||||
// "capture", so by the time this window maps the backdrop is already on disk.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: ShellState.captureOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
// Fullscreen overlays must not reserve space, or every window on the
|
||||
// workspace gets resized as the picker opens and closes.
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-capture" // matched by a layerrule in hypr/rules.lua
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!win.visible)
|
||||
return;
|
||||
// Hyprland reports window geometry in layout-global coordinates; the
|
||||
// picker works in window-local ones. On a single monitor this is 0,0.
|
||||
Capture.originX = win.screen ? win.screen.x : 0;
|
||||
Capture.originY = win.screen ? win.screen.y : 0;
|
||||
keys.forceActiveFocus();
|
||||
}
|
||||
|
||||
// Fire whatever the current mode has selected.
|
||||
function capture(): void {
|
||||
if (Capture.mode === "screen") {
|
||||
Capture.commit(null);
|
||||
return;
|
||||
}
|
||||
if (Capture.mode === "window") {
|
||||
const r = picker.selectedRect;
|
||||
if (r)
|
||||
Capture.commit(r);
|
||||
return;
|
||||
}
|
||||
if (!selection.hasSelection)
|
||||
return;
|
||||
const s = selection.selection;
|
||||
Capture.commit(Qt.rect(s.x + Capture.originX, s.y + Capture.originY, s.width, s.height));
|
||||
}
|
||||
|
||||
function cycleMode(step: int): void {
|
||||
const order = ["screen", "window", "selection"];
|
||||
const i = order.indexOf(Capture.mode);
|
||||
Capture.mode = order[(i + step + order.length) % order.length];
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: keys
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Escape:
|
||||
Capture.close();
|
||||
break;
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
case Qt.Key_Space:
|
||||
win.capture();
|
||||
break;
|
||||
case Qt.Key_Left:
|
||||
win.cycleMode(-1);
|
||||
break;
|
||||
case Qt.Key_Right:
|
||||
win.cycleMode(1);
|
||||
break;
|
||||
case Qt.Key_1:
|
||||
Capture.mode = "screen";
|
||||
break;
|
||||
case Qt.Key_2:
|
||||
Capture.mode = "window";
|
||||
break;
|
||||
case Qt.Key_3:
|
||||
Capture.mode = "selection";
|
||||
break;
|
||||
case Qt.Key_P:
|
||||
Capture.showPointer = !Capture.showPointer;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
// Painted under the frozen frame so a failed grab (no wlr-screencopy)
|
||||
// still gives the picker something opaque to sit on.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.bgDark
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
// The frozen screen. grim -o pins the grab to this output, so filling
|
||||
// the window is an exact 1:1 mapping; the source is 4500x3000 device
|
||||
// pixels drawn into 3000x2000 logical ones, i.e. full sharpness.
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: Capture.freezeUrl
|
||||
fillMode: Image.Stretch
|
||||
// Unique filename per grab plus cache:false — otherwise QML would
|
||||
// happily repaint a previous session's frame.
|
||||
cache: false
|
||||
// Synchronous so the overlay never flashes black on the way in; a
|
||||
// raw PPM decode is a parse, not a decompress.
|
||||
asynchronous: false
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
// ── Screen mode ─────────────────────────────────────────────────────
|
||||
// Nothing to pick, so just outline the output the way GNOME does.
|
||||
Frame {
|
||||
anchors.fill: parent
|
||||
visible: Capture.mode === "screen"
|
||||
strokeWidth: 3
|
||||
strokeColor: Theme.accent
|
||||
}
|
||||
|
||||
// ── Window mode ─────────────────────────────────────────────────────
|
||||
WindowPicker {
|
||||
id: picker
|
||||
anchors.fill: parent
|
||||
visible: Capture.mode === "window"
|
||||
originX: Capture.originX
|
||||
originY: Capture.originY
|
||||
onPicked: win.capture()
|
||||
}
|
||||
|
||||
// ── Selection mode ──────────────────────────────────────────────────
|
||||
SelectionLayer {
|
||||
id: selection
|
||||
anchors.fill: parent
|
||||
visible: Capture.mode === "selection"
|
||||
onCommitted: win.capture()
|
||||
}
|
||||
|
||||
CaptureBar {
|
||||
anchors {
|
||||
horizontalCenter: parent.horizontalCenter
|
||||
bottom: parent.bottom
|
||||
bottomMargin: 56
|
||||
}
|
||||
captureEnabled: {
|
||||
if (Capture.mode === "window")
|
||||
return picker.selectedRect !== null;
|
||||
if (Capture.mode === "selection")
|
||||
return selection.hasSelection;
|
||||
return true;
|
||||
}
|
||||
onCaptureRequested: win.capture()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// An outline drawn as four opaque strips rather than a Rectangle border.
|
||||
//
|
||||
// A rounded Rectangle with a translucent fill and a border renders holes at the
|
||||
// corners on this Qt build (QTBUG-137166), and every outline in the capture
|
||||
// overlay sits on top of a photo where that would be very visible. Four plain
|
||||
// strips have no corners to get wrong.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property color strokeColor: Theme.accent
|
||||
property int strokeWidth: 2
|
||||
// Optional wash over the enclosed area, drawn under the strips.
|
||||
property color fillColor: "transparent"
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.fillColor
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top }
|
||||
height: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors { left: parent.left; right: parent.right; bottom: parent.bottom }
|
||||
height: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors { top: parent.top; bottom: parent.bottom; left: parent.left }
|
||||
width: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
Rectangle {
|
||||
anchors { top: parent.top; bottom: parent.bottom; right: parent.right }
|
||||
width: root.strokeWidth
|
||||
color: root.strokeColor
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// Focused result sheet for local OCR and code recognition. It is intentionally
|
||||
// a transient overlay: the user selects pixels, acts on the result, and leaves
|
||||
// without creating another application window or workspace tile.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: ScreenIntelligence.visible
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
WlrLayershell.namespace: "qs-screen-intelligence"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible)
|
||||
keyboardScope.forceActiveFocus();
|
||||
else
|
||||
copied = false;
|
||||
}
|
||||
|
||||
property bool copied: false
|
||||
|
||||
Timer {
|
||||
id: copiedTimer
|
||||
interval: 1500
|
||||
onTriggered: win.copied = false
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: keyboardScope
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
ScreenIntelligence.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, 0.46)
|
||||
border.width: 0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ScreenIntelligence.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: sheet
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 840)
|
||||
height: Math.min(parent.height - 120, ScreenIntelligence.phase === "ready" ? 680 : 390)
|
||||
radius: 24
|
||||
color: Theme.alpha(Theme.bgPopover, 0.965)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
clip: true
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: sheet.radius
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: mouse => mouse.accepted = true
|
||||
}
|
||||
|
||||
Column {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.right: closeButton.left
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 28
|
||||
anchors.topMargin: 25
|
||||
anchors.rightMargin: 18
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "Screen Intelligence"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: {
|
||||
if (ScreenIntelligence.phase === "capturing")
|
||||
return "Capturing the selected pixels";
|
||||
if (ScreenIntelligence.phase === "reading")
|
||||
return "Reading locally on this computer";
|
||||
if (ScreenIntelligence.phase === "error")
|
||||
return "Recognition needs attention";
|
||||
return "Local text and code recognition";
|
||||
}
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeButton
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: 20
|
||||
anchors.rightMargin: 20
|
||||
width: 34
|
||||
height: 34
|
||||
radius: 11
|
||||
color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "×"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 20
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: ScreenIntelligence.close()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: body
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: heading.bottom
|
||||
anchors.bottom: actions.top
|
||||
anchors.leftMargin: 28
|
||||
anchors.rightMargin: 28
|
||||
anchors.topMargin: 24
|
||||
anchors.bottomMargin: 18
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, 520)
|
||||
spacing: 13
|
||||
visible: ScreenIntelligence.busy
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: ""
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 40
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: ScreenIntelligence.phase === "capturing" ? "Capturing…" : "Reading text and codes…"
|
||||
color: Theme.fg
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Pixels stay on this computer. No network request is being made."
|
||||
color: Theme.fgDim
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, 560)
|
||||
spacing: 14
|
||||
visible: ScreenIntelligence.phase === "error"
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: ""
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 38
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: ScreenIntelligence.error
|
||||
color: Theme.fg
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: commandText.implicitWidth + 28
|
||||
height: 38
|
||||
radius: 10
|
||||
visible: !ScreenIntelligence.ocrReady
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: commandText
|
||||
anchors.centerIn: parent
|
||||
text: "sudo dnf install -y tesseract zbar"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 12
|
||||
visible: ScreenIntelligence.phase === "ready"
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: ScreenIntelligence.codeValue === "" ? 0 : 62
|
||||
visible: ScreenIntelligence.codeValue !== ""
|
||||
radius: 13
|
||||
color: Theme.alpha(Theme.accent, 0.09)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.2)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: ""
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 22
|
||||
}
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 52
|
||||
anchors.right: codeAction.left
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text { text: ScreenIntelligence.codeType || "Detected code"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSizeSmall; font.weight: Font.DemiBold }
|
||||
Text { width: parent.width; text: ScreenIntelligence.codeValue; color: Theme.fgDim; elide: Text.ElideMiddle; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
}
|
||||
ResultAction {
|
||||
id: codeAction
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
label: ScreenIntelligence.primaryUrl !== "" ? "Open" : "Copy"
|
||||
onClicked: {
|
||||
if (ScreenIntelligence.primaryUrl !== "")
|
||||
ScreenIntelligence.openDetected();
|
||||
else
|
||||
ScreenIntelligence.copyCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: parent.height - (ScreenIntelligence.codeValue === "" ? 0 : 74)
|
||||
radius: 14
|
||||
color: Theme.alpha(Theme.bgDark, 0.72)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.075)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 60
|
||||
visible: ScreenIntelligence.text === "" && ScreenIntelligence.codeValue === ""
|
||||
text: "No readable text or code was found. Try a tighter selection with larger, higher-contrast text."
|
||||
color: Theme.fgDim
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 17
|
||||
visible: ScreenIntelligence.text !== ""
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: resultText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
TextEdit {
|
||||
id: resultText
|
||||
width: parent.width
|
||||
text: ScreenIntelligence.text
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.bgDark
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actions
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.rightMargin: 28
|
||||
anchors.bottomMargin: 24
|
||||
spacing: 8
|
||||
visible: ScreenIntelligence.phase === "ready" && (ScreenIntelligence.text !== "" || ScreenIntelligence.codeValue !== "")
|
||||
|
||||
ResultAction {
|
||||
label: "Open link"
|
||||
visible: ScreenIntelligence.primaryUrl !== "" && ScreenIntelligence.codeValue === ""
|
||||
onClicked: ScreenIntelligence.openDetected()
|
||||
}
|
||||
ResultAction {
|
||||
label: "Translate"
|
||||
enabled: ScreenIntelligence.text !== ""
|
||||
onClicked: ScreenIntelligence.translate()
|
||||
}
|
||||
ResultAction {
|
||||
label: "Search"
|
||||
enabled: ScreenIntelligence.text !== ""
|
||||
onClicked: ScreenIntelligence.search()
|
||||
}
|
||||
ResultAction {
|
||||
label: win.copied ? "Copied" : "Copy text"
|
||||
primary: true
|
||||
enabled: ScreenIntelligence.text !== ""
|
||||
onClicked: {
|
||||
ScreenIntelligence.copyText();
|
||||
win.copied = true;
|
||||
copiedTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component ResultAction: Rectangle {
|
||||
id: action
|
||||
property string label: ""
|
||||
property bool primary: false
|
||||
property bool enabled: true
|
||||
signal clicked
|
||||
|
||||
implicitWidth: actionLabel.implicitWidth + 26
|
||||
implicitHeight: 34
|
||||
radius: 10
|
||||
opacity: enabled ? 1 : 0.4
|
||||
color: primary
|
||||
? (actionMouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.1) : Theme.accent)
|
||||
: (actionMouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075))
|
||||
border.width: primary ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: actionLabel
|
||||
anchors.centerIn: parent
|
||||
text: action.label
|
||||
color: action.primary ? Theme.bgDark : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: actionMouse
|
||||
anchors.fill: parent
|
||||
enabled: action.enabled
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: action.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// One segment of the Screen / Window / Selection control.
|
||||
//
|
||||
// Icons are Nerd Font glyphs rather than freedesktop icons on purpose: the
|
||||
// Adwaita "symbolic" SVGs are flat black and Qt, unlike GTK, will not recolour
|
||||
// them, so they'd be invisible on this dark overlay.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string glyph: ""
|
||||
property string label: ""
|
||||
property bool active: false
|
||||
|
||||
signal clicked
|
||||
|
||||
implicitWidth: content.implicitWidth + 30
|
||||
implicitHeight: 40
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
|
||||
color: {
|
||||
if (root.active)
|
||||
return mouse.containsMouse ? Qt.lighter(Theme.accent, 1.1) : Theme.accent;
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(Theme.fg, Theme.activeAlpha);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color contentColor: root.active ? Theme.bgDark : Theme.fg
|
||||
|
||||
Row {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.glyph
|
||||
color: root.contentColor
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label
|
||||
color: root.contentColor
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// The "you are being recorded" pill.
|
||||
//
|
||||
// GNOME puts a red dot and a timer in the top bar while a screencast runs and
|
||||
// clicking it stops the recording. The bar is another module's window, so this
|
||||
// is its own tiny layer surface parked just below it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: Capture.recording
|
||||
color: "transparent"
|
||||
|
||||
// Top only: with neither left nor right anchored, layer-shell centres the
|
||||
// surface horizontally.
|
||||
anchors.top: true
|
||||
margins.top: Theme.barGap * 2 + Theme.barHeight + Theme.barGap
|
||||
|
||||
implicitWidth: pill.implicitWidth
|
||||
implicitHeight: pill.implicitHeight
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-recording"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
function elapsed(): string {
|
||||
const t = Capture.recordingSeconds;
|
||||
const s = ("0" + (t % 60)).slice(-2);
|
||||
const m = Math.floor(t / 60) % 60;
|
||||
const h = Math.floor(t / 3600);
|
||||
return h > 0 ? h + ":" + ("0" + m).slice(-2) + ":" + s : m + ":" + s;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
implicitWidth: row.implicitWidth + 12
|
||||
implicitHeight: 34
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.redDeep
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
// Static dot, not a blinking one: nothing in this shell repaints
|
||||
// while idle.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: win.elapsed()
|
||||
color: Theme.fg
|
||||
// Tabular figures: the timer must not shuffle as it counts.
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: stop
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 26
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: stopMouse.containsMouse ? Theme.alpha(Theme.fg, 0.28) : Theme.alpha(Theme.fg, 0.14)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: ""
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: stopMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// SIGINT, so wf-recorder finalises the container.
|
||||
onClicked: Capture.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Selection mode: a GNOME-style rubber band over the frozen screen.
|
||||
//
|
||||
// Behaviours copied from GNOME 42: the overlay opens with a sensible centred
|
||||
// selection already in place (so the shutter is immediately useful), dragging
|
||||
// inside moves it, dragging a corner resizes from the opposite corner, and
|
||||
// dragging anywhere else starts a fresh band.
|
||||
//
|
||||
// A single MouseArea owns all of it. Nested MouseAreas for the handles would
|
||||
// fight the move/create area for the press, and the hit priorities are much
|
||||
// easier to reason about in one place.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Anything smaller than this on release is treated as a stray click and the
|
||||
// previous selection is restored.
|
||||
readonly property real minSize: 12
|
||||
// Pointer distance from a corner that counts as grabbing its handle.
|
||||
readonly property real handleGrab: 24
|
||||
readonly property real handleSize: 14
|
||||
|
||||
property real selX: 0
|
||||
property real selY: 0
|
||||
property real selW: 0
|
||||
property real selH: 0
|
||||
|
||||
readonly property bool hasSelection: root.selW >= root.minSize && root.selH >= root.minSize
|
||||
readonly property rect selection: Qt.rect(root.selX, root.selY, root.selW, root.selH)
|
||||
|
||||
// Double-clicking inside the band captures, like confirming a crop.
|
||||
signal committed
|
||||
|
||||
// "none" | "new" | "move" | "nw" | "ne" | "sw" | "se"
|
||||
property string _grab: "none"
|
||||
property real _anchorX: 0
|
||||
property real _anchorY: 0
|
||||
property real _pressX: 0
|
||||
property real _pressY: 0
|
||||
property real _origX: 0
|
||||
property real _origY: 0
|
||||
property rect _prev: Qt.rect(0, 0, 0, 0)
|
||||
|
||||
onWidthChanged: root._resetIfEmpty()
|
||||
onHeightChanged: root._resetIfEmpty()
|
||||
Component.onCompleted: root._resetIfEmpty()
|
||||
|
||||
function _resetIfEmpty(): void {
|
||||
if (root.selW === 0 && root.selH === 0)
|
||||
root.resetSelection();
|
||||
}
|
||||
|
||||
// Centred, roughly the middle half of the screen.
|
||||
function resetSelection(): void {
|
||||
if (root.width <= 0 || root.height <= 0)
|
||||
return;
|
||||
root.selW = Math.round(root.width * 0.55);
|
||||
root.selH = Math.round(root.height * 0.55);
|
||||
root.selX = Math.round((root.width - root.selW) / 2);
|
||||
root.selY = Math.round((root.height - root.selH) / 2);
|
||||
}
|
||||
|
||||
function _clampX(v: real): real {
|
||||
return Math.max(0, Math.min(root.width, v));
|
||||
}
|
||||
function _clampY(v: real): real {
|
||||
return Math.max(0, Math.min(root.height, v));
|
||||
}
|
||||
|
||||
function _near(a: real, b: real): bool {
|
||||
return Math.abs(a - b) <= root.handleGrab;
|
||||
}
|
||||
|
||||
function _hitTest(px: real, py: real): string {
|
||||
const x2 = root.selX + root.selW;
|
||||
const y2 = root.selY + root.selH;
|
||||
if (root.hasSelection) {
|
||||
if (root._near(px, root.selX) && root._near(py, root.selY))
|
||||
return "nw";
|
||||
if (root._near(px, x2) && root._near(py, root.selY))
|
||||
return "ne";
|
||||
if (root._near(px, root.selX) && root._near(py, y2))
|
||||
return "sw";
|
||||
if (root._near(px, x2) && root._near(py, y2))
|
||||
return "se";
|
||||
if (px > root.selX && px < x2 && py > root.selY && py < y2)
|
||||
return "move";
|
||||
}
|
||||
return "new";
|
||||
}
|
||||
|
||||
function _cursorFor(m: string): int {
|
||||
switch (m) {
|
||||
case "nw":
|
||||
case "se":
|
||||
return Qt.SizeFDiagCursor;
|
||||
case "ne":
|
||||
case "sw":
|
||||
return Qt.SizeBDiagCursor;
|
||||
case "move":
|
||||
return Qt.SizeAllCursor;
|
||||
default:
|
||||
return Qt.CrossCursor;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the band from a fixed anchor corner and the live pointer.
|
||||
function _dragTo(px: real, py: real): void {
|
||||
const cx = root._clampX(px);
|
||||
const cy = root._clampY(py);
|
||||
root.selX = Math.min(root._anchorX, cx);
|
||||
root.selY = Math.min(root._anchorY, cy);
|
||||
root.selW = Math.abs(cx - root._anchorX);
|
||||
root.selH = Math.abs(cy - root._anchorY);
|
||||
}
|
||||
|
||||
Spotlight {
|
||||
anchors.fill: parent
|
||||
holeVisible: root.hasSelection
|
||||
hole: root.selection
|
||||
}
|
||||
|
||||
Frame {
|
||||
x: root.selX
|
||||
y: root.selY
|
||||
width: root.selW
|
||||
height: root.selH
|
||||
visible: root.hasSelection
|
||||
strokeWidth: 2
|
||||
strokeColor: Theme.accent
|
||||
}
|
||||
|
||||
// ── Corner handles ──────────────────────────────────────────────────────
|
||||
Repeater {
|
||||
model: [
|
||||
{
|
||||
gx: 0,
|
||||
gy: 0
|
||||
},
|
||||
{
|
||||
gx: 1,
|
||||
gy: 0
|
||||
},
|
||||
{
|
||||
gx: 0,
|
||||
gy: 1
|
||||
},
|
||||
{
|
||||
gx: 1,
|
||||
gy: 1
|
||||
}
|
||||
]
|
||||
|
||||
Rectangle {
|
||||
required property var modelData
|
||||
|
||||
visible: root.hasSelection
|
||||
width: root.handleSize
|
||||
height: root.handleSize
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: Theme.accent
|
||||
x: root.selX + modelData.gx * root.selW - width / 2
|
||||
y: root.selY + modelData.gy * root.selH - height / 2
|
||||
|
||||
// A dark core keeps the handle readable on top of a light photo.
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 6
|
||||
height: width
|
||||
radius: width / 2
|
||||
border.width: 0
|
||||
color: Theme.bgDark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live size readout ───────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
id: readout
|
||||
visible: root.hasSelection
|
||||
implicitWidth: readoutText.implicitWidth + 24
|
||||
implicitHeight: 34
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
|
||||
x: Math.max(0, Math.min(root.width - width, root.selX + (root.selW - width) / 2))
|
||||
// Inside the band when there is room, otherwise tucked just above it.
|
||||
y: root.selH > 110 ? root.selY + (root.selH - height) / 2 : Math.max(0, root.selY - height - 10)
|
||||
|
||||
Text {
|
||||
id: readoutText
|
||||
anchors.centerIn: parent
|
||||
text: Math.round(root.selW) + " × " + Math.round(root.selH)
|
||||
color: Theme.fg
|
||||
// Tabular figures so the readout doesn't twitch while dragging.
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: area
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root._cursorFor(root._grab !== "none" ? root._grab : root._hitTest(area.mouseX, area.mouseY))
|
||||
|
||||
onPressed: event => {
|
||||
root._prev = root.selection;
|
||||
root._grab = root._hitTest(event.x, event.y);
|
||||
root._pressX = event.x;
|
||||
root._pressY = event.y;
|
||||
root._origX = root.selX;
|
||||
root._origY = root.selY;
|
||||
|
||||
const x2 = root.selX + root.selW;
|
||||
const y2 = root.selY + root.selH;
|
||||
switch (root._grab) {
|
||||
case "nw":
|
||||
root._anchorX = x2;
|
||||
root._anchorY = y2;
|
||||
break;
|
||||
case "ne":
|
||||
root._anchorX = root.selX;
|
||||
root._anchorY = y2;
|
||||
break;
|
||||
case "sw":
|
||||
root._anchorX = x2;
|
||||
root._anchorY = root.selY;
|
||||
break;
|
||||
case "se":
|
||||
root._anchorX = root.selX;
|
||||
root._anchorY = root.selY;
|
||||
break;
|
||||
case "new":
|
||||
root._anchorX = root._clampX(event.x);
|
||||
root._anchorY = root._clampY(event.y);
|
||||
root.selX = root._anchorX;
|
||||
root.selY = root._anchorY;
|
||||
root.selW = 0;
|
||||
root.selH = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onPositionChanged: event => {
|
||||
if (root._grab === "none")
|
||||
return;
|
||||
if (root._grab === "move") {
|
||||
root.selX = Math.max(0, Math.min(root.width - root.selW, root._origX + (event.x - root._pressX)));
|
||||
root.selY = Math.max(0, Math.min(root.height - root.selH, root._origY + (event.y - root._pressY)));
|
||||
} else {
|
||||
root._dragTo(event.x, event.y);
|
||||
}
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
// A plain click outside the band would otherwise wipe the selection
|
||||
// down to nothing; treat it as "no change" instead.
|
||||
if (root._grab !== "move" && !root.hasSelection) {
|
||||
root.selX = root._prev.x;
|
||||
root.selY = root._prev.y;
|
||||
root.selW = root._prev.width;
|
||||
root.selH = root._prev.height;
|
||||
}
|
||||
root._grab = "none";
|
||||
}
|
||||
|
||||
onDoubleClicked: event => {
|
||||
if (root.hasSelection && event.x > root.selX && event.x < root.selX + root.selW && event.y > root.selY && event.y < root.selY + root.selH)
|
||||
root.committed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Dims everything except one rectangle, GNOME-style.
|
||||
//
|
||||
// Four dimming rectangles laid around the hole, because QML has no way to
|
||||
// subtract a shape from another without an OpacityMask shader — and a shader
|
||||
// here would mean a full-screen 4500x3000 offscreen texture on every pointer
|
||||
// move.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The region left undimmed, in this item's coordinates.
|
||||
property rect hole: Qt.rect(0, 0, 0, 0)
|
||||
// When false the whole item dims uniformly (nothing selected yet).
|
||||
property bool holeVisible: false
|
||||
property color dimColor: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
|
||||
readonly property real _l: Math.max(0, Math.min(root.width, root.hole.x))
|
||||
readonly property real _t: Math.max(0, Math.min(root.height, root.hole.y))
|
||||
readonly property real _r: Math.max(root._l, Math.min(root.width, root.hole.x + root.hole.width))
|
||||
readonly property real _b: Math.max(root._t, Math.min(root.height, root.hole.y + root.hole.height))
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: !root.holeVisible
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
// Above the hole
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: 0
|
||||
y: 0
|
||||
width: root.width
|
||||
height: root._t
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
// Below
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: 0
|
||||
y: root._b
|
||||
width: root.width
|
||||
height: Math.max(0, root.height - root._b)
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
// Left of the hole, between top and bottom bands
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: 0
|
||||
y: root._t
|
||||
width: root._l
|
||||
height: Math.max(0, root._b - root._t)
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
// Right
|
||||
Rectangle {
|
||||
visible: root.holeVisible
|
||||
x: root._r
|
||||
y: root._t
|
||||
width: Math.max(0, root.width - root._r)
|
||||
height: Math.max(0, root._b - root._t)
|
||||
color: root.dimColor
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Window mode: outline every window on the focused workspace, highlight the one
|
||||
// under the pointer and dim the rest, click to capture it.
|
||||
//
|
||||
// Geometry comes from `hyprctl -j clients` via services/Capture.qml, because
|
||||
// Quickshell's HyprlandToplevel exposes no position or size.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// Layout-global origin of the output this overlay covers.
|
||||
property real originX: 0
|
||||
property real originY: 0
|
||||
|
||||
signal picked
|
||||
|
||||
readonly property var windows: Capture.windows
|
||||
|
||||
// Front-to-back hit test: Capture.windows is already sorted by
|
||||
// focusHistoryID, so the first hit is the topmost window.
|
||||
readonly property int hoveredIndex: {
|
||||
if (!area.containsMouse)
|
||||
return -1;
|
||||
const mx = area.mouseX;
|
||||
const my = area.mouseY;
|
||||
for (let i = 0; i < root.windows.length; i++) {
|
||||
const w = root.windows[i];
|
||||
const x = w.x - root.originX;
|
||||
const y = w.y - root.originY;
|
||||
if (mx >= x && mx <= x + w.w && my >= y && my <= y + w.h)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Once a window has been hovered it stays highlighted after the pointer
|
||||
// moves down onto the control bar, so the shutter button always has
|
||||
// something to shoot. Index 0 is the most recently focused window, which is
|
||||
// what GNOME preselects.
|
||||
property int stickyIndex: 0
|
||||
onHoveredIndexChanged: {
|
||||
if (root.hoveredIndex >= 0)
|
||||
root.stickyIndex = root.hoveredIndex;
|
||||
}
|
||||
onWindowsChanged: root.stickyIndex = 0
|
||||
|
||||
readonly property int activeIndex: root.hoveredIndex >= 0 ? root.hoveredIndex : root.stickyIndex
|
||||
readonly property var hovered: (root.activeIndex >= 0 && root.activeIndex < root.windows.length) ? root.windows[root.activeIndex] : null
|
||||
|
||||
// Layout-global rect of the hovered window, or null. The overlay hands this
|
||||
// straight to Capture.commit().
|
||||
readonly property var selectedRect: root.hovered ? Qt.rect(root.hovered.x, root.hovered.y, root.hovered.w, root.hovered.h) : null
|
||||
|
||||
Spotlight {
|
||||
anchors.fill: parent
|
||||
holeVisible: root.hovered !== null
|
||||
hole: root.hovered ? Qt.rect(root.hovered.x - root.originX, root.hovered.y - root.originY, root.hovered.w, root.hovered.h) : Qt.rect(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Faint outline on every candidate so the user can see what is pickable
|
||||
// before moving the pointer, the way GNOME's window shots do.
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
Frame {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
x: modelData.x - root.originX
|
||||
y: modelData.y - root.originY
|
||||
width: modelData.w
|
||||
height: modelData.h
|
||||
visible: index !== root.activeIndex
|
||||
strokeWidth: 1
|
||||
strokeColor: Theme.alpha(Theme.fg, 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
Frame {
|
||||
visible: root.hovered !== null
|
||||
x: root.hovered ? root.hovered.x - root.originX : 0
|
||||
y: root.hovered ? root.hovered.y - root.originY : 0
|
||||
width: root.hovered ? root.hovered.w : 0
|
||||
height: root.hovered ? root.hovered.h : 0
|
||||
strokeWidth: 3
|
||||
strokeColor: Theme.accent
|
||||
fillColor: Theme.alpha(Theme.accent, 0.08)
|
||||
}
|
||||
|
||||
// Title chip on the highlighted window — stacked windows are otherwise very
|
||||
// hard to tell apart once the rest of the screen is dimmed.
|
||||
Rectangle {
|
||||
id: chip
|
||||
visible: root.hovered !== null
|
||||
x: (root.hovered ? root.hovered.x - root.originX : 0) + 12
|
||||
y: (root.hovered ? root.hovered.y - root.originY : 0) + 12
|
||||
implicitWidth: Math.min(chipText.implicitWidth + 24, root.hovered ? root.hovered.w - 24 : 0)
|
||||
implicitHeight: 32
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
width: chip.width - 24
|
||||
text: root.hovered ? (root.hovered.title || root.hovered.appId) : ""
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
// Shown when hyprctl gave us nothing — a blank dimmed screen with no
|
||||
// explanation would just look broken.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.windows.length === 0
|
||||
text: "No windows to capture on this workspace"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: area
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.hovered ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.hovered)
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// One row of clipboard history: what was copied, where it came from, and how
|
||||
// long ago. Clicking it puts the entry back on the clipboard.
|
||||
//
|
||||
// Rows are cards on an already-glass surface, so they get no prism edge — the
|
||||
// hairline marks a pane of glass, not every item sitting on one.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var entry
|
||||
|
||||
signal activated
|
||||
|
||||
implicitHeight: Math.max(52, body.implicitHeight + 18)
|
||||
|
||||
radius: Theme.cardRadius
|
||||
// border.width: 0 works around QTBUG-137166, where transparent rounded
|
||||
// rectangles can render with holes at the corners.
|
||||
border.width: 0
|
||||
|
||||
color: {
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(Theme.accent, Theme.activeAlpha);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
// Fast in, relaxed out — the row should feel like it answers the pointer
|
||||
// rather than fading after it.
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: mouse.containsMouse ? Theme.durFast : Theme.durNormal
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// Nerd Font glyph for the kind of thing that was copied. Text is the common
|
||||
// case and gets the quietest icon, so the odd link or image stands out.
|
||||
readonly property string kindGlyph: {
|
||||
switch (root.entry.kind) {
|
||||
case Clipboard.kindLink:
|
||||
return "\u{F0C1}"; // nf-fa-link
|
||||
case Clipboard.kindImage:
|
||||
return "\u{F03E}"; // nf-fa-picture_o
|
||||
case Clipboard.kindFile:
|
||||
return "\u{F016}"; // nf-fa-file_o
|
||||
default:
|
||||
return "\u{F15C}"; // nf-fa-file_text_o
|
||||
}
|
||||
}
|
||||
|
||||
// Where it came from. Vicinae leaves `source` empty for clients it cannot
|
||||
// attribute (anything pasted by a script, for one), in which case a link's
|
||||
// host is more informative than nothing.
|
||||
readonly property string origin: root.entry.source || root.entry.host || ""
|
||||
|
||||
Text {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 11
|
||||
text: root.entry.encrypted ? "\u{F023}" : root.kindGlyph
|
||||
// fontMono is the Nerd Font, used here only to draw an icon.
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
color: root.entry.pinned ? Theme.accent : Theme.fgMuted
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: timeLabel.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.entry.encrypted ? "Concealed by Vicinae" : root.entry.preview
|
||||
color: root.entry.encrypted ? Theme.fgMuted : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.italic: root.entry.encrypted
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 6
|
||||
visible: chip.visible || originLabel.visible
|
||||
|
||||
// Images and file drags have no text worth reading, so the type is
|
||||
// spelled out rather than left to the glyph alone.
|
||||
Rectangle {
|
||||
id: chip
|
||||
visible: !root.entry.textual
|
||||
width: chipLabel.implicitWidth + 12
|
||||
height: chipLabel.implicitHeight + 4
|
||||
radius: 4
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.10)
|
||||
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: root.entry.typeLabel
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: originLabel
|
||||
anchors.verticalCenter: chip.verticalCenter
|
||||
visible: root.origin !== ""
|
||||
text: root.origin
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: timeLabel
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 11
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.entry.pinned
|
||||
text: "\u{F0403}" // nf-md-pin
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 11
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Clipboard.relativeTime(root.entry.ts)
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
// The list is full of these stacked in a column; tabular figures
|
||||
// keep them from stepping sideways row to row.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activated()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Clipboard history, backed by Vicinae's existing database. Opening the panel
|
||||
// performs one read-only query; nothing polls or repaints while it is closed.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
import qs.modules.quicksettings
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.clipboardOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
right: true
|
||||
}
|
||||
margins {
|
||||
top: Theme.barHeight + Theme.barGap * 2
|
||||
right: Theme.barSideMargin
|
||||
}
|
||||
|
||||
exclusiveZone: 0
|
||||
implicitWidth: 430
|
||||
implicitHeight: 550
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-clipboard"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
property string filter: ""
|
||||
|
||||
readonly property var filteredEntries: Clipboard.entries.filter(entry => {
|
||||
const needle = root.filter.trim().toLowerCase();
|
||||
if (!needle)
|
||||
return true;
|
||||
return [entry.preview, entry.source, entry.host, entry.typeLabel]
|
||||
.join(" ").toLowerCase().includes(needle);
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
ShellState.close();
|
||||
}
|
||||
|
||||
function activateFirst(): void {
|
||||
if (root.filteredEntries.length === 0)
|
||||
return;
|
||||
Clipboard.copyEntry(root.filteredEntries[0].id);
|
||||
root.close();
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible) {
|
||||
root.filter = "";
|
||||
search.clear();
|
||||
Clipboard.refresh();
|
||||
openAnim.restart();
|
||||
Qt.callLater(search.grab);
|
||||
}
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: root.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)
|
||||
|
||||
PrismEdge {
|
||||
anchors {
|
||||
top: parent.top
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
topMargin: 1
|
||||
}
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
transform: Scale {
|
||||
id: openScale
|
||||
origin.x: surface.width
|
||||
origin.y: 0
|
||||
xScale: 1
|
||||
yScale: 1
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.sectionSpacing
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 30
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 17
|
||||
icon: "edit-paste-symbolic"
|
||||
iconFallback: "edit-copy-symbolic"
|
||||
tint: Theme.accent
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Clipboard"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Clipboard.entries.length === 1
|
||||
? "1 item"
|
||||
: Clipboard.entries.length + " items"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: search
|
||||
width: parent.width
|
||||
placeholder: "Search clipboard history"
|
||||
onTextChanged: root.filter = text
|
||||
onAccepted: root.activateFirst()
|
||||
onDismissed: root.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - y
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: Clipboard.loading
|
||||
text: "Loading clipboard history…"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 40
|
||||
visible: !Clipboard.loading && !Clipboard.available
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
text: "Clipboard history is unavailable. Start Vicinae to restore it."
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !Clipboard.loading && Clipboard.available
|
||||
&& root.filteredEntries.length === 0
|
||||
text: root.filter ? "No matching clipboard items" : "Nothing copied yet"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
anchors.fill: parent
|
||||
maxHeight: parent.height
|
||||
spacing: 4
|
||||
visible: !Clipboard.loading && Clipboard.available
|
||||
&& root.filteredEntries.length > 0
|
||||
|
||||
Repeater {
|
||||
model: root.filteredEntries
|
||||
|
||||
ClipboardEntry {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
entry: modelData
|
||||
onActivated: {
|
||||
Clipboard.copyEntry(modelData.id);
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: openAnim
|
||||
target: openScale
|
||||
property: "yScale"
|
||||
from: 0.96
|
||||
to: 1
|
||||
duration: Theme.durFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// The bar entry for clipboard history. The icon is intentionally familiar and
|
||||
// quiet: the history itself is a utility, not another permanent status readout.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
implicitWidth: 30
|
||||
implicitHeight: 28
|
||||
|
||||
onActivated: ShellState.toggle("clipboard")
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 16
|
||||
icon: "edit-paste-symbolic"
|
||||
iconFallback: "edit-copy-symbolic"
|
||||
tint: ShellState.clipboardOpen ? Theme.accent : Theme.fg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// The filter box at the top of the clipboard popover. Nothing is submitted:
|
||||
// the text is read live and narrows the list that is already loaded, which is
|
||||
// how GNOME's own search entries behave.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias text: input.text
|
||||
property string placeholder: "Search"
|
||||
|
||||
signal accepted
|
||||
signal dismissed
|
||||
|
||||
implicitHeight: 32
|
||||
radius: Theme.pillRadius
|
||||
// border.width is set below rather than left at 0, so the focus ring has
|
||||
// something to change. See QTBUG-137166 for why 0 matters elsewhere.
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
input.text = "";
|
||||
}
|
||||
|
||||
function grab(): void {
|
||||
input.forceActiveFocus();
|
||||
}
|
||||
|
||||
Text {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F002}" // nf-fa-search — fontMono is the Nerd Font, icon only
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
color: input.activeFocus ? Theme.accent : Theme.fgMuted
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 9
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
clip: true
|
||||
|
||||
onAccepted: root.accepted()
|
||||
|
||||
// Escape clears a non-empty filter first and only closes the popover on
|
||||
// a second press — losing a typed query to a stray Escape is worse than
|
||||
// one extra keystroke.
|
||||
Keys.onEscapePressed: {
|
||||
if (input.text !== "")
|
||||
input.text = "";
|
||||
else
|
||||
root.dismissed();
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: input.text === ""
|
||||
text: root.placeholder
|
||||
color: Theme.fgMuted
|
||||
font: input.font
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module qs.modules.clipboard
|
||||
ClipboardEntry 1.0 ClipboardEntry.qml
|
||||
ClipboardPanel 1.0 ClipboardPanel.qml
|
||||
ClipboardWidget 1.0 ClipboardWidget.qml
|
||||
SearchField 1.0 SearchField.qml
|
||||
@@ -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
|
||||
@@ -0,0 +1,152 @@
|
||||
// The dock — a replacement for Dash-to-Dock configured as: bottom edge, 48px
|
||||
// icons, intellihide against all windows, dot running-indicators, show-apps
|
||||
// button at the leading edge.
|
||||
//
|
||||
// Intellihide means the dock reserves no space at all (exclusiveZone 0) and
|
||||
// floats over windows, appearing when the pointer reaches the bottom edge or
|
||||
// when nothing is in the way.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
// Set by Variants when instantiated per-screen. Plain rather than
|
||||
// `required` so the dock can also be created standalone while testing;
|
||||
// Variants injects into the existing property either way.
|
||||
property var modelData: null
|
||||
screen: root.modelData
|
||||
|
||||
anchors {
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
|
||||
// A dock that reserved space would not be intellihiding.
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename.
|
||||
WlrLayershell.namespace: "qs-dock"
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────────
|
||||
// Height = room for the tooltip above the bar + the bar + the gap under it.
|
||||
readonly property int revealStripHeight: 3
|
||||
readonly property int bottomMargin: Theme.barGap
|
||||
readonly property int tooltipSpace: 34
|
||||
|
||||
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
|
||||
|
||||
// ── Intellihide ─────────────────────────────────────────────────────────
|
||||
// Hyprland does not expose live toplevel geometry, so exact overlap cannot
|
||||
// be computed. "Is anything on this workspace at all" is the robust proxy,
|
||||
// and it is what Dash-to-Dock's all-windows intellihide felt like in
|
||||
// practice: an empty workspace keeps the dock out.
|
||||
readonly property bool workspaceOccupied: {
|
||||
const ws = Hyprland.focusedWorkspace;
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
|
||||
|
||||
property bool revealed: true
|
||||
|
||||
onWantRevealedChanged: {
|
||||
if (wantRevealed) {
|
||||
hideTimer.stop();
|
||||
revealTimer.restart();
|
||||
} else {
|
||||
revealTimer.stop();
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: revealTimer
|
||||
interval: Settings.dockRevealDelayMs
|
||||
onTriggered: root.revealed = true
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: Settings.dockHideDelayMs
|
||||
onTriggered: root.revealed = false
|
||||
}
|
||||
|
||||
// Other modules (the bar, the capture overlay) read this.
|
||||
onRevealedChanged: ShellState.dockRevealed = revealed
|
||||
|
||||
// wantRevealed's first evaluation emits no change signal when it lands on
|
||||
// false (the default), so the initial state has to be taken explicitly —
|
||||
// otherwise a shell started on a busy workspace would leave the dock up.
|
||||
Component.onCompleted: {
|
||||
revealed = wantRevealed;
|
||||
ShellState.dockRevealed = revealed;
|
||||
}
|
||||
|
||||
// ── Input region ────────────────────────────────────────────────────────
|
||||
// Revealed: the bar plus everything below it, so crossing the gap under the
|
||||
// dock does not count as leaving. Hidden: a sliver along the screen edge,
|
||||
// which is the only thing that can still trigger the dock — every other
|
||||
// click passes straight through to the window underneath.
|
||||
mask: Region {
|
||||
item: maskItem
|
||||
}
|
||||
|
||||
Item {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
|
||||
// Reports the pointer anywhere inside the input region. A HoverHandler
|
||||
// rather than a MouseArea because it keeps reporting while the icons'
|
||||
// own MouseAreas are hovered.
|
||||
HoverHandler {
|
||||
id: pointer
|
||||
}
|
||||
|
||||
Item {
|
||||
id: maskItem
|
||||
x: root.revealed ? body.x : 0
|
||||
y: root.revealed ? body.y : surface.height - root.revealStripHeight
|
||||
width: root.revealed ? body.width : surface.width
|
||||
height: root.revealed ? surface.height - body.y : root.revealStripHeight
|
||||
}
|
||||
|
||||
DockBody {
|
||||
id: body
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
// Slides off the bottom edge when hidden.
|
||||
y: root.revealed ? root.tooltipSpace : surface.height
|
||||
opacity: root.revealed ? 1 : 0
|
||||
|
||||
// Asymmetric on purpose. Revealing is a response to something the
|
||||
// user just did, so it has to feel immediate — any delay there
|
||||
// reads as lag. Hiding is not a response to anything, so it can
|
||||
// take its time and stay calm in peripheral vision.
|
||||
Behavior on y {
|
||||
NumberAnimation {
|
||||
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
|
||||
easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: root.revealed ? Theme.durDockReveal : Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// The visible dock bar: the rounded translucent pill, its items, and the
|
||||
// tooltip that floats above it.
|
||||
//
|
||||
// Kept separate from Dock.qml so it can be dropped straight into a
|
||||
// FloatingWindow for testing — PanelWindow cannot map without layer shell.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// ── Model ───────────────────────────────────────────────────────────────
|
||||
// One pass over the live toplevel list produces the whole dock: pinned
|
||||
// apps first in Settings order, then anything else that is running.
|
||||
// Entries are plain JS objects: { entry, windows, appId }.
|
||||
readonly property var items: {
|
||||
// DesktopEntries is scanned asynchronously at startup, and byId() is a
|
||||
// plain method call that creates no binding dependency. Reading the
|
||||
// model here is what makes the dock fill in once the scan lands.
|
||||
if (DesktopEntries.applications.values.length === 0)
|
||||
return [];
|
||||
|
||||
const groups = {}; // appId -> [HyprlandToplevel]
|
||||
const order = []; // appIds in first-seen order
|
||||
|
||||
const toplevels = Hyprland.toplevels.values;
|
||||
for (let i = 0; i < toplevels.length; i++) {
|
||||
const t = toplevels[i];
|
||||
// `wayland` is null until Hyprland reports the address, and it is
|
||||
// the only live source of an app id — there is no `class` property.
|
||||
const appId = t.wayland ? t.wayland.appId : "";
|
||||
if (!appId)
|
||||
continue;
|
||||
if (!groups[appId]) {
|
||||
groups[appId] = [];
|
||||
order.push(appId);
|
||||
}
|
||||
groups[appId].push(t);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const claimed = {};
|
||||
|
||||
const pinned = Settings.dockPinned;
|
||||
for (let i = 0; i < pinned.length; i++) {
|
||||
const entry = DesktopEntries.byId(pinned[i]);
|
||||
// A pin that no longer resolves is dropped rather than drawn as a
|
||||
// broken icon.
|
||||
if (!entry)
|
||||
continue;
|
||||
|
||||
let windows = [];
|
||||
for (let j = 0; j < order.length; j++) {
|
||||
if (!claimed[order[j]] && root.matches(order[j], entry, pinned[i])) {
|
||||
claimed[order[j]] = true;
|
||||
windows = windows.concat(groups[order[j]]);
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
entry: entry,
|
||||
windows: windows,
|
||||
appId: pinned[i]
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const appId = order[i];
|
||||
if (claimed[appId])
|
||||
continue;
|
||||
out.push({
|
||||
entry: DesktopEntries.heuristicLookup(appId),
|
||||
windows: groups[appId],
|
||||
appId: appId
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wayland app ids and desktop entry ids agree often but not always
|
||||
// (`org.gnome.Nautilus` vs `nautilus`, `Steam` vs `steam`), so compare the
|
||||
// usual candidates case-insensitively and fall back to the last
|
||||
// reverse-DNS segment.
|
||||
function matches(appId: string, entry: var, pinId: string): bool {
|
||||
const a = appId.toLowerCase();
|
||||
if (a === pinId.toLowerCase())
|
||||
return true;
|
||||
if (entry.id && a === entry.id.toLowerCase())
|
||||
return true;
|
||||
if (entry.startupClass && a === entry.startupClass.toLowerCase())
|
||||
return true;
|
||||
|
||||
const tail = a.split(".").pop();
|
||||
return tail === pinId.toLowerCase().split(".").pop();
|
||||
}
|
||||
|
||||
// ── Appearance ──────────────────────────────────────────────────────────
|
||||
radius: Theme.dockRadius
|
||||
color: Theme.alpha(Theme.bgPanel, Theme.dockAlpha)
|
||||
border.width: 0 // QTBUG-137166: rounded translucent rects lose their corners
|
||||
|
||||
// The prism edge — see widgets/PrismEdge.qml.
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
|
||||
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
|
||||
|
||||
// The item the tooltip is currently describing, or null.
|
||||
property Item hoveredItem: null
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.dockGap
|
||||
|
||||
ShowAppsButton {
|
||||
id: showApps
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = showApps
|
||||
onExited: if (root.hoveredItem === showApps)
|
||||
root.hoveredItem = null
|
||||
}
|
||||
|
||||
// Separator between the launcher and the apps, as in GNOME's dash.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 1
|
||||
height: Theme.dockIconSize * 0.7
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.14)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.items
|
||||
|
||||
DockItem {
|
||||
id: dockItem
|
||||
required property var modelData
|
||||
app: modelData
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onEntered: root.hoveredItem = dockItem
|
||||
onExited: if (root.hoveredItem === dockItem)
|
||||
root.hoveredItem = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tooltip ─────────────────────────────────────────────────────────────
|
||||
// Styled like widgets/Popover.qml. Deliberately outside the dock's bounds
|
||||
// (negative y): it renders fine there, and the dock's input mask does not
|
||||
// cover it, so it can never swallow a click.
|
||||
Rectangle {
|
||||
id: tooltip
|
||||
|
||||
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
|
||||
|
||||
visible: opacity > 0
|
||||
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
// Centred on the hovered item, clamped inside the dock. Reads the item's
|
||||
// x directly (rather than mapToItem) so the binding re-evaluates when
|
||||
// the row reflows.
|
||||
x: {
|
||||
if (!root.hoveredItem)
|
||||
return 0;
|
||||
const centre = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
|
||||
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
|
||||
}
|
||||
y: -height - 8
|
||||
|
||||
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
|
||||
height: tipLabel.implicitHeight + 8
|
||||
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: tipLabel
|
||||
anchors.centerIn: parent
|
||||
text: tooltip.text
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// One dock icon: the app, its running-window dots, hover scale and click
|
||||
// behaviour. Reproduces Dash-to-Dock's item: 48px icon, up to four dots
|
||||
// underneath, grow-on-hover, click to focus / cycle / launch.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string }
|
||||
// Built by DockBody so this file stays presentational.
|
||||
required property var app
|
||||
|
||||
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
|
||||
readonly property var windows: app && app.windows ? app.windows : []
|
||||
readonly property string label: entry ? entry.name : (app && app.appId ? app.appId : "")
|
||||
readonly property bool running: windows.length > 0
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
|
||||
// Emitted so DockBody can drive the single shared tooltip.
|
||||
signal entered
|
||||
signal exited
|
||||
|
||||
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
||||
// size so the row doesn't reflow.
|
||||
implicitWidth: Theme.dockIconSize
|
||||
implicitHeight: Theme.dockIconSize + dots.height + 4
|
||||
|
||||
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
|
||||
// absolute path. iconPath() only understands names, so branch on it. The
|
||||
// `true` argument makes a missing icon return "" instead of a placeholder
|
||||
// that renders as a black square in some themes.
|
||||
readonly property string iconSource: {
|
||||
const name = entry && entry.icon ? entry.icon : (app && app.appId ? app.appId : "");
|
||||
if (!name)
|
||||
return "";
|
||||
if (name.startsWith("/"))
|
||||
return "file://" + name;
|
||||
return Quickshell.iconPath(name, true);
|
||||
}
|
||||
|
||||
Item {
|
||||
id: iconSlot
|
||||
width: Theme.dockIconSize
|
||||
height: Theme.dockIconSize
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: 0
|
||||
|
||||
// Grow upward out of the dock, the way GNOME's dash does, so the icon
|
||||
// never overlaps its own running dots.
|
||||
transformOrigin: Item.Bottom
|
||||
scale: root.hovered ? 1.15 : 1.0
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
easing.type: Easing.OutBack
|
||||
easing.overshoot: 1.6
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
anchors.fill: parent
|
||||
visible: root.iconSource !== ""
|
||||
source: root.iconSource
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
}
|
||||
|
||||
// Last resort for an app with no resolvable icon: an initial, which is
|
||||
// still recognisable, unlike the theme's broken-icon placeholder.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.iconSource === ""
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, Theme.activeAlpha)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: root.label ? root.label.charAt(0).toUpperCase() : "?"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.round(Theme.dockIconSize * 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Running indicators. Dash-to-Dock showed one dot per window up to four.
|
||||
Row {
|
||||
id: dots
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 3
|
||||
height: 4
|
||||
|
||||
Repeater {
|
||||
model: Math.min(root.windows.length, 4)
|
||||
|
||||
Rectangle {
|
||||
width: 4
|
||||
height: 4
|
||||
radius: 2
|
||||
border.width: 0 // QTBUG-137166: transparent rounded rects punch corners
|
||||
color: Theme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
|
||||
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
|
||||
onClicked: mev => {
|
||||
// Middle click always starts a new instance, as in GNOME.
|
||||
if (mev.button === Qt.MiddleButton) {
|
||||
root.launch();
|
||||
return;
|
||||
}
|
||||
if (root.running)
|
||||
root.focusNext();
|
||||
else
|
||||
root.launch();
|
||||
}
|
||||
}
|
||||
|
||||
function launch(): void {
|
||||
if (root.entry)
|
||||
root.entry.execute();
|
||||
}
|
||||
|
||||
// Clicking a running app cycles through its windows, matching GNOME's dash.
|
||||
function focusNext(): void {
|
||||
const wins = root.windows;
|
||||
if (wins.length === 0)
|
||||
return;
|
||||
|
||||
let next = wins[0];
|
||||
for (let i = 0; i < wins.length; i++) {
|
||||
if (wins[i].activated) {
|
||||
next = wins[(i + 1) % wins.length];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!next)
|
||||
return;
|
||||
|
||||
// Quickshell reports the address without the 0x prefix Hyprland's
|
||||
// window selector expects; normalise so either form works.
|
||||
if (next.address) {
|
||||
const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address;
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`);
|
||||
} else if (next.wayland) {
|
||||
// No address yet (the toplevel is still being reported) — the
|
||||
// wlr handle can still raise it.
|
||||
next.wayland.activate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// The leading "show applications" button. GNOME's dash had
|
||||
// show-apps-at-top: true, which on a bottom dock means the leading edge.
|
||||
//
|
||||
// The glyph is drawn rather than pulled from the icon theme: GNOME's app-grid
|
||||
// icon is literally a 3x3 dot grid, and drawing it removes any dependency on
|
||||
// which icon theme happens to be installed.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
signal entered
|
||||
signal exited
|
||||
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
readonly property string label: qsTr("Show Applications")
|
||||
|
||||
implicitWidth: Theme.dockIconSize
|
||||
implicitHeight: Theme.dockIconSize
|
||||
|
||||
Rectangle {
|
||||
id: bg
|
||||
anchors.centerIn: parent
|
||||
width: Theme.dockIconSize
|
||||
height: Theme.dockIconSize
|
||||
radius: width / 2
|
||||
border.width: 0 // QTBUG-137166
|
||||
color: root.hovered ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
transformOrigin: Item.Center
|
||||
scale: root.hovered ? 1.15 : 1.0
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
easing.type: Easing.OutBack
|
||||
easing.overshoot: 1.6
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
anchors.centerIn: parent
|
||||
columns: 3
|
||||
spacing: Math.round(Theme.dockIconSize * 0.11)
|
||||
|
||||
Repeater {
|
||||
model: 9
|
||||
|
||||
Rectangle {
|
||||
readonly property int dot: Math.round(Theme.dockIconSize * 0.135)
|
||||
width: dot
|
||||
height: dot
|
||||
radius: dot / 2
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
// vicinae is the launcher this shell uses in place of GNOME's app grid.
|
||||
onClicked: Quickshell.execDetached(["vicinae", "toggle"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// One action in the focus capsule. Kept local to the module so the capsule's
|
||||
// low, tailored geometry does not leak into generic shell buttons.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string icon: ""
|
||||
property string iconFallback: "dialog-information-symbolic"
|
||||
property string label: ""
|
||||
property bool destructive: false
|
||||
|
||||
signal activated
|
||||
|
||||
implicitHeight: 34
|
||||
radius: Theme.cardRadius - 2
|
||||
border.width: 0
|
||||
|
||||
readonly property color actionColor: root.destructive ? Theme.danger : Theme.fg
|
||||
|
||||
color: {
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(root.actionColor, 0.24);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(root.actionColor, root.destructive ? 0.16 : Theme.hoverAlpha);
|
||||
return Theme.alpha(Theme.fg, 0.07);
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 7
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 15
|
||||
icon: root.icon
|
||||
iconFallback: root.iconFallback
|
||||
tint: root.actionColor
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label
|
||||
color: root.actionColor
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activated()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// The session never disappears completely. Dismissing the larger glass signal
|
||||
// leaves this quiet bar-native indicator, which reopens it in one click.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
visible: FocusSession.active
|
||||
horizontalPadding: 8
|
||||
onActivated: FocusSession.reveal()
|
||||
onSecondaryActivated: FocusSession.pauseOrResume()
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.paused ? "\u{F03E4}" : "\u{F051F}" // pause / timer-outline
|
||||
color: FocusSession.paused ? Theme.warn : Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.remainingText
|
||||
color: FocusSession.paused ? Theme.fgDim : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module qs.modules.focus
|
||||
FocusAction 1.0 FocusAction.qml
|
||||
FocusIndicator 1.0 FocusIndicator.qml
|
||||
@@ -0,0 +1,306 @@
|
||||
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
||||
// optional image, action buttons and an inline reply box.
|
||||
//
|
||||
// Shared by the toasts and the notification centre so a notification looks the
|
||||
// same wherever you meet it.
|
||||
//
|
||||
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
||||
// widgets/, but that directory is owned elsewhere.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var notification
|
||||
|
||||
// The centre renders a denser card than a toast banner.
|
||||
property bool compact: false
|
||||
|
||||
signal dismissed
|
||||
readonly property alias hovered: hover.containsMouse
|
||||
// Raised when the reply field takes focus: the toast window has to ask the
|
||||
// compositor for keyboard focus before anything can be typed.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
// The image sits outside `layout`, so the card has to be tall enough for
|
||||
// whichever of the two is bigger.
|
||||
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0
|
||||
color: hover.containsMouse ? Theme.alpha(Theme.fg, 0.1) : Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
// Critical notifications get a red edge rather than a colour wash, so the
|
||||
// text contrast never changes.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 6
|
||||
width: 3
|
||||
radius: 1.5
|
||||
border.width: 0
|
||||
color: Theme.urgent
|
||||
visible: root.notification.urgency === NotificationUrgency.Critical
|
||||
}
|
||||
|
||||
readonly property var defaultAction: {
|
||||
for (const action of root.notification.actions) {
|
||||
if (action.identifier === "default")
|
||||
return action;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// "default" is the card click, so it never becomes a button.
|
||||
readonly property bool hasButtons: {
|
||||
for (const action of root.notification.actions) {
|
||||
if (action.identifier !== "default")
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Everything except the buttons: clicking the body runs the notification's
|
||||
// default action, which is what GNOME does.
|
||||
MouseArea {
|
||||
id: hover
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.defaultAction)
|
||||
root.defaultAction.invoke();
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: appIcon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 14
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 13
|
||||
implicitSize: 22
|
||||
asynchronous: true
|
||||
source: {
|
||||
if (root.notification.appIcon !== "")
|
||||
return Quickshell.iconPath(root.notification.appIcon, true);
|
||||
const entry = DesktopEntries.byId(root.notification.desktopEntry);
|
||||
return entry ? Quickshell.iconPath(entry.icon, true) : "";
|
||||
}
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: closeButton
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 6
|
||||
size: 24
|
||||
iconSize: 12
|
||||
tint: Theme.fgDim
|
||||
icon: "window-close-symbolic"
|
||||
onClicked: root.dismissed()
|
||||
}
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
||||
anchors.leftMargin: appIcon.visible ? 10 : 14
|
||||
anchors.right: image.visible ? image.left : closeButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 12
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
const app = root.notification.appName || "Notification";
|
||||
const time = Notifs.timeText(root.notification.id);
|
||||
return time === "" ? app : app + " · " + time;
|
||||
}
|
||||
color: Theme.fgMuted
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: root.notification.summary
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
wrapMode: Text.Wrap
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: root.notification.body
|
||||
color: Theme.fgDim
|
||||
// The server advertises bodyMarkupSupported, so bodies arrive with
|
||||
// the freedesktop subset of HTML in them.
|
||||
textFormat: Text.StyledText
|
||||
linkColor: Theme.accent
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: root.compact ? 6 : 3
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 1
|
||||
height: 4
|
||||
visible: actionRow.visible || replyBox.visible
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: actionRow
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
visible: root.hasButtons
|
||||
|
||||
Repeater {
|
||||
model: root.notification.actions
|
||||
|
||||
Rectangle {
|
||||
id: actionButton
|
||||
|
||||
required property var modelData
|
||||
|
||||
visible: actionButton.modelData.identifier !== "default"
|
||||
width: visible ? actionLabel.implicitWidth + 22 : 0
|
||||
height: visible ? 28 : 0
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: actionMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: actionLabel
|
||||
anchors.centerIn: parent
|
||||
text: actionButton.modelData.text
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: actionMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: actionButton.modelData.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: replyBox
|
||||
width: parent.width
|
||||
height: 36
|
||||
visible: root.notification.hasInlineReply
|
||||
radius: Theme.cardRadius - 2
|
||||
border.width: 1
|
||||
border.color: replyInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.12)
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
TextInput {
|
||||
id: replyInput
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: replySend.left
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
clip: true
|
||||
|
||||
onActiveFocusChanged: root.replyFocusChanged(replyInput.activeFocus)
|
||||
|
||||
onAccepted: {
|
||||
if (replyInput.text.length === 0)
|
||||
return;
|
||||
root.notification.sendInlineReply(replyInput.text);
|
||||
replyInput.text = "";
|
||||
root.dismissed();
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: replyInput.text === ""
|
||||
text: root.notification.inlineReplyPlaceholder || "Reply"
|
||||
color: Theme.fgMuted
|
||||
font: replyInput.font
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: replySend
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 28
|
||||
iconSize: 14
|
||||
icon: "go-next-symbolic"
|
||||
onClicked: replyInput.accepted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification images (album art, avatars, screenshots) sit on the right.
|
||||
ClippingRectangle {
|
||||
id: image
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 14
|
||||
anchors.top: closeButton.bottom
|
||||
anchors.topMargin: 4
|
||||
implicitWidth: 52
|
||||
implicitHeight: 52
|
||||
radius: 8
|
||||
border.width: 0
|
||||
color: "transparent"
|
||||
visible: root.notification.image !== ""
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.notification.image
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
sourceSize.width: 104
|
||||
sourceSize.height: 104
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// GNOME's message tray: everything that has arrived, grouped by app.
|
||||
//
|
||||
// Drops from the top centre because that is where GNOME's tray lives and the
|
||||
// muscle memory is the whole point. Layer-shell centres a surface on whichever
|
||||
// axis it is not anchored to, so anchoring only `top` does it.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.notificationsOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 440
|
||||
implicitHeight: surface.implicitHeight
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
Notifs.markAllRead();
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
windows: [root]
|
||||
active: root.visible
|
||||
onCleared: ShellState.close()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
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
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Notifications"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
IconButton {
|
||||
size: 30
|
||||
iconSize: 16
|
||||
icon: "notifications-disabled-symbolic"
|
||||
tint: Notifs.doNotDisturb ? Theme.accent : Theme.fgDim
|
||||
onClicked: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: clearLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
visible: Notifs.hasNotifications
|
||||
color: clearMouse.containsMouse ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 120
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grouped history ─────────────────────────────────────────
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 620
|
||||
spacing: Theme.itemSpacing
|
||||
visible: Notifs.hasNotifications
|
||||
|
||||
Repeater {
|
||||
model: Notifs.groups
|
||||
|
||||
Column {
|
||||
id: group
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 26
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: group.modelData.app
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
IconButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 13
|
||||
tint: Theme.fgDim
|
||||
icon: "edit-clear-all-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: Notifs.dismissApp(group.modelData.app)
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: group.modelData.items
|
||||
|
||||
NotificationCard {
|
||||
required property var modelData
|
||||
|
||||
width: group.width
|
||||
compact: true
|
||||
notification: modelData
|
||||
onDismissed: Notifs.dismiss(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// A single banner: a notification card that slides in and times itself out.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var notification
|
||||
|
||||
// Raised while the inline reply field has focus — Toasts.qml turns that
|
||||
// into a keyboard focus request on the layer surface.
|
||||
signal replyFocusChanged(bool focused)
|
||||
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
// Critical notifications stay until dismissed (the setting is 0). An app
|
||||
// asking for 0 means "never expire" per the freedesktop spec; -1 means
|
||||
// "server decides", which is our default.
|
||||
readonly property int timeoutMs: {
|
||||
if (root.notification.urgency === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (root.notification.expireTimeout === 0)
|
||||
return 0;
|
||||
if (root.notification.expireTimeout > 0)
|
||||
return Math.round(root.notification.expireTimeout * 1000);
|
||||
return Settings.notificationTimeoutMs;
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
Timer {
|
||||
// Hovering holds the banner open; the countdown restarts on leave.
|
||||
running: root.timeoutMs > 0 && !hover.hovered
|
||||
interval: root.timeoutMs
|
||||
onTriggered: Notifs.dropPopup(root.notification)
|
||||
}
|
||||
|
||||
NotificationCard {
|
||||
id: card
|
||||
width: parent.width
|
||||
notification: root.notification
|
||||
onDismissed: Notifs.dropPopup(root.notification)
|
||||
onReplyFocusChanged: focused => root.replyFocusChanged(focused)
|
||||
|
||||
// Slide in from the right edge. Runs once, on creation.
|
||||
NumberAnimation on x {
|
||||
from: 40
|
||||
to: 0
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
|
||||
NumberAnimation on opacity {
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Theme.durNormal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// The banner stack: newest at the top, tucked under the bar on the right.
|
||||
//
|
||||
// The window is exactly as wide and tall as the stack, so everything outside
|
||||
// it stays clickable; the mask trims it further to the cards themselves.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: !Notifs.doNotDisturb && Notifs.popups.length > 0
|
||||
color: "transparent"
|
||||
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 400
|
||||
implicitHeight: Math.max(1, stack.implicitHeight)
|
||||
|
||||
WlrLayershell.namespace: "qs-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
|
||||
// Normally inert so banners never steal a keystroke. An open inline reply
|
||||
// is the one case where the surface has to be typeable.
|
||||
WlrLayershell.keyboardFocus: root.replyFocused ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||||
|
||||
property bool replyFocused: false
|
||||
|
||||
mask: Region {
|
||||
item: stack
|
||||
}
|
||||
|
||||
Column {
|
||||
id: stack
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: Notifs.popups.slice(0, Settings.maxVisibleToasts)
|
||||
|
||||
Toast {
|
||||
required property var modelData
|
||||
|
||||
width: stack.width
|
||||
notification: modelData
|
||||
onReplyFocusChanged: focused => root.replyFocused = focused
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// The overview — a replacement for GNOME's Activities. Opened with Super+grave
|
||||
// (the lead wires the IPC; this file only reacts to ShellState.overviewOpen).
|
||||
//
|
||||
// The compositor blurs and dims what is behind this surface via the
|
||||
// `qs-overlay` layer rule; the scrim below adds the tint on top of that.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
|
||||
WlrLayershell.namespace: "qs-overview"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: root.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
||||
|
||||
readonly property bool open: ShellState.overviewOpen
|
||||
|
||||
// The window has to stay mapped for the length of the close animation,
|
||||
// otherwise it vanishes instantly and only the open transition is ever
|
||||
// seen. `mapped` tracks overviewOpen, delayed on the way down.
|
||||
property bool mapped: false
|
||||
visible: mapped
|
||||
|
||||
// Each open bumps the token, which makes every thumbnail re-capture once.
|
||||
// Nothing captures while the overview is closed or idle.
|
||||
property int refreshToken: 0
|
||||
|
||||
onOpenChanged: {
|
||||
if (open) {
|
||||
unmapTimer.stop();
|
||||
// The IPC/toplevel caches can lag a fast workspace switch.
|
||||
Hyprland.refreshWorkspaces();
|
||||
Hyprland.refreshToplevels();
|
||||
mapped = true;
|
||||
refreshToken++;
|
||||
Qt.callLater(() => body.prepare());
|
||||
} else {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Item {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
opacity: root.open ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
transform: Scale {
|
||||
origin.x: surface.width / 2
|
||||
origin.y: surface.height / 2
|
||||
xScale: root.open ? 1 : 0.96
|
||||
yScale: xScale
|
||||
|
||||
Behavior on xScale {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
|
||||
// Clicking anywhere that is not a card dismisses, as in GNOME.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ShellState.close()
|
||||
}
|
||||
}
|
||||
|
||||
OverviewBody {
|
||||
id: body
|
||||
anchors.fill: parent
|
||||
refreshToken: root.refreshToken
|
||||
open: root.open
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// Continuum Mission Control: a GNOME-like workspace filmstrip above a large
|
||||
// selected desktop, with cross-workspace window search when the user types.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int refreshToken: 0
|
||||
property bool open: false
|
||||
property int selectedWorkspaceId: 0
|
||||
property string query: ""
|
||||
property int actionRefreshToken: 0
|
||||
property int selectedResultIndex: 0
|
||||
|
||||
readonly property int sideMargin: 52
|
||||
readonly property int filmCardWidth: 210
|
||||
readonly property int filmCardHeight: 140
|
||||
|
||||
readonly property var workspaceList: {
|
||||
const out = [];
|
||||
const all = Hyprland.workspaces?.values ?? [];
|
||||
for (const workspace of all) {
|
||||
if (workspace && workspace.id > 0)
|
||||
out.push(workspace);
|
||||
}
|
||||
out.sort((a, b) => a.id - b.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
readonly property var scratchWorkspace: {
|
||||
const all = Hyprland.workspaces?.values ?? [];
|
||||
return all.find(workspace => workspace?.name === "special:scratch") ?? null;
|
||||
}
|
||||
|
||||
readonly property int nextWorkspaceId: {
|
||||
let max = 0;
|
||||
for (const workspace of root.workspaceList)
|
||||
max = Math.max(max, workspace.id);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
readonly property var selectedWorkspace: {
|
||||
for (const workspace of root.workspaceList) {
|
||||
if (workspace.id === root.selectedWorkspaceId)
|
||||
return workspace;
|
||||
}
|
||||
return Hyprland.focusedWorkspace?.id > 0 ? Hyprland.focusedWorkspace : (root.workspaceList[0] ?? null);
|
||||
}
|
||||
|
||||
readonly property real screenAspect: {
|
||||
const monitor = Hyprland.focusedMonitor;
|
||||
return monitor && monitor.height > 0 ? monitor.width / monitor.height : 3 / 2;
|
||||
}
|
||||
|
||||
readonly property var windowEntries: {
|
||||
const out = [];
|
||||
for (const workspace of root.workspaceList) {
|
||||
const windows = workspace.toplevels?.values ?? [];
|
||||
for (const toplevel of windows)
|
||||
out.push({ workspace, toplevel });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
readonly property var filteredWindows: {
|
||||
const needle = root.query.trim().toLowerCase();
|
||||
if (!needle)
|
||||
return [];
|
||||
return root.windowEntries.filter(entry => {
|
||||
const title = entry.toplevel?.title ?? "";
|
||||
const appId = entry.toplevel?.wayland?.appId ?? "";
|
||||
return `${title} ${appId}`.toLowerCase().includes(needle);
|
||||
});
|
||||
}
|
||||
|
||||
readonly property int resultColumns: Math.min(3, Math.max(1, root.filteredWindows.length))
|
||||
readonly property var selectedResult: root.filteredWindows.length > 0
|
||||
? root.filteredWindows[Math.min(root.selectedResultIndex, root.filteredWindows.length - 1)]
|
||||
: null
|
||||
|
||||
onQueryChanged: root.selectedResultIndex = 0
|
||||
onFilteredWindowsChanged: {
|
||||
if (root.selectedResultIndex >= root.filteredWindows.length)
|
||||
root.selectedResultIndex = Math.max(0, root.filteredWindows.length - 1);
|
||||
}
|
||||
|
||||
function prepare(): void {
|
||||
root.query = ShellState.overviewQuery;
|
||||
const requested = ShellState.overviewWorkspaceId;
|
||||
const focused = Hyprland.focusedWorkspace?.id ?? 0;
|
||||
root.selectedWorkspaceId = root.hasWorkspace(requested) ? requested : focused;
|
||||
Qt.callLater(() => searchInput.forceActiveFocus());
|
||||
}
|
||||
|
||||
function hasWorkspace(id: int): bool {
|
||||
if (id <= 0)
|
||||
return false;
|
||||
return root.workspaceList.some(workspace => workspace.id === id);
|
||||
}
|
||||
|
||||
function selectWorkspace(workspace: var): void {
|
||||
if (!workspace)
|
||||
return;
|
||||
root.selectedWorkspaceId = workspace.id;
|
||||
workspace.activate();
|
||||
}
|
||||
|
||||
function cycleWorkspace(delta: int): void {
|
||||
if (root.workspaceList.length === 0)
|
||||
return;
|
||||
let index = root.workspaceList.findIndex(workspace => workspace.id === root.selectedWorkspaceId);
|
||||
if (index < 0)
|
||||
index = 0;
|
||||
const next = Math.max(0, Math.min(root.workspaceList.length - 1, index + delta));
|
||||
root.selectWorkspace(root.workspaceList[next]);
|
||||
}
|
||||
|
||||
function focusToplevel(toplevel: var): void {
|
||||
if (!toplevel)
|
||||
return;
|
||||
|
||||
const workspace = toplevel.workspace;
|
||||
if (workspace)
|
||||
workspace.activate();
|
||||
|
||||
if (toplevel.address) {
|
||||
const address = toplevel.address.startsWith("0x") ? toplevel.address : "0x" + toplevel.address;
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
|
||||
} else if (toplevel.wayland) {
|
||||
toplevel.wayland.activate();
|
||||
}
|
||||
ShellState.close();
|
||||
}
|
||||
|
||||
function addressOf(toplevel: var): string {
|
||||
const raw = String(toplevel?.address ?? "");
|
||||
if (!raw)
|
||||
return "";
|
||||
return raw.startsWith("0x") ? raw : "0x" + raw;
|
||||
}
|
||||
|
||||
function closeToplevel(toplevel: var): void {
|
||||
const address = root.addressOf(toplevel);
|
||||
if (!address)
|
||||
return;
|
||||
Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`);
|
||||
root.refreshAfterAction();
|
||||
}
|
||||
|
||||
function moveToplevel(toplevel: var, workspaceName: string): void {
|
||||
const address = root.addressOf(toplevel);
|
||||
if (!address || !/^(?:[1-9]\d*|special:scratch)$/.test(workspaceName))
|
||||
return;
|
||||
Hyprland.dispatch(`hl.dsp.window.move({ workspace = "${workspaceName}", follow = false, window = "address:${address}" })`);
|
||||
root.refreshAfterAction();
|
||||
}
|
||||
|
||||
function restoreToplevel(toplevel: var): void {
|
||||
const address = root.addressOf(toplevel);
|
||||
const target = root.selectedWorkspaceId > 0 ? root.selectedWorkspaceId : (Hyprland.focusedWorkspace?.id ?? 1);
|
||||
if (!address || target <= 0)
|
||||
return;
|
||||
Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${target}, follow = true, window = "address:${address}" })`);
|
||||
ShellState.close();
|
||||
}
|
||||
|
||||
function refreshAfterAction(): void {
|
||||
actionRefresh.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: actionRefresh
|
||||
interval: 140
|
||||
onTriggered: {
|
||||
Hyprland.refreshWorkspaces();
|
||||
Hyprland.refreshToplevels();
|
||||
root.actionRefreshToken++;
|
||||
}
|
||||
}
|
||||
|
||||
// Address-only actions make the exact same boundary testable end-to-end
|
||||
// without synthesizing pointer input. They are also useful to launcher
|
||||
// commands later; validation still limits destinations to Panama's model.
|
||||
IpcHandler {
|
||||
target: "overview-actions"
|
||||
function move(address: string, workspaceName: string): void {
|
||||
root.moveToplevel({ address }, workspaceName);
|
||||
}
|
||||
function close(address: string): void {
|
||||
root.closeToplevel({ address });
|
||||
}
|
||||
function restore(address: string): void {
|
||||
root.restoreToplevel({ address });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Header and search ──────────────────────────────────────────────────
|
||||
Item {
|
||||
id: header
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 42
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: root.sideMargin
|
||||
anchors.rightMargin: root.sideMargin
|
||||
height: 42
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Activities"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: searchBox
|
||||
anchors.centerIn: parent
|
||||
width: 390
|
||||
height: 38
|
||||
radius: Theme.pillRadius
|
||||
border.width: searchInput.activeFocus ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.72)
|
||||
color: Theme.alpha(Theme.bgPopover, 0.84)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F0349}" // magnify
|
||||
color: searchInput.activeFocus ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 16
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: searchInput
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 40
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.query
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.bgDark
|
||||
clip: true
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
onTextEdited: root.query = text
|
||||
|
||||
Keys.onReturnPressed: {
|
||||
if (root.query.trim() === "") {
|
||||
if (root.selectedWorkspace)
|
||||
root.selectWorkspace(root.selectedWorkspace);
|
||||
ShellState.close();
|
||||
} else if (root.selectedResult) {
|
||||
root.focusToplevel(root.selectedResult.toplevel);
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onLeftPressed: event => {
|
||||
if (root.query.trim() !== "")
|
||||
return;
|
||||
root.cycleWorkspace(-1);
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Keys.onRightPressed: event => {
|
||||
if (root.query.trim() !== "")
|
||||
return;
|
||||
root.cycleWorkspace(1);
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Keys.onUpPressed: event => {
|
||||
if (!root.selectedResult)
|
||||
return;
|
||||
root.selectedResultIndex = Math.max(0, root.selectedResultIndex - root.resultColumns);
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Keys.onDownPressed: event => {
|
||||
if (!root.selectedResult)
|
||||
return;
|
||||
root.selectedResultIndex = Math.min(root.filteredWindows.length - 1, root.selectedResultIndex + root.resultColumns);
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Keys.onDeletePressed: event => {
|
||||
if (!root.selectedResult)
|
||||
return;
|
||||
root.closeToplevel(root.selectedResult.toplevel);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: searchInput.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: searchInput.text === ""
|
||||
text: "Find a window"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.workspaceList.length + (root.workspaceList.length === 1 ? " workspace" : " workspaces")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
ScratchpadShelf {
|
||||
id: scratchpadShelf
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: root.sideMargin
|
||||
anchors.rightMargin: root.sideMargin
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 20
|
||||
workspace: root.scratchWorkspace
|
||||
refreshToken: root.refreshToken + root.actionRefreshToken
|
||||
onWindowDropped: toplevel => root.moveToplevel(toplevel, "special:scratch")
|
||||
onRestoreRequested: toplevel => root.restoreToplevel(toplevel)
|
||||
onCloseRequested: toplevel => root.closeToplevel(toplevel)
|
||||
}
|
||||
|
||||
// ── Workspace filmstrip ────────────────────────────────────────────────
|
||||
Flickable {
|
||||
id: filmstrip
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: 18
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: root.sideMargin
|
||||
anchors.rightMargin: root.sideMargin
|
||||
height: root.filmCardHeight
|
||||
clip: true
|
||||
contentWidth: Math.max(width, filmRow.implicitWidth)
|
||||
contentHeight: height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
onWheel: event => root.cycleWorkspace(event.angleDelta.y > 0 ? -1 : 1)
|
||||
}
|
||||
|
||||
Row {
|
||||
id: filmRow
|
||||
x: filmstrip.contentWidth > filmstrip.width ? 0 : (filmstrip.width - implicitWidth) / 2
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.workspaceList
|
||||
|
||||
WorkspaceCard {
|
||||
required property var modelData
|
||||
workspace: modelData
|
||||
refreshToken: root.refreshToken + root.actionRefreshToken
|
||||
emphasized: modelData.id === root.selectedWorkspaceId
|
||||
focusBound: FocusSession.active && modelData.id === FocusSession.workspaceId
|
||||
width: root.filmCardWidth
|
||||
height: root.filmCardHeight
|
||||
onActivated: root.selectWorkspace(modelData)
|
||||
onWindowActivated: toplevel => root.focusToplevel(toplevel)
|
||||
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
|
||||
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceCard {
|
||||
workspace: null
|
||||
newWorkspaceId: root.nextWorkspaceId
|
||||
emphasized: false
|
||||
width: root.filmCardWidth
|
||||
height: root.filmCardHeight
|
||||
onActivated: {
|
||||
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${root.nextWorkspaceId} })`);
|
||||
root.selectedWorkspaceId = root.nextWorkspaceId;
|
||||
}
|
||||
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
|
||||
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Large selected desktop ─────────────────────────────────────────────
|
||||
Item {
|
||||
id: desktopStage
|
||||
anchors.top: filmstrip.bottom
|
||||
anchors.topMargin: 26
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: hint.top
|
||||
anchors.bottomMargin: 18
|
||||
visible: root.query.trim() === ""
|
||||
|
||||
WorkspaceCard {
|
||||
id: largeWorkspace
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 18
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
workspace: root.selectedWorkspace
|
||||
refreshToken: root.refreshToken + root.actionRefreshToken
|
||||
emphasized: true
|
||||
focusBound: FocusSession.active && root.selectedWorkspace?.id === FocusSession.workspaceId
|
||||
width: Math.min(parent.width - root.sideMargin * 2, 1760)
|
||||
height: Math.min(parent.height, Math.round(width / root.screenAspect) + labelHeight)
|
||||
onActivated: {
|
||||
if (workspace)
|
||||
workspace.activate();
|
||||
ShellState.close();
|
||||
}
|
||||
onWindowActivated: toplevel => root.focusToplevel(toplevel)
|
||||
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
|
||||
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cross-workspace search results ─────────────────────────────────────
|
||||
Flickable {
|
||||
id: searchResults
|
||||
anchors.top: filmstrip.bottom
|
||||
anchors.topMargin: 28
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: hint.top
|
||||
anchors.leftMargin: root.sideMargin
|
||||
anchors.rightMargin: root.sideMargin
|
||||
anchors.bottomMargin: 18
|
||||
visible: root.query.trim() !== ""
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: resultFlow.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Flow {
|
||||
id: resultFlow
|
||||
readonly property real cardWidth: (searchResults.width - Theme.sectionSpacing * 2) / 3
|
||||
x: Math.max(0, (searchResults.width - width) / 2)
|
||||
width: root.resultColumns * cardWidth + (root.resultColumns - 1) * Theme.sectionSpacing
|
||||
spacing: Theme.sectionSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.filteredWindows
|
||||
|
||||
WindowThumbnail {
|
||||
required property var modelData
|
||||
required property int index
|
||||
toplevel: modelData.toplevel
|
||||
refreshToken: root.refreshToken + root.actionRefreshToken
|
||||
selected: index === root.selectedResultIndex
|
||||
width: resultFlow.cardWidth
|
||||
height: Math.round(width / root.screenAspect) + 18
|
||||
onActivated: root.focusToplevel(modelData.toplevel)
|
||||
onCloseRequested: root.closeToplevel(modelData.toplevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.filteredWindows.length === 0
|
||||
text: "No windows match “" + root.query + "”"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: hint
|
||||
anchors.bottom: scratchpadShelf.top
|
||||
anchors.bottomMargin: 8
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.query.trim() === "" ? "Drag windows between workspaces or into Scratchpad · Esc to return" : "Type to search every workspace · Enter opens the first match"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// One global place to set windows aside. Empty, it is a quiet drop target;
|
||||
// populated, it expands into a compact row of restorable window cards.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var workspace
|
||||
property int refreshToken: 0
|
||||
|
||||
signal windowDropped(var toplevel)
|
||||
signal restoreRequested(var toplevel)
|
||||
signal closeRequested(var toplevel)
|
||||
|
||||
readonly property var windows: root.workspace?.toplevels?.values ?? []
|
||||
readonly property bool populated: root.windows.length > 0
|
||||
|
||||
implicitHeight: root.populated ? 112 : 42
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
width: root.populated
|
||||
? Math.min(parent.width, Math.max(360, root.windows.length * 198 + 28))
|
||||
: 292
|
||||
height: root.populated ? 112 : 42
|
||||
radius: root.populated ? Theme.popoverRadius : Theme.pillRadius
|
||||
border.width: 1
|
||||
border.color: dropArea.containsDrag ? Theme.ok : Theme.alpha(Theme.fg, root.populated ? 0.1 : 0.08)
|
||||
color: Theme.alpha(Theme.bgPopover, root.populated ? 0.76 : 0.42)
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on height {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.populated
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: dropArea.containsDrag ? "\u{F04E7}" : "\u{F02D4}"
|
||||
color: dropArea.containsDrag ? Theme.ok : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: dropArea.containsDrag ? "Release to set aside" : "Drop to Scratchpad"
|
||||
color: dropArea.containsDrag ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 14
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 9
|
||||
visible: root.populated
|
||||
text: "Scratchpad · " + root.windows.length
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: windowStrip
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
anchors.bottomMargin: 8
|
||||
height: 76
|
||||
visible: root.populated
|
||||
clip: true
|
||||
contentWidth: Math.max(width, cards.implicitWidth)
|
||||
contentHeight: height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Row {
|
||||
id: cards
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
WindowThumbnail {
|
||||
required property var modelData
|
||||
toplevel: modelData
|
||||
refreshToken: root.refreshToken
|
||||
width: 190
|
||||
height: 76
|
||||
onActivated: root.restoreRequested(modelData)
|
||||
onCloseRequested: root.closeRequested(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: dropArea
|
||||
anchors.fill: parent
|
||||
z: 5
|
||||
onDropped: drop => {
|
||||
if (!drop.source?.toplevel)
|
||||
return;
|
||||
root.windowDropped(drop.source.toplevel);
|
||||
drop.acceptProposedAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// A single live window preview inside a workspace card, with the app icon and
|
||||
// title underneath — GNOME's overview window chrome.
|
||||
//
|
||||
// Capture is deliberately one-shot (`live: false` + an explicit captureFrame()
|
||||
// on open). Streaming every window continuously would repaint the whole
|
||||
// overview forever, which is exactly the idle-repaint cost this shell avoids.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var toplevel // HyprlandToplevel
|
||||
|
||||
// Bumped by Overview each time it opens; each bump re-captures.
|
||||
property int refreshToken: 0
|
||||
property bool selected: false
|
||||
|
||||
signal activated
|
||||
signal closeRequested
|
||||
signal dragStarted
|
||||
signal dragFinished
|
||||
|
||||
property real dragStartX: 0
|
||||
property real dragStartY: 0
|
||||
|
||||
z: dragHandler.active ? 100 : 0
|
||||
opacity: dragHandler.active ? 0.88 : 1
|
||||
Drag.active: dragHandler.active
|
||||
Drag.hotSpot.x: width / 2
|
||||
Drag.hotSpot.y: height / 2
|
||||
Drag.supportedActions: Qt.MoveAction
|
||||
|
||||
// Null until Hyprland reports the address — and always null on compositors
|
||||
// without wlr-screencopy, which is why every use of it is guarded.
|
||||
readonly property var source: toplevel ? toplevel.wayland : null
|
||||
readonly property string title: toplevel && toplevel.title ? toplevel.title : ""
|
||||
readonly property string appId: source && source.appId ? source.appId : ""
|
||||
// The `applications` read is deliberate: the entry scan finishes
|
||||
// asynchronously, and heuristicLookup() alone creates no binding dependency.
|
||||
readonly property var entry: appId && DesktopEntries.applications.values.length > 0 ? DesktopEntries.heuristicLookup(appId) : null
|
||||
|
||||
// "" when nothing resolves — a missing icon is left blank rather than shown
|
||||
// as the theme's broken-icon placeholder.
|
||||
readonly property string iconSource: {
|
||||
const name = entry && entry.icon ? entry.icon : appId;
|
||||
if (!name)
|
||||
return "";
|
||||
if (name.startsWith("/"))
|
||||
return "file://" + name;
|
||||
return Quickshell.iconPath(name, true);
|
||||
}
|
||||
|
||||
onRefreshTokenChanged: root.recapture()
|
||||
|
||||
function recapture(): void {
|
||||
if (shotLoader.item)
|
||||
shotLoader.item.tryCapture();
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: preview
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: caption.top
|
||||
anchors.bottomMargin: 4
|
||||
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0 // QTBUG-137166
|
||||
color: Theme.alpha(Theme.bg, 0.6)
|
||||
clip: true
|
||||
|
||||
scale: dragHandler.active ? 1.04 : (hover.hovered ? 1.025 : 1)
|
||||
|
||||
Behavior on scale {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
// Shown until (or unless) a frame arrives. On a compositor without
|
||||
// screencopy this is all the user ever sees, rather than an error.
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
visible: root.iconSource !== ""
|
||||
source: root.iconSource
|
||||
implicitSize: Math.max(24, Math.min(64, Math.min(preview.width, preview.height) * 0.4))
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
opacity: shotLoader.hasFrame ? 0 : 1
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: shotLoader
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property bool hasFrame: item ? item.hasContent : false
|
||||
|
||||
// Only instantiate a capture when there is something to capture AND
|
||||
// the overview has been opened at least once.
|
||||
//
|
||||
// refreshToken is incremented by Overview.qml at the moment it maps,
|
||||
// so gating on it guarantees the ScreencopyView is created while the
|
||||
// window is actually mapped. Creating it earlier makes the initial
|
||||
// captureFrame() fail with "no recording context is ready", because
|
||||
// an unmapped layer surface has no capture context yet.
|
||||
active: !!root.source && root.refreshToken > 0
|
||||
sourceComponent: shotComponent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.selected
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: Theme.accent
|
||||
z: 3
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeButton
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 7
|
||||
visible: hover.hovered && !dragHandler.active
|
||||
width: 26
|
||||
height: 26
|
||||
radius: 13
|
||||
z: 4
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
color: closeHover.hovered ? Theme.alpha(Theme.danger, 0.28) : Theme.alpha(Theme.bgDark, 0.82)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u{F0156}"
|
||||
color: closeHover.hovered ? Theme.danger : Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 13
|
||||
}
|
||||
|
||||
HoverHandler { id: closeHover }
|
||||
TapHandler {
|
||||
onTapped: root.closeRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: shotComponent
|
||||
|
||||
ScreencopyView {
|
||||
id: shot
|
||||
captureSource: root.source
|
||||
live: false
|
||||
paintCursor: false
|
||||
constraintSize: Qt.size(preview.width, preview.height)
|
||||
|
||||
// Fit inside the preview without distorting the window's shape.
|
||||
readonly property real aspect: sourceSize.height > 0 ? sourceSize.width / sourceSize.height : 16 / 9
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, parent.height * aspect)
|
||||
height: aspect > 0 ? width / aspect : parent.height
|
||||
opacity: hasContent ? 1 : 0
|
||||
|
||||
// The capture context isn't ready the instant the layer surface is
|
||||
// told to become visible — it needs the surface to actually map,
|
||||
// which takes a frame or two. Calling captureFrame() before then
|
||||
// logs "no recording context is ready" and yields nothing, so
|
||||
// retry a few times and then give up quietly (the caption and app
|
||||
// icon are still shown, so a missing thumbnail is cosmetic).
|
||||
property int captureAttempts: 0
|
||||
|
||||
function tryCapture(): void {
|
||||
captureAttempts = 0;
|
||||
captureRetry.restart();
|
||||
}
|
||||
|
||||
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
|
||||
// resolve to the enclosing ScreencopyView.
|
||||
Timer {
|
||||
id: captureRetry
|
||||
interval: 80
|
||||
repeat: true
|
||||
running: false
|
||||
onTriggered: {
|
||||
if (shot.hasContent || shot.captureAttempts >= 6) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
shot.captureAttempts++;
|
||||
shot.captureFrame();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: tryCapture()
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: caption
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 6
|
||||
height: 18
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.iconSource !== ""
|
||||
width: visible ? 16 : 0
|
||||
height: 16
|
||||
source: root.iconSource
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// Clamped against the thumbnail rather than the Row: sizing against
|
||||
// the Row would make its width depend on its own implicitWidth.
|
||||
width: Math.min(implicitWidth, Math.max(0, root.width - 22))
|
||||
text: root.title
|
||||
elide: Text.ElideRight
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.activated()
|
||||
}
|
||||
|
||||
DragHandler {
|
||||
id: dragHandler
|
||||
target: root
|
||||
cursorShape: Qt.DragMoveCursor
|
||||
|
||||
onActiveChanged: {
|
||||
if (active) {
|
||||
root.dragStartX = root.x;
|
||||
root.dragStartY = root.y;
|
||||
root.dragStarted();
|
||||
} else {
|
||||
root.Drag.drop();
|
||||
root.x = root.dragStartX;
|
||||
root.y = root.dragStartY;
|
||||
root.dragFinished();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// One workspace in the overview: a proportional card holding live thumbnails
|
||||
// of its windows, labelled underneath. The focused workspace is outlined in the
|
||||
// accent colour, as GNOME outlines the current workspace.
|
||||
//
|
||||
// A card with a null `workspace` is the trailing "new workspace" affordance
|
||||
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// HyprlandWorkspace, or null for the trailing "new workspace" card.
|
||||
required property var workspace
|
||||
// Shown on the empty trailing card.
|
||||
property int newWorkspaceId: 0
|
||||
|
||||
property int refreshToken: 0
|
||||
// `emphasized` is the overview's current spatial selection. It normally
|
||||
// follows compositor focus, but can point at a requested focus workspace
|
||||
// before the user commits to switching there.
|
||||
property bool emphasized: focused
|
||||
property bool focusBound: false
|
||||
|
||||
signal activated
|
||||
signal windowActivated(var toplevel)
|
||||
signal windowCloseRequested(var toplevel)
|
||||
signal windowDropped(var toplevel, string workspaceName)
|
||||
|
||||
readonly property bool isNew: !workspace
|
||||
readonly property var windows: workspace ? workspace.toplevels.values : []
|
||||
readonly property bool focused: workspace ? workspace.focused : false
|
||||
readonly property string dropWorkspaceName: workspace ? String(workspace.id)
|
||||
: (newWorkspaceId > 0 ? String(newWorkspaceId) : "")
|
||||
|
||||
readonly property string label: {
|
||||
if (isNew)
|
||||
return qsTr("New");
|
||||
// Named workspaces keep their name; numbered ones read better as
|
||||
// "Workspace N" only when there is room, so just show the name.
|
||||
return workspace.name ? workspace.name : String(workspace.id);
|
||||
}
|
||||
|
||||
// Card body — the label sits under it, so reserve that space.
|
||||
readonly property int labelHeight: 22
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: root.labelHeight
|
||||
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.55)
|
||||
border.width: 2
|
||||
border.color: {
|
||||
if (dropArea.containsDrag)
|
||||
return Theme.ok;
|
||||
if (root.emphasized)
|
||||
return Theme.accent;
|
||||
if (root.focusBound)
|
||||
return Theme.alpha(Theme.accentSecondary, 0.72);
|
||||
return Theme.alpha(Theme.fg, 0.08);
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
// Windows are laid out on a near-square grid. Hyprland does not expose
|
||||
// live per-window geometry, so an honest grid beats a wrong guess at
|
||||
// the real tiling.
|
||||
readonly property int columns: Math.max(1, Math.ceil(Math.sqrt(root.windows.length)))
|
||||
readonly property int rows: Math.max(1, Math.ceil(root.windows.length / columns))
|
||||
readonly property int cellW: Math.floor((width - Theme.itemSpacing * (columns + 1)) / columns)
|
||||
readonly property int cellH: Math.floor((height - Theme.itemSpacing * (rows + 1)) / rows)
|
||||
|
||||
Grid {
|
||||
anchors.centerIn: parent
|
||||
columns: card.columns
|
||||
spacing: Theme.itemSpacing
|
||||
visible: root.windows.length > 0
|
||||
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
WindowThumbnail {
|
||||
required property var modelData
|
||||
toplevel: modelData
|
||||
refreshToken: root.refreshToken
|
||||
width: card.cellW
|
||||
height: card.cellH
|
||||
onActivated: root.windowActivated(modelData)
|
||||
onCloseRequested: root.windowCloseRequested(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropArea {
|
||||
id: dropArea
|
||||
anchors.fill: parent
|
||||
z: 3
|
||||
enabled: root.dropWorkspaceName !== ""
|
||||
onDropped: drop => {
|
||||
if (!drop.source?.toplevel)
|
||||
return;
|
||||
root.windowDropped(drop.source.toplevel, root.dropWorkspaceName);
|
||||
drop.acceptProposedAction();
|
||||
}
|
||||
}
|
||||
|
||||
// Empty workspace: a big soft "+" so the card still reads as clickable.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.windows.length === 0
|
||||
text: "+"
|
||||
color: Theme.alpha(Theme.fg, 0.25)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(Theme.fontSizeTitle, Math.round(card.height * 0.28))
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 8
|
||||
visible: root.focusBound
|
||||
width: focusBadge.implicitWidth + 16
|
||||
height: 24
|
||||
radius: Theme.pillRadius
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgDark, 0.82)
|
||||
|
||||
Row {
|
||||
id: focusBadge
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F051F}"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Focus"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sits below the thumbnails so clicking a window still focuses it.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
onClicked: root.activated()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
height: root.labelHeight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.label
|
||||
color: root.emphasized ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.bold: root.emphasized
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// One tile in the power menu: big glyph, label underneath.
|
||||
//
|
||||
// Anything that ends the session arms on the first press and only fires on the
|
||||
// second, with the label swapping to "Confirm" — an accidental Ctrl+Alt+Delete
|
||||
// should never be one click away from losing everything that is open.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string glyph: ""
|
||||
property string label: ""
|
||||
// Tint red on hover and require the two-step confirm.
|
||||
property bool destructive: false
|
||||
// Highlighted by keyboard navigation.
|
||||
property bool selected: false
|
||||
|
||||
signal activated
|
||||
|
||||
readonly property bool armed: confirmTimer.running
|
||||
|
||||
implicitWidth: 136
|
||||
implicitHeight: 136
|
||||
radius: Theme.cardRadius + 6
|
||||
border.width: 0
|
||||
|
||||
readonly property color accentFor: root.destructive ? Theme.danger : Theme.accent
|
||||
|
||||
color: {
|
||||
if (root.armed)
|
||||
return Theme.alpha(Theme.danger, 0.32);
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(root.accentFor, 0.3);
|
||||
if (mouse.containsMouse || root.selected)
|
||||
return Theme.alpha(root.accentFor, 0.18);
|
||||
return Theme.alpha(Theme.fg, 0.07);
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard focus ring.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.width: root.selected ? 2 : 0
|
||||
border.color: root.accentFor
|
||||
visible: root.selected
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.glyph
|
||||
color: root.armed ? Theme.danger : Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 40
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: root.armed ? "Confirm" : root.label
|
||||
color: root.armed ? Theme.danger : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger from a click or from Enter while keyboard-selected.
|
||||
function trigger(): void {
|
||||
if (root.destructive && !root.armed) {
|
||||
confirmTimer.restart();
|
||||
return;
|
||||
}
|
||||
confirmTimer.stop();
|
||||
root.activated();
|
||||
}
|
||||
|
||||
function disarm(): void {
|
||||
confirmTimer.stop();
|
||||
}
|
||||
|
||||
// Arming is not sticky: walk away and the button forgets.
|
||||
Timer {
|
||||
id: confirmTimer
|
||||
interval: 4000
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.trigger()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Replaces GNOME's power-off dialog. Opened by Ctrl+Alt+Delete and from the
|
||||
// quick-settings footer.
|
||||
//
|
||||
// Log Out, Restart and Power Off arm on the first press and fire on the second
|
||||
// (see PowerButton); Lock and Suspend are one press because neither loses work.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
|
||||
visible: ShellState.powerMenuOpen
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
bottom: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
exclusiveZone: 0
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-power"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
|
||||
property int currentIndex: 0
|
||||
|
||||
// The session is uwsm-managed, so logging out means stopping the uwsm unit
|
||||
// rather than killing the compositor; the hyprctl branch is only there for
|
||||
// a session started without it.
|
||||
readonly property string logoutScript: 'if command -v uwsm >/dev/null 2>&1; then exec uwsm stop; else exec hyprctl dispatch "hl.dsp.exit()"; fi'
|
||||
|
||||
readonly property var entries: [
|
||||
{
|
||||
glyph: "",
|
||||
label: "Lock",
|
||||
destructive: false,
|
||||
cmd: ["loginctl", "lock-session"]
|
||||
},
|
||||
{
|
||||
glyph: "",
|
||||
label: "Log Out",
|
||||
destructive: true,
|
||||
cmd: ["sh", "-c", win.logoutScript]
|
||||
},
|
||||
{
|
||||
glyph: "",
|
||||
label: "Suspend",
|
||||
destructive: false,
|
||||
cmd: ["systemctl", "suspend"]
|
||||
},
|
||||
{
|
||||
glyph: "",
|
||||
label: "Restart",
|
||||
destructive: true,
|
||||
cmd: ["systemctl", "reboot"]
|
||||
},
|
||||
{
|
||||
glyph: "",
|
||||
label: "Power Off",
|
||||
destructive: true,
|
||||
cmd: ["systemctl", "poweroff"]
|
||||
}
|
||||
]
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!win.visible)
|
||||
return;
|
||||
// Never reopen with a destructive button still armed from last time.
|
||||
win.currentIndex = 0;
|
||||
for (let i = 0; i < rep.count; i++)
|
||||
rep.itemAt(i).disarm();
|
||||
keys.forceActiveFocus();
|
||||
}
|
||||
|
||||
function run(index: int): void {
|
||||
ShellState.close();
|
||||
Quickshell.execDetached(win.entries[index].cmd);
|
||||
}
|
||||
|
||||
function move(step: int): void {
|
||||
// Moving away disarms, so Enter can never fire a button the user only
|
||||
// meant to walk past.
|
||||
rep.itemAt(win.currentIndex).disarm();
|
||||
win.currentIndex = (win.currentIndex + step + win.entries.length) % win.entries.length;
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
id: keys
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Escape:
|
||||
ShellState.close();
|
||||
break;
|
||||
case Qt.Key_Left:
|
||||
win.move(-1);
|
||||
break;
|
||||
case Qt.Key_Right:
|
||||
win.move(1);
|
||||
break;
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
case Qt.Key_Space:
|
||||
rep.itemAt(win.currentIndex).trigger();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
border.width: 0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ShellState.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: row.implicitWidth + 2 * 24
|
||||
implicitHeight: row.implicitHeight + 2 * 24
|
||||
radius: Theme.popoverRadius + 8
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
|
||||
// Swallow clicks that land on the panel but miss a tile, so the
|
||||
// outside-click handler underneath doesn't close the menu.
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 14
|
||||
|
||||
Repeater {
|
||||
id: rep
|
||||
model: win.entries
|
||||
|
||||
PowerButton {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
glyph: modelData.glyph
|
||||
label: modelData.label
|
||||
destructive: modelData.destructive
|
||||
selected: index === win.currentIndex
|
||||
onActivated: win.run(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Output / input device picker. Writing Pipewire.preferredDefaultAudioSink is
|
||||
// what actually moves audio; the default* properties are read-only reflections
|
||||
// of what the session manager settled on.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// false → input devices.
|
||||
property bool output: true
|
||||
property alias maxHeight: list.maxHeight
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
readonly property var nodes: {
|
||||
return Pipewire.nodes.values.filter(n => {
|
||||
if (n.isStream)
|
||||
return false;
|
||||
// Sources have to be filtered on the type flags: !isSink also
|
||||
// matches video nodes (webcams show up here otherwise).
|
||||
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
|
||||
});
|
||||
}
|
||||
|
||||
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
|
||||
|
||||
function select(node): void {
|
||||
if (root.output)
|
||||
Pipewire.preferredDefaultAudioSink = node;
|
||||
else
|
||||
Pipewire.preferredDefaultAudioSource = node;
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
maxHeight: 220
|
||||
|
||||
Repeater {
|
||||
model: root.nodes
|
||||
|
||||
RowButton {
|
||||
id: nodeRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: 38
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
|
||||
selected: nodeRow.modelData === root.current
|
||||
onClicked: root.select(nodeRow.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Volume row: mute button, slider, and a chevron that opens the device picker.
|
||||
//
|
||||
// PwObjectTracker is mandatory — without it node.audio.volume reads 0 and
|
||||
// writes are dropped on the floor with no error.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
// Output vs input, purely for icon selection.
|
||||
property bool output: true
|
||||
property bool expanded: false
|
||||
|
||||
signal toggleExpanded
|
||||
|
||||
implicitHeight: 32
|
||||
visible: root.node !== null
|
||||
|
||||
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
|
||||
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
|
||||
function iconName(): string {
|
||||
if (root.output) {
|
||||
if (root.muted || root.volume <= 0.001)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (root.volume < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (root.volume < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
}
|
||||
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
|
||||
onClicked: {
|
||||
if (root.node && root.node.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.right: chevron.left
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: v => {
|
||||
if (!root.node || !root.node.audio)
|
||||
return;
|
||||
// Nudging the slider is also how you unmute, same as GNOME.
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = v;
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: chevron
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 28
|
||||
iconSize: 14
|
||||
icon: root.expanded ? "pan-up-symbolic" : "pan-down-symbolic"
|
||||
iconFallback: "go-next-symbolic"
|
||||
onClicked: root.toggleExpanded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Bluetooth device picker. Paired devices first (that's what you actually
|
||||
// reconnect to), then whatever discovery has turned up.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// True while the section is open. Discovery is expensive, so it only runs
|
||||
// while the list is on screen — same as the GNOME panel.
|
||||
property bool active: false
|
||||
property alias maxHeight: list.maxHeight
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
property bool discoveryOwned: false
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
readonly property var devices: {
|
||||
const list = Bluetooth.devices.values.slice();
|
||||
list.sort((a, b) => {
|
||||
if (a.connected !== b.connected)
|
||||
return a.connected ? -1 : 1;
|
||||
if (a.paired !== b.paired)
|
||||
return a.paired ? -1 : 1;
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function syncDiscovery(): void {
|
||||
if (!root.adapter)
|
||||
return;
|
||||
const shouldDiscover = root.active && root.adapter.enabled;
|
||||
if (shouldDiscover && !root.adapter.discovering) {
|
||||
root.adapter.discovering = true;
|
||||
root.discoveryOwned = true;
|
||||
} else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) {
|
||||
root.adapter.discovering = false;
|
||||
root.discoveryOwned = false;
|
||||
}
|
||||
}
|
||||
|
||||
onActiveChanged: root.syncDiscovery()
|
||||
onAdapterChanged: {
|
||||
if (root.active)
|
||||
root.syncDiscovery();
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (root.adapter && root.discoveryOwned && root.adapter.discovering)
|
||||
root.adapter.discovering = false;
|
||||
}
|
||||
|
||||
function stateText(device): string {
|
||||
if (device.pairing)
|
||||
return "Pairing…";
|
||||
switch (device.state) {
|
||||
case BluetoothDeviceState.Connecting:
|
||||
return "Connecting…";
|
||||
case BluetoothDeviceState.Disconnecting:
|
||||
return "Disconnecting…";
|
||||
case BluetoothDeviceState.Connected:
|
||||
return device.batteryAvailable ? "Connected · " + Math.round(device.battery * 100) + "%" : "Connected";
|
||||
default:
|
||||
return device.paired ? "Paired" : "";
|
||||
}
|
||||
}
|
||||
|
||||
// Click cycles the obvious next step: pair, connect, disconnect.
|
||||
function activate(device): void {
|
||||
if (device.connected)
|
||||
device.disconnect();
|
||||
else if (device.paired)
|
||||
device.connect();
|
||||
else
|
||||
device.pair();
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
maxHeight: 300
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.devices.length === 0
|
||||
topPadding: 12
|
||||
bottomPadding: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: {
|
||||
if (!root.adapter)
|
||||
return "No Bluetooth adapter";
|
||||
if (!root.adapter.enabled)
|
||||
return "Bluetooth is off";
|
||||
return "Searching…";
|
||||
}
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.devices
|
||||
|
||||
RowButton {
|
||||
id: deviceRow
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
// BlueZ reports a plain freedesktop name ("audio-headphones");
|
||||
// the symbolic variant is the one that can be recoloured.
|
||||
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
|
||||
iconFallback: "bluetooth-symbolic"
|
||||
label: deviceRow.modelData.name || deviceRow.modelData.address
|
||||
sublabel: root.stateText(deviceRow.modelData)
|
||||
selected: deviceRow.modelData.connected
|
||||
onClicked: root.activate(deviceRow.modelData)
|
||||
|
||||
// Forgetting is destructive enough that it gets its own
|
||||
// control rather than sharing the row click.
|
||||
IconButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: deviceRow.modelData.paired
|
||||
size: 26
|
||||
iconSize: 13
|
||||
icon: "user-trash-symbolic"
|
||||
iconFallback: "window-close-symbolic"
|
||||
onClicked: deviceRow.modelData.forget()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Backlight slider, via brightnessctl.
|
||||
//
|
||||
// This machine drives an external DisplayPort monitor and has no backlight
|
||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
||||
// row removes itself rather than sitting there as a dead control. Probed once
|
||||
// at startup — backlight devices do not appear and disappear.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property real value: 0
|
||||
|
||||
visible: root.available
|
||||
implicitHeight: root.available ? 32 : 0
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
running: true
|
||||
command: ["brightnessctl", "-m", "-c", "backlight", "-l"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parseDevices(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
function parseDevices(text: string): void {
|
||||
for (const line of text.trim().split("\n")) {
|
||||
const fields = line.split(",");
|
||||
if (fields.length < 5 || fields[1] !== "backlight")
|
||||
continue;
|
||||
root.available = true;
|
||||
root.value = parseInt(fields[3]) / 100;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(v: real): void {
|
||||
root.value = v;
|
||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||
}
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 17
|
||||
icon: "display-brightness-symbolic"
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.value
|
||||
onMoved: v => root.apply(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// A small circular icon button — mute toggles, chevrons, per-row actions.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string icon: ""
|
||||
property string iconFallback: "dialog-information-symbolic"
|
||||
property int size: 28
|
||||
property int iconSize: 16
|
||||
property color tint: Theme.fg
|
||||
|
||||
signal clicked
|
||||
|
||||
implicitWidth: root.size
|
||||
implicitHeight: root.size
|
||||
radius: root.size / 2
|
||||
border.width: 0
|
||||
|
||||
color: {
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(Theme.fg, Theme.activeAlpha);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: root.iconSize
|
||||
icon: root.icon
|
||||
iconFallback: root.iconFallback
|
||||
tint: root.tint
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// The quick settings popover — GNOME's system menu (Super+S).
|
||||
//
|
||||
// A layer-shell surface rather than a PopupWindow because it needs keyboard
|
||||
// focus for the wifi password field, and because the bar it hangs from is
|
||||
// owned by a different module.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
visible: ShellState.quickSettingsOpen
|
||||
color: "transparent"
|
||||
|
||||
// Hangs off the top-right corner, clear of the floating bar.
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitHeight: panel.implicitHeight
|
||||
|
||||
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
||||
WlrLayershell.namespace: "qs-popover-quicksettings"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// OnDemand rather than Exclusive: the panel must be able to take typing for
|
||||
// the wifi password field, but must not steal it while merely visible.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
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
|
||||
}
|
||||
|
||||
transform: Scale {
|
||||
id: openScale
|
||||
origin.x: surface.width
|
||||
origin.y: 0
|
||||
xScale: 1
|
||||
yScale: 1
|
||||
}
|
||||
|
||||
// Escape reaches here by propagating up from whatever holds focus
|
||||
// (usually the password field), so it works in every sub-state.
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
Keys.onEscapePressed: ShellState.close()
|
||||
|
||||
QuickSettingsPanel {
|
||||
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,296 @@
|
||||
// The contents of the quick settings popover — GNOME's system menu, in order:
|
||||
// toggle grid, detail lists, sliders, footer.
|
||||
//
|
||||
// Kept separate from QuickSettings.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 Quickshell.Widgets
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
|
||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||
// open at a time, so the panel never grows past the screen.
|
||||
property string expandedSection: ""
|
||||
|
||||
function expand(name: string): void {
|
||||
root.expandedSection = root.expandedSection === name ? "" : name;
|
||||
}
|
||||
|
||||
// Collapse everything — called when the popover closes so it reopens in a
|
||||
// known state.
|
||||
function reset(): void {
|
||||
root.expandedSection = "";
|
||||
}
|
||||
|
||||
// ── System state ────────────────────────────────────────────────────────
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly property var wiredDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wired && device.connected)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly property string ssid: {
|
||||
if (!root.wifiDevice || !root.wifiDevice.networks)
|
||||
return "";
|
||||
for (const network of root.wifiDevice.networks.values) {
|
||||
if (network.connected)
|
||||
return network.name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
readonly property var btAdapter: Bluetooth.defaultAdapter
|
||||
|
||||
readonly property string btSummary: {
|
||||
if (!root.btAdapter)
|
||||
return "Unavailable";
|
||||
if (!root.btAdapter.enabled)
|
||||
return "Off";
|
||||
for (const device of Bluetooth.devices.values) {
|
||||
if (device.connected)
|
||||
return device.name || device.address;
|
||||
}
|
||||
return "On";
|
||||
}
|
||||
|
||||
readonly property int cellWidth: (content.width - Theme.itemSpacing) / 2
|
||||
|
||||
Column {
|
||||
id: content
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
// ── Toggle grid ─────────────────────────────────────────────────────
|
||||
Grid {
|
||||
columns: 2
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: Networking.wifiEnabled ? "network-wireless-symbolic" : "network-wireless-offline-symbolic"
|
||||
label: "Wi-Fi"
|
||||
active: Networking.wifiEnabled
|
||||
enabled: Networking.wifiHardwareEnabled
|
||||
sublabel: {
|
||||
if (!Networking.wifiEnabled)
|
||||
return "Off";
|
||||
return root.ssid !== "" ? root.ssid : "Not connected";
|
||||
}
|
||||
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
|
||||
onExpanded: root.expand("wifi")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
||||
label: "Bluetooth"
|
||||
active: root.btAdapter !== null && root.btAdapter.enabled
|
||||
enabled: root.btAdapter !== null
|
||||
sublabel: root.btSummary
|
||||
onToggled: {
|
||||
if (root.btAdapter)
|
||||
root.btAdapter.enabled = !root.btAdapter.enabled;
|
||||
}
|
||||
onExpanded: root.expand("bluetooth")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: "preferences-desktop-screensaver-symbolic"
|
||||
label: "Caffeine"
|
||||
sublabel: Caffeine.enabled ? "Staying awake" : "Off"
|
||||
active: Caffeine.enabled
|
||||
onToggled: Caffeine.toggle()
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: "night-light-symbolic"
|
||||
label: "Night Light"
|
||||
active: NightLight.active
|
||||
sublabel: {
|
||||
if (NightLight.automatic)
|
||||
return NightLight.active ? "Scheduled · on" : "Scheduled";
|
||||
return NightLight.active ? NightLight.temperature + "K" : "Off";
|
||||
}
|
||||
onToggled: NightLight.toggle()
|
||||
// No detail list to open, so the secondary action flips the
|
||||
// schedule on and off instead.
|
||||
onExpanded: NightLight.automatic = !NightLight.automatic
|
||||
}
|
||||
}
|
||||
|
||||
RowButton {
|
||||
width: content.width
|
||||
icon: "preferences-system-time-symbolic"
|
||||
iconFallback: "appointment-soon-symbolic"
|
||||
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
|
||||
sublabel: FocusSession.active ? FocusSession.remainingText + (FocusSession.paused ? " · paused" : " remaining") : Settings.focusDurationMinutes + " minutes · current workspace"
|
||||
selected: FocusSession.active
|
||||
onClicked: {
|
||||
ShellState.close();
|
||||
FocusSession.reveal();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail lists ────────────────────────────────────────────────────
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "wifi"
|
||||
|
||||
WifiList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
device: root.wifiDevice
|
||||
active: root.expandedSection === "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "bluetooth"
|
||||
|
||||
BluetoothList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
active: root.expandedSection === "bluetooth"
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: content.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── Sliders ─────────────────────────────────────────────────────────
|
||||
AudioSlider {
|
||||
width: content.width
|
||||
node: Pipewire.defaultAudioSink
|
||||
output: true
|
||||
expanded: root.expandedSection === "sink"
|
||||
onToggleExpanded: root.expand("sink")
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "sink"
|
||||
|
||||
AudioDeviceList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
output: true
|
||||
}
|
||||
}
|
||||
|
||||
AudioSlider {
|
||||
width: content.width
|
||||
node: Pipewire.defaultAudioSource
|
||||
output: false
|
||||
expanded: root.expandedSection === "source"
|
||||
onToggleExpanded: root.expand("source")
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "source"
|
||||
|
||||
AudioDeviceList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
output: false
|
||||
}
|
||||
}
|
||||
|
||||
BrightnessControl {
|
||||
width: content.width
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: content.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────────
|
||||
Item {
|
||||
width: content.width
|
||||
height: 36
|
||||
|
||||
ThemedIcon {
|
||||
id: avatar
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 20
|
||||
icon: "avatar-default-symbolic"
|
||||
iconFallback: "user-info-symbolic"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: avatar.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: actions.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Quickshell.env("USER") || "user"
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actions
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
IconButton {
|
||||
size: 32
|
||||
iconSize: 17
|
||||
icon: "preferences-system-symbolic"
|
||||
onClicked: {
|
||||
ShellState.close();
|
||||
ShellState.openSettings("home");
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
size: 32
|
||||
iconSize: 17
|
||||
icon: "system-shutdown-symbolic"
|
||||
onClicked: ShellState.open("powermenu")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// One line in a detail list (a wifi network, a bluetooth device, an output
|
||||
// device). Icon, two lines of text, and an optional trailing slot.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string icon: ""
|
||||
// Used when `icon` is not in the active icon theme — several of the names
|
||||
// GNOME uses only exist in Adwaita, and iconPath() returns "" otherwise.
|
||||
property string iconFallback: "dialog-information-symbolic"
|
||||
property string label: ""
|
||||
property string sublabel: ""
|
||||
property bool selected: false
|
||||
property bool dimmed: false
|
||||
|
||||
default property alias trailing: trailingRow.data
|
||||
|
||||
signal clicked
|
||||
|
||||
implicitHeight: 44
|
||||
radius: Theme.cardRadius - 2
|
||||
border.width: 0
|
||||
|
||||
color: {
|
||||
if (root.selected)
|
||||
return Theme.alpha(Theme.accent, Theme.activeAlpha);
|
||||
if (mouse.pressed)
|
||||
return Theme.alpha(Theme.fg, Theme.activeAlpha);
|
||||
if (mouse.containsMouse)
|
||||
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.durFast
|
||||
}
|
||||
}
|
||||
|
||||
opacity: root.dimmed ? 0.5 : 1.0
|
||||
|
||||
// Declared first so anything in the trailing slot stacks above it and can
|
||||
// take its own clicks (a per-row disconnect button, say).
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
id: leading
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 20
|
||||
icon: root.icon
|
||||
iconFallback: root.iconFallback
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
visible: root.icon !== ""
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: leading.visible ? leading.right : parent.left
|
||||
anchors.leftMargin: leading.visible ? 10 : 12
|
||||
anchors.right: trailingRow.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: root.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.sublabel !== ""
|
||||
text: root.sublabel
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
// Full-height rather than vertically centred so slot contents can anchor
|
||||
// their own verticalCenter to it without a size loop.
|
||||
Row {
|
||||
id: trailingRow
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// A column that grows to fit its contents up to `maxHeight`, then scrolls.
|
||||
// Used by every detail list in the panel so they all overflow the same way.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int maxHeight: 300
|
||||
property alias spacing: column.spacing
|
||||
default property alias content: column.data
|
||||
|
||||
implicitHeight: Math.min(column.implicitHeight, root.maxHeight)
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
contentHeight: column.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: flick.width
|
||||
spacing: 2
|
||||
}
|
||||
}
|
||||
|
||||
// Static indicator — no fade timers, nothing repainting while idle. It is
|
||||
// only there so a clipped list doesn't look like the whole list.
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
width: 3
|
||||
radius: 1.5
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.25)
|
||||
visible: flick.contentHeight > flick.height + 1
|
||||
height: Math.max(24, flick.height * (flick.height / Math.max(1, flick.contentHeight)))
|
||||
y: (flick.height - height) * (flick.contentY / Math.max(1, flick.contentHeight - flick.height))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// An expander: collapses to zero height and animates open, GNOME-style.
|
||||
//
|
||||
// The child is measured at its natural size and the *section* animates; the
|
||||
// child itself never re-layouts, so opening a 20-row wifi list costs one
|
||||
// height animation rather than twenty.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
default property alias content: holder.data
|
||||
|
||||
clip: true
|
||||
// Column/ColumnLayout skip invisible children, so this also collapses the
|
||||
// surrounding spacing when closed.
|
||||
visible: implicitHeight > 0
|
||||
|
||||
implicitHeight: root.expanded ? holder.implicitHeight : 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation {
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: holder
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitHeight: childrenRect.height
|
||||
height: implicitHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// The wifi picker. This is the reason the quick settings panel exists: the
|
||||
// user must never have to drop to nmcli to join a network.
|
||||
//
|
||||
// Everything here goes through Quickshell.Networking (NetworkManager over
|
||||
// DBus) — no shelling out.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import Quickshell.Networking
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The WifiDevice, or null while NetworkManager is still enumerating.
|
||||
property var device: null
|
||||
|
||||
// True while the section is open. Drives the NM scan request so we are not
|
||||
// burning radio time scanning for a list nobody is looking at.
|
||||
property bool active: false
|
||||
|
||||
property alias maxHeight: list.maxHeight
|
||||
|
||||
// SSID whose inline password field is open, and the last failure.
|
||||
property string passwordFor: ""
|
||||
property string failedSsid: ""
|
||||
property string failedText: ""
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
// GNOME's ordering: the current network, then saved ones, then by signal.
|
||||
readonly property var networks: {
|
||||
if (!root.device || !root.device.networks)
|
||||
return [];
|
||||
const list = root.device.networks.values.slice();
|
||||
list.sort((a, b) => {
|
||||
if (a.connected !== b.connected)
|
||||
return a.connected ? -1 : 1;
|
||||
if (a.known !== b.known)
|
||||
return a.known ? -1 : 1;
|
||||
return b.signalStrength - a.signalStrength;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function syncScanner(): void {
|
||||
if (root.device)
|
||||
root.device.scannerEnabled = root.active;
|
||||
}
|
||||
|
||||
onActiveChanged: root.syncScanner()
|
||||
onDeviceChanged: root.syncScanner()
|
||||
Component.onCompleted: root.syncScanner()
|
||||
Component.onDestruction: {
|
||||
if (root.device)
|
||||
root.device.scannerEnabled = false;
|
||||
}
|
||||
|
||||
function signalIcon(strength: real): string {
|
||||
if (strength >= 0.8)
|
||||
return "network-wireless-signal-excellent-symbolic";
|
||||
if (strength >= 0.55)
|
||||
return "network-wireless-signal-good-symbolic";
|
||||
if (strength >= 0.3)
|
||||
return "network-wireless-signal-ok-symbolic";
|
||||
if (strength > 0.05)
|
||||
return "network-wireless-signal-weak-symbolic";
|
||||
return "network-wireless-signal-none-symbolic";
|
||||
}
|
||||
|
||||
// NM's failure reasons are useful but not English.
|
||||
function failureText(reason): string {
|
||||
switch (reason) {
|
||||
case ConnectionFailReason.NoSecrets:
|
||||
return "Wrong password";
|
||||
case ConnectionFailReason.WifiAuthTimeout:
|
||||
return "Authentication timed out";
|
||||
case ConnectionFailReason.WifiNetworkLost:
|
||||
return "Network out of range";
|
||||
default:
|
||||
return "Couldn't connect";
|
||||
}
|
||||
}
|
||||
|
||||
function isSecured(network): bool {
|
||||
return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe && network.security !== WifiSecurityType.Unknown;
|
||||
}
|
||||
|
||||
// Left click on a row. Saved and open networks connect immediately;
|
||||
// anything else reveals the password field rather than failing silently.
|
||||
function activate(network): void {
|
||||
root.failedSsid = "";
|
||||
if (network.connected)
|
||||
return;
|
||||
if (network.known || !root.isSecured(network)) {
|
||||
root.passwordFor = "";
|
||||
network.connect();
|
||||
return;
|
||||
}
|
||||
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
maxHeight: 340
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 30
|
||||
visible: root.networks.length > 0
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Available networks"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.networks.length === 0
|
||||
topPadding: 12
|
||||
bottomPadding: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: root.device ? (Networking.wifiEnabled ? "Scanning…" : "Wi-Fi is off") : "No Wi-Fi adapter"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.networks
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
readonly property bool secured: root.isSecured(entry.modelData)
|
||||
readonly property string ssid: entry.modelData.name
|
||||
|
||||
// Reported by NM when a connection attempt gives up. Bad
|
||||
// passwords land here, so re-open the field to retry.
|
||||
Connections {
|
||||
target: entry.modelData
|
||||
function onConnectionFailed(reason) {
|
||||
root.failedSsid = entry.ssid;
|
||||
root.failedText = root.failureText(reason);
|
||||
if (entry.secured && !entry.modelData.known)
|
||||
root.passwordFor = entry.ssid;
|
||||
}
|
||||
}
|
||||
|
||||
RowButton {
|
||||
width: parent.width
|
||||
icon: root.signalIcon(entry.modelData.signalStrength)
|
||||
iconFallback: "network-wireless-symbolic"
|
||||
label: entry.ssid !== "" ? entry.ssid : "Hidden network"
|
||||
selected: entry.modelData.connected
|
||||
sublabel: {
|
||||
if (entry.modelData.stateChanging)
|
||||
return "Connecting…";
|
||||
if (entry.modelData.connected)
|
||||
return "Connected";
|
||||
if (root.failedSsid === entry.ssid)
|
||||
return root.failedText;
|
||||
if (entry.modelData.known)
|
||||
return "Saved";
|
||||
return "";
|
||||
}
|
||||
onClicked: root.activate(entry.modelData)
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 14
|
||||
visible: entry.secured
|
||||
tint: Theme.fgMuted
|
||||
icon: "network-wireless-encrypted-symbolic"
|
||||
iconFallback: "changes-prevent-symbolic"
|
||||
}
|
||||
|
||||
IconButton {
|
||||
visible: entry.modelData.connected
|
||||
size: 26
|
||||
iconSize: 13
|
||||
icon: "window-close-symbolic"
|
||||
onClicked: entry.modelData.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
id: pskSection
|
||||
width: parent.width
|
||||
expanded: root.passwordFor === entry.ssid
|
||||
|
||||
onExpandedChanged: {
|
||||
if (pskSection.expanded)
|
||||
pskInput.forceActiveFocus();
|
||||
else
|
||||
pskInput.text = "";
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
height: 42
|
||||
y: 4
|
||||
radius: Theme.cardRadius - 2
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.4)
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
TextInput {
|
||||
id: pskInput
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: sendButton.left
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
echoMode: TextInput.Password
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
clip: true
|
||||
|
||||
onAccepted: {
|
||||
if (pskInput.text.length > 0) {
|
||||
root.failedSsid = "";
|
||||
entry.modelData.connectWithPsk(pskInput.text);
|
||||
root.passwordFor = "";
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: pskInput.text === ""
|
||||
text: "Password"
|
||||
color: Theme.fgMuted
|
||||
font: pskInput.font
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: sendButton
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 30
|
||||
iconSize: 15
|
||||
icon: "go-next-symbolic"
|
||||
onClicked: pskInput.accepted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Panama Desktop"
|
||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
|
||||
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
|
||||
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
|
||||
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Design principles"
|
||||
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" }
|
||||
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" }
|
||||
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Theme"
|
||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
|
||||
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Clock"
|
||||
SettingRow {
|
||||
label: "24-hour time"
|
||||
detail: "Use 18:30 instead of 6:30 PM"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Show seconds"
|
||||
detail: "Keep a precise clock in the center of the bar"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Show weekday"
|
||||
detail: "Include the abbreviated weekday before the date"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } }
|
||||
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } }
|
||||
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import QtQuick
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi‑Fi"
|
||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
||||
SettingRow {
|
||||
label: "Wi‑Fi"
|
||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
||||
controlWidth: 48
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Networking.wifiEnabled
|
||||
enabled: Networking.wifiHardwareEnabled
|
||||
onToggled: value => Networking.wifiEnabled = value
|
||||
}
|
||||
}
|
||||
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
|
||||
SettingRow {
|
||||
label: "Advanced network settings"
|
||||
detail: "VPN, wired profiles, DNS, and connection details"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Bluetooth"
|
||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
||||
SettingRow {
|
||||
label: "Bluetooth"
|
||||
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
||||
controlWidth: 48
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.bluetoothAdapter?.enabled ?? false
|
||||
enabled: root.bluetoothAdapter !== null
|
||||
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; }
|
||||
}
|
||||
}
|
||||
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
|
||||
SettingRow {
|
||||
label: "Advanced Bluetooth settings"
|
||||
detail: "Device details and system-level options"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
SettingRow {
|
||||
label: "Automatically hide the Dock"
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value }
|
||||
}
|
||||
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` }
|
||||
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window layout"
|
||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
|
||||
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
|
||||
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
|
||||
SettingRow {
|
||||
label: "Desktop preferences"
|
||||
detail: "Your pinned applications and files are not changed"
|
||||
divider: false
|
||||
controlWidth: 122
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorName || "Active display"
|
||||
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
|
||||
SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
|
||||
SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "These values apply immediately and are restored when Panama starts."
|
||||
SettingRow {
|
||||
label: "Game-aware HDR"
|
||||
detail: "Enter HDR only for fullscreen content that requests it"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Content-aware VRR"
|
||||
detail: "Enable variable refresh only for game and video content"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Direct scanout for games"
|
||||
detail: "Bypass compositing only for windows classified as games"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Night Light"
|
||||
SettingRow {
|
||||
label: "Warm display colors"
|
||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Color temperature"
|
||||
detail: `${NightLight.temperature} K`
|
||||
divider: false
|
||||
controlWidth: 230
|
||||
ValueSlider {
|
||||
anchors.fill: parent
|
||||
value: (6500 - NightLight.temperature) / 4000
|
||||
icon: "weather-clear-night-symbolic"
|
||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: warningText.implicitHeight + 30
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.warn, 0.085)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
||||
Text {
|
||||
id: warningText
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
|
||||
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property int openedHour: new Date().getHours()
|
||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: `${root.greeting}, Gabriel`
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Your Panama desktop is configured and ready."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
bottomPadding: 6
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorDescription || "Active display"
|
||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 164
|
||||
spacing: 28
|
||||
|
||||
Item {
|
||||
width: parent.width * 0.47
|
||||
height: parent.height
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(parent.width - 24, 260)
|
||||
height: width * 0.64
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 5
|
||||
radius: 12
|
||||
color: Theme.alpha(Theme.bgDark, 0.94)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.34)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
radius: 7
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
|
||||
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: SystemSettings.monitorName || "DISPLAY"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width * 0.47
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 13
|
||||
|
||||
Text {
|
||||
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
|
||||
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
|
||||
SettingsCard {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
title: "Quiet focus"
|
||||
subtitle: "Notifications and focused work"
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.doNotDisturb
|
||||
onToggled: value => Notifs.doNotDisturb = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
title: "Desktop services"
|
||||
subtitle: "The essentials are running"
|
||||
|
||||
SettingRow {
|
||||
label: "Sync & remote access"
|
||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||
divider: false
|
||||
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Notifications"
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: "Keep notifications in the center but suppress banners"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
|
||||
}
|
||||
SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` }
|
||||
SettingRow {
|
||||
label: "Clear notification history"
|
||||
detail: "Dismiss every item currently in the notification center"
|
||||
divider: false
|
||||
controlWidth: 94
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus sessions"
|
||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||
SettingRow {
|
||||
label: "Default duration"
|
||||
detail: "Used by Super+Shift+F and Quick Settings"
|
||||
controlWidth: 264
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
Repeater {
|
||||
model: [25, 45, 60, 90]
|
||||
SettingsButton {
|
||||
required property int modelData
|
||||
text: `${modelData}m`
|
||||
tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal"
|
||||
onClicked: DesktopPreferences.focusDurationMinutes = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
|
||||
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
|
||||
divider: false
|
||||
controlWidth: 120
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.active ? "Show controls" : "Start focus"
|
||||
tone: FocusSession.active ? "normal" : "accent"
|
||||
onClicked: FocusSession.reveal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
Component.onCompleted: ScreenIntelligence.refresh()
|
||||
|
||||
Timer {
|
||||
id: launchDelay
|
||||
interval: 180
|
||||
onTriggered: Capture.openIntelligence()
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: "Screen Intelligence"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: "Turn text and codes on screen into content you can use."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
bottomPadding: 6
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Read anything on screen"
|
||||
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
||||
|
||||
SettingRow {
|
||||
icon: ""
|
||||
label: "Read screen text"
|
||||
detail: "Copy, search, translate, or open detected links"
|
||||
controlWidth: 160
|
||||
divider: false
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Start reading"
|
||||
tone: "accent"
|
||||
enabled: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady
|
||||
onClicked: {
|
||||
ShellState.closeSettings();
|
||||
launchDelay.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Local recognition"
|
||||
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
||||
|
||||
SettingRow {
|
||||
label: "Text recognition"
|
||||
detail: "Tesseract with the English language model"
|
||||
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
||||
}
|
||||
SettingRow {
|
||||
label: "QR & barcodes"
|
||||
detail: "ZBar recognizes codes alongside ordinary text"
|
||||
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
||||
}
|
||||
SettingRow {
|
||||
label: "Privacy"
|
||||
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
||||
value: "Local first"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Shortcut"
|
||||
SettingRow {
|
||||
label: "Read a screen selection"
|
||||
detail: "Also available as Read in the Print-screen picker"
|
||||
value: "Super + Shift + S"
|
||||
controlWidth: 190
|
||||
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
|
||||
}
|
||||
SettingRow {
|
||||
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
||||
label: "Install recognition engines"
|
||||
detail: "sudo dnf install -y tesseract zbar"
|
||||
value: "Required once"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
function status(active: bool): string { return active ? "Running" : "Stopped"; }
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
|
||||
}
|
||||
Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Your services"
|
||||
SettingRow {
|
||||
label: "Nextcloud"
|
||||
detail: "File synchronization and tray status"
|
||||
controlWidth: 190
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: "RustDesk"
|
||||
detail: "Remote access through the enabled system service"
|
||||
controlWidth: 190
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
|
||||
}
|
||||
}
|
||||
SettingRow {
|
||||
label: "KDE Connect"
|
||||
detail: "Phone pairing, clipboard, files, and remote controls"
|
||||
divider: false
|
||||
controlWidth: 190
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Desktop foundation"
|
||||
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) }
|
||||
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) }
|
||||
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Fedora system settings"
|
||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
||||
SettingRow {
|
||||
label: "Network, Bluetooth, printers, users, and accounts"
|
||||
detail: "GNOME Settings remains searchable from the launcher too"
|
||||
divider: false
|
||||
controlWidth: 122
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
default property alias trailingData: trailing.data
|
||||
property string icon: ""
|
||||
property string label: ""
|
||||
property string detail: ""
|
||||
property string value: ""
|
||||
property bool divider: true
|
||||
property int controlWidth: 150
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||
|
||||
Text {
|
||||
id: iconLabel
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.icon === "" ? 0 : 28
|
||||
visible: root.icon !== ""
|
||||
text: root.icon
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 17
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
anchors.left: iconLabel.visible ? iconLabel.right : parent.left
|
||||
anchors.leftMargin: iconLabel.visible ? 10 : 0
|
||||
anchors.right: trailing.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.detail !== ""
|
||||
text: root.detail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: trailing
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.controlWidth
|
||||
height: 32
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.value !== ""
|
||||
text: root.value
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: copy.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string tone: "normal"
|
||||
property bool enabled: true
|
||||
signal clicked
|
||||
|
||||
implicitWidth: label.implicitWidth + 22
|
||||
implicitHeight: 31
|
||||
radius: 9
|
||||
opacity: enabled ? 1 : 0.45
|
||||
color: {
|
||||
if (tone === "accent")
|
||||
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
|
||||
return mouse.containsMouse ? Theme.alpha(Theme.fg, 0.13) : Theme.alpha(Theme.fg, 0.075);
|
||||
}
|
||||
border.width: tone === "accent" ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.tone === "accent" ? Theme.bgDark : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
enabled: root.enabled
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.clicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
default property alias contentData: body.data
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
|
||||
width: parent ? parent.width : 680
|
||||
implicitHeight: body.implicitHeight + 30
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.bgPanel, 0.72)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: root.radius
|
||||
opacity: 0.36
|
||||
}
|
||||
|
||||
Column {
|
||||
id: body
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 15
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.title !== ""
|
||||
text: root.title
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
bottomPadding: root.subtitle === "" ? 10 : 3
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.subtitle !== ""
|
||||
text: root.subtitle
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
bottomPadding: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var hostWindow: null
|
||||
|
||||
color: Theme.bg
|
||||
radius: 18
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
clip: true
|
||||
|
||||
SettingsSidebar {
|
||||
id: sidebar
|
||||
anchors.left: parent.left
|
||||
anchors.top: titlebar.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
selectedPage: ShellState.settingsPage
|
||||
onPageRequested: page => ShellState.openSettings(page)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: titlebar
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: 48
|
||||
color: Theme.alpha(Theme.bgDark, 0.97)
|
||||
border.width: 0
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Panama Settings"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Rectangle {
|
||||
width: 28; height: 28; radius: 9
|
||||
color: minMouse.containsMouse ? Theme.alpha(Theme.fg, 0.12) : Theme.alpha(Theme.fg, 0)
|
||||
Text { anchors.centerIn: parent; text: "—"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
|
||||
MouseArea { id: minMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (root.hostWindow) root.hostWindow.minimized = true }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 28; height: 28; radius: 9
|
||||
color: closeMouse.containsMouse ? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
|
||||
Text { anchors.centerIn: parent; text: "×"; color: closeMouse.containsMouse ? Theme.danger : Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: 18 }
|
||||
MouseArea { id: closeMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: ShellState.closeSettings() }
|
||||
}
|
||||
}
|
||||
|
||||
DragHandler {
|
||||
target: null
|
||||
onActiveChanged: if (active && root.hostWindow) root.hostWindow.startSystemMove()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: sidebar.right
|
||||
anchors.top: titlebar.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
width: 1
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: pageLoader
|
||||
anchors.left: sidebar.right
|
||||
anchors.right: parent.right
|
||||
anchors.top: titlebar.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
sourceComponent: {
|
||||
switch (ShellState.settingsPage) {
|
||||
case "appearance": return appearancePage;
|
||||
case "displays": return displaysPage;
|
||||
case "connectivity": return connectivityPage;
|
||||
case "desktop": return desktopPage;
|
||||
case "sound": return soundPage;
|
||||
case "notifications": return notificationsPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
case "shortcuts": return shortcutsPage;
|
||||
case "services": return servicesPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 22
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 20
|
||||
width: Math.min(460, errorCopy.implicitWidth + 34)
|
||||
height: 42
|
||||
radius: 12
|
||||
visible: SystemSettings.lastError !== ""
|
||||
color: Theme.mix(Theme.bgPopover, Theme.danger, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.danger, 0.28)
|
||||
|
||||
Text {
|
||||
id: errorCopy
|
||||
anchors.centerIn: parent
|
||||
text: SystemSettings.lastError
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: SystemSettings.lastError = ""
|
||||
}
|
||||
}
|
||||
|
||||
Component { id: homePage; HomePage {} }
|
||||
Component { id: appearancePage; AppearancePage {} }
|
||||
Component { id: displaysPage; DisplaysPage {} }
|
||||
Component { id: connectivityPage; ConnectivityPage {} }
|
||||
Component { id: desktopPage; DesktopPage {} }
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
Component { id: servicesPage; ServicesPage {} }
|
||||
Component { id: aboutPage; AboutPage {} }
|
||||
|
||||
Shortcut {
|
||||
sequence: "Escape"
|
||||
onActivated: ShellState.closeSettings()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string selectedPage: "home"
|
||||
property string query: searchInput.text.trim().toLowerCase()
|
||||
signal pageRequested(string page)
|
||||
|
||||
readonly property var destinations: [
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}" },
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
||||
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
||||
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
|
||||
]
|
||||
|
||||
width: 272
|
||||
color: Theme.alpha(Theme.bgDark, 0.96)
|
||||
border.width: 0
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: "PANAMA"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 2.2
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 36
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.fg, searchArea.containsMouse || searchInput.activeFocus ? 0.105 : 0.065)
|
||||
border.width: 1
|
||||
border.color: searchInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.055)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F0349}"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: searchInput
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 35
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.accent
|
||||
selectedTextColor: Theme.bgDark
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: !searchInput.text && !searchInput.activeFocus
|
||||
text: "Search settings"
|
||||
color: Theme.fgMuted
|
||||
font: searchInput.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: searchArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.IBeamCursor
|
||||
onClicked: searchInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
|
||||
Repeater {
|
||||
model: root.destinations.filter(item => root.query === "" || item.label.toLowerCase().includes(root.query))
|
||||
|
||||
Rectangle {
|
||||
id: navItem
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
height: 40
|
||||
radius: 10
|
||||
color: modelData.page === root.selectedPage
|
||||
? Theme.alpha(Theme.accent, 0.17)
|
||||
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
|
||||
border.width: modelData.page === root.selectedPage ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.26)
|
||||
|
||||
Rectangle {
|
||||
width: 2
|
||||
height: 18
|
||||
radius: 1
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: navItem.modelData.page === root.selectedPage
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0; color: Theme.accent }
|
||||
GradientStop { position: 1; color: Theme.accentSecondary }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 25
|
||||
text: navItem.modelData.icon
|
||||
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 47
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: navItem.modelData.label
|
||||
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: navMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.pageRequested(navItem.modelData.page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 54
|
||||
color: Theme.alpha(Theme.bg, 0.35)
|
||||
border.width: 0
|
||||
|
||||
Rectangle {
|
||||
width: 7
|
||||
height: 7
|
||||
radius: 4
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 19
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.ok
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 36
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Panama desktop is healthy"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property bool checked: false
|
||||
property bool enabled: true
|
||||
signal toggled(bool checked)
|
||||
|
||||
implicitWidth: 42
|
||||
implicitHeight: 24
|
||||
radius: height / 2
|
||||
color: checked ? Theme.accent : Theme.alpha(Theme.fg, 0.13)
|
||||
opacity: enabled ? 1 : 0.45
|
||||
border.width: 1
|
||||
border.color: checked ? Theme.alpha(Theme.accentAlt, 0.55) : Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||
|
||||
Rectangle {
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
x: root.checked ? root.width - width - 3 : 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: root.checked ? Theme.bgDark : Theme.fgDim
|
||||
border.width: 0
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggled(!root.checked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
FloatingWindow {
|
||||
id: root
|
||||
|
||||
title: "Panama Settings"
|
||||
visible: ShellState.settingsOpen
|
||||
implicitWidth: 1120
|
||||
implicitHeight: 760
|
||||
minimumSize: Qt.size(900, 620)
|
||||
color: Theme.alpha(Theme.bg, 0)
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
SystemSettings.refresh();
|
||||
} else if (ShellState.settingsOpen) {
|
||||
// Keep Super+I deterministic after compositor close/Alt+F4.
|
||||
ShellState.closeSettings();
|
||||
}
|
||||
}
|
||||
|
||||
SettingsShell {
|
||||
anchors.fill: parent
|
||||
hostWindow: root
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
readonly property var shortcuts: [
|
||||
{ key: "Super + T", action: "Terminal" },
|
||||
{ key: "Super + N", action: "Neovim in the current directory" },
|
||||
{ key: "Super + W", action: "Web browser" },
|
||||
{ key: "Super + F", action: "Files" },
|
||||
{ key: "Super + I", action: "Panama Settings" },
|
||||
{ key: "Super + Space", action: "Launcher" },
|
||||
{ key: "Super + Grave", action: "Continuum overview" },
|
||||
{ key: "Super + V", action: "Clipboard history" },
|
||||
{ key: "Super + S", action: "Quick Settings" },
|
||||
{ key: "Super + B", action: "Notifications" },
|
||||
{ key: "Super + Shift + S", action: "Screen Intelligence" },
|
||||
{ key: "Super + Shift + F", action: "Focus session" },
|
||||
{ key: "Alt + H / L", action: "Previous / next workspace" },
|
||||
{ key: "Alt + Shift + H / L", action: "Move window between workspaces" },
|
||||
{ key: "Super + H / J / K / L", action: "Focus a tiled window" },
|
||||
{ key: "Super + Shift + H / J / K / L", action: "Move a tiled window" },
|
||||
{ key: "Super + Shift + X", action: "Send window to scratchpad" },
|
||||
{ key: "Super + X", action: "Toggle scratchpad" },
|
||||
{ key: "Print", action: "Capture, record, or read" }
|
||||
]
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Panama shortcuts"
|
||||
Repeater {
|
||||
model: root.shortcuts
|
||||
SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
label: modelData.action
|
||||
value: modelData.key
|
||||
controlWidth: 210
|
||||
divider: index < root.shortcuts.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Hardware input"
|
||||
SettingRow {
|
||||
label: "Mouse, touchpad, and keyboard devices"
|
||||
detail: "Use Fedora's hardware-backed input panels"
|
||||
divider: false
|
||||
controlWidth: 126
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Output"
|
||||
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
|
||||
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSink; output: true }
|
||||
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
|
||||
AudioDeviceList { width: parent.width; output: true; maxHeight: 190 }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Input"
|
||||
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
|
||||
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false }
|
||||
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
|
||||
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Advanced sound"
|
||||
SettingRow {
|
||||
label: "Application volumes and profiles"
|
||||
detail: "Open Fedora's complete sound panel"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
module qs.modules.settings
|
||||
AboutPage 1.0 AboutPage.qml
|
||||
AppearancePage 1.0 AppearancePage.qml
|
||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||
DesktopPage 1.0 DesktopPage.qml
|
||||
DisplaysPage 1.0 DisplaysPage.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
NotificationsPage 1.0 NotificationsPage.qml
|
||||
ScreenIntelligencePage 1.0 ScreenIntelligencePage.qml
|
||||
ServicesPage 1.0 ServicesPage.qml
|
||||
SettingRow 1.0 SettingRow.qml
|
||||
SettingsCard 1.0 SettingsCard.qml
|
||||
SettingsButton 1.0 SettingsButton.qml
|
||||
SettingsShell 1.0 SettingsShell.qml
|
||||
SettingsSidebar 1.0 SettingsSidebar.qml
|
||||
SettingsToggle 1.0 SettingsToggle.qml
|
||||
SettingsWindow 1.0 SettingsWindow.qml
|
||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||
SoundPage 1.0 SoundPage.qml
|
||||
@@ -0,0 +1,134 @@
|
||||
// Quiet transient Signal Glass content. It acknowledges one meaningful state
|
||||
// transition and then leaves; it is not a notification and has no history.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var event
|
||||
readonly property color toneColor: {
|
||||
if (event?.tone === "danger")
|
||||
return Theme.danger;
|
||||
if (event?.tone === "warn")
|
||||
return Theme.warn;
|
||||
if (event?.tone === "ok")
|
||||
return Theme.ok;
|
||||
return Theme.accent;
|
||||
}
|
||||
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.86)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: mark
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 38
|
||||
height: 38
|
||||
radius: 19
|
||||
border.width: 0
|
||||
color: Theme.alpha(root.toneColor, 0.14)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.event?.glyph !== ""
|
||||
text: root.event?.glyph ?? ""
|
||||
color: root.toneColor
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 19
|
||||
}
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
visible: root.event?.glyph === ""
|
||||
size: 19
|
||||
icon: root.event?.icon ?? "dialog-information-symbolic"
|
||||
tint: root.toneColor
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: mark.right
|
||||
anchors.leftMargin: 11
|
||||
anchors.right: closeButton.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.event?.title ?? ""
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: root.event?.detail ?? ""
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeButton
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
border.width: 0
|
||||
color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 14
|
||||
icon: "window-close-symbolic"
|
||||
tint: Theme.fgDim
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: StatusEvents.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.left: parent.left
|
||||
anchors.right: closeButton.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
cursorShape: root.event?.actionId ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.event?.actionId)
|
||||
StatusEvents.invoke();
|
||||
else
|
||||
StatusEvents.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Rich persistent mode of Signal Glass. Dismiss hides controls only; the
|
||||
// compact bar timer remains until the session ends.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.focus
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.88)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Item {
|
||||
id: primary
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
height: 54
|
||||
|
||||
Rectangle {
|
||||
id: focusMark
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 38
|
||||
height: 38
|
||||
radius: 19
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.accent, 0.16)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u{F051F}"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 20
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: focusMark.right
|
||||
anchors.leftMargin: 11
|
||||
anchors.right: remaining.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: FocusSession.workspaceLabel || "Focus session"
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: FocusSession.statusText
|
||||
color: FocusSession.paused ? Theme.warn : Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: remaining
|
||||
anchors.right: closeButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.remainingText
|
||||
color: FocusSession.paused ? Theme.warn : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: closeButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
border.width: 0
|
||||
color: closeMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : "transparent"
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 14
|
||||
icon: "window-close-symbolic"
|
||||
tint: Theme.fgDim
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: FocusSession.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.left: parent.left
|
||||
anchors.right: closeButton.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
z: -1
|
||||
onClicked: {
|
||||
FocusSession.activateWorkspace();
|
||||
FocusSession.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
anchors.bottomMargin: 12
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
FocusAction {
|
||||
width: (parent.width - parent.spacing * 2) / 3
|
||||
icon: FocusSession.paused ? "media-playback-start-symbolic" : "media-playback-pause-symbolic"
|
||||
label: FocusSession.paused ? "Resume" : "Pause"
|
||||
onActivated: FocusSession.pauseOrResume()
|
||||
}
|
||||
|
||||
FocusAction {
|
||||
width: (parent.width - parent.spacing * 2) / 3
|
||||
icon: "view-grid-symbolic"
|
||||
iconFallback: "view-app-grid-symbolic"
|
||||
label: "Workspace"
|
||||
onActivated: {
|
||||
ShellState.openOverview(FocusSession.workspaceId);
|
||||
FocusSession.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
FocusAction {
|
||||
width: (parent.width - parent.spacing * 2) / 3
|
||||
icon: "process-stop-symbolic"
|
||||
label: "End"
|
||||
destructive: true
|
||||
onActivated: FocusSession.end(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// The one Signal Glass layer surface. Transient curated events briefly take
|
||||
// precedence; when they expire, an explicitly revealed focus session returns.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
property var modelData: null
|
||||
readonly property var event: StatusEvents.activeEvent
|
||||
readonly property string eventMonitor: root.event?.monitorName ?? Hyprland.focusedMonitor?.name ?? ""
|
||||
readonly property bool eventBelongsHere: StatusEvents.active && (!root.eventMonitor || !root.modelData || root.modelData.name === root.eventMonitor)
|
||||
readonly property bool focusBelongsHere: FocusSession.active && FocusSession.capsuleVisible
|
||||
&& (!FocusSession.monitorName || !root.modelData || root.modelData.name === FocusSession.monitorName)
|
||||
readonly property bool showingEvent: root.eventBelongsHere
|
||||
readonly property bool requestedVisible: root.showingEvent || root.focusBelongsHere
|
||||
|
||||
screen: root.modelData
|
||||
anchors.top: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
exclusiveZone: 0
|
||||
implicitWidth: root.showingEvent ? 388 : 438
|
||||
implicitHeight: root.showingEvent ? 76 : 132
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: "qs-signal-glass"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
property bool mapped: false
|
||||
visible: root.mapped
|
||||
|
||||
onRequestedVisibleChanged: {
|
||||
if (root.requestedVisible) {
|
||||
unmapTimer.stop();
|
||||
root.mapped = true;
|
||||
entrance.restart();
|
||||
} else if (root.mapped) {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
onShowingEventChanged: {
|
||||
if (root.requestedVisible)
|
||||
entrance.restart();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.requestedVisible)
|
||||
root.mapped = true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
sourceComponent: root.showingEvent ? eventComponent : focusComponent
|
||||
opacity: root.requestedVisible ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
transform: Translate {
|
||||
id: slide
|
||||
y: root.requestedVisible ? 0 : -6
|
||||
Behavior on y {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: eventComponent
|
||||
EventCard { event: root.event }
|
||||
}
|
||||
|
||||
Component {
|
||||
id: focusComponent
|
||||
FocusCard {}
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entrance
|
||||
NumberAnimation { target: content; property: "opacity"; from: 0; to: 1; duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { target: slide; property: "y"; from: -6; to: 0; duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
module qs.modules.signals
|
||||
EventCard 1.0 EventCard.qml
|
||||
FocusCard 1.0 FocusCard.qml
|
||||
SignalGlass 1.0 SignalGlass.qml
|
||||
Reference in New Issue
Block a user