Initial commit

This commit is contained in:
ly-sec 2025-07-11 14:14:28 +02:00
commit a8c2f88654
53 changed files with 9269 additions and 0 deletions

162
Bar/Bar.qml Normal file
View file

@ -0,0 +1,162 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Qt5Compat.GraphicalEffects
import qs.Bar.Modules
import qs.Settings
import qs.Services
import qs.Components
import qs.Widgets
import qs.Widgets.Sidebar
import qs.Widgets.Sidebar.Panel
import qs.Helpers
import QtQuick.Controls
Scope {
id: rootScope
property var shell
Item {
id: barRootItem
anchors.fill: parent
Variants {
model: Quickshell.screens
Item {
property var modelData
PanelWindow {
id: panel
screen: modelData
color: "transparent"
implicitHeight: barBackground.height + 24
anchors.top: true
anchors.left: true
anchors.right: true
visible: true
property string lastFocusedWindowTitle: ""
property bool activeWindowVisible: false
property string displayedWindowTitle: ""
onLastFocusedWindowTitleChanged: {
displayedWindowTitle = (lastFocusedWindowTitle === "(No active window)") ? "" : lastFocusedWindowTitle
}
Timer {
id: hideTimer
interval: 4000
repeat: false
onTriggered: panel.activeWindowVisible = false
}
Connections {
target: Niri
function onFocusedWindowTitleChanged() {
var newTitle = Niri.focusedWindowTitle
if (newTitle !== panel.lastFocusedWindowTitle) {
panel.lastFocusedWindowTitle = newTitle
if (newTitle === "(No active window)") {
panel.activeWindowVisible = false
hideTimer.stop()
} else {
panel.activeWindowVisible = true
hideTimer.restart()
}
}
}
}
Rectangle {
id: barBackground
width: parent.width
height: 36
color: Theme.backgroundPrimary
anchors.top: parent.top
anchors.left: parent.left
}
ActiveWindow {}
Workspace {
id: workspace
anchors.horizontalCenter: barBackground.horizontalCenter
anchors.verticalCenter: barBackground.verticalCenter
}
Row {
id: rightWidgetsRow
anchors.verticalCenter: barBackground.verticalCenter
anchors.right: barBackground.right
anchors.rightMargin: 18
spacing: 12
Brightness {
id: widgetsBrightness
}
Volume {
id: widgetsVolume
shell: rootScope.shell
}
SystemTray {
id: systemTrayModule
shell: rootScope.shell
bar: panel
trayMenu: externalTrayMenu
}
CustomTrayMenu {
id: externalTrayMenu
}
ClockWidget {}
PanelPopup {
id: sidebarPopup
}
Button {
barBackground: barBackground
screen: modelData
sidebarPopup: sidebarPopup
}
}
Corners {
id: topleftCorner
position: "bottomleft"
size: 1.3
fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222"
offsetX: -39
offsetY: 0
anchors.top: barBackground.bottom
}
Corners {
id: toprightCorner
position: "bottomright"
size: 1.3
fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222"
offsetX: 39
offsetY: 0
anchors.top: barBackground.bottom
}
Background {}
Overview {}
}
}
}
}
// This alias exposes the visual bar's visibility to the outside world
property alias visible: barRootItem.visible
}

View file

@ -0,0 +1,67 @@
import QtQuick
import Quickshell
import qs.Components
import qs.Settings
Item {
id: activeWindowWrapper
width: parent.width
property int fullHeight: activeWindowTitleContainer.height
y: panel.activeWindowVisible ? barBackground.height : barBackground.height - fullHeight
height: panel.activeWindowVisible ? fullHeight : 1
opacity: panel.activeWindowVisible ? 1 : 0
clip: true
Behavior on height { NumberAnimation { duration: 300; easing.type: Easing.OutQuad } }
Behavior on y { NumberAnimation { duration: 300; easing.type: Easing.OutQuad } }
Behavior on opacity { NumberAnimation { duration: 250 } }
Rectangle {
id: activeWindowTitleContainer
color: Theme.backgroundPrimary
bottomLeftRadius: Math.max(0, width / 2)
bottomRightRadius: Math.max(0, width / 2)
width: Math.min(barBackground.width - 200, activeWindowTitle.implicitWidth + 24)
height: activeWindowTitle.implicitHeight + 12
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
Text {
id: activeWindowTitle
text: panel.displayedWindowTitle && panel.displayedWindowTitle.length > 60
? panel.displayedWindowTitle.substring(0, 60) + "..."
: panel.displayedWindowTitle
font.pixelSize: 12
color: Theme.textSecondary
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.fill: parent
anchors.margins: 6
maximumLineCount: 1
}
}
Corners {
id: activeCornerRight
position: "bottomleft"
size: 1.1
fillColor: Theme.backgroundPrimary
offsetX: activeWindowTitleContainer.x + activeWindowTitleContainer.width - 33
offsetY: 0
anchors.top: activeWindowTitleContainer.top
}
Corners {
id: activeCornerLeft
position: "bottomright"
size: 1.1
fillColor: Theme.backgroundPrimary
anchors.top: activeWindowTitleContainer.top
x: activeWindowTitleContainer.x + 33 - width
offsetY: 0
}
}

364
Bar/Modules/Applauncher.qml Normal file
View file

@ -0,0 +1,364 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Components
import qs.Settings
import Qt5Compat.GraphicalEffects
import Quickshell.Wayland
import "../../Helpers/Fuzzysort.js" as Fuzzysort
PanelWindow {
id: appLauncherPanel
implicitWidth: 460
implicitHeight: 640
color: "transparent"
visible: false
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
screen: (typeof modelData !== 'undefined' ? modelData : null)
property bool shouldBeVisible: false
anchors.top: true
margins.top: -26
function showAt() {
visible = true;
shouldBeVisible = true;
searchField.forceActiveFocus()
}
function hidePanel() {
shouldBeVisible = false;
}
Rectangle {
id: root
width: 400
height: 640
x: (parent.width - width) / 2 // Horizontally centered
color: Theme.backgroundPrimary
bottomLeftRadius: 28
bottomRightRadius: 28
property var appModel: DesktopEntries.applications.values
property var filteredApps: []
property int selectedIndex: 0
property int targetY: (parent.height - height) / 2 // Centered vertically
y: appLauncherPanel.shouldBeVisible ? targetY : -height // Slide from above
Behavior on y {
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
scale: appLauncherPanel.shouldBeVisible ? 1 : 0
Behavior on scale {
NumberAnimation { duration: 200; easing.type: Easing.InOutCubic }
}
onScaleChanged: {
if (scale === 0 && !appLauncherPanel.shouldBeVisible) {
appLauncherPanel.visible = false;
}
}
function isMathExpression(str) {
// Allow numbers, operators, parentheses, decimal points, spaces
return /^[-+*/().0-9\s]+$/.test(str);
}
function safeEval(expr) {
// Only allow safe math expressions
try {
// eslint-disable-next-line no-eval
return Function('return (' + expr + ')')();
} catch (e) {
return undefined;
}
}
function updateFilter() {
var query = searchField.text ? searchField.text.toLowerCase() : "";
var apps = root.appModel;
var results = [];
// Calculator mode: starts with '='
if (query.startsWith("=")) {
var expr = searchField.text.slice(1).trim();
if (expr && isMathExpression(expr)) {
var value = safeEval(expr);
if (value !== undefined && value !== null && value !== "") {
results.push({
isCalculator: true,
name: `Calculator: ${expr} = ${value}`,
result: value,
expr: expr,
icon: "calculate"
});
}
}
}
// Normal app search
if (!query || query.startsWith("=")) {
results = results.concat(apps);
} else {
var fuzzyResults = Fuzzysort.go(query, apps, { keys: ["name", "comment", "genericName"] });
results = results.concat(fuzzyResults.map(function(r) { return r.obj; }));
}
root.filteredApps = results;
root.selectedIndex = 0; // reset selection
}
function selectNext() {
if (filteredApps.length > 0)
selectedIndex = Math.min(selectedIndex + 1, filteredApps.length - 1);
}
function selectPrev() {
if (filteredApps.length > 0)
selectedIndex = Math.max(selectedIndex - 1, 0);
}
function activateSelected() {
if (filteredApps.length === 0)
return;
var modelData = filteredApps[selectedIndex];
if (modelData.isCalculator) {
Qt.callLater(function() {
Quickshell.clipboardText = String(modelData.result);
Quickshell.execDetached([
"notify-send",
"Calculator Result",
`${modelData.expr} = ${modelData.result} (copied to clipboard)`
]);
});
} else if (modelData.execString) {
Quickshell.execDetached(["sh", "-c", modelData.execString]);
} else if (modelData.exec) {
Quickshell.execDetached(["sh", "-c", modelData.exec]);
} else {
if (!modelData.isCalculator)
console.warn("Cannot launch app:", modelData.name, "missing execString or exec", modelData);
}
appLauncherPanel.hidePanel();
}
Component.onCompleted: updateFilter()
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: 32
spacing: 18
Rectangle {
Layout.fillWidth: true
height: 1.5
color: Theme.outline
opacity: 0.10
}
// Search Bar
Rectangle {
id: searchBar
color: Theme.surfaceVariant
radius: 22
height: 48
Layout.fillWidth: true
//border.color: searchField.activeFocus ? Theme.accentPrimary : Theme.outline
//border.width: searchField.activeFocus ? 2.5 : 1.5
layer.enabled: searchField.activeFocus
layer.effect: DropShadow {
color: Theme.accentPrimary
radius: 12
samples: 16
verticalOffset: 0
horizontalOffset: 0
opacity: 0.10
}
RowLayout {
anchors.fill: parent
spacing: 10
anchors.margins: 14
Text {
text: "search"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: searchField.activeFocus ? Theme.accentPrimary : Theme.textSecondary
verticalAlignment: Text.AlignVCenter
}
TextField {
id: searchField
placeholderText: "Search apps..."
color: Theme.textPrimary
placeholderTextColor: Theme.textSecondary
background: null
font.pixelSize: 17
Layout.fillWidth: true
onTextChanged: root.updateFilter()
selectedTextColor: Theme.onAccent
selectionColor: Theme.accentPrimary
padding: 2
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
font.bold: true
Keys.onDownPressed: root.selectNext()
Keys.onUpPressed: root.selectPrev()
Keys.onEnterPressed: root.activateSelected()
Keys.onReturnPressed: root.activateSelected()
Keys.onEscapePressed: appLauncherPanel.hidePanel()
}
}
Behavior on border.color { ColorAnimation { duration: 120 } }
Behavior on border.width { NumberAnimation { duration: 120 } }
}
// App List Card
Rectangle {
color: Theme.surface
radius: 20
//border.color: Theme.outline
//border.width: 1
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
anchors.margins: 0
property int innerPadding: 16
// Add an Item for padding
Item {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: parent.innerPadding
visible: false // for layout only
}
ListView {
id: appList
anchors.fill: parent
anchors.margins: parent.innerPadding
spacing: 2
model: root.filteredApps
currentIndex: root.selectedIndex
delegate: Item {
id: appDelegate
width: appList.width
height: 48
property bool hovered: mouseArea.containsMouse
property bool isSelected: index === root.selectedIndex
Rectangle {
anchors.fill: parent
color: hovered || isSelected ? Theme.accentPrimary : "transparent"
radius: 12
border.color: hovered || isSelected ? Theme.accentPrimary : "transparent"
border.width: hovered || isSelected ? 2 : 0
Behavior on color { ColorAnimation { duration: 120 } }
Behavior on border.color { ColorAnimation { duration: 120 } }
Behavior on border.width { NumberAnimation { duration: 120 } }
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 10
Item {
width: 28; height: 28
property bool iconLoaded: !modelData.isCalculator && iconImg.status === Image.Ready && iconImg.source !== "" && iconImg.status !== Image.Error
Image {
id: iconImg
anchors.fill: parent
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
asynchronous: true
source: modelData.isCalculator ? "qrc:/icons/calculate.svg" : Quickshell.iconPath(modelData.icon, "")
visible: modelData.isCalculator || parent.iconLoaded
}
Text {
anchors.centerIn: parent
visible: !modelData.isCalculator && !parent.iconLoaded
text: "broken_image"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: Theme.accentPrimary
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Text {
text: modelData.name
color: hovered || isSelected ? Theme.onAccent : Theme.textPrimary
font.pixelSize: 14
font.bold: hovered || isSelected
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : (modelData.comment || modelData.genericName || "")
color: hovered || isSelected ? Theme.onAccent : Theme.textSecondary
font.pixelSize: 11
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item { Layout.fillWidth: true }
Text {
text: modelData.isCalculator ? "content_copy" : "chevron_right"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: hovered || isSelected ? Theme.onAccent : Theme.textSecondary
verticalAlignment: Text.AlignVCenter
}
}
Rectangle {
id: ripple
anchors.fill: parent
color: Theme.onAccent
opacity: 0.0
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
ripple.opacity = 0.18
rippleNumberAnimation.start()
root.selectedIndex = index // update selection on click
root.activateSelected()
}
onPressed: ripple.opacity = 0.18
onReleased: ripple.opacity = 0.0
}
NumberAnimation {
id: rippleNumberAnimation
target: ripple
property: "opacity"
to: 0.0
duration: 320
}
// Divider (except last item)
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.outline
opacity: index === appList.count - 1 ? 0 : 0.10
}
}
}
}
}
}
Corners {
id: launcherCornerRight
position: "bottomleft"
size: 1.1
fillColor: Theme.backgroundPrimary
anchors.top: root.top
offsetX: 397
offsetY: 0
}
Corners {
id: launcherCornerLeft
position: "bottomright"
size: 1.1
fillColor: Theme.backgroundPrimary
anchors.top: root.top
offsetX: -397
offsetY: 0
}
}

221
Bar/Modules/Bluetooth.qml Normal file
View file

@ -0,0 +1,221 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import qs.Settings
import qs.Components
Item {
id: bluetoothDisplay
width: 22
height: 22
property color hoverColor: Theme.rippleEffect
property real hoverOpacity: 0.0
property bool isActive: mouseArea.containsMouse || (bluetoothPopup && bluetoothPopup.visible)
// Show the Bluetooth popup when clicked
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: {
if (bluetoothPopup.visible) {
bluetoothPopup.hidePopup();
} else {
bluetoothPopup.showAt(this, 0, parent.height);
}
}
onEntered: bluetoothDisplay.hoverOpacity = 0.18
onExited: bluetoothDisplay.hoverOpacity = 0.0
}
Rectangle {
anchors.fill: parent
anchors.margins: -4 // Make hover area larger than icon
color: hoverColor
opacity: isActive ? 0.18 : hoverOpacity
radius: height / 2
z: 0
visible: opacity > 0.01
}
Text {
anchors.centerIn: parent
text: "bluetooth"
font.family: isActive ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 18
color: bluetoothPopup.visible ? Theme.accentPrimary : Theme.textPrimary
z: 1
}
Behavior on hoverOpacity {
NumberAnimation {
duration: 120
easing.type: Easing.OutQuad
}
}
// The popup window for device list
PopupWindow {
id: bluetoothPopup
implicitWidth: 350
//property int deviceCount: (typeof Bluetooth.devices.count === 'number' && Bluetooth.devices.count >= 0) ? Bluetooth.devices.count : 0
//implicitHeight: Math.max(100, Math.min(420, 56 + (deviceCount * 36) + 24))
implicitHeight: 400
visible: false
color: "transparent"
property var anchorItem: null
property real anchorX
property real anchorY
anchor.item: anchorItem ? anchorItem : null
anchor.rect.x: anchorX - (implicitWidth / 2) + (anchorItem ? anchorItem.width / 2 : 0)
anchor.rect.y: anchorY + 8 // Move popup further down
function showAt(item, x, y) {
if (!item) {
console.warn("Bluetooth: anchorItem is undefined, not showing popup.")
return
}
anchorItem = item
anchorX = x
anchorY = y
visible = true
forceActiveFocus()
}
function hidePopup() {
visible = false
}
Item {
anchors.fill: parent
Keys.onEscapePressed: bluetoothPopup.hidePopup()
}
Rectangle {
id: bg
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 12
border.width: 1
border.color: Theme.surfaceVariant
z: 0
}
// Header
Rectangle {
id: header
width: parent.width
height: 56
color: "transparent"
Text {
text: "Bluetooth"
font.pixelSize: 18
font.bold: true
color: Theme.textPrimary
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 16
}
}
// Device list container with proper margins
Rectangle {
id: listContainer
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 8
color: "transparent"
clip: true
ListView {
id: deviceListView
anchors.fill: parent
spacing: 4
boundsBehavior: Flickable.StopAtBounds
model: Bluetooth.devices
delegate: Rectangle {
width: parent.width
height: 42
color: "transparent"
radius: 8
Rectangle {
anchors.fill: parent
radius: 8
color: modelData.connected ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.18)
: (deviceMouseArea.containsMouse ? Theme.highlight : "transparent")
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
Text {
text: modelData.connected ? "bluetooth" : "bluetooth_disabled"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: modelData.connected ? Theme.accentPrimary : Theme.textSecondary
verticalAlignment: Text.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: modelData.name || "Unknown Device"
color: modelData.connected ? Theme.accentPrimary : Theme.textPrimary
font.pixelSize: 14
elide: Text.ElideRight
}
Text {
text: modelData.address
color: modelData.connected ? Theme.accentPrimary : Theme.textSecondary
font.pixelSize: 11
elide: Text.ElideRight
}
}
}
MouseArea {
id: deviceMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
modelData.disconnect()
} else {
modelData.connect()
}
}
}
}
}
}
// Scroll indicator when needed
Rectangle {
anchors.right: parent.right
anchors.rightMargin: 2
anchors.top: listContainer.top
anchors.bottom: listContainer.bottom
width: 4
radius: 2
color: Theme.textSecondary
opacity: deviceListView.contentHeight > deviceListView.height ? 0.3 : 0
visible: opacity > 0
}
}
}

View file

@ -0,0 +1,50 @@
import QtQuick
import Quickshell.Io
import qs.Settings
import qs.Components
Item {
id: brightnessDisplay
property int brightness: -1
width: pill.width
height: pill.height
FileView {
id: brightnessFile
path: "/tmp/brightness_osd_level"
watchChanges: true
blockLoading: true
onLoaded: updateBrightness()
onFileChanged: {
brightnessFile.reload()
updateBrightness()
}
function updateBrightness() {
const val = parseInt(brightnessFile.text())
if (!isNaN(val) && val !== brightnessDisplay.brightness) {
brightnessDisplay.brightness = val
pill.text = brightness + "%"
pill.show()
}
}
}
PillIndicator {
id: pill
icon: "brightness_high"
text: brightness >= 0 ? brightness + "%" : ""
pillColor: Theme.surfaceVariant
iconCircleColor: Theme.accentPrimary
iconTextColor: Theme.backgroundPrimary
textColor: Theme.textPrimary
}
Component.onCompleted: {
if (brightness >= 0) {
pill.show()
}
}
}

View file

@ -0,0 +1,18 @@
import QtQuick
import qs.Settings
Rectangle {
width: textItem.paintedWidth
height: textItem.paintedHeight
color: "transparent"
Text {
id: textItem
text: Time.time
font.family: "Roboto"
font.weight: Font.Bold
font.pixelSize: 14
color: Theme.textPrimary
anchors.centerIn: parent
}
}

View file

@ -0,0 +1,137 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Settings
PopupWindow {
id: trayMenu
implicitWidth: 180
implicitHeight: Math.max(40, listView.contentHeight + 12)
visible: false
color: "transparent"
property QsMenuHandle menu
property var anchorItem: null
property real anchorX
property real anchorY
anchor.item: anchorItem ? anchorItem : null
anchor.rect.x: anchorX
anchor.rect.y: anchorY
function showAt(item, x, y) {
if (!item) {
console.warn("CustomTrayMenu: anchorItem is undefined, not showing menu.");
return;
}
anchorItem = item
anchorX = x
anchorY = y
visible = true
forceActiveFocus()
Qt.callLater(() => trayMenu.anchor.updateAnchor())
}
function hideMenu() {
visible = false
}
Item {
anchors.fill: parent
Keys.onEscapePressed: trayMenu.hideMenu()
}
QsMenuOpener {
id: opener
menu: trayMenu.menu
}
Rectangle {
id: bg
anchors.fill: parent
color: Theme.backgroundPrimary
border.color: Theme.accentPrimary
border.width: 2
radius: 20
z: 0
}
ListView {
id: listView
anchors.fill: parent
anchors.margins: 6
spacing: 2
interactive: false
enabled: trayMenu.visible
clip: true
model: ScriptModel {
values: opener.children ? [...opener.children.values] : []
}
delegate: Rectangle {
id: entry
required property var modelData
width: listView.width
height: (modelData?.isSeparator) ? 8 : 28
color: "transparent"
radius: 6
Rectangle {
anchors.centerIn: parent
width: parent.width - 20
height: 1
color: Qt.darker(Theme.backgroundPrimary, 1.4)
visible: modelData?.isSeparator ?? false
}
Rectangle {
anchors.fill: parent
color: mouseArea.containsMouse ? Theme.highlight : "transparent"
radius: 6
visible: !(modelData?.isSeparator ?? false)
RowLayout {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
Text {
Layout.fillWidth: true
color: (modelData?.enabled ?? true) ? Theme.textPrimary : Theme.textDisabled
text: modelData?.text ?? ""
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Image {
Layout.preferredWidth: 16
Layout.preferredHeight: 16
source: modelData?.icon ?? ""
visible: (modelData?.icon ?? "") !== ""
fillMode: Image.PreserveAspectFit
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: (modelData?.enabled ?? true) && !(modelData?.isSeparator ?? false) && trayMenu.visible
onClicked: {
if (modelData && !modelData.isSeparator) {
modelData.triggered()
trayMenu.hideMenu()
}
}
}
}
}
}
}

128
Bar/Modules/SystemTray.qml Normal file
View file

@ -0,0 +1,128 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Services.SystemTray
Row {
property var bar
property var shell
property var trayMenu
spacing: 8
Layout.alignment: Qt.AlignVCenter
property bool containsMouse: false
property var systemTray: SystemTray
Repeater {
model: systemTray.items
delegate: Item {
width: 24
height: 24
// Hide Spotify icon, or adjust to your liking
visible: modelData && modelData.id !== "spotify"
property bool isHovered: trayMouseArea.containsMouse
// Hover scale animation
scale: isHovered ? 1.15 : 1.0
Behavior on scale {
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
// Subtle rotation on hover
rotation: isHovered ? 5 : 0
Behavior on rotation {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
Image {
id: trayIcon
anchors.centerIn: parent
width: 18
height: 18
sourceSize.width: 18
sourceSize.height: 18
smooth: false // Memory savings
asynchronous: true
cache: false
source: {
let icon = modelData?.icon || "";
if (!icon) return "";
// Process icon path
if (icon.includes("?path=")) {
const [name, path] = icon.split("?path=");
const fileName = name.substring(name.lastIndexOf("/") + 1);
return `file://${path}/${fileName}`;
}
return icon;
}
opacity: status === Image.Ready ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: 300
easing.type: Easing.OutCubic
}
}
Component.onCompleted: {
// console.log("Tray icon for", modelData?.id, ":", modelData?.icon)
}
}
MouseArea {
id: trayMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: (mouse) => {
if (!modelData) return;
if (mouse.button === Qt.LeftButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
}
if (!modelData.onlyMenu) {
modelData.activate()
}
} else if (mouse.button === Qt.MiddleButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
}
modelData.secondaryActivate && modelData.secondaryActivate()
} else if (mouse.button === Qt.RightButton) {
console.log("Right click on", modelData.id, "hasMenu:", modelData.hasMenu, "menu:", modelData.menu)
// If menu is already visible, close it
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
return
}
if (modelData.hasMenu && modelData.menu && trayMenu) {
// Anchor the menu to the tray icon item (parent) and position it below the icon
const menuX = (width / 2) - (trayMenu.width / 2);
const menuY = height + 20;
trayMenu.menu = modelData.menu;
trayMenu.showAt(parent, menuX, menuY);
} else {
// console.log("No menu available for", modelData.id, "or trayMenu not set")
}
}
}
}
Component.onDestruction: {
// No cache cleanup needed
}
}
}
}

16
Bar/Modules/Time.qml Normal file
View file

@ -0,0 +1,16 @@
pragma Singleton
import Quickshell
import QtQuick
Singleton {
id: root
readonly property string time: {
Qt.formatDateTime(clock.date, "hh:mm")
}
SystemClock {
id: clock
precision: SystemClock.Seconds
}
}

44
Bar/Modules/Volume.qml Normal file
View file

@ -0,0 +1,44 @@
import QtQuick
import Quickshell
import qs.Settings
import qs.Components
Item {
id: volumeDisplay
property var shell
property int volume: 0
// The total width will match the pill's width
width: pillIndicator.width
height: pillIndicator.height
PillIndicator {
id: pillIndicator
icon: volume === 0 ? "volume_off" : (volume < 30 ? "volume_down" : "volume_up")
text: volume + "%"
pillColor: Theme.surfaceVariant
iconCircleColor: Theme.accentPrimary
iconTextColor: Theme.backgroundPrimary
textColor: Theme.textPrimary
}
Connections {
target: shell ?? null
function onVolumeChanged() {
if (shell && shell.volume !== volume) {
volume = shell.volume
pillIndicator.text = volume + "%"
pillIndicator.icon = volume === 0 ? "volume_off" : (volume < 30 ? "volume_down" : "volume_up")
pillIndicator.show()
}
}
}
Component.onCompleted: {
if (shell && shell.volume !== undefined) {
volume = shell.volume
pillIndicator.show()
}
}
}

430
Bar/Modules/Wifi.qml Normal file
View file

@ -0,0 +1,430 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Settings
Item {
id: wifiDisplay
width: 22
height: 22
property color hoverColor: Theme.rippleEffect
property real hoverOpacity: 0.0
property bool isActive: mouseArea.containsMouse || (wifiPanel && wifiPanel.visible)
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onClicked: {
if (wifiPanel.visible) {
wifiPanel.hidePopup();
} else {
wifiPanel.showAt(this, 0, parent.height);
}
}
onEntered: wifiDisplay.hoverOpacity = 0.18
onExited: wifiDisplay.hoverOpacity = 0.0
}
Rectangle {
anchors.fill: parent
anchors.margins: -4
color: hoverColor
opacity: isActive ? 0.18 : hoverOpacity
radius: height / 2
z: 0
visible: opacity > 0.01
}
Text {
anchors.centerIn: parent
text: "wifi"
font.family: isActive ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 18
color: wifiPanel.visible ? Theme.accentPrimary : Theme.textPrimary
z: 1
}
PanelWindow {
id: wifiPanel
implicitWidth: 350
implicitHeight: 400
color: "transparent"
visible: false
property var anchorItem: null
property real anchorX
property real anchorY
property var networks: [] // { ssid, signal, security, connected }
property string connectingSsid: ""
property string errorMsg: ""
property string passwordPromptSsid: ""
property string passwordInput: ""
property bool showPasswordPrompt: false
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
function showAt(item, x, y) {
if (!item) {
console.warn("Wifi: anchorItem is undefined, not showing panel.");
return;
}
anchorItem = item
anchorX = x
anchorY = y
visible = true
refreshNetworks()
Qt.callLater(() => {
wifiPanel.x = anchorX - (wifiPanel.width / 2) + (anchorItem ? anchorItem.width / 2 : 0)
wifiPanel.y = anchorY + 8
})
}
function hidePopup() {
visible = false
showPasswordPrompt = false
errorMsg = ""
}
// Scan for networks
Process {
id: scanProcess
running: false
command: ["nmcli", "-t", "-f", "SSID,SECURITY,SIGNAL,IN-USE", "device", "wifi", "list"]
stdout: StdioCollector {
onStreamFinished: {
var lines = text.split("\n");
var nets = [];
var seen = {};
for (var i = 0; i < lines.length; ++i) {
var line = lines[i].trim();
if (!line) continue;
var parts = line.split(":");
var ssid = parts[0];
var security = parts[1];
var signal = parseInt(parts[2]);
var inUse = parts[3] === "*";
if (ssid && !seen[ssid]) {
nets.push({ ssid: ssid, security: security, signal: signal, connected: inUse });
seen[ssid] = true;
}
}
wifiPanel.networks = nets;
}
}
}
function refreshNetworks() {
scanProcess.running = true;
}
// Connect to a network
Process {
id: connectProcess
property string ssid: ""
property string password: ""
running: false
command: password ? ["nmcli", "device", "wifi", "connect", ssid, "password", password] : ["nmcli", "device", "wifi", "connect", ssid]
stdout: StdioCollector {
onStreamFinished: {
wifiPanel.connectingSsid = "";
wifiPanel.showPasswordPrompt = false;
wifiPanel.errorMsg = "";
refreshNetworks();
}
}
stderr: StdioCollector {
onStreamFinished: {
wifiPanel.connectingSsid = "";
wifiPanel.errorMsg = text;
wifiPanel.showPasswordPrompt = false;
}
}
}
function connectNetwork(ssid, security) {
if (security && security !== "--") {
// Prompt for password
passwordPromptSsid = ssid;
passwordInput = "";
showPasswordPrompt = true;
} else {
connectingSsid = ssid;
connectProcess.ssid = ssid;
connectProcess.password = "";
connectProcess.running = true;
}
}
function submitPassword() {
connectingSsid = passwordPromptSsid;
connectProcess.ssid = passwordPromptSsid;
connectProcess.password = passwordInput;
connectProcess.running = true;
}
// Disconnect
Process {
id: disconnectProcess
property string ssid: ""
running: false
command: ["nmcli", "connection", "down", "id", ssid]
onRunningChanged: {
if (!running) {
refreshNetworks();
}
}
}
function disconnectNetwork(ssid) {
disconnectProcess.ssid = ssid;
disconnectProcess.running = true;
}
// UI
Rectangle {
id: bg
anchors.fill: parent
radius: 12
border.width: 1
border.color: Theme.surfaceVariant
color: Theme.backgroundPrimary
z: 0
}
// Header
Rectangle {
id: header
width: parent.width
height: 56
color: "transparent"
Text {
text: "Wi-Fi"
font.pixelSize: 18
font.bold: true
color: Theme.textPrimary
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 16
}
// Refresh button
Rectangle {
id: refreshBtn
width: 36
height: 36
radius: 18
color: Theme.surfaceVariant
border.color: refreshMouseArea.containsMouse ? Theme.accentPrimary : Theme.outline
border.width: 1
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: 12
MouseArea {
id: refreshMouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: wifiPanel.refreshNetworks()
}
Text {
anchors.centerIn: parent
text: "refresh"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Theme.accentPrimary
}
}
}
// Error message
Text {
visible: wifiPanel.errorMsg.length > 0
text: wifiPanel.errorMsg
color: Theme.error
font.pixelSize: 12
anchors.top: header.bottom
anchors.left: parent.left
anchors.leftMargin: 16
anchors.topMargin: 2
}
// Device list container
Rectangle {
id: listContainer
anchors.top: header.bottom
anchors.topMargin: wifiPanel.showPasswordPrompt ? 68 : 0
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 8
color: "transparent"
clip: true
ListView {
id: networkListView
anchors.fill: parent
spacing: 4
boundsBehavior: Flickable.StopAtBounds
model: wifiPanel.networks
delegate: Rectangle {
id: networkEntry
width: parent.width
height: modelData.ssid === wifiPanel.passwordPromptSsid && wifiPanel.showPasswordPrompt ? 110 : 42
color: "transparent"
radius: 8
Rectangle {
anchors.fill: parent
radius: 8
color: modelData.connected ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.18)
: ((networkMouseArea.containsMouse || (modelData.ssid === wifiPanel.passwordPromptSsid && wifiPanel.showPasswordPrompt)) ? Theme.highlight : "transparent")
z: 0
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 0
anchors.bottomMargin: 0
anchors.topMargin: 0
height: 42
spacing: 12
// Signal icon
Text {
text: signalIcon(modelData.signal)
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: hovered ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
verticalAlignment: Text.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: modelData.ssid || "Unknown Network"
color: hovered ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textPrimary)
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: modelData.security && modelData.security !== "--" ? modelData.security : "Open"
color: hovered ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
font.pixelSize: 11
elide: Text.ElideRight
Layout.fillWidth: true
}
}
// Status
Text {
visible: modelData.connected
text: "connected"
color: hovered ? Theme.backgroundPrimary : Theme.accentPrimary
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
}
}
// Password prompt dropdown (only for selected network)
Rectangle {
visible: modelData.ssid === wifiPanel.passwordPromptSsid && wifiPanel.showPasswordPrompt
width: parent.width
height: 60
radius: 8
color: Theme.surfaceVariant
border.color: passwordField.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
anchors.bottom: parent.bottom
z: 2
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Theme.surfaceVariant
border.color: passwordField.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: passwordField
anchors.fill: parent
anchors.margins: 12
text: wifiPanel.passwordInput
font.pixelSize: 13
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
echoMode: TextInput.Password
onTextChanged: wifiPanel.passwordInput = text
onAccepted: wifiPanel.submitPassword()
MouseArea {
id: passwordMouseArea
anchors.fill: parent
onClicked: passwordField.forceActiveFocus()
}
}
}
Rectangle {
width: 80
height: 36
radius: 18
color: Theme.accentPrimary
border.color: Theme.accentPrimary
border.width: 0
MouseArea {
anchors.fill: parent
onClicked: wifiPanel.submitPassword()
cursorShape: Qt.PointingHandCursor
}
Text {
anchors.centerIn: parent
text: "Connect"
color: Theme.backgroundPrimary
font.pixelSize: 14
font.bold: true
}
}
}
}
MouseArea {
id: networkMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
wifiPanel.disconnectNetwork(modelData.ssid)
} else if (modelData.security && modelData.security !== "--") {
wifiPanel.passwordPromptSsid = modelData.ssid;
wifiPanel.passwordInput = "";
wifiPanel.showPasswordPrompt = true;
} else {
wifiPanel.connectNetwork(modelData.ssid, modelData.security)
}
}
}
// Helper for hover text color
property bool hovered: networkMouseArea.containsMouse || (modelData.ssid === wifiPanel.passwordPromptSsid && wifiPanel.showPasswordPrompt)
}
}
}
// Scroll indicator
Rectangle {
anchors.right: parent.right
anchors.rightMargin: 2
anchors.top: listContainer.top
anchors.bottom: listContainer.bottom
width: 4
radius: 2
color: Theme.textSecondary
opacity: networkListView.contentHeight > networkListView.height ? 0.3 : 0
visible: opacity > 0
}
}
// Helper for signal icon
function signalIcon(signal) {
if (signal >= 80) return "network_wifi_4_bar";
if (signal >= 60) return "network_wifi_3_bar";
if (signal >= 40) return "network_wifi_2_bar";
if (signal >= 20) return "network_wifi_1_bar";
return "wifi_0_bar";
}
}

229
Bar/Modules/Workspace.qml Normal file
View file

@ -0,0 +1,229 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import Qt5Compat.GraphicalEffects
import Quickshell.Io
import qs.Settings
import qs.Services
Item {
id: root
property ListModel workspaces: ListModel {}
property bool isDestroying: false
property bool hovered: false
signal workspaceChanged(int workspaceId, color accentColor)
property real masterProgress: 0.0
property bool effectsActive: false
property color effectColor: Theme.accentPrimary
property int horizontalPadding: 16
property int spacingBetweenPills: 8
width: {
let total = 0
for (let i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i)
if (ws.isFocused) total += 44
else if (ws.isActive) total += 28
else total += 16
}
total += Math.max(workspaces.count - 1, 0) * spacingBetweenPills
total += horizontalPadding * 2
return total
}
height: 36
Component.onCompleted: updateWorkspaceList()
Connections {
target: Niri
function onWorkspacesChanged() { updateWorkspaceList(); }
function onFocusedWorkspaceIndexChanged() { updateWorkspaceFocus(); }
}
function triggerUnifiedWave() {
effectColor = Theme.accentPrimary
masterAnimation.restart()
}
SequentialAnimation {
id: masterAnimation
PropertyAction { target: root; property: "effectsActive"; value: true }
NumberAnimation {
target: root
property: "masterProgress"
from: 0.0
to: 1.0
duration: 1000
easing.type: Easing.OutQuint
}
PropertyAction { target: root; property: "effectsActive"; value: false }
PropertyAction { target: root; property: "masterProgress"; value: 0.0 }
}
function updateWorkspaceList() {
const newList = Niri.workspaces || []
workspaces.clear()
for (let i = 0; i < newList.length; i++) {
const ws = newList[i]
workspaces.append({
id: ws.id,
idx: ws.idx,
name: ws.name || "",
output: ws.output,
isActive: ws.is_active,
isFocused: ws.is_focused,
isUrgent: ws.is_urgent
})
}
updateWorkspaceFocus()
}
function updateWorkspaceFocus() {
const focusedId = Niri.workspaces?.[Niri.focusedWorkspaceIndex]?.id ?? -1
for (let i = 0; i < workspaces.count; i++) {
const ws = workspaces.get(i)
const isFocused = ws.id === focusedId
const isActive = isFocused
if (ws.isFocused !== isFocused || ws.isActive !== isActive) {
workspaces.setProperty(i, "isFocused", isFocused)
workspaces.setProperty(i, "isActive", isActive)
if (isFocused) {
root.triggerUnifiedWave()
root.workspaceChanged(ws.id, Theme.accentPrimary)
}
}
}
}
Rectangle {
id: workspaceBackground
width: parent.width - 15
height: 26
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
radius: 12
color: Theme.surfaceVariant
border.color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.1)
border.width: 1
layer.enabled: true
layer.effect: DropShadow {
color: "black"
radius: 12
samples: 24
verticalOffset: 0
horizontalOffset: 0
opacity: 0.10
}
}
Row {
id: pillRow
spacing: spacingBetweenPills
anchors.verticalCenter: workspaceBackground.verticalCenter
width: root.width - horizontalPadding * 2
x: horizontalPadding
Repeater {
model: root.workspaces
Rectangle {
id: workspacePill
height: 12
width: {
if (model.isFocused) return 44
else if (model.isActive) return 28
else return 16
}
radius: {
if (model.isFocused) return 12 // half of focused height (if you want to animate this too)
else return 6
}
color: {
if (model.isFocused) return Theme.accentPrimary
if (model.isActive) return Theme.accentPrimary.lighter(130)
if (model.isUrgent) return Theme.error
return Qt.lighter(Theme.surfaceVariant, 1.6)
}
scale: model.isFocused ? 1.0 : 0.9
z: 0
// Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius
Behavior on width {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on height {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
Behavior on scale {
NumberAnimation {
duration: 300
easing.type: Easing.OutBack
}
}
Behavior on color {
ColorAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
Behavior on opacity {
NumberAnimation {
duration: 200
easing.type: Easing.InOutCubic
}
}
Behavior on radius {
NumberAnimation {
duration: 350
easing.type: Easing.OutBack
}
}
// Burst effect overlay for focused pill (smaller outline)
Rectangle {
id: pillBurst
anchors.centerIn: parent
width: parent.width + 18 * root.masterProgress
height: parent.height + 18 * root.masterProgress
radius: width / 2
color: "transparent"
border.color: root.effectColor
border.width: 2 + 6 * (1.0 - root.masterProgress)
opacity: root.effectsActive && model.isFocused
? (1.0 - root.masterProgress) * 0.7
: 0
visible: root.effectsActive && model.isFocused
z: 1
}
}
}
}
// MouseArea to open/close Applauncher
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
if (appLauncherPanel && appLauncherPanel.visible) {
appLauncherPanel.hidePanel();
} else if (appLauncherPanel) {
appLauncherPanel.showAt();
}
}
z: 1000 // ensure it's above other content
hoverEnabled: true
onEntered: root.hovered = true
onExited: root.hovered = false
}
Component.onDestruction: {
root.isDestroying = true
}
}

68
Components/Cava.qml Normal file
View file

@ -0,0 +1,68 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Components
Scope {
id: root
property int count: 32
property int noiseReduction: 60
property string channels: "mono" // or stereo
property string monoOption: "average" // or left or right
property var config: ({
general: { bars: count },
smoothing: { noise_reduction: noiseReduction },
output: {
method: "raw",
bit_format: 8,
channels: channels,
mono_option: monoOption,
}
})
property var values: Array(count).fill(0) // 0 <= value <= 1
onConfigChanged: {
process.running = false
process.running = true
}
Process {
property int index: 0
id: process
stdinEnabled: true
command: ["cava", "-p", "/dev/stdin"]
onExited: { stdinEnabled = true; index = 0 }
onStarted: {
const iniParts = []
for (const k in config) {
if (typeof config[k] !== "object") {
write(k + "=" + config[k] + "\n")
continue
}
write("[" + k + "]\n")
const obj = config[k]
for (const k2 in obj) {
write(k2 + "=" + obj[k2] + "\n")
}
}
stdinEnabled = false
}
stdout: SplitParser {
property var newValues: Array(count).fill(0)
splitMarker: ""
onRead: data => {
if (process.index + data.length > config.general.bars) {
process.index = 0
}
for (let i = 0; i < data.length; i += 1) {
newValues[i + process.index] = Math.min(data.charCodeAt(i), 128) / 128
}
process.index += data.length
if (newValues.length !== values.length) {
console.log("length!", values.length, newValues.length)
}
values = newValues
}
}
}
}

View file

@ -0,0 +1,134 @@
import QtQuick
import qs.Settings
Rectangle {
id: circularProgressBar
color: "transparent"
// Properties
property real progress: 0.0 // 0.0 to 1.0
property int size: 80
property color backgroundColor: Theme.surfaceVariant
property color progressColor: Theme.accentPrimary
property int strokeWidth: 6
property bool showText: true
property string text: Math.round(progress * 100) + "%"
property int textSize: 10
property color textColor: Theme.textPrimary
// Notch properties
property bool hasNotch: false
property real notchSize: 0.25 // Size of the notch as a fraction of the circle
property string notchIcon: ""
property int notchIconSize: 12
property color notchIconColor: Theme.accentPrimary
width: size
height: size
Canvas {
id: canvas
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
var centerX = width / 2
var centerY = height / 2
var radius = Math.min(width, height) / 2 - strokeWidth / 2
var startAngle = -Math.PI / 2 // Start from top
var notchAngle = notchSize * 2 * Math.PI
var notchStartAngle = -notchAngle / 2
var notchEndAngle = notchAngle / 2
// Clear canvas
ctx.reset()
// Background circle
ctx.strokeStyle = backgroundColor
ctx.lineWidth = strokeWidth
ctx.lineCap = "round"
ctx.beginPath()
if (hasNotch) {
// Draw background circle with notch on the right side
// Draw the arc excluding the notch area (notch is at 0 radians, right side)
ctx.arc(centerX, centerY, radius, notchEndAngle, 2 * Math.PI + notchStartAngle)
} else {
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
}
ctx.stroke()
// Progress arc
if (progress > 0) {
ctx.strokeStyle = progressColor
ctx.lineWidth = strokeWidth
ctx.lineCap = "round"
ctx.beginPath()
if (hasNotch) {
// Calculate progress with notch consideration
var availableAngle = 2 * Math.PI - notchAngle
var progressAngle = availableAngle * progress
// Start from where the notch cutout begins (top-right) and go clockwise
var adjustedStartAngle = notchEndAngle
var adjustedEndAngle = adjustedStartAngle + progressAngle
// Ensure we don't exceed the available space
if (adjustedEndAngle > 2 * Math.PI + notchStartAngle) {
adjustedEndAngle = 2 * Math.PI + notchStartAngle
}
if (adjustedEndAngle > adjustedStartAngle) {
ctx.arc(centerX, centerY, radius, adjustedStartAngle, adjustedEndAngle)
}
} else {
ctx.arc(centerX, centerY, radius, startAngle, startAngle + (2 * Math.PI * progress))
}
ctx.stroke()
}
}
}
// Center text - always show the percentage
Text {
id: centerText
anchors.centerIn: parent
text: circularProgressBar.text
font.pixelSize: textSize
font.bold: true
color: textColor
visible: showText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Notch icon - positioned further to the right
Text {
id: notchIconText
anchors.right: parent.right
anchors.rightMargin: -4
anchors.verticalCenter: parent.verticalCenter
text: notchIcon
font.family: "Material Symbols Outlined"
font.pixelSize: notchIconSize
color: notchIconColor
visible: hasNotch && notchIcon !== ""
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
// Animate progress changes
Behavior on progress {
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
// Redraw canvas when properties change
onProgressChanged: canvas.requestPaint()
onSizeChanged: canvas.requestPaint()
onBackgroundColorChanged: canvas.requestPaint()
onProgressColorChanged: canvas.requestPaint()
onStrokeWidthChanged: canvas.requestPaint()
onHasNotchChanged: canvas.requestPaint()
onNotchSizeChanged: canvas.requestPaint()
}

View file

@ -0,0 +1,47 @@
import QtQuick
import qs.Components
Item {
id: root
property int innerRadius: 34
property int outerRadius: 48
property int barCount: 40
property color fillColor: "#fff"
property color strokeColor: "#fff"
property int strokeWidth: 0
width: outerRadius * 2
height: outerRadius * 2
// Cava input
Cava {
id: cava
count: root.barCount
}
Repeater {
model: root.barCount
Rectangle {
property real value: cava.values[index]
property real angle: (index / root.barCount) * 360
width: Math.max(2, (root.innerRadius * 2 * Math.PI) / root.barCount - 4)
height: value * (root.outerRadius - root.innerRadius)
radius: width / 2
color: root.fillColor
border.color: root.strokeColor
border.width: root.strokeWidth
antialiasing: true
x: root.width / 2 + (root.innerRadius) * Math.cos(Math.PI/2 + 2 * Math.PI * index / root.barCount) - width / 2
y: root.height / 2 - (root.innerRadius) * Math.sin(Math.PI/2 + 2 * Math.PI * index / root.barCount) - height
transform: Rotation {
origin.x: width / 2
origin.y: height
angle: -angle
}
Behavior on height { SmoothedAnimation { duration: 120 } }
}
}
}

86
Components/Corners.qml Normal file
View file

@ -0,0 +1,86 @@
import QtQuick
import QtQuick.Shapes
import qs.Settings
Shape {
id: root
property string position: "topleft" // Corner position: topleft/topright/bottomleft/bottomright
property real size: 1.0 // Scale multiplier for entire corner
property int concaveWidth: 100 * size
property int concaveHeight: 60 * size
property int offsetX: -20
property int offsetY: -20
property color fillColor: Theme.accentPrimary
property int arcRadius: 20 * size
property var modelData: null
// Position flags derived from position string
property bool _isTop: position.includes("top")
property bool _isLeft: position.includes("left")
property bool _isRight: position.includes("right")
property bool _isBottom: position.includes("bottom")
// Shift the path vertically if offsetY is negative to pull shape up
property real pathOffsetY: Math.min(offsetY, 0)
// Base coordinates for left corner shape, shifted by pathOffsetY vertically
property real _baseStartX: 30 * size
property real _baseStartY: (_isTop ? 20 * size : 0) + pathOffsetY
property real _baseLineX: 30 * size
property real _baseLineY: (_isTop ? 0 : 20 * size) + pathOffsetY
property real _baseArcX: 50 * size
property real _baseArcY: (_isTop ? 20 * size : 0) + pathOffsetY
// Mirror coordinates for right corners
property real _startX: _isRight ? (concaveWidth - _baseStartX) : _baseStartX
property real _startY: _baseStartY
property real _lineX: _isRight ? (concaveWidth - _baseLineX) : _baseLineX
property real _lineY: _baseLineY
property real _arcX: _isRight ? (concaveWidth - _baseArcX) : _baseArcX
property real _arcY: _baseArcY
// Arc direction varies by corner to maintain proper concave shape
property int _arcDirection: {
if (_isTop && _isLeft) return PathArc.Counterclockwise
if (_isTop && _isRight) return PathArc.Clockwise
if (_isBottom && _isLeft) return PathArc.Clockwise
if (_isBottom && _isRight) return PathArc.Counterclockwise
return PathArc.Counterclockwise
}
width: concaveWidth
height: concaveHeight
// Position relative to parent based on corner type
x: _isLeft ? offsetX : (parent ? parent.width - width + offsetX : 0)
y: _isTop ? offsetY : (parent ? parent.height - height + offsetY : 0)
preferredRendererType: Shape.CurveRenderer
layer.enabled: true
layer.samples: 4
ShapePath {
strokeWidth: 0
fillColor: root.fillColor
strokeColor: root.fillColor
startX: root._startX
startY: root._startY
PathLine {
x: root._lineX
y: root._lineY
}
PathArc {
x: root._arcX
y: root._arcY
radiusX: root.arcRadius
radiusY: root.arcRadius
useLargeArc: false
direction: root._arcDirection
}
}
}

View file

@ -0,0 +1,168 @@
import QtQuick
import QtQuick.Controls
import qs.Settings
Item {
id: revealPill
// External properties
property string icon: ""
property string text: ""
property color pillColor: Theme.surfaceVariant
property color textColor: Theme.textPrimary
property color iconCircleColor: Theme.accentPrimary
property color iconTextColor: Theme.backgroundPrimary
property int pillHeight: 22
property int iconSize: 22
property int pillPaddingHorizontal: 14
// Internal state
property bool showPill: false
property bool shouldAnimateHide: false
// Exposed width logic
readonly property int pillOverlap: iconSize / 2
readonly property int maxPillWidth: Math.max(1, textItem.implicitWidth + pillPaddingHorizontal * 2 + pillOverlap)
signal shown()
signal hidden()
width: iconSize + (showPill ? maxPillWidth - pillOverlap : 0)
height: pillHeight
Rectangle {
id: pill
width: showPill ? maxPillWidth : 1 // Never 0 width
height: pillHeight
x: (iconCircle.x + iconCircle.width / 2) - width
opacity: showPill ? 1 : 0
color: pillColor
topLeftRadius: pillHeight / 2
bottomLeftRadius: pillHeight / 2
anchors.verticalCenter: parent.verticalCenter
Text {
id: textItem
anchors.centerIn: parent
text: revealPill.text
font.pixelSize: 14
font.weight: Font.Bold
color: textColor
visible: showPill // Hide text when pill is collapsed
}
Behavior on width {
enabled: showAnim.running || hideAnim.running
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
}
Behavior on opacity {
enabled: showAnim.running || hideAnim.running
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
}
}
// Icon circle
Rectangle {
id: iconCircle
width: iconSize
height: iconSize
radius: width / 2
color: showPill ? iconCircleColor : "transparent"
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
Behavior on color {
ColorAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
Text {
anchors.centerIn: parent
font.family: showPill ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 14
text: revealPill.icon
color: showPill ? iconTextColor : textColor
}
}
// Show animation
ParallelAnimation {
id: showAnim
running: false
NumberAnimation {
target: pill
property: "width"
from: 1 // Start from 1 instead of 0
to: maxPillWidth
duration: 250
easing.type: Easing.OutCubic
}
NumberAnimation {
target: pill
property: "opacity"
from: 0
to: 1
duration: 250
easing.type: Easing.OutCubic
}
onStarted: {
showPill = true
}
onStopped: {
delayedHideAnim.start()
shown()
}
}
// Delayed auto-hide
SequentialAnimation {
id: delayedHideAnim
running: false
PauseAnimation { duration: 2500 }
ScriptAction { script: if (shouldAnimateHide) hideAnim.start() }
}
// Hide animation
ParallelAnimation {
id: hideAnim
running: false
NumberAnimation {
target: pill
property: "width"
from: maxPillWidth
to: 1 // End at 1 instead of 0
duration: 250
easing.type: Easing.InCubic
}
NumberAnimation {
target: pill
property: "opacity"
from: 1
to: 0
duration: 250
easing.type: Easing.InCubic
}
onStopped: {
showPill = false
shouldAnimateHide = false
hidden()
}
}
// Exposed functions
function show() {
if (!showPill) {
shouldAnimateHide = true
showAnim.start()
} else {
// Reset hide timer if already shown
hideAnim.stop()
delayedHideAnim.restart()
}
}
function hide() {
if (showPill) {
hideAnim.start()
}
}
}

View file

@ -0,0 +1,70 @@
import QtQuick
import Quickshell.Io
QtObject {
// List all known devices
function listDevices(callback) {
var proc = Qt.createQmlObject('
import Quickshell.Io;\n\
Process {\n\
command: ["bluetoothctl", "devices"],\n\
running: true;\n\
stdout: StdioCollector {\n\
onStreamFinished: {\n\
var lines = this.text.split("\n");\n\
var devs = [];\n\
for (var i = 0; i < lines.length; ++i) {\n\
var line = lines[i].trim();\n\
if (line.startsWith("Device ")) {\n\
var parts = line.split(" ");\n\
var mac = parts[1];\n\
var name = parts.slice(2).join(" ");\n\
devs.push({ mac: mac, name: name });\n\
}\n\
}\n\
callback(devs);\n\
parent.destroy();\n\
}\n\
}\n\
}', this);
}
// Check if a device is connected
function checkConnected(mac, callback) {
var proc = Qt.createQmlObject('
import Quickshell.Io;\n\
Process {\n\
command: ["bluetoothctl", "info", "' + mac + '"],\n\
running: true;\n\
stdout: StdioCollector {\n\
onStreamFinished: {\n\
var connected = this.text.indexOf("Connected: yes") !== -1;\n\
callback(connected);\n\
parent.destroy();\n\
}\n\
}\n\
}', this);
}
// Connect to a device
function connect(mac, callback) {
var proc = Qt.createQmlObject('
import Quickshell.Io;\n\
Process {\n\
command: ["bluetoothctl", "connect", "' + mac + '"],\n\
running: true;\n\
stdout: StdioCollector { onStreamFinished: { callback(true); parent.destroy(); } }\n\
}', this);
}
// Disconnect from a device
function disconnect(mac, callback) {
var proc = Qt.createQmlObject('
import Quickshell.Io;\n\
Process {\n\
command: ["bluetoothctl", "disconnect", "' + mac + '"],\n\
running: true;\n\
stdout: StdioCollector { onStreamFinished: { callback(true); parent.destroy(); } }\n\
}', this);
}
}

678
Helpers/Fuzzysort.js Normal file
View file

@ -0,0 +1,678 @@
.pragma library
var single = (search, target) => {
if(!search || !target) return NULL
var preparedSearch = getPreparedSearch(search)
if(!isPrepared(target)) target = getPrepared(target)
var searchBitflags = preparedSearch.bitflags
if((searchBitflags & target._bitflags) !== searchBitflags) return NULL
return algorithm(preparedSearch, target)
}
var go = (search, targets, options) => {
if(!search) return options?.all ? all(targets, options) : noResults
var preparedSearch = getPreparedSearch(search)
var searchBitflags = preparedSearch.bitflags
var containsSpace = preparedSearch.containsSpace
var threshold = denormalizeScore( options?.threshold || 0 )
var limit = options?.limit || INFINITY
var resultsLen = 0; var limitedCount = 0
var targetsLen = targets.length
function push_result(result) {
if(resultsLen < limit) { q.add(result); ++resultsLen }
else {
++limitedCount
if(result._score > q.peek()._score) q.replaceTop(result)
}
}
// This code is copy/pasted 3 times for performance reasons [options.key, options.keys, no keys]
// options.key
if(options?.key) {
var key = options.key
for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
var target = getValue(obj, key)
if(!target) continue
if(!isPrepared(target)) target = getPrepared(target)
if((searchBitflags & target._bitflags) !== searchBitflags) continue
var result = algorithm(preparedSearch, target)
if(result === NULL) continue
if(result._score < threshold) continue
result.obj = obj
push_result(result)
}
// options.keys
} else if(options?.keys) {
var keys = options.keys
var keysLen = keys.length
outer: for(var i = 0; i < targetsLen; ++i) { var obj = targets[i]
{ // early out based on bitflags
var keysBitflags = 0
for (var keyI = 0; keyI < keysLen; ++keyI) {
var key = keys[keyI]
var target = getValue(obj, key)
if(!target) { tmpTargets[keyI] = noTarget; continue }
if(!isPrepared(target)) target = getPrepared(target)
tmpTargets[keyI] = target
keysBitflags |= target._bitflags
}
if((searchBitflags & keysBitflags) !== searchBitflags) continue
}
if(containsSpace) for(let i=0; i<preparedSearch.spaceSearches.length; i++) keysSpacesBestScores[i] = NEGATIVE_INFINITY
for (var keyI = 0; keyI < keysLen; ++keyI) {
target = tmpTargets[keyI]
if(target === noTarget) { tmpResults[keyI] = noTarget; continue }
tmpResults[keyI] = algorithm(preparedSearch, target, /*allowSpaces=*/false, /*allowPartialMatch=*/containsSpace)
if(tmpResults[keyI] === NULL) { tmpResults[keyI] = noTarget; continue }
// todo: this seems weird and wrong. like what if our first match wasn't good. this should just replace it instead of averaging with it
// if our second match isn't good we ignore it instead of averaging with it
if(containsSpace) for(let i=0; i<preparedSearch.spaceSearches.length; i++) {
if(allowPartialMatchScores[i] > -1000) {
if(keysSpacesBestScores[i] > NEGATIVE_INFINITY) {
var tmp = (keysSpacesBestScores[i] + allowPartialMatchScores[i]) / 4/*bonus score for having multiple matches*/
if(tmp > keysSpacesBestScores[i]) keysSpacesBestScores[i] = tmp
}
}
if(allowPartialMatchScores[i] > keysSpacesBestScores[i]) keysSpacesBestScores[i] = allowPartialMatchScores[i]
}
}
if(containsSpace) {
for(let i=0; i<preparedSearch.spaceSearches.length; i++) { if(keysSpacesBestScores[i] === NEGATIVE_INFINITY) continue outer }
} else {
var hasAtLeast1Match = false
for(let i=0; i < keysLen; i++) { if(tmpResults[i]._score !== NEGATIVE_INFINITY) { hasAtLeast1Match = true; break } }
if(!hasAtLeast1Match) continue
}
var objResults = new KeysResult(keysLen)
for(let i=0; i < keysLen; i++) { objResults[i] = tmpResults[i] }
if(containsSpace) {
var score = 0
for(let i=0; i<preparedSearch.spaceSearches.length; i++) score += keysSpacesBestScores[i]
} else {
// todo could rewrite this scoring to be more similar to when there's spaces
// if we match multiple keys give us bonus points
var score = NEGATIVE_INFINITY
for(let i=0; i<keysLen; i++) {
var result = objResults[i]
if(result._score > -1000) {
if(score > NEGATIVE_INFINITY) {
var tmp = (score + result._score) / 4/*bonus score for having multiple matches*/
if(tmp > score) score = tmp
}
}
if(result._score > score) score = result._score
}
}
objResults.obj = obj
objResults._score = score
if(options?.scoreFn) {
score = options.scoreFn(objResults)
if(!score) continue
score = denormalizeScore(score)
objResults._score = score
}
if(score < threshold) continue
push_result(objResults)
}
// no keys
} else {
for(var i = 0; i < targetsLen; ++i) { var target = targets[i]
if(!target) continue
if(!isPrepared(target)) target = getPrepared(target)
if((searchBitflags & target._bitflags) !== searchBitflags) continue
var result = algorithm(preparedSearch, target)
if(result === NULL) continue
if(result._score < threshold) continue
push_result(result)
}
}
if(resultsLen === 0) return noResults
var results = new Array(resultsLen)
for(var i = resultsLen - 1; i >= 0; --i) results[i] = q.poll()
results.total = resultsLen + limitedCount
return results
}
// this is written as 1 function instead of 2 for minification. perf seems fine ...
// except when minified. the perf is very slow
var highlight = (result, open='<b>', close='</b>') => {
var callback = typeof open === 'function' ? open : undefined
var target = result.target
var targetLen = target.length
var indexes = result.indexes
var highlighted = ''
var matchI = 0
var indexesI = 0
var opened = false
var parts = []
for(var i = 0; i < targetLen; ++i) { var char = target[i]
if(indexes[indexesI] === i) {
++indexesI
if(!opened) { opened = true
if(callback) {
parts.push(highlighted); highlighted = ''
} else {
highlighted += open
}
}
if(indexesI === indexes.length) {
if(callback) {
highlighted += char
parts.push(callback(highlighted, matchI++)); highlighted = ''
parts.push(target.substr(i+1))
} else {
highlighted += char + close + target.substr(i+1)
}
break
}
} else {
if(opened) { opened = false
if(callback) {
parts.push(callback(highlighted, matchI++)); highlighted = ''
} else {
highlighted += close
}
}
}
highlighted += char
}
return callback ? parts : highlighted
}
var prepare = (target) => {
if(typeof target === 'number') target = ''+target
else if(typeof target !== 'string') target = ''
var info = prepareLowerInfo(target)
return new_result(target, {_targetLower:info._lower, _targetLowerCodes:info.lowerCodes, _bitflags:info.bitflags})
}
var cleanup = () => { preparedCache.clear(); preparedSearchCache.clear() }
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
// Below this point is only internal code
class Result {
get ['indexes']() { return this._indexes.slice(0, this._indexes.len).sort((a,b)=>a-b) }
set ['indexes'](indexes) { return this._indexes = indexes }
['highlight'](open, close) { return highlight(this, open, close) }
get ['score']() { return normalizeScore(this._score) }
set ['score'](score) { this._score = denormalizeScore(score) }
}
class KeysResult extends Array {
get ['score']() { return normalizeScore(this._score) }
set ['score'](score) { this._score = denormalizeScore(score) }
}
var new_result = (target, options) => {
const result = new Result()
result['target'] = target
result['obj'] = options.obj ?? NULL
result._score = options._score ?? NEGATIVE_INFINITY
result._indexes = options._indexes ?? []
result._targetLower = options._targetLower ?? ''
result._targetLowerCodes = options._targetLowerCodes ?? NULL
result._nextBeginningIndexes = options._nextBeginningIndexes ?? NULL
result._bitflags = options._bitflags ?? 0
return result
}
var normalizeScore = score => {
if(score === NEGATIVE_INFINITY) return 0
if(score > 1) return score
return Math.E ** ( ((-score + 1)**.04307 - 1) * -2)
}
var denormalizeScore = normalizedScore => {
if(normalizedScore === 0) return NEGATIVE_INFINITY
if(normalizedScore > 1) return normalizedScore
return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307)
}
var prepareSearch = (search) => {
if(typeof search === 'number') search = ''+search
else if(typeof search !== 'string') search = ''
search = search.trim()
var info = prepareLowerInfo(search)
var spaceSearches = []
if(info.containsSpace) {
var searches = search.split(/\s+/)
searches = [...new Set(searches)] // distinct
for(var i=0; i<searches.length; i++) {
if(searches[i] === '') continue
var _info = prepareLowerInfo(searches[i])
spaceSearches.push({lowerCodes:_info.lowerCodes, _lower:searches[i].toLowerCase(), containsSpace:false})
}
}
return {lowerCodes: info.lowerCodes, _lower: info._lower, containsSpace: info.containsSpace, bitflags: info.bitflags, spaceSearches: spaceSearches}
}
var getPrepared = (target) => {
if(target.length > 999) return prepare(target) // don't cache huge targets
var targetPrepared = preparedCache.get(target)
if(targetPrepared !== undefined) return targetPrepared
targetPrepared = prepare(target)
preparedCache.set(target, targetPrepared)
return targetPrepared
}
var getPreparedSearch = (search) => {
if(search.length > 999) return prepareSearch(search) // don't cache huge searches
var searchPrepared = preparedSearchCache.get(search)
if(searchPrepared !== undefined) return searchPrepared
searchPrepared = prepareSearch(search)
preparedSearchCache.set(search, searchPrepared)
return searchPrepared
}
var all = (targets, options) => {
var results = []; results.total = targets.length // this total can be wrong if some targets are skipped
var limit = options?.limit || INFINITY
if(options?.key) {
for(var i=0;i<targets.length;i++) { var obj = targets[i]
var target = getValue(obj, options.key)
if(target == NULL) continue
if(!isPrepared(target)) target = getPrepared(target)
var result = new_result(target.target, {_score: target._score, obj: obj})
results.push(result); if(results.length >= limit) return results
}
} else if(options?.keys) {
for(var i=0;i<targets.length;i++) { var obj = targets[i]
var objResults = new KeysResult(options.keys.length)
for (var keyI = options.keys.length - 1; keyI >= 0; --keyI) {
var target = getValue(obj, options.keys[keyI])
if(!target) { objResults[keyI] = noTarget; continue }
if(!isPrepared(target)) target = getPrepared(target)
target._score = NEGATIVE_INFINITY
target._indexes.len = 0
objResults[keyI] = target
}
objResults.obj = obj
objResults._score = NEGATIVE_INFINITY
results.push(objResults); if(results.length >= limit) return results
}
} else {
for(var i=0;i<targets.length;i++) { var target = targets[i]
if(target == NULL) continue
if(!isPrepared(target)) target = getPrepared(target)
target._score = NEGATIVE_INFINITY
target._indexes.len = 0
results.push(target); if(results.length >= limit) return results
}
}
return results
}
var algorithm = (preparedSearch, prepared, allowSpaces=false, allowPartialMatch=false) => {
if(allowSpaces===false && preparedSearch.containsSpace) return algorithmSpaces(preparedSearch, prepared, allowPartialMatch)
var searchLower = preparedSearch._lower
var searchLowerCodes = preparedSearch.lowerCodes
var searchLowerCode = searchLowerCodes[0]
var targetLowerCodes = prepared._targetLowerCodes
var searchLen = searchLowerCodes.length
var targetLen = targetLowerCodes.length
var searchI = 0 // where we at
var targetI = 0 // where you at
var matchesSimpleLen = 0
// very basic fuzzy match; to remove non-matching targets ASAP!
// walk through target. find sequential matches.
// if all chars aren't found then exit
for(;;) {
var isMatch = searchLowerCode === targetLowerCodes[targetI]
if(isMatch) {
matchesSimple[matchesSimpleLen++] = targetI
++searchI; if(searchI === searchLen) break
searchLowerCode = searchLowerCodes[searchI]
}
++targetI; if(targetI >= targetLen) return NULL // Failed to find searchI
}
var searchI = 0
var successStrict = false
var matchesStrictLen = 0
var nextBeginningIndexes = prepared._nextBeginningIndexes
if(nextBeginningIndexes === NULL) nextBeginningIndexes = prepared._nextBeginningIndexes = prepareNextBeginningIndexes(prepared.target)
targetI = matchesSimple[0]===0 ? 0 : nextBeginningIndexes[matchesSimple[0]-1]
// Our target string successfully matched all characters in sequence!
// Let's try a more advanced and strict test to improve the score
// only count it as a match if it's consecutive or a beginning character!
var backtrackCount = 0
if(targetI !== targetLen) for(;;) {
if(targetI >= targetLen) {
// We failed to find a good spot for this search char, go back to the previous search char and force it forward
if(searchI <= 0) break // We failed to push chars forward for a better match
++backtrackCount; if(backtrackCount > 200) break // exponential backtracking is taking too long, just give up and return a bad match
--searchI
var lastMatch = matchesStrict[--matchesStrictLen]
targetI = nextBeginningIndexes[lastMatch]
} else {
var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI]
if(isMatch) {
matchesStrict[matchesStrictLen++] = targetI
++searchI; if(searchI === searchLen) { successStrict = true; break }
++targetI
} else {
targetI = nextBeginningIndexes[targetI]
}
}
}
// check if it's a substring match
var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, matchesSimple[0]) // perf: this is slow
var isSubstring = !!~substringIndex
var isSubstringBeginning = !isSubstring ? false : substringIndex===0 || prepared._nextBeginningIndexes[substringIndex-1] === substringIndex
// if it's a substring match but not at a beginning index, let's try to find a substring starting at a beginning index for a better score
if(isSubstring && !isSubstringBeginning) {
for(var i=0; i<nextBeginningIndexes.length; i=nextBeginningIndexes[i]) {
if(i <= substringIndex) continue
for(var s=0; s<searchLen; s++) if(searchLowerCodes[s] !== prepared._targetLowerCodes[i+s]) break
if(s === searchLen) { substringIndex = i; isSubstringBeginning = true; break }
}
}
// tally up the score & keep track of matches for highlighting later
// if it's a simple match, we'll switch to a substring match if a substring exists
// if it's a strict match, we'll switch to a substring match only if that's a better score
var calculateScore = matches => {
var score = 0
var extraMatchGroupCount = 0
for(var i = 1; i < searchLen; ++i) {
if(matches[i] - matches[i-1] !== 1) {score -= matches[i]; ++extraMatchGroupCount}
}
var unmatchedDistance = matches[searchLen-1] - matches[0] - (searchLen-1)
score -= (12+unmatchedDistance) * extraMatchGroupCount // penality for more groups
if(matches[0] !== 0) score -= matches[0]*matches[0]*.2 // penality for not starting near the beginning
if(!successStrict) {
score *= 1000
} else {
// successStrict on a target with too many beginning indexes loses points for being a bad target
var uniqueBeginningIndexes = 1
for(var i = nextBeginningIndexes[0]; i < targetLen; i=nextBeginningIndexes[i]) ++uniqueBeginningIndexes
if(uniqueBeginningIndexes > 24) score *= (uniqueBeginningIndexes-24)*10 // quite arbitrary numbers here ...
}
score -= (targetLen - searchLen)/2 // penality for longer targets
if(isSubstring) score /= 1+searchLen*searchLen*1 // bonus for being a full substring
if(isSubstringBeginning) score /= 1+searchLen*searchLen*1 // bonus for substring starting on a beginningIndex
score -= (targetLen - searchLen)/2 // penality for longer targets
return score
}
if(!successStrict) {
if(isSubstring) for(var i=0; i<searchLen; ++i) matchesSimple[i] = substringIndex+i // at this point it's safe to overwrite matchehsSimple with substr matches
var matchesBest = matchesSimple
var score = calculateScore(matchesBest)
} else {
if(isSubstringBeginning) {
for(var i=0; i<searchLen; ++i) matchesSimple[i] = substringIndex+i // at this point it's safe to overwrite matchehsSimple with substr matches
var matchesBest = matchesSimple
var score = calculateScore(matchesSimple)
} else {
var matchesBest = matchesStrict
var score = calculateScore(matchesStrict)
}
}
prepared._score = score
for(var i = 0; i < searchLen; ++i) prepared._indexes[i] = matchesBest[i]
prepared._indexes.len = searchLen
const result = new Result()
result.target = prepared.target
result._score = prepared._score
result._indexes = prepared._indexes
return result
}
var algorithmSpaces = (preparedSearch, target, allowPartialMatch) => {
var seen_indexes = new Set()
var score = 0
var result = NULL
var first_seen_index_last_search = 0
var searches = preparedSearch.spaceSearches
var searchesLen = searches.length
var changeslen = 0
// Return _nextBeginningIndexes back to its normal state
var resetNextBeginningIndexes = () => {
for(let i=changeslen-1; i>=0; i--) target._nextBeginningIndexes[nextBeginningIndexesChanges[i*2 + 0]] = nextBeginningIndexesChanges[i*2 + 1]
}
var hasAtLeast1Match = false
for(var i=0; i<searchesLen; ++i) {
allowPartialMatchScores[i] = NEGATIVE_INFINITY
var search = searches[i]
result = algorithm(search, target)
if(allowPartialMatch) {
if(result === NULL) continue
hasAtLeast1Match = true
} else {
if(result === NULL) {resetNextBeginningIndexes(); return NULL}
}
// if not the last search, we need to mutate _nextBeginningIndexes for the next search
var isTheLastSearch = i === searchesLen - 1
if(!isTheLastSearch) {
var indexes = result._indexes
var indexesIsConsecutiveSubstring = true
for(let i=0; i<indexes.len-1; i++) {
if(indexes[i+1] - indexes[i] !== 1) {
indexesIsConsecutiveSubstring = false; break;
}
}
if(indexesIsConsecutiveSubstring) {
var newBeginningIndex = indexes[indexes.len-1] + 1
var toReplace = target._nextBeginningIndexes[newBeginningIndex-1]
for(let i=newBeginningIndex-1; i>=0; i--) {
if(toReplace !== target._nextBeginningIndexes[i]) break
target._nextBeginningIndexes[i] = newBeginningIndex
nextBeginningIndexesChanges[changeslen*2 + 0] = i
nextBeginningIndexesChanges[changeslen*2 + 1] = toReplace
changeslen++
}
}
}
score += result._score / searchesLen
allowPartialMatchScores[i] = result._score / searchesLen
// dock points based on order otherwise "c man" returns Manifest.cpp instead of CheatManager.h
if(result._indexes[0] < first_seen_index_last_search) {
score -= (first_seen_index_last_search - result._indexes[0]) * 2
}
first_seen_index_last_search = result._indexes[0]
for(var j=0; j<result._indexes.len; ++j) seen_indexes.add(result._indexes[j])
}
if(allowPartialMatch && !hasAtLeast1Match) return NULL
resetNextBeginningIndexes()
// allows a search with spaces that's an exact substring to score well
var allowSpacesResult = algorithm(preparedSearch, target, /*allowSpaces=*/true)
if(allowSpacesResult !== NULL && allowSpacesResult._score > score) {
if(allowPartialMatch) {
for(var i=0; i<searchesLen; ++i) {
allowPartialMatchScores[i] = allowSpacesResult._score / searchesLen
}
}
return allowSpacesResult
}
if(allowPartialMatch) result = target
result._score = score
var i = 0
for (let index of seen_indexes) result._indexes[i++] = index
result._indexes.len = i
return result
}
// we use this instead of just .normalize('NFD').replace(/[\u0300-\u036f]/g, '') because that screws with japanese characters
var remove_accents = (str) => str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, '')
var prepareLowerInfo = (str) => {
str = remove_accents(str)
var strLen = str.length
var lower = str.toLowerCase()
var lowerCodes = [] // new Array(strLen) sparse array is too slow
var bitflags = 0
var containsSpace = false // space isn't stored in bitflags because of how searching with a space works
for(var i = 0; i < strLen; ++i) {
var lowerCode = lowerCodes[i] = lower.charCodeAt(i)
if(lowerCode === 32) {
containsSpace = true
continue // it's important that we don't set any bitflags for space
}
var bit = lowerCode>=97&&lowerCode<=122 ? lowerCode-97 // alphabet
: lowerCode>=48&&lowerCode<=57 ? 26 // numbers
// 3 bits available
: lowerCode<=127 ? 30 // other ascii
: 31 // other utf8
bitflags |= 1<<bit
}
return {lowerCodes:lowerCodes, bitflags:bitflags, containsSpace:containsSpace, _lower:lower}
}
var prepareBeginningIndexes = (target) => {
var targetLen = target.length
var beginningIndexes = []; var beginningIndexesLen = 0
var wasUpper = false
var wasAlphanum = false
for(var i = 0; i < targetLen; ++i) {
var targetCode = target.charCodeAt(i)
var isUpper = targetCode>=65&&targetCode<=90
var isAlphanum = isUpper || targetCode>=97&&targetCode<=122 || targetCode>=48&&targetCode<=57
var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum
wasUpper = isUpper
wasAlphanum = isAlphanum
if(isBeginning) beginningIndexes[beginningIndexesLen++] = i
}
return beginningIndexes
}
var prepareNextBeginningIndexes = (target) => {
target = remove_accents(target)
var targetLen = target.length
var beginningIndexes = prepareBeginningIndexes(target)
var nextBeginningIndexes = [] // new Array(targetLen) sparse array is too slow
var lastIsBeginning = beginningIndexes[0]
var lastIsBeginningI = 0
for(var i = 0; i < targetLen; ++i) {
if(lastIsBeginning > i) {
nextBeginningIndexes[i] = lastIsBeginning
} else {
lastIsBeginning = beginningIndexes[++lastIsBeginningI]
nextBeginningIndexes[i] = lastIsBeginning===undefined ? targetLen : lastIsBeginning
}
}
return nextBeginningIndexes
}
var preparedCache = new Map()
var preparedSearchCache = new Map()
// the theory behind these being globals is to reduce garbage collection by not making new arrays
var matchesSimple = []; var matchesStrict = []
var nextBeginningIndexesChanges = [] // allows straw berry to match strawberry well, by modifying the end of a substring to be considered a beginning index for the rest of the search
var keysSpacesBestScores = []; var allowPartialMatchScores = []
var tmpTargets = []; var tmpResults = []
// prop = 'key' 2.5ms optimized for this case, seems to be about as fast as direct obj[prop]
// prop = 'key1.key2' 10ms
// prop = ['key1', 'key2'] 27ms
// prop = obj => obj.tags.join() ??ms
var getValue = (obj, prop) => {
var tmp = obj[prop]; if(tmp !== undefined) return tmp
if(typeof prop === 'function') return prop(obj) // this should run first. but that makes string props slower
var segs = prop
if(!Array.isArray(prop)) segs = prop.split('.')
var len = segs.length
var i = -1
while (obj && (++i < len)) obj = obj[segs[i]]
return obj
}
var isPrepared = (x) => { return typeof x === 'object' && typeof x._bitflags === 'number' }
var INFINITY = Infinity; var NEGATIVE_INFINITY = -INFINITY
var noResults = []; noResults.total = 0
var NULL = null
var noTarget = prepare('')
// Hacked version of https://github.com/lemire/FastPriorityQueue.js
var fastpriorityqueue=r=>{var e=[],o=0,a={},v=r=>{for(var a=0,v=e[a],c=1;c<o;){var s=c+1;a=c,s<o&&e[s]._score<e[c]._score&&(a=s),e[a-1>>1]=e[a],c=1+(a<<1)}for(var f=a-1>>1;a>0&&v._score<e[f]._score;f=(a=f)-1>>1)e[a]=e[f];e[a]=v};return a.add=(r=>{var a=o;e[o++]=r;for(var v=a-1>>1;a>0&&r._score<e[v]._score;v=(a=v)-1>>1)e[a]=e[v];e[a]=r}),a.poll=(r=>{if(0!==o){var a=e[0];return e[0]=e[--o],v(),a}}),a.peek=(r=>{if(0!==o)return e[0]}),a.replaceTop=(r=>{e[0]=r,v()}),a}
var q = fastpriorityqueue() // reuse this

7
Helpers/Globals.qml Normal file
View file

@ -0,0 +1,7 @@
pragma Singleton
import QtQuick
QtObject {
// Global username, set at app startup
property string userName: "User"
}

21
Helpers/IPCHandlers.qml Normal file
View file

@ -0,0 +1,21 @@
import Quickshell.Io
IpcHandler {
property var appLauncherPanel
target: "globalIPC"
// Toggle Applauncher visibility
function toggleLauncher(): void {
if (!appLauncherPanel) {
console.warn("AppLauncherIpcHandler: appLauncherPanel not set!");
return;
}
if (appLauncherPanel.visible) {
appLauncherPanel.hidePanel();
} else {
console.log("[IPC] Applauncher show() called");
appLauncherPanel.showAt();
}
}
}

68
Helpers/Processes.qml Normal file
View file

@ -0,0 +1,68 @@
pragma Singleton
import QtQuick
import Quickshell.Io
QtObject {
id: processesRoot
property string userName: "User"
property string uptimeText: "--:--"
property int uptimeUpdateTrigger: 0
property Process whoamiProcess: Process {
command: ["whoami"]
running: false
stdout: StdioCollector {
onStreamFinished: {
processesRoot.userName = this.text.trim()
whoamiProcess.running = false
}
}
}
property Process shutdownProcess: Process {
command: ["shutdown", "-h", "now"]
running: false
}
property Process rebootProcess: Process {
command: ["reboot"]
running: false
}
property Process logoutProcess: Process {
command: ["niri", "msg", "action", "quit", "--skip-confirmation"]
running: false
}
property Process uptimeProcess: Process {
command: ["sh", "-c", "uptime | awk -F 'up ' '{print $2}' | awk -F ',' '{print $1}' | xargs"]
running: false
stdout: StdioCollector {
onStreamFinished: {
processesRoot.uptimeText = this.text.trim()
uptimeProcess.running = false
}
}
}
Component.onCompleted: {
whoamiProcess.running = true
updateUptime()
}
function shutdown() {
shutdownProcess.running = true
}
function reboot() {
rebootProcess.running = true
}
function logout() {
logoutProcess.running = true
}
function updateUptime() {
uptimeProcess.running = true
}
onUptimeUpdateTriggerChanged: {
uptimeProcess.running = true
}
}

53
Helpers/Spinner.qml Normal file
View file

@ -0,0 +1,53 @@
import QtQuick
Item {
id: root
property bool running: false
property color color: "white"
property int size: 16
property int strokeWidth: 2
property int duration: 1000
implicitWidth: size
implicitHeight: size
Canvas {
id: spinnerCanvas
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var centerX = width / 2
var centerY = height / 2
var radius = Math.min(width, height) / 2 - strokeWidth / 2
ctx.strokeStyle = root.color
ctx.lineWidth = root.strokeWidth
ctx.lineCap = "round"
// Draw arc with gap (270 degrees with 90 degree gap)
ctx.beginPath()
ctx.arc(centerX, centerY, radius, -Math.PI/2 + rotationAngle, -Math.PI/2 + rotationAngle + Math.PI * 1.5)
ctx.stroke()
}
property real rotationAngle: 0
onRotationAngleChanged: {
requestPaint()
}
NumberAnimation {
target: spinnerCanvas
property: "rotationAngle"
running: root.running
from: 0
to: 2 * Math.PI
duration: root.duration
loops: Animation.Infinite
}
}
}

View file

@ -0,0 +1,58 @@
pragma Singleton
import QtQuick
import Quickshell.Io
Item {
id: manager
// Hardcoded directory for v1
property string wallpaperDirectory: "/home/lysec/nixos/assets/wallpapers"
property var wallpaperList: []
property string currentWallpaper: ""
property bool scanning: false
// Log initial state
Component.onCompleted: {
loadWallpapers()
}
// Scan directory for wallpapers
function loadWallpapers() {
scanning = true;
wallpaperList = [];
findProcess.tempList = [];
findProcess.running = true;
}
function setCurrentWallpaper(path) {
currentWallpaper = path;
}
Process {
id: findProcess
property var tempList: []
running: false
command: ["find", manager.wallpaperDirectory, "-type", "f", "-name", "*.png", "-o", "-name", "*.jpg", "-o", "-name", "*.jpeg"]
onRunningChanged: {
}
stdout: StdioCollector {
onStreamFinished: {
var lines = text.split("\n");
for (var i = 0; i < lines.length; ++i) {
var trimmed = lines[i].trim();
if (trimmed) {
findProcess.tempList.push(trimmed);
}
}
}
}
stderr: StdioCollector {
onStreamFinished: {
}
}
onExited: {
manager.wallpaperList = findProcess.tempList.slice();
scanning = false;
}
}
}

58
Helpers/Weather.js Normal file
View file

@ -0,0 +1,58 @@
function fetchCoordinates(city, callback, errorCallback) {
var geoUrl = "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(city) + "&count=1&language=en&format=json";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var geoData = JSON.parse(xhr.responseText);
if (geoData.results && geoData.results.length > 0) {
callback(geoData.results[0].latitude, geoData.results[0].longitude);
} else {
errorCallback("City not found.");
}
} catch (e) {
errorCallback("Failed to parse geocoding data.");
}
} else {
errorCallback("Geocoding error: " + xhr.status);
}
}
}
xhr.open("GET", geoUrl);
xhr.send();
}
function fetchWeather(latitude, longitude, callback, errorCallback) {
var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "&current_weather=true&current=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto";
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var weatherData = JSON.parse(xhr.responseText);
callback(weatherData);
} catch (e) {
errorCallback("Failed to parse weather data.");
}
} else {
errorCallback("Weather fetch error: " + xhr.status);
}
}
}
xhr.open("GET", url);
xhr.send();
}
function fetchCityWeather(city, callback, errorCallback) {
fetchCoordinates(city, function(lat, lon) {
fetchWeather(lat, lon, function(weatherData) {
callback({
city: city,
latitude: lat,
longitude: lon,
weather: weatherData
});
}, errorCallback);
}, errorCallback);
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ly-sec
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

147
README.md Normal file
View file

@ -0,0 +1,147 @@
# Noctalia
**_quiet by design_**
A sleek, minimal, and thoughtfully crafted setup for Wayland using **Quickshell**. This setup includes a status bar, notification system, control panel, wifi & bluetooth indicators, power profiles, lockscreen, tray, workspaces, and more — all styled with a warm lavender palette.
## Preview
<details>
<summary>Click to expand preview images</summary>
![Main](https://i.imgur.com/5mOIGD2.jpeg)
</br>
![Control Panel](https://i.imgur.com/fJmCV6m.jpeg)
</br>
![Applauncher](https://i.imgur.com/9OPV30q.jpeg)
</details>
<br>
---
> ⚠️ **Note:**
> This setup currently requires **Niri** as your compositor, mainly due to its custom workspace indicator integration. However if you want, you can just adapt the Workspace.qml to your own compositor.
---
## Features
- **Status Bar:** Modular and informative with smooth animations.
- **Notifications:** Non-intrusive alerts styled to blend naturally.
- **Control Panel:** Centralized system controls for quick adjustments.
- **Connectivity:** Easy management of WiFi and Bluetooth devices.
- **Power Profiles:** Quick toggles for performance and battery modes.
- **Lockscreen:** Secure and visually consistent lock experience.
- **Tray & Workspaces:** Efficient workspace switching and tray icons.
- **Applauncher:** Stylized Applauncher to fit into the setup.
---
<details>
<summary><strong>Theme Colors</strong></summary>
| Color Role | Color | Description |
| -------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------- |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#0C0D11;margin-right:8px;"></span> | `#0C0D11` | Background Primary — Deep indigo-black |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#151720;margin-right:8px;"></span> | `#151720` | Background Secondary — Slightly lifted dark |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#1D202B;margin-right:8px;"></span> | `#1D202B` | Background Tertiary — Soft contrast surface |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#1A1C26;margin-right:8px;"></span> | `#1A1C26` | Surface — Material-like base layer |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#2A2D3A;margin-right:8px;"></span> | `#2A2D3A` | Surface Variant — Lightly elevated |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#CACEE2;margin-right:8px;"></span> | `#CACEE2` | Text Primary — Gentle off-white |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#B7BBD0;margin-right:8px;"></span> | `#B7BBD0` | Text Secondary — Muted lavender-blue |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#6B718A;margin-right:8px;"></span> | `#6B718A` | Text Disabled — Dimmed blue-gray |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#A8AEFF;margin-right:8px;"></span> | `#A8AEFF` | Accent Primary — Light enchanted lavender |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#9EA0FF;margin-right:8px;"></span> | `#9EA0FF` | Accent Secondary — Softer lavender hue |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#8EABFF;margin-right:8px;"></span> | `#8EABFF` | Accent Tertiary — Warm golden glow (from lantern) |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#FF6B81;margin-right:8px;"></span> | `#FF6B81` | Error — Soft rose red |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#FFBB66;margin-right:8px;"></span> | `#FFBB66` | Warning — Candlelight amber-orange |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#E3C2FF;margin-right:8px;"></span> | `#E3C2FF` | Highlight — Bright magical lavender |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#F3DEFF;margin-right:8px;"></span> | `#F3DEFF` | Ripple Effect — Gentle soft splash |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#1A1A1A;margin-right:8px;"></span> | `#1A1A1A` | On Accent — Text on accent background |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#44485A;margin-right:8px;"></span> | `#44485A` | Outline — Subtle bluish-gray line |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#000000B3;margin-right:8px;"></span> | `#000000B3` | Shadow — Standard soft black shadow |
| <span style="display:inline-block;width:15px;height:15px;border-radius:50%;background:#11121ACC;margin-right:8px;"></span> | `#11121ACC` | Overlay — Deep bluish overlay |
</details>
---
## Installation & Usage
<details>
<summary><strong>Installation</strong></summary>
Install quickshell:
```
yay -S quickshell-git
```
or use any other way of installing quickshell-git (flake, paru etc).
_Git clone the repo:_
```
git clone https://github.com/Ly-sec/Noctalia.git
```
_Move content to ~/.config/quickshell_
```
cd Noctalia && mv * ~/.config/quickshell/
```
</details>
</br>
<details>
<summary><strong>Usage</strong></summary>
### Start quickshell:
```
qs
```
(If you want to autostart it, just add it to your niri configuration.)
### Settings:
To make the weather widget, wallpaper manager and record button work you will have to open up the settings menu in to right panel (top right button to open panel) and edit said things accordingly.
</details>
</br>
<details>
<summary><strong>Keybinds</strong></summary>
### Open Applauncher:
```
qs ipc call globalIPC toggleLauncher
```
You can keybind it however you want in your niri setup.
</details>
---
## Known issues
Currently the brightness indicator is very opiniated (using ddcutil with a script to log current brightness). This will be fixed **asap**!
---
## Contributing
Contributions are welcome! Feel free to open issues or submit pull requests.
---
## License
This project is licensed under the terms of the [MIT License](./LICENSE).

87
Services/Niri.qml Normal file
View file

@ -0,0 +1,87 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property list<var> workspaces: []
property int focusedWorkspaceIndex: 0
property list<var> windows: []
property int focusedWindowIndex: 0
property bool inOverview: false
// Reactive property for focused window title
property string focusedWindowTitle: "(No active window)"
// Update the focusedWindowTitle whenever relevant properties change
function updateFocusedWindowTitle() {
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.length) {
focusedWindowTitle = windows[focusedWindowIndex].title || "(Unnamed window)";
} else {
focusedWindowTitle = "(No active window)";
}
}
// Call updateFocusedWindowTitle on changes
onWindowsChanged: updateFocusedWindowTitle()
onFocusedWindowIndexChanged: updateFocusedWindowTitle()
Process {
command: ["niri", "msg", "-j", "event-stream"]
running: true
stdout: SplitParser {
onRead: data => {
const event = JSON.parse(data.trim());
if (event.WorkspacesChanged) {
root.workspaces = [...event.WorkspacesChanged.workspaces].sort((a, b) => a.idx - b.idx);
root.focusedWorkspaceIndex = root.workspaces.findIndex(w => w.is_focused);
if (root.focusedWorkspaceIndex < 0) {
root.focusedWorkspaceIndex = 0;
}
} else if (event.WorkspaceActivated) {
root.focusedWorkspaceIndex = root.workspaces.findIndex(w => w.id === event.WorkspaceActivated.id);
if (root.focusedWorkspaceIndex < 0) {
root.focusedWorkspaceIndex = 0;
}
} else if (event.WindowsChanged) {
root.windows = [...event.WindowsChanged.windows].sort((a, b) => a.id - b.id);
//const window = event.WindowOpenedOrChanged.window;
// const index = root.windows.findIndex(w => w.id === window.id);
// if (index >= 0) {
// root.windows[index] = window;
// } else {
// root.windows.push(window);
// root.windows = [...root.windows].sort((a, b) => a.id - b.id);
// if (window.is_focused) {
// root.focusedWindowIndex = root.windows.findIndex(w => w.id === window.id);
// if (root.focusedWindowIndex < 0) {
// root.focusedWindowIndex = 0;
// }
// }
// }
} else if (event.WindowClosed) {
root.windows = [...root.windows.filter(w => w.id !== event.WindowClosed.id)];
} else if (event.WindowFocusChanged) {
if (event.WindowFocusChanged.id) {
root.focusedWindowIndex = root.windows.findIndex(w => w.id === event.WindowFocusChanged.id);
if (root.focusedWindowIndex < 0) {
root.focusedWindowIndex = 0;
}
const focusedWin = root.windows[root.focusedWindowIndex];
"title:", focusedWin ? `"${focusedWin.title}"` : "<none>";
} else {
root.focusedWindowIndex = -1;
}
} else if (event.OverviewOpenedOrClosed) {
root.inOverview = event.OverviewOpenedOrClosed.is_open;
}
}
}
}
}

48
Settings/Settings.qml Normal file
View file

@ -0,0 +1,48 @@
pragma Singleton
import QtQuick
import QtCore
QtObject {
property string weatherCity: "Dinslaken"
property string profileImage: "https://cdn.discordapp.com/avatars/158005126638993408/de403f05fd7f74bb17e01a9b066a30fa?size=64"
property bool useFahrenheit
property string wallpaperFolder: "/home/lysec/nixos/assets/wallpapers" // Default path, make persistent
property string currentWallpaper: ""
property string videoPath: "~/Videos/" // Default path, make persistent
// Settings persistence
property var settings: Qt.createQmlObject('import QtCore; Settings { category: "Quickshell" }', this, "settings")
Component.onCompleted: {
loadSettings()
}
function loadSettings() {
let wc = settings.value("weatherCity", "Dinslaken");
weatherCity = (wc !== undefined && wc !== null) ? wc : "Dinslaken";
let pi = settings.value("profileImage", "https://cdn.discordapp.com/avatars/158005126638993408/de403f05fd7f74bb17e01a9b066a30fa?size=64");
profileImage = (pi !== undefined && pi !== null) ? pi : "https://cdn.discordapp.com/avatars/158005126638993408/de403f05fd7f74bb17e01a9b066a30fa?size=64";
let tempUnit = settings.value("weatherTempUnit", "celsius")
useFahrenheit = (tempUnit === "fahrenheit")
wallpaperFolder = settings.value("wallpaperFolder", "/home/lysec/nixos/assets/wallpapers")
currentWallpaper = settings.value("currentWallpaper", "")
videoPath = settings.value("videoPath", "/home/lysec/Videos")
console.log("Loaded profileImage:", profileImage)
}
function saveSettings() {
settings.setValue("weatherCity", weatherCity)
settings.setValue("profileImage", profileImage)
settings.setValue("weatherTempUnit", useFahrenheit ? "fahrenheit" : "celsius")
settings.setValue("wallpaperFolder", wallpaperFolder)
settings.setValue("currentWallpaper", currentWallpaper)
settings.setValue("videoPath", videoPath)
settings.sync()
console.log("Saving profileImage:", profileImage)
}
// Property change handlers to auto-save (all commented out for explicit save only)
// onWeatherCityChanged: saveSettings()
// onProfileImageChanged: saveSettings()
// onUseFahrenheitChanged: saveSettings()
}

40
Settings/Theme.qml Normal file
View file

@ -0,0 +1,40 @@
// Theme.qml
pragma Singleton
import QtQuick
QtObject {
// Backgrounds
readonly property color backgroundPrimary: "#0C0D11" // Deep indigo-black
readonly property color backgroundSecondary: "#151720" // Slightly lifted dark
readonly property color backgroundTertiary: "#1D202B" // Soft contrast surface
// Surfaces & Elevation
readonly property color surface: "#1A1C26" // Material-like base layer
readonly property color surfaceVariant: "#2A2D3A" // Lightly elevated
// Text Colors
readonly property color textPrimary: "#CACEE2" // Gentle off-white
readonly property color textSecondary: "#B7BBD0" // Muted lavender-blue
readonly property color textDisabled: "#6B718A" // Dimmed blue-gray
// Accent Colors (lavender-gold theme)
readonly property color accentPrimary: "#A8AEFF" // Light enchanted lavender
readonly property color accentSecondary: "#9EA0FF" // Softer lavender hue
readonly property color accentTertiary: "#8EABFF" // Warm golden glow (from lantern)
// Error/Warning
readonly property color error: "#FF6B81" // Soft rose red
readonly property color warning: "#FFBB66" // Candlelight amber-orange
// Highlights & Focus
readonly property color highlight: "#E3C2FF" // Bright magical lavender
readonly property color rippleEffect: "#F3DEFF" // Gentle soft splash
// Additional Theme Properties
readonly property color onAccent: "#1A1A1A" // Text on accent background
readonly property color outline: "#44485A" // Subtle bluish-gray line
// Shadows & Overlays
readonly property color shadow: "#000000B3" // Standard soft black shadow
readonly property color overlay: "#11121ACC" // Deep bluish overlay
}

31
Widgets/Background.qml Normal file
View file

@ -0,0 +1,31 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Helpers
import qs.Settings
ShellRoot {
property string wallpaperSource: Settings.currentWallpaper !== "" ? Settings.currentWallpaper : "/home/lysec/nixos/assets/wallpapers/lantern.png"
PanelWindow {
anchors {
bottom: true
top: true
right: true
left: true
}
margins {
top: 0
}
color: "transparent"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-wallpaper"
Image {
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: wallpaperSource
cache: true
smooth: true
}
}
}

780
Widgets/LockScreen.qml Normal file
View file

@ -0,0 +1,780 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Effects
import Qt5Compat.GraphicalEffects
import Quickshell.Wayland
import Quickshell
import Quickshell.Services.Pam
import Quickshell.Io
import qs.Settings
import qs.Helpers
import "../Helpers/Weather.js" as WeatherHelper
// Password-only lockscreen for all screens
WlSessionLock {
id: lock
property bool demoMode: true // Set to true for demo/recording mode
property string errorMessage: ""
property bool authenticating: false
property string password: ""
property bool pamAvailable: typeof PamContext !== "undefined"
property string weatherCity: Settings.weatherCity
property var weatherData: null
property string weatherError: ""
property string weatherInfo: ""
property string weatherIcon: ""
property double currentTemp: 0
locked: false // Start unlocked, only lock when button is clicked
// On component completed, fetch weather data
Component.onCompleted: {
fetchWeatherData()
}
// Weather fetching function
function fetchWeatherData() {
WeatherHelper.fetchCityWeather(weatherCity,
function(result) {
weatherData = result.weather;
weatherError = "";
},
function(err) {
weatherError = err;
}
);
}
function materialSymbolForCode(code) {
if (code === 0) return "sunny";
if (code === 1 || code === 2) return "partly_cloudy_day";
if (code === 3) return "cloud";
if (code >= 45 && code <= 48) return "foggy";
if (code >= 51 && code <= 67) return "rainy";
if (code >= 71 && code <= 77) return "weather_snowy";
if (code >= 80 && code <= 82) return "rainy";
if (code >= 95 && code <= 99) return "thunderstorm";
return "cloud";
}
// Authentication function
function unlockAttempt() {
console.log("Unlock attempt started");
if (!pamAvailable) {
lock.errorMessage = "PAM authentication not available.";
console.log("PAM not available");
return;
}
if (!lock.password) {
lock.errorMessage = "Password required.";
console.log("No password entered");
return;
}
console.log("Starting PAM authentication...");
lock.authenticating = true;
lock.errorMessage = "";
console.log("[LockScreen] About to create PAM context with userName:", Quickshell.env("USER"))
var pam = Qt.createQmlObject('import Quickshell.Services.Pam; PamContext { config: "login"; user: "' + Quickshell.env("USER") + '" }', lock);
console.log("PamContext created", pam);
pam.onCompleted.connect(function(result) {
console.log("PAM completed with result:", result);
lock.authenticating = false;
if (result === PamResult.Success) {
console.log("Authentication successful, unlocking...");
lock.locked = false;
lock.password = "";
lock.errorMessage = "";
} else {
console.log("Authentication failed");
lock.errorMessage = "Authentication failed.";
lock.password = "";
}
pam.destroy();
});
pam.onError.connect(function(error) {
console.log("PAM error:", error);
lock.authenticating = false;
lock.errorMessage = pam.message || "Authentication error.";
lock.password = "";
pam.destroy();
});
pam.onPamMessage.connect(function() {
console.log("PAM message:", pam.message, "isError:", pam.messageIsError);
if (pam.messageIsError) {
lock.errorMessage = pam.message;
}
});
pam.onResponseRequiredChanged.connect(function() {
console.log("PAM response required:", pam.responseRequired);
if (pam.responseRequired && lock.authenticating) {
console.log("Responding to PAM with password");
pam.respond(lock.password);
}
});
var started = pam.start();
console.log("PAM start result:", started);
}
// Remove the surface property and use a Loader instead
Loader {
anchors.fill: parent
active: true
sourceComponent: demoMode ? demoComponent : lockComponent
}
Component {
id: demoComponent
Window {
id: demoWindow
visible: true
width: 900
height: 600
color: "transparent"
flags: Qt.Window | Qt.FramelessWindowHint
// Blurred wallpaper background
Image {
id: demoBgImage
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: WallpaperManager.currentWallpaper !== "" ? WallpaperManager.currentWallpaper : "/home/lysec/nixos/assets/wallpapers/lantern.png"
cache: true
smooth: true
sourceSize.width: 2560
sourceSize.height: 1440
visible: true // Show the original for FastBlur input
}
FastBlur {
anchors.fill: parent
source: demoBgImage
radius: 48 // Adjust blur strength as needed
transparentBorder: true
}
// Main content container (moved up, Rectangle removed)
ColumnLayout {
anchors.centerIn: parent
spacing: 30
width: Math.min(parent.width * 0.8, 400)
// User avatar/icon
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: 80
height: 80
radius: 40
color: Theme.accentPrimary
Image {
id: avatarImage
anchors.fill: parent
anchors.margins: 4
source: Settings.profileImage
fillMode: Image.PreserveAspectCrop
visible: false // Only show the masked version
asynchronous: true
}
OpacityMask {
anchors.fill: avatarImage
source: avatarImage
maskSource: Rectangle {
width: avatarImage.width
height: avatarImage.height
radius: avatarImage.width / 2
visible: false
}
visible: Settings.profileImage !== ""
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 32
color: Theme.onAccent
visible: Settings.profileImage === ""
}
// Glow effect
layer.enabled: true
layer.effect: Glow {
color: Theme.accentPrimary
radius: 8
samples: 16
}
}
// Username
Text {
Layout.alignment: Qt.AlignHCenter
text: Settings.userName
font.pixelSize: 24
font.weight: Font.Medium
color: Theme.textPrimary
}
// Password input container
Rectangle {
Layout.fillWidth: true
height: 50
radius: 25
color: Theme.surface
opacity: 0.3
border.color: passwordInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 2
TextInput {
id: passwordInput
anchors.fill: parent
anchors.margins: 15
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
font.pixelSize: 16
color: Theme.textPrimary
echoMode: TextInput.Password
passwordCharacter: "●"
passwordMaskDelay: 0
text: lock.password
onTextChanged: lock.password = text
// Placeholder text
Text {
anchors.centerIn: parent
text: "Enter password..."
color: Theme.textSecondary
opacity: 0.6
font.pixelSize: 16
visible: !passwordInput.text && !passwordInput.activeFocus
}
// Handle Enter key
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
lock.unlockAttempt()
}
}
}
}
// Error message
Text {
Layout.alignment: Qt.AlignHCenter
text: lock.errorMessage
color: Theme.error
font.pixelSize: 14
visible: lock.errorMessage !== ""
opacity: lock.errorMessage !== "" ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: 200 }
}
}
// Unlock button
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: 120
height: 44
radius: 22
color: unlockButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 2
opacity: lock.authenticating ? 0.5 : 0.8
enabled: !lock.authenticating
Text {
anchors.centerIn: parent
text: lock.authenticating ? "Authenticating..." : "Unlock"
font.pixelSize: 16
font.bold: true
color: unlockButtonArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
MouseArea {
id: unlockButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (!lock.authenticating) {
lock.unlockAttempt()
}
}
}
}
// Bypass Login button
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: 120
height: 44
radius: 22
color: bypassButtonArea.containsMouse ? Theme.accentSecondary : "transparent"
border.color: Theme.accentSecondary
border.width: 2
opacity: lock.authenticating ? 0.5 : 0.8
enabled: !lock.authenticating
Text {
anchors.centerIn: parent
text: "Bypass Login"
font.pixelSize: 16
font.bold: true
color: bypassButtonArea.containsMouse ? Theme.onAccent : Theme.accentSecondary
}
MouseArea {
id: bypassButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (!lock.authenticating) {
lock.locked = false;
lock.errorMessage = "";
lock.password = "";
}
}
}
}
}
// Top-center info panel (clock + weather)
ColumnLayout {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 40
spacing: 8
// Clock
Text {
id: timeText
text: Qt.formatDateTime(new Date(), "HH:mm")
font.pixelSize: 48
font.bold: true
color: Theme.textPrimary
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
Text {
id: dateText
text: Qt.formatDateTime(new Date(), "dddd, MMMM d")
font.pixelSize: 16
color: Theme.textSecondary
opacity: 0.8
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
// Weather info (centered, no city)
RowLayout {
spacing: 6
Layout.alignment: Qt.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
visible: weatherData && weatherData.current_weather
Text {
text: weatherData && weatherData.current_weather ? materialSymbolForCode(weatherData.current_weather.weathercode) : "cloud"
font.family: "Material Symbols Outlined"
font.pixelSize: 28
color: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
}
Text {
text: weatherData && weatherData.current_weather ? (Settings.useFahrenheit ? `${Math.round(weatherData.current_weather.temperature * 9/5 + 32)}°F` : `${Math.round(weatherData.current_weather.temperature)}°C`) : (Settings.useFahrenheit ? "--°F" : "--°C")
font.pixelSize: 18
color: Theme.textSecondary
verticalAlignment: Text.AlignVCenter
}
}
// Weather error
Text {
text: weatherError
color: Theme.error
visible: weatherError !== ""
font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
}
// Update clock every second
Timer {
interval: 1000
running: true
repeat: true
onTriggered: {
timeText.text = Qt.formatDateTime(new Date(), "HH:mm")
dateText.text = Qt.formatDateTime(new Date(), "dddd, MMMM d")
}
}
// Update weather every 10 minutes
Timer {
interval: 600000 // 10 minutes
running: true
repeat: true
onTriggered: {
fetchWeatherData()
}
}
}
}
Component {
id: lockComponent
WlSessionLockSurface {
// Blurred wallpaper background
Image {
id: lockBgImage
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: WallpaperManager.currentWallpaper !== "" ? WallpaperManager.currentWallpaper : "/home/lysec/nixos/assets/wallpapers/lantern.png"
cache: true
smooth: true
sourceSize.width: 2560
sourceSize.height: 1440
visible: true // Show the original for FastBlur input
}
FastBlur {
anchors.fill: parent
source: lockBgImage
radius: 48 // Adjust blur strength as needed
transparentBorder: true
}
// Main content container (moved up, Rectangle removed)
ColumnLayout {
anchors.centerIn: parent
spacing: 30
width: Math.min(parent.width * 0.8, 400)
// User avatar/icon
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: 80
height: 80
radius: 40
color: Theme.accentPrimary
Image {
id: avatarImage
anchors.fill: parent
anchors.margins: 4
source: Settings.profileImage
fillMode: Image.PreserveAspectCrop
visible: false // Only show the masked version
asynchronous: true
}
OpacityMask {
anchors.fill: avatarImage
source: avatarImage
maskSource: Rectangle {
width: avatarImage.width
height: avatarImage.height
radius: avatarImage.width / 2
visible: false
}
visible: Settings.profileImage !== ""
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 32
color: Theme.onAccent
visible: Settings.profileImage === ""
}
// Glow effect
layer.enabled: true
layer.effect: Glow {
color: Theme.accentPrimary
radius: 8
samples: 16
}
}
// Username
Text {
Layout.alignment: Qt.AlignHCenter
text: Quickshell.env("USER")
font.pixelSize: 24
font.weight: Font.Medium
color: Theme.textPrimary
}
// Password input container
Rectangle {
Layout.fillWidth: true
height: 50
radius: 25
color: Theme.surface
opacity: 0.3
border.color: passwordInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 2
TextInput {
id: passwordInput
anchors.fill: parent
anchors.margins: 15
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
font.pixelSize: 16
color: Theme.textPrimary
echoMode: TextInput.Password
passwordCharacter: "●"
passwordMaskDelay: 0
text: lock.password
onTextChanged: lock.password = text
// Placeholder text
Text {
anchors.centerIn: parent
text: "Enter password..."
color: Theme.textSecondary
opacity: 0.6
font.pixelSize: 16
visible: !passwordInput.text && !passwordInput.activeFocus
}
// Handle Enter key
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
lock.unlockAttempt()
}
}
}
}
// Error message
Text {
Layout.alignment: Qt.AlignHCenter
text: lock.errorMessage
color: Theme.error
font.pixelSize: 14
visible: lock.errorMessage !== ""
opacity: lock.errorMessage !== "" ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: 200 }
}
}
// Unlock button
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: 120
height: 44
radius: 22
color: unlockButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 2
opacity: lock.authenticating ? 0.5 : 0.8
enabled: !lock.authenticating
Text {
anchors.centerIn: parent
text: lock.authenticating ? "Authenticating..." : "Unlock"
font.pixelSize: 16
font.bold: true
color: unlockButtonArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
MouseArea {
id: unlockButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (!lock.authenticating) {
lock.unlockAttempt()
}
}
}
}
// Bypass Login button
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: 120
height: 44
radius: 22
color: bypassButtonArea.containsMouse ? Theme.accentSecondary : "transparent"
border.color: Theme.accentSecondary
border.width: 2
opacity: lock.authenticating ? 0.5 : 0.8
enabled: !lock.authenticating
Text {
anchors.centerIn: parent
text: "Bypass Login"
font.pixelSize: 16
font.bold: true
color: bypassButtonArea.containsMouse ? Theme.onAccent : Theme.accentSecondary
}
MouseArea {
id: bypassButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (!lock.authenticating) {
lock.locked = false;
lock.errorMessage = "";
lock.password = "";
}
}
}
}
}
// Top-center info panel (clock + weather)
ColumnLayout {
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 40
spacing: 8
// Clock
Text {
id: timeText
text: Qt.formatDateTime(new Date(), "HH:mm")
font.pixelSize: 48
font.bold: true
color: Theme.textPrimary
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
Text {
id: dateText
text: Qt.formatDateTime(new Date(), "dddd, MMMM d")
font.pixelSize: 16
color: Theme.textSecondary
opacity: 0.8
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
// Weather info (centered, no city)
RowLayout {
spacing: 6
Layout.alignment: Qt.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter
visible: weatherData && weatherData.current_weather
Text {
text: weatherData && weatherData.current_weather ? materialSymbolForCode(weatherData.current_weather.weathercode) : "cloud"
font.family: "Material Symbols Outlined"
font.pixelSize: 28
color: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
}
Text {
text: weatherData && weatherData.current_weather ? (Settings.useFahrenheit ? `${Math.round(weatherData.current_weather.temperature * 9/5 + 32)}°F` : `${Math.round(weatherData.current_weather.temperature)}°C`) : (Settings.useFahrenheit ? "--°F" : "--°C")
font.pixelSize: 18
color: Theme.textSecondary
verticalAlignment: Text.AlignVCenter
}
}
// Weather error
Text {
text: weatherError
color: Theme.error
visible: weatherError !== ""
font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
}
// Update clock every second
Timer {
interval: 1000
running: true
repeat: true
onTriggered: {
timeText.text = Qt.formatDateTime(new Date(), "HH:mm")
dateText.text = Qt.formatDateTime(new Date(), "dddd, MMMM d")
}
}
// Update weather every 10 minutes
Timer {
interval: 600000 // 10 minutes
running: true
repeat: true
onTriggered: {
fetchWeatherData()
}
}
// System control buttons (bottom right)
ColumnLayout {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 32
spacing: 12
// Shutdown
Rectangle {
width: 48; height: 48; radius: 24
color: shutdownArea.containsMouse ? Theme.error : "transparent"
border.color: Theme.error
border.width: 1
MouseArea {
id: shutdownArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Qt.createQmlObject('import Quickshell.Io; Process { command: ["shutdown", "-h", "now"]; running: true }', lock)
}
}
Text {
anchors.centerIn: parent
text: "power_settings_new"
font.family: "Material Symbols Outlined"
font.pixelSize: 24
color: shutdownArea.containsMouse ? Theme.onAccent : Theme.error
}
}
// Reboot
Rectangle {
width: 48; height: 48; radius: 24
color: rebootArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 1
MouseArea {
id: rebootArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Qt.createQmlObject('import Quickshell.Io; Process { command: ["reboot"]; running: true }', lock)
}
}
Text {
anchors.centerIn: parent
text: "refresh"
font.family: "Material Symbols Outlined"
font.pixelSize: 24
color: rebootArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
}
// Logout
Rectangle {
width: 48; height: 48; radius: 24
color: logoutArea.containsMouse ? Theme.accentSecondary : "transparent"
border.color: Theme.accentSecondary
border.width: 1
MouseArea {
id: logoutArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Qt.createQmlObject('import Quickshell.Io; Process { command: ["loginctl", "terminate-user", "' + Quickshell.env("USER") + '"]; running: true }', lock)
}
}
Text {
anchors.centerIn: parent
text: "exit_to_app"
font.family: "Material Symbols Outlined"
font.pixelSize: 24
color: logoutArea.containsMouse ? Theme.onAccent : Theme.accentSecondary
}
}
}
}
}
}

View file

@ -0,0 +1,178 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Settings
PanelWindow {
id: window
width: 350
implicitHeight: notificationColumn.implicitHeight + 20
color: "transparent"
visible: false
screen: Quickshell.primaryScreen
focusable: false
anchors.top: true
anchors.right: true
margins.top: -20 // keep as you want
margins.right: 6
property var notifications: []
property int maxVisible: 5
property int spacing: 10
function addNotification(notification) {
var notifObj = {
id: notification.id,
appName: notification.appName || "Notification",
summary: notification.summary || "",
body: notification.body || "",
rawNotification: notification
};
notifications.unshift(notifObj);
if (notifications.length > maxVisible) {
notifications = notifications.slice(0, maxVisible);
}
visible = true;
notificationsChanged();
}
function dismissNotification(id) {
notifications = notifications.filter(n => n.id !== id);
if (notifications.length === 0) {
visible = false;
}
notificationsChanged();
}
Column {
id: notificationColumn
anchors.right: parent.right
spacing: window.spacing
width: parent.width
clip: false // prevent clipping during animation
Repeater {
model: notifications
delegate: Rectangle {
id: notificationDelegate
width: parent.width
height: contentColumn.height + 20
color: Theme.backgroundPrimary
radius: 20
opacity: 1
Column {
id: contentColumn
width: parent.width - 20
anchors.centerIn: parent
spacing: 5
Text {
text: modelData.appName
width: parent.width
color: "white"
font.bold: true
font.pixelSize: 14
elide: Text.ElideRight
}
Text {
text: modelData.summary
width: parent.width
color: "#eeeeee"
font.pixelSize: 13
wrapMode: Text.Wrap
visible: text !== ""
}
Text {
text: modelData.body
width: parent.width
color: "#cccccc"
font.pixelSize: 12
wrapMode: Text.Wrap
visible: text !== ""
}
}
Timer {
interval: 4000
running: true
onTriggered: {
dismissAnimation.start();
if (modelData.rawNotification) {
modelData.rawNotification.expire();
}
}
}
MouseArea {
anchors.fill: parent
onClicked: {
dismissAnimation.start();
if (modelData.rawNotification) {
modelData.rawNotification.dismiss();
}
}
}
ParallelAnimation {
id: dismissAnimation
NumberAnimation {
target: notificationDelegate
property: "opacity"
to: 0
duration: 300
}
NumberAnimation {
target: notificationDelegate
property: "height"
to: 0
duration: 300
}
onFinished: window.dismissNotification(modelData.id)
}
Component.onCompleted: {
opacity = 0;
height = 0;
appearAnimation.start();
}
ParallelAnimation {
id: appearAnimation
NumberAnimation {
target: notificationDelegate
property: "opacity"
to: 1
duration: 300
}
NumberAnimation {
target: notificationDelegate
property: "height"
to: contentColumn.height + 20
duration: 300
}
}
}
}
}
onNotificationsChanged: {
height = notificationColumn.implicitHeight + 20
}
Connections {
target: Quickshell
function onScreensChanged() {
if (window.screen) {
x = window.screen.width - width - 20
// y stays as it is (margins.top = -20)
}
}
}
}

View file

@ -0,0 +1,266 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Settings
PanelWindow {
id: window
implicitWidth: 350
implicitHeight: notificationColumn.implicitHeight + 60
color: "transparent"
visible: notificationModel.count > 0
screen: Quickshell.primaryScreen !== undefined ? Quickshell.primaryScreen : null
focusable: false
property bool barVisible: true
anchors.top: true
anchors.right: true
margins.top: barVisible ? -20 : 10
margins.right: 6
ListModel {
id: notificationModel
}
property int maxVisible: 5
property int spacing: 5
function addNotification(notification) {
notificationModel.insert(0, {
id: notification.id,
appName: notification.appName || "Notification",
summary: notification.summary || "",
body: notification.body || "",
rawNotification: notification,
appeared: false,
dismissed: false
});
while (notificationModel.count > maxVisible) {
notificationModel.remove(notificationModel.count - 1);
}
}
function dismissNotificationById(id) {
for (var i = 0; i < notificationModel.count; i++) {
if (notificationModel.get(i).id === id) {
dismissNotificationByIndex(i);
break;
}
}
}
function dismissNotificationByIndex(index) {
if (index >= 0 && index < notificationModel.count) {
var notif = notificationModel.get(index);
if (!notif.dismissed) {
notificationModel.set(index, {
id: notif.id,
appName: notif.appName,
summary: notif.summary,
body: notif.body,
rawNotification: notif.rawNotification,
appeared: notif.appeared,
dismissed: true
});
}
}
}
Column {
id: notificationColumn
anchors.right: parent.right
spacing: window.spacing
width: parent.width
clip: false
Repeater {
id: notificationRepeater
model: notificationModel
delegate: Rectangle {
id: notificationDelegate
width: parent.width
color: Theme.backgroundPrimary
radius: 20
property bool appeared: model.appeared
property bool dismissed: model.dismissed
property var rawNotification: model.rawNotification
x: appeared ? 0 : width
opacity: dismissed ? 0 : 1
height: dismissed ? 0 : contentRow.height + 20
Row {
id: contentRow
anchors.centerIn: parent
spacing: 10
width: parent.width - 20
// Circular Icon container with border
Rectangle {
id: iconBackground
width: 36
height: 36
radius: width / 2 // Circular
color: Theme.accentPrimary
anchors.verticalCenter: parent.verticalCenter
border.color: Qt.darker(Theme.accentPrimary, 1.2)
border.width: 1.5
// Get all possible icon sources from notification
property var iconSources: [
rawNotification?.image || "",
rawNotification?.appIcon || "",
rawNotification?.icon || ""
]
// Try to load notification icon
Image {
id: iconImage
anchors.fill: parent
anchors.margins: 4
fillMode: Image.PreserveAspectFit
smooth: true
cache: false
asynchronous: true
sourceSize.width: 36
sourceSize.height: 36
source: {
for (var i = 0; i < iconBackground.iconSources.length; i++) {
var icon = iconBackground.iconSources[i];
if (!icon) continue;
if (icon.includes("?path=")) {
const [name, path] = icon.split("?path=");
const fileName = name.substring(name.lastIndexOf("/") + 1);
return `file://${path}/${fileName}`;
}
if (icon.startsWith('/')) {
return "file://" + icon;
}
return icon;
}
return "";
}
visible: status === Image.Ready && source.toString() !== ""
}
// Fallback to first letter of app name
Text {
anchors.centerIn: parent
visible: !iconImage.visible
text: model.appName ? model.appName.charAt(0).toUpperCase() : "?"
font.pixelSize: 18
font.bold: true
color: Theme.textPrimary
}
}
Column {
width: contentRow.width - iconBackground.width - 10
spacing: 5
Text {
text: model.appName
width: parent.width
color: Theme.textPrimary
font.bold: true
font.pixelSize: 14
elide: Text.ElideRight
}
Text {
text: model.summary
width: parent.width
color: "#eeeeee"
font.pixelSize: 13
wrapMode: Text.Wrap
visible: text !== ""
}
Text {
text: model.body
width: parent.width
color: "#cccccc"
font.pixelSize: 12
wrapMode: Text.Wrap
visible: text !== ""
}
}
}
Timer {
interval: 4000
running: !dismissed
repeat: false
onTriggered: {
dismissAnimation.start();
if (rawNotification) rawNotification.expire();
}
}
MouseArea {
anchors.fill: parent
onClicked: {
dismissAnimation.start();
if (rawNotification) rawNotification.dismiss();
}
}
ParallelAnimation {
id: dismissAnimation
NumberAnimation { target: notificationDelegate; property: "opacity"; to: 0; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "height"; to: 0; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "x"; to: width; duration: 150; easing.type: Easing.InQuad }
onFinished: {
var idx = notificationRepeater.indexOf(notificationDelegate);
if (idx !== -1) {
notificationModel.remove(idx);
}
}
}
ParallelAnimation {
id: appearAnimation
NumberAnimation { target: notificationDelegate; property: "opacity"; to: 1; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "height"; to: contentRow.height + 20; duration: 150 }
NumberAnimation { target: notificationDelegate; property: "x"; to: 0; duration: 150; easing.type: Easing.OutQuad }
}
Component.onCompleted: {
if (!appeared) {
opacity = 0;
height = 0;
x = width;
appearAnimation.start();
var idx = notificationRepeater.indexOf(notificationDelegate);
if (idx !== -1) {
var oldItem = notificationModel.get(idx);
notificationModel.set(idx, {
id: oldItem.id,
appName: oldItem.appName,
summary: oldItem.summary,
body: oldItem.body,
rawNotification: oldItem.rawNotification,
appeared: true,
dismissed: oldItem.dismissed
});
}
}
}
}
}
}
Connections {
target: Quickshell
function onScreensChanged() {
if (window.screen) {
x = window.screen.width - width - 20
}
}
}
}

37
Widgets/Overview.qml Normal file
View file

@ -0,0 +1,37 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Qt5Compat.GraphicalEffects
import qs.Helpers
import qs.Settings
ShellRoot {
property string wallpaperSource: Settings.currentWallpaper !== "" ? Settings.currentWallpaper : "/home/lysec/nixos/assets/wallpapers/lantern.png"
PanelWindow {
anchors {
top: true
bottom: true
right: true
left: true
}
color: "transparent"
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-overview"
Image {
id: bgImage
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: wallpaperSource
cache: true
smooth: true
visible: true // Show the original for FastBlur input
}
FastBlur {
anchors.fill: parent
source: bgImage
radius: 24 // Adjust blur strength as needed
transparentBorder: true
}
}
}

View file

@ -0,0 +1,66 @@
import QtQuick
import Quickshell
import qs.Settings
import qs.Widgets.Sidebar.Panel
Item {
id: buttonRoot
property Item barBackground
property var screen
width: iconText.implicitWidth + 0
height: iconText.implicitHeight + 0
property color hoverColor: Theme.rippleEffect
property real hoverOpacity: 0.0
property bool isActive: mouseArea.containsMouse || (sidebarPopup && sidebarPopup.visible)
property var sidebarPopup
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (sidebarPopup.visible) {
// Close all modals if open
if (sidebarPopup.settingsModal && sidebarPopup.settingsModal.visible) {
sidebarPopup.settingsModal.visible = false;
}
if (sidebarPopup.wallpaperManagerModal && sidebarPopup.wallpaperManagerModal.visible) {
sidebarPopup.wallpaperManagerModal.visible = false;
}
sidebarPopup.hidePopup();
} else {
sidebarPopup.showAt();
}
}
onEntered: buttonRoot.hoverOpacity = 0.18
onExited: buttonRoot.hoverOpacity = 0.0
}
Rectangle {
anchors.fill: parent
color: hoverColor
opacity: isActive ? 0.18 : hoverOpacity
radius: height / 2
z: 0
visible: (isActive ? 0.18 : hoverOpacity) > 0.01
}
Text {
id: iconText
text: "dashboard"
font.family: isActive ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16
color: sidebarPopup.visible ? Theme.accentPrimary : Theme.textPrimary
anchors.centerIn: parent
z: 1
}
Behavior on hoverOpacity {
NumberAnimation {
duration: 120
easing.type: Easing.OutQuad
}
}
}

486
Widgets/Sidebar/Config.qml Normal file
View file

@ -0,0 +1,486 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Effects
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Wayland
import qs.Settings
Rectangle {
id: settingsModal
anchors.centerIn: parent
color: Settings.Theme.backgroundPrimary
radius: 20
visible: false
z: 100
// Local properties for editing (not saved until apply)
property string tempWeatherCity: Settings.weatherCity
property bool tempUseFahrenheit: false
property string tempProfileImage: Settings.profileImage
property string tempWallpaperFolder: Settings.wallpaperFolder
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 16
// Header
RowLayout {
Layout.fillWidth: true
spacing: 16
Text {
text: "settings"
font.family: "Material Symbols Outlined"
font.pixelSize: 28
color: Settings.Theme.accentPrimary
}
Text {
text: "Settings"
font.pixelSize: 22
font.bold: true
color: Settings.Theme.textPrimary
Layout.fillWidth: true
}
Rectangle {
width: 36
height: 36
radius: 18
color: closeButtonArea.containsMouse ? Settings.Theme.accentPrimary : "transparent"
border.color: Settings.Theme.accentPrimary
border.width: 1
Text {
anchors.centerIn: parent
text: "close"
font.family: "Material Symbols Outlined"
font.pixelSize: 18
color: closeButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.accentPrimary
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: settingsModal.closeSettings()
}
}
}
// Weather Settings Card
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 180
color: Settings.Theme.surface
radius: 18
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Weather Settings Header
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "wb_sunny"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Settings.Theme.accentPrimary
}
Text {
text: "Weather Settings"
font.pixelSize: 16
font.bold: true
color: Settings.Theme.textPrimary
Layout.fillWidth: true
}
}
// Weather City Setting
ColumnLayout {
spacing: 8
Layout.fillWidth: true
Text {
text: "City"
font.pixelSize: 13
font.bold: true
color: Settings.Theme.textPrimary
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Settings.Theme.surfaceVariant
border.color: cityInput.activeFocus ? Settings.Theme.accentPrimary : Settings.Theme.outline
border.width: 1
TextInput {
id: cityInput
anchors.fill: parent
anchors.margins: 12
text: tempWeatherCity
font.pixelSize: 13
color: Settings.Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
onTextChanged: {
tempWeatherCity = text
}
MouseArea {
anchors.fill: parent
onClicked: {
cityInput.forceActiveFocus()
}
}
}
}
}
// Temperature Unit Setting
RowLayout {
spacing: 12
Layout.fillWidth: true
Text {
text: "Temperature Unit"
font.pixelSize: 13
font.bold: true
color: Settings.Theme.textPrimary
}
Item {
Layout.fillWidth: true
}
// Custom Material 3 Switch
Rectangle {
id: customSwitch
width: 52
height: 32
radius: 16
color: Settings.Theme.accentPrimary
border.color: Settings.Theme.accentPrimary
border.width: 2
Rectangle {
id: thumb
width: 28
height: 28
radius: 14
color: Settings.Theme.surface
border.color: Settings.Theme.outline
border.width: 1
y: 2
x: tempUseFahrenheit ? customSwitch.width - width - 2 : 2
Text {
anchors.centerIn: parent
text: tempUseFahrenheit ? "°F" : "°C"
font.pixelSize: 12
font.bold: true
color: Settings.Theme.textPrimary
}
Behavior on x {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
}
MouseArea {
anchors.fill: parent
onClicked: {
tempUseFahrenheit = !tempUseFahrenheit
}
}
}
}
}
}
// Profile Image Card
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 140
color: Settings.Theme.surface
radius: 18
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 0
border.color: "transparent"
border.width: 0
Layout.bottomMargin: 16
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Profile Image Header
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Settings.Theme.accentPrimary
}
Text {
text: "Profile Image"
font.pixelSize: 16
font.bold: true
color: Settings.Theme.textPrimary
Layout.fillWidth: true
}
}
// Profile Image Input Row
RowLayout {
spacing: 8
Layout.fillWidth: true
Rectangle {
width: 36
height: 36
radius: 18
color: Settings.Theme.surfaceVariant
border.color: profileImageInput.activeFocus ? Settings.Theme.accentPrimary : Settings.Theme.outline
border.width: 1
Image {
id: avatarImage
anchors.fill: parent
anchors.margins: 2
source: tempProfileImage
fillMode: Image.PreserveAspectCrop
visible: false
asynchronous: true
}
OpacityMask {
anchors.fill: avatarImage
source: avatarImage
maskSource: Rectangle {
width: avatarImage.width
height: avatarImage.height
radius: avatarImage.width / 2
visible: false
}
visible: tempProfileImage !== ""
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 18
color: Settings.Theme.accentPrimary
visible: tempProfileImage === ""
}
}
// Text input styled exactly like weather city
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Settings.Theme.surfaceVariant
border.color: profileImageInput.activeFocus ? Settings.Theme.accentPrimary : Settings.Theme.outline
border.width: 1
TextInput {
id: profileImageInput
anchors.fill: parent
anchors.margins: 12
text: tempProfileImage
font.pixelSize: 13
color: Settings.Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
onTextChanged: {
tempProfileImage = text
}
MouseArea {
anchors.fill: parent
onClicked: {
profileImageInput.forceActiveFocus()
}
}
}
}
}
}
}
// Wallpaper Folder Card
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 100
color: Settings.Theme.surface
radius: 18
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Header
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "image"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Settings.Theme.accentPrimary
}
Text {
text: "Wallpaper Folder"
font.pixelSize: 16
font.bold: true
color: Settings.Theme.textPrimary
Layout.fillWidth: true
}
}
// Folder Path Input
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Settings.Theme.surfaceVariant
border.color: wallpaperFolderInput.activeFocus ? Settings.Theme.accentPrimary : Settings.Theme.outline
border.width: 1
TextInput {
id: wallpaperFolderInput
anchors.fill: parent
anchors.margins: 12
text: tempWallpaperFolder
font.pixelSize: 13
color: Settings.Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhUrlCharactersOnly
onTextChanged: tempWallpaperFolder = text
MouseArea {
anchors.fill: parent
onClicked: wallpaperFolderInput.forceActiveFocus()
}
}
}
}
}
// Spacer to push content to top
Item {
Layout.fillHeight: true
}
// Apply Button
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 44
radius: 12
color: applyButtonArea.containsMouse ? Settings.Theme.accentPrimary : Settings.Theme.accentPrimary
border.color: "transparent"
border.width: 0
opacity: 1.0
Text {
anchors.centerIn: parent
text: "Apply Changes"
font.pixelSize: 15
font.bold: true
color: applyButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.onAccent
}
MouseArea {
id: applyButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
// Apply the changes
Settings.weatherCity = tempWeatherCity
Settings.useFahrenheit = tempUseFahrenheit
Settings.profileImage = tempProfileImage
Settings.wallpaperFolder = tempWallpaperFolder
// Force save settings
Settings.saveSettings()
// Refresh weather if available
if (typeof weather !== 'undefined' && weather) {
weather.fetchCityWeather()
}
// Close the modal
settingsModal.closeSettings()
}
}
}
}
// Function to open the modal and initialize temp values
function openSettings() {
tempWeatherCity = Settings.weatherCity
tempUseFahrenheit = Settings.useFahrenheit
tempProfileImage = Settings.profileImage
tempWallpaperFolder = Settings.wallpaperFolder
visible = true
// Force focus on the text input after a short delay
focusTimer.start()
}
// Function to close the modal and release focus
function closeSettings() {
visible = false
cityInput.focus = false
profileImageInput.focus = false
wallpaperFolderInput.focus = false
}
Timer {
id: focusTimer
interval: 100
repeat: false
onTriggered: {
if (visible) {
cityInput.forceActiveFocus()
// Optionally, also focus profileImageInput if you want both to get focus:
// profileImageInput.forceActiveFocus()
}
}
}
// Release focus when modal becomes invisible
onVisibleChanged: {
if (!visible) {
cityInput.focus = false
profileImageInput.focus = false
wallpaperFolderInput.focus = false
}
}
}

View file

@ -0,0 +1,54 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import qs.Settings
ColumnLayout {
property alias title: headerText.text
property bool expanded: false // Hidden by default
default property alias content: contentItem.children
Rectangle {
Layout.fillWidth: true
height: 44
radius: 12
color: Theme.surface
border.color: Theme.accentPrimary
border.width: 2
RowLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 8
Text {
id: headerText
font.pixelSize: 16
font.bold: true
color: Theme.textPrimary
}
Item { Layout.fillWidth: true }
Rectangle {
width: 32; height: 32
color: "transparent"
Text {
anchors.centerIn: parent
text: expanded ? "expand_less" : "expand_more"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Theme.accentPrimary
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: expanded = !expanded
}
}
Item { height: 8 }
ColumnLayout {
id: contentItem
Layout.fillWidth: true
visible: expanded
spacing: 0
}
}

View file

@ -0,0 +1,171 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Qt5Compat.GraphicalEffects
import qs.Settings
Rectangle {
id: profileSettingsCard
Layout.fillWidth: true
Layout.preferredHeight: 140
color: Theme.surface
radius: 18
border.color: "transparent"
border.width: 0
Layout.bottomMargin: 16
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Profile Image Header
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Theme.accentPrimary
}
Text {
text: "Profile Image"
font.pixelSize: 16
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
}
// Profile Image Input Row
RowLayout {
spacing: 8
Layout.fillWidth: true
Rectangle {
width: 36
height: 36
radius: 18
color: Theme.surfaceVariant
border.color: profileImageInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
Image {
id: avatarImage
anchors.fill: parent
anchors.margins: 2
source: Settings.profileImage
fillMode: Image.PreserveAspectCrop
visible: false
asynchronous: true
cache: false
sourceSize.width: 64
sourceSize.height: 64
}
OpacityMask {
anchors.fill: avatarImage
source: avatarImage
maskSource: Rectangle {
width: avatarImage.width
height: avatarImage.height
radius: avatarImage.width / 2
visible: false
}
visible: Settings.profileImage !== ""
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 18
color: Theme.accentPrimary
visible: Settings.profileImage === ""
}
}
// Text input styled exactly like weather city
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Theme.surfaceVariant
border.color: profileImageInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: profileImageInput
anchors.fill: parent
anchors.margins: 12
text: Settings.profileImage
font.pixelSize: 13
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
onTextChanged: {
Settings.profileImage = text
Settings.saveSettings()
}
MouseArea {
anchors.fill: parent
onClicked: {
profileImageInput.forceActiveFocus()
}
}
}
}
}
// Video Path Input Row
RowLayout {
spacing: 8
Layout.fillWidth: true
Text {
text: "Video Path"
font.pixelSize: 14
color: Theme.textPrimary
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Theme.surfaceVariant
border.color: videoPathInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: videoPathInput
anchors.fill: parent
anchors.margins: 12
text: Settings.videoPath !== undefined ? Settings.videoPath : ""
font.pixelSize: 13
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhUrlCharactersOnly
onTextChanged: {
Settings.videoPath = text
Settings.saveSettings()
}
MouseArea {
anchors.fill: parent
onClicked: videoPathInput.forceActiveFocus()
}
}
}
}
}
}

View file

@ -0,0 +1,210 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Quickshell
import Quickshell.Wayland
import qs.Settings
PanelWindow {
id: settingsModal
implicitWidth: 480
implicitHeight: 720
visible: false
color: "transparent"
anchors.top: true
anchors.right: true
margins.right: 0
margins.top: -24
//z: 100
//border.color: Theme.outline
//border.width: 1
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// Local properties for editing (not saved until apply)
property string tempWeatherCity: (Settings.weatherCity !== undefined && Settings.weatherCity !== null) ? Settings.weatherCity : ""
property bool tempUseFahrenheit: Settings.useFahrenheit
property string tempProfileImage: (Settings.profileImage !== undefined && Settings.profileImage !== null) ? Settings.profileImage : ""
property string tempWallpaperFolder: (Settings.wallpaperFolder !== undefined && Settings.wallpaperFolder !== null) ? Settings.wallpaperFolder : ""
Rectangle {
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 24
//border.color: Theme.outline
//border.width: 1
z: 0
ColumnLayout {
anchors.fill: parent
anchors.margins: 32
spacing: 24
// Header
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: 20
Text {
text: "settings"
font.family: "Material Symbols Outlined"
font.pixelSize: 32
color: Theme.accentPrimary
}
Text {
text: "Settings"
font.pixelSize: 26
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
Rectangle {
width: 36
height: 36
radius: 18
color: closeButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 1
Text {
anchors.centerIn: parent
text: "close"
font.family: closeButtonArea.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 20
color: closeButtonArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: settingsModal.closeSettings()
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outline
opacity: 0.12
}
}
// Scrollable settings area
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 520
color: "transparent"
border.width: 0
radius: 20
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
interactive: true
boundsBehavior: Flickable.StopAtBounds
ColumnLayout {
id: column
width: flick.width
spacing: 24
// CollapsibleCategory sections here
CollapsibleCategory {
title: "Weather"
expanded: false
WeatherSettings {
weatherCity: (typeof tempWeatherCity !== 'undefined' && tempWeatherCity !== null) ? tempWeatherCity : ""
useFahrenheit: tempUseFahrenheit
onCityChanged: function(city) { tempWeatherCity = city }
onTemperatureUnitChanged: function(useFahrenheit) { tempUseFahrenheit = useFahrenheit }
}
}
CollapsibleCategory {
title: "System"
expanded: false
ProfileSettings { }
}
CollapsibleCategory {
title: "Wallpaper"
expanded: false
WallpaperSettings {
wallpaperFolder: (typeof tempWallpaperFolder !== 'undefined' && tempWallpaperFolder !== null) ? tempWallpaperFolder : ""
onWallpaperFolderEdited: function(folder) { tempWallpaperFolder = folder }
}
}
}
}
}
// Apply Button
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 52
radius: 16
color: applyButtonArea.containsMouse ? Theme.accentPrimary : Theme.accentPrimary
border.color: "transparent"
border.width: 0
opacity: 1.0
Text {
anchors.centerIn: parent
text: "Apply Changes"
font.pixelSize: 17
font.bold: true
color: applyButtonArea.containsMouse ? Theme.onAccent : Theme.onAccent
}
MouseArea {
id: applyButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Settings.weatherCity = (typeof tempWeatherCity !== 'undefined' && tempWeatherCity !== null) ? tempWeatherCity : ""
Settings.useFahrenheit = tempUseFahrenheit
Settings.profileImage = (typeof tempProfileImage !== 'undefined' && tempProfileImage !== null) ? tempProfileImage : ""
Settings.wallpaperFolder = (typeof tempWallpaperFolder !== 'undefined' && tempWallpaperFolder !== null) ? tempWallpaperFolder : ""
Settings.saveSettings()
if (typeof weather !== 'undefined' && weather) {
weather.fetchCityWeather()
}
settingsModal.closeSettings()
}
}
}
}
}
// Function to open the modal and initialize temp values
function openSettings() {
tempWeatherCity = (Settings.weatherCity !== undefined && Settings.weatherCity !== null) ? Settings.weatherCity : ""
tempUseFahrenheit = Settings.useFahrenheit
tempProfileImage = (Settings.profileImage !== undefined && Settings.profileImage !== null) ? Settings.profileImage : ""
tempWallpaperFolder = (Settings.wallpaperFolder !== undefined && Settings.wallpaperFolder !== null) ? Settings.wallpaperFolder : ""
if (tempWallpaperFolder === undefined || tempWallpaperFolder === null) tempWallpaperFolder = ""
visible = true
// Force focus on the text input after a short delay
focusTimer.start()
}
// Function to close the modal and release focus
function closeSettings() {
visible = false
}
Timer {
id: focusTimer
interval: 100
repeat: false
onTriggered: {
if (visible) {
// Focus will be handled by the individual components
}
}
}
// Release focus when modal becomes invisible
onVisibleChanged: {
if (!visible) {
// Focus will be handled by the individual components
}
}
}

View file

@ -0,0 +1,71 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import qs.Settings
Rectangle {
id: wallpaperSettingsCard
Layout.fillWidth: true
Layout.preferredHeight: 100
color: Theme.surface
radius: 18
// Property for binding
property string wallpaperFolder: ""
signal wallpaperFolderEdited(string folder)
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Header
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "image"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Theme.accentPrimary
}
Text {
text: "Wallpaper Folder"
font.pixelSize: 16
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
}
// Folder Path Input
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Theme.surfaceVariant
border.color: folderInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: folderInput
anchors.fill: parent
anchors.margins: 12
text: wallpaperFolder
font.pixelSize: 13
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhUrlCharactersOnly
onTextChanged: {
wallpaperFolderEdited(text)
}
MouseArea {
anchors.fill: parent
onClicked: folderInput.forceActiveFocus()
}
}
}
}
}

View file

@ -0,0 +1,153 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import qs.Settings
Rectangle {
id: weatherSettingsCard
Layout.fillWidth: true
Layout.preferredHeight: 180
color: Theme.surface
radius: 18
// Properties for binding
property string weatherCity: ""
property bool useFahrenheit: false
signal cityChanged(string city)
signal temperatureUnitChanged(bool useFahrenheit)
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Weather Settings Header
RowLayout {
Layout.fillWidth: true
spacing: 12
Text {
text: "wb_sunny"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Theme.accentPrimary
}
Text {
text: "Weather Settings"
font.pixelSize: 16
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
}
// Weather City Setting
ColumnLayout {
spacing: 8
Layout.fillWidth: true
Text {
text: "City"
font.pixelSize: 13
font.bold: true
color: Theme.textPrimary
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 8
color: Theme.surfaceVariant
border.color: cityInput.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: cityInput
anchors.fill: parent
anchors.margins: 12
text: weatherCity
font.pixelSize: 13
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
onTextChanged: {
cityChanged(text)
}
MouseArea {
anchors.fill: parent
onClicked: {
cityInput.forceActiveFocus()
}
}
}
}
}
// Temperature Unit Setting
RowLayout {
spacing: 12
Layout.fillWidth: true
Text {
text: "Temperature Unit"
font.pixelSize: 13
font.bold: true
color: Theme.textPrimary
}
Item {
Layout.fillWidth: true
}
// Custom Material 3 Switch
Rectangle {
id: customSwitch
width: 52
height: 32
radius: 16
color: Theme.accentPrimary
border.color: Theme.accentPrimary
border.width: 2
Rectangle {
id: thumb
width: 28
height: 28
radius: 14
color: Theme.surface
border.color: Theme.outline
border.width: 1
y: 2
x: useFahrenheit ? customSwitch.width - width - 2 : 2
Text {
anchors.centerIn: parent
text: useFahrenheit ? "°F" : "°C"
font.pixelSize: 12
font.bold: true
color: Theme.textPrimary
}
Behavior on x {
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
}
}
MouseArea {
anchors.fill: parent
onClicked: {
temperatureUnitChanged(!useFahrenheit)
}
}
}
}
}
}

View file

@ -0,0 +1,343 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Quickshell.Wayland
import Quickshell
import Quickshell.Bluetooth
import qs.Settings
import qs.Components
import qs.Helpers
Item {
id: root
property alias panel: bluetoothPanelModal
// For showing error/status messages
property string statusMessage: ""
property bool statusPopupVisible: false
function showStatus(msg) {
statusMessage = msg
statusPopupVisible = true
}
function hideStatus() {
statusPopupVisible = false
}
function showAt() {
bluetoothLogic.showAt()
}
Rectangle {
id: card
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: bluetoothButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
Text {
anchors.centerIn: parent
text: "bluetooth"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: bluetoothButtonArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
}
MouseArea {
id: bluetoothButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: bluetoothLogic.showAt()
}
}
QtObject {
id: bluetoothLogic
function showAt() {
if (Bluetooth.defaultAdapter) {
if (!Bluetooth.defaultAdapter.enabled)
Bluetooth.defaultAdapter.enabled = true
if (!Bluetooth.defaultAdapter.discovering)
Bluetooth.defaultAdapter.discovering = true
}
bluetoothPanelModal.visible = true
}
}
PanelWindow {
id: bluetoothPanelModal
implicitWidth: 480
implicitHeight: 720
visible: false
color: "transparent"
anchors.top: true
anchors.right: true
margins.right: 0
margins.top: -24
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
onVisibleChanged: {
if (!visible && Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering)
Bluetooth.defaultAdapter.discovering = false
}
Rectangle {
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 24
ColumnLayout {
anchors.fill: parent
anchors.margins: 32
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: 20
Layout.preferredHeight: 48
Layout.leftMargin: 16
Layout.rightMargin: 16
Text {
text: "bluetooth"
font.family: "Material Symbols Outlined"
font.pixelSize: 32
color: Theme.accentPrimary
}
Text {
text: "Bluetooth"
font.pixelSize: 26
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
Rectangle {
width: 36; height: 36; radius: 18
color: closeButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 1
Text {
anchors.centerIn: parent
text: "close"
font.family: closeButtonArea.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 20
color: closeButtonArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: bluetoothPanelModal.visible = false
cursorShape: Qt.PointingHandCursor
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outline
opacity: 0.12
}
// Content area (centered, in a card)
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 520
Layout.alignment: Qt.AlignHCenter
Layout.margins: 0
color: Theme.surfaceVariant
radius: 18
border.color: Theme.outline
border.width: 1
anchors.topMargin: 32
Rectangle {
id: bg
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 12
border.width: 1
border.color: Theme.surfaceVariant
z: 0
}
Rectangle {
id: header
color: "transparent"
}
Rectangle {
id: listContainer
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 24
color: "transparent"
clip: true
ListView {
id: deviceListView
anchors.fill: parent
spacing: 4
boundsBehavior: Flickable.StopAtBounds
model: Bluetooth.defaultAdapter ? Bluetooth.defaultAdapter.devices : []
delegate: Rectangle {
width: parent.width
height: 60
color: "transparent"
radius: 8
property bool userInitiatedDisconnect: false
Rectangle {
anchors.fill: parent
radius: 8
color: modelData.connected ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.18)
: (deviceMouseArea.containsMouse ? Theme.highlight : "transparent")
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
// Fixed-width icon for alignment
Text {
width: 28
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: modelData.connected ? "bluetooth" : "bluetooth_disabled"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: modelData.connected ? Theme.accentPrimary : Theme.textSecondary
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
// Device name always fills width for alignment
Text {
Layout.fillWidth: true
text: modelData.name || "Unknown Device"
color: modelData.connected ? Theme.accentPrimary : Theme.textPrimary
font.pixelSize: 14
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: modelData.address
color: modelData.connected ? Theme.accentPrimary : Theme.textSecondary
font.pixelSize: 11
elide: Text.ElideRight
}
Text {
text: "Paired: " + modelData.paired + " | Trusted: " + modelData.trusted
font.pixelSize: 10
color: Theme.textSecondary
visible: true
}
// No "Connected" text here!
}
Spinner {
running: modelData.pairing || modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting
color: Theme.textPrimary
size: 16
visible: running
}
}
MouseArea {
id: deviceMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (modelData.connected) {
userInitiatedDisconnect = true
modelData.disconnect()
} else if (!modelData.paired) {
modelData.pair()
root.showStatus("Pairing... Please check your phone or system for a PIN dialog.")
} else {
modelData.connect()
}
}
}
Connections {
target: modelData
function onPairedChanged() {
if (modelData.paired) {
root.showStatus("Paired! Now connecting...")
modelData.connect()
}
}
function onPairingChanged() {
if (!modelData.pairing && !modelData.paired) {
root.showStatus("Pairing failed or was cancelled.")
}
}
function onConnectedChanged() {
userInitiatedDisconnect = false
}
function onStateChanged() {
// Optionally handle more granular feedback here
}
}
}
}
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: 2
anchors.top: listContainer.top
anchors.bottom: listContainer.bottom
width: 4
radius: 2
color: Theme.textSecondary
opacity: deviceListView.contentHeight > deviceListView.height ? 0.3 : 0
visible: opacity > 0
}
}
}
}
// Status/Info popup
Popup {
id: statusPopup
x: (parent.width - width) / 2
y: 40
width: Math.min(360, parent.width - 40)
visible: root.statusPopupVisible
modal: false
focus: false
background: Rectangle {
color: Theme.accentPrimary // Use your theme's accent color
radius: 8
}
contentItem: Text {
text: root.statusMessage
color: "white"
wrapMode: Text.WordWrap
padding: 12
font.pixelSize: 14
}
onVisibleChanged: {
if (visible) {
// Auto-hide after 3 seconds
statusPopupTimer.restart()
}
}
}
}
}

View file

@ -0,0 +1,410 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import Qt5Compat.GraphicalEffects
import Quickshell.Services.Mpris
import qs.Settings
import qs.Components
import QtQuick
Rectangle {
id: musicCard
width: 360
height: 200
color: "transparent"
property var currentPlayer: null
property real currentPosition: 0
property int selectedPlayerIndex: 0
// Get all available players
function getAvailablePlayers() {
if (!Mpris.players || !Mpris.players.values) {
return []
}
let allPlayers = Mpris.players.values
let controllablePlayers = []
for (let i = 0; i < allPlayers.length; i++) {
let player = allPlayers[i]
if (player && player.canControl) {
controllablePlayers.push(player)
}
}
return controllablePlayers
}
// Find the active player
function findActivePlayer() {
let availablePlayers = getAvailablePlayers()
if (availablePlayers.length === 0) {
return null
}
// Use selected player if valid, otherwise use first available
if (selectedPlayerIndex < availablePlayers.length) {
return availablePlayers[selectedPlayerIndex]
} else {
selectedPlayerIndex = 0
return availablePlayers[0]
}
}
// Update current player
function updateCurrentPlayer() {
let newPlayer = findActivePlayer()
if (newPlayer !== currentPlayer) {
currentPlayer = newPlayer
currentPosition = currentPlayer ? currentPlayer.position : 0
}
}
// Timer to update progress bar position
Timer {
id: positionTimer
interval: 1000
running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0
repeat: true
onTriggered: {
if (currentPlayer && currentPlayer.isPlaying) {
currentPosition = currentPlayer.position
}
}
}
// Monitor for player changes
Connections {
target: Mpris.players
function onValuesChanged() {
updateCurrentPlayer()
}
}
Component.onCompleted: {
updateCurrentPlayer()
}
Rectangle {
id: card
anchors.fill: parent
color: Theme.surface
radius: 18
// No music player available state
Item {
width: parent.width
height: parent.height
visible: !currentPlayer
ColumnLayout {
anchors.centerIn: parent
spacing: 16
Text {
text: "music_note"
font.family: "Material Symbols Outlined"
font.pixelSize: 48
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3)
Layout.alignment: Qt.AlignHCenter
}
Text {
text: getAvailablePlayers().length > 0 ? "No controllable player selected" : "No music player detected"
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.6)
font.family: "Roboto"
font.pixelSize: 14
Layout.alignment: Qt.AlignHCenter
}
}
}
// Music player content
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
visible: currentPlayer
// Album artwork and track info row
RowLayout {
spacing: 12
Layout.fillWidth: true
// Album artwork with circular spectrum visualizer, aligned left
Item {
id: albumArtContainer
width: 96; height: 96 // enough for spectrum and art (will adjust if needed)
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
// Circular spectrum visualizer behind album art
CircularSpectrum {
id: spectrum
anchors.centerIn: parent
innerRadius: 30 // just outside 60x60 album art
outerRadius: 48 // how far bars extend
fillColor: Theme.accentPrimary
strokeColor: Theme.accentPrimary
strokeWidth: 0
z: 0
}
// Album art in the center
Rectangle {
id: albumArtwork
width: 60; height: 60
anchors.centerIn: parent
radius: 30 // circle
color: Qt.darker(Theme.surface, 1.1)
border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3)
border.width: 1
Image {
id: albumArt
anchors.fill: parent
anchors.margins: 2
fillMode: Image.PreserveAspectCrop
smooth: true
cache: false
asynchronous: true
sourceSize.width: 60
sourceSize.height: 60
source: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
visible: source.toString() !== ""
// Rounded corners using layer
layer.enabled: true
layer.effect: OpacityMask {
cached: true
maskSource: Rectangle {
width: albumArt.width
height: albumArt.height
radius: albumArt.width / 2 // circle
visible: false
}
}
}
// Fallback music icon
Text {
anchors.centerIn: parent
text: "album"
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.4)
visible: !albumArt.visible
}
}
}
// Track info
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text {
text: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : ""
color: Theme.textPrimary
font.family: "Roboto"
font.pixelSize: 14
font.bold: true
elide: Text.ElideRight
wrapMode: Text.Wrap
maximumLineCount: 2
Layout.fillWidth: true
}
Text {
text: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : ""
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.8)
font.family: "Roboto"
font.pixelSize: 12
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : ""
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.6)
font.family: "Roboto"
font.pixelSize: 10
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
// Progress bar
Rectangle {
id: progressBarBackground
width: parent.width
height: 6
radius: 3
color: Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.15)
Layout.fillWidth: true
property real progressRatio: currentPlayer && currentPlayer.length > 0 ?
(currentPosition / currentPlayer.length) : 0
Rectangle {
id: progressFill
width: progressBarBackground.progressRatio * parent.width
height: parent.height
radius: parent.radius
color: Theme.accentPrimary
Behavior on width {
NumberAnimation { duration: 200 }
}
}
// Interactive progress handle
Rectangle {
id: progressHandle
width: 12
height: 12
radius: 6
color: Theme.accentPrimary
border.color: Qt.lighter(Theme.accentPrimary, 1.3)
border.width: 1
x: Math.max(0, Math.min(parent.width - width, progressFill.width - width/2))
anchors.verticalCenter: parent.verticalCenter
visible: currentPlayer && currentPlayer.length > 0
scale: progressMouseArea.containsMouse || progressMouseArea.pressed ? 1.2 : 1.0
Behavior on scale {
NumberAnimation { duration: 150 }
}
}
// Mouse area for seeking
MouseArea {
id: progressMouseArea
anchors.fill: parent
hoverEnabled: true
enabled: currentPlayer && currentPlayer.length > 0 && currentPlayer.canSeek
onClicked: function(mouse) {
if (currentPlayer && currentPlayer.length > 0) {
let ratio = mouse.x / width
let seekPosition = ratio * currentPlayer.length
currentPlayer.position = seekPosition
currentPosition = seekPosition
}
}
onPositionChanged: function(mouse) {
if (pressed && currentPlayer && currentPlayer.length > 0) {
let ratio = Math.max(0, Math.min(1, mouse.x / width))
let seekPosition = ratio * currentPlayer.length
currentPlayer.position = seekPosition
currentPosition = seekPosition
}
}
}
}
// Media controls
RowLayout {
spacing: 4
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
// Previous button
Rectangle {
width: 28
height: 28
radius: 14
color: previousButton.containsMouse ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.2) : Qt.darker(Theme.surface, 1.1)
border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3)
border.width: 1
MouseArea {
id: previousButton
anchors.fill: parent
hoverEnabled: true
enabled: currentPlayer && currentPlayer.canGoPrevious
onClicked: if (currentPlayer) currentPlayer.previous()
}
Text {
anchors.centerIn: parent
text: "skip_previous"
font.family: "Material Symbols Outlined"
font.pixelSize: 12
color: previousButton.enabled ? Theme.accentPrimary : Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3)
}
}
// Play/Pause button
Rectangle {
width: 36
height: 36
radius: 18
color: playButton.containsMouse ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.2) : Qt.darker(Theme.surface, 1.1)
border.color: Theme.accentPrimary
border.width: 2
MouseArea {
id: playButton
anchors.fill: parent
hoverEnabled: true
enabled: currentPlayer && (currentPlayer.canPlay || currentPlayer.canPause)
onClicked: {
if (currentPlayer) {
if (currentPlayer.isPlaying) {
currentPlayer.pause()
} else {
currentPlayer.play()
}
}
}
}
Text {
anchors.centerIn: parent
text: currentPlayer && currentPlayer.isPlaying ? "pause" : "play_arrow"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: playButton.enabled ? Theme.accentPrimary : Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3)
}
}
// Next button
Rectangle {
width: 28
height: 28
radius: 14
color: nextButton.containsMouse ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.2) : Qt.darker(Theme.surface, 1.1)
border.color: Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.3)
border.width: 1
MouseArea {
id: nextButton
anchors.fill: parent
hoverEnabled: true
enabled: currentPlayer && currentPlayer.canGoNext
onClicked: if (currentPlayer) currentPlayer.next()
}
Text {
anchors.centerIn: parent
text: "skip_next"
font.family: "Material Symbols Outlined"
font.pixelSize: 12
color: nextButton.enabled ? Theme.accentPrimary : Qt.rgba(Theme.textPrimary.r, Theme.textPrimary.g, Theme.textPrimary.b, 0.3)
}
}
}
}
}
// Audio Visualizer (Cava)
Cava {
id: cava
count: 64
}
}

View file

@ -0,0 +1,394 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Settings
import qs.Widgets.Sidebar.Config
import qs.Components
PanelWindow {
id: panelPopup
implicitWidth: 500
implicitHeight: 750
visible: false
color: "transparent"
screen: modelData
anchors.top: true
anchors.right: true
margins.top: -24
WlrLayershell.keyboardFocus: (settingsModal.visible && mouseArea.containsMouse) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
// Animation properties
property real slideOffset: width
property bool isAnimating: false
function showAt() {
if (!visible) {
visible = true;
forceActiveFocus();
slideAnim.from = width;
slideAnim.to = 0;
slideAnim.running = true;
// Start system monitoring when sidebar becomes visible
if (systemMonitor) systemMonitor.startMonitoring();
if (weather) weather.startWeatherFetch();
if (systemWidget) systemWidget.panelVisible = true;
if (quickAccessWidget) quickAccessWidget.panelVisible = true;
}
}
function hidePopup() {
if (visible) {
slideAnim.from = 0;
slideAnim.to = width;
slideAnim.running = true;
}
}
NumberAnimation {
id: slideAnim
target: panelPopup
property: "slideOffset"
duration: 300
easing.type: Easing.OutCubic
onStopped: {
if (panelPopup.slideOffset === panelPopup.width) {
panelPopup.visible = false;
// Stop system monitoring when sidebar becomes hidden
if (systemMonitor) systemMonitor.stopMonitoring();
if (weather) weather.stopWeatherFetch();
if (systemWidget) systemWidget.panelVisible = false;
if (quickAccessWidget) quickAccessWidget.panelVisible = false;
}
panelPopup.isAnimating = false;
}
onStarted: {
panelPopup.isAnimating = true;
}
}
property int leftPadding: 20
property int bottomPadding: 20
Rectangle {
id: mainRectangle
width: parent.width - leftPadding
height: parent.height - bottomPadding
anchors.top: parent.top
x: leftPadding + slideOffset
y: 0
color: Theme.backgroundPrimary
bottomLeftRadius: 20
z: 0
Behavior on x {
enabled: !panelPopup.isAnimating
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
}
property alias settingsModal: settingsModal
SettingsModal {
id: settingsModal
}
Item {
anchors.fill: mainRectangle
x: slideOffset
Behavior on x {
enabled: !panelPopup.isAnimating
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 16
System {
id: systemWidget
Layout.alignment: Qt.AlignHCenter
z: 3
}
Weather {
id: weather
Layout.alignment: Qt.AlignHCenter
z: 2
}
// Music and System Monitor row
RowLayout {
spacing: 12
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
Music {
z: 2
}
SystemMonitor {
id: systemMonitor
z: 2
}
}
// Power profile, Wifi and Bluetooth row
RowLayout {
Layout.alignment: Qt.AlignLeft
Layout.preferredHeight: 80
spacing: 16
z: 3
PowerProfile {
Layout.alignment: Qt.AlignLeft
Layout.preferredHeight: 80
}
// Network card containing Wifi and Bluetooth
Rectangle {
Layout.preferredHeight: 70
Layout.preferredWidth: 140
Layout.fillWidth: false
color: Theme.surface
radius: 18
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: 20
// Wifi button
Rectangle {
id: wifiButton
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: wifiButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
Text {
anchors.centerIn: parent
text: "wifi"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: wifiButtonArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
id: wifiButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: wifiPanelModal.showAt()
}
}
// Bluetooth button
Rectangle {
id: bluetoothButton
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: bluetoothButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
Text {
anchors.centerIn: parent
text: "bluetooth"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: bluetoothButtonArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
id: bluetoothButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: bluetoothPanelModal.showAt()
}
}
}
}
}
// Hidden panel components for modal functionality
WifiPanel {
id: wifiPanelModal
visible: false
}
BluetoothPanel {
id: bluetoothPanelModal
visible: false
}
Item {
Layout.fillHeight: true
}
// QuickAccess widget
QuickAccess {
id: quickAccessWidget
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: -16
z: 2
isRecording: panelPopup.isRecording
onRecordingRequested: {
startRecording()
}
onStopRecordingRequested: {
stopRecording()
}
onRecordingStateMismatch: function(actualState) {
isRecording = actualState
quickAccessWidget.isRecording = actualState
}
onSettingsRequested: {
settingsModal.visible = true
}
onWallpaperRequested: {
wallpaperPanelModal.visible = true
}
}
}
Keys.onEscapePressed: panelPopup.hidePopup()
}
onVisibleChanged: if (!visible) {/* cleanup if needed */}
// Update height when screen changes
onScreenChanged: {
if (screen) {
// Height is now hardcoded to 720, no need to update
}
}
// Recording properties
property bool isRecording: false
property var recordingProcess: null
property var recordingPid: null
// Start screen recording
function startRecording() {
var currentDate = new Date()
var hours = String(currentDate.getHours()).padStart(2, '0')
var minutes = String(currentDate.getMinutes()).padStart(2, '0')
var day = String(currentDate.getDate()).padStart(2, '0')
var month = String(currentDate.getMonth() + 1).padStart(2, '0')
var year = currentDate.getFullYear()
var filename = hours + "-" + minutes + "-" + day + "-" + month + "-" + year + ".mp4"
var outputPath = Settings.videoPath + filename
var command = "gpu-screen-recorder -w portal -f 60 -a default_output -o " + outputPath
var qmlString = 'import Quickshell.Io; Process { command: ["sh", "-c", "' + command + '"]; running: true }'
recordingProcess = Qt.createQmlObject(qmlString, panelPopup)
isRecording = true
quickAccessWidget.isRecording = true
}
// Stop recording with cleanup
function stopRecording() {
if (recordingProcess && isRecording) {
var stopQmlString = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -SIGINT -f \'gpu-screen-recorder.*portal\'"]; running: true; onExited: function() { destroy() } }'
var stopProcess = Qt.createQmlObject(stopQmlString, panelPopup)
var cleanupTimer = Qt.createQmlObject('import QtQuick; Timer { interval: 3000; running: true; repeat: false }', panelPopup)
cleanupTimer.triggered.connect(function() {
if (recordingProcess) {
recordingProcess.running = false
recordingProcess.destroy()
recordingProcess = null
}
var forceKillQml = 'import Quickshell.Io; Process { command: ["sh", "-c", "pkill -9 -f \'gpu-screen-recorder.*portal\' 2>/dev/null || true"]; running: true; onExited: function() { destroy() } }'
var forceKillProcess = Qt.createQmlObject(forceKillQml, panelPopup)
cleanupTimer.destroy()
})
}
isRecording = false
quickAccessWidget.isRecording = false
recordingPid = null
}
// Clean up processes on destruction
Component.onDestruction: {
if (isRecording) {
stopRecording()
}
if (recordingProcess) {
recordingProcess.running = false
recordingProcess.destroy()
recordingProcess = null
}
}
Corners {
id: sidebarCornerLeft
position: "bottomright"
size: 1.1
fillColor: Theme.backgroundPrimary
anchors.top: mainRectangle.top
offsetX: -447 + panelPopup.slideOffset
offsetY: 0
Behavior on offsetX {
enabled: !panelPopup.isAnimating
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
}
Corners {
id: sidebarCornerBottom
position: "bottomright"
size: 1.1
fillColor: Theme.backgroundPrimary
offsetX: 33 + panelPopup.slideOffset
offsetY: 46
Behavior on offsetX {
enabled: !panelPopup.isAnimating
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
}
WallpaperPanel {
id: wallpaperPanelModal
visible: false
Component.onCompleted: {
if (parent) {
wallpaperPanelModal.anchors.top = parent.top;
wallpaperPanelModal.anchors.right = parent.right;
}
}
// Add a close button inside WallpaperPanel.qml for user to close the modal
}
}

View file

@ -0,0 +1,127 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Quickshell.Services.UPower
import qs.Settings
Rectangle {
id: card
width: 200
height: 70
color: Theme.surface
radius: 18
Row {
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: 20
// Performance
Rectangle {
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: (typeof PowerProfiles !== 'undefined' && PowerProfiles.profile === PowerProfile.Performance)
? Theme.accentPrimary
: (perfMouseArea.containsMouse ? Theme.accentPrimary : "transparent")
opacity: (typeof PowerProfiles !== 'undefined' && !PowerProfiles.hasPerformanceProfile) ? 0.4 : 1
Text {
anchors.centerIn: parent
text: "speed"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: (typeof PowerProfiles !== 'undefined' && PowerProfiles.profile === PowerProfile.Performance) || perfMouseArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
id: perfMouseArea
anchors.fill: parent
hoverEnabled: true
enabled: typeof PowerProfiles !== 'undefined' && PowerProfiles.hasPerformanceProfile
cursorShape: Qt.PointingHandCursor
onClicked: {
if (typeof PowerProfiles !== 'undefined')
PowerProfiles.profile = PowerProfile.Performance;
}
}
}
// Balanced
Rectangle {
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: (typeof PowerProfiles !== 'undefined' && PowerProfiles.profile === PowerProfile.Balanced)
? Theme.accentPrimary
: (balMouseArea.containsMouse ? Theme.accentPrimary : "transparent")
opacity: 1
Text {
anchors.centerIn: parent
text: "balance"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: (typeof PowerProfiles !== 'undefined' && PowerProfiles.profile === PowerProfile.Balanced) || balMouseArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
id: balMouseArea
anchors.fill: parent
hoverEnabled: true
enabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (typeof PowerProfiles !== 'undefined')
PowerProfiles.profile = PowerProfile.Balanced;
}
}
}
// Power Saver
Rectangle {
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: (typeof PowerProfiles !== 'undefined' && PowerProfiles.profile === PowerProfile.PowerSaver)
? Theme.accentPrimary
: (saveMouseArea.containsMouse ? Theme.accentPrimary : "transparent")
opacity: 1
Text {
anchors.centerIn: parent
text: "eco"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: (typeof PowerProfiles !== 'undefined' && PowerProfiles.profile === PowerProfile.PowerSaver) || saveMouseArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
id: saveMouseArea
anchors.fill: parent
hoverEnabled: true
enabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (typeof PowerProfiles !== 'undefined')
PowerProfiles.profile = PowerProfile.PowerSaver;
}
}
}
}
}

View file

@ -0,0 +1,196 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Io
import "root:/Settings" as Settings
Rectangle {
id: quickAccessWidget
width: 440
height: 80
color: "transparent"
anchors.horizontalCenterOffset: -2
required property bool isRecording
signal recordingRequested()
signal stopRecordingRequested()
signal recordingStateMismatch(bool actualState)
signal settingsRequested()
signal wallpaperRequested()
Rectangle {
id: card
anchors.fill: parent
color: Settings.Theme.surface
radius: 18
RowLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Settings Button
Rectangle {
id: settingsButton
Layout.fillWidth: true
Layout.preferredHeight: 44
radius: 12
color: settingsButtonArea.containsMouse ? Settings.Theme.accentPrimary : "transparent"
border.color: Settings.Theme.accentPrimary
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: "settings"
font.family: settingsButtonArea.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16
color: settingsButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.accentPrimary
}
Text {
text: "Settings"
font.pixelSize: 14
font.bold: true
color: settingsButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: settingsButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
settingsRequested()
}
}
}
// Screen Recorder Button
Rectangle {
id: recorderButton
Layout.fillWidth: true
Layout.preferredHeight: 44
radius: 12
color: isRecording ? Settings.Theme.accentPrimary :
(recorderButtonArea.containsMouse ? Settings.Theme.accentPrimary : "transparent")
border.color: Settings.Theme.accentPrimary
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: isRecording ? "radio_button_checked" : "radio_button_unchecked"
font.family: (isRecording || recorderButtonArea.containsMouse) ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16
color: isRecording || recorderButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.accentPrimary
}
Text {
text: isRecording ? "End" : "Record"
font.pixelSize: 14
font.bold: true
color: isRecording || recorderButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: recorderButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (isRecording) {
stopRecordingRequested()
} else {
recordingRequested()
}
}
}
}
// Wallpaper Button
Rectangle {
id: wallpaperButton
Layout.fillWidth: true
Layout.preferredHeight: 44
radius: 12
color: wallpaperButtonArea.containsMouse ? Settings.Theme.accentPrimary : "transparent"
border.color: Settings.Theme.accentPrimary
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: "image"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: wallpaperButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.accentPrimary
}
Text {
text: "Wallpaper"
font.pixelSize: 14
font.bold: true
color: wallpaperButtonArea.containsMouse ? Settings.Theme.onAccent : Settings.Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: wallpaperButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
wallpaperRequested()
}
}
}
}
}
// Properties
property bool panelVisible: false
// Timer to check if recording is active
Timer {
interval: 2000 // Check every 2 seconds
repeat: true
running: panelVisible
onTriggered: checkRecordingStatus()
}
function checkRecordingStatus() {
// Simple check - if we're recording but no process, reset state
if (isRecording) {
checkRecordingProcess.running = true
}
}
// Process to check if gpu-screen-recorder is running
Process {
id: checkRecordingProcess
command: ["pgrep", "-f", "gpu-screen-recorder.*portal"]
onExited: function(exitCode, exitStatus) {
var isActuallyRecording = exitCode === 0
// If we think we're recording but process isn't running, reset state
if (isRecording && !isActuallyRecording) {
recordingStateMismatch(isActuallyRecording)
}
}
}
}

View file

@ -0,0 +1,372 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Io
import qs.Settings
import qs.Widgets
import qs.Helpers
Rectangle {
id: systemWidget
width: 440
height: 80
color: "transparent"
anchors.horizontalCenterOffset: -2
Rectangle {
id: card
anchors.fill: parent
color: Theme.surface
radius: 18
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// User Info Row
RowLayout {
Layout.fillWidth: true
spacing: 12
// Profile Image
Rectangle {
width: 48
height: 48
radius: 24
color: Theme.accentPrimary
// Border overlay
Rectangle {
anchors.fill: parent
color: "transparent"
radius: 24
border.color: Theme.accentPrimary
border.width: 2
z: 2
}
OpacityMask {
anchors.fill: parent
source: Image {
id: avatarImage
anchors.fill: parent
source: Settings.profileImage !== undefined ? Settings.profileImage : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
sourceSize.width: 44
sourceSize.height: 44
}
maskSource: Rectangle {
width: 44
height: 44
radius: 22
visible: false
}
visible: Settings.profileImage !== undefined && Settings.profileImage !== ""
z: 1
}
// Fallback icon
Text {
anchors.centerIn: parent
text: "person"
font.family: "Material Symbols Outlined"
font.pixelSize: 24
color: Theme.onAccent
visible: Settings.profileImage === undefined || Settings.profileImage === ""
z: 0
}
}
// User Info
ColumnLayout {
spacing: 4
Layout.fillWidth: true
Text {
text: Quickshell.env("USER")
font.pixelSize: 16
font.bold: true
color: Theme.textPrimary
}
Text {
text: "System Uptime: " + uptimeText
font.pixelSize: 12
color: Theme.textSecondary
}
}
// Spacer to push button to the right
Item {
Layout.fillWidth: true
}
// System Menu Button - positioned all the way to the right
Rectangle {
id: systemButton
width: 32
height: 32
radius: 16
color: systemButtonArea.containsMouse || systemButtonArea.pressed ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 1
Text {
anchors.centerIn: parent
text: "power_settings_new"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: systemButtonArea.containsMouse || systemButtonArea.pressed ? Theme.backgroundPrimary : Theme.accentPrimary
}
MouseArea {
id: systemButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
systemMenu.visible = !systemMenu.visible
}
}
}
}
}
}
// System Menu Popup - positioned below the button
Rectangle {
id: systemMenu
width: 160
height: 180
color: Theme.surface
radius: 8
border.color: Theme.outline
border.width: 1
visible: false
z: 9999
// Position relative to the system button using absolute positioning
x: systemButton.x + systemButton.width - width + 12
y: systemButton.y + systemButton.height + 32
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 4
// Lock Button
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 6
color: lockButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: "lock_outline"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: lockButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
}
Text {
text: "Lock Screen"
font.pixelSize: 14
color: lockButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: lockButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
lockScreen.locked = true;
systemMenu.visible = false;
}
}
}
// Reboot Button
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 6
color: rebootButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: "refresh"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: rebootButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
}
Text {
text: "Reboot"
font.pixelSize: 14
color: rebootButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: rebootButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Processes.reboot()
systemMenu.visible = false
}
}
}
// Logout Button
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 6
color: logoutButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: "exit_to_app"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: logoutButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
}
Text {
text: "Logout"
font.pixelSize: 14
color: logoutButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: logoutButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Processes.logout()
systemMenu.visible = false
}
}
}
// Shutdown Button
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
radius: 6
color: shutdownButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
Text {
text: "power_settings_new"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: shutdownButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
}
Text {
text: "Shutdown"
font.pixelSize: 14
color: shutdownButtonArea.containsMouse ? Theme.onAccent : Theme.textPrimary
Layout.fillWidth: true
}
}
MouseArea {
id: shutdownButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
Processes.shutdown()
systemMenu.visible = false
}
}
}
}
// Close menu when clicking outside
MouseArea {
anchors.fill: parent
enabled: systemMenu.visible
onClicked: systemMenu.visible = false
z: -1 // Put this behind other elements
}
}
// Properties
property string uptimeText: "--:--"
// Process to get uptime
Process {
id: uptimeProcess
command: ["sh", "-c", "uptime | awk -F 'up ' '{print $2}' | awk -F ',' '{print $1}' | xargs"]
running: false
stdout: StdioCollector {
onStreamFinished: {
uptimeText = this.text.trim()
uptimeProcess.running = false
}
}
}
property bool panelVisible: false
// Trigger initial update when panel becomes visible
onPanelVisibleChanged: {
if (panelVisible) {
updateSystemInfo()
}
}
// Timer to update uptime - only runs when panel is visible
Timer {
interval: 60000 // Update every minute
repeat: true
running: panelVisible
onTriggered: updateSystemInfo()
}
Component.onCompleted: {
// Don't update system info immediately - wait for panel to be visible
// updateSystemInfo() will be called when panelVisible becomes true
uptimeProcess.running = true
}
function updateSystemInfo() {
uptimeProcess.running = true
}
// Add lockscreen instance (hidden by default)
LockScreen {
id: lockScreen
}
}

View file

@ -0,0 +1,158 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Quickshell.Io
import "root:/Settings" as Settings
import "root:/Components" as Components
Rectangle {
id: systemMonitor
width: 70
height: 200
color: "transparent"
property real cpuUsage: 0
property real memoryUsage: 0
property real diskUsage: 0
property bool isVisible: false
// Timers to control when processes run
Timer {
id: cpuTimer
interval: 2000
repeat: true
running: isVisible
onTriggered: cpuInfo.running = true
}
Timer {
id: memoryTimer
interval: 3000
repeat: true
running: isVisible
onTriggered: memoryInfo.running = true
}
Timer {
id: diskTimer
interval: 5000
repeat: true
running: isVisible
onTriggered: diskInfo.running = true
}
// Process for getting CPU usage
Process {
id: cpuInfo
command: ["sh", "-c", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | awk -F'%' '{print $1}'"]
running: false
stdout: SplitParser {
onRead: data => {
let usage = parseFloat(data.trim())
if (!isNaN(usage)) {
systemMonitor.cpuUsage = usage
}
cpuInfo.running = false
}
}
}
// Process for getting memory usage
Process {
id: memoryInfo
command: ["sh", "-c", "free | grep Mem | awk '{print int($3/$2 * 100)}'"]
running: false
stdout: SplitParser {
onRead: data => {
let usage = parseFloat(data.trim())
if (!isNaN(usage)) {
systemMonitor.memoryUsage = usage
}
memoryInfo.running = false
}
}
}
// Process for getting disk usage
Process {
id: diskInfo
command: ["sh", "-c", "df / | tail -1 | awk '{print int($5)}'"]
running: false
stdout: SplitParser {
onRead: data => {
let usage = parseFloat(data.trim())
if (!isNaN(usage)) {
systemMonitor.diskUsage = usage
}
diskInfo.running = false
}
}
}
// Function to start monitoring
function startMonitoring() {
isVisible = true
// Trigger initial readings
cpuInfo.running = true
memoryInfo.running = true
diskInfo.running = true
}
// Function to stop monitoring
function stopMonitoring() {
isVisible = false
cpuInfo.running = false
memoryInfo.running = false
diskInfo.running = false
}
Rectangle {
id: card
anchors.fill: parent
color: Settings.Theme.surface
radius: 18
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 12
Layout.alignment: Qt.AlignVCenter
// CPU Usage
Components.CircularProgressBar {
progress: cpuUsage / 100
size: 50
strokeWidth: 4
hasNotch: true
notchIcon: "speed"
notchIconSize: 14
Layout.alignment: Qt.AlignHCenter
}
// Memory Usage
Components.CircularProgressBar {
progress: memoryUsage / 100
size: 50
strokeWidth: 4
hasNotch: true
notchIcon: "memory"
notchIconSize: 14
Layout.alignment: Qt.AlignHCenter
}
// Disk Usage
Components.CircularProgressBar {
progress: diskUsage / 100
size: 50
strokeWidth: 4
hasNotch: true
notchIcon: "storage"
notchIconSize: 14
Layout.alignment: Qt.AlignHCenter
}
}
}
}

View file

@ -0,0 +1,150 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import Quickshell
import Quickshell.Io
import qs.Settings
PanelWindow {
id: wallpaperPanelModal
implicitWidth: 480
implicitHeight: 720
visible: false
color: "transparent"
anchors.top: true
anchors.right: true
margins.right: 0
margins.top: -24
property var wallpapers: []
Process {
id: listWallpapersProcess
running: visible
command: ["ls", Settings.wallpaperFolder !== undefined ? Settings.wallpaperFolder : ""]
stdout: StdioCollector {
onStreamFinished: {
// Split by newlines and filter out empty lines
wallpaperPanelModal.wallpapers = this.text.split("\n").filter(function(x){return x.length > 0})
}
}
}
Rectangle {
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 24
ColumnLayout {
anchors.fill: parent
anchors.margins: 32
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: 20
Layout.preferredHeight: 48
Text {
text: "image"
font.family: "Material Symbols Outlined"
font.pixelSize: 32
color: Theme.accentPrimary
}
Text {
text: "Wallpapers"
font.pixelSize: 26
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
Rectangle {
width: 36; height: 36; radius: 18
color: closeButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 1
Text {
anchors.centerIn: parent
text: "close"
font.family: closeButtonArea.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 20
color: closeButtonArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: wallpaperPanelModal.visible = false
cursorShape: Qt.PointingHandCursor
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outline
opacity: 0.12
}
// Wallpaper grid area
Item {
Layout.fillWidth: true
Layout.fillHeight: true
anchors.topMargin: 16
anchors.bottomMargin: 16
anchors.leftMargin: 0
anchors.rightMargin: 0
anchors.margins: 0
clip: true
ScrollView {
id: scrollView
anchors.fill: parent
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
GridView {
id: wallpaperGrid
anchors.fill: parent
cellWidth: Math.max(120, (scrollView.width / 3) - 12)
cellHeight: cellWidth * 0.6
model: wallpapers
cacheBuffer: 0
leftMargin: 8
rightMargin: 8
topMargin: 8
bottomMargin: 8
delegate: Item {
width: wallpaperGrid.cellWidth - 8
height: wallpaperGrid.cellHeight - 8
Rectangle {
id: wallpaperItem
anchors.fill: parent
anchors.margins: 4
color: Qt.darker(Theme.backgroundPrimary, 1.1)
radius: 12
border.color: Settings.currentWallpaper === (Settings.wallpaperFolder !== undefined ? Settings.wallpaperFolder : "") + "/" + modelData ? Theme.accentPrimary : Theme.outline
border.width: Settings.currentWallpaper === (Settings.wallpaperFolder !== undefined ? Settings.wallpaperFolder : "") + "/" + modelData ? 3 : 1
Image {
id: wallpaperImage
anchors.fill: parent
anchors.margins: 4
source: (Settings.wallpaperFolder !== undefined ? Settings.wallpaperFolder : "") + "/" + modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
sourceSize.width: Math.min(width, 150)
sourceSize.height: Math.min(height, 90)
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: {
var selectedPath = (Settings.wallpaperFolder !== undefined ? Settings.wallpaperFolder : "") + "/" + modelData;
Settings.currentWallpaper = selectedPath;
Settings.saveSettings();
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,197 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15
import qs.Settings
import "root:/Helpers/Weather.js" as WeatherHelper
Rectangle {
id: weatherRoot
width: 440
height: 180
color: "transparent"
anchors.horizontalCenterOffset: -2
property string city: Settings.weatherCity !== undefined ? Settings.weatherCity : ""
property var weatherData: null
property string errorString: ""
property bool isVisible: false
Component.onCompleted: {
if (isVisible) {
fetchCityWeather()
}
}
function fetchCityWeather() {
WeatherHelper.fetchCityWeather(city,
function(result) {
weatherData = result.weather;
errorString = "";
},
function(err) {
errorString = err;
}
);
}
function startWeatherFetch() {
isVisible = true
fetchCityWeather()
}
function stopWeatherFetch() {
isVisible = false
}
Rectangle {
id: card
anchors.fill: parent
color: Theme.surface
radius: 18
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 12
// Current weather row
RowLayout {
spacing: 12
Layout.fillWidth: true
// Weather icon and basic info
RowLayout {
spacing: 12
Layout.preferredWidth: 140
// Material Symbol icon
Text {
id: weatherIcon
text: weatherData && weatherData.current_weather ? materialSymbolForCode(weatherData.current_weather.weathercode) : "cloud"
font.family: "Material Symbols Outlined"
font.pixelSize: 28
verticalAlignment: Text.AlignVCenter
color: Theme.accentPrimary
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
spacing: 2
RowLayout {
spacing: 4
Text {
text: city
font.pixelSize: 14
font.bold: true
color: Theme.textPrimary
}
Text {
text: weatherData && weatherData.timezone_abbreviation ? `(${weatherData.timezone_abbreviation})` : ""
font.pixelSize: 10
color: Theme.textSecondary
leftPadding: 2
}
}
Text {
text: weatherData && weatherData.current_weather ? ((Settings.useFahrenheit !== undefined ? Settings.useFahrenheit : false) ? `${Math.round(weatherData.current_weather.temperature * 9/5 + 32)}°F` : `${Math.round(weatherData.current_weather.temperature)}°C`) : ((Settings.useFahrenheit !== undefined ? Settings.useFahrenheit : false) ? "--°F" : "--°C")
font.pixelSize: 24
font.bold: true
color: Theme.textPrimary
}
}
}
// Spacer to push content to the right
Item {
Layout.fillWidth: true
}
}
// Separator line
Rectangle {
width: parent.width
height: 1
color: Qt.rgba(Theme.textSecondary.g, Theme.textSecondary.g, Theme.textSecondary.b, 0.12)
Layout.fillWidth: true
Layout.topMargin: 2
Layout.bottomMargin: 2
}
// 5-day forecast row (smaller)
RowLayout {
spacing: 12
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
visible: weatherData && weatherData.daily && weatherData.daily.time
Repeater {
model: weatherData && weatherData.daily && weatherData.daily.time ? 5 : 0
delegate: ColumnLayout {
spacing: 2
Layout.alignment: Qt.AlignHCenter
Text {
// Day name (e.g., Mon)
text: Qt.formatDateTime(new Date(weatherData.daily.time[index]), "ddd")
font.pixelSize: 12
color: Theme.textSecondary
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
Text {
// Material Symbol icon
text: materialSymbolForCode(weatherData.daily.weathercode[index])
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: Theme.accentPrimary
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
Text {
// High/low temp
text: weatherData && weatherData.daily ? ((Settings.useFahrenheit !== undefined ? Settings.useFahrenheit : false) ? `${Math.round(weatherData.daily.temperature_2m_max[index] * 9/5 + 32)}° / ${Math.round(weatherData.daily.temperature_2m_min[index] * 9/5 + 32)}°` : `${Math.round(weatherData.daily.temperature_2m_max[index])}° / ${Math.round(weatherData.daily.temperature_2m_min[index])}°`) : ((Settings.useFahrenheit !== undefined ? Settings.useFahrenheit : false) ? "--° / --°" : "--° / --°")
font.pixelSize: 12
color: Theme.textPrimary
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
}
}
}
// Error message (if any)
Text {
text: errorString
color: Theme.error
visible: errorString !== ""
font.pixelSize: 10
horizontalAlignment: Text.AlignHCenter
Layout.alignment: Qt.AlignHCenter
}
}
}
// Weather code to Material Symbol ligature
function materialSymbolForCode(code) {
// Open-Meteo WMO code mapping
if (code === 0) return "sunny"; // Clear
if (code === 1 || code === 2) return "partly_cloudy_day"; // Mainly clear/partly cloudy
if (code === 3) return "cloud"; // Overcast
if (code >= 45 && code <= 48) return "foggy"; // Fog
if (code >= 51 && code <= 67) return "rainy"; // Drizzle
if (code >= 71 && code <= 77) return "weather_snowy"; // Snow
if (code >= 80 && code <= 82) return "rainy"; // Rain showers
if (code >= 95 && code <= 99) return "thunderstorm"; // Thunderstorm
return "cloud";
}
function weatherDescriptionForCode(code) {
if (code === 0) return "Clear sky";
if (code === 1) return "Mainly clear";
if (code === 2) return "Partly cloudy";
if (code === 3) return "Overcast";
if (code === 45 || code === 48) return "Fog";
if (code >= 51 && code <= 67) return "Drizzle";
if (code >= 71 && code <= 77) return "Snow";
if (code >= 80 && code <= 82) return "Rain showers";
if (code >= 95 && code <= 99) return "Thunderstorm";
return "Unknown";
}
}

View file

@ -0,0 +1,637 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell.Wayland
import Quickshell
import Quickshell.Io
import Quickshell.Bluetooth
import qs.Settings
import qs.Components
import qs.Helpers
Item {
property alias panel: wifiPanelModal
function showAt() {
wifiPanelModal.visible = true;
wifiLogic.refreshNetworks();
}
function signalIcon(signal) {
if (signal >= 80) return "network_wifi_4_bar";
if (signal >= 60) return "network_wifi_3_bar";
if (signal >= 40) return "network_wifi_2_bar";
if (signal >= 20) return "network_wifi_1_bar";
return "wifi_0_bar";
}
Process {
id: scanProcess
running: false
command: ["nmcli", "-t", "-f", "SSID,SECURITY,SIGNAL,IN-USE", "device", "wifi", "list"]
onRunningChanged: {
// Removed debug log
}
stdout: StdioCollector {
onStreamFinished: {
var lines = text.split("\n");
var nets = [];
var seen = {};
for (var i = 0; i < lines.length; ++i) {
var line = lines[i].trim();
if (!line) continue;
var parts = line.split(":");
var ssid = parts[0];
var security = parts[1];
var signal = parseInt(parts[2]);
var inUse = parts[3] === "*";
if (ssid && !seen[ssid]) {
nets.push({ ssid: ssid, security: security, signal: signal, connected: inUse });
seen[ssid] = true;
}
}
wifiLogic.networks = nets;
}
}
}
QtObject {
id: wifiLogic
property var networks: []
property var anchorItem: null
property real anchorX
property real anchorY
property string passwordPromptSsid: ""
property string passwordInput: ""
property bool showPasswordPrompt: false
property string connectingSsid: ""
property string connectStatus: ""
property string connectStatusSsid: ""
property string connectError: ""
property string connectSecurity: ""
property var pendingConnect: null // store connect params for after delete
property string detectedInterface: ""
property var connectionsToDelete: []
function profileNameForSsid(ssid) {
return "quickshell-" + ssid.replace(/[^a-zA-Z0-9]/g, "_");
}
function disconnectAndDeleteNetwork(ssid) {
var profileName = wifiLogic.profileNameForSsid(ssid);
console.log('WifiPanel: disconnectAndDeleteNetwork called for SSID', ssid, 'profile', profileName);
disconnectProfileProcess.connectionName = profileName;
disconnectProfileProcess.running = true;
}
function refreshNetworks() {
scanProcess.running = true;
}
function showAt() {
wifiPanelModal.visible = true;
wifiLogic.refreshNetworks();
}
function connectNetwork(ssid, security) {
wifiLogic.pendingConnect = {ssid: ssid, security: security, password: ""};
listConnectionsProcess.running = true;
}
function submitPassword() {
wifiLogic.pendingConnect = {ssid: wifiLogic.passwordPromptSsid, security: wifiLogic.connectSecurity, password: wifiLogic.passwordInput};
listConnectionsProcess.running = true;
}
function doConnect() {
var params = wifiLogic.pendingConnect;
wifiLogic.connectingSsid = params.ssid;
if (params.security && params.security !== "--") {
getInterfaceProcess.running = true;
} else {
connectProcess.security = params.security;
connectProcess.ssid = params.ssid;
connectProcess.password = params.password;
connectProcess.running = true;
wifiLogic.pendingConnect = null;
}
}
}
// Disconnect, then delete the profile. This chain is triggered by clicking the row.
Process {
id: disconnectProfileProcess
property string connectionName: ""
running: false
command: ["nmcli", "connection", "down", "id", connectionName]
onRunningChanged: {
if (!running) {
// After disconnect, delete the profile
deleteProfileProcess.connectionName = connectionName;
deleteProfileProcess.running = true;
}
}
}
Process {
id: deleteProfileProcess
property string connectionName: ""
running: false
command: ["nmcli", "connection", "delete", "id", connectionName]
onRunningChanged: {
if (!running) {
wifiLogic.refreshNetworks();
}
}
}
Process {
id: listConnectionsProcess
running: false
command: ["nmcli", "-t", "-f", "NAME,SSID", "connection", "show"]
stdout: StdioCollector {
onStreamFinished: {
var params = wifiLogic.pendingConnect;
var lines = text.split("\n");
var toDelete = [];
for (var i = 0; i < lines.length; ++i) {
var parts = lines[i].split(":");
if (parts.length === 2 && parts[1] === params.ssid) {
toDelete.push(parts[0]);
}
}
wifiLogic.connectionsToDelete = toDelete;
if (toDelete.length > 0) {
deleteProfileProcess.connectionName = toDelete[0];
deleteProfileProcess.running = true;
} else {
wifiLogic.doConnect();
}
}
}
}
Process {
id: connectProcess
property string ssid: ""
property string password: ""
property string security: ""
running: false
command: {
if (password) {
return ["nmcli", "device", "wifi", "connect", ssid, "password", password]
} else {
return ["nmcli", "device", "wifi", "connect", ssid]
}
}
stdout: StdioCollector {
onStreamFinished: {
wifiLogic.connectingSsid = "";
wifiLogic.showPasswordPrompt = false;
wifiLogic.passwordPromptSsid = "";
wifiLogic.passwordInput = "";
wifiLogic.connectStatus = "success";
wifiLogic.connectStatusSsid = connectProcess.ssid;
wifiLogic.connectError = "";
wifiLogic.refreshNetworks();
}
}
stderr: StdioCollector {
onStreamFinished: {
wifiLogic.connectingSsid = "";
wifiLogic.showPasswordPrompt = false;
wifiLogic.passwordPromptSsid = "";
wifiLogic.passwordInput = "";
wifiLogic.connectStatus = "error";
wifiLogic.connectStatusSsid = connectProcess.ssid;
wifiLogic.connectError = text;
}
}
}
Process {
id: getInterfaceProcess
running: false
command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"]
stdout: StdioCollector {
onStreamFinished: {
var lines = text.split("\n");
for (var i = 0; i < lines.length; ++i) {
var parts = lines[i].split(":");
if (parts[1] === "wifi" && parts[2] !== "unavailable") {
wifiLogic.detectedInterface = parts[0];
break;
}
}
if (wifiLogic.detectedInterface) {
var params = wifiLogic.pendingConnect;
addConnectionProcess.ifname = wifiLogic.detectedInterface;
addConnectionProcess.ssid = params.ssid;
addConnectionProcess.password = params.password;
addConnectionProcess.profileName = wifiLogic.profileNameForSsid(params.ssid);
addConnectionProcess.security = params.security;
addConnectionProcess.running = true;
} else {
wifiLogic.connectStatus = "error";
wifiLogic.connectStatusSsid = wifiLogic.pendingConnect.ssid;
wifiLogic.connectError = "No Wi-Fi interface found.";
wifiLogic.connectingSsid = "";
wifiLogic.pendingConnect = null;
}
}
}
}
Process {
id: addConnectionProcess
property string ifname: ""
property string ssid: ""
property string password: ""
property string profileName: ""
property string security: ""
running: false
command: {
var cmd = ["nmcli", "connection", "add", "type", "wifi", "ifname", ifname, "con-name", profileName, "ssid", ssid];
if (security && security !== "--") {
cmd.push("wifi-sec.key-mgmt");
cmd.push("wpa-psk");
cmd.push("wifi-sec.psk");
cmd.push(password);
}
return cmd;
}
stdout: StdioCollector {
onStreamFinished: {
upConnectionProcess.profileName = addConnectionProcess.profileName;
upConnectionProcess.running = true;
}
}
stderr: StdioCollector {
onStreamFinished: {
upConnectionProcess.profileName = addConnectionProcess.profileName;
upConnectionProcess.running = true;
}
}
}
Process {
id: upConnectionProcess
property string profileName: ""
running: false
command: ["nmcli", "connection", "up", "id", profileName]
stdout: StdioCollector {
onStreamFinished: {
wifiLogic.connectingSsid = "";
wifiLogic.showPasswordPrompt = false;
wifiLogic.passwordPromptSsid = "";
wifiLogic.passwordInput = "";
wifiLogic.connectStatus = "success";
wifiLogic.connectStatusSsid = wifiLogic.pendingConnect ? wifiLogic.pendingConnect.ssid : "";
wifiLogic.connectError = "";
wifiLogic.refreshNetworks();
wifiLogic.pendingConnect = null;
}
}
stderr: StdioCollector {
onStreamFinished: {
wifiLogic.connectingSsid = "";
wifiLogic.showPasswordPrompt = false;
wifiLogic.passwordPromptSsid = "";
wifiLogic.passwordInput = "";
wifiLogic.connectStatus = "error";
wifiLogic.connectStatusSsid = wifiLogic.pendingConnect ? wifiLogic.pendingConnect.ssid : "";
wifiLogic.connectError = text;
wifiLogic.pendingConnect = null;
}
}
}
// Wifi button (no background card)
Rectangle {
id: wifiButton
width: 36; height: 36
radius: 18
border.color: Theme.accentPrimary
border.width: 1
color: wifiButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
Text {
anchors.centerIn: parent
text: "wifi"
font.family: "Material Symbols Outlined"
font.pixelSize: 22
color: wifiButtonArea.containsMouse
? Theme.backgroundPrimary
: Theme.accentPrimary
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
MouseArea {
id: wifiButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: wifiLogic.showAt()
}
}
PanelWindow {
id: wifiPanelModal
implicitWidth: 480
implicitHeight: 720
visible: false
color: "transparent"
anchors.top: true
anchors.right: true
margins.right: 0
margins.top: -24
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
Component.onCompleted: {
wifiLogic.refreshNetworks()
}
Rectangle {
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 24
ColumnLayout {
anchors.fill: parent
anchors.margins: 32
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: 20
Layout.preferredHeight: 48
Layout.leftMargin: 16
Layout.rightMargin: 16
Text {
text: "wifi"
font.family: "Material Symbols Outlined"
font.pixelSize: 32
color: Theme.accentPrimary
}
Text {
text: "Wi-Fi"
font.pixelSize: 26
font.bold: true
color: Theme.textPrimary
Layout.fillWidth: true
}
Rectangle {
width: 36; height: 36; radius: 18
color: closeButtonArea.containsMouse ? Theme.accentPrimary : "transparent"
border.color: Theme.accentPrimary
border.width: 1
Text {
anchors.centerIn: parent
text: "close"
font.family: closeButtonArea.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 20
color: closeButtonArea.containsMouse ? Theme.onAccent : Theme.accentPrimary
}
MouseArea {
id: closeButtonArea
anchors.fill: parent
hoverEnabled: true
onClicked: wifiPanelModal.visible = false
cursorShape: Qt.PointingHandCursor
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.outline
opacity: 0.12
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 520
Layout.alignment: Qt.AlignHCenter
Layout.margins: 0
color: Theme.surfaceVariant
radius: 18
border.color: Theme.outline
border.width: 1
Rectangle {
id: bg
anchors.fill: parent
color: Theme.backgroundPrimary
radius: 12
border.width: 1
border.color: Theme.surfaceVariant
z: 0
}
Rectangle {
id: header
}
Rectangle {
id: listContainer
anchors.top: header.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 24
color: "transparent"
clip: true
ListView {
id: networkListView
anchors.fill: parent
spacing: 4
boundsBehavior: Flickable.StopAtBounds
model: wifiLogic.networks
delegate: Item {
id: networkEntry
width: parent.width
height: modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt ? 102 : 42
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 42
radius: 8
color: modelData.connected ? Qt.rgba(Theme.accentPrimary.r, Theme.accentPrimary.g, Theme.accentPrimary.b, 0.18) : (networkMouseArea.containsMouse || (modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt) ? Theme.highlight : "transparent")
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
Text {
text: signalIcon(modelData.signal)
font.family: "Material Symbols Outlined"
font.pixelSize: 20
color: networkMouseArea.containsMouse || (modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt) ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
text: modelData.ssid || "Unknown Network"
color: networkMouseArea.containsMouse || (modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt) ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textPrimary)
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
Item {
width: 22; height: 22
visible: wifiLogic.connectStatusSsid === modelData.ssid && wifiLogic.connectStatus !== ""
RowLayout {
anchors.fill: parent
spacing: 2
Text {
visible: wifiLogic.connectStatus === "success"
text: "check_circle"
font.family: "Material Symbols Outlined"
font.pixelSize: 18
color: "#43a047"
verticalAlignment: Text.AlignVCenter
}
Text {
visible: wifiLogic.connectStatus === "error"
text: "error"
font.family: "Material Symbols Outlined"
font.pixelSize: 18
color: Theme.error
verticalAlignment: Text.AlignVCenter
}
}
}
Spinner {
visible: wifiLogic.connectingSsid === modelData.ssid
running: wifiLogic.connectingSsid === modelData.ssid
color: Theme.textPrimary
size: 18
}
}
Text {
text: modelData.security && modelData.security !== "--" ? modelData.security : "Open"
color: networkMouseArea.containsMouse || (modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt) ? Theme.backgroundPrimary : (modelData.connected ? Theme.accentPrimary : Theme.textSecondary)
font.pixelSize: 11
elide: Text.ElideRight
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
Text {
visible: wifiLogic.connectStatusSsid === modelData.ssid && wifiLogic.connectStatus === "error" && wifiLogic.connectError.length > 0
text: wifiLogic.connectError
color: Theme.error
font.pixelSize: 11
elide: Text.ElideRight
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
}
Text {
visible: modelData.connected
text: "connected"
color: networkMouseArea.containsMouse || (modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt) ? Theme.backgroundPrimary : Theme.accentPrimary
font.pixelSize: 11
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignVCenter
}
}
MouseArea {
id: networkMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
wifiLogic.disconnectAndDeleteNetwork(modelData.ssid);
} else if (modelData.security && modelData.security !== "--") {
wifiLogic.passwordPromptSsid = modelData.ssid;
wifiLogic.passwordInput = "";
wifiLogic.showPasswordPrompt = true;
wifiLogic.connectStatus = "";
wifiLogic.connectStatusSsid = "";
wifiLogic.connectError = "";
wifiLogic.connectSecurity = modelData.security;
} else {
wifiLogic.connectNetwork(modelData.ssid, modelData.security)
}
}
}
}
Rectangle {
visible: modelData.ssid === wifiLogic.passwordPromptSsid && wifiLogic.showPasswordPrompt
Layout.fillWidth: true
Layout.preferredHeight: 60
radius: 8
color: "transparent"
anchors.leftMargin: 32
anchors.rightMargin: 32
z: 2
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 10
Item {
Layout.fillWidth: true
Layout.preferredHeight: 36
Rectangle {
anchors.fill: parent
radius: 8
color: "transparent"
border.color: passwordField.activeFocus ? Theme.accentPrimary : Theme.outline
border.width: 1
TextInput {
id: passwordField
anchors.fill: parent
anchors.margins: 12
text: wifiLogic.passwordInput
font.pixelSize: 13
color: Theme.textPrimary
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
echoMode: TextInput.Password
onTextChanged: wifiLogic.passwordInput = text
onAccepted: wifiLogic.submitPassword()
MouseArea {
id: passwordMouseArea
anchors.fill: parent
onClicked: passwordField.forceActiveFocus()
}
}
}
}
Rectangle {
width: 80
height: 36
radius: 18
color: Theme.accentPrimary
border.color: Theme.accentPrimary
border.width: 0
opacity: 1.0
Behavior on color { ColorAnimation { duration: 100 } }
MouseArea {
anchors.fill: parent
onClicked: wifiLogic.submitPassword()
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.color = Qt.darker(Theme.accentPrimary, 1.1)
onExited: parent.color = Theme.accentPrimary
}
Text {
anchors.centerIn: parent
text: "Connect"
color: Theme.backgroundPrimary
font.pixelSize: 14
font.bold: true
}
}
}
}
}
}
}
}
}
}
}
}
}

57
shell.qml Normal file
View file

@ -0,0 +1,57 @@
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import Quickshell.Services.Notifications
import QtQuick
import QtCore
import qs.Bar
import qs.Bar.Modules
import qs.Widgets
import qs.Settings
import qs.Helpers
Scope {
id: root
property alias appLauncherPanel: appLauncherPanel
Component.onCompleted: {
Quickshell.shell = root
}
Bar {
id: bar
shell: root
}
Applauncher {
id: appLauncherPanel
visible: false
}
NotificationServer {
id: notificationServer
onNotification: function(notification) {
notification.tracked = true;
notificationPopup.addNotification(notification);
}
}
NotificationPopup {
id: notificationPopup
barVisible: bar.visible
}
property var defaultAudioSink: Pipewire.defaultAudioSink
property int volume: defaultAudioSink && defaultAudioSink.audio
? Math.round(defaultAudioSink.audio.volume * 100)
: 0
PwObjectTracker {
objects: [Pipewire.defaultAudioSink]
}
IPCHandlers {
appLauncherPanel: appLauncherPanel
}
}