Merge branch 'main' into sdfs

This commit is contained in:
2 * r + 2 * t 2026-03-30 02:10:22 +11:00
commit b57d596230
35 changed files with 1538 additions and 206 deletions

View file

@ -55,6 +55,7 @@ Elevation {
function onClicked(): void {
root.itemSelected(item.modelData);
root.active = item.modelData;
item.modelData.clicked();
root.expanded = false;
}

View file

@ -267,11 +267,13 @@ Singleton {
function serializeNotifs(): var {
return {
expire: notifs.expire,
fullscreen: notifs.fullscreen,
defaultExpireTimeout: notifs.defaultExpireTimeout,
clearThreshold: notifs.clearThreshold,
expandThreshold: notifs.expandThreshold,
actionOnClick: notifs.actionOnClick,
groupPreviewNum: notifs.groupPreviewNum
groupPreviewNum: notifs.groupPreviewNum,
openExpanded: notifs.openExpanded
};
}
@ -318,11 +320,30 @@ Singleton {
}
function serializeUtilities(): var {
const vpnProviders = [];
for (let i = 0; i < utilities.vpn.provider.length; i++) {
const p = utilities.vpn.provider[i];
const provider = {
displayName: p.displayName,
enabled: p.enabled,
iface: p.iface,
name: p.name
};
if (p.connectCmd && p.connectCmd.length > 0) {
provider.connectCmd = p.connectCmd;
}
if (p.disconnectCmd && p.disconnectCmd.length > 0) {
provider.disconnectCmd = p.disconnectCmd;
}
vpnProviders.push(provider);
}
return {
enabled: utilities.enabled,
maxToasts: utilities.maxToasts,
toasts: {
configLoaded: utilities.toasts.configLoaded,
fullscreen: utilities.toasts.fullscreen,
chargingChanged: utilities.toasts.chargingChanged,
gameModeChanged: utilities.toasts.gameModeChanged,
dndChanged: utilities.toasts.dndChanged,
@ -336,7 +357,7 @@ Singleton {
},
vpn: {
enabled: utilities.vpn.enabled,
provider: utilities.vpn.provider
provider: vpnProviders
},
quickToggles: utilities.quickToggles
};

View file

@ -2,6 +2,7 @@ import Quickshell.Io
JsonObject {
property bool expire: true
property string fullscreen: "on"
property int defaultExpireTimeout: 5000
property real clearThreshold: 0.3
property int expandThreshold: 20

View file

@ -46,6 +46,7 @@ JsonObject {
component Toasts: JsonObject {
property bool configLoaded: true
property string fullscreen: "off"
property bool chargingChanged: true
property bool gameModeChanged: true
property bool dndChanged: true
@ -61,6 +62,6 @@ JsonObject {
component Vpn: JsonObject {
property bool enabled: false
property list<var> provider: ["netbird"]
property list<var> provider: []
}
}

View file

@ -9,7 +9,7 @@ Scope {
id: root
property bool launcherInterrupted
readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false
readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false
// qmllint disable unresolved-type
CustomShortcut {

View file

@ -3,7 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import Quickshell
import Quickshell.Widgets
import Caelestia.Internal
import Caelestia.Services
import qs.components
import qs.services
@ -55,25 +55,29 @@ Item {
service: Audio.cava
}
Item {
id: content
VisualiserBars {
id: bars
anchors.fill: parent
anchors.margins: Config.border.thickness
anchors.leftMargin: Visibilities.bars.get(root.screen).exclusiveZone + Appearance.spacing.small * Config.background.visualiser.spacing
Side {
content: content
}
Side {
content: content
isRight: true
}
values: Audio.cava.values
primaryColor: Qt.alpha(Colours.palette.m3primary, 0.7)
secondaryColor: Qt.alpha(Colours.palette.m3inversePrimary, 0.7)
rounding: Appearance.rounding.small * Config.background.visualiser.rounding
spacing: Appearance.spacing.small * Config.background.visualiser.spacing
animationDuration: Appearance.anim.durations.normal
Behavior on anchors.leftMargin {
Anim {}
}
}
FrameAnimation {
running: root.opacity > 0 && !bars.settled
onTriggered: bars.advance(frameTime)
}
}
}
}
@ -85,69 +89,4 @@ Item {
Behavior on opacity {
Anim {}
}
component Side: Repeater {
id: side
required property Item content
property bool isRight
model: Config.services.visualiserBars
ClippingRectangle {
id: bar
required property int modelData
property real value: Math.max(0, Math.min(1, Audio.cava.values[side.isRight ? modelData : side.count - modelData - 1]))
clip: true
x: modelData * ((side.content.width * 0.4) / Config.services.visualiserBars) + (side.isRight ? side.content.width * 0.6 : 0)
implicitWidth: (side.content.width * 0.4) / Config.services.visualiserBars - Appearance.spacing.small * Config.background.visualiser.spacing
y: side.content.height - height
implicitHeight: bar.value * side.content.height * 0.4
color: "transparent"
topLeftRadius: Appearance.rounding.small * Config.background.visualiser.rounding
topRightRadius: Appearance.rounding.small * Config.background.visualiser.rounding
Rectangle {
topLeftRadius: parent.topLeftRadius
topRightRadius: parent.topRightRadius
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop {
position: 0
color: Qt.alpha(Colours.palette.m3primary, 0.7)
Behavior on color {
CAnim {}
}
}
GradientStop {
position: 1
color: Qt.alpha(Colours.palette.m3inversePrimary, 0.7)
Behavior on color {
CAnim {}
}
}
}
anchors.left: parent.left
anchors.right: parent.right
y: parent.height - height
implicitHeight: side.content.height * 0.4
}
Behavior on value {
Anim {
duration: Appearance.anim.durations.small
}
}
}
}
}

View file

@ -16,6 +16,7 @@ ColumnLayout {
required property ShellScreen screen
required property DrawerVisibilities visibilities
required property BarPopouts.Wrapper popouts
required property bool fullscreen
readonly property int vPadding: Appearance.padding.large
function closeTray(): void {
@ -128,6 +129,7 @@ ColumnLayout {
delegate: WrappedLoader {
sourceComponent: Workspaces {
screen: root.screen
fullscreen: root.fullscreen
}
}
}
@ -135,6 +137,7 @@ ColumnLayout {
roleValue: "activeWindow"
delegate: WrappedLoader {
Layout.fillWidth: true
visible: !root.fullscreen
sourceComponent: ActiveWindow {
bar: root
monitor: Brightness.getMonitorForScreen(root.screen)
@ -144,18 +147,21 @@ ColumnLayout {
DelegateChoice {
roleValue: "tray"
delegate: WrappedLoader {
visible: !root.fullscreen
sourceComponent: Tray {}
}
}
DelegateChoice {
roleValue: "clock"
delegate: WrappedLoader {
visible: !root.fullscreen
sourceComponent: Clock {}
}
}
DelegateChoice {
roleValue: "statusIcons"
delegate: WrappedLoader {
visible: !root.fullscreen
sourceComponent: StatusIcons {}
}
}

View file

@ -13,12 +13,13 @@ Item {
required property DrawerVisibilities visibilities
required property BarPopouts.Wrapper popouts
required property bool disabled
required property bool fullscreen
readonly property int clampedWidth: Math.max(Config.border.minThickness, implicitWidth)
readonly property int padding: Math.max(Appearance.padding.smaller, Config.border.thickness)
readonly property int contentWidth: Config.bar.sizes.innerWidth + padding * 2
readonly property int exclusiveZone: !disabled && (Config.bar.persistent || visibilities.bar) ? contentWidth : Config.border.thickness
readonly property bool shouldBeVisible: !disabled && (Config.bar.persistent || visibilities.bar || isHovered)
readonly property bool shouldBeVisible: !fullscreen && !disabled && (Config.bar.persistent || visibilities.bar || isHovered)
property bool isHovered
function closeTray(): void {
@ -33,8 +34,9 @@ Item {
(content.item as Bar)?.handleWheel(y, angleDelta);
}
visible: width > Config.border.thickness
implicitWidth: Config.border.thickness
clip: true
visible: width > 0
implicitWidth: fullscreen ? 0 : Config.border.thickness
states: State {
name: "visible"
@ -83,6 +85,7 @@ Item {
screen: root.screen
visibilities: root.visibilities
popouts: root.popouts // qmllint disable incompatible-type
fullscreen: root.fullscreen
}
}
}

View file

@ -10,6 +10,7 @@ StyledRect {
required property int activeWsId
required property Repeater workspaces
required property Item mask
required property bool fullscreen
readonly property int currentWsIdx: {
let i = activeWsId - 1;

View file

@ -12,6 +12,7 @@ StyledClippingRect {
id: root
required property ShellScreen screen
required property bool fullscreen
readonly property bool onSpecial: (Config.bar.workspaces.perMonitorWorkspaces ? Hypr.monitorFor(screen) : Hypr.focusedMonitor)?.lastIpcObject.specialWorkspace?.name !== ""
readonly property int activeWsId: Config.bar.workspaces.perMonitorWorkspaces ? (Hypr.monitorFor(screen).activeWorkspace?.id ?? 1) : Hypr.activeWsId
@ -36,6 +37,7 @@ StyledClippingRect {
anchors.fill: parent
scale: root.onSpecial ? 0.8 : 1
opacity: root.onSpecial ? 0.5 : 1
visible: !root.fullscreen
layer.enabled: root.blur > 0
layer.effect: MultiEffect {
@ -86,6 +88,7 @@ StyledClippingRect {
activeWsId: root.activeWsId
workspaces: workspaces
mask: layout
fullscreen: root.fullscreen
}
}

View file

@ -36,6 +36,12 @@ QtObject {
readonly property string icon: "task_alt"
readonly property string component: "taskbar/TaskbarPane.qml"
},
QtObject {
readonly property string id: "notifications"
readonly property string label: "notifications"
readonly property string icon: "notifications"
readonly property string component: "notifications/NotificationsPane.qml"
},
QtObject {
readonly property string id: "launcher"
readonly property string label: "launcher"
@ -60,7 +66,7 @@ QtObject {
return result;
}
function getByIndex(index: int): QtObject {
function getByIndex(index: int): var {
if (index >= 0 && index < panes.length) {
return panes[index];
}
@ -76,12 +82,12 @@ QtObject {
return -1;
}
function getByLabel(label: string): QtObject {
function getByLabel(label: string): var {
const index = getIndexByLabel(label);
return getByIndex(index);
}
function getById(id: string): QtObject {
function getById(id: string): var {
for (let i = 0; i < panes.length; i++) {
if (panes[i].id === id) {
return panes[i];

View file

@ -5,6 +5,7 @@ import "network"
import "audio"
import "appearance"
import "taskbar"
import "notifications"
import "launcher"
import "dashboard"
import QtQuick

View file

@ -109,10 +109,13 @@ DeviceDetails {
inactiveOnColour: Colours.palette.m3onSecondaryContainer
onClicked: {
const provider = Config.utilities.vpn.provider[root.vpnProvider.index];
editVpnDialog.editIndex = root.vpnProvider.index;
editVpnDialog.providerName = root.vpnProvider.name;
editVpnDialog.displayName = root.vpnProvider.displayName;
editVpnDialog.interfaceName = root.vpnProvider.interface;
editVpnDialog.connectCmd = (provider && provider.connectCmd) ? provider.connectCmd.join(" ") : "";
editVpnDialog.disconnectCmd = (provider && provider.disconnectCmd) ? provider.disconnectCmd.join(" ") : "";
editVpnDialog.open();
}
}
@ -136,6 +139,30 @@ DeviceDetails {
}
}
}
TextButton {
Layout.fillWidth: true
Layout.topMargin: Appearance.spacing.normal
visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl !== ""
text: qsTr("Open Login Page")
inactiveColour: Colours.palette.m3tertiaryContainer
inactiveOnColour: Colours.palette.m3onTertiaryContainer
onClicked: {
Qt.openUrlExternally(VPN.status.authUrl);
}
}
StyledText {
Layout.fillWidth: true
Layout.topMargin: Appearance.spacing.normal
visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl === ""
text: qsTr("Click 'Connect' to generate authentication URL")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
},
@ -176,11 +203,30 @@ DeviceDetails {
return qsTr("Disabled");
if (VPN.connecting)
return qsTr("Connecting...");
if (VPN.connected)
switch (VPN.status.state) {
case "connected":
return qsTr("Connected");
return qsTr("Enabled (Not connected)");
case "disconnected":
return qsTr("Disconnected");
case "connecting":
return qsTr("Connecting...");
case "needs-auth":
return qsTr("Authentication required");
case "error":
return qsTr("Error");
default:
return qsTr("Unknown");
}
}
}
PropertyRow {
visible: VPN.status.reason !== ""
showTopMargin: true
label: qsTr("Details")
value: VPN.status.reason
}
PropertyRow {
showTopMargin: true
@ -200,6 +246,8 @@ DeviceDetails {
property string providerName: ""
property string displayName: ""
property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function closeWithAnimation(): void {
close();
@ -349,6 +397,82 @@ DeviceDetails {
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: editVpnDialog.connectCmd.length > 0
StyledText {
text: qsTr("Connect Command")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: connectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: connectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: connectCmdFieldEdit
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: editVpnDialog.connectCmd
onTextChanged: editVpnDialog.connectCmd = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: editVpnDialog.disconnectCmd.length > 0
StyledText {
text: qsTr("Disconnect Command")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: disconnectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: disconnectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: disconnectCmdFieldEdit
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: editVpnDialog.disconnectCmd
onTextChanged: editVpnDialog.disconnectCmd = text
}
}
}
RowLayout {
Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true
@ -376,12 +500,23 @@ DeviceDetails {
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i === editVpnDialog.editIndex) {
providers.push({
name: editVpnDialog.providerName,
const hasCommands = editVpnDialog.connectCmd.length > 0 && editVpnDialog.disconnectCmd.length > 0;
const newProvider = {
displayName: editVpnDialog.displayName || editVpnDialog.interfaceName,
interface: editVpnDialog.interfaceName,
enabled: wasEnabled
});
enabled: wasEnabled,
iface: editVpnDialog.interfaceName,
name: editVpnDialog.providerName,
connectCmd: hasCommands ? editVpnDialog.connectCmd.split(" ").filter(s => s.length > 0) : undefined,
disconnectCmd: hasCommands ? editVpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0) : undefined
};
// Remove undefined properties
if (!hasCommands) {
delete newProvider.connectCmd;
delete newProvider.disconnectCmd;
}
providers.push(newProvider);
} else {
providers.push(Config.utilities.vpn.provider[i]);
}

View file

@ -159,15 +159,38 @@ ColumnLayout {
StyledText {
Layout.fillWidth: true
text: {
if (modelData.enabled && VPN.connected)
return qsTr("Connected");
if (modelData.enabled && VPN.connecting)
return qsTr("Connecting...");
if (modelData.enabled)
return qsTr("Enabled");
if (!modelData.enabled)
return qsTr("Disabled");
if (VPN.connecting)
return qsTr("Connecting...");
switch (VPN.status.state) {
case "connected":
return qsTr("Connected");
case "disconnected":
return qsTr("Enabled");
case "connecting":
return qsTr("Connecting...");
case "needs-auth":
return qsTr("Auth required");
case "error":
return qsTr("Error");
default:
return qsTr("Enabled");
}
}
color: {
if (!modelData.enabled)
return Colours.palette.m3outline;
if (VPN.status.state === "connected")
return Colours.palette.m3primary;
if (VPN.status.state === "error")
return Colours.palette.m3error;
if (VPN.status.state === "needs-auth")
return Colours.palette.m3tertiary;
return Colours.palette.m3onSurface;
}
color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
font.weight: modelData.enabled && VPN.connected ? 500 : 400
elide: Text.ElideRight
@ -271,6 +294,8 @@ ColumnLayout {
property string providerName: ""
property string displayName: ""
property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function showProviderSelection(): void {
currentState = "selection";
@ -286,6 +311,8 @@ ColumnLayout {
providerName = providerType;
displayName = defaultDisplayName;
interfaceName = "";
connectCmd = "";
disconnectCmd = "";
if (currentState === "selection") {
transitionToForm.start();
@ -304,6 +331,8 @@ ColumnLayout {
providerName = isObject ? (provider.name || "custom") : String(provider);
displayName = isObject ? (provider.displayName || providerName) : providerName;
interfaceName = isObject ? (provider.interface || "") : "";
connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : "";
disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : "";
currentState = "form";
open();
@ -491,7 +520,7 @@ ColumnLayout {
TextButton {
Layout.fillWidth: true
text: qsTr("WireGuard (Custom)")
text: qsTr("WireGuard")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
@ -499,6 +528,16 @@ ColumnLayout {
}
}
TextButton {
Layout.fillWidth: true
text: qsTr("Custom")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
vpnDialog.showAddForm("custom", "Custom VPN");
}
}
TextButton {
Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true
@ -604,6 +643,82 @@ ColumnLayout {
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom")
StyledText {
text: qsTr("Connect Command (e.g., wg-quick up wg0)")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: connectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: connectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: connectCmdField
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.connectCmd
onTextChanged: vpnDialog.connectCmd = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom")
StyledText {
text: qsTr("Disconnect Command (e.g., wg-quick down wg0)")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: disconnectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: disconnectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: disconnectCmdField
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.disconnectCmd
onTextChanged: vpnDialog.disconnectCmd = text
}
}
}
RowLayout {
Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true
@ -620,19 +735,37 @@ ColumnLayout {
TextButton {
Layout.fillWidth: true
text: qsTr("Save")
enabled: vpnDialog.interfaceName.length > 0
enabled: {
const hasCommands = vpnDialog.connectCmd.length > 0 || vpnDialog.disconnectCmd.length > 0;
if (hasCommands) {
return vpnDialog.interfaceName.length > 0 && vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
}
return vpnDialog.interfaceName.length > 0;
}
inactiveColour: Colours.palette.m3primaryContainer
inactiveOnColour: Colours.palette.m3onPrimaryContainer
onClicked: {
const providers = [];
const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
const newProvider = {
name: vpnDialog.providerName,
displayName: vpnDialog.displayName || vpnDialog.interfaceName,
interface: vpnDialog.interfaceName
enabled: false,
interface: vpnDialog.interfaceName,
name: vpnDialog.providerName
};
if (hasCommands) {
newProvider.connectCmd = vpnDialog.connectCmd.split(" ").filter(s => s.length > 0);
newProvider.disconnectCmd = vpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0);
}
if (vpnDialog.editIndex >= 0) {
const oldProvider = Config.utilities.vpn.provider[vpnDialog.editIndex];
if (typeof oldProvider === "object" && oldProvider.enabled !== undefined) {
newProvider.enabled = oldProvider.enabled;
}
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i === vpnDialog.editIndex) {
providers.push(newProvider);

View file

@ -0,0 +1,427 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import ".."
import "../components"
import qs.components
import qs.components.controls
import qs.components.effects
import qs.components.containers
import qs.services
import qs.config
Item {
id: root
required property Session session
property bool notificationsExpire: Config.notifs.expire ?? true
property string notificationsFullscreen: Config.notifs.fullscreen ?? "on"
property bool notificationsOpenExpanded: Config.notifs.openExpanded ?? false
property int notificationsDefaultExpireTimeout: Config.notifs.defaultExpireTimeout ?? 5000
property int notificationsGroupPreviewNum: Config.notifs.groupPreviewNum ?? 3
property int maxToasts: Config.utilities.maxToasts ?? 4
property string toastsFullscreen: Config.utilities.toasts.fullscreen ?? "off"
property bool chargingChanged: Config.utilities.toasts.chargingChanged ?? true
property bool gameModeChanged: Config.utilities.toasts.gameModeChanged ?? true
property bool dndChanged: Config.utilities.toasts.dndChanged ?? true
property bool audioOutputChanged: Config.utilities.toasts.audioOutputChanged ?? true
property bool audioInputChanged: Config.utilities.toasts.audioInputChanged ?? true
property bool capsLockChanged: Config.utilities.toasts.capsLockChanged ?? true
property bool numLockChanged: Config.utilities.toasts.numLockChanged ?? true
property bool kbLayoutChanged: Config.utilities.toasts.kbLayoutChanged ?? true
property bool vpnChanged: Config.utilities.toasts.vpnChanged ?? true
property bool nowPlaying: Config.utilities.toasts.nowPlaying ?? false
function saveConfig(): void {
Config.notifs.expire = root.notificationsExpire;
Config.notifs.fullscreen = root.notificationsFullscreen;
Config.notifs.openExpanded = root.notificationsOpenExpanded;
Config.notifs.defaultExpireTimeout = root.notificationsDefaultExpireTimeout;
Config.notifs.groupPreviewNum = root.notificationsGroupPreviewNum;
Config.utilities.maxToasts = root.maxToasts;
Config.utilities.toasts.fullscreen = root.toastsFullscreen;
Config.utilities.toasts.chargingChanged = root.chargingChanged;
Config.utilities.toasts.gameModeChanged = root.gameModeChanged;
Config.utilities.toasts.dndChanged = root.dndChanged;
Config.utilities.toasts.audioOutputChanged = root.audioOutputChanged;
Config.utilities.toasts.audioInputChanged = root.audioInputChanged;
Config.utilities.toasts.capsLockChanged = root.capsLockChanged;
Config.utilities.toasts.numLockChanged = root.numLockChanged;
Config.utilities.toasts.kbLayoutChanged = root.kbLayoutChanged;
Config.utilities.toasts.vpnChanged = root.vpnChanged;
Config.utilities.toasts.nowPlaying = root.nowPlaying;
Config.save();
}
anchors.fill: parent
ClippingRectangle {
id: notificationsClippingRect
anchors.fill: parent
anchors.margins: Appearance.padding.normal
anchors.leftMargin: 0
anchors.rightMargin: Appearance.padding.normal
color: "transparent"
radius: notificationsBorder.innerRadius
Loader {
id: notificationsLoader
anchors.fill: parent
anchors.margins: Appearance.padding.large + Appearance.padding.normal
anchors.leftMargin: Appearance.padding.large
anchors.rightMargin: Appearance.padding.large
sourceComponent: notificationsContentComponent
}
}
InnerBorder {
id: notificationsBorder
leftThickness: 0
rightThickness: Appearance.padding.normal
}
Component {
id: notificationsContentComponent
StyledFlickable {
id: notificationsFlickable
flickableDirection: Flickable.VerticalFlick
contentHeight: notificationsLayout.height
StyledScrollBar.vertical: StyledScrollBar {
flickable: notificationsFlickable
}
RowLayout {
id: notificationsLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: Appearance.spacing.normal
ColumnLayout {
Layout.fillWidth: true
Layout.maximumWidth: 500
Layout.alignment: Qt.AlignTop
spacing: Appearance.spacing.normal
SectionContainer {
Layout.fillWidth: true
alignTop: true
StyledText {
text: qsTr("Notifications")
font.pointSize: Appearance.font.size.normal
}
SplitButtonRow {
id: notificationsFullscreenSelector
function syncActiveItem(): void {
active = root.notificationsFullscreen === "off" ? notificationsFullscreenOffItem : notificationsFullscreenOnItem;
}
label: qsTr("Show in fullscreen")
menuItems: [notificationsFullscreenOffItem, notificationsFullscreenOnItem]
Component.onCompleted: syncActiveItem()
Connections {
function onNotificationsFullscreenChanged(): void {
notificationsFullscreenSelector.syncActiveItem();
}
target: root
}
MenuItem {
id: notificationsFullscreenOffItem
text: qsTr("Off")
icon: "notifications_off"
activeText: qsTr("Off")
onClicked: {
root.notificationsFullscreen = "off";
root.saveConfig();
}
}
MenuItem {
id: notificationsFullscreenOnItem
text: qsTr("On")
icon: "notifications"
activeText: qsTr("On")
onClicked: {
root.notificationsFullscreen = "on";
root.saveConfig();
}
}
}
SwitchRow {
label: qsTr("Expire automatically")
checked: root.notificationsExpire
onToggled: checked => {
root.notificationsExpire = checked;
root.saveConfig();
}
}
SwitchRow {
label: qsTr("Open expanded")
checked: root.notificationsOpenExpanded
onToggled: checked => {
root.notificationsOpenExpanded = checked;
root.saveConfig();
}
}
SpinBoxRow {
label: qsTr("Default timeout")
value: root.notificationsDefaultExpireTimeout
min: 1000
max: 60000
step: 500
onValueModified: value => {
root.notificationsDefaultExpireTimeout = value;
root.saveConfig();
}
}
SpinBoxRow {
label: qsTr("Group preview count")
value: root.notificationsGroupPreviewNum
min: 1
max: 10
step: 1
onValueModified: value => {
root.notificationsGroupPreviewNum = value;
root.saveConfig();
}
}
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: Appearance.spacing.normal
SectionContainer {
Layout.fillWidth: true
alignTop: true
StyledText {
text: qsTr("Toast settings")
font.pointSize: Appearance.font.size.normal
}
SplitButtonRow {
id: toastFullscreenSelector
function syncActiveItem(): void {
if (root.toastsFullscreen === "all") {
active = toastFullscreenAllItem;
return;
}
if (root.toastsFullscreen === "important") {
active = toastFullscreenImportantItem;
return;
}
active = toastFullscreenOffItem;
}
Layout.fillWidth: true
z: expanded ? 100 : 0
label: qsTr("Show in fullscreen")
menuItems: [toastFullscreenOffItem, toastFullscreenImportantItem, toastFullscreenAllItem]
Component.onCompleted: syncActiveItem()
Connections {
function onToastsFullscreenChanged(): void {
toastFullscreenSelector.syncActiveItem();
}
target: root
}
MenuItem {
id: toastFullscreenOffItem
text: qsTr("Off")
icon: "notifications_off"
activeText: qsTr("Off")
onClicked: {
root.toastsFullscreen = "off";
root.saveConfig();
}
}
MenuItem {
id: toastFullscreenImportantItem
text: qsTr("Important")
icon: "priority_high"
activeText: qsTr("Important")
onClicked: {
root.toastsFullscreen = "important";
root.saveConfig();
}
}
MenuItem {
id: toastFullscreenAllItem
text: qsTr("On")
icon: "notifications"
activeText: qsTr("On")
onClicked: {
root.toastsFullscreen = "all";
root.saveConfig();
}
}
}
SpinBoxRow {
Layout.fillWidth: true
label: qsTr("Visible toasts")
value: root.maxToasts
min: 1
max: 10
step: 1
onValueModified: value => {
root.maxToasts = value;
root.saveConfig();
}
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: Appearance.spacing.normal
rowSpacing: Appearance.spacing.normal
SwitchRow {
Layout.fillWidth: true
label: qsTr("Charging changes")
checked: root.chargingChanged
onToggled: checked => {
root.chargingChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Game mode changes")
checked: root.gameModeChanged
onToggled: checked => {
root.gameModeChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Do not disturb")
checked: root.dndChanged
onToggled: checked => {
root.dndChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Audio output changes")
checked: root.audioOutputChanged
onToggled: checked => {
root.audioOutputChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Audio input changes")
checked: root.audioInputChanged
onToggled: checked => {
root.audioInputChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Caps lock changes")
checked: root.capsLockChanged
onToggled: checked => {
root.capsLockChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Num lock changes")
checked: root.numLockChanged
onToggled: checked => {
root.numLockChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Keyboard layout changes")
checked: root.kbLayoutChanged
onToggled: checked => {
root.kbLayoutChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("VPN changes")
checked: root.vpnChanged
onToggled: checked => {
root.vpnChanged = checked;
root.saveConfig();
}
}
SwitchRow {
Layout.fillWidth: true
label: qsTr("Now playing")
checked: root.nowPlaying
onToggled: checked => {
root.nowPlaying = checked;
root.saveConfig();
}
}
}
}
}
}
}
}
}

View file

@ -358,32 +358,29 @@ Item {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * heroCard.animatedUsage
implicitWidth: parent.width * heroCard.animatedUsage
color: Qt.alpha(heroCard.accentColor, 0.15)
}
ColumnLayout {
anchors.fill: parent
anchors.leftMargin: Appearance.padding.large
anchors.rightMargin: Appearance.padding.large
anchors.topMargin: Appearance.padding.normal
anchors.bottomMargin: Appearance.padding.normal
spacing: Appearance.spacing.small
CardHeader {
anchors.left: parent.left
anchors.top: parent.top
anchors.leftMargin: Appearance.padding.large
anchors.topMargin: Math.round(Appearance.padding.large * 1.2)
width: parent.width - anchors.leftMargin - usageColumn.anchors.rightMargin - usageLabel.width - Appearance.spacing.normal
icon: heroCard.icon
title: heroCard.title
accentColor: heroCard.accentColor
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Appearance.spacing.normal
Column {
Layout.alignment: Qt.AlignBottom
Layout.fillWidth: true
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Math.round(Appearance.padding.large * 1.2)
anchors.bottomMargin: Math.round(Appearance.padding.large * 1.3)
spacing: Appearance.spacing.small
Row {
@ -404,21 +401,17 @@ Item {
}
ProgressBar {
width: parent.width * 0.5
height: 6
implicitWidth: parent.width * 0.5
implicitHeight: 6
value: heroCard.tempProgress
fgColor: heroCard.accentColor
bgColor: Qt.alpha(heroCard.accentColor, 0.2)
}
}
Item {
Layout.fillWidth: true
}
}
}
Column {
id: usageColumn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Appearance.padding.large
@ -426,6 +419,8 @@ Item {
spacing: 0
StyledText {
id: usageLabel
anchors.right: parent.right
text: heroCard.mainLabel
font.pointSize: Appearance.font.size.normal

View file

@ -15,14 +15,17 @@ Shape {
required property Panels panels
required property Item bar
required property real borderThickness
required property real borderRounding
anchors.fill: parent
anchors.margins: Config.border.thickness
anchors.margins: root.borderThickness
anchors.leftMargin: bar.implicitWidth
preferredRendererType: Shape.CurveRenderer
Osd.Background {
wrapper: root.panels.osd // qmllint disable incompatible-type
rounding: Config.border.rounding
startX: root.width - root.panels.session.width - root.panels.sidebar.width
startY: (root.height - wrapper.height) / 2 - rounding
@ -31,6 +34,7 @@ Shape {
Notifications.Background {
wrapper: root.panels.notifications // qmllint disable incompatible-type
sidebar: sidebar
rounding: Config.border.rounding
startX: root.width
startY: 0
@ -68,6 +72,7 @@ Shape {
Utilities.Background {
wrapper: root.panels.utilities // qmllint disable incompatible-type
sidebar: sidebar
rounding: root.borderRounding
startX: root.width
startY: root.height
@ -78,6 +83,7 @@ Shape {
wrapper: root.panels.sidebar // qmllint disable incompatible-type
panels: root.panels
rounding: root.borderRounding
startX: root.width
startY: root.panels.notifications.height

View file

@ -4,12 +4,13 @@ import QtQuick
import QtQuick.Effects
import qs.components
import qs.services
import qs.config
Item {
id: root
required property Item bar
required property real borderThickness
required property real borderRounding
anchors.fill: parent
@ -36,9 +37,9 @@ Item {
Rectangle {
anchors.fill: parent
anchors.margins: Config.border.thickness
anchors.margins: root.borderThickness
anchors.leftMargin: root.bar.implicitWidth
radius: Config.border.rounding
radius: root.borderRounding
}
}
}

View file

@ -26,18 +26,34 @@ Variants {
Exclusions {
screen: scope.modelData
bar: bar
borderThickness: Config.border.thickness
}
StyledWindow {
id: win
readonly property bool hasFullscreen: Hypr.monitorFor(screen)?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false
readonly property var monitor: Hypr.monitorFor(screen)
readonly property bool hasSpecialWorkspace: (monitor?.lastIpcObject?.specialWorkspace?.name.length ?? 0) > 0
readonly property bool hasFullscreen: {
if (hasSpecialWorkspace) {
const specialName = monitor?.lastIpcObject?.specialWorkspace?.name;
if (!specialName)
return false;
const specialWs = Hypr.workspaces.values.find(ws => ws.name === specialName);
return specialWs?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false;
}
return monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false;
}
property real borderThickness: hasFullscreen ? 0 : Config.border.thickness
readonly property real borderLayoutThickness: hasFullscreen ? 0 : Config.border.thickness
property real borderRounding: hasFullscreen ? 0 : Config.border.rounding
property real shadowOpacity: hasFullscreen ? 0 : 0.7
readonly property int dragMaskPadding: {
if (focusGrab.active || panels.popouts.isDetached)
return 0;
const mon = Hypr.monitorFor(screen);
if (mon?.lastIpcObject.specialWorkspace?.name || mon?.activeWorkspace?.lastIpcObject.windows > 0)
if (mon?.lastIpcObject.specialWorkspace?.name || mon?.activeWorkspace.lastIpcObject.windows > 0)
return 0;
const thresholds = [];
@ -56,6 +72,7 @@ Variants {
screen: scope.modelData
name: "drawers"
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.session || panels.dashboard.needsKeyboard ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
mask: Regions {
@ -69,6 +86,30 @@ Variants {
anchors.left: true
anchors.right: true
Behavior on borderThickness {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on borderRounding {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
Behavior on shadowOpacity {
Anim {
duration: Appearance.anim.durations.expressiveDefaultSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial
}
}
HyprlandFocusGrab {
id: focusGrab
@ -101,7 +142,7 @@ Variants {
layer.effect: MultiEffect {
shadowEnabled: true
blurMax: 15
shadowColor: Qt.alpha(Colours.palette.m3shadow, 0.7)
shadowColor: Qt.alpha(Colours.palette.m3shadow, Math.max(0, win.shadowOpacity))
}
// Border {
@ -127,11 +168,11 @@ Variants {
anchors.fill: parent
anchors.margins: -50 // Make border thicker to smooth out bulge from closed drawers
group: blobGroup
radius: Config.border.rounding
radius: win.borderRounding
borderLeft: bar.implicitWidth - anchors.margins
borderRight: Config.border.thickness - anchors.margins
borderTop: Config.border.thickness - anchors.margins
borderBottom: Config.border.thickness - anchors.margins
borderRight: win.borderThickness - anchors.margins
borderTop: win.borderThickness - anchors.margins
borderBottom: win.borderThickness - anchors.margins
}
PanelBg {
@ -140,6 +181,7 @@ Variants {
blobGroup: blobGroup
panel: panels.dashboard
bar: bar
borderThickness: win.borderThickness
deformAmount: 0.1
}
@ -149,6 +191,7 @@ Variants {
blobGroup: blobGroup
panel: panels.launcher
bar: bar
borderThickness: win.borderThickness
deformAmount: 0.1
}
@ -158,6 +201,7 @@ Variants {
blobGroup: blobGroup
panel: panels.sessionWrapper
bar: bar
borderThickness: win.borderThickness
deformAmount: 0.25
x: panels.sessionWrapper.x + panels.session.x + bar.implicitWidth
implicitWidth: panels.session.width
@ -169,6 +213,7 @@ Variants {
blobGroup: blobGroup
panel: panels.sidebar
bar: bar
borderThickness: win.borderThickness
deformAmount: 0.05
implicitHeight: panel.height * (1 / rawDeformMatrix.m22) + 2
exclude: panels.sidebar.offsetScale > 0.08 ? [] : [utilsBg]
@ -181,6 +226,7 @@ Variants {
blobGroup: blobGroup
panel: panels.osdWrapper
bar: bar
borderThickness: win.borderThickness
deformAmount: 0.3
x: panels.osdWrapper.x + panels.osd.x + bar.implicitWidth
implicitWidth: panels.osd.width
@ -192,6 +238,7 @@ Variants {
blobGroup: blobGroup
panel: panels.notifications
bar: bar
borderThickness: win.borderThickness
}
PanelBg {
@ -200,6 +247,7 @@ Variants {
blobGroup: blobGroup
panel: panels.utilities
bar: bar
borderThickness: win.borderThickness
deformAmount: panels.sidebar.visible ? 0.1 : 0.15
exclude: panels.sidebar.offsetScale > 0.08 ? [] : [sidebarBg]
topLeftRadius: Math.max(0, Math.min(1, panels.sidebar.offsetScale / 0.3)) * radius
@ -211,6 +259,7 @@ Variants {
blobGroup: blobGroup
panel: panels.popouts
bar: bar
borderThickness: win.borderThickness
x: bar.implicitWidth - (panels.popouts.isDetached ? -(win.width - panels.popouts.shownWidth) / 2 : panels.popouts.hasCurrent ? 0 : panels.popouts.shownWidth + 5)
implicitWidth: panels.popouts.shownWidth
@ -243,6 +292,8 @@ Variants {
visibilities: visibilities
panels: panels
bar: bar
borderThickness: win.borderLayoutThickness
fullscreen: win.hasFullscreen
Panels {
id: panels
@ -250,6 +301,7 @@ Variants {
screen: scope.modelData
visibilities: visibilities
bar: bar
borderThickness: win.borderThickness
utilities.horizontalStretch: (sidebarBg.rawDeformMatrix.m11 - 1) / 2 + 1
@ -290,6 +342,7 @@ Variants {
popouts: panels.popouts
disabled: scope.barDisabled
fullscreen: win.hasFullscreen
Component.onCompleted: Visibilities.bars.set(scope.modelData, this)
}
@ -301,11 +354,12 @@ Variants {
required property BlobGroup blobGroup
required property Item panel
required property Item bar
required property real borderThickness
property real deformAmount: 0.15
group: panel.width > 0 && panel.height > 0 ? blobGroup : null
x: panel.x + bar.implicitWidth
y: panel.y + Config.border.thickness
y: panel.y + borderThickness
implicitWidth: panel.width
implicitHeight: panel.height
radius: Config.border.rounding

View file

@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.components.containers
import qs.config
import qs.modules.bar as Bar
Scope {
@ -11,6 +10,7 @@ Scope {
required property ShellScreen screen
required property Bar.BarWrapper bar
required property real borderThickness
ExclusionZone {
anchors.left: true
@ -32,7 +32,7 @@ Scope {
component ExclusionZone: StyledWindow {
screen: root.screen
name: "border-exclusion"
exclusiveZone: Config.border.thickness
exclusiveZone: root.borderThickness
mask: Region {}
implicitWidth: 1
implicitHeight: 1

View file

@ -15,6 +15,8 @@ CustomMouseArea {
required property DrawerVisibilities visibilities
required property Panels panels
required property Bar.BarWrapper bar
required property real borderThickness
required property bool fullscreen
property point dragStart
property bool dashboardShortcutActive
@ -22,7 +24,7 @@ CustomMouseArea {
property bool utilitiesShortcutActive
function withinPanelHeight(panel: Item, x: real, y: real): bool {
const panelY = Config.border.thickness + panel.y;
const panelY = root.borderThickness + panel.y;
return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding;
}
@ -49,13 +51,16 @@ CustomMouseArea {
}
function onWheel(event: WheelEvent): void {
if (fullscreen)
return;
if (event.x < bar.implicitWidth) {
bar.handleWheel(event.y, event.angleDelta);
}
}
anchors.fill: parent
hoverEnabled: true
acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons
hoverEnabled: !fullscreen
onPressed: event => dragStart = Qt.point(event.x, event.y)
onContainsMouseChanged: {

View file

@ -19,6 +19,7 @@ Item {
required property ShellScreen screen
required property DrawerVisibilities visibilities
required property Bar.BarWrapper bar
required property real borderThickness
readonly property alias osd: osd
readonly property alias osdWrapper: osdWrapper
@ -33,7 +34,7 @@ Item {
readonly property alias sidebar: sidebar
anchors.fill: parent
anchors.margins: Config.border.thickness
anchors.margins: root.borderThickness
anchors.leftMargin: bar.implicitWidth
Item {
@ -123,7 +124,7 @@ Item {
if (isDetached)
return (root.height - nonAnimHeight) / 2;
const off = currentCenter - Config.border.thickness - nonAnimHeight / 2;
const off = currentCenter - root.borderThickness - nonAnimHeight / 2;
const diff = root.height - Math.floor(off + nonAnimHeight);
if (diff < 0)
return off + diff;

View file

@ -2,14 +2,13 @@ import QtQuick
import QtQuick.Shapes
import qs.components
import qs.services
import qs.config
ShapePath {
id: root
required property Wrapper wrapper
required property var sidebar
readonly property real rounding: Config.border.rounding
required property real rounding
readonly property bool flatten: wrapper.height < rounding * 2
readonly property real roundingY: flatten ? wrapper.height / 2 : rounding
@ -31,14 +30,14 @@ ShapePath {
relativeY: root.wrapper.height - root.roundingY * 2
}
PathArc {
relativeX: root.sidebar.notifsRoundingX
relativeX: root.rounding
relativeY: root.roundingY
radiusX: root.sidebar.notifsRoundingX
radiusX: root.rounding
radiusY: Math.min(root.rounding, root.wrapper.height)
direction: PathArc.Counterclockwise
}
PathLine {
relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.notifsRoundingX : root.wrapper.width
relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding * 2 : root.wrapper.width
relativeY: 0
}
PathArc {

View file

@ -2,13 +2,12 @@ import QtQuick
import QtQuick.Shapes
import qs.components
import qs.services
import qs.config
ShapePath {
id: root
required property Wrapper wrapper
readonly property real rounding: Config.border.rounding
required property real rounding
readonly property bool flatten: wrapper.width < rounding * 2
readonly property real roundingX: flatten ? wrapper.width / 2 : rounding

View file

@ -2,15 +2,13 @@ import QtQuick
import QtQuick.Shapes
import qs.components
import qs.services
import qs.config
ShapePath {
id: root
required property Wrapper wrapper
required property var panels
readonly property real rounding: Config.border.rounding
required property real rounding
readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width
readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding

View file

@ -2,14 +2,13 @@ import QtQuick
import QtQuick.Shapes
import qs.components
import qs.services
import qs.config
ShapePath {
id: root
required property Wrapper wrapper
required property var sidebar
readonly property real rounding: Config.border.rounding
required property real rounding
readonly property bool flatten: wrapper.height < rounding * 2
readonly property real roundingY: flatten ? wrapper.height / 2 : rounding

View file

@ -141,8 +141,10 @@ StyledRect {
roleValue: "vpn"
delegate: Toggle {
icon: "vpn_key"
checked: VPN.connected
checked: VPN.connected && VPN.status.state !== "needs-auth" && VPN.status.state !== "error"
enabled: !VPN.connecting
toggle: VPN.status.state !== "needs-auth" && VPN.status.state !== "error"
inactiveOnColour: Colours.palette.m3onSurfaceVariant
onClicked: VPN.toggle()
}
}

View file

@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Caelestia
import qs.components
import qs.services
import qs.config
Item {
@ -12,6 +13,16 @@ Item {
readonly property int spacing: Appearance.spacing.small
property bool flag
function shouldShowToast(toast: Toast): bool {
if (!Notifs.hasFullscreen())
return true;
if (Config.utilities.toasts.fullscreen === "all")
return true;
if (Config.utilities.toasts.fullscreen === "important")
return toast.type === Toast.Warning || toast.type === Toast.Error;
return false;
}
implicitWidth: Config.utilities.sizes.toastWidth - Appearance.padding.normal * 2
implicitHeight: {
let h = -spacing;
@ -31,6 +42,8 @@ Item {
const toasts = [];
let count = 0;
for (const toast of Toaster.toasts) {
if (!root.shouldShowToast(toast))
continue;
toasts.push(toast);
if (!toast.closed) {
count++;

View file

@ -126,8 +126,6 @@ in
prePatch = ''
substituteInPlace assets/pam.d/fprint \
--replace-fail pam_fprintd.so /run/current-system/sw/lib/security/pam_fprintd.so
substituteInPlace shell.qml \
--replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false'
'';
postInstall = ''

View file

@ -9,6 +9,7 @@ qml_module(caelestia-internal
hyprextras.hpp hyprextras.cpp
logindmanager.hpp logindmanager.cpp
sparklineitem.hpp sparklineitem.cpp
visualiserbars.hpp visualiserbars.cpp
LIBRARIES
Qt::Gui
Qt::Quick

View file

@ -0,0 +1,198 @@
#include "visualiserbars.hpp"
#include <algorithm>
#include <cmath>
#include <qbrush.h>
#include <qpainter.h>
#include <qpainterpath.h>
#include <qpen.h>
namespace caelestia::internal {
VisualiserBars::VisualiserBars(QQuickItem* parent)
: QQuickPaintedItem(parent) {
setAntialiasing(true);
}
void VisualiserBars::advance(qreal dt) {
if (m_displayValues.isEmpty() || m_settled)
return;
// dt is in seconds (from FrameAnimation.frameTime), convert to ms
const qreal dtMs = dt * 1000.0;
const qreal tau = m_animationDuration / 3.0;
const qreal alpha = 1.0 - std::exp(-dtMs / tau);
bool allSettled = true;
for (qsizetype i = 0; i < m_displayValues.size(); ++i) {
const double diff = m_targetValues[i] - m_displayValues[i];
if (std::abs(diff) > 0.001) {
m_displayValues[i] += diff * alpha;
allSettled = false;
} else {
m_displayValues[i] = m_targetValues[i];
}
}
update();
if (allSettled && !m_settled) {
m_settled = true;
emit settledChanged();
}
}
void VisualiserBars::paint(QPainter* painter) {
if (m_displayValues.isEmpty())
return;
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setPen(Qt::NoPen);
const qreal h = height();
const qreal maxBarHeight = h * 0.4;
QLinearGradient gradient(0, h - maxBarHeight, 0, h);
gradient.setColorAt(0, m_primaryColor);
gradient.setColorAt(1, m_secondaryColor);
painter->setBrush(gradient);
drawSide(painter, false);
drawSide(painter, true);
}
void VisualiserBars::drawSide(QPainter* painter, bool rightSide) {
const qreal w = width();
const qreal h = height();
const auto count = m_displayValues.size();
if (count == 0)
return;
const qreal sideWidth = w * 0.4;
const qreal slotWidth = sideWidth / static_cast<qreal>(count);
const qreal barWidth = slotWidth - m_spacing;
if (barWidth <= 0)
return;
const qreal sideOffset = rightSide ? w * 0.6 : 0;
const qreal maxBarHeight = h * 0.4;
for (qsizetype i = 0; i < count; ++i) {
const qsizetype valueIndex = rightSide ? i : (count - i - 1);
const qreal value = std::clamp(m_displayValues[valueIndex], 0.0, 1.0);
const qreal barHeight = value * maxBarHeight;
if (barHeight <= 0)
continue;
const qreal x = static_cast<qreal>(i) * slotWidth + sideOffset;
const qreal y = h - barHeight;
const qreal r = std::min({ m_rounding, barWidth / 2.0, barHeight });
QPainterPath path;
path.moveTo(x, h);
path.lineTo(x, y + r);
if (r > 0) {
path.arcTo(x, y, r * 2, r * 2, 180, -90);
path.lineTo(x + barWidth - r, y);
path.arcTo(x + barWidth - r * 2, y, r * 2, r * 2, 90, -90);
} else {
path.lineTo(x, y);
path.lineTo(x + barWidth, y);
}
path.lineTo(x + barWidth, h);
path.closeSubpath();
painter->drawPath(path);
}
}
QVector<double> VisualiserBars::values() const {
return m_targetValues;
}
void VisualiserBars::setValues(const QVector<double>& values) {
m_targetValues = values;
if (m_displayValues.size() != values.size()) {
m_displayValues.resize(values.size(), 0.0);
}
if (m_settled) {
m_settled = false;
emit settledChanged();
}
emit valuesChanged();
}
bool VisualiserBars::settled() const {
return m_settled;
}
QColor VisualiserBars::primaryColor() const {
return m_primaryColor;
}
void VisualiserBars::setPrimaryColor(const QColor& color) {
if (m_primaryColor == color)
return;
m_primaryColor = color;
emit primaryColorChanged();
update();
}
QColor VisualiserBars::secondaryColor() const {
return m_secondaryColor;
}
void VisualiserBars::setSecondaryColor(const QColor& color) {
if (m_secondaryColor == color)
return;
m_secondaryColor = color;
emit secondaryColorChanged();
update();
}
qreal VisualiserBars::rounding() const {
return m_rounding;
}
void VisualiserBars::setRounding(qreal rounding) {
if (qFuzzyCompare(m_rounding, rounding))
return;
m_rounding = rounding;
emit roundingChanged();
update();
}
qreal VisualiserBars::spacing() const {
return m_spacing;
}
void VisualiserBars::setSpacing(qreal spacing) {
if (qFuzzyCompare(m_spacing, spacing))
return;
m_spacing = spacing;
emit spacingChanged();
update();
}
int VisualiserBars::animationDuration() const {
return m_animationDuration;
}
void VisualiserBars::setAnimationDuration(int duration) {
if (m_animationDuration == duration)
return;
m_animationDuration = duration;
emit animationDurationChanged();
}
} // namespace caelestia::internal

View file

@ -0,0 +1,72 @@
#pragma once
#include <qcolor.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qquickpainteditem.h>
#include <qvector.h>
namespace caelestia::internal {
class VisualiserBars : public QQuickPaintedItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QVector<double> values READ values WRITE setValues NOTIFY valuesChanged)
Q_PROPERTY(QColor primaryColor READ primaryColor WRITE setPrimaryColor NOTIFY primaryColorChanged)
Q_PROPERTY(QColor secondaryColor READ secondaryColor WRITE setSecondaryColor NOTIFY secondaryColorChanged)
Q_PROPERTY(qreal rounding READ rounding WRITE setRounding NOTIFY roundingChanged)
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
Q_PROPERTY(int animationDuration READ animationDuration WRITE setAnimationDuration NOTIFY animationDurationChanged)
Q_PROPERTY(bool settled READ settled NOTIFY settledChanged)
public:
explicit VisualiserBars(QQuickItem* parent = nullptr);
void paint(QPainter* painter) override;
Q_INVOKABLE void advance(qreal dt);
[[nodiscard]] QVector<double> values() const;
void setValues(const QVector<double>& values);
[[nodiscard]] QColor primaryColor() const;
void setPrimaryColor(const QColor& color);
[[nodiscard]] QColor secondaryColor() const;
void setSecondaryColor(const QColor& color);
[[nodiscard]] qreal rounding() const;
void setRounding(qreal rounding);
[[nodiscard]] qreal spacing() const;
void setSpacing(qreal spacing);
[[nodiscard]] int animationDuration() const;
void setAnimationDuration(int duration);
[[nodiscard]] bool settled() const;
signals:
void valuesChanged();
void primaryColorChanged();
void secondaryColorChanged();
void roundingChanged();
void spacingChanged();
void animationDurationChanged();
void settledChanged();
private:
void drawSide(QPainter* painter, bool rightSide);
QVector<double> m_targetValues;
QVector<double> m_displayValues;
QColor m_primaryColor;
QColor m_secondaryColor;
qreal m_rounding = 0.0;
qreal m_spacing = 0.0;
int m_animationDuration = 200;
bool m_settled = true;
};
} // namespace caelestia::internal

View file

@ -21,6 +21,22 @@ Singleton {
property bool loaded
function hasFullscreen(): bool {
for (const monitor of Hypr.monitors.values) {
if (monitor?.activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1))
return true;
}
return false;
}
function shouldShowPopup(): bool {
if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar))
return false;
if (Config.notifs.fullscreen === "off" && hasFullscreen())
return false;
return true;
}
onDndChanged: {
if (!Config.utilities.toasts.dndChanged)
return;
@ -79,7 +95,7 @@ Singleton {
notif.tracked = true;
const comp = notifComp.createObject(root, {
popup: !props.dnd && ![...Visibilities.screens.values()].some(v => v.sidebar),
popup: root.shouldShowPopup(),
notification: notif
});
root.list = [comp, ...root.list];

View file

@ -10,6 +10,12 @@ Singleton {
id: root
property bool connected: false
property var status: ({
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
})
readonly property bool connecting: connectProc.running || disconnectProc.running
readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
@ -19,7 +25,7 @@ Singleton {
}
readonly property bool isCustomProvider: typeof providerInput === "object"
readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput)
readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : ""
readonly property string interfaceName: isCustomProvider ? (providerInput.iface || "") : ""
readonly property var currentConfig: {
const name = providerName;
const iface = interfaceName;
@ -30,7 +36,7 @@ Singleton {
return {
connectCmd: custom.connectCmd || defaults.connectCmd,
disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd,
interface: custom.interface || defaults.interface,
interface: custom.iface || defaults.interface,
displayName: custom.displayName || defaults.displayName
};
}
@ -53,7 +59,7 @@ Singleton {
displayName: "Warp"
},
"netbird": {
connectCmd: ["netbird", "up"],
connectCmd: ["netbird", "up", "--no-browser"],
disconnectCmd: ["netbird", "down"],
interface: "wt0",
displayName: "NetBird"
@ -75,6 +81,10 @@ Singleton {
}
function connect(): void {
if (status.state === "needs-auth" && status.authUrl) {
emitStatusToast(status);
return;
}
if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) {
connectProc.exec(root.currentConfig.connectCmd);
}
@ -87,11 +97,7 @@ Singleton {
}
function toggle(): void {
if (connected) {
disconnect();
} else {
connect();
}
connected ? disconnect() : connect();
}
function checkStatus(): void {
@ -100,16 +106,221 @@ Singleton {
}
}
onConnectedChanged: {
function getStatusCommand(): var {
switch (providerName) {
case "tailscale":
return ["tailscale", "status", "--json"];
case "netbird":
return ["netbird", "status", "--json"];
case "warp":
return ["warp-cli", "status"];
case "wireguard":
return ["ip", "link", "show"];
default:
return ["ip", "link", "show"];
}
}
function parseTailscaleStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
// Handle empty or whitespace-only output
if (!output || output.trim().length === 0) {
return status;
}
// Check for common non-JSON states first
if (output.includes("Logged out") || output.includes("Stopped") || output.includes("not running") || output.includes("Tailscale is not running")) {
status.state = "disconnected";
return status;
}
// Try to parse as JSON
try {
const data = JSON.parse(output);
const backendState = data.BackendState || "";
if (backendState === "Running") {
status.connected = true;
status.state = "connected";
} else if (backendState === "Starting") {
status.state = "connecting";
} else if (backendState === "NeedsLogin" || backendState === "NeedsMachineAuth") {
status.state = "needs-auth";
status.reason = backendState === "NeedsLogin" ? "Login required" : "Machine authorization required";
status.authUrl = data.AuthURL || "";
}
} catch (e) {
// JSON parsing failed - treat as disconnected unless it looks like an error
if (output.includes("error") || output.includes("Error") || output.includes("failed")) {
status.state = "disconnected";
status.reason = "Tailscale may not be running";
} else {
status.state = "disconnected";
}
}
return status;
}
function parseNetBirdStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
try {
const data = JSON.parse(output);
const mgmtConnected = data.management?.connected;
const signalConnected = data.signal?.connected;
if (mgmtConnected && signalConnected) {
status.connected = true;
status.state = "connected";
} else if (data.management?.error) {
const error = data.management.error;
if (error.includes("auth") || error.includes("login")) {
status.state = "needs-auth";
status.reason = "Authentication required";
} else {
status.reason = error;
}
}
} catch (e) {
status.state = "error";
status.reason = "Failed to parse status";
}
return status;
}
function parseWarpStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
if (output.includes("Connected")) {
status.connected = true;
status.state = "connected";
} else if (output.includes("Connecting")) {
status.state = "connecting";
} else if (output.includes("Unable") || output.includes("Registration Missing") || output.includes("registration") || output.includes("register")) {
status.state = "needs-auth";
status.reason = "WARP registration required";
} else if (!output.includes("Disconnected")) {
status.state = "error";
status.reason = "Unknown WARP status";
}
return status;
}
function parseWireGuardStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
const iface = root.currentConfig?.interface || "";
if (iface && output.includes(iface + ":")) {
status.connected = true;
status.state = "connected";
}
return status;
}
function parseStatusOutput(output: string): var {
switch (providerName) {
case "tailscale":
return parseTailscaleStatus(output);
case "netbird":
return parseNetBirdStatus(output);
case "warp":
return parseWarpStatus(output);
case "wireguard":
default:
return parseWireGuardStatus(output);
}
}
function extractAuthUrl(text: string): string {
const urlMatch = text.match(/(https?:\/\/[^\s]+)/);
return urlMatch ? urlMatch[1] : "";
}
function createAuthStatus(authUrl: string): var {
return {
connected: false,
state: "needs-auth",
reason: "Authentication required",
authUrl: authUrl
};
}
function updateStatus(newStatus: var): void {
const oldState = status.state;
if (newStatus.state === "needs-auth" && !newStatus.authUrl && status.authUrl) {
newStatus.authUrl = status.authUrl;
}
status = newStatus;
root.connected = newStatus.connected;
if (oldState !== newStatus.state) {
emitStatusToast(newStatus);
}
}
function emitStatusToast(statusObj: var): void {
if (!Config.utilities.toasts.vpnChanged)
return;
const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN";
if (connected) {
switch (statusObj.state) {
case "connected":
Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key");
} else {
break;
case "disconnected":
if (status.connected) {
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off");
}
break;
case "needs-auth":
const authMsg = statusObj.reason || "Authentication required";
Toaster.toast(qsTr("VPN authentication required"), qsTr("%1: %2").arg(displayName).arg(authMsg), "vpn_lock");
break;
case "error":
if (status.state === "connected" || status.state === "connecting" || status.state === "needs-auth") {
const errMsg = statusObj.reason || "Unknown error";
Toaster.toast(qsTr("VPN error"), qsTr("%1: %2").arg(displayName).arg(errMsg), "error");
}
break;
}
}
onStatusChanged: {
if (providerName === "warp" && status.state === "needs-auth" && status.reason.includes("registration")) {
warpRegisterProc.exec(["warp-cli", "registration", "new"]);
}
}
onProviderNameChanged: {
status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
root.connected = false;
statusCheckTimer.start();
}
Component.onCompleted: root.enabled && statusCheckTimer.start()
@ -127,7 +338,7 @@ Singleton {
Process {
id: statusProc
command: ["ip", "link", "show"]
command: root.getStatusCommand()
// qmllint disable incompatible-type
environment: ({
// qmllint enable incompatible-type
@ -136,8 +347,38 @@ Singleton {
})
stdout: StdioCollector {
onStreamFinished: {
const iface = root.currentConfig ? root.currentConfig.interface : "";
root.connected = iface && text.includes(iface + ":");
const newStatus = root.parseStatusOutput(text);
root.updateStatus(newStatus);
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim().length > 0) {
if (text.includes("doesn't appear to be running") || text.includes("failed to connect to local tailscaled") || text.includes("daemon is not running") || text.includes("not running") && (text.includes("netbird") || text.includes("warp"))) {
let cmd = "sudo systemctl start ";
switch (root.providerName) {
case "tailscale":
cmd += "tailscaled";
break;
case "netbird":
cmd += "netbird";
break;
case "warp":
cmd += "warp-svc";
break;
default:
cmd += root.providerName + "d";
break;
}
const errorStatus = {
connected: false,
state: "disconnected",
reason: `Service not running (run: ${cmd})`,
authUrl: ""
};
root.updateStatus(errorStatus);
}
}
}
}
}
@ -145,12 +386,59 @@ Singleton {
Process {
id: connectProc
onExited: statusCheckTimer.start() // qmllint disable signal-handler-parameters
onExited: exitCode => { // qmllint disable signal-handler-parameters
if (exitCode !== 0) {
return;
}
if (root.providerName === "tailscale") {
Qt.callLater(() => {
if (root.status.state !== "needs-auth") {
statusCheckTimer.start();
}
});
} else if (root.status.state !== "needs-auth") {
statusCheckTimer.start();
}
}
stdout: SplitParser {
onRead: data => {
const authUrl = root.extractAuthUrl(data);
if (authUrl) {
root.updateStatus(root.createAuthStatus(authUrl));
}
}
}
stderr: StdioCollector {
onStreamFinished: {
const error = text.trim();
if (error && !error.includes("[#]") && !error.includes("already exists")) {
console.warn("VPN connection error:", error);
if (error.includes("Access denied") || error.includes("checkprefs access denied")) {
const errorStatus = {
connected: false,
state: "disconnected",
reason: "Permission denied. Run in terminal: sudo tailscale set --operator=$USER",
authUrl: ""
};
root.updateStatus(errorStatus);
return;
}
if (error.includes("Unknown device type") || error.includes("Protocol not supported")) {
const errorStatus = {
connected: false,
state: "disconnected",
reason: "WireGuard module not loaded. Run: sudo modprobe wireguard",
authUrl: ""
};
root.updateStatus(errorStatus);
return;
}
const authUrl = root.extractAuthUrl(error);
if (authUrl) {
root.updateStatus(root.createAuthStatus(authUrl));
} else if (error.includes("already exists")) {
root.connected = true;
}
@ -172,6 +460,16 @@ Singleton {
}
}
Process {
id: warpRegisterProc
onExited: exitCode => { // qmllint disable signal-handler-parameters
if (exitCode === 0) {
statusCheckTimer.start();
}
}
}
Timer {
id: statusCheckTimer

View file

@ -121,8 +121,8 @@ Singleton {
humidity: json.current.relative_humidity_2m,
windSpeed: json.current.wind_speed_10m,
isDay: json.current.is_day,
sunrise: json.daily.sunrise[0],
sunset: json.daily.sunset[0]
sunrise: json.daily.sunrise[0].replace("T", " "),
sunset: json.daily.sunset[0].replace("T", " ")
};
const forecastList = [];
@ -218,7 +218,6 @@ Singleton {
target: Config.services
}
// Refresh current location hourly
Timer {
interval: 3600000 // 1 hour
running: true