Current state of main at 0240060 feat(hp-laptop): install TeleportFling from its flake. History intentionally collapsed to a single commit; this repo mirrors only the latest state.
6694 lines
212 KiB
QML
6694 lines
212 KiB
QML
//@ pragma UseQApplication
|
|
// ~/.config/quickshell/shell.qml
|
|
// QuickShell desktop shell for Hyprland (Nix-Vibe hp-laptop).
|
|
//
|
|
// "Sci-fi dark glass + vivid neon" theme:
|
|
// - frosted-glass pill bar (blur via Hyprland layerrule, namespace qs-neon-bar)
|
|
// - workspace pills with neon bloom on the active one
|
|
// - icon status row: battery (UPower), volume (PipeWire), brightness
|
|
// (brightnessctl), Wi-Fi (Networking); scroll volume/brightness chips
|
|
// - notification center (native NotificationServer): toasts + history/DND/clear
|
|
// - workspace overview (SUPER+W): lite mission control, jump with a click
|
|
// - Orbitron display font, Symbols Nerd Font icons
|
|
// - HUD corner brackets per monitor with a slow pulse
|
|
// - animated accent sweep along the bar's bottom edge
|
|
// QuickShell live-reloads this file on save.
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Io
|
|
import Quickshell.Networking
|
|
import Quickshell.Services.Mpris
|
|
import Quickshell.Services.Notifications
|
|
import Quickshell.Services.Pipewire
|
|
import Quickshell.Services.UPower
|
|
import Quickshell.Services.SystemTray
|
|
import Quickshell.Wayland
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
|
|
ShellRoot {
|
|
id: root
|
|
|
|
// Keep-awake: while true the compositor is told the session must not go
|
|
// idle, so hypridle's lock/blank/suspend listeners never fire.
|
|
// Session-lifetime only (resets on logout) — it's meant as a temporary hold.
|
|
property bool keepAwake: false
|
|
|
|
// ---------- system stats (qs-stats probe) ----------
|
|
// Parsed JSON from the stats probe; refreshed on the slow bar timer and
|
|
// fast while the popup is open.
|
|
property var stats: null
|
|
readonly property string statsScript: "/home/petere/.local/bin/qs-stats"
|
|
|
|
// Pinned flake.lock info (nixpkgs node) injected at build time by hyprland.nix.
|
|
readonly property var flakeInfo: @FLAKEINFO@
|
|
|
|
// Build-time theme colors injected by hyprland.nix from the shared palette
|
|
// (home-manager/modules/palette.nix). At startup these may be repainted by a
|
|
// wallpaper-driven palette (~/.cache/quickshell/theme.json, see
|
|
// applyRuntimeTheme/themeOverrideProc below).
|
|
readonly property var themeColors: @THEMECOLORS@
|
|
|
|
// helpers for the chip/popup display values
|
|
function statsHot() {
|
|
if (root.stats === null || root.stats.temps === undefined || root.stats.temps.length === 0)
|
|
return 0;
|
|
let hot = 0;
|
|
for (const t of root.stats.temps)
|
|
hot = Math.max(hot, t.temp);
|
|
return hot;
|
|
}
|
|
|
|
function statsAgeDays() {
|
|
if (root.flakeInfo === null || root.flakeInfo.lastModified === undefined)
|
|
return -1;
|
|
return Math.max(0, Math.floor((Date.now() / 1000 - root.flakeInfo.lastModified) / 86400));
|
|
}
|
|
|
|
function statsRev() {
|
|
if (root.flakeInfo === null || root.flakeInfo.rev === undefined)
|
|
return "";
|
|
return root.flakeInfo.rev.substring(0, 8);
|
|
}
|
|
|
|
// ---------- autostart app manager ----------
|
|
property var appsEntries: []
|
|
property bool appsAddOpen: false
|
|
property string appsNewName: ""
|
|
property string appsNewCmd: ""
|
|
|
|
// ---------- battery charge limit (gear quick-settings panel) ----------
|
|
// Backend `battery-charge-limit` (NixOS hardware/battery-limit modules) is
|
|
// provided by both the HP module (acpi_call SBCO/SBCC) and the ThinkPad
|
|
// module (native charge_control_start/end_threshold sysfs). The on/off
|
|
// switch refreshes the persisted state file via `status` and toggles via
|
|
// `sudo <script> on|off`.
|
|
property bool chargeLimitActive: false
|
|
property bool chargeLimitBusy: false
|
|
readonly property string chargeLimitScript: "/run/current-system/sw/bin/battery-charge-limit"
|
|
|
|
// ============================ Theme ============================
|
|
QtObject {
|
|
id: theme
|
|
|
|
// Surfaces (translucent over the Hyprland layer blur). Mutable so a
|
|
// wallpaper-driven palette can repaint them at startup.
|
|
property color glass: root.themeColors.glass
|
|
property color glassPanel: root.themeColors.glassPanel
|
|
property color surface: root.themeColors.surface
|
|
property color line: root.themeColors.line
|
|
|
|
// Text
|
|
property color text: root.themeColors.text
|
|
property color muted: root.themeColors.muted
|
|
property color ink: root.themeColors.ink
|
|
|
|
// Neon accents
|
|
property color neon: root.themeColors.neon
|
|
property color magenta: root.themeColors.magenta
|
|
property color violet: root.themeColors.violet
|
|
property color danger: root.themeColors.danger
|
|
property color cyan: root.themeColors.cyan
|
|
property color blue: root.themeColors.blue
|
|
|
|
// Fonts
|
|
readonly property string displayFont: "Orbitron"
|
|
readonly property string iconFont: "Symbols Nerd Font"
|
|
readonly property int fontSize: 12
|
|
|
|
// Metrics
|
|
readonly property int barHeight: 38
|
|
readonly property int radius: 12
|
|
|
|
readonly property bool hudFrame: true // toggle monitor corner brackets
|
|
}
|
|
|
|
// Apply a wallpaper-driven palette (qs-theme / matugen). Expects a JSON
|
|
// object with hex '#rgb' values for surface/ink/text/muted/line/neon/
|
|
// violet/magenta/danger. Translucent panels are re-derived from `surface`.
|
|
function applyRuntimeTheme(j) {
|
|
if (j === null || typeof j !== "object") return;
|
|
const set = (name, val) => {
|
|
if (val !== undefined && val !== null) theme[name] = val;
|
|
};
|
|
set("surface", j.surface);
|
|
set("ink", j.ink);
|
|
set("text", j.text);
|
|
set("muted", j.muted);
|
|
set("line", j.line);
|
|
set("neon", j.neon);
|
|
set("violet", j.violet);
|
|
set("magenta", j.magenta);
|
|
set("danger", j.danger);
|
|
if (j.surface !== undefined) {
|
|
theme.glass = Qt.rgba(theme.surface.r, theme.surface.g, theme.surface.b, 0.55);
|
|
theme.glassPanel = Qt.rgba(theme.surface.r, theme.surface.g, theme.surface.b, 0.76);
|
|
}
|
|
}
|
|
|
|
// Watches the wallpaper-driven palette (written by `qs-theme` via matugen
|
|
// to ~/.cache/quickshell/theme.json) and repaints the theme on any change,
|
|
// including at startup. No-op if the file doesn't exist yet.
|
|
FileView {
|
|
id: themeFile
|
|
path: "file:///home/petere/.cache/quickshell/theme.json"
|
|
watchChanges: true
|
|
onLoaded: {
|
|
try {
|
|
root.applyRuntimeTheme(JSON.parse(text()));
|
|
} catch (e) { }
|
|
}
|
|
onFileChanged: themeFile.reload()
|
|
}
|
|
|
|
// ============================ HUD frame ============================
|
|
// Transparent background-layer panel per monitor: glowing corner brackets
|
|
// with a slow pulse. Sits below windows; top brackets clear of the bar.
|
|
Variants {
|
|
model: Quickshell.screens
|
|
|
|
PanelWindow {
|
|
id: hud
|
|
|
|
required property var modelData
|
|
screen: modelData
|
|
|
|
anchors {
|
|
top: true
|
|
bottom: true
|
|
left: true
|
|
right: true
|
|
}
|
|
WlrLayershell.layer: WlrLayer.Background
|
|
WlrLayershell.namespace: "qs-hud"
|
|
exclusiveZone: 0
|
|
implicitWidth: modelData.width
|
|
implicitHeight: modelData.height
|
|
color: "transparent"
|
|
visible: theme.hudFrame
|
|
|
|
// model rows: [xFrac, yFrac, dirX, dirY]
|
|
Repeater {
|
|
model: [
|
|
[0, 0, 1, 1],
|
|
[1, 0, -1, 1],
|
|
[0, 1, 1, -1],
|
|
[1, 1, -1, -1]
|
|
]
|
|
|
|
Item {
|
|
required property var modelData
|
|
|
|
width: 36
|
|
height: 36
|
|
x: modelData[0] === 0 ? 6 : hud.width - width - 6
|
|
y: modelData[1] === 0 ? 6 + theme.barHeight + 2 : hud.height - height - 6
|
|
|
|
// L-shaped bracket: horizontal leg
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 2
|
|
radius: 1
|
|
color: theme.neon
|
|
anchors.top: modelData[1] === 0 ? parent.top : undefined
|
|
anchors.bottom: modelData[1] === 1 ? parent.bottom : undefined
|
|
anchors.left: modelData[2] === 1 ? parent.left : undefined
|
|
anchors.right: modelData[2] === -1 ? parent.right : undefined
|
|
}
|
|
// vertical leg
|
|
Rectangle {
|
|
width: 2
|
|
height: parent.height
|
|
radius: 1
|
|
color: theme.neon
|
|
anchors.left: modelData[2] === 1 ? parent.left : undefined
|
|
anchors.right: modelData[2] === -1 ? parent.right : undefined
|
|
anchors.top: modelData[1] === 0 ? parent.top : undefined
|
|
anchors.bottom: modelData[1] === 1 ? parent.bottom : undefined
|
|
}
|
|
|
|
SequentialAnimation on opacity {
|
|
loops: Animation.Infinite
|
|
running: true
|
|
NumberAnimation {
|
|
from: 0.35
|
|
to: 0.8
|
|
duration: 1800
|
|
easing.type: Easing.InOutSine
|
|
}
|
|
NumberAnimation {
|
|
from: 0.8
|
|
to: 0.35
|
|
duration: 1800
|
|
easing.type: Easing.InOutSine
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================ Top bar ============================
|
|
// Dynamic island: a centered floating pill that tucks up to the screen
|
|
// top edge (only a peek strip stays visible), slides in fully on hover or
|
|
// event (workspace switch / notification / media change — see
|
|
// root.islandRaise), and auto-retracts after 10s of no interaction.
|
|
Variants {
|
|
model: Quickshell.screens
|
|
|
|
PanelWindow {
|
|
id: bar
|
|
|
|
required property var modelData
|
|
screen: modelData
|
|
|
|
// island geometry: centered pill, tucked up when hidden
|
|
readonly property int islandWidth: Math.min(1160, modelData.width - 64)
|
|
readonly property int peekPixels: 12 // strip left visible when tucked
|
|
// (must stay > barInner.bottomMargin so the rounded glass edge +
|
|
// cyan hairline remain visible as a hover target)
|
|
// Hover tracking: a counter of active hover sources (peek-area MouseArea
|
|
// + every chip MouseArea in the bar). The hovered flag stays true while
|
|
// the cursor is anywhere inside the bar, preventing premature collapse
|
|
// when moving from the peek strip onto a chip.
|
|
property int hoverCount: 0
|
|
readonly property bool hovered: bar.hoverCount > 0
|
|
property bool anyPopupVisible: quickPopup.visible || volPopup.visible
|
|
|| netPopup.visible || btPanel.visible || notifPopup.visible || statsPopup.visible
|
|
|| root.calOpen || root.wsOpen || root.helpOpen || root.clipOpen || root.wallOpen || root.btOpen
|
|
property bool revealed: bar.hovered || root.islandRevealed || bar.anyPopupVisible
|
|
property real topMargin: bar.revealed ? 0 : -(theme.barHeight - bar.peekPixels)
|
|
|
|
anchors {
|
|
top: true
|
|
left: true
|
|
}
|
|
implicitWidth: bar.islandWidth
|
|
implicitHeight: theme.barHeight
|
|
exclusiveZone: 0
|
|
margins.top: bar.topMargin
|
|
margins.left: (modelData.width - bar.islandWidth) / 2
|
|
Behavior on topMargin {
|
|
NumberAnimation {
|
|
duration: 260
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
}
|
|
color: "transparent"
|
|
WlrLayershell.namespace: "qs-neon-bar"
|
|
|
|
// Hover detection for the whole island (clicks still fall through to
|
|
// the chips below because acceptedButtons is NoButton).
|
|
MouseArea {
|
|
id: islandHover
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.NoButton
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
}
|
|
|
|
// Inhibit session idle while keep-awake is on (compositors treat
|
|
// PanelWindows as important, so this blocks lock/blank/suspend).
|
|
IdleInhibitor {
|
|
window: bar
|
|
enabled: root.keepAwake
|
|
}
|
|
|
|
// ---------- Wi-Fi (native Networking over NetworkManager DBus) ----------
|
|
readonly property WifiDevice wifiDev: {
|
|
for (const d of Networking.devices.values) {
|
|
if (d.type === DeviceType.Wifi)
|
|
return d;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
readonly property string connectedSsid: {
|
|
if (wifiDev === null || !wifiDev.connected)
|
|
return "";
|
|
for (const n of wifiDev.networks.values) {
|
|
if (n.connected)
|
|
return n.name;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// ---------- Battery (UPower display device or real laptop battery) ----------
|
|
readonly property var battery: {
|
|
const dd = UPower.displayDevice;
|
|
if (dd.ready && dd.isLaptopBattery)
|
|
return dd;
|
|
const b = UPower.devices.values.find(d => d.isLaptopBattery);
|
|
return b === undefined ? null : b;
|
|
}
|
|
|
|
readonly property bool batteryCharging: bar.battery !== null && bar.battery.state === UPowerDeviceState.Charging
|
|
|
|
// ---------- Volume (PipeWire default sink) ----------
|
|
readonly property var sink: Pipewire.defaultAudioSink
|
|
readonly property real sinkVolume: bar.sink !== null && bar.sink.audio !== null ? bar.sink.audio.volume : 0
|
|
readonly property bool sinkMuted: bar.sink !== null && bar.sink.audio !== null ? bar.sink.audio.muted : false
|
|
|
|
// PwNodeAudio.volume/muted are only valid while the node is tracked.
|
|
PwObjectTracker {
|
|
objects: bar.sink === null ? [] : [bar.sink]
|
|
}
|
|
|
|
// Symbols Nerd Font (FontAwesome set, BMP) — battery/lock/volume/wifi.
|
|
// NOTE: QuickShell UPowerDevice.percentage is a 0..1 fraction (it
|
|
// divides UPower's 0-100 by 100), despite the docs' wording.
|
|
function batteryIcon(pct) {
|
|
if (pct >= 0.9)
|
|
return "\uf240";
|
|
if (pct >= 0.66)
|
|
return "\uf241";
|
|
if (pct >= 0.4)
|
|
return "\uf242";
|
|
if (pct >= 0.15)
|
|
return "\uf243";
|
|
return "\uf244";
|
|
}
|
|
|
|
function volumeIcon(vol, muted) {
|
|
if (muted || vol <= 0.005)
|
|
return "\uf026"; // volume-off
|
|
if (vol >= 0.55)
|
|
return "\uf028"; // volume-up
|
|
return "\uf027"; // volume-down
|
|
}
|
|
|
|
// ---------- frosted glass body ----------
|
|
Rectangle {
|
|
id: barInner
|
|
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
bottomMargin: 5
|
|
}
|
|
radius: theme.radius
|
|
color: theme.glass
|
|
border.color: theme.line
|
|
border.width: 1
|
|
clip: true
|
|
|
|
// static bottom hairline: cyan -> violet gradient
|
|
Rectangle {
|
|
anchors {
|
|
bottom: parent.bottom
|
|
left: parent.left
|
|
right: parent.right
|
|
margins: theme.radius
|
|
}
|
|
height: 1
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop {
|
|
position: 0
|
|
color: "#0000e5ff"
|
|
}
|
|
GradientStop {
|
|
position: 0.5
|
|
color: "#8000e5ff"
|
|
}
|
|
GradientStop {
|
|
position: 1
|
|
color: "#007c4dff"
|
|
}
|
|
}
|
|
}
|
|
|
|
// animated accent sweep along the bottom edge
|
|
Rectangle {
|
|
id: barSweep
|
|
width: 180
|
|
height: 2
|
|
radius: 1
|
|
y: barInner.height - 2
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop {
|
|
position: 0
|
|
color: "#0000e5ff"
|
|
}
|
|
GradientStop {
|
|
position: 0.5
|
|
color: "#b300e5ff"
|
|
}
|
|
GradientStop {
|
|
position: 1
|
|
color: "#00ff2e97"
|
|
}
|
|
}
|
|
SequentialAnimation on x {
|
|
loops: Animation.Infinite
|
|
running: true
|
|
NumberAnimation {
|
|
from: 0
|
|
to: barInner.width - 180
|
|
duration: 9000
|
|
easing.type: Easing.InOutSine
|
|
}
|
|
NumberAnimation {
|
|
from: barInner.width - 180
|
|
to: 0
|
|
duration: 9000
|
|
easing.type: Easing.InOutSine
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
anchors {
|
|
fill: barInner
|
|
leftMargin: 10
|
|
rightMargin: 10
|
|
}
|
|
spacing: 12
|
|
|
|
// ---------- Left: workspace pills ----------
|
|
Row {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
spacing: 5
|
|
|
|
Repeater {
|
|
model: Hyprland.workspaces
|
|
|
|
Rectangle {
|
|
id: ws
|
|
|
|
required property var modelData
|
|
|
|
// Hide empty/transient workspaces (e.g. a closed scratchpad) so
|
|
// the bar doesn't keep showing their pills after they close.
|
|
visible: modelData.windows > 0
|
|
height: 24
|
|
radius: 8
|
|
color: modelData.focused
|
|
? theme.neon
|
|
: (modelData.active ? theme.line : theme.surface)
|
|
Behavior on color { ColorAnimation { duration: 200 } }
|
|
|
|
// pill width hugs the text ("1" is narrow, "special:term" is wide)
|
|
implicitWidth: wsText.implicitWidth + 16
|
|
|
|
// neon bloom behind the active pill (stacked translucent halos
|
|
// — they can spread beyond the pill, unlike a layer blur)
|
|
Repeater {
|
|
model: ws.modelData.focused ? [4, 2] : []
|
|
|
|
Rectangle {
|
|
required property int index
|
|
|
|
anchors.centerIn: parent
|
|
width: ws.width + modelData
|
|
height: ws.height + modelData
|
|
radius: 8 + modelData / 2
|
|
color: theme.neon
|
|
opacity: index === 0 ? 0.14 : 0.22
|
|
Behavior on opacity { NumberAnimation { duration: 200 } }
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: wsText
|
|
anchors.centerIn: parent
|
|
text: ws.modelData.name || ws.modelData.id
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
color: ws.modelData.focused ? theme.ink : theme.muted
|
|
Behavior on color { ColorAnimation { duration: 200 } }
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: ws.modelData.activate()
|
|
}
|
|
|
|
// Reveal the island bar on workspace switch.
|
|
Connections {
|
|
target: ws.modelData
|
|
function onFocusedChanged() {
|
|
if (ws.modelData.focused && root.osdArmed)
|
|
root.islandRaise();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- Center: focused window title ----------
|
|
Text {
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: Hyprland.activeToplevel ? Hyprland.activeToplevel.title : ""
|
|
color: theme.muted
|
|
font.pixelSize: theme.fontSize
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
// ---------- Right: status cluster + clock ----------
|
|
Row {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
spacing: 12
|
|
|
|
// quick-settings (gear)
|
|
Rectangle {
|
|
id: gearButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 26
|
|
height: 24
|
|
radius: 8
|
|
color: quickPopup.visible ? theme.surface : "transparent"
|
|
border.color: quickPopup.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "\uf013"
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: theme.text
|
|
SequentialAnimation on rotation {
|
|
loops: Animation.Infinite
|
|
running: quickPopup.visible
|
|
NumberAnimation {
|
|
to: 360
|
|
duration: 4000
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: quickPopup.visible = !quickPopup.visible
|
|
}
|
|
}
|
|
|
|
// now playing (MPRIS): click toggles play/pause
|
|
Text {
|
|
id: mediaText
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.mediaPlayer !== null && root.mediaPlayer.trackTitle !== ""
|
|
width: Math.min(implicitWidth, 260)
|
|
text: (root.mediaPlayer !== null && root.mediaPlayer.isPlaying ? "\uf04c " : "\uf04b ")
|
|
+ (root.mediaPlayer !== null ? root.mediaPlayer.trackTitle : "")
|
|
+ (root.mediaPlayer !== null && root.mediaPlayer.trackArtist !== "" ? " \u2014 " + root.mediaPlayer.trackArtist : "")
|
|
font.pixelSize: theme.fontSize - 1
|
|
color: root.mediaPlayer !== null && root.mediaPlayer.isPlaying ? theme.neon : theme.muted
|
|
elide: Text.ElideRight
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: {
|
|
if (root.mediaPlayer !== null && root.mediaPlayer.canTogglePlaying)
|
|
root.mediaPlayer.togglePlaying();
|
|
}
|
|
}
|
|
}
|
|
|
|
// volume (click to open inline slider popup)
|
|
Rectangle {
|
|
id: volButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: volText.implicitWidth + 14
|
|
height: 24
|
|
radius: 8
|
|
color: volPopup.visible ? theme.surface : "transparent"
|
|
border.color: volPopup.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
visible: bar.sink !== null
|
|
|
|
Text {
|
|
id: volText
|
|
anchors.centerIn: parent
|
|
text: bar.volumeIcon(bar.sinkVolume, bar.sinkMuted) + " " + Math.round(bar.sinkVolume * 100) + "%"
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: bar.sinkMuted ? theme.muted : theme.text
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: volPopup.visible = !volPopup.visible
|
|
onWheel: event => {
|
|
// scroll to nudge volume by one 5% step (OSD follows
|
|
// automatically via the shellVol change tracking).
|
|
// Touchpads report pixelDelta with a zero angleDelta, so use
|
|
// whichever delta is non-zero for the direction.
|
|
if (bar.sink === null || bar.sink.audio === null)
|
|
return;
|
|
const dy = event.angleDelta.y !== 0 ? event.angleDelta.y : event.pixelDelta.y;
|
|
const step = dy > 0 ? 0.05 : -0.05;
|
|
bar.sink.audio.muted = false;
|
|
bar.sink.audio.volume = Math.max(0, Math.min(1, bar.sink.audio.volume + step));
|
|
}
|
|
}
|
|
}
|
|
|
|
// brightness (scroll to adjust + OSD, click opens quick settings)
|
|
Rectangle {
|
|
id: brightButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: brightText.implicitWidth + 14
|
|
height: 24
|
|
radius: 8
|
|
color: "transparent"
|
|
border.color: "transparent"
|
|
border.width: 1
|
|
|
|
Text {
|
|
id: brightText
|
|
anchors.centerIn: parent
|
|
text: "\uf185 " + (root.brightNow >= 0 ? root.brightNow + "%" : "--")
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: theme.text
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: quickPopup.visible = !quickPopup.visible
|
|
onWheel: event => {
|
|
if (root.brightSetBusy)
|
|
return;
|
|
// touchpads: pixelDelta direction, angleDelta may be zero
|
|
const dy = event.angleDelta.y !== 0 ? event.angleDelta.y : event.pixelDelta.y;
|
|
const step = dy > 0 ? 5 : -5;
|
|
const next = Math.max(0, Math.min(100, (root.brightNow >= 0 ? root.brightNow : 50) + step));
|
|
root.brightNow = next;
|
|
root.brightSetBusy = true;
|
|
shellBrightSet.exec(["brightnessctl", "s", next + "%"]);
|
|
osdWin.showBright();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wi-Fi (opens menu)
|
|
Rectangle {
|
|
id: wifiButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: wifiRow.implicitWidth + 14
|
|
height: 24
|
|
radius: 8
|
|
color: netPopup.visible ? theme.surface : "transparent"
|
|
border.color: netPopup.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Row {
|
|
id: wifiRow
|
|
anchors.centerIn: parent
|
|
spacing: 4
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "\uf1eb"
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: !Networking.wifiEnabled ? theme.muted
|
|
: (bar.connectedSsid !== "" ? theme.neon : theme.text)
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: bar.connectedSsid !== "" ? bar.connectedSsid : (Networking.wifiEnabled ? "Wi-Fi" : "off")
|
|
font.pixelSize: theme.fontSize - 1
|
|
color: bar.connectedSsid !== "" ? theme.text : theme.muted
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: netPopup.visible = !netPopup.visible
|
|
}
|
|
}
|
|
|
|
// Bluetooth (opens menu)
|
|
Rectangle {
|
|
id: btButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: btRow.implicitWidth + 14
|
|
height: 24
|
|
radius: 8
|
|
color: btPanel.visible ? theme.surface : "transparent"
|
|
border.color: btPanel.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Row {
|
|
id: btRow
|
|
anchors.centerIn: parent
|
|
spacing: 4
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "\uf294"
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: root.btPowered === null || root.btPowered === false ? theme.muted
|
|
: (root.btConnected > 0 ? theme.neon : theme.text)
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.btPowered === false || root.btPowered === null
|
|
? "off"
|
|
: (root.btConnected > 0 ? root.btConnected + " devices" : "Bluetooth")
|
|
font.pixelSize: theme.fontSize - 1
|
|
color: root.btPowered === false || root.btPowered === null ? theme.muted : theme.text
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: {
|
|
netPopup.visible = false;
|
|
root.btOpen = !root.btOpen;
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep-awake indicator (state lives in the quick-settings panel)
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.keepAwake
|
|
text: "\uf0f4" // coffee
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: theme.magenta
|
|
}
|
|
|
|
// on-screen keyboard / tablet-mode (click toggles wvkbd). Icon + color
|
|
// follow the state file written by the hyprland-tablet daemon.
|
|
Rectangle {
|
|
id: oskButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: 26
|
|
height: 24
|
|
radius: 8
|
|
color: root.oskActive ? theme.surface : "transparent"
|
|
border.color: root.oskActive ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.tabletActive ? "\uf108" /* tablet */ : "\uf11c" /* keyboard */
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: root.oskActive ? theme.neon
|
|
: (root.tabletActive ? theme.magenta : theme.muted)
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: root.oskToggle()
|
|
}
|
|
}
|
|
|
|
// notification center (bell)
|
|
Rectangle {
|
|
id: notifButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 26
|
|
height: 24
|
|
radius: 8
|
|
color: notifPopup.visible ? theme.surface : "transparent"
|
|
border.color: notifPopup.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.notifDnd ? "\uf2ed" : "\uf0f3" // bell-slash / bell
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: root.notifDnd ? theme.muted
|
|
: (root.notifUnseen > 0 ? theme.magenta : theme.text)
|
|
}
|
|
|
|
// unseen-dot badge (cleared when the center opens)
|
|
Rectangle {
|
|
visible: root.notifUnseen > 0 && !root.notifOpen
|
|
anchors {
|
|
top: parent.top
|
|
right: parent.right
|
|
}
|
|
width: 6
|
|
height: 6
|
|
radius: 3
|
|
color: theme.magenta
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: root.notifOpen = !root.notifOpen
|
|
}
|
|
}
|
|
|
|
// system tray (StatusNotifier): per-app icons + DBusMenu context menus
|
|
Row {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 4
|
|
visible: trayList.count > 0
|
|
|
|
Repeater {
|
|
id: trayList
|
|
model: SystemTray.items
|
|
|
|
Item {
|
|
id: trayItem
|
|
required property var modelData
|
|
property bool hovered: false
|
|
width: 26
|
|
height: 24
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 8
|
|
color: trayItem.hovered ? theme.surface : "transparent"
|
|
border.color: trayMenu.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
}
|
|
|
|
Image {
|
|
id: trayIcon
|
|
anchors.centerIn: parent
|
|
width: 18
|
|
height: 18
|
|
source: trayItem.modelData.icon
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
smooth: true
|
|
}
|
|
|
|
// fallback: first letter when the icon can't be resolved
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: trayIcon.status !== Image.Ready
|
|
text: trayItem.modelData.title !== "" ? trayItem.modelData.title.charAt(0).toUpperCase() : "?"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: true
|
|
color: theme.muted
|
|
}
|
|
|
|
// right-click context menu (the app's DBusMenu)
|
|
QsMenuAnchor {
|
|
id: trayMenu
|
|
menu: trayItem.modelData.menu
|
|
anchor.window: bar
|
|
anchor.rect: {
|
|
const pos = trayItem.mapToItem(bar.contentItem, 0, 0);
|
|
return Qt.rect(pos.x, pos.y, trayItem.width, trayItem.height);
|
|
}
|
|
anchor.edges: Edges.Bottom | Edges.Left
|
|
}
|
|
|
|
MouseArea {
|
|
id: trayArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
|
|
|
onEntered: { trayItem.hovered = true; bar.hoverCount++ }
|
|
onExited: { trayItem.hovered = false; bar.hoverCount-- }
|
|
onWheel: event => {
|
|
trayItem.modelData.scroll(
|
|
event.angleDelta.y > 0 ? 1 : -1,
|
|
(event.modifiers & Qt.ShiftModifier) !== 0
|
|
);
|
|
}
|
|
onClicked: event => {
|
|
if (event.button === Qt.RightButton) {
|
|
if (trayItem.modelData.hasMenu)
|
|
trayMenu.open();
|
|
else
|
|
trayItem.modelData.secondaryActivate();
|
|
} else if (event.button === Qt.MiddleButton) {
|
|
trayItem.modelData.secondaryActivate();
|
|
} else if (event.button === Qt.LeftButton) {
|
|
trayItem.modelData.activate();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// system stats (CPU/temp; quick probe refresh — click opens details)
|
|
Rectangle {
|
|
id: statsButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: statsRow.implicitWidth + 14
|
|
height: 24
|
|
radius: 8
|
|
color: statsPopup.visible ? theme.surface : "transparent"
|
|
border.color: statsPopup.visible ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Row {
|
|
id: statsRow
|
|
anchors.centerIn: parent
|
|
spacing: 4
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.statsHot() >= 80 ? "\uf2c6" : "\uf2c7" // thermometer-full / half
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: root.statsHot() >= 80 ? theme.danger
|
|
: (root.statsHot() >= 65 ? theme.magenta : theme.neon)
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.stats === null ? "--" : (Math.round(root.stats.cpu) + "%")
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
color: root.stats === null ? theme.muted
|
|
: (root.stats.cpu >= 85 ? theme.danger : theme.text)
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.stats === null ? "" : (Math.round(root.statsHot()) + "\u00b0")
|
|
font.pixelSize: theme.fontSize - 1
|
|
color: theme.muted
|
|
}
|
|
|
|
// separator
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 1
|
|
height: 12
|
|
color: theme.line
|
|
}
|
|
|
|
// battery (UPower): icon + %, magenta when charging, danger <15%
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: bar.battery !== null
|
|
text: bar.battery === null ? "" : (bar.batteryCharging ? "\uf0e7" : bar.batteryIcon(bar.battery.percentage))
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: bar.battery === null ? theme.muted
|
|
: (bar.batteryCharging ? theme.magenta
|
|
: (bar.battery.percentage < 0.15 ? theme.danger : theme.text))
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: bar.battery !== null
|
|
text: bar.battery === null ? "" : Math.round(bar.battery.percentage * 100) + "%"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
color: bar.battery === null ? theme.muted
|
|
: (bar.battery.percentage < 0.15 ? theme.danger : theme.muted)
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: {
|
|
netPopup.visible = false;
|
|
quickPopup.visible = false;
|
|
statsPopup.visible = !statsPopup.visible;
|
|
}
|
|
}
|
|
}
|
|
|
|
// clock (Orbitron, neon cyan) — click opens the calendar+weather popup
|
|
Rectangle {
|
|
id: clockButton
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
implicitWidth: clockRow.implicitWidth + 16
|
|
height: 24
|
|
radius: 8
|
|
color: root.calOpen ? theme.surface : "transparent"
|
|
border.color: root.calOpen ? theme.neon : "transparent"
|
|
border.width: 1
|
|
|
|
Row {
|
|
id: clockRow
|
|
anchors.centerIn: parent
|
|
spacing: 6
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: Qt.formatDateTime(timeSource.date, "HH:mm")
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 13
|
|
font.bold: true
|
|
color: theme.neon
|
|
}
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: theme.muted
|
|
font.pixelSize: 8
|
|
text: Qt.formatDateTime(timeSource.date, "ddd d MMM")
|
|
}
|
|
}
|
|
|
|
SystemClock {
|
|
id: timeSource
|
|
precision: SystemClock.Seconds
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onEntered: bar.hoverCount++
|
|
onExited: bar.hoverCount--
|
|
onClicked: {
|
|
netPopup.visible = false;
|
|
quickPopup.visible = false;
|
|
root.btOpen = false;
|
|
root.notifOpen = false;
|
|
root.wsOpen = false;
|
|
root.calOpen = !root.calOpen;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Quick settings =================
|
|
// Volume (PipeWire), screen brightness (brightnessctl), session actions.
|
|
PopupWindow {
|
|
id: quickPopup
|
|
implicitWidth: 300
|
|
implicitHeight: quickColumn.implicitHeight + 20
|
|
visible: false
|
|
grabFocus: true
|
|
color: "transparent"
|
|
|
|
property int brightNow: -1 // cached percent from the last query
|
|
|
|
property var actions: [
|
|
{
|
|
"label": "LOCK",
|
|
"icon": "\uf023",
|
|
"cmd": ["hyprlock"],
|
|
"accent": theme.neon
|
|
},
|
|
{
|
|
"label": "EXIT",
|
|
"icon": "\uf08b",
|
|
"cmd": ["hyprctl", "dispatch", "exit"],
|
|
"accent": theme.muted
|
|
},
|
|
{
|
|
"label": "REBOOT",
|
|
"icon": "\uf021",
|
|
"cmd": ["sudo", "systemctl", "reboot"],
|
|
"accent": theme.violet
|
|
},
|
|
{
|
|
"label": "POWER",
|
|
"icon": "\uf011",
|
|
"cmd": ["sudo", "systemctl", "poweroff"],
|
|
"accent": theme.danger
|
|
}
|
|
]
|
|
|
|
anchor {
|
|
item: gearButton
|
|
edges: Edges.Bottom
|
|
}
|
|
|
|
// refresh backlight + volume + autostart list state on open
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
brightQuery.running = true;
|
|
volSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
|
|
root.appsRefresh();
|
|
root.chargeLimitRefresh();
|
|
}
|
|
}
|
|
|
|
// `brightnessctl g` + `brightnessctl m` in one go: "<cur> <max>"
|
|
Process {
|
|
id: brightQuery
|
|
command: ["sh", "-c", "echo $(brightnessctl g) $(brightnessctl m)"]
|
|
running: false
|
|
stdout: SplitParser {
|
|
onRead: line => {
|
|
const parts = line.trim().split(/\s+/).map(Number);
|
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[1] > 0) {
|
|
brightSlider.value = Math.max(0, Math.min(1, parts[0] / parts[1]));
|
|
quickPopup.brightNow = Math.round(brightSlider.value * 100);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// throttled setter: skip while a previous request is still running
|
|
Process {
|
|
id: brightSet
|
|
running: false
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: quickColumn
|
|
anchors {
|
|
fill: parent
|
|
margins: 10
|
|
}
|
|
spacing: 10
|
|
|
|
Text {
|
|
text: "SYSTEM // QUICK CONTROL"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// volume
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: bar.volumeIcon(bar.sinkVolume, bar.sinkMuted)
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: bar.sinkMuted ? theme.muted : theme.text
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (bar.sink !== null && bar.sink.audio !== null)
|
|
bar.sink.audio.muted = !bar.sink.audio.muted;
|
|
}
|
|
}
|
|
}
|
|
|
|
SciSlider {
|
|
id: volSlider
|
|
Layout.fillWidth: true
|
|
value: bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume)
|
|
onMoved: v => {
|
|
if (bar.sink !== null && bar.sink.audio !== null) {
|
|
bar.sink.audio.muted = false;
|
|
bar.sink.audio.volume = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: bar.sinkMuted ? "MUTE" : Math.round(volSlider.value * 100) + "%"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.preferredWidth: 44
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
|
|
// brightness
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "\uf185" // sun
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: theme.text
|
|
}
|
|
|
|
SciSlider {
|
|
id: brightSlider
|
|
Layout.fillWidth: true
|
|
accent: theme.magenta
|
|
value: 0.5
|
|
onMoved: v => {
|
|
quickPopup.brightNow = Math.round(v * 100);
|
|
if (!brightSet.running)
|
|
brightSet.exec(["brightnessctl", "s", quickPopup.brightNow + "%"]);
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: quickPopup.brightNow >= 0 ? quickPopup.brightNow + "%" : "--"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.preferredWidth: 44
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
|
|
// power profile (power-profiles-daemon via Quickshell.UPower)
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "PWR"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
Repeater {
|
|
model: [
|
|
{
|
|
"label": "SAVER",
|
|
"profile": PowerProfile.PowerSaver,
|
|
"enabled": true
|
|
},
|
|
{
|
|
"label": "BALANCED",
|
|
"profile": PowerProfile.Balanced,
|
|
"enabled": true
|
|
},
|
|
{
|
|
"label": "PERF",
|
|
"profile": PowerProfile.Performance,
|
|
"enabled": PowerProfiles.hasPerformanceProfile
|
|
}
|
|
].filter(m => m.enabled)
|
|
|
|
Rectangle {
|
|
id: pfPill
|
|
required property var modelData
|
|
|
|
implicitWidth: pfText.implicitWidth + 16
|
|
implicitHeight: 22
|
|
radius: 6
|
|
color: PowerProfiles.profile === modelData.profile ? theme.neon : "transparent"
|
|
border.color: PowerProfiles.profile === modelData.profile ? theme.neon : theme.line
|
|
border.width: 1
|
|
Behavior on color { ColorAnimation { duration: 150 } }
|
|
|
|
Text {
|
|
id: pfText
|
|
anchors.centerIn: parent
|
|
text: pfPill.modelData.label
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
color: PowerProfiles.profile === pfPill.modelData.profile ? theme.ink : theme.muted
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: PowerProfiles.profile = pfPill.modelData.profile
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep awake: hold off lock/screen-off/suspend (idle inhibitor)
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "\uf0f4" // coffee
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: root.keepAwake ? theme.magenta : theme.text
|
|
}
|
|
|
|
Text {
|
|
text: "KEEP AWAKE"
|
|
color: root.keepAwake ? theme.magenta : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 38
|
|
implicitHeight: 20
|
|
radius: 10
|
|
color: root.keepAwake ? theme.magenta : theme.line
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
x: root.keepAwake ? 20 : 2
|
|
width: 16
|
|
height: 16
|
|
radius: 8
|
|
color: theme.ink
|
|
|
|
Behavior on x {
|
|
NumberAnimation {
|
|
duration: 140
|
|
easing.type: Easing.InOutQuad
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.keepAwake = !root.keepAwake
|
|
}
|
|
}
|
|
}
|
|
|
|
// charge limit: stop charging at 80% (battery health); on = limit on
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "\uf240" // battery-3/4
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: root.chargeLimitActive ? theme.neon : theme.text
|
|
}
|
|
|
|
Text {
|
|
text: "CHARGE LIMIT"
|
|
color: root.chargeLimitActive ? theme.neon : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: Math.round(bar.battery ? bar.battery.percentage * 100 : 0) + "%"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
visible: root.chargeLimitActive
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 38
|
|
implicitHeight: 20
|
|
radius: 10
|
|
color: root.chargeLimitActive ? theme.neon : theme.line
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
x: root.chargeLimitActive ? 20 : 2
|
|
width: 16
|
|
height: 16
|
|
radius: 8
|
|
color: theme.ink
|
|
|
|
Behavior on x {
|
|
NumberAnimation {
|
|
duration: 140
|
|
easing.type: Easing.InOutQuad
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.chargeLimitToggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- autostart apps: pick which installed apps start at login ---
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: 4
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "\uf135" // rocket
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: theme.neon
|
|
}
|
|
|
|
Text {
|
|
text: "APPS ON LOGIN"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 20
|
|
implicitHeight: 20
|
|
radius: 4
|
|
color: root.appsAddOpen ? theme.neon : "transparent"
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "\uf067" // plus
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 10
|
|
color: root.appsAddOpen ? theme.ink : theme.neon
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -2
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
root.appsAddOpen = !root.appsAddOpen;
|
|
if (!root.appsAddOpen) {
|
|
root.appsNewName = "";
|
|
root.appsNewCmd = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// add form (name + command), collapsed by default
|
|
ColumnLayout {
|
|
visible: root.appsAddOpen
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
|
|
Text {
|
|
text: "NAME"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
Layout.preferredWidth: 36
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 24
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
|
|
TextInput {
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 6
|
|
rightMargin: 6
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.pixelSize: 11
|
|
selectByMouse: true
|
|
onTextChanged: root.appsNewName = text
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
|
|
Text {
|
|
text: "CMD"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
Layout.preferredWidth: 36
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 24
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
|
|
TextInput {
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 6
|
|
rightMargin: 6
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
selectByMouse: true
|
|
onTextChanged: root.appsNewCmd = text
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 34
|
|
height: 24
|
|
radius: 6
|
|
color: (root.appsNewName && root.appsNewCmd) ? theme.neon : theme.surface
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "ADD"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
color: (root.appsNewName && root.appsNewCmd) ? theme.ink : theme.neon
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -2
|
|
cursorShape: Qt.PointingHandCursor
|
|
enabled: root.appsNewName && root.appsNewCmd
|
|
onClicked: {
|
|
root.appsAdd(root.appsNewName, root.appsNewCmd);
|
|
root.appsNewName = "";
|
|
root.appsNewCmd = "";
|
|
root.appsAddOpen = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
id: appsListView
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.min(appsListView.contentHeight, 140)
|
|
Layout.topMargin: 4
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
spacing: 2
|
|
model: root.appsEntries
|
|
|
|
delegate: Rectangle {
|
|
id: appsRow
|
|
required property var modelData
|
|
width: appsListView.width
|
|
height: 32
|
|
radius: 6
|
|
color: appsRowMa.containsMouse ? theme.surface : "transparent"
|
|
|
|
Text {
|
|
anchors {
|
|
left: parent.left
|
|
verticalCenter: parent.verticalCenter
|
|
leftMargin: 8
|
|
}
|
|
text: appsRow.modelData.name
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
}
|
|
|
|
// remove (user-added entries only)
|
|
Text {
|
|
visible: appsRow.modelData.source === "user"
|
|
anchors {
|
|
right: appsRowTog.left
|
|
verticalCenter: parent.verticalCenter
|
|
rightMargin: 6
|
|
}
|
|
text: "\uf00d" // times
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 12
|
|
color: theme.danger
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.appsRemove(appsRow.modelData.name)
|
|
}
|
|
}
|
|
|
|
// toggle knob (keep-awake style)
|
|
Rectangle {
|
|
id: appsRowTog
|
|
anchors {
|
|
right: parent.right
|
|
verticalCenter: parent.verticalCenter
|
|
rightMargin: 8
|
|
}
|
|
implicitWidth: 38
|
|
implicitHeight: 20
|
|
radius: 10
|
|
color: appsRow.modelData.enabled ? theme.neon : theme.line
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
x: appsRow.modelData.enabled ? 20 : 2
|
|
width: 16
|
|
height: 16
|
|
radius: 8
|
|
color: theme.ink
|
|
|
|
Behavior on x {
|
|
NumberAnimation {
|
|
duration: 140
|
|
easing.type: Easing.InOutQuad
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.appsToggle(appsRow.modelData.name)
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: appsRowMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
z: -1
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// action buttons: LOCK / EXIT / REBOOT / POWER
|
|
Row {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Repeater {
|
|
model: quickPopup.actions
|
|
|
|
Rectangle {
|
|
required property var modelData
|
|
|
|
width: (quickColumn.width - 3 * 8) / 4
|
|
height: 48
|
|
radius: 8
|
|
color: actMa.containsMouse ? theme.surface : "transparent"
|
|
border.color: modelData.accent
|
|
border.width: actMa.containsMouse ? 2 : 1
|
|
|
|
Column {
|
|
anchors.centerIn: parent
|
|
spacing: 2
|
|
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: modelData.icon
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize + 2
|
|
color: modelData.accent
|
|
}
|
|
Text {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: modelData.label
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
color: theme.muted
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: actMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: Quickshell.execDetached(modelData.cmd)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep the volume slider in sync with app/media-key changes
|
|
Connections {
|
|
target: bar
|
|
function onSinkVolumeChanged() {
|
|
if (!volSlider.pressed && !bar.sinkMuted)
|
|
volSlider.value = Math.min(1, bar.sinkVolume);
|
|
}
|
|
function onSinkMutedChanged() {
|
|
if (!volSlider.pressed)
|
|
volSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= System stats =================
|
|
// CPU/RAM/swap/disk/temps + pinned-flake badge. Popup-only full probe
|
|
// keeps the CPU sample off the always-on chip.
|
|
PopupWindow {
|
|
id: statsPopup
|
|
implicitWidth: 340
|
|
implicitHeight: statsColumn.implicitHeight + 24
|
|
visible: false
|
|
grabFocus: true
|
|
color: "transparent"
|
|
|
|
anchor {
|
|
item: statsButton
|
|
edges: Edges.Bottom
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
statsProbe.exec(["sh", "-c", root.statsScript]);
|
|
statsPopupTimer.running = true;
|
|
} else {
|
|
statsPopupTimer.running = false;
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors {
|
|
fill: parent
|
|
topMargin: 4
|
|
}
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: statsColumn
|
|
anchors {
|
|
fill: parent
|
|
margins: 10
|
|
}
|
|
spacing: 8
|
|
|
|
// Header
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "SYSTEM"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
Text {
|
|
text: root.stats === null ? "…" : (root.stats.freq + " GHz")
|
|
color: theme.muted
|
|
font.pixelSize: 10
|
|
}
|
|
}
|
|
|
|
// CPU
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
Text {
|
|
text: "CPU"
|
|
color: theme.text
|
|
font.pixelSize: 10
|
|
Layout.preferredWidth: 34
|
|
}
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 6
|
|
radius: 3
|
|
color: theme.line
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 1
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 1
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 1
|
|
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.cpu / 100))
|
|
radius: 2
|
|
color: root.stats === null ? theme.muted
|
|
: (root.stats.cpu >= 85 ? theme.danger
|
|
: (root.stats.cpu >= 60 ? theme.magenta : theme.neon))
|
|
}
|
|
}
|
|
Text {
|
|
text: root.stats === null ? "--" : (root.stats.cpu + "%")
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.preferredWidth: 40
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
|
|
// RAM
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
Text {
|
|
text: "RAM"
|
|
color: theme.text
|
|
font.pixelSize: 10
|
|
Layout.preferredWidth: 34
|
|
}
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 6
|
|
radius: 3
|
|
color: theme.line
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 1
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 1
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 1
|
|
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.ram.pct / 100))
|
|
radius: 2
|
|
color: root.stats === null ? theme.muted
|
|
: (root.stats.ram.pct >= 85 ? theme.danger
|
|
: (root.stats.ram.pct >= 65 ? theme.magenta : theme.violet))
|
|
}
|
|
}
|
|
Text {
|
|
text: root.stats === null ? "--" : (root.stats.ram.used + "G / " + root.stats.ram.total + "G")
|
|
color: theme.text
|
|
font.pixelSize: 9
|
|
font.family: theme.displayFont
|
|
Layout.preferredWidth: 92
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
|
|
// Swap (only when nonzero)
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
visible: root.stats !== null && root.stats.swap.total > 0
|
|
Text {
|
|
text: "SWAP"
|
|
color: theme.text
|
|
font.pixelSize: 10
|
|
Layout.preferredWidth: 34
|
|
}
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 6
|
|
radius: 3
|
|
color: theme.line
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 1
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 1
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 1
|
|
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.swap.pct / 100))
|
|
radius: 2
|
|
color: theme.muted
|
|
}
|
|
}
|
|
Text {
|
|
text: root.stats === null ? "--" : (root.stats.swap.used + "G / " + root.stats.swap.total + "G")
|
|
color: theme.text
|
|
font.pixelSize: 9
|
|
font.family: theme.displayFont
|
|
Layout.preferredWidth: 92
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
|
|
// Disk root
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
Text {
|
|
text: "DISK"
|
|
color: theme.text
|
|
font.pixelSize: 10
|
|
Layout.preferredWidth: 34
|
|
}
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 6
|
|
radius: 3
|
|
color: theme.line
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 1
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 1
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 1
|
|
width: (parent.width - 2) * (root.stats === null ? 0 : Math.min(1, root.stats.disk.pct / 100))
|
|
radius: 2
|
|
color: root.stats !== null && root.stats.disk.pct >= 90 ? theme.danger
|
|
: (root.stats !== null && root.stats.disk.pct >= 75 ? theme.magenta : theme.neon)
|
|
}
|
|
}
|
|
Text {
|
|
text: root.stats === null ? "--" : (root.stats.disk.used + "G / " + root.stats.disk.total + "G")
|
|
color: theme.text
|
|
font.pixelSize: 9
|
|
font.family: theme.displayFont
|
|
Layout.preferredWidth: 92
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
|
|
// Temperatures
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
|
|
Repeater {
|
|
model: root.stats === null ? [] : root.stats.temps
|
|
|
|
Rectangle {
|
|
required property var modelData
|
|
implicitWidth: rowText.implicitWidth + 10
|
|
implicitHeight: 18
|
|
radius: 4
|
|
color: modelData.temp >= 80 ? "#3df7768e" : (modelData.temp >= 65 ? "#3fff2e97" : "#38a9b1d6")
|
|
|
|
Text {
|
|
id: rowText
|
|
anchors.centerIn: parent
|
|
text: modelData.name + " " + Math.round(modelData.temp) + "\u00b0"
|
|
font.pixelSize: 8
|
|
font.family: theme.displayFont
|
|
color: modelData.temp >= 65 ? theme.ink : (modelData.temp >= 45 ? theme.text : theme.muted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load average
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
Text {
|
|
text: "LOAD"
|
|
color: theme.text
|
|
font.pixelSize: 10
|
|
Layout.preferredWidth: 34
|
|
}
|
|
Text {
|
|
text: root.stats === null || root.stats.load.length === 0 ? "--" : root.stats.load.join(" ")
|
|
color: theme.muted
|
|
font.pixelSize: 9
|
|
font.family: theme.displayFont
|
|
horizontalAlignment: Text.AlignRight
|
|
Layout.fillWidth: true
|
|
}
|
|
Text {
|
|
text: root.stats === null ? "" : (Math.round(root.stats.uptime / 3600) + "h up")
|
|
color: theme.muted
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// Pinned flake badge: rev + date + staleness + copy-update-cmd
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "\uf120" // terminal
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 12
|
|
color: theme.muted
|
|
}
|
|
ColumnLayout {
|
|
spacing: 2
|
|
Layout.fillWidth: true
|
|
|
|
Text {
|
|
text: "NIXPKGS"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
text: root.statsRev() + " \u00b7 " + (root.statsAgeDays() < 0 ? "--" : root.statsAgeDays() + "d old")
|
|
color: theme.muted
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
Rectangle {
|
|
Layout.preferredWidth: 116
|
|
Layout.preferredHeight: 26
|
|
radius: 6
|
|
color: copyMa.containsMouse ? theme.surface : "transparent"
|
|
border.color: theme.violet
|
|
border.width: copyMa.containsMouse ? 2 : 1
|
|
|
|
Text {
|
|
id: flakeCopyLabel
|
|
anchors.centerIn: parent
|
|
text: "\uf0c1 UPDATE CMD"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 7
|
|
color: theme.violet
|
|
}
|
|
|
|
MouseArea {
|
|
id: copyMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.flakeCopy()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Wi-Fi menu =================
|
|
PopupWindow {
|
|
id: netPopup
|
|
implicitWidth: 320
|
|
implicitHeight: netColumn.implicitHeight + 24
|
|
visible: false
|
|
grabFocus: true
|
|
color: "transparent"
|
|
|
|
// Secured network awaiting a password (null = prompt hidden)
|
|
property var pendingNetwork: null
|
|
// Error text from the last failed connect attempt ("" = no error)
|
|
property string errText: ""
|
|
|
|
anchor {
|
|
item: wifiButton
|
|
edges: Edges.Bottom
|
|
}
|
|
|
|
// Keep the NetworkManager scan list live only while the menu is open.
|
|
onVisibleChanged: {
|
|
if (bar.wifiDev !== null)
|
|
bar.wifiDev.scannerEnabled = visible;
|
|
if (!visible) {
|
|
netPopup.pendingNetwork = null;
|
|
netPopup.errText = "";
|
|
pskInput.text = "";
|
|
}
|
|
}
|
|
|
|
// Password flow for networks NetworkManager has never seen:
|
|
// Quickshell's connectWithPsk() creates an incomplete profile (no
|
|
// key-mgmt) for new networks and only logs DBus errors, so connect
|
|
// via nmcli instead — it builds a full profile and its stderr is
|
|
// surfaced in the menu. (Saved/open networks still use the native
|
|
// connect() path in the row click handler.)
|
|
Process {
|
|
id: wifiConnectProc
|
|
running: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: netPopup.errText = ""
|
|
}
|
|
stderr: StdioCollector {
|
|
onStreamFinished: netPopup.errText = this.text.trim()
|
|
}
|
|
onExited: (code) => {
|
|
if (code === 0) {
|
|
netPopup.pendingNetwork = null;
|
|
netPopup.errText = "";
|
|
pskInput.text = "";
|
|
} else if (netPopup.errText === "") {
|
|
netPopup.errText = "Connection failed (nmcli exit " + code + ")";
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors {
|
|
fill: parent
|
|
topMargin: 4
|
|
}
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: netColumn
|
|
anchors {
|
|
top: parent.top
|
|
left: parent.left
|
|
right: parent.right
|
|
topMargin: 16
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
bottomMargin: 8
|
|
}
|
|
spacing: 6
|
|
|
|
// Header: title + Wi-Fi radio toggle
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "WI-FI"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 38
|
|
implicitHeight: 20
|
|
radius: 10
|
|
color: Networking.wifiEnabled ? theme.neon : theme.line
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
x: Networking.wifiEnabled ? 20 : 2
|
|
width: 16
|
|
height: 16
|
|
radius: 8
|
|
color: theme.ink
|
|
|
|
Behavior on x {
|
|
NumberAnimation {
|
|
duration: 140
|
|
easing.type: Easing.InOutQuad
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: Networking.wifiEnabled = !Networking.wifiEnabled
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// Empty / scanning placeholder
|
|
Text {
|
|
visible: bar.wifiDev === null || bar.wifiDev.networks.count === 0
|
|
text: bar.wifiDev === null
|
|
? "No Wi-Fi adapter"
|
|
: (bar.wifiDev.scannerEnabled ? "Scanning…" : "No networks found")
|
|
color: theme.muted
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
// Network rows (live ObjectModel from NetworkManager)
|
|
Repeater {
|
|
model: bar.wifiDev !== null ? bar.wifiDev.networks : null
|
|
|
|
Rectangle {
|
|
id: netRow
|
|
required property WifiNetwork modelData
|
|
|
|
Layout.fillWidth: true
|
|
implicitHeight: 32
|
|
radius: 6
|
|
color: netRowMa.containsMouse ? theme.surface : "transparent"
|
|
|
|
Text {
|
|
anchors {
|
|
left: parent.left
|
|
verticalCenter: parent.verticalCenter
|
|
leftMargin: 6
|
|
}
|
|
text: (netRow.modelData.security !== WifiSecurityType.None ? "\uf023 " : "") + netRow.modelData.name
|
|
color: netRow.modelData.connected ? theme.neon : theme.text
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
Text {
|
|
anchors {
|
|
right: parent.right
|
|
verticalCenter: parent.verticalCenter
|
|
rightMargin: 6
|
|
}
|
|
text: netRow.modelData.stateChanging
|
|
? "…"
|
|
: (netRow.modelData.connected ? "LINK" : Math.round(netRow.modelData.signalStrength * 100) + "%")
|
|
color: netRow.modelData.connected ? theme.neon : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
MouseArea {
|
|
id: netRowMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (netRow.modelData.connected) {
|
|
netRow.modelData.disconnect();
|
|
return;
|
|
}
|
|
netPopup.pendingNetwork = null;
|
|
netPopup.errText = "";
|
|
pskInput.text = "";
|
|
if (netRow.modelData.known || netRow.modelData.security === WifiSecurityType.None) {
|
|
netRow.modelData.connect();
|
|
} else {
|
|
netPopup.pendingNetwork = netRow.modelData;
|
|
pskInput.forceActiveFocus();
|
|
}
|
|
}
|
|
}
|
|
|
|
// If connect() fails because NetworkManager has no stored
|
|
// secret, fall back to the inline password prompt.
|
|
Connections {
|
|
target: netRow.modelData
|
|
function onConnectionFailed(reason) {
|
|
if (reason === ConnectionFailReason.NoSecrets) {
|
|
netPopup.pendingNetwork = netRow.modelData;
|
|
pskInput.forceActiveFocus();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inline password prompt
|
|
ColumnLayout {
|
|
id: pskColumn
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: 4
|
|
visible: netPopup.pendingNetwork !== null
|
|
spacing: 6
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
Text {
|
|
text: "PASSWORD // " + (netPopup.pendingNetwork !== null ? netPopup.pendingNetwork.name : "")
|
|
color: theme.magenta
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
implicitHeight: 26
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.neon
|
|
border.width: pskInput.activeFocus ? 2 : 1
|
|
|
|
TextInput {
|
|
id: pskInput
|
|
anchors {
|
|
fill: parent
|
|
margins: 5
|
|
}
|
|
clip: true
|
|
color: theme.text
|
|
echoMode: TextInput.Password
|
|
font.pixelSize: 12
|
|
onAccepted: pskColumn.submit()
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
implicitWidth: connectText.implicitWidth + 16
|
|
implicitHeight: 24
|
|
radius: 6
|
|
color: connectMa.containsMouse ? theme.line : theme.neon
|
|
|
|
Text {
|
|
id: connectText
|
|
anchors.centerIn: parent
|
|
text: "CONNECT"
|
|
color: theme.ink
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
|
|
MouseArea {
|
|
id: connectMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: pskColumn.submit()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: cancelText.implicitWidth + 16
|
|
implicitHeight: 24
|
|
radius: 6
|
|
color: cancelMa.containsMouse ? theme.surface : "transparent"
|
|
border.color: theme.line
|
|
|
|
Text {
|
|
id: cancelText
|
|
anchors.centerIn: parent
|
|
text: "CANCEL"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
MouseArea {
|
|
id: cancelMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
netPopup.pendingNetwork = null;
|
|
netPopup.errText = "";
|
|
pskInput.text = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: wifiConnectProc.running
|
|
text: "LINKING…"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: netPopup.errText !== ""
|
|
Layout.fillWidth: true
|
|
text: netPopup.errText
|
|
color: theme.danger
|
|
font.pixelSize: 11
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
function submit() {
|
|
if (netPopup.pendingNetwork === null || pskInput.text === "")
|
|
return;
|
|
netPopup.errText = "";
|
|
wifiConnectProc.exec([
|
|
"nmcli", "device", "wifi", "connect",
|
|
netPopup.pendingNetwork.name,
|
|
"password", pskInput.text
|
|
]);
|
|
}
|
|
}
|
|
|
|
Item {
|
|
Layout.preferredHeight: 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Bluetooth menu =================
|
|
PanelWindow {
|
|
id: btPanel
|
|
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
right: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
// align right edge with the island's right edge, not the screen's
|
|
// (bar is a centered pill now, so screen-right no longer matches it)
|
|
margins.right: (modelData.width - bar.islandWidth) / 2 + 210
|
|
focusable: true
|
|
visible: root.btOpen && modelData === Quickshell.screens[0]
|
|
implicitWidth: 340
|
|
implicitHeight: Math.min(btColumn.implicitHeight + 24, modelData.height - 140)
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-bt"
|
|
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
root.btError = "";
|
|
root.btRefresh();
|
|
}
|
|
}
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [btPanel]
|
|
active: btPanel.visible
|
|
onCleared: root.btOpen = false
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: btColumn
|
|
anchors {
|
|
fill: parent
|
|
margins: 8
|
|
}
|
|
spacing: 6
|
|
|
|
// Header: title + power toggle
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "BLUETOOTH"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 38
|
|
implicitHeight: 20
|
|
radius: 10
|
|
color: root.btPowered === true ? theme.neon : theme.line
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
x: root.btPowered === true ? 20 : 2
|
|
width: 16
|
|
height: 16
|
|
radius: 8
|
|
color: theme.ink
|
|
|
|
Behavior on x {
|
|
NumberAnimation {
|
|
duration: 140
|
|
easing.type: Easing.InOutQuad
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.btRun(["power", root.btPowered === true ? "off" : "on"])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Subline: adapter status + scan action
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: root.btPowered === null
|
|
? "No Bluetooth adapter"
|
|
: (root.btPowered === true
|
|
? (root.btAdapterName !== "" ? root.btAdapterName : "adapter ready")
|
|
: "adapter off")
|
|
color: theme.muted
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: scanText.implicitWidth + 16
|
|
implicitHeight: 22
|
|
radius: 5
|
|
visible: root.btPowered === true && !root.btBusy
|
|
color: scanMa.containsMouse ? theme.line : "transparent"
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
|
|
Text {
|
|
id: scanText
|
|
anchors.centerIn: parent
|
|
text: root.btDiscovering ? "SCANNING…" : "SCAN"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
|
|
MouseArea {
|
|
id: scanMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.btScan()
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// Busy / error line
|
|
Text {
|
|
visible: root.btBusy || root.btError !== ""
|
|
Layout.fillWidth: true
|
|
text: root.btBusy ? "WORKING…" : root.btError
|
|
color: root.btBusy ? theme.neon : theme.danger
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
// Empty state
|
|
Text {
|
|
visible: root.btPowered === true && root.btDevices.length === 0 && !root.btBusy && root.btError === ""
|
|
Layout.fillWidth: true
|
|
text: root.btDiscovering ? "Scanning…" : "No devices — try SCAN"
|
|
color: theme.muted
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
// Device rows
|
|
Repeater {
|
|
model: root.btDevices
|
|
|
|
Rectangle {
|
|
id: btRow
|
|
required property var modelData
|
|
|
|
Layout.fillWidth: true
|
|
implicitHeight: 32
|
|
radius: 6
|
|
color: btRowMa.containsMouse ? theme.surface : "transparent"
|
|
|
|
Row {
|
|
anchors {
|
|
left: parent.left
|
|
verticalCenter: parent.verticalCenter
|
|
leftMargin: 6
|
|
}
|
|
spacing: 8
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: btRow.modelData.icon === "audio-headset" || btRow.modelData.icon === "audio-card"
|
|
? "\uf025"
|
|
: (btRow.modelData.icon === "computer" ? "\uf109"
|
|
: (btRow.modelData.icon === "input-keyboard" ? "\uf11c"
|
|
: (btRow.modelData.icon === "input-mouse" ? "\uf8cc"
|
|
: (btRow.modelData.icon === "phone" ? "\uf10b" : "\uf294"))))
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: btRow.modelData.connected ? theme.neon : theme.muted
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: btRow.modelData.name !== "" ? btRow.modelData.name : btRow.modelData.mac
|
|
color: btRow.modelData.connected ? theme.neon : theme.text
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors {
|
|
right: forgetBtn.left
|
|
verticalCenter: parent.verticalCenter
|
|
rightMargin: 6
|
|
}
|
|
text: btRow.modelData.connected
|
|
? "LINK"
|
|
: (btRow.modelData.paired
|
|
? (btRow.modelData.battery !== null ? btRow.modelData.battery + "%" : "PAIRED")
|
|
: "")
|
|
color: btRow.modelData.connected ? theme.neon : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
// forget (unpair) — topmost, stops row click propagation
|
|
Rectangle {
|
|
id: forgetBtn
|
|
anchors {
|
|
right: parent.right
|
|
verticalCenter: parent.verticalCenter
|
|
rightMargin: 4
|
|
}
|
|
width: 20
|
|
height: 20
|
|
radius: 4
|
|
visible: btRow.modelData.paired || btRow.modelData.connected
|
|
color: forgetMa.containsMouse ? theme.danger : "transparent"
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "\uf2ed"
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 10
|
|
color: theme.muted
|
|
}
|
|
|
|
MouseArea {
|
|
id: forgetMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.btRun(["remove", btRow.modelData.mac])
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: btRowMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.btAct(btRow.modelData.mac)
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
Layout.preferredHeight: 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Volume popup =================
|
|
PopupWindow {
|
|
id: volPopup
|
|
implicitWidth: 220
|
|
implicitHeight: volPopColumn.implicitHeight + 20
|
|
visible: false
|
|
grabFocus: true
|
|
color: "transparent"
|
|
|
|
anchor {
|
|
item: volButton
|
|
edges: Edges.Bottom
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (visible)
|
|
volPopSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: volPopColumn
|
|
anchors {
|
|
fill: parent
|
|
margins: 10
|
|
}
|
|
spacing: 10
|
|
|
|
Text {
|
|
text: "AUDIO // VOLUME"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: bar.volumeIcon(bar.sinkVolume, bar.sinkMuted)
|
|
font.family: theme.iconFont
|
|
font.pixelSize: theme.fontSize
|
|
color: bar.sinkMuted ? theme.muted : theme.text
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (bar.sink !== null && bar.sink.audio !== null)
|
|
bar.sink.audio.muted = !bar.sink.audio.muted;
|
|
}
|
|
}
|
|
}
|
|
|
|
SciSlider {
|
|
id: volPopSlider
|
|
Layout.fillWidth: true
|
|
value: bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume)
|
|
onMoved: v => {
|
|
if (bar.sink !== null && bar.sink.audio !== null) {
|
|
bar.sink.audio.muted = false;
|
|
bar.sink.audio.volume = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: bar.sinkMuted ? "MUTE" : Math.round(volPopSlider.value * 100) + "%"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.preferredWidth: 44
|
|
horizontalAlignment: Text.AlignRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep the volume slider in sync with app/media-key changes
|
|
Connections {
|
|
target: bar
|
|
function onSinkVolumeChanged() {
|
|
if (!volPopSlider.pressed && !bar.sinkMuted)
|
|
volPopSlider.value = Math.min(1, bar.sinkVolume);
|
|
}
|
|
function onSinkMutedChanged() {
|
|
if (!volPopSlider.pressed)
|
|
volPopSlider.value = bar.sinkMuted ? 0 : Math.min(1, bar.sinkVolume);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Clipboard picker (cliphist) =================
|
|
// One panel per bar instance, but only ever shown on the primary
|
|
// screen (the cliphist data + list live at ShellRoot scope).
|
|
// NB: deliberately a PanelWindow (layer surface) not a PopupWindow
|
|
// (xdg-popup): xdg-popups opened from a keybind have no input serial
|
|
// and never receive clicks or key events. A layer surface takes input
|
|
// normally and is first-class for HyprlandFocusGrab.
|
|
PanelWindow {
|
|
id: clipPopup
|
|
screen: modelData
|
|
// top-left anchors; horizontal centering computed via margins.left
|
|
anchors {
|
|
top: true
|
|
left: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
margins.left: (modelData.width - clipPopup.implicitWidth) / 2
|
|
// required or the panel never receives keyboard events (1-9 / Esc)
|
|
focusable: true
|
|
implicitWidth: 440
|
|
implicitHeight: clipCol.implicitHeight + 24
|
|
visible: root.clipOpen && modelData === Quickshell.screens[0]
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-clipboard"
|
|
|
|
// pull keyboard focus onto the key-handler item whenever the picker
|
|
// opens (needed for 1-9 / Esc to reach the Keys handlers)
|
|
onVisibleChanged: if (visible)
|
|
clipKeys.forceActiveFocus()
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [clipPopup]
|
|
active: clipPopup.visible
|
|
onCleared: {
|
|
console.log("clip: grab cleared");
|
|
root.clipOpen = false;
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: clipCol
|
|
anchors {
|
|
fill: parent
|
|
margins: 12
|
|
}
|
|
spacing: 8
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "CLIPBOARD"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: root.clipEntries.length + " ITEMS"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
Text {
|
|
visible: root.clipEntries.length === 0
|
|
text: "Clipboard history is empty"
|
|
color: theme.muted
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
ListView {
|
|
id: clipListView
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.min(clipListView.contentHeight, 340)
|
|
visible: root.clipEntries.length > 0
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
spacing: 2
|
|
model: root.clipEntries
|
|
|
|
delegate: Rectangle {
|
|
id: clipRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: clipListView.width
|
|
height: 38
|
|
radius: 6
|
|
color: clipRowMa.containsMouse ? theme.surface : "transparent"
|
|
|
|
Text {
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
verticalCenter: parent.verticalCenter
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
text: (clipRow.index + 1) + ". " + clipRow.modelData.text
|
|
color: theme.text
|
|
font.pixelSize: 12
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
MouseArea {
|
|
id: clipRowMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
console.log("clip: row clicked " + clipRow.index);
|
|
root.clipPick(clipRow.modelData.hash);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "1-9 pick + paste // click to restore // esc closes"
|
|
color: theme.muted
|
|
font.pixelSize: 8
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
|
|
// key handler surface: the ListView is click-only
|
|
Item {
|
|
id: clipKeys
|
|
anchors.fill: parent
|
|
focus: true
|
|
Keys.onEscapePressed: root.clipOpen = false
|
|
Keys.onPressed: event => {
|
|
if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) {
|
|
const i = event.key - Qt.Key_1;
|
|
if (i < root.clipEntries.length)
|
|
root.clipPick(root.clipEntries[i].hash);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Keybind cheatsheet (SUPER+CTRL+H) =================
|
|
// Data comes from keybinds.json generated by Home Manager (single
|
|
// source of truth, kept in sync with the hl.bind()s in hyprland.nix).
|
|
PanelWindow {
|
|
id: helpPopup
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
left: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
margins.left: (modelData.width - helpPopup.implicitWidth) / 2
|
|
focusable: true
|
|
visible: root.helpOpen && modelData === Quickshell.screens[0]
|
|
implicitWidth: 640
|
|
implicitHeight: Math.max(240, Math.min(helpCol.implicitHeight + 100, modelData.height - 140))
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-help"
|
|
|
|
onVisibleChanged: if (visible)
|
|
helpKeys.forceActiveFocus()
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [helpPopup]
|
|
active: helpPopup.visible
|
|
onCleared: root.helpOpen = false
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors {
|
|
fill: parent
|
|
margins: 12
|
|
}
|
|
spacing: 8
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "SYSTEM // KEYBINDS"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: "esc / click away closes"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
Flickable {
|
|
id: helpFlick
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
contentHeight: helpCol.implicitHeight
|
|
|
|
ColumnLayout {
|
|
id: helpCol
|
|
width: helpFlick.width
|
|
spacing: 10
|
|
|
|
Repeater {
|
|
model: root.helpSections
|
|
|
|
ColumnLayout {
|
|
id: helpSection
|
|
required property var modelData
|
|
Layout.fillWidth: true
|
|
spacing: 2
|
|
|
|
Text {
|
|
text: helpSection.modelData.cat
|
|
color: theme.magenta
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Repeater {
|
|
model: helpSection.modelData.items
|
|
|
|
RowLayout {
|
|
id: helpRow
|
|
required property var modelData
|
|
Layout.fillWidth: true
|
|
Layout.leftMargin: 8
|
|
spacing: 10
|
|
|
|
Text {
|
|
Layout.preferredWidth: 230
|
|
text: helpRow.modelData.keys
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: helpRow.modelData.desc
|
|
color: theme.text
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: helpKeys
|
|
anchors.fill: parent
|
|
focus: true
|
|
Keys.onEscapePressed: root.helpOpen = false
|
|
}
|
|
}
|
|
|
|
// ================= Wallpaper picker (SUPER+CTRL+A) =================
|
|
// Thumbnail grid of ~/Pictures/wallpapers (hyprpaper set). Clicking a
|
|
// card applies it via `hyprctl hyprpaper` and persists the choice; the
|
|
// active entry keeps a magenta frame.
|
|
PanelWindow {
|
|
id: wallPopup
|
|
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
left: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
margins.left: (modelData.width - wallPopup.implicitWidth) / 2
|
|
focusable: true
|
|
visible: root.wallOpen && modelData === Quickshell.screens[0]
|
|
implicitWidth: 620
|
|
implicitHeight: 58 + wallPopup.wallListH
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-wall"
|
|
|
|
// Grid keyboard cursor: arrows move the highlight AND live-apply the
|
|
// wallpaper (thumbnails = instant preview). Cols must match the popup's
|
|
// wallCols at its 620px width (2 columns of 262 + 12 spacing).
|
|
readonly property int wallCols: Math.max(1, Math.floor((wallPopup.width - 20) / (wallCardW + 12)))
|
|
readonly property int wallCardW: 262
|
|
readonly property int wallCardH: 150
|
|
readonly property int wallRows: Math.max(1, Math.ceil(root.wallList.length / wallPopup.wallCols))
|
|
readonly property int wallListH: Math.min(wallPopup.wallRows * (wallCardH + 10), Math.max(160, modelData.height - 300))
|
|
|
|
onVisibleChanged: if (visible) {
|
|
root.wallIndex = -1;
|
|
root.wallSelPath = "";
|
|
root.wallRefresh();
|
|
wallKeys.forceActiveFocus();
|
|
}
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [wallPopup]
|
|
active: wallPopup.visible
|
|
onCleared: root.wallOpen = false
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors {
|
|
fill: parent
|
|
margins: 12
|
|
}
|
|
spacing: 8
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "WALLPAPERS"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: root.wallList.length + " in ~/Pictures/wallpapers"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
|
|
Rectangle {
|
|
width: paletteBtn.width + 12
|
|
height: 20
|
|
radius: 6
|
|
color: themeProc.generating ? theme.neon : theme.surface
|
|
border.color: themeProc.generating ? theme.ink : theme.neon
|
|
border.width: 1
|
|
visible: root.wallSelPath !== ""
|
|
|
|
Text {
|
|
id: paletteBtn
|
|
anchors.centerIn: parent
|
|
text: themeProc.generating ? "..." : "PALETTE"
|
|
color: themeProc.generating ? theme.ink : theme.neon
|
|
font.pixelSize: 8
|
|
font.bold: true
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (root.wallSelPath && !themeProc.generating) {
|
|
themeProc.generating = true;
|
|
themeProc.exec([root.qsThemeScript, root.wallSelPath]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
Flickable {
|
|
id: wallFlick
|
|
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: wallPopup.wallListH
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
contentHeight: wallGrid.implicitHeight + 4
|
|
contentY: {
|
|
// keep the keyboard cursor's row in view
|
|
if (root.wallIndex < 0)
|
|
return 0;
|
|
const row = Math.floor(root.wallIndex / wallPopup.wallCols);
|
|
const maxY = Math.max(0, wallGrid.implicitHeight + 4 - wallFlick.height);
|
|
return Math.max(0, Math.min(row * (wallPopup.wallCardH + 10), maxY));
|
|
}
|
|
|
|
Grid {
|
|
id: wallGrid
|
|
columns: wallPopup.wallCols
|
|
columnSpacing: 12
|
|
rowSpacing: 10
|
|
|
|
Repeater {
|
|
model: root.wallList
|
|
|
|
Rectangle {
|
|
id: wallCard
|
|
required property var modelData
|
|
|
|
width: wallPopup.wallCardW
|
|
height: wallPopup.wallCardH
|
|
radius: theme.radius
|
|
readonly property bool selected: wallCard.modelData.path === root.wallSelPath
|
|
color: wallCard.selected
|
|
? theme.surface
|
|
: (wallCard.modelData.active ? theme.surface : "#101322")
|
|
border.color: wallCard.selected
|
|
? theme.neon
|
|
: (wallCard.hovered
|
|
? theme.neon
|
|
: (wallCard.modelData.active ? theme.magenta : theme.line))
|
|
border.width: wallCard.selected || wallCard.modelData.active ? 2 : 1
|
|
property bool hovered: false
|
|
Behavior on border.color { ColorAnimation { duration: 120 } }
|
|
Behavior on color { ColorAnimation { duration: 120 } }
|
|
|
|
ColumnLayout {
|
|
anchors {
|
|
fill: parent
|
|
margins: 7
|
|
}
|
|
spacing: 6
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 108
|
|
radius: 7
|
|
clip: true
|
|
color: theme.surface
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
source: "file://" + wallCard.modelData.path
|
|
fillMode: Image.PreserveAspectCrop
|
|
smooth: true
|
|
cache: false
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: wallCard.modelData.name
|
|
color: wallCard.modelData.active ? theme.neon : theme.text
|
|
font.pixelSize: 10
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onEntered: {
|
|
wallCard.hovered = true;
|
|
root.wallSelPath = wallCard.modelData.path; // highlight follows the pointer
|
|
}
|
|
onExited: wallCard.hovered = false
|
|
onClicked: {
|
|
root.wallSelPath = wallCard.modelData.path;
|
|
root.wallPick(wallCard.modelData.path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "arrow keys preview live // click applies // esc closes"
|
|
color: theme.muted
|
|
font.pixelSize: 8
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: wallKeys
|
|
anchors.fill: parent
|
|
focus: true
|
|
Keys.onEscapePressed: root.wallOpen = false
|
|
Keys.onLeftPressed: root.wallMove(-1, 0)
|
|
Keys.onRightPressed: root.wallMove(1, 0)
|
|
Keys.onUpPressed: root.wallMove(0, -1)
|
|
Keys.onDownPressed: root.wallMove(0, 1)
|
|
Keys.onReturnPressed: root.wallOpen = false
|
|
Keys.onEnterPressed: root.wallOpen = false
|
|
}
|
|
}
|
|
|
|
// ================= Notification toasts (top-right) =================
|
|
// Live notifications render here for ~5s (mako->QuickShell migration);
|
|
// seen toasts fall through to the center history. Non-focusable layer
|
|
// surface that never takes keyboard focus.
|
|
PanelWindow {
|
|
id: toastLayer
|
|
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
right: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
margins.right: 12
|
|
focusable: false
|
|
visible: root.toastItems.length > 0 && modelData === Quickshell.screens[0]
|
|
implicitWidth: 380
|
|
implicitHeight: toastCol.implicitHeight
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-toasts"
|
|
|
|
Column {
|
|
id: toastCol
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Repeater {
|
|
model: root.toastItems
|
|
|
|
Rectangle {
|
|
id: toast
|
|
required property var modelData
|
|
|
|
width: toastCol.width
|
|
height: tInner.implicitHeight + 20
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: root.notifAccent(toast.modelData.urgency)
|
|
border.width: 1
|
|
|
|
ColumnLayout {
|
|
id: tInner
|
|
anchors {
|
|
fill: parent
|
|
margins: 10
|
|
}
|
|
spacing: 6
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.fillWidth: true
|
|
text: toast.modelData.appName !== "" ? toast.modelData.appName : "Unknown"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
text: "\uf00d" // close
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 10
|
|
color: theme.muted
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.notifDismiss(toast.modelData)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: toast.modelData.summary !== "" ? toast.modelData.summary : toast.modelData.body
|
|
color: theme.text
|
|
font.pixelSize: 12
|
|
font.bold: true
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: toast.modelData.body !== "" && toast.modelData.summary !== ""
|
|
text: toast.modelData.body
|
|
color: theme.muted
|
|
font.pixelSize: 11
|
|
textFormat: Text.PlainText
|
|
wrapMode: Text.Wrap
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 3
|
|
}
|
|
|
|
// screenshot preview (qs-shot posts image-path hint → image://qsimage/…)
|
|
Image {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 140
|
|
visible: toast.modelData.image !== ""
|
|
source: toast.modelData.image
|
|
fillMode: Image.PreserveAspectCrop
|
|
clip: true
|
|
smooth: true
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: 2
|
|
visible: toast.modelData.actions.length > 0
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: toast.modelData.actions
|
|
|
|
Rectangle {
|
|
id: toastAct
|
|
required property var modelData
|
|
implicitWidth: toastActText.implicitWidth + 14
|
|
implicitHeight: 20
|
|
radius: 5
|
|
color: "transparent"
|
|
border.color: theme.line
|
|
border.width: 1
|
|
|
|
Text {
|
|
id: toastActText
|
|
anchors.centerIn: parent
|
|
text: toastAct.modelData.text
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calNotifAction(toast.modelData, toastAct.modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
interval: Math.max(4, toast.modelData.expireTimeout) * 1000
|
|
running: true
|
|
repeat: false
|
|
onTriggered: root.toastRemove(toast.modelData)
|
|
}
|
|
|
|
// toast vanishes too when the notification is closed anywhere
|
|
Connections {
|
|
target: toast.modelData
|
|
function onClosed(reason) {
|
|
root.toastRemove(toast.modelData);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Notification center (bell / SUPER+CTRL+N) =================
|
|
PanelWindow {
|
|
id: notifPopup
|
|
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
right: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
margins.right: (modelData.width - bar.islandWidth) / 2 + 12
|
|
focusable: true
|
|
visible: root.notifOpen && modelData === Quickshell.screens[0]
|
|
implicitWidth: 390
|
|
implicitHeight: Math.min(notifCol.implicitHeight + 24, modelData.height - 160)
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-notifications"
|
|
|
|
onVisibleChanged: if (visible) {
|
|
root.notifUnseen = 0;
|
|
root.toastItems = []; // seen toasts now live in the center
|
|
notifKeys.forceActiveFocus();
|
|
}
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [notifPopup]
|
|
active: notifPopup.visible
|
|
onCleared: root.notifOpen = false
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: notifCol
|
|
anchors {
|
|
fill: parent
|
|
margins: 12
|
|
}
|
|
spacing: 8
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "NOTIFICATIONS"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: notifServer.trackedNotifications.values.length + " IN HISTORY"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
id: dndPill
|
|
implicitWidth: dndText.implicitWidth + 16
|
|
implicitHeight: 22
|
|
radius: 6
|
|
color: root.notifDnd ? theme.neon : "transparent"
|
|
border.color: root.notifDnd ? theme.neon : theme.line
|
|
border.width: 1
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: 150
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: dndText
|
|
anchors.centerIn: parent
|
|
text: root.notifDnd ? "DND ON" : "DND OFF"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
color: root.notifDnd ? theme.ink : theme.muted
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.notifDnd = !root.notifDnd
|
|
}
|
|
}
|
|
|
|
Item {
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: "CLEAR"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.notifClear()
|
|
}
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
id: notifList
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.min(notifList.contentHeight, 360)
|
|
visible: notifServer.trackedNotifications.values.length > 0
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
spacing: 6
|
|
// newest notifications at the top (array model is append-ordered,
|
|
// so show a reversed copy; no ListView.reverse in this env)
|
|
model: notifServer.trackedNotifications.values.slice().reverse()
|
|
|
|
delegate: Rectangle {
|
|
id: notifRow
|
|
required property var modelData
|
|
|
|
width: notifList.width
|
|
height: nCol.implicitHeight + 18
|
|
radius: 6
|
|
color: nRowMa.containsMouse ? theme.surface : "transparent"
|
|
|
|
// left urgency accent strip
|
|
Rectangle {
|
|
anchors {
|
|
left: parent.left
|
|
top: parent.top
|
|
bottom: parent.bottom
|
|
leftMargin: 4
|
|
topMargin: 6
|
|
bottomMargin: 6
|
|
}
|
|
width: 3
|
|
radius: 2
|
|
color: root.notifAccent(notifRow.modelData.urgency)
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: nCol
|
|
anchors {
|
|
fill: parent
|
|
margins: 9
|
|
}
|
|
spacing: 4
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
|
|
// app letter chip tinted by urgency
|
|
Rectangle {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
width: 18
|
|
height: 18
|
|
radius: 5
|
|
color: root.notifAccent(notifRow.modelData.urgency)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: (notifRow.modelData.appName !== "" ? notifRow.modelData.appName : "?")[0].toUpperCase()
|
|
color: theme.ink
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: true
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.fillWidth: true
|
|
text: notifRow.modelData.appName !== "" ? notifRow.modelData.appName : "Unknown"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
text: "\uf00d" // close
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 9
|
|
color: theme.muted
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -5
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.notifDismiss(notifRow.modelData)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: notifRow.modelData.summary !== ""
|
|
text: notifRow.modelData.summary
|
|
color: theme.text
|
|
font.pixelSize: 12
|
|
font.bold: true
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: notifRow.modelData.body !== ""
|
|
text: notifRow.modelData.body
|
|
color: theme.muted
|
|
font.pixelSize: 11
|
|
textFormat: Text.PlainText
|
|
wrapMode: Text.Wrap
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 4
|
|
}
|
|
|
|
// screenshot thumbnail (history copy of the live toast preview)
|
|
Image {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 120
|
|
visible: notifRow.modelData.image !== ""
|
|
source: notifRow.modelData.image
|
|
fillMode: Image.PreserveAspectCrop
|
|
clip: true
|
|
smooth: true
|
|
}
|
|
|
|
RowLayout {
|
|
id: nActs
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: 2
|
|
visible: notifRow.modelData.actions.length > 0
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: notifRow.modelData.actions
|
|
|
|
Rectangle {
|
|
id: nAct
|
|
required property var modelData
|
|
implicitWidth: nActText.implicitWidth + 14
|
|
implicitHeight: 20
|
|
radius: 5
|
|
color: "transparent"
|
|
border.color: theme.line
|
|
border.width: 1
|
|
|
|
Text {
|
|
id: nActText
|
|
anchors.centerIn: parent
|
|
text: nAct.modelData.text
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calNotifAction(notifRow.modelData, nAct.modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: nRowMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: notifServer.trackedNotifications.values.length === 0
|
|
text: "No notifications yet"
|
|
color: theme.muted
|
|
font.pixelSize: 12
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: "esc / click away closes // DND hides toasts, history kept"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: notifKeys
|
|
anchors.fill: parent
|
|
focus: true
|
|
Keys.onEscapePressed: root.notifOpen = false
|
|
}
|
|
}
|
|
|
|
// ================= Calendar + weather (clock click / SUPER+CTRL+C) =================
|
|
// Month grid + agenda fed by the qs-cal sync pipeline (Nextcloud CalDAV
|
|
// via vdirsyncer+khal -> ~/.cache/quickshell-cal/events.json; weather via
|
|
// Open-Meteo). YEARS read caches instantly via `qs-cal-sync read`.
|
|
PanelWindow {
|
|
id: calPopup
|
|
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
right: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 8
|
|
margins.right: (modelData.width - bar.islandWidth) / 2 + 12
|
|
focusable: true
|
|
visible: root.calOpen && modelData === Quickshell.screens[0]
|
|
implicitWidth: 660
|
|
implicitHeight: Math.min(calBody.implicitHeight + 24, modelData.height - 140)
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-cal"
|
|
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
root.calResetToday();
|
|
root.calRead();
|
|
calKeys.forceActiveFocus();
|
|
}
|
|
}
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [calPopup]
|
|
active: calPopup.visible
|
|
onCleared: root.calOpen = false
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radius
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: calBody
|
|
anchors {
|
|
fill: parent
|
|
margins: 12
|
|
}
|
|
spacing: 10
|
|
|
|
// ---------- header ----------
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "CALENDAR"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 12
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: root.calFetchedEvents !== "" ? "SYNC " + root.calFetchedEvents.slice(0, 16).replace("T", " ") : "SYNC —"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
}
|
|
|
|
// sync-health badge: "!" triangle when the last sync or a push
|
|
// (new event / edit / delete) failed or is stale.
|
|
Text {
|
|
visible: root.calSyncOk && root.calSyncOk.ok === false
|
|
text: "\uf071" // exclamation-triangle
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 11
|
|
color: theme.danger
|
|
Text {
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 16
|
|
anchors.right: parent.right
|
|
width: 190
|
|
horizontalAlignment: Text.AlignRight
|
|
visible: parent.visible && caleErrHover.containsMouse
|
|
text: "Last sync/push failed:\n" + (root.calSyncOk ? root.calSyncOk.error : "")
|
|
color: theme.danger
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
wrapMode: Text.Wrap
|
|
}
|
|
MouseArea {
|
|
id: caleErrHover
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calRefresh()
|
|
}
|
|
}
|
|
|
|
Item {
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: "\uf0d9" // angle-left
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: theme.muted
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calShiftMonth(-1)
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: Qt.formatDate(root.calView, "MMMM yyyy").toUpperCase()
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
}
|
|
|
|
Text {
|
|
text: "\uf0da" // angle-right
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: theme.muted
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calShiftMonth(1)
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "TODAY"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calResetToday()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.preferredWidth: 1
|
|
Layout.fillHeight: true
|
|
color: theme.line
|
|
}
|
|
|
|
Text {
|
|
text: "\uf055" // plus-circle
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: root.calAdding ? theme.neon : theme.muted
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
if (root.calAdding)
|
|
root.calAdding = false;
|
|
else
|
|
root.calStartAdd();
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "\uf01e" // refresh
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: theme.muted
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calRefresh()
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "\uf00d" // close
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: theme.muted
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calOpen = false
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// ---------- new-event form ----------
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
visible: root.calAdding
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: root.calEditMode ? "EDIT EVENT" : "NEW EVENT"
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: true
|
|
}
|
|
|
|
// calendar picker (Flow so long calendar names wrap onto a second row;
|
|
// each pill sizes itself from the name so the row stays readable)
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
Repeater {
|
|
model: root.calCals
|
|
Rectangle {
|
|
required property string modelData
|
|
readonly property bool active: modelData === root.calNewCal
|
|
width: Math.max(44, modelData.length * 6.2 + 30)
|
|
height: 24
|
|
radius: 6
|
|
color: active ? Qt.rgba(0, 229, 255, 0.12) : theme.surface
|
|
border.color: active ? theme.neon : theme.line
|
|
border.width: 1
|
|
Rectangle {
|
|
width: 8
|
|
height: 8
|
|
radius: 4
|
|
color: root.calCalColor(modelData)
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
Text {
|
|
id: calPickTxt
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 18
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 6
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData
|
|
color: active ? theme.neon : theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
elide: Text.ElideRight
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calNewCal = modelData
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// title
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
Text {
|
|
text: "TITLE"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 32
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
TextInput {
|
|
id: calNewTitleInput
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.pixelSize: 13
|
|
selectByMouse: true
|
|
text: root.calNewTitle
|
|
onTextChanged: root.calNewTitle = text
|
|
}
|
|
}
|
|
}
|
|
|
|
// date + all-day + reminder
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
Layout.preferredWidth: 130
|
|
height: 28
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
TextInput {
|
|
id: calNewDateInput
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
selectByMouse: true
|
|
text: root.calNewDate
|
|
onTextChanged: root.calNewDate = text
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 74
|
|
height: 28
|
|
radius: 6
|
|
color: root.calNewAllDay ? theme.violet : theme.surface
|
|
border.color: root.calNewAllDay ? theme.violet : theme.line
|
|
border.width: 1
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "ALL DAY"
|
|
color: root.calNewAllDay ? theme.ink : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calNewAllDay = !root.calNewAllDay
|
|
}
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
Text {
|
|
text: "REMIND"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Repeater {
|
|
model: [[0, "OFF"], [5, "5m"], [10, "10m"], [15, "15m"], [30, "30m"], [60, "1h"]]
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
readonly property int val: modelData[0]
|
|
implicitWidth: 26
|
|
height: 22
|
|
radius: 6
|
|
color: root.calNewRem === val ? theme.neon : theme.surface
|
|
border.color: root.calNewRem === val ? theme.neon : theme.line
|
|
border.width: 1
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: modelData[1]
|
|
color: root.calNewRem === val ? theme.ink : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: root.calNewRem === val
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calNewRem = val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// times + location (hidden for all-day)
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: !root.calNewAllDay
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "FROM"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.preferredWidth: 74
|
|
height: 28
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
TextInput {
|
|
id: calNewStartInput
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
selectByMouse: true
|
|
text: root.calNewStart
|
|
onTextChanged: root.calNewStart = text
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "TO"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.preferredWidth: 74
|
|
height: 28
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
TextInput {
|
|
id: calNewEndInput
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
selectByMouse: true
|
|
text: root.calNewEnd
|
|
onTextChanged: root.calNewEnd = text
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "LOC"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 28
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
TextInput {
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.pixelSize: 11
|
|
selectByMouse: true
|
|
text: root.calNewLoc
|
|
onTextChanged: root.calNewLoc = text
|
|
}
|
|
}
|
|
}
|
|
|
|
// repeat selector
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: "REPEAT"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Repeater {
|
|
model: [["NONE", "NONE"], ["DAILY", "DAILY"], ["WEEKLY", "WEEKLY"], ["MONTHLY", "MONTHLY"]]
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
readonly property string val: modelData[0]
|
|
implicitWidth: 66
|
|
height: 24
|
|
radius: 6
|
|
color: root.calNewRep === val ? theme.violet : theme.surface
|
|
border.color: root.calNewRep === val ? theme.violet : theme.line
|
|
border.width: 1
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: modelData[1]
|
|
color: root.calNewRep === val ? theme.ink : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
font.bold: root.calNewRep === val
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calNewRep = val
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: root.calEditMode && root.calEditEv && root.calEditEv.rep
|
|
text: "NOTE: repeat edits apply to the whole series"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
}
|
|
|
|
// save / cancel / (edit-mode) delete
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignRight
|
|
spacing: 10
|
|
|
|
Text {
|
|
text: "CANCEL"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
Layout.alignment: Qt.AlignVCenter
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calDiscardEdit()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
visible: root.calEditMode
|
|
implicitWidth: 70
|
|
height: 30
|
|
radius: 6
|
|
color: theme.danger
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "DELETE"
|
|
color: theme.ink
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calDeleteEv()
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 62
|
|
height: 30
|
|
radius: 6
|
|
color: theme.neon
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "SAVE"
|
|
color: theme.ink
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calCommitSave()
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
}
|
|
|
|
// ---------- month grid + agenda ----------
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 12
|
|
|
|
ColumnLayout {
|
|
Layout.preferredWidth: 322
|
|
Layout.alignment: Qt.AlignLeft
|
|
spacing: 4
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignLeft
|
|
spacing: 0
|
|
|
|
Repeater {
|
|
model: ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
|
|
delegate: Text {
|
|
required property string modelData
|
|
Layout.preferredWidth: calGrid.cellWidth
|
|
horizontalAlignment: Text.AlignHCenter
|
|
text: modelData
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
}
|
|
}
|
|
}
|
|
|
|
GridView {
|
|
id: calGrid
|
|
Layout.preferredWidth: 322
|
|
Layout.preferredHeight: 228
|
|
cellWidth: 46
|
|
cellHeight: 38
|
|
interactive: false
|
|
clip: false
|
|
focus: false
|
|
|
|
model: ScriptModel {
|
|
values: root.calBuildMonth()
|
|
}
|
|
|
|
delegate: Rectangle {
|
|
required property var modelData
|
|
readonly property var c: modelData
|
|
width: 46
|
|
height: 38
|
|
color: "transparent"
|
|
|
|
Rectangle {
|
|
width: 42
|
|
height: 36
|
|
anchors {
|
|
top: parent.top
|
|
topMargin: 1
|
|
left: parent.left
|
|
leftMargin: 2
|
|
}
|
|
radius: 6
|
|
color: c.m
|
|
? (c.today ? Qt.rgba(0, 229, 255, 0.12) : "transparent")
|
|
: "transparent"
|
|
border.color: c.sel ? theme.magenta : (c.today ? theme.neon : "transparent")
|
|
border.width: c.sel ? 2 : 1
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: c.n
|
|
color: c.sel ? theme.magenta : (c.today ? theme.neon : (c.m ? theme.text : theme.muted))
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 13
|
|
font.bold: c.sel || c.today
|
|
}
|
|
|
|
Rectangle {
|
|
visible: c.has
|
|
anchors {
|
|
bottom: parent.bottom
|
|
bottomMargin: 3
|
|
horizontalCenter: parent.horizontalCenter
|
|
}
|
|
width: 4
|
|
height: 4
|
|
radius: 2
|
|
color: theme.magenta
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
const d = new Date(c.ms);
|
|
d.setHours(0, 0, 0, 0);
|
|
root.calSel = d;
|
|
root.calView = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// small legend
|
|
RowLayout {
|
|
spacing: 10
|
|
RowLayout {
|
|
spacing: 5
|
|
Rectangle { width: 6; height: 6; radius: 3; color: theme.magenta }
|
|
Text { text: "has events"; color: theme.muted; font.family: theme.displayFont; font.pixelSize: 10 }
|
|
}
|
|
RowLayout {
|
|
spacing: 5
|
|
Rectangle { width: 10; height: 10; radius: 5; color: "transparent"; border.color: theme.neon; border.width: 1 }
|
|
Text { text: "today"; color: theme.muted; font.family: theme.displayFont; font.pixelSize: 10 }
|
|
}
|
|
RowLayout {
|
|
spacing: 5
|
|
Rectangle { width: 10; height: 10; radius: 5; color: "transparent"; border.color: theme.magenta; border.width: 2 }
|
|
Text { text: "selected"; color: theme.muted; font.family: theme.displayFont; font.pixelSize: 10 }
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.preferredWidth: 1
|
|
Layout.fillHeight: true
|
|
color: theme.line
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
spacing: 6
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Text {
|
|
text: "AGENDA · " + Qt.formatDate(root.calSel, "ddd d MMM").toUpperCase()
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
}
|
|
Text {
|
|
text: root.calSelEventsCount() + " EVENTS"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: root.calSelEvents()
|
|
|
|
MouseArea {
|
|
required property var modelData
|
|
readonly property var ev: modelData
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 30
|
|
cursorShape: Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
onClicked: root.calStartEdit(modelData)
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: 6
|
|
color: parent.containsMouse ? Qt.rgba(0, 229, 255, 0.08) : "transparent"
|
|
}
|
|
|
|
RowLayout {
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 6
|
|
rightMargin: 6
|
|
}
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: ev.allDay ? "ALL DAY" : ev.t
|
|
color: ev.allDay ? theme.violet : theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
font.bold: !ev.allDay
|
|
Layout.preferredWidth: 62
|
|
}
|
|
|
|
Rectangle {
|
|
width: 6
|
|
height: 6
|
|
radius: 3
|
|
Layout.alignment: Qt.AlignVCenter
|
|
color: root.calCalColor(ev.cal)
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
text: ev.title + (ev.rep ? " \uf2f1" : "")
|
|
color: theme.text
|
|
font.pixelSize: 13
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
visible: parent.parent.containsMouse
|
|
text: "\uf044" // edit
|
|
color: theme.muted
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 10
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: root.calSelEventsCount() === 0
|
|
text: root.calEvents.length === 0
|
|
? "No events in cache.\nLink a Nextcloud calendar in secrets.yaml\n(hp-laptop/nextcloud-cal-env) and restart qs-cal-sync."
|
|
: "Nothing scheduled."
|
|
color: theme.muted
|
|
font.pixelSize: 11
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
Item { Layout.fillHeight: true }
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
// ---------- weather ----------
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 12
|
|
|
|
ColumnLayout {
|
|
Layout.preferredWidth: 160
|
|
spacing: 2
|
|
|
|
RowLayout {
|
|
spacing: 10
|
|
Text {
|
|
text: root.calCurTemp()
|
|
color: theme.neon
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 30
|
|
font.bold: true
|
|
}
|
|
ColumnLayout {
|
|
spacing: 0
|
|
Text {
|
|
text: root.calWmoLabel(root.calCurCode())
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
}
|
|
Text {
|
|
text: root.calWeather && root.calWeather.daily && root.calWeather.daily.length > 0
|
|
? "H " + root.calTemp(root.calWeather.daily[0].tmax) + " L " + root.calTemp(root.calWeather.daily[0].tmin)
|
|
: "—"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
}
|
|
Text {
|
|
text: root.calWeather && root.calWeather.current
|
|
? "FEELS " + root.calTemp(root.calWeather.current.feels) + " HUM " + (root.calWeather.current.hum != null ? Math.round(root.calWeather.current.hum) + "%" : "—")
|
|
: ""
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
Text {
|
|
visible: root.calWeather && root.calWeather.current && root.calWeather.current.wind != null
|
|
text: "WIND " + Math.round(root.calWeather.current.wind) + " KM/H"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
spacing: 4
|
|
Text {
|
|
text: root.calLoc ? String(root.calLoc.name).toUpperCase() : "SET LOCATION"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
text: "\uf040" // pencil
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 13
|
|
color: theme.neon
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -6
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
root.calEditingLoc = !root.calEditingLoc;
|
|
if (root.calEditingLoc && locInput.text === "")
|
|
locInput.text = "Search city or lat,lon";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
|
|
// hourly trace for the rest of today (starts at current hour)
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 5
|
|
Repeater {
|
|
model: root.calHourlySlice()
|
|
|
|
ColumnLayout {
|
|
required property var modelData
|
|
readonly property var hr: modelData
|
|
width: 52
|
|
spacing: 1
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: hr.h
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: root.calTemp(hr.t)
|
|
color: root.calWmoColor(hr.code)
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
visible: hr.pop != null && hr.pop > 0
|
|
text: "☔" + Math.round(hr.pop) + "%"
|
|
color: hr.pop >= 60 ? theme.danger : (hr.pop >= 30 ? theme.violet : theme.muted)
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// next 7 days, two rows of 4 + 3
|
|
Repeater {
|
|
model: [0, 4]
|
|
RowLayout {
|
|
required property var modelData
|
|
readonly property int start: modelData
|
|
Layout.fillWidth: true
|
|
spacing: 5
|
|
Repeater {
|
|
model: root.calWeather && root.calWeather.daily
|
|
? root.calWeather.daily.slice(start + 1, start + 5) : []
|
|
ColumnLayout {
|
|
required property var modelData
|
|
readonly property var day: modelData
|
|
width: 96
|
|
spacing: 1
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: root.calWeekday(day.d).toUpperCase()
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: root.calWmoLabel(day.code)
|
|
color: root.calWmoColor(day.code)
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: root.calTemp(day.tmax) + "/" + root.calTemp(day.tmin)
|
|
color: theme.text
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
visible: day.pop != null && day.pop > 0
|
|
text: "☔" + Math.round(day.pop) + "%"
|
|
color: day.pop >= 60 ? theme.danger : (day.pop >= 30 ? theme.violet : theme.muted)
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- location editor ----------
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: root.calEditingLoc
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 30
|
|
radius: 6
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
|
|
TextInput {
|
|
id: locInput
|
|
anchors {
|
|
fill: parent
|
|
leftMargin: 8
|
|
rightMargin: 8
|
|
}
|
|
verticalAlignment: Text.AlignVCenter
|
|
color: theme.text
|
|
font.pixelSize: 13
|
|
selectByMouse: true
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
implicitWidth: 52
|
|
height: 30
|
|
radius: 6
|
|
color: theme.neon
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "SAVE"
|
|
color: theme.ink
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
font.bold: true
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
const q = locInput.text.trim();
|
|
if (q !== "" && q !== "Search city or lat,lon") {
|
|
root.calRun("setloc", q);
|
|
root.calEditingLoc = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: "CANCEL"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 10
|
|
Layout.alignment: Qt.AlignVCenter
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
anchors.margins: -4
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.calEditingLoc = false
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: -4
|
|
text: root.calWeather && root.calWeather.fetched
|
|
? "weather for " + root.calWeather.name.toUpperCase() + " · " + root.calWeather.fetched.slice(0, 16).replace("T", " ")
|
|
: "weather: click the pencil to set a location"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 8
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: calKeys
|
|
anchors.fill: parent
|
|
focus: true
|
|
Keys.onEscapePressed: root.calOpen = false
|
|
Keys.onLeftPressed: root.calSelMove(-1, 0)
|
|
Keys.onRightPressed: root.calSelMove(1, 0)
|
|
Keys.onUpPressed: root.calSelMove(0, -1)
|
|
Keys.onDownPressed: root.calSelMove(0, 1)
|
|
Keys.onReturnPressed: root.calResetToday()
|
|
Keys.onSpacePressed: root.calResetToday()
|
|
}
|
|
}
|
|
|
|
// ================= Workspace overview (SUPER+W) =================
|
|
// "Mission control" built in the shell rather than a compositor plugin:
|
|
// fullscreen scrim + one card per workspace. Keyboard-driven (arrows,
|
|
// enter, 1-9) or click to jump. QuickShell can't capture live frames, so
|
|
// windows are title lists — hyprspace is parked until its nixpkgs
|
|
// package builds again (AnimationManager.hpp moved upstream).
|
|
PanelWindow {
|
|
id: wsPopup
|
|
|
|
screen: modelData
|
|
anchors {
|
|
top: true
|
|
left: true
|
|
right: true
|
|
bottom: true
|
|
}
|
|
exclusiveZone: 0
|
|
margins.top: theme.barHeight + 16
|
|
margins.left: 24
|
|
margins.right: 24
|
|
margins.bottom: 24
|
|
focusable: true
|
|
visible: root.wsOpen && modelData === Quickshell.screens[0]
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-workspaces"
|
|
|
|
HyprlandFocusGrab {
|
|
windows: [wsPopup]
|
|
active: wsPopup.visible
|
|
}
|
|
|
|
onVisibleChanged: if (visible) {
|
|
root.wsIndex = root.focusedWorkspaceIndex();
|
|
wsKeys.forceActiveFocus();
|
|
}
|
|
|
|
// translucent veil behind the panel frame (dim the desktop)
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: "#070a15"
|
|
opacity: 0.72
|
|
radius: theme.radius + 8
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
|
|
Rectangle {
|
|
anchors {
|
|
fill: parent
|
|
margins: 4
|
|
}
|
|
radius: theme.radius + 4
|
|
color: "transparent"
|
|
border.color: theme.line
|
|
border.width: 1
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors {
|
|
fill: parent
|
|
margins: 28
|
|
}
|
|
spacing: 14
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 12
|
|
|
|
Text {
|
|
text: "WORKSPACES"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 13
|
|
font.bold: true
|
|
color: theme.neon
|
|
}
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignRight
|
|
text: "arrows + enter // click // 1-9 direct // esc"
|
|
color: theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
|
|
Grid {
|
|
id: wsGrid
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
columns: Math.max(1, Math.min(4, Math.round(wsPopup.width / 440)))
|
|
columnSpacing: 16
|
|
rowSpacing: 16
|
|
|
|
readonly property int cols: columns
|
|
readonly property real cardW: (wsGrid.width - (cols - 1) * columnSpacing) / cols
|
|
|
|
Repeater {
|
|
model: Hyprland.workspaces
|
|
|
|
Rectangle {
|
|
id: wsCard
|
|
required property var modelData
|
|
|
|
// Repeater does not inject `index` for ObjectModel models in
|
|
// this build, so selection is derived by object identity with
|
|
// the workspace currently at root.wsIndex.
|
|
readonly property bool selected: modelData === Hyprland.workspaces.values[root.wsIndex]
|
|
property var titles: root.wsTitles(modelData)
|
|
property int winCount: (modelData.windows == null ? 0 : modelData.windows)
|
|
|
|
width: wsGrid.cardW
|
|
height: 212
|
|
radius: theme.radius
|
|
color: selected
|
|
? theme.surface
|
|
: Qt.rgba(11 / 255, 16 / 255, 25 / 255, 0.72)
|
|
border.color: selected
|
|
? theme.magenta
|
|
: (modelData.focused
|
|
? theme.neon
|
|
: (modelData.active ? theme.line : "#1c1f2b66"))
|
|
border.width: selected ? 2 : 1
|
|
Behavior on border.color { ColorAnimation { duration: 120 } }
|
|
Behavior on color { ColorAnimation { duration: 120 } }
|
|
|
|
ColumnLayout {
|
|
anchors {
|
|
fill: parent
|
|
margins: 14
|
|
}
|
|
spacing: 7
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 10
|
|
|
|
Text {
|
|
text: modelData.id > 0 ? modelData.id : (modelData.name || modelData.id)
|
|
color: modelData.focused ? theme.neon : (selected ? theme.magenta : theme.muted)
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 20
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: modelData.id > 0 ? ("workspace " + modelData.id) : (modelData.name || "workspace")
|
|
color: selected ? theme.text : theme.muted
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignRight
|
|
text: (winCount > 0 ? wsCard.titles.length + "/" + winCount + " wins" : "empty")
|
|
color: winCount > 0 ? theme.muted : theme.magenta
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: theme.line
|
|
}
|
|
|
|
Repeater {
|
|
model: wsCard.titles
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
required property var modelData
|
|
text: " \uE0B7 " + modelData
|
|
color: modelData === undefined ? theme.text : theme.text
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: winCount > wsCard.titles.length
|
|
text: " \uf0a1 " + (winCount - wsCard.titles.length) + " more..."
|
|
color: theme.muted
|
|
font.pixelSize: 10
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onEntered: root.wsIndex = Hyprland.workspaces.values.indexOf(modelData)
|
|
onClicked: {
|
|
wsCard.modelData.activate()
|
|
root.wsOpen = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: wsKeys
|
|
anchors.fill: parent
|
|
focus: true
|
|
|
|
function wsJump(): void {
|
|
const vals = Hyprland.workspaces.values;
|
|
if (root.wsIndex >= 0 && root.wsIndex < vals.length) {
|
|
vals[root.wsIndex].activate();
|
|
root.wsOpen = false;
|
|
}
|
|
}
|
|
|
|
Keys.onEscapePressed: root.wsOpen = false
|
|
Keys.onReturnPressed: wsKeys.wsJump()
|
|
Keys.onSpacePressed: wsKeys.wsJump()
|
|
Keys.onLeftPressed: root.wsIndex = Math.max(0, root.wsIndex - 1)
|
|
Keys.onRightPressed: root.wsIndex = Math.min(Hyprland.workspaces.values.length - 1, root.wsIndex + 1)
|
|
Keys.onUpPressed: root.wsIndex = Math.max(0, root.wsIndex - wsGrid.columns)
|
|
Keys.onDownPressed: root.wsIndex = Math.min(Hyprland.workspaces.values.length - 1, root.wsIndex + wsGrid.columns)
|
|
Keys.onDigit1Pressed: wsKeyJump(1)
|
|
Keys.onDigit2Pressed: wsKeyJump(2)
|
|
Keys.onDigit3Pressed: wsKeyJump(3)
|
|
Keys.onDigit4Pressed: wsKeyJump(4)
|
|
Keys.onDigit5Pressed: wsKeyJump(5)
|
|
Keys.onDigit6Pressed: wsKeyJump(6)
|
|
Keys.onDigit7Pressed: wsKeyJump(7)
|
|
Keys.onDigit8Pressed: wsKeyJump(8)
|
|
Keys.onDigit9Pressed: wsKeyJump(9)
|
|
Keys.onDigit0Pressed: wsKeyJump(10)
|
|
|
|
function wsKeyJump(id: int): void {
|
|
const vals = Hyprland.workspaces.values;
|
|
for (let i = 0; i < vals.length; i++) {
|
|
if (vals[i].id === id) {
|
|
root.wsIndex = i;
|
|
vals[i].activate();
|
|
root.wsOpen = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ================= Shell services (single-instance) =================
|
|
// Everything below lives at ShellRoot scope so it is NOT duplicated per
|
|
// screen by the Variants above.
|
|
|
|
// OSD startup guard: skip the initial volume-binding evaluation so the
|
|
// overlay doesn't flash on login.
|
|
property bool osdArmed: false
|
|
Timer {
|
|
interval: 1500
|
|
running: true
|
|
onTriggered: root.osdArmed = true
|
|
}
|
|
|
|
// ---------- dynamic-island bar state ----------
|
|
// Event-driven reveal (workspace switch, notification, media change).
|
|
// Hover-driven reveal lives per-bar (bar.hovered).
|
|
property bool islandRevealed: false
|
|
Timer {
|
|
id: islandHideTimer
|
|
interval: 10000
|
|
onTriggered: root.islandRevealed = false
|
|
}
|
|
function islandRaise() {
|
|
root.islandRevealed = true;
|
|
islandHideTimer.restart();
|
|
}
|
|
|
|
// PipeWire sink tracked at shell scope to drive the volume OSD (the bar
|
|
// keeps its own per-screen copy for display).
|
|
readonly property var shellSink: Pipewire.defaultAudioSink
|
|
readonly property real shellVol: root.shellSink !== null && root.shellSink.audio !== null ? root.shellSink.audio.volume : 0
|
|
readonly property bool shellMuted: root.shellSink !== null && root.shellSink.audio !== null ? root.shellSink.audio.muted : false
|
|
|
|
PwObjectTracker {
|
|
objects: root.shellSink === null ? [] : [root.shellSink]
|
|
}
|
|
|
|
// ---------- screen brightness (brightnessctl, shell scope) ----------
|
|
// Single source of truth shared by the bar brightness chip, the OSD and
|
|
// the quick-settings slider. `brightnessctl g/m` in one go: "<cur> <max>".
|
|
// (named shellBright* to avoid shadowing the per-popup brightSet/brightQuery)
|
|
property int brightNow: -1 // cached percent from the last query
|
|
property bool brightSetBusy: false
|
|
|
|
Process {
|
|
id: shellBrightProbe
|
|
command: ["sh", "-c", "echo $(brightnessctl g) $(brightnessctl m)"]
|
|
running: false
|
|
stdout: SplitParser {
|
|
onRead: line => {
|
|
const parts = line.trim().split(/\s+/).map(Number);
|
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[1] > 0)
|
|
root.brightNow = Math.round((parts[0] / parts[1]) * 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
// throttled setter: skip while a previous request is still running
|
|
Process {
|
|
id: shellBrightSet
|
|
running: false
|
|
onExited: {
|
|
root.brightSetBusy = false;
|
|
shellBrightProbe.running = true; // refresh chiplets + OSD fraction
|
|
}
|
|
}
|
|
|
|
// keep the chip fresh when the panel keys / other tools change brightness
|
|
Timer {
|
|
interval: 30000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: shellBrightProbe.running = true
|
|
}
|
|
|
|
Connections {
|
|
target: root
|
|
function onShellVolChanged() {
|
|
osdWin.showVol();
|
|
}
|
|
function onShellMutedChanged() {
|
|
osdWin.showVol();
|
|
}
|
|
function onMediaPlayerChanged() {
|
|
if (!root.osdArmed) return;
|
|
root.islandRaise();
|
|
}
|
|
}
|
|
|
|
// MPRIS active player: first playing one, else the first registered.
|
|
readonly property MprisPlayer mediaPlayer: {
|
|
const ps = Mpris.players.values;
|
|
for (const p of ps) {
|
|
if (p.isPlaying)
|
|
return p;
|
|
}
|
|
return ps.length > 0 ? ps[0] : null;
|
|
}
|
|
|
|
// ---------- keybind cheatsheet ----------
|
|
// helpSections is loaded from keybinds.json, which Home Manager generates
|
|
// next to shell.qml; watchChanges keeps it fresh across rebuilds.
|
|
property bool helpOpen: false
|
|
property var helpSections: []
|
|
|
|
FileView {
|
|
id: helpFile
|
|
|
|
path: "file://" + Quickshell.shellDir + "/keybinds.json"
|
|
watchChanges: true
|
|
onLoaded: {
|
|
try {
|
|
root.helpSections = JSON.parse(text());
|
|
} catch (e) {
|
|
console.log("keybinds.json parse failed: " + e);
|
|
}
|
|
}
|
|
onFileChanged: helpFile.reload()
|
|
}
|
|
|
|
// ---------- system stats ------------------------------
|
|
// One probe (~250ms idle CPU sample) powers both the always-on chip and the
|
|
// popup. The chip polls slowly (15s) to stay battery-cheap; the popup bumps
|
|
// to a fast 3s refresh (with a real CPU%) while open.
|
|
Process {
|
|
id: statsProbe
|
|
running: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
root.stats = JSON.parse(this.text);
|
|
} catch (e) {
|
|
console.log("qs-stats parse failed: " + e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: statsTimer
|
|
interval: 15000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: statsProbe.exec(["sh", "-c", root.statsScript])
|
|
}
|
|
|
|
Timer {
|
|
id: statsPopupTimer
|
|
interval: 3000
|
|
running: false
|
|
repeat: true
|
|
onTriggered: statsProbe.exec(["sh", "-c", root.statsScript])
|
|
}
|
|
|
|
// Update command shown by the flake badge (copied to clipboard on click).
|
|
readonly property string flakeUpdateCmd: "cd ~/Nix-Vibe && nix flake update && sudo nixos-rebuild switch --flake .#x1carbon"
|
|
Process {
|
|
id: flakeCopyProc
|
|
}
|
|
|
|
function flakeCopy() {
|
|
flakeCopyProc.exec(["sh", "-c", "printf '%s' '" + root.flakeUpdateCmd + "' | wl-copy"]);
|
|
flakeCopyLabel.text = "\uf00c COPIED";
|
|
flakeResetTimer.start();
|
|
}
|
|
|
|
Timer {
|
|
id: flakeResetTimer
|
|
interval: 1800
|
|
onTriggered: flakeCopyLabel.text = "\uf0c1 COPY UPDATE CMD";
|
|
}
|
|
|
|
// ---------- clipboard history state (cliphist via the HM service) ----------
|
|
property bool clipOpen: false
|
|
property var clipEntries: []
|
|
|
|
Process {
|
|
id: clipList
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const out = [];
|
|
for (const l of text.split("\n")) {
|
|
if (l.length === 0)
|
|
continue;
|
|
const tab = l.indexOf("\t");
|
|
if (tab > 0)
|
|
out.push({ "hash": l.slice(0, tab), "text": l.slice(tab + 1) });
|
|
else
|
|
out.push({ "hash": l, "text": l });
|
|
}
|
|
root.clipEntries = out;
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: clipCopy
|
|
}
|
|
|
|
function clipPick(hash) {
|
|
console.log("clipPick: " + hash);
|
|
clipCopy.exec(["sh", "-c", "printf %s '" + hash + "' | cliphist decode | wl-copy"]);
|
|
root.clipOpen = false;
|
|
clipPasteTimer.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: clipPasteTimer
|
|
interval: 120
|
|
onTriggered: {
|
|
// window selector resolved at dispatch time: prefer the app that was
|
|
// focused when the picker OPENED (captured below), else whatever is
|
|
// active now
|
|
const wsel = clipPaste.targetAddr !== "" ? "address:" + clipPaste.targetAddr : "active";
|
|
console.log("clip paste -> " + wsel);
|
|
clipPaste.exec(["hyprctl", "dispatch", "hl.dsp.send_shortcut({mods = \"CTRL\", key = \"V\", window = \"" + wsel + "\"})"]);
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: clipPaste
|
|
property string targetAddr: ""
|
|
running: false
|
|
}
|
|
|
|
// authoritative active-window query at picker-open time (Hyprland's
|
|
// activeToplevel property proved unreliable from the IpcHandler scope)
|
|
Process {
|
|
id: clipAddrProbe
|
|
command: ["sh", "-c", "hyprctl activewindow | head -1 | cut -d' ' -f2 | sed 's/^/0x/'"]
|
|
running: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const a = text.trim();
|
|
if (a !== "")
|
|
clipPaste.targetAddr = a;
|
|
}
|
|
}
|
|
}
|
|
|
|
function clipRefresh() {
|
|
clipList.exec(["sh", "-c", "cliphist list | head -n 30"]);
|
|
}
|
|
|
|
Connections {
|
|
target: root
|
|
function onClipOpenChanged() {
|
|
if (root.clipOpen) {
|
|
clipAddrProbe.running = true;
|
|
root.clipRefresh();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- notification center (native Quickshell NotificationServer) ----------
|
|
// Replaces mako. One server per process (takes the freedesktop.org
|
|
// Notifications DBus name). Toasts show live, tracked notifications keep
|
|
// a history in the center; DND hides toasts but keeps tracking.
|
|
property bool notifOpen: false
|
|
property bool notifDnd: false
|
|
property int notifUnseen: 0
|
|
property var toastItems: [] // Notification refs currently rendered as toasts
|
|
|
|
NotificationServer {
|
|
id: notifServer
|
|
|
|
actionsSupported: true
|
|
bodyMarkupSupported: false // we render bodies as PlainText
|
|
keepOnReload: true
|
|
|
|
onNotification: notification => {
|
|
if (!notification.transient)
|
|
notification.tracked = true;
|
|
|
|
// after a reload these are re-emitted for history, not new toasts
|
|
if (notification.lastGeneration)
|
|
return;
|
|
|
|
if (!root.notifOpen)
|
|
root.notifUnseen++;
|
|
|
|
if (!root.notifOpen && !root.notifDnd)
|
|
root.toastPush(notification);
|
|
}
|
|
}
|
|
|
|
function toastPush(n) {
|
|
root.islandRaise();
|
|
root.toastItems = root.toastItems.concat(n);
|
|
// cap on-screen toasts like mako's max-visible=5
|
|
if (root.toastItems.length > 5)
|
|
root.toastItems = root.toastItems.slice(root.toastItems.length - 5);
|
|
}
|
|
|
|
function toastRemove(n) {
|
|
root.toastItems = root.toastItems.filter(t => t !== n);
|
|
}
|
|
|
|
function notifDismiss(n) {
|
|
n.dismiss(); // closes + tells the app, and drops it from history
|
|
}
|
|
|
|
function notifClear() {
|
|
const vals = notifServer.trackedNotifications.values;
|
|
const tod = [];
|
|
for (let i = 0; i < vals.length; i++)
|
|
tod.push(vals[i]);
|
|
tod.forEach(nt => nt.dismiss());
|
|
root.notifUnseen = 0;
|
|
}
|
|
|
|
function notifAccent(u) {
|
|
if (u === NotificationUrgency.Critical)
|
|
return theme.danger;
|
|
if (u === NotificationUrgency.Low)
|
|
return theme.muted;
|
|
return theme.neon;
|
|
}
|
|
|
|
// ---------- workspace overview (SUPER+W mission control) ----------
|
|
// HyprlandWorkspace only exposes a window *count* on this build, so window
|
|
// titles are gathered by filtering the root Hyprland.toplevels object model
|
|
// for the workspace id (max 6, then "+N more").
|
|
property bool wsOpen: false
|
|
property int wsIndex: 0 // keyboard/hover selection within Hyprland.workspaces
|
|
|
|
function focusedWorkspaceIndex(): int {
|
|
const tl = Hyprland.workspaces.values;
|
|
for (let i = 0; i < tl.length; i++) {
|
|
if (tl[i].focused)
|
|
return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function wsTitles(w) {
|
|
const out = [];
|
|
const tl = Hyprland.toplevels.values;
|
|
for (let i = 0; i < tl.length; i++) {
|
|
if (out.length >= 6)
|
|
break;
|
|
const t = tl[i];
|
|
if (t.workspace != null && t.workspace.id === w.id)
|
|
out.push(t.title);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------- calendar + weather popup (clock click / SUPER+CTRL+C) ----------
|
|
// Data comes from `qs-cal-sync` (home-manager quickshell-cal module):
|
|
// qs-cal-sync read -> merge caches to one JSON doc (instant)
|
|
// qs-cal-sync sync -> Nextcloud CalDAV sync + weather refresh
|
|
// qs-cal-sync setloc <q> -> geocode, rewrite loc.json, refresh + print
|
|
// Caches live in ~/.cache/quickshell-cal/events.json + weather.json + loc.json,
|
|
// refreshed every 20 min by a home-manager user timer under petere.
|
|
property bool calOpen: false
|
|
property bool calLoading: false
|
|
property bool calEditingLoc: false
|
|
property var calEvents: []
|
|
property var calWeather: null
|
|
property var calLoc: null
|
|
property string calFetchedEvents: ""
|
|
property var calView: new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
|
property var calSel: (() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; })()
|
|
readonly property string calScript: "/home/petere/.local/bin/qs-cal-sync"
|
|
|
|
// ---------- "new event" form ----------
|
|
property var calCals: []
|
|
property bool calAdding: false
|
|
property string calNewCal: ""
|
|
property string calNewTitle: ""
|
|
property string calNewDate: ""
|
|
property string calNewStart: "09:00"
|
|
property string calNewEnd: "10:00"
|
|
property string calNewLoc: ""
|
|
property bool calNewAllDay: false
|
|
property int calNewRem: 0
|
|
property string calNewRep: "NONE"
|
|
// Editing an existing event (instead of adding): the agenda row click fills
|
|
// the form and sets calEditEv with the target's {uid, file, cal} so the save
|
|
// path rewrites that .ics instead of creating a new one.
|
|
property bool calEditMode: false
|
|
property var calEditEv: null
|
|
// sync.json health -> "!" badge in the header when a sync/add push failed.
|
|
property var calSyncOk: null
|
|
|
|
// ---------- wallpaper picker (SUPER+CTRL+A) ----------
|
|
// Backend `qs-wall` (home-manager hyprland module) lists
|
|
// ~/Pictures/wallpapers, applies via `hyprctl hyprpaper`, and persists the
|
|
// choice to ~/.cache/quickshell/wallpaper.
|
|
property bool wallOpen: false
|
|
property var wallList: []
|
|
property int wallIndex: -1 // keyboard cursor; -1 = not yet positioned
|
|
property string wallSelPath: "" // highlighted card (path-compare; Repeater index is unavailable in this quickshell)
|
|
readonly property string wallScript: "/home/petere/.local/bin/qs-wall"
|
|
readonly property string qsThemeScript: "/home/petere/.local/bin/qs-theme"
|
|
|
|
// ---------- autostart app manager (gear quick-settings panel) ----------
|
|
// Backend `qs-apps` (home-manager quickshell-apps module) manages a
|
|
// user-toggleable list of apps to start at login. State is persisted in
|
|
// ~/.cache/quickshell/autostart.json; `qs-apps run` is called at login
|
|
// from the Hyprland start handler (hyprland.nix).
|
|
readonly property string appsScript: "/home/petere/.local/bin/qs-apps"
|
|
|
|
// ---------- bluetooth (BT status-bar button + popup) ----------
|
|
// Backend `qs-bt` (home-manager hyprland module) wraps bluetoothctl; state is
|
|
// re-fetched whenever the popup opens and after every action.
|
|
property bool btOpen: false
|
|
property bool btBusy: false
|
|
property var btPowered: null // null = no adapter fetched, true/false = powered
|
|
property bool btDiscovering: false
|
|
property string btAdapterName: ""
|
|
property string btError: ""
|
|
property var btDevices: []
|
|
readonly property int btConnected: root.btDevices.filter(d => d.connected).length
|
|
readonly property string btScript: "/home/petere/.local/bin/qs-bt"
|
|
|
|
// tablet-mode state mirrored by the hyprland-tablet daemon (status file):
|
|
// tablet = display rotated into tablet orientation, osk = wvkbd running.
|
|
property bool oskActive: false
|
|
property bool tabletActive: false
|
|
readonly property string oskScript: "hyprland-tablet"
|
|
|
|
function calStartAdd() {
|
|
const cals = root.calCals;
|
|
root.calNewCal = cals.indexOf("personal") >= 0 ? "personal" : (cals.length > 0 ? cals[0] : "");
|
|
root.calNewDate = root.calDayPath(root.calSel);
|
|
root.calNewTitle = "";
|
|
root.calNewStart = "09:00";
|
|
root.calNewEnd = "10:00";
|
|
root.calNewLoc = "";
|
|
root.calNewAllDay = false;
|
|
root.calNewRem = 0;
|
|
root.calNewRep = "NONE";
|
|
root.calEditMode = false;
|
|
root.calEditEv = null;
|
|
root.calAdding = true;
|
|
calNewTitleInput.forceActiveFocus();
|
|
}
|
|
|
|
// Repeat presets <-> RRULE for the form selector.
|
|
function calRepFromRrule(rrule) {
|
|
const s = String(rrule || "").toUpperCase();
|
|
if (s.indexOf("FREQ=DAILY") >= 0)
|
|
return "DAILY";
|
|
if (s.indexOf("FREQ=WEEKLY") >= 0)
|
|
return "WEEKLY";
|
|
if (s.indexOf("FREQ=MONTHLY") >= 0)
|
|
return "MONTHLY";
|
|
return "NONE";
|
|
}
|
|
|
|
function calStartEdit(ev) {
|
|
root.calNewCal = ev.cal;
|
|
root.calNewTitle = ev.title;
|
|
root.calNewDate = ev.d;
|
|
root.calNewStart = ev.t !== "" ? ev.t : "09:00";
|
|
root.calNewEnd = ev.e !== "" ? ev.e : "10:00";
|
|
root.calNewLoc = ev.loc || "";
|
|
root.calNewAllDay = ev.allDay === true;
|
|
root.calNewRem = ev.rem || 0;
|
|
root.calNewRep = root.calRepFromRrule(ev.rrule);
|
|
root.calEditMode = true;
|
|
root.calEditEv = ev;
|
|
root.calAdding = true;
|
|
calNewTitleInput.forceActiveFocus();
|
|
}
|
|
|
|
function calDiscardEdit() {
|
|
root.calAdding = false;
|
|
root.calEditMode = false;
|
|
root.calEditEv = null;
|
|
}
|
|
|
|
function calCommitSave() {
|
|
const title = root.calNewTitle.trim();
|
|
if (title === "" || root.calNewCal === "") {
|
|
console.log("cal save: missing title/calendar");
|
|
return;
|
|
}
|
|
const obj = {
|
|
cal: root.calNewCal,
|
|
title: title,
|
|
date: root.calNewDate,
|
|
start: root.calNewStart.trim() || "09:00",
|
|
end: root.calNewEnd.trim() || "10:00",
|
|
loc: root.calNewLoc.trim(),
|
|
allDay: root.calNewAllDay,
|
|
reminder: root.calNewRem,
|
|
repeat: root.calNewRep,
|
|
};
|
|
const mode = root.calEditMode && root.calEditEv ? "edit" : "add";
|
|
if (root.calEditEv && root.calEditEv.uid) {
|
|
obj.uid = root.calEditEv.uid;
|
|
obj.file = root.calEditEv.file;
|
|
}
|
|
root.calAdding = false;
|
|
root.calEditMode = false;
|
|
root.calEditEv = null;
|
|
calAddProc.exec([root.calScript, mode, JSON.stringify(obj)]);
|
|
}
|
|
|
|
function calDeleteEv() {
|
|
const ev = root.calEditEv;
|
|
if (!ev || !ev.uid)
|
|
return;
|
|
root.calAdding = false;
|
|
root.calEditMode = false;
|
|
calAddProc.exec([root.calScript, "delete", JSON.stringify({
|
|
cal: ev.cal,
|
|
file: ev.file,
|
|
uid: ev.uid,
|
|
})]);
|
|
root.calEditEv = null;
|
|
}
|
|
|
|
function calDayPath(d) {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return y + "-" + m + "-" + day;
|
|
}
|
|
|
|
function calResetToday() {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
root.calSel = d;
|
|
root.calView = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
}
|
|
|
|
function calShiftMonth(delta) {
|
|
root.calView = new Date(root.calView.getFullYear(), root.calView.getMonth() + delta, 1);
|
|
}
|
|
|
|
function calSelMove(dx, dy) {
|
|
let d = root.calSel != null ? new Date(root.calSel) : new Date();
|
|
d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + (7 * dy + dx));
|
|
root.calSel = d;
|
|
root.calView = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
}
|
|
|
|
function calBuildMonth() {
|
|
const view = root.calView;
|
|
const first = new Date(view.getFullYear(), view.getMonth(), 1);
|
|
const lead = (first.getDay() + 6) % 7; // Monday-first
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const sel = root.calSel != null ? new Date(root.calSel) : today;
|
|
sel.setHours(0, 0, 0, 0);
|
|
const start = new Date(first);
|
|
start.setDate(first.getDate() - lead);
|
|
const cells = [];
|
|
const ev = root.calEvents;
|
|
for (let i = 0; i < 42; i++) {
|
|
const d = new Date(start);
|
|
d.setDate(start.getDate() + i);
|
|
d.setHours(0, 0, 0, 0);
|
|
const path = root.calDayPath(d);
|
|
let has = false;
|
|
for (let j = 0; j < ev.length; j++) {
|
|
if (ev[j].d === path) {
|
|
has = true;
|
|
break;
|
|
}
|
|
}
|
|
cells.push({
|
|
ms: d.getTime(),
|
|
n: d.getDate(),
|
|
m: d.getMonth() === view.getMonth(),
|
|
today: d.getTime() === today.getTime(),
|
|
sel: d.getTime() === sel.getTime(),
|
|
has: has,
|
|
});
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
function calSelEvents() {
|
|
if (root.calSel == null || root.calEvents.length === 0)
|
|
return [];
|
|
const path = root.calDayPath(root.calSel);
|
|
const out = [];
|
|
for (let i = 0; i < root.calEvents.length; i++) {
|
|
if (out.length >= 6)
|
|
break;
|
|
if (root.calEvents[i].d === path)
|
|
out.push(root.calEvents[i]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function calSelEventsCount() {
|
|
if (root.calSel == null)
|
|
return 0;
|
|
const path = root.calDayPath(root.calSel);
|
|
let n = 0;
|
|
for (let i = 0; i < root.calEvents.length; i++)
|
|
if (root.calEvents[i].d === path)
|
|
n++;
|
|
return n;
|
|
}
|
|
|
|
function calCalColor(name) {
|
|
const palette = ["#ff2e97", "#00e5ff", "#7c4dff", "#9ece6a", "#e0af68", "#f7768e"];
|
|
let h = 0;
|
|
for (let i = 0; i < name.length; i++)
|
|
h = (h + name.charCodeAt(i)) % 9973;
|
|
return palette[h % palette.length];
|
|
}
|
|
|
|
function calTemp(v) {
|
|
if (v === null || v === undefined || isNaN(v))
|
|
return "--";
|
|
return Math.round(v) + "\u00b0";
|
|
}
|
|
|
|
function calCurTemp() {
|
|
if (root.calWeather && root.calWeather.current)
|
|
return root.calTemp(root.calWeather.current.t);
|
|
return "--\u00b0";
|
|
}
|
|
|
|
function calCurCode() {
|
|
if (root.calWeather && root.calWeather.current)
|
|
return root.calWeather.current.code;
|
|
return null;
|
|
}
|
|
|
|
// Hourly trace for the rest of today (up to 8 entries from the current hour).
|
|
function calHourlySlice() {
|
|
const w = root.calWeather;
|
|
if (!w || !w.hourly || w.hourly.length === 0)
|
|
return [];
|
|
const nowH = new Date().getHours();
|
|
const out = [];
|
|
for (let i = 0; i < w.hourly.length && out.length < 8; i++) {
|
|
const hNum = Number(String(w.hourly[i].h).split(":")[0]);
|
|
if (!isNaN(hNum) && hNum >= nowH)
|
|
out.push(w.hourly[i]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function calWeekday(isodate) {
|
|
const p = String(isodate).split("-");
|
|
if (p.length !== 3)
|
|
return "?";
|
|
const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
|
|
return ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"][d.getDay()];
|
|
}
|
|
|
|
function calWmoLabel(code) {
|
|
if (code === null || code === undefined || isNaN(code))
|
|
return "—";
|
|
if (code === 0)
|
|
return "CLEAR";
|
|
if (code === 1 || code === 2)
|
|
return "FEW CLOUDS";
|
|
if (code === 3)
|
|
return "OVERCAST";
|
|
if (code >= 45 && code <= 48)
|
|
return "FOG";
|
|
if (code >= 51 && code <= 57)
|
|
return "DRIZZLE";
|
|
if (code >= 61 && code <= 67)
|
|
return "RAIN";
|
|
if (code >= 71 && code <= 77)
|
|
return "SNOW";
|
|
if (code >= 80 && code <= 82)
|
|
return "SHOWERS";
|
|
if (code >= 85 && code <= 86)
|
|
return "SNOW SHOWERS";
|
|
if (code >= 95)
|
|
return "STORM";
|
|
return "—";
|
|
}
|
|
|
|
function calWmoColor(code) {
|
|
if (code === 0)
|
|
return theme.neon;
|
|
if (code >= 95)
|
|
return theme.danger;
|
|
if (code >= 45)
|
|
return theme.violet;
|
|
return theme.muted;
|
|
}
|
|
|
|
Process {
|
|
id: calProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.calConsume(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: calSyncProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.calRead()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: calAddProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.calRead()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: calSnoozeProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: (text) => {
|
|
// best-effort; the backend echoes {ok,key,until}
|
|
console.log("cal snooze: " + text.trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
// screenshot toast actions: shot-open opens the copied file, shot-copy
|
|
// re-pushes it to the clipboard (both identifiers carry the saved path)
|
|
readonly property string shotScript: "/home/petere/.local/bin/qs-shot"
|
|
Process { id: shotRunProc }
|
|
|
|
function calRun(mode, arg) {
|
|
root.calLoading = true;
|
|
calProc.exec(arg && arg !== "" ? [root.calScript, mode, arg] : [root.calScript, mode]);
|
|
}
|
|
|
|
function calRead() {
|
|
root.calRun("read");
|
|
}
|
|
|
|
function calRefresh() {
|
|
root.calLoading = true;
|
|
calSyncProc.exec([root.calScript, "sync"]);
|
|
}
|
|
|
|
function calConsume(text) {
|
|
root.calLoading = false;
|
|
try {
|
|
const o = JSON.parse(text.trim());
|
|
if (o.events !== undefined)
|
|
root.calEvents = o.events;
|
|
if (o.weather !== undefined && o.weather !== null)
|
|
root.calWeather = o.weather;
|
|
if (o.loc !== undefined && o.loc !== null)
|
|
root.calLoc = o.loc;
|
|
if (o.eventsFetched !== undefined)
|
|
root.calFetchedEvents = o.eventsFetched;
|
|
if (o.cals !== undefined && o.cals !== null)
|
|
root.calCals = o.cals.filter(c => c.indexOf("birthday") === -1);
|
|
if (o.sync !== undefined)
|
|
root.calSyncOk = o.sync;
|
|
} catch (e) {
|
|
console.log("cal parse error: " + e);
|
|
}
|
|
}
|
|
|
|
// ---------- calendar actions on notification buttons ----------
|
|
// The reminder notifier (qs-cal-sync remind) encodes payloads in the action
|
|
// *identifier*: cal-open:<date> opens the popup at that day; cal-snooze:<key>
|
|
// delays the contiguous reminder by 10 min. Anything else falls through to the
|
|
// normal D-Bus action invocation.
|
|
function calNotifAction(notification, action) {
|
|
const idn = String(action.identifier);
|
|
if (idn.indexOf("cal-open:") === 0) {
|
|
const parts = idn.slice("cal-open:".length).split("-");
|
|
if (parts.length === 3) {
|
|
const y = Number(parts[0]);
|
|
const m = Number(parts[1]) - 1;
|
|
const d = Number(parts[2]);
|
|
if (!isNaN(y) && !isNaN(m) && !isNaN(d)) {
|
|
const dd = new Date(y, m, d);
|
|
dd.setHours(0, 0, 0, 0);
|
|
root.calSel = dd;
|
|
root.calView = new Date(y, m, 1);
|
|
}
|
|
}
|
|
root.notifOpen = false;
|
|
root.calOpen = true;
|
|
notification.dismiss();
|
|
return;
|
|
}
|
|
if (idn.indexOf("cal-snooze:") === 0) {
|
|
const key = idn.slice("cal-snooze:".length);
|
|
const proc = calSnoozeProc;
|
|
root.notifDismiss(notification);
|
|
proc.exec([root.calScript, "snooze", key, "10"]);
|
|
return;
|
|
}
|
|
// screenshot toast actions: identifier payload is the saved PNG path
|
|
if (idn.indexOf("shot-open:") === 0) {
|
|
root.notifDismiss(notification);
|
|
shotRunProc.exec([root.shotScript, "open", idn.slice("shot-open:".length)]);
|
|
return;
|
|
}
|
|
if (idn.indexOf("shot-copy:") === 0) {
|
|
shotRunProc.exec([root.shotScript, "copy", idn.slice("shot-copy:".length)]);
|
|
return;
|
|
}
|
|
action.invoke();
|
|
}
|
|
|
|
// ---------- wallpaper picker plumbing ----------
|
|
Process {
|
|
id: wallListProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
root.wallList = JSON.parse(text.trim());
|
|
if (root.wallOpen && root.wallSelPath === "") {
|
|
const i = root.wallList.findIndex(e => e.active);
|
|
root.wallIndex = i >= 0 ? i : 0;
|
|
root.wallSelPath = root.wallList.length > 0
|
|
? root.wallList[root.wallIndex].path
|
|
: "";
|
|
}
|
|
} catch (e) {
|
|
console.log("wall list parse error: " + e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: wallSetProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.wallRefresh()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: themeProc
|
|
property bool generating: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: themeProc.generating = false
|
|
}
|
|
}
|
|
|
|
function wallRefresh() {
|
|
wallListProc.exec([root.wallScript, "list"]);
|
|
}
|
|
|
|
// Columns for the cursor math (matches the popup's wallCols at 620px width).
|
|
property int wallColsTotal: Math.max(1, Math.floor((620 - 20) / (262 + 12)))
|
|
|
|
function wallMove(dx, dy) {
|
|
const list = root.wallList;
|
|
if (list.length === 0)
|
|
return;
|
|
// linear cursor: left/right step one item, up/down step a full row
|
|
const cols = root.wallColsTotal;
|
|
const idx = Math.min(list.length - 1, root.wallIndex >= 0 ? root.wallIndex : 0);
|
|
const ni = Math.max(0, Math.min(list.length - 1, idx + (dx !== 0 ? dx : dy * cols)));
|
|
if (ni === idx)
|
|
return;
|
|
root.wallIndex = ni;
|
|
root.wallSelPath = list[ni].path;
|
|
root.wallPick(list[ni].path);
|
|
}
|
|
|
|
function wallPick(path) {
|
|
console.log("wall: set " + path);
|
|
wallSetProc.exec([root.wallScript, "set", path]);
|
|
}
|
|
|
|
// ---------- autostart app manager plumbing ----------
|
|
Process {
|
|
id: appsListProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const data = JSON.parse(text.trim());
|
|
root.appsEntries = data.apps || [];
|
|
} catch (e) {
|
|
console.log("apps list parse error: " + e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: appsSetProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.appsRefresh()
|
|
}
|
|
}
|
|
|
|
function appsRefresh() {
|
|
appsListProc.exec([root.appsScript, "list"]);
|
|
}
|
|
|
|
function appsToggle(name) {
|
|
appsSetProc.exec([root.appsScript, "toggle", name]);
|
|
}
|
|
|
|
function appsAdd(name, cmd) {
|
|
appsSetProc.exec([root.appsScript, "add", name, cmd]);
|
|
}
|
|
|
|
function appsRemove(name) {
|
|
appsSetProc.exec([root.appsScript, "remove", name]);
|
|
}
|
|
|
|
// ---------- battery charge limit plumbing ----------
|
|
Process {
|
|
id: chargeLimitStatusProc
|
|
running: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const s = text.trim().toLowerCase();
|
|
if (s === "on" || s === "off")
|
|
root.chargeLimitActive = s === "on";
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: chargeLimitSetProc
|
|
running: false
|
|
onExited: {
|
|
root.chargeLimitBusy = false;
|
|
root.chargeLimitRefresh();
|
|
}
|
|
}
|
|
|
|
function chargeLimitRefresh() {
|
|
chargeLimitStatusProc.exec([root.chargeLimitScript, "status"]);
|
|
}
|
|
|
|
function chargeLimitToggle() {
|
|
if (root.chargeLimitBusy)
|
|
return;
|
|
root.chargeLimitBusy = true;
|
|
chargeLimitSetProc.exec([
|
|
"sudo",
|
|
root.chargeLimitScript,
|
|
root.chargeLimitActive ? "off" : "on",
|
|
]);
|
|
}
|
|
|
|
// ---------- bluetooth plumbing ----------
|
|
Process {
|
|
id: btStatusProc
|
|
running: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const o = JSON.parse(text.trim());
|
|
root.btPowered = o.powered;
|
|
root.btDiscovering = o.discovering === true;
|
|
root.btAdapterName = o.alias !== "" ? o.alias : o.name;
|
|
root.btDevices = o.devices || [];
|
|
root.btError = "";
|
|
} catch (e) {
|
|
console.log("bt status parse error: " + e);
|
|
}
|
|
}
|
|
}
|
|
onExited: root.btBusy = false
|
|
}
|
|
|
|
// ---------- tablet-mode / on-screen keyboard plumbing ----------
|
|
Process {
|
|
id: oskStatusProc
|
|
running: false
|
|
command: [root.oskScript, "status"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const o = JSON.parse(text.trim());
|
|
root.tabletActive = o.tablet === true;
|
|
root.oskActive = o.osk === true;
|
|
} catch (e) {
|
|
console.log("osk status parse error: " + e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: oskToggleProc
|
|
running: false
|
|
command: [root.oskScript, "keyboard-toggle"]
|
|
onExited: oskStatusTimer.restart()
|
|
}
|
|
|
|
Timer {
|
|
id: oskStatusTimer
|
|
interval: 2000
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: oskStatusProc.running = true
|
|
}
|
|
|
|
function oskToggle() {
|
|
oskToggleProc.running = true;
|
|
}
|
|
|
|
Process {
|
|
id: btActionProc
|
|
running: false
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const o = JSON.parse(text.trim());
|
|
if (o && o.ok === false && o.error)
|
|
root.btError = o.error;
|
|
} catch (e) {
|
|
}
|
|
}
|
|
}
|
|
onExited: (code) => {
|
|
root.btBusy = false;
|
|
root.btDiscovering = false;
|
|
root.btRefresh();
|
|
}
|
|
}
|
|
|
|
function btRefresh() {
|
|
if (root.btBusy)
|
|
return;
|
|
root.btBusy = true;
|
|
btStatusProc.exec([root.btScript, "status"]);
|
|
}
|
|
|
|
function btRun(args) {
|
|
root.btBusy = true;
|
|
btActionProc.exec([root.btScript].concat(args));
|
|
}
|
|
|
|
function btScan() {
|
|
root.btBusy = true;
|
|
root.btDiscovering = true;
|
|
btActionProc.exec([root.btScript, "scan"]);
|
|
}
|
|
|
|
// row click: disconnect if connected, connect if paired, else pair
|
|
function btAct(mac) {
|
|
const d = root.btDevices.find(x => x.mac === mac);
|
|
if (!d)
|
|
return;
|
|
if (d.connected)
|
|
root.btRun(["disconnect", mac]);
|
|
else if (d.paired)
|
|
root.btRun(["connect", mac]);
|
|
else
|
|
root.btRun(["pair", mac]);
|
|
}
|
|
|
|
// ---------- IPC endpoints called from Hyprland keybinds ----------
|
|
// SUPER+CTRL+V -> qs ipc call clip toggle
|
|
// brightness keys -> qs ipc call osd bright (after brightnessctl set)
|
|
// NOTE: 0.3 renamed the IpcHandler name property from `topic` to `target`.
|
|
IpcHandler {
|
|
target: "clip"
|
|
function toggle(): void {
|
|
console.log("clip toggle, now " + !root.clipOpen);
|
|
if (!root.clipOpen)
|
|
clipAddrProbe.running = true;
|
|
root.clipOpen = !root.clipOpen;
|
|
}
|
|
|
|
// debug hook: qs ipc call clip pick <hash>
|
|
function pick(h: string): void {
|
|
root.clipPick(h);
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "help"
|
|
function toggle(): void {
|
|
root.helpOpen = !root.helpOpen;
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "osd"
|
|
// MUST NOT be named show()/call()/listen()/wait()/prop(): the qs CLI
|
|
// parses those tokens as its own subcommands and silently never calls
|
|
// the handler (qs ipc call osd show prints a metadata listing instead).
|
|
function bright(): void {
|
|
osdWin.showBright();
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "notif"
|
|
function toggle(): void {
|
|
root.notifOpen = !root.notifOpen;
|
|
if (root.notifOpen)
|
|
root.notifUnseen = 0;
|
|
}
|
|
|
|
// debug wrapper: qs ipc call notif dnd
|
|
function dnd(): void {
|
|
root.notifDnd = !root.notifDnd;
|
|
console.log("NOTIF dnd now " + root.notifDnd);
|
|
}
|
|
|
|
// debug wrapper: qs ipc call notif state
|
|
function state(): void {
|
|
console.log("NOTIF state: open=" + root.notifOpen
|
|
+ " dnd=" + root.notifDnd
|
|
+ " unseen=" + root.notifUnseen
|
|
+ " toasts=" + root.toastItems.length
|
|
+ " tracked=" + notifServer.trackedNotifications.values.length);
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "wall"
|
|
function toggle(): void {
|
|
root.wallOpen = !root.wallOpen;
|
|
}
|
|
|
|
// debug wrapper: qs ipc call wall move right|left|up|down (same code path
|
|
// as the arrow keys — handy for remote testing)
|
|
function move(dir: string): void {
|
|
if (dir === "left")
|
|
root.wallMove(-1, 0);
|
|
else if (dir === "right")
|
|
root.wallMove(1, 0);
|
|
else if (dir === "up")
|
|
root.wallMove(0, -1);
|
|
else if (dir === "down")
|
|
root.wallMove(0, 1);
|
|
}
|
|
|
|
// debug wrapper: qs ipc call wall state
|
|
function state(): void {
|
|
console.log("WALL state: open=" + root.wallOpen + " items=" + root.wallList.length);
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "ws"
|
|
function toggle(): void {
|
|
root.wsOpen = !root.wsOpen;
|
|
}
|
|
|
|
// debug wrapper: qs ipc call ws state
|
|
function state(): void {
|
|
console.log("WS state: open=" + root.wsOpen
|
|
+ " workspaces=" + Hyprland.workspaces.values.length
|
|
+ " toplevels=" + Hyprland.toplevels.values.length);
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "osk"
|
|
function toggle(): void {
|
|
root.oskToggle();
|
|
}
|
|
|
|
// debug wrapper: qs ipc call osk state
|
|
function state(): void {
|
|
console.log("OSK state: osk=" + root.oskActive + " tablet=" + root.tabletActive);
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "cal"
|
|
function toggle(): void {
|
|
root.calOpen = !root.calOpen;
|
|
}
|
|
|
|
function refresh(): void {
|
|
root.calRefresh();
|
|
}
|
|
|
|
// debug wrapper: qs ipc call cal state
|
|
function state(): void {
|
|
console.log("CAL state: open=" + root.calOpen
|
|
+ " events=" + root.calEvents.length
|
|
+ " weather=" + (root.calWeather ? root.calWeather.name : "none")
|
|
+ " loc=" + (root.calLoc ? root.calLoc.name : "none"));
|
|
}
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "bt"
|
|
function toggle(): void {
|
|
root.btOpen = !root.btOpen;
|
|
}
|
|
|
|
function refresh(): void {
|
|
root.btRefresh();
|
|
}
|
|
|
|
// debug wrappers: qs ipc call bt {power on, scan, pair <mac>, ...}
|
|
function power(val: string): void {
|
|
root.btRun(["power", val]);
|
|
}
|
|
function scan(): void {
|
|
root.btScan();
|
|
}
|
|
|
|
// debug wrapper: qs ipc call bt state
|
|
function state(): void {
|
|
console.log("BT state: open=" + root.btOpen
|
|
+ " powered=" + root.btPowered
|
|
+ " discovering=" + root.btDiscovering
|
|
+ " adapter=" + root.btAdapterName
|
|
+ " devices=" + root.btDevices.length
|
|
+ " connected=" + root.btConnected
|
|
+ " busy=" + root.btBusy
|
|
+ " error='" + root.btError + "'");
|
|
}
|
|
}
|
|
|
|
// ---------- volume / brightness OSD (right-edge neon pill) ----------
|
|
PanelWindow {
|
|
id: osdWin
|
|
|
|
// anchored to the first screen (Quickshell.primaryScreen does not exist
|
|
// in 0.3.1) — the strip covers the full right edge of that screen only
|
|
screen: Quickshell.screens.length > 0 ? Quickshell.screens[0] : null
|
|
anchors {
|
|
top: true
|
|
bottom: true
|
|
right: true
|
|
}
|
|
exclusiveZone: 0
|
|
implicitWidth: 64
|
|
color: "transparent"
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "qs-osd"
|
|
visible: false
|
|
|
|
// never steals input from whatever is under the strip
|
|
mask: Region {}
|
|
|
|
property string mode: "volume" // "volume" | "brightness"
|
|
property real frac: 0
|
|
readonly property int labelPct: Math.round(osdWin.frac * 100)
|
|
|
|
function showVol() {
|
|
if (!root.osdArmed)
|
|
return;
|
|
osdWin.mode = "volume";
|
|
osdWin.frac = root.shellMuted ? 0 : Math.min(1, root.shellVol);
|
|
osdWin.reveal();
|
|
}
|
|
|
|
function showBright() {
|
|
if (!root.osdArmed)
|
|
return;
|
|
osdWin.mode = "brightness";
|
|
if (root.brightNow >= 0)
|
|
osdWin.frac = Math.max(0, Math.min(1, root.brightNow / 100));
|
|
osdWin.reveal();
|
|
osdBrightProbe.running = true; // reconcile exact fraction
|
|
}
|
|
|
|
function reveal() {
|
|
osdWin.visible = true;
|
|
osdFade.stop();
|
|
osdContent.opacity = 1.0;
|
|
osdHideTimer.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: osdHideTimer
|
|
interval: 1400
|
|
onTriggered: osdFade.start()
|
|
}
|
|
|
|
NumberAnimation {
|
|
id: osdFade
|
|
target: osdContent
|
|
property: "opacity"
|
|
to: 0.0
|
|
duration: 250
|
|
onFinished: osdWin.visible = false
|
|
}
|
|
|
|
// `brightnessctl g` + `brightnessctl m` in one go: "<cur> <max>"
|
|
Process {
|
|
id: osdBrightProbe
|
|
command: ["sh", "-c", "echo $(brightnessctl g) $(brightnessctl m)"]
|
|
running: false
|
|
stdout: SplitParser {
|
|
onRead: line => {
|
|
const parts = line.trim().split(/\s+/).map(Number);
|
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[1] > 0)
|
|
osdWin.frac = Math.max(0, Math.min(1, parts[0] / parts[1]));
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: osdContent
|
|
|
|
anchors {
|
|
verticalCenter: parent.verticalCenter
|
|
right: parent.right
|
|
rightMargin: 10
|
|
}
|
|
width: 48
|
|
height: 190
|
|
radius: 14
|
|
color: theme.glassPanel
|
|
border.color: theme.neon
|
|
border.width: 1
|
|
|
|
ColumnLayout {
|
|
anchors {
|
|
fill: parent
|
|
margins: 8
|
|
}
|
|
spacing: 6
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: osdWin.mode === "volume"
|
|
? (root.shellMuted ? "\uf026" : "\uf028")
|
|
: "\uf185" // sun
|
|
font.family: theme.iconFont
|
|
font.pixelSize: 16
|
|
color: osdWin.mode === "brightness" ? theme.magenta : theme.neon
|
|
}
|
|
|
|
Item {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
|
|
// vertical track, fills from the bottom
|
|
Rectangle {
|
|
id: osdTrack
|
|
|
|
anchors.centerIn: parent
|
|
width: 8
|
|
height: parent.height
|
|
radius: 4
|
|
color: theme.surface
|
|
border.color: theme.line
|
|
border.width: 1
|
|
|
|
Rectangle {
|
|
anchors {
|
|
bottom: parent.bottom
|
|
horizontalCenter: parent.horizontalCenter
|
|
}
|
|
width: parent.width
|
|
height: Math.max(4, osdTrack.height * Math.max(0, Math.min(1, osdWin.frac)))
|
|
radius: 4
|
|
color: osdWin.mode === "brightness" ? theme.magenta : theme.neon
|
|
Behavior on height { NumberAnimation { duration: 100 } }
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
text: osdWin.labelPct + "%"
|
|
font.family: theme.displayFont
|
|
font.pixelSize: 9
|
|
color: theme.muted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- battery low warnings (notify-send -> QuickShell toasts) ----------
|
|
property bool battArmed20: true
|
|
property bool battArmed5: true
|
|
|
|
Process {
|
|
id: battProc
|
|
running: false
|
|
}
|
|
|
|
Timer {
|
|
id: battTimer
|
|
interval: 30000
|
|
repeat: true
|
|
running: true
|
|
triggeredOnStart: true
|
|
onTriggered: {
|
|
const dd = UPower.displayDevice;
|
|
const b = (dd.ready && dd.isLaptopBattery) ? dd : UPower.devices.values.find(d => d.isLaptopBattery);
|
|
if (b === undefined || b === null)
|
|
return;
|
|
if (b.state === UPowerDeviceState.Charging || b.state === UPowerDeviceState.Full) {
|
|
// re-arm on charge so the warnings fire again next discharge
|
|
root.battArmed20 = true;
|
|
root.battArmed5 = true;
|
|
return;
|
|
}
|
|
const pct = b.percentage; // 0..1 fraction
|
|
if (pct <= 0.05 && root.battArmed5) {
|
|
root.battArmed5 = false;
|
|
root.battArmed20 = false;
|
|
battProc.exec(["notify-send", "-u", "critical", "-a", "quickshell", "BATTERY CRITICAL", "SYSTEM // " + Math.round(pct * 100) + "% // CONNECT POWER"]);
|
|
} else if (pct <= 0.20 && root.battArmed20) {
|
|
root.battArmed20 = false;
|
|
battProc.exec(["notify-send", "-u", "normal", "-a", "quickshell", "BATTERY LOW", "SYSTEM // " + Math.round(pct * 100) + "% REMAINING"]);
|
|
}
|
|
}
|
|
}
|
|
}
|