Initial commit
This commit is contained in:
commit
a8c2f88654
53 changed files with 9269 additions and 0 deletions
162
Bar/Bar.qml
Normal file
162
Bar/Bar.qml
Normal 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
|
||||
}
|
||||
67
Bar/Modules/ActiveWindow.qml
Normal file
67
Bar/Modules/ActiveWindow.qml
Normal 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
364
Bar/Modules/Applauncher.qml
Normal 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
221
Bar/Modules/Bluetooth.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Bar/Modules/Brightness.qml
Normal file
50
Bar/Modules/Brightness.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Bar/Modules/ClockWidget.qml
Normal file
18
Bar/Modules/ClockWidget.qml
Normal 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
|
||||
}
|
||||
}
|
||||
137
Bar/Modules/CustomTrayMenu.qml
Normal file
137
Bar/Modules/CustomTrayMenu.qml
Normal 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
128
Bar/Modules/SystemTray.qml
Normal 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
16
Bar/Modules/Time.qml
Normal 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
44
Bar/Modules/Volume.qml
Normal 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
430
Bar/Modules/Wifi.qml
Normal 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
229
Bar/Modules/Workspace.qml
Normal 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
68
Components/Cava.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
Components/CircularProgressBar.qml
Normal file
134
Components/CircularProgressBar.qml
Normal 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()
|
||||
}
|
||||
47
Components/CircularSpectrum.qml
Normal file
47
Components/CircularSpectrum.qml
Normal 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
86
Components/Corners.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Components/PillIndicator.qml
Normal file
168
Components/PillIndicator.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Helpers/BluetoothManager.qml
Normal file
70
Helpers/BluetoothManager.qml
Normal 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
678
Helpers/Fuzzysort.js
Normal 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
7
Helpers/Globals.qml
Normal 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
21
Helpers/IPCHandlers.qml
Normal 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
68
Helpers/Processes.qml
Normal 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
53
Helpers/Spinner.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Helpers/WallpaperManager.qml
Normal file
58
Helpers/WallpaperManager.qml
Normal 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
58
Helpers/Weather.js
Normal 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 + "¤t_weather=true¤t=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
21
LICENSE
Normal 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
147
README.md
Normal 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>
|
||||
|
||||

|
||||
</br>
|
||||
|
||||

|
||||
</br>
|
||||
|
||||

|
||||
|
||||
</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
87
Services/Niri.qml
Normal 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
48
Settings/Settings.qml
Normal 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
40
Settings/Theme.qml
Normal 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
31
Widgets/Background.qml
Normal 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
780
Widgets/LockScreen.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
Widgets/NotificationManager.qml
Normal file
178
Widgets/NotificationManager.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
266
Widgets/NotificationPopup.qml
Normal file
266
Widgets/NotificationPopup.qml
Normal 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
37
Widgets/Overview.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
66
Widgets/Sidebar/Button.qml
Normal file
66
Widgets/Sidebar/Button.qml
Normal 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
486
Widgets/Sidebar/Config.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Widgets/Sidebar/Config/CollapsibleCategory.qml
Normal file
54
Widgets/Sidebar/Config/CollapsibleCategory.qml
Normal 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
|
||||
}
|
||||
}
|
||||
171
Widgets/Sidebar/Config/ProfileSettings.qml
Normal file
171
Widgets/Sidebar/Config/ProfileSettings.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
Widgets/Sidebar/Config/SettingsModal.qml
Normal file
210
Widgets/Sidebar/Config/SettingsModal.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
71
Widgets/Sidebar/Config/WallpaperSettings.qml
Normal file
71
Widgets/Sidebar/Config/WallpaperSettings.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
Widgets/Sidebar/Config/WeatherSettings.qml
Normal file
153
Widgets/Sidebar/Config/WeatherSettings.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
343
Widgets/Sidebar/Panel/BluetoothPanel.qml
Normal file
343
Widgets/Sidebar/Panel/BluetoothPanel.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
410
Widgets/Sidebar/Panel/Music.qml
Normal file
410
Widgets/Sidebar/Panel/Music.qml
Normal 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
|
||||
}
|
||||
}
|
||||
394
Widgets/Sidebar/Panel/PanelPopup.qml
Normal file
394
Widgets/Sidebar/Panel/PanelPopup.qml
Normal 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
|
||||
}
|
||||
}
|
||||
127
Widgets/Sidebar/Panel/PowerProfile.qml
Normal file
127
Widgets/Sidebar/Panel/PowerProfile.qml
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
196
Widgets/Sidebar/Panel/QuickAccess.qml
Normal file
196
Widgets/Sidebar/Panel/QuickAccess.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
372
Widgets/Sidebar/Panel/System.qml
Normal file
372
Widgets/Sidebar/Panel/System.qml
Normal 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
|
||||
}
|
||||
}
|
||||
158
Widgets/Sidebar/Panel/SystemMonitor.qml
Normal file
158
Widgets/Sidebar/Panel/SystemMonitor.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Widgets/Sidebar/Panel/WallpaperPanel.qml
Normal file
150
Widgets/Sidebar/Panel/WallpaperPanel.qml
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
Widgets/Sidebar/Panel/Weather.qml
Normal file
197
Widgets/Sidebar/Panel/Weather.qml
Normal 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";
|
||||
}
|
||||
}
|
||||
637
Widgets/Sidebar/Panel/WifiPanel.qml
Normal file
637
Widgets/Sidebar/Panel/WifiPanel.qml
Normal 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
57
shell.qml
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue