Merge branch 'main' into miniplayer-eyecandy

This commit is contained in:
Lemmy 2025-08-20 20:40:17 -04:00 committed by GitHub
commit e51c5cf4bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 3481 additions and 3542 deletions

View file

@ -12,388 +12,285 @@ import qs.Widgets
import "../../Helpers/FuzzySort.js" as Fuzzysort
NLoader {
id: appLauncher
isLoaded: false
// Clipboard state is persisted in Services/ClipboardService.qml
content: Component {
NPanel {
id: appLauncherPanel
NPanel {
id: root
panelWidth: Math.min(700 * scaling, screen?.width * 0.75)
panelHeight: Math.min(550 * scaling, screen?.height * 0.8)
panelAnchorCentered: true
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
// Import modular components
Calculator {
id: calculator
}
// No local timer/processes; use persistent Clipboard service
ClipboardHistory {
id: clipboardHistory
}
// Removed local clipboard processes; handled by Clipboard service
// Properties
property var desktopEntries: DesktopEntries.applications.values
property string searchText: ""
property int selectedIndex: 0
// Copy helpers via simple exec; avoid keeping processes alive locally
function copyImageBase64(mime, base64) {
Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`])
// Refresh clipboard when user starts typing clipboard commands
onSearchTextChanged: {
if (searchText.startsWith(">clip")) {
clipboardHistory.refresh()
}
}
// Main filtering logic
property var filteredEntries: {
Logger.log("AppLauncher", "Total desktop entries:", desktopEntries ? desktopEntries.length : 0)
if (!desktopEntries || desktopEntries.length === 0) {
Logger.log("AppLauncher", "No desktop entries available")
return []
}
// Filter out entries that shouldn't be displayed
var visibleEntries = desktopEntries.filter(entry => {
if (!entry || entry.noDisplay) {
return false
}
return true
})
Logger.log("AppLauncher", "Visible entries:", visibleEntries.length)
var query = searchText ? searchText.toLowerCase() : ""
var results = []
// Handle special commands
if (query === ">") {
results.push({
"isCommand": true,
"name": ">calc",
"content": "Calculator - evaluate mathematical expressions",
"icon": "calculate",
"execute": executeCalcCommand
})
results.push({
"isCommand": true,
"name": ">clip",
"content": "Clipboard history - browse and restore clipboard items",
"icon": "content_paste",
"execute": executeClipCommand
})
return results
}
// Handle clipboard history
if (query.startsWith(">clip")) {
return clipboardHistory.processQuery(query)
}
// Handle calculator
if (query.startsWith(">calc")) {
return calculator.processQuery(query, "calc")
}
// Handle direct math expressions after ">"
if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) {
const mathResults = calculator.processQuery(query, "direct")
if (mathResults.length > 0) {
return mathResults
}
// If math evaluation fails, fall through to regular search
}
function copyText(text) {
Quickshell.execDetached(["sh", "-lc", `printf %s ${text} | wl-copy -t text/plain;charset=utf-8`])
// Regular app search
if (!query) {
results = results.concat(visibleEntries.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase())
}))
} else {
var fuzzyResults = Fuzzysort.go(query, visibleEntries, {
"keys": ["name", "comment", "genericName"]
})
results = results.concat(fuzzyResults.map(function (r) {
return r.obj
}))
}
Logger.log("AppLauncher", "Filtered entries:", results.length)
return results
}
// Command execution functions
function executeCalcCommand() {
searchText = ">calc "
searchInput.cursorPosition = searchText.length
}
function executeClipCommand() {
searchText = ">clip "
searchInput.cursorPosition = searchText.length
}
// Navigation functions
function selectNext() {
if (filteredEntries.length > 0) {
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
}
}
function selectPrev() {
if (filteredEntries.length > 0) {
selectedIndex = Math.max(selectedIndex - 1, 0)
}
}
function activateSelected() {
if (filteredEntries.length === 0)
return
var modelData = filteredEntries[selectedIndex]
if (modelData && modelData.execute) {
if (modelData.isCommand) {
modelData.execute()
return
} else {
modelData.execute()
}
root.close()
}
}
function updateClipboardHistory() {
ClipboardService.refresh()
Component.onCompleted: {
Logger.log("AppLauncher", "Component completed")
Logger.log("AppLauncher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
if (typeof DesktopEntries !== 'undefined') {
Logger.log("AppLauncher", "DesktopEntries.entries:",
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
}
// Start clipboard refresh immediately on open
clipboardHistory.refresh()
}
// Main content container
panelContent: Rectangle {
// Subtle gradient background
gradient: Gradient {
GradientStop {
position: 0.0
color: Qt.lighter(Color.mSurface, 1.02)
}
function selectNext() {
if (filteredEntries.length > 0) {
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
}
GradientStop {
position: 1.0
color: Qt.darker(Color.mSurface, 1.1)
}
}
function selectPrev() {
if (filteredEntries.length > 0) {
selectedIndex = Math.max(selectedIndex - 1, 0)
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
function activateSelected() {
if (filteredEntries.length === 0)
return
// Search bar
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Layout.bottomMargin: Style.marginM * scaling
radius: Style.radiusM * scaling
color: Color.mSurface
border.color: searchInput.activeFocus ? Color.mPrimary : Color.mOutline
border.width: Math.max(1, searchInput.activeFocus ? Style.borderM * scaling : Style.borderS * scaling)
var modelData = filteredEntries[selectedIndex]
if (modelData && modelData.execute) {
if (modelData.isCommand) {
modelData.execute()
return
} else {
modelData.execute()
Item {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
NIcon {
id: searchIcon
text: "search"
font.pointSize: Style.fontSizeXL * scaling
color: searchInput.activeFocus ? Color.mPrimary : Color.mOnSurface
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
appLauncherPanel.hide()
}
}
property var desktopEntries: DesktopEntries.applications.values
property string searchText: ""
property int selectedIndex: 0
// Refresh clipboard when user starts typing clipboard commands
onSearchTextChanged: {
if (searchText.startsWith(">clip")) {
ClipboardService.refresh()
}
}
property var filteredEntries: {
Logger.log("AppLauncher", "Total desktop entries:", desktopEntries ? desktopEntries.length : 0)
if (!desktopEntries || desktopEntries.length === 0) {
Logger.log("AppLauncher", "No desktop entries available")
return []
}
// Filter out entries that shouldn't be displayed
var visibleEntries = desktopEntries.filter(entry => {
if (!entry || entry.noDisplay) {
return false
}
return true
})
Logger.log("AppLauncher", "Visible entries:", visibleEntries.length)
var query = searchText ? searchText.toLowerCase() : ""
var results = []
// Handle special commands
if (query === ">") {
results.push({
"isCommand": true,
"name": ">calc",
"content": "Calculator - evaluate mathematical expressions",
"icon": "tag",
"execute": function () {
searchText = ">calc "
searchInput.cursorPosition = searchText.length
}
})
results.push({
"isCommand": true,
"name": ">clip",
"content": "Clipboard history - browse and restore clipboard items",
"icon": "content_paste",
"execute": function () {
searchText = ">clip "
searchInput.cursorPosition = searchText.length
}
})
return results
}
// Handle clipboard history
if (query.startsWith(">clip")) {
const searchTerm = query.slice(5).trim()
ClipboardService.history.forEach(function (clip, index) {
let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip
if (!searchTerm || searchContent.toLowerCase().includes(searchTerm)) {
let entry
if (clip.type === 'image') {
entry = {
"isClipboard": true,
"name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(),
"content": "Image: " + clip.mimeType,
"icon": "image",
"type": 'image',
"data": clip.data,
"execute": function () {
const base64Data = clip.data.split(',')[1]
copyImageBase64(clip.mimeType, base64Data)
Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType])
}
}
} else {
const textContent = clip.content || clip
let displayContent = textContent
let previewContent = ""
displayContent = displayContent.replace(/\s+/g, ' ').trim()
if (displayContent.length > 50) {
previewContent = displayContent
displayContent = displayContent.split('\n')[0].substring(0, 50) + "..."
}
entry = {
"isClipboard": true,
"name": displayContent,
"content": previewContent || textContent,
"icon": "content_paste",
"execute": function () {
Quickshell.clipboardText = String(textContent)
copyText(String(textContent))
var preview = (textContent.length > 50) ? textContent.slice(0, 50) + "…" : textContent
Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview])
}
}
}
results.push(entry)
TextField {
id: searchInput
placeholderText: searchText === "" ? "Search applications... (use > to view commands)" : "Search applications..."
color: Color.mOnSurface
placeholderTextColor: Color.mOnSurfaceVariant
background: null
font.pointSize: Style.fontSizeL * scaling
anchors.left: searchIcon.right
anchors.leftMargin: Style.marginS * scaling
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onTextChanged: {
searchText = text
// Defer selectedIndex reset to avoid binding loops
Qt.callLater(() => selectedIndex = 0)
}
})
if (results.length === 0) {
results.push({
"isClipboard": true,
"name": "No clipboard history",
"content": "No matching clipboard entries found",
"icon": "content_paste_off"
})
}
return results
}
// Handle direct math expressions after ">"
if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) {
var mathExpr = query.slice(1).trim()
// Check if it looks like a math expression (contains numbers and math operators)
if (mathExpr && /[0-9+\-*/().]/.test(mathExpr)) {
try {
var sanitizedExpr = mathExpr.replace(/[^0-9+\-*/().\s]/g, '')
var result = eval(sanitizedExpr)
if (isFinite(result) && !isNaN(result)) {
var displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, '')
results.push({
"isCalculator": true,
"name": `${mathExpr} = ${displayResult}`,
"result": result,
"expr": mathExpr,
"icon": "tag",
"execute": function () {
Quickshell.clipboardText = displayResult
copyText(displayResult)
Quickshell.execDetached(
["notify-send", "Calculator", `${mathExpr} = ${displayResult} (copied to clipboard)`])
}
})
return results
}
} catch (error) {
// If math evaluation fails, fall through to regular search
}
}
}
// Handle calculator
if (query.startsWith(">calc")) {
var expr = searchText.slice(5).trim()
if (expr && expr !== "") {
try {
// Simple evaluation - only allow basic math operations
var sanitizedExpr = expr.replace(/[^0-9+\-*/().\s]/g, '')
var result = eval(sanitizedExpr)
if (isFinite(result) && !isNaN(result)) {
var displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/,
'')
results.push({
"isCalculator": true,
"name": `${expr} = ${displayResult}`,
"result": result,
"expr": expr,
"icon": "tag",
"execute": function () {
Quickshell.clipboardText = displayResult
copyText(displayResult)
Quickshell.execDetached(
["notify-send", "Calculator", `${expr} = ${displayResult} (copied to clipboard)`])
}
})
} else {
results.push({
"isCalculator": true,
"name": "Invalid expression",
"content": "Please enter a valid mathematical expression",
"icon": "tag",
"execute": function () {}
})
}
} catch (error) {
results.push({
"isCalculator": true,
"name": "Invalid expression",
"content": "Please enter a valid mathematical expression",
"icon": "tag",
"execute": function () {}
selectedTextColor: Color.mOnSurface
selectionColor: Color.mPrimary
padding: 0
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
font.bold: true
Component.onCompleted: {
// Focus the search bar by default
Qt.callLater(() => {
searchInput.forceActiveFocus()
})
}
} else {
// Show placeholder when just ">calc" is entered
results.push({
"isCalculator": true,
"name": "Calculator",
"content": "Enter a mathematical expression (e.g., 5+5, 2*3, 10/2)",
"icon": "tag",
"execute": function () {}
})
Keys.onDownPressed: selectNext()
Keys.onUpPressed: selectPrev()
Keys.onEnterPressed: activateSelected()
Keys.onReturnPressed: activateSelected()
Keys.onEscapePressed: root.close()
}
return results
}
// Regular app search
if (!query) {
results = results.concat(visibleEntries.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase())
}))
} else {
var fuzzyResults = Fuzzysort.go(query, visibleEntries, {
"keys": ["name", "comment", "genericName"]
})
results = results.concat(fuzzyResults.map(function (r) {
return r.obj
}))
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Logger.log("AppLauncher", "Filtered entries:", results.length)
return results
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
}
Component.onCompleted: {
Logger.log("AppLauncher", "Component completed")
Logger.log("AppLauncher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
if (typeof DesktopEntries !== 'undefined') {
Logger.log("AppLauncher", "DesktopEntries.entries:",
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
}
// Start clipboard refresh immediately on open
updateClipboardHistory()
}
// Applications list
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
// Main content container
Rectangle {
anchors.centerIn: parent
width: Math.min(700 * scaling, parent.width * 0.75)
height: Math.min(550 * scaling, parent.height * 0.8)
radius: Style.radiusL * scaling
color: Color.mSurface
border.color: Color.mOutline
border.width: Style.borderS * scaling
// Subtle gradient background
gradient: Gradient {
GradientStop {
position: 0.0
color: Qt.lighter(Color.mSurface, 1.02)
}
GradientStop {
position: 1.0
color: Qt.darker(Color.mSurface, 1.1)
}
}
ColumnLayout {
ListView {
id: appsList
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
spacing: Style.marginXXS * scaling
model: filteredEntries
currentIndex: selectedIndex
// Search bar
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Layout.bottomMargin: Style.marginM * scaling
delegate: Rectangle {
width: appsList.width - Style.marginS * scaling
height: 65 * scaling
radius: Style.radiusM * scaling
color: Color.mSurface
border.color: searchInput.activeFocus ? Color.mPrimary : Color.mOutline
border.width: Math.max(1, searchInput.activeFocus ? Style.borderM * scaling : Style.borderS * scaling)
property bool isSelected: index === selectedIndex
color: (appCardArea.containsMouse || isSelected) ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurface
border.color: (appCardArea.containsMouse || isSelected) ? Color.mPrimary : Color.transparent
border.width: Math.max(1, (appCardArea.containsMouse || isSelected) ? Style.borderM * scaling : 0)
Item {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
NIcon {
id: searchIcon
text: "search"
font.pointSize: Style.fontSizeXL * scaling
color: searchInput.activeFocus ? Color.mPrimary : Color.mOnSurface
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
TextField {
id: searchInput
placeholderText: searchText === "" ? "Search applications... (use > to view commands)" : "Search applications..."
color: Color.mOnSurface
placeholderTextColor: Color.mOnSurfaceVariant
background: null
font.pointSize: Style.fontSizeL * scaling
anchors.left: searchIcon.right
anchors.leftMargin: Style.marginS * scaling
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onTextChanged: {
searchText = text
selectedIndex = 0 // Reset selection when search changes
}
selectedTextColor: Color.mOnSurface
selectionColor: Color.mPrimary
padding: 0
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
font.bold: true
Component.onCompleted: {
contentItem.cursorColor = Color.mOnSurface
contentItem.verticalAlignment = TextInput.AlignVCenter
// Focus the search bar by default
Qt.callLater(() => {
searchInput.forceActiveFocus()
})
}
onActiveFocusChanged: contentItem.cursorColor = Color.mOnSurface
Keys.onDownPressed: selectNext()
Keys.onUpPressed: selectPrev()
Keys.onEnterPressed: activateSelected()
Keys.onReturnPressed: activateSelected()
Keys.onEscapePressed: appLauncherPanel.hide()
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
@ -408,181 +305,136 @@ NLoader {
duration: Style.animationFast
}
}
}
// Applications list
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ListView {
id: appsList
RowLayout {
anchors.fill: parent
spacing: Style.marginXXS * scaling
model: filteredEntries
currentIndex: selectedIndex
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
delegate: Rectangle {
width: appsList.width - Style.marginS * scaling
height: 65 * scaling
radius: Style.radiusM * scaling
property bool isSelected: index === selectedIndex
color: (appCardArea.containsMouse || isSelected) ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurface
border.color: (appCardArea.containsMouse || isSelected) ? Color.mPrimary : Color.transparent
border.width: Math.max(1, (appCardArea.containsMouse || isSelected) ? Style.borderM * scaling : 0)
// App icon with background
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
radius: Style.radiusS * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|| (iconImg.status === Image.Ready && iconImg.source !== ""
&& iconImg.status !== Image.Error && iconImg.source !== "")
visible: !searchText.startsWith(">calc") // Hide icon when in calculator mode
// Clipboard image display
Image {
id: clipboardImage
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
visible: modelData.type === 'image'
source: modelData.data || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
}
IconImage {
id: iconImg
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
asynchronous: true
source: modelData.isCalculator ? "" : modelData.isClipboard ? "" : modelData.isCommand ? modelData.icon : (modelData.icon ? Quickshell.iconPath(modelData.icon, "application-x-executable") : "")
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand || parent.iconLoaded)
&& modelData.type !== 'image'
}
// Fallback icon container
Rectangle {
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
radius: Style.radiusXS * scaling
color: Color.mPrimary
opacity: Style.opacityMedium
visible: !parent.iconLoaded
}
NText {
anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard
|| modelData.isCommand)
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Font.Bold
color: Color.mPrimary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
// App info
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS * scaling
NText {
text: modelData.name || "Unknown"
font.pointSize: Style.fontSizeL * scaling
font.weight: Font.Bold
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
// App icon with background
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
radius: Style.radiusS * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|| (iconImg.status === Image.Ready && iconImg.source !== ""
&& iconImg.status !== Image.Error && iconImg.source !== "")
visible: !searchText.startsWith(">calc") // Hide icon when in calculator mode
// Clipboard image display
Image {
id: clipboardImage
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
visible: modelData.type === 'image'
source: modelData.data || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
}
IconImage {
id: iconImg
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
asynchronous: true
source: modelData.isCalculator ? "" : modelData.isClipboard ? "" : modelData.isCommand ? modelData.icon : (modelData.icon ? Quickshell.iconPath(modelData.icon, "application-x-executable") : "")
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand
|| parent.iconLoaded) && modelData.type !== 'image'
}
// Fallback icon container
Rectangle {
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
radius: Style.radiusXS * scaling
color: Color.mPrimary
opacity: Style.opacityMedium
visible: !parent.iconLoaded
}
Text {
anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard
|| modelData.isCommand)
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Font.Bold
color: Color.mPrimary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
// App info
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS * scaling
NText {
text: modelData.name || "Unknown"
font.pointSize: Style.fontSizeL * scaling
font.weight: Font.Bold
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
font.pointSize: Style.fontSizeM * scaling
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
visible: text !== ""
}
}
}
MouseArea {
id: appCardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
selectedIndex = index
activateSelected()
}
NText {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
font.pointSize: Style.fontSizeM * scaling
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
visible: text !== ""
}
}
}
}
// No results message
NText {
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: filteredEntries.length === 0
}
MouseArea {
id: appCardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Results count
NText {
text: searchText.startsWith(
">clip") ? `${filteredEntries.length} clipboard item${filteredEntries.length
!== 1 ? 's' : ''}` : searchText.startsWith(
">calc") ? `${filteredEntries.length} result${filteredEntries.length
!== 1 ? 's' : ''}` : `${filteredEntries.length} application${filteredEntries.length
!== 1 ? 's' : ''}`
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: searchText.trim() !== ""
onClicked: {
selectedIndex = index
activateSelected()
}
}
}
}
}
// No results message
NText {
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: filteredEntries.length === 0
}
// Results count
NText {
text: searchText.startsWith(
">clip") ? `${filteredEntries.length} clipboard item${filteredEntries.length
!== 1 ? 's' : ''}` : searchText.startsWith(
">calc") ? `${filteredEntries.length} result${filteredEntries.length
!== 1 ? 's' : ''}` : `${filteredEntries.length} application${filteredEntries.length
!== 1 ? 's' : ''}`
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: searchText.trim() !== ""
}
}
}
}

View file

@ -0,0 +1,151 @@
import QtQuick
import Quickshell
import qs.Commons
import "../../Helpers/AdvancedMath.js" as AdvancedMath
QtObject {
id: calculator
// Function to evaluate mathematical expressions
function evaluate(expression) {
if (!expression || expression.trim() === "") {
return {
"isValid": false,
"result": "",
"displayResult": "",
"error": "Empty expression"
}
}
try {
// Try advanced math first
if (typeof AdvancedMath !== 'undefined') {
const result = AdvancedMath.evaluate(expression.trim())
const displayResult = AdvancedMath.formatResult(result)
return {
"isValid": true,
"result": result,
"displayResult": displayResult,
"expression": expression,
"error": ""
}
} else {
// Fallback to basic evaluation
console.log("AdvancedMath not available, using basic eval")
// Basic preprocessing for common functions
var processed = expression.trim(
).replace(/\bpi\b/gi,
Math.PI).replace(/\be\b/gi,
Math.E).replace(/\bsqrt\s*\(/g,
'Math.sqrt(').replace(/\bsin\s*\(/g,
'Math.sin(').replace(/\bcos\s*\(/g,
'Math.cos(').replace(/\btan\s*\(/g, 'Math.tan(').replace(/\blog\s*\(/g, 'Math.log10(').replace(/\bln\s*\(/g, 'Math.log(').replace(/\bexp\s*\(/g, 'Math.exp(').replace(/\bpow\s*\(/g, 'Math.pow(').replace(/\babs\s*\(/g, 'Math.abs(')
// Sanitize and evaluate
if (!/^[0-9+\-*/().\s\w,]+$/.test(processed)) {
throw new Error("Invalid characters in expression")
}
const result = eval(processed)
if (!isFinite(result) || isNaN(result)) {
throw new Error("Invalid result")
}
const displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, '')
return {
"isValid": true,
"result": result,
"displayResult": displayResult,
"expression": expression,
"error": ""
}
}
} catch (error) {
return {
"isValid": false,
"result": "",
"displayResult": "",
"error": error.message || error.toString()
}
}
}
// Generate calculator entry for display
function createEntry(expression, searchContext = "") {
const evaluation = evaluate(expression)
if (!evaluation.isValid) {
return {
"isCalculator": true,
"name": "Invalid expression",
"content": evaluation.error,
"icon": "error",
"execute": function () {// Do nothing for invalid expressions
}
}
}
const displayName = searchContext
=== "calc" ? `${expression} = ${evaluation.displayResult}` : `${expression} = ${evaluation.displayResult}`
return {
"isCalculator": true,
"name": displayName,
"result": evaluation.result,
"expr": expression,
"displayResult": evaluation.displayResult,
"icon": "calculate",
"execute": function () {
Quickshell.clipboardText = evaluation.displayResult
// Also copy using shell command for better compatibility
Quickshell.execDetached(
["sh", "-lc", `printf %s ${evaluation.displayResult} | wl-copy -t text/plain;charset=utf-8`])
Quickshell.execDetached(
["notify-send", "Calculator", `${expression} = ${evaluation.displayResult} (copied to clipboard)`])
}
}
}
// Create placeholder entry for empty calculator mode
function createPlaceholderEntry() {
return {
"isCalculator": true,
"name": "Calculator",
"content": "Try: sqrt(16), sin(1), cos(0), pi*2, exp(1), pow(2,8), abs(-5)",
"icon": "calculate",
"execute": function () {// Do nothing for placeholder
}
}
}
// Process calculator queries
function processQuery(query, searchContext = "") {
const results = []
if (searchContext === "calc") {
// Handle ">calc" mode
const expr = query.slice(5).trim()
if (expr && expr !== "") {
results.push(createEntry(expr, "calc"))
} else {
results.push(createPlaceholderEntry())
}
} else if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) {
// Handle direct math expressions after ">"
const mathExpr = query.slice(1).trim()
const evaluation = evaluate(mathExpr)
if (evaluation.isValid) {
results.push(createEntry(mathExpr, "direct"))
}
// If invalid, don't add anything - let it fall through to regular search
}
return results
}
}

View file

@ -0,0 +1,157 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services
QtObject {
id: clipboardHistory
// Copy helpers for different content types
function copyImageBase64(mime, base64) {
Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`])
}
function copyText(text) {
// Use printf with proper quoting to handle special characters
Quickshell.execDetached(["sh", "-c", `printf '%s' ${JSON.stringify(text)} | wl-copy -t text/plain`])
}
// Create clipboard entry for display
function createClipboardEntry(clip, index) {
if (clip.type === 'image') {
return {
"isClipboard": true,
"name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(),
"content": "Image: " + clip.mimeType,
"icon": "image",
"type": 'image',
"data": clip.data,
"timestamp": clip.timestamp,
"index": index,
"execute": function () {
const dataParts = clip.data.split(',')
const base64Data = dataParts.length > 1 ? dataParts[1] : clip.data
copyImageBase64(clip.mimeType, base64Data)
Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType])
}
}
} else {
// Handle text content
const textContent = clip.content || clip
let displayContent = textContent
let previewContent = ""
// Normalize whitespace for display
displayContent = displayContent.replace(/\s+/g, ' ').trim()
// Create preview for long content
if (displayContent.length > 50) {
previewContent = displayContent
displayContent = displayContent.split('\n')[0].substring(0, 50) + "..."
}
return {
"isClipboard": true,
"name": displayContent,
"content": previewContent || textContent,
"icon": "content_paste",
"type": 'text',
"timestamp": clip.timestamp,
"index": index,
"textData": textContent,
"execute"// Store the text data for the execute function
: function () {
const text = this.textData || clip.content || clip
Quickshell.clipboardText = String(text)
copyText(String(text))
var preview = (text.length > 50) ? text.slice(0, 50) + "…" : text
Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview])
}
}
}
}
// Create empty state entry
function createEmptyEntry() {
return {
"isClipboard": true,
"name": "No clipboard history",
"content": "No matching clipboard entries found",
"icon": "content_paste_off",
"execute": function () {// Do nothing for empty state
}
}
}
// Process clipboard queries
function processQuery(query) {
const results = []
if (!query.startsWith(">clip")) {
return results
}
// Extract search term after ">clip "
const searchTerm = query.slice(5).trim()
// Note: Clipboard refresh should be handled externally to avoid binding loops
// Process each clipboard item
ClipboardService.history.forEach(function (clip, index) {
let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip
// Apply search filter if provided
if (!searchTerm || searchContent.toLowerCase().includes(searchTerm.toLowerCase())) {
const entry = createClipboardEntry(clip, index)
results.push(entry)
}
})
// Show empty state if no results
if (results.length === 0) {
results.push(createEmptyEntry())
}
return results
}
// Create command entry for clipboard mode (deprecated - use direct creation in parent)
function createCommandEntry() {
return {
"isCommand": true,
"name": ">clip",
"content": "Clipboard history - browse and restore clipboard items",
"icon": "content_paste",
"execute": function () {// This should be handled by the parent component
}
}
}
// Utility function to refresh clipboard
function refresh() {
ClipboardService.refresh()
}
// Get clipboard history count
function getHistoryCount() {
return ClipboardService.history ? ClipboardService.history.length : 0
}
// Get formatted timestamp for display
function formatTimestamp(timestamp) {
return new Date(timestamp).toLocaleTimeString()
}
// Get clipboard entry by index
function getEntryByIndex(index) {
if (ClipboardService.history && index >= 0 && index < ClipboardService.history.length) {
return ClipboardService.history[index]
}
return null
}
// Clear all clipboard history
function clearAll() {
ClipboardService.clearHistory()
}
}

View file

@ -4,49 +4,53 @@ import Quickshell.Wayland
import qs.Commons
import qs.Services
Variants {
model: Quickshell.screens
Loader {
active: !Settings.data.wallpaper.swww.enabled
delegate: PanelWindow {
required property ShellScreen modelData
property string wallpaperSource: WallpaperService.currentWallpaper !== ""
&& !Settings.data.wallpaper.swww.enabled ? WallpaperService.currentWallpaper : ""
sourceComponent: Variants {
model: Quickshell.screens
visible: wallpaperSource !== "" && !Settings.data.wallpaper.swww.enabled
delegate: PanelWindow {
required property ShellScreen modelData
property string wallpaperSource: WallpaperService.currentWallpaper !== ""
&& !Settings.data.wallpaper.swww.enabled ? WallpaperService.currentWallpaper : ""
// Force update when SWWW setting changes
onVisibleChanged: {
if (visible) {
visible: wallpaperSource !== "" && !Settings.data.wallpaper.swww.enabled
} else {
// Force update when SWWW setting changes
onVisibleChanged: {
if (visible) {
} else {
}
}
}
color: Color.transparent
screen: modelData
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-wallpaper"
color: Color.transparent
screen: modelData
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "quickshell-wallpaper"
anchors {
bottom: true
top: true
right: true
left: true
}
anchors {
bottom: true
top: true
right: true
left: true
}
margins {
top: 0
}
margins {
top: 0
}
Image {
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: wallpaperSource
visible: wallpaperSource !== ""
cache: true
smooth: true
mipmap: false
Image {
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: wallpaperSource
visible: wallpaperSource !== ""
cache: true
smooth: true
mipmap: false
}
}
}
}

View file

@ -6,14 +6,12 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
Loader {
active: CompositorService.isNiri
Component.onCompleted: {
if (CompositorService.isNiri) {
Logger.log("Overview", "Loading Overview component (Niri detected)")
} else {
Logger.log("Overview", "Skipping Overview component (Niri not detected)")
Logger.log("Overview", "Loading Overview component for Niri")
}
}

View file

@ -6,10 +6,10 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
isLoaded: Settings.data.general.showScreenCorners
Loader {
active: Settings.data.general.showScreenCorners
content: Variants {
sourceComponent: Variants {
model: Quickshell.screens
PanelWindow {

View file

@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
@ -17,6 +18,8 @@ Variants {
readonly property real scaling: ScalingService.scale(screen)
screen: modelData
WlrLayershell.namespace: "noctalia-bar"
implicitHeight: Style.barHeight * scaling
color: Color.transparent
@ -119,16 +122,6 @@ Variants {
anchors.verticalCenter: parent.verticalCenter
}
// NIconButton {
// id: demoPanelToggle
// icon: "experiment"
// tooltipText: "Open Demo Panel"
// sizeMultiplier: 0.8
// anchors.verticalCenter: parent.verticalCenter
// onClicked: {
// demoPanel.isLoaded = !demoPanel.isLoaded
// }
// }
SidePanelToggle {}
}
}

View file

@ -31,25 +31,10 @@ NIconButton {
}
tooltipText: "Bluetooth Devices"
onClicked: {
if (!bluetoothMenuLoader.active) {
bluetoothMenuLoader.isLoaded = true
}
if (bluetoothMenuLoader.item) {
if (bluetoothMenuLoader.item.visible) {
// Panel is visible, hide it with animation
if (bluetoothMenuLoader.item.hide) {
bluetoothMenuLoader.item.hide()
} else {
bluetoothMenuLoader.item.visible = false
}
} else {
// Panel is hidden, show it
bluetoothMenuLoader.item.visible = true
}
}
bluetoothPanel.toggle(screen)
}
BluetoothMenu {
id: bluetoothMenuLoader
BluetoothPanel {
id: bluetoothPanel
}
}

View file

@ -1,496 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
// Loader for Bluetooth menu
NLoader {
id: root
content: Component {
NPanel {
id: bluetoothPanel
function hide() {
bluetoothMenuRect.scaleValue = 0.8
bluetoothMenuRect.opacityValue = 0.0
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: bluetoothPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
bluetoothMenuRect.scaleValue = 0.8
bluetoothMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (visible && Settings.data.network.bluetoothEnabled) {
// Always refresh devices when menu opens to get fresh device objects
BluetoothService.adapter.discovering = true
} else if (bluetoothMenuRect.opacityValue > 0) {
// Start hide animation
bluetoothMenuRect.scaleValue = 0.8
bluetoothMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
bluetoothPanel.visible = false
bluetoothPanel.dismissed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Rectangle {
id: bluetoothMenuRect
property var deviceData: null
color: Color.mSurface
radius: Style.radiusL * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
width: 380 * scaling
height: 500 * scaling
anchors {
right: parent.right
rightMargin: Style.marginXS * scaling
top: Settings.data.bar.position === "top" ? parent.top : undefined
bottom: Settings.data.bar.position === "bottom" ? parent.bottom : undefined
topMargin: Settings.data.bar.position === "top" ? Style.marginXS * scaling : undefined
bottomMargin: Settings.data.bar.position === "bottom" ? Style.barHeight * scaling + Style.marginXS * scaling : undefined
}
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Prevent closing the window if clicking inside it
MouseArea {
anchors.fill: parent
}
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
// HEADER
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
NIcon {
text: "bluetooth"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
NText {
text: "Bluetooth"
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop_circle" : "refresh"
tooltipText: "Refresh Devices"
sizeMultiplier: 0.8
onClicked: {
if (BluetoothService.adapter) {
BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering
}
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
bluetoothPanel.hide()
}
}
}
NDivider {}
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
// Available devices
Column {
id: column
width: parent.width
spacing: Style.marginM * scaling
visible: BluetoothService.adapter && BluetoothService.adapter.enabled
RowLayout {
width: parent.width
spacing: Style.marginM * scaling
NText {
text: "Available Devices"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
font.weight: Style.fontWeightMedium
}
}
Repeater {
model: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return []
var filtered = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked && (dev.signalStrength === undefined
|| dev.signalStrength > 0)
})
return BluetoothService.sortDevices(filtered)
}
Rectangle {
property bool canConnect: BluetoothService.canConnect(modelData)
property bool isBusy: BluetoothService.isDeviceBusy(modelData)
width: parent.width
height: 70
radius: Style.radiusM * scaling
color: {
if (availableDeviceArea.containsMouse && !isBusy)
return Color.mTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mPrimary
if (modelData.blocked)
return Color.mError
return Color.mSurfaceVariant
}
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
Row {
anchors.left: parent.left
anchors.leftMargin: Style.marginM * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
// One device BT icon
NIcon {
text: BluetoothService.getDeviceIcon(modelData)
font.pointSize: Style.fontSizeXXL * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: Style.marginXXS * scaling
anchors.verticalCenter: parent.verticalCenter
// One device name
NText {
text: modelData.name || modelData.deviceName
font.pointSize: Style.fonttSizeMedium * scaling
elide: Text.ElideRight
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
font.weight: Style.fontWeightMedium
}
Row {
spacing: Style.marginXS * scaling
Row {
spacing: Style.marginS * spacing
// One device signal strength - "Unknown" when not connected
NText {
text: {
if (modelData.pairing)
return "Pairing..."
if (modelData.blocked)
return "Blocked"
return BluetoothService.getSignalStrength(modelData)
}
font.pointSize: Style.fontSizeXS * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
}
NIcon {
text: BluetoothService.getSignalIcon(modelData)
font.pointSize: Style.fontSizeXS * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0
&& !modelData.pairing && !modelData.blocked
}
NText {
text: (modelData.signalStrength !== undefined
&& modelData.signalStrength > 0) ? modelData.signalStrength + "%" : ""
font.pointSize: Style.fontSizeXS * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0
&& !modelData.pairing && !modelData.blocked
}
}
}
}
}
Rectangle {
width: 80 * scaling
height: 28 * scaling
radius: Style.radiusM * scaling
anchors.right: parent.right
anchors.rightMargin: Style.marginM * scaling
anchors.verticalCenter: parent.verticalCenter
visible: modelData.state !== BluetoothDeviceState.Connecting
color: Color.transparent
border.color: {
if (availableDeviceArea.containsMouse) {
return Color.mOnTertiary
} else {
return Color.mPrimary
}
}
border.width: Math.max(1, Style.borderS * scaling)
opacity: canConnect || isBusy ? 1 : 0.5
// On device connect button
NText {
anchors.centerIn: parent
text: {
if (modelData.pairing)
return "Pairing..."
if (modelData.blocked)
return "Blocked"
return "Connect"
}
font.pointSize: Style.fontSizeXS * scaling
font.weight: Style.fontWeightMedium
color: {
if (availableDeviceArea.containsMouse) {
return Color.mOnTertiary
} else {
return Color.mPrimary
}
}
}
}
MouseArea {
id: availableDeviceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: canConnect
&& !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor)
enabled: canConnect && !isBusy
onClicked: {
if (modelData)
BluetoothService.connectDeviceWithTrust(modelData)
}
}
}
}
// Fallback if nothing available
Column {
width: parent.width
spacing: Style.marginM * scaling
visible: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return false
var availableCount = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
}).length
return availableCount === 0
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.marginM * scaling
NIcon {
text: "sync"
font.pointSize: Style.fontSizeXLL * 1.5 * scaling
color: Color.mPrimary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: 2000
}
}
NText {
text: "Scanning for devices..."
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
font.weight: Style.fontWeightMedium
anchors.verticalCenter: parent.verticalCenter
}
}
NText {
text: "Make sure your device is in pairing mode"
font.pointSize: Style.fontSizeM * scaling
color: Color.mOnSurfaceVariant
anchors.horizontalCenter: parent.horizontalCenter
}
}
NText {
text: "No devices found. Put your device in pairing mode and click Start Scanning."
font.pointSize: Style.fontSizeM * scaling
color: Color.mOnSurfaceVariant
visible: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return true
var availableCount = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
}).length
return availableCount === 0 && !BluetoothService.adapter.discovering
}
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignHCenter
}
}
}
// This item takes up all the remaining vertical space.
Item {
Layout.fillHeight: true
}
}
}
}
}
}

View file

@ -0,0 +1,398 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NPanel {
id: root
panelWidth: 380 * scaling
panelHeight: 500 * scaling
panelAnchorRight: true
panelContent: Rectangle {
color: Color.transparent
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
// HEADER
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
NIcon {
text: "bluetooth"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
NText {
text: "Bluetooth"
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop_circle" : "refresh"
tooltipText: "Refresh Devices"
sizeMultiplier: 0.8
onClicked: {
if (BluetoothService.adapter) {
BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering
}
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
root.close()
}
}
}
NDivider {
Layout.fillWidth: true
}
ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ScrollBar.vertical.policy: ScrollBar.AsNeeded
// Available devices
Column {
id: column
width: parent.width
spacing: Style.marginM * scaling
visible: BluetoothService.adapter && BluetoothService.adapter.enabled
RowLayout {
width: parent.width
spacing: Style.marginM * scaling
NText {
text: "Available Devices"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
font.weight: Style.fontWeightMedium
}
}
Repeater {
model: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return []
var filtered = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing && !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
})
return BluetoothService.sortDevices(filtered)
}
Rectangle {
property bool canConnect: BluetoothService.canConnect(modelData)
property bool isBusy: BluetoothService.isDeviceBusy(modelData)
width: parent.width
height: 70
radius: Style.radiusM * scaling
color: {
if (availableDeviceArea.containsMouse && !isBusy)
return Color.mTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mPrimary
if (modelData.blocked)
return Color.mError
return Color.mSurfaceVariant
}
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
Row {
anchors.left: parent.left
anchors.leftMargin: Style.marginM * scaling
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
// One device BT icon
NIcon {
text: BluetoothService.getDeviceIcon(modelData)
font.pointSize: Style.fontSizeXXL * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: Style.marginXXS * scaling
anchors.verticalCenter: parent.verticalCenter
// One device name
NText {
text: modelData.name || modelData.deviceName
font.pointSize: Style.fonttSizeMedium * scaling
elide: Text.ElideRight
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
font.weight: Style.fontWeightMedium
}
Row {
spacing: Style.marginXS * scaling
Row {
spacing: Style.marginS * spacing
// One device signal strength - "Unknown" when not connected
NText {
text: {
if (modelData.pairing)
return "Pairing..."
if (modelData.blocked)
return "Blocked"
return BluetoothService.getSignalStrength(modelData)
}
font.pointSize: Style.fontSizeXS * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
}
NIcon {
text: BluetoothService.getSignalIcon(modelData)
font.pointSize: Style.fontSizeXS * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0
&& !modelData.pairing && !modelData.blocked
}
NText {
text: (modelData.signalStrength !== undefined
&& modelData.signalStrength > 0) ? modelData.signalStrength + "%" : ""
font.pointSize: Style.fontSizeXS * scaling
color: {
if (availableDeviceArea.containsMouse)
return Color.mOnTertiary
if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting)
return Color.mOnPrimary
if (modelData.blocked)
return Color.mOnError
return Color.mOnSurface
}
visible: modelData.signalStrength !== undefined && modelData.signalStrength > 0
&& !modelData.pairing && !modelData.blocked
}
}
}
}
}
Rectangle {
width: 80 * scaling
height: 28 * scaling
radius: Style.radiusM * scaling
anchors.right: parent.right
anchors.rightMargin: Style.marginM * scaling
anchors.verticalCenter: parent.verticalCenter
visible: modelData.state !== BluetoothDeviceState.Connecting
color: Color.transparent
border.color: {
if (availableDeviceArea.containsMouse) {
return Color.mOnTertiary
} else {
return Color.mPrimary
}
}
border.width: Math.max(1, Style.borderS * scaling)
opacity: canConnect || isBusy ? 1 : 0.5
// On device connect button
NText {
anchors.centerIn: parent
text: {
if (modelData.pairing)
return "Pairing..."
if (modelData.blocked)
return "Blocked"
return "Connect"
}
font.pointSize: Style.fontSizeXS * scaling
font.weight: Style.fontWeightMedium
color: {
if (availableDeviceArea.containsMouse) {
return Color.mOnTertiary
} else {
return Color.mPrimary
}
}
}
}
MouseArea {
id: availableDeviceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: canConnect && !isBusy ? Qt.PointingHandCursor : (isBusy ? Qt.BusyCursor : Qt.ArrowCursor)
enabled: canConnect && !isBusy
onClicked: {
if (modelData)
BluetoothService.connectDeviceWithTrust(modelData)
}
}
}
}
// Fallback if nothing available
Column {
width: parent.width
spacing: Style.marginM * scaling
visible: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices)
return false
var availableCount = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
}).length
return availableCount === 0
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.marginM * scaling
NIcon {
text: "sync"
font.pointSize: Style.fontSizeXLL * 1.5 * scaling
color: Color.mPrimary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: 2000
}
}
NText {
text: "Scanning for devices..."
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
font.weight: Style.fontWeightMedium
anchors.verticalCenter: parent.verticalCenter
}
}
NText {
text: "Make sure your device is in pairing mode"
font.pointSize: Style.fontSizeM * scaling
color: Color.mOnSurfaceVariant
anchors.horizontalCenter: parent.horizontalCenter
}
}
NText {
text: "No devices found. Put your device in pairing mode and click Start Scanning."
font.pointSize: Style.fontSizeM * scaling
color: Color.mOnSurfaceVariant
visible: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return true
var availableCount = Bluetooth.devices.values.filter(dev => {
return dev && !dev.paired && !dev.pairing
&& !dev.blocked
&& (dev.signalStrength === undefined
|| dev.signalStrength > 0)
}).length
return availableCount === 0 && !BluetoothService.adapter.discovering
}
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignHCenter
}
}
}
// This item takes up all the remaining vertical space.
Item {
Layout.fillHeight: true
}
}
}
}

View file

@ -70,7 +70,7 @@ Item {
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.Brightness
settingsPanel.isLoaded = true
settingsPanel.open(screen)
}
}
}

View file

@ -24,7 +24,7 @@ Rectangle {
}
onEntered: {
if (!calendarPanel.isLoaded) {
if (!calendarPanel.active) {
tooltip.show()
}
}
@ -33,7 +33,7 @@ Rectangle {
}
onClicked: {
tooltip.hide()
calendarPanel.isLoaded = !calendarPanel.isLoaded
calendarPanel.toggle(screen)
}
}
}

View file

@ -32,8 +32,6 @@ Row {
height: Math.round(Style.capsuleHeight * scaling)
radius: Math.round(Style.radiusM * scaling)
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Math.round(Style.borderS * scaling))
anchors.verticalCenter: parent.verticalCenter

View file

@ -20,21 +20,6 @@ NIconButton {
colorBorderHover: Color.transparent
onClicked: {
if (!notificationHistoryPanel.active) {
notificationHistoryPanel.isLoaded = true
}
if (notificationHistoryPanel.item) {
if (notificationHistoryPanel.item.visible) {
// Panel is visible, hide it with animation
if (notificationHistoryPanel.item.hide) {
notificationHistoryPanel.item.hide()
} else {
notificationHistoryPanel.item.visible = false
}
} else {
// Panel is hidden, show it
notificationHistoryPanel.item.visible = true
}
}
notificationHistoryPanel.toggle(screen)
}
}

View file

@ -14,23 +14,5 @@ NIconButton {
colorBorderHover: Color.transparent
anchors.verticalCenter: parent.verticalCenter
onClicked: {
// Map this button's center to the screen and open the side panel below it
const localCenterX = width / 2
const localCenterY = height / 2
const globalPoint = mapToItem(null, localCenterX, localCenterY)
if (sidePanel.isLoaded) {
// Call hide() instead of directly setting isLoaded to false
if (sidePanel.item && sidePanel.item.hide) {
sidePanel.item.hide()
} else {
sidePanel.isLoaded = false
}
} else if (sidePanel.openAt) {
sidePanel.openAt(globalPoint.x, screen)
} else {
// Fallback: toggle if API unavailable
sidePanel.isLoaded = true
}
}
onClicked: sidePanel.toggle(screen)
}

View file

@ -76,37 +76,34 @@ Rectangle {
if (mouse.button === Qt.LeftButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
}
trayPanel.close()
if (!modelData.onlyMenu) {
modelData.activate()
}
} else if (mouse.button === Qt.MiddleButton) {
// Close any open menu first
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
}
trayPanel.close()
modelData.secondaryActivate && modelData.secondaryActivate()
} else if (mouse.button === Qt.RightButton) {
trayTooltip.hide()
// If menu is already visible, close it
if (trayMenu && trayMenu.visible) {
trayMenu.hideMenu()
// Close the menu if it was visible
if (trayPanel && trayPanel.visible) {
trayPanel.close()
return
}
if (modelData.hasMenu && modelData.menu && trayMenu) {
trayPanel.open()
// Anchor the menu to the tray icon item (parent) and position it below the icon
const menuX = (width / 2) - (trayMenu.width / 2)
const menuY = (Style.barHeight * scaling)
trayMenu.menu = modelData.menu
trayMenu.showAt(parent, menuX, menuY)
trayPanel.show()
} else {
Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set")
}
}
@ -125,94 +122,37 @@ Rectangle {
}
}
// Attached TrayMenu drop down
// Wrapped in NPanel so we can detect click outside of the menu to close the TrayMenu
NPanel {
PanelWindow {
id: trayPanel
showOverlay: false // no colors overlay even if activated in settings
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
visible: false
color: Color.transparent
screen: screen
// Override hide function to animate first
function hide() {
// Start hide animation
trayMenuRect.scaleValue = 0.8
trayMenuRect.opacityValue = 0.0
function open() {
visible = true
// Hide after animation completes
hideTimer.start()
// Register into the panel service
// so this will autoclose if we open another panel
PanelService.registerOpen(trayPanel)
}
Connections {
target: trayPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
trayMenuRect.scaleValue = 0.8
trayMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
function close() {
visible = false
trayMenu.hideMenu()
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && trayMenuRect.opacityValue > 0) {
// Start hide animation
trayMenuRect.scaleValue = 0.8
trayMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
trayPanel.visible = false
trayMenu.hideMenu()
}
}
Rectangle {
id: trayMenuRect
color: Color.transparent
// Clicking outside of the rectangle to close
MouseArea {
anchors.fill: parent
onClicked: trayPanel.close()
}
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
TrayMenu {
id: trayMenu
}
TrayMenu {
id: trayMenu
}
}
}

View file

@ -65,7 +65,7 @@ Item {
}
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.AudioService
settingsPanel.isLoaded = true
settingsPanel.open(screen)
}
}
}

View file

@ -34,27 +34,10 @@ NIconButton {
}
tooltipText: "WiFi Networks"
onClicked: {
if (!wifiMenuLoader.active) {
wifiMenuLoader.isLoaded = true
}
if (wifiMenuLoader.item) {
if (wifiMenuLoader.item.visible) {
// Panel is visible, hide it with animation
if (wifiMenuLoader.item.hide) {
wifiMenuLoader.item.hide()
} else {
wifiMenuLoader.item.visible = false
NetworkService.onMenuClosed()
}
} else {
// Panel is hidden, show it
wifiMenuLoader.item.visible = true
NetworkService.onMenuOpened()
}
}
wifiPanel.toggle(screen)
}
WiFiMenu {
id: wifiMenuLoader
WiFiPanel {
id: wifiPanel
}
}

View file

@ -1,435 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
// Loader for WiFi menu
NLoader {
id: root
content: Component {
NPanel {
id: wifiPanel
property string passwordPromptSsid: ""
property string passwordInput: ""
property bool showPasswordPrompt: false
function hide() {
wifiMenuRect.scaleValue = 0.8
wifiMenuRect.opacityValue = 0.0
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: wifiPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
wifiMenuRect.scaleValue = 0.8
wifiMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (visible && Settings.data.network.wifiEnabled) {
NetworkService.refreshNetworks()
} else if (wifiMenuRect.opacityValue > 0) {
// Start hide animation
wifiMenuRect.scaleValue = 0.8
wifiMenuRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
wifiPanel.visible = false
wifiPanel.dismissed()
// NetworkService.onMenuClosed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
// Timer to refresh networks when WiFi is enabled while menu is open
Timer {
id: wifiEnableRefreshTimer
interval: 3000 // Wait 3 seconds for WiFi to be fully ready
repeat: false
onTriggered: {
if (Settings.data.network.wifiEnabled && wifiPanel.visible) {
NetworkService.refreshNetworks()
}
}
}
Rectangle {
id: wifiMenuRect
color: Color.mSurface
radius: Style.radiusL * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
width: 340 * scaling
height: 500 * scaling
anchors {
right: parent.right
rightMargin: Style.marginXS * scaling
top: Settings.data.bar.position === "top" ? parent.top : undefined
bottom: Settings.data.bar.position === "bottom" ? parent.bottom : undefined
topMargin: Settings.data.bar.position === "top" ? Style.marginXS * scaling : undefined
bottomMargin: Settings.data.bar.position === "bottom" ? Style.barHeight * scaling + Style.marginXS * scaling : undefined
}
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
NIcon {
text: "wifi"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
NText {
text: "WiFi"
font.pointSize: Style.fontSizeL * scaling
font.bold: true
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "refresh"
tooltipText: "Refresh Networks"
sizeMultiplier: 0.8
enabled: Settings.data.network.wifiEnabled && !NetworkService.isLoading
onClicked: {
NetworkService.refreshNetworks()
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
wifiPanel.hide()
}
}
}
NDivider {}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
// Loading indicator
ColumnLayout {
anchors.centerIn: parent
visible: Settings.data.network.wifiEnabled && NetworkService.isLoading
spacing: Style.marginM * scaling
NBusyIndicator {
running: NetworkService.isLoading
color: Color.mPrimary
size: Style.baseWidgetSize * scaling
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Scanning for networks..."
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
// WiFi disabled message
ColumnLayout {
anchors.centerIn: parent
visible: !Settings.data.network.wifiEnabled
spacing: Style.marginM * scaling
NIcon {
text: "wifi_off"
font.pointSize: Style.fontSizeXXXL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "WiFi is disabled"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Enable WiFi to see available networks"
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
// Network list
ListView {
id: networkList
anchors.fill: parent
visible: Settings.data.network.wifiEnabled && !NetworkService.isLoading
model: Object.values(NetworkService.networks)
spacing: Style.marginM * scaling
clip: true
delegate: Item {
width: parent ? parent.width : 0
height: modelData.ssid === passwordPromptSsid
&& showPasswordPrompt ? 108 * scaling : Style.baseWidgetSize * 1.5 * scaling
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 1.5 * scaling
radius: Style.radiusM * scaling
color: modelData.connected ? Color.mPrimary : (networkMouseArea.containsMouse ? Color.mTertiary : Color.transparent)
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginS * scaling
NIcon {
text: NetworkService.signalIcon(modelData.signal)
font.pointSize: Style.fontSizeXXL * scaling
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS * scaling
// SSID
NText {
text: modelData.ssid || "Unknown Network"
font.pointSize: Style.fontSizeNormal * scaling
elide: Text.ElideRight
Layout.fillWidth: true
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
// Security Protocol
NText {
text: modelData.security && modelData.security !== "--" ? modelData.security : "Open"
font.pointSize: Style.fontSizeXS * scaling
elide: Text.ElideRight
Layout.fillWidth: true
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
NText {
visible: NetworkService.connectStatusSsid === modelData.ssid
&& NetworkService.connectStatus === "error" && NetworkService.connectError.length > 0
text: NetworkService.connectError
color: Color.mError
font.pointSize: Style.fontSizeXS * scaling
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item {
Layout.preferredWidth: Style.baseWidgetSize * 0.7 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 0.7 * scaling
visible: NetworkService.connectStatusSsid === modelData.ssid
&& (NetworkService.connectStatus !== ""
|| NetworkService.connectingSsid === modelData.ssid)
NBusyIndicator {
visible: NetworkService.connectingSsid === modelData.ssid
running: NetworkService.connectingSsid === modelData.ssid
color: Color.mPrimary
anchors.centerIn: parent
size: Style.baseWidgetSize * 0.7 * scaling
}
}
NText {
visible: modelData.connected
text: "connected"
font.pointSize: Style.fontSizeXS * scaling
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
}
MouseArea {
id: networkMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
NetworkService.disconnectNetwork(modelData.ssid)
} else if (NetworkService.isSecured(modelData.security) && !modelData.existing) {
passwordPromptSsid = modelData.ssid
showPasswordPrompt = true
passwordInput = "" // Clear previous input
Qt.callLater(function () {
passwordInputField.forceActiveFocus()
})
} else {
NetworkService.connectNetwork(modelData.ssid, modelData.security)
}
}
}
}
// Password prompt section
Rectangle {
id: passwordPromptSection
Layout.fillWidth: true
Layout.preferredHeight: modelData.ssid === passwordPromptSsid && showPasswordPrompt ? 60 : 0
Layout.margins: Style.marginS * scaling
visible: modelData.ssid === passwordPromptSsid && showPasswordPrompt
color: Color.mSurfaceVariant
radius: Style.radiusS * scaling
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginS * scaling
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Rectangle {
anchors.fill: parent
radius: Style.radiusXS * scaling
color: Color.transparent
border.color: passwordInputField.activeFocus ? Color.mPrimary : Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
TextInput {
id: passwordInputField
anchors.fill: parent
anchors.margins: Style.marginM * scaling
text: passwordInput
font.pointSize: Style.fontSizeM * scaling
color: Color.mOnSurface
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
echoMode: TextInput.Password
onTextChanged: passwordInput = text
onAccepted: {
NetworkService.submitPassword(passwordPromptSsid, passwordInput)
showPasswordPrompt = false
}
MouseArea {
id: passwordInputMouseArea
anchors.fill: parent
onClicked: passwordInputField.forceActiveFocus()
}
}
}
}
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 2.5 * scaling
Layout.preferredHeight: Style.barHeight * scaling
radius: Style.radiusM * scaling
color: Color.mPrimary
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
NText {
anchors.centerIn: parent
text: "Connect"
color: Color.mSurface
font.pointSize: Style.fontSizeXS * scaling
}
MouseArea {
anchors.fill: parent
onClicked: {
NetworkService.submitPassword(passwordPromptSsid, passwordInput)
showPasswordPrompt = false
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.color = Qt.darker(Color.mPrimary, 1.1)
onExited: parent.color = Color.mPrimary
}
}
}
}
}
}
}
}
}
}
}
}
}

335
Modules/Bar/WiFiPanel.qml Normal file
View file

@ -0,0 +1,335 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NPanel {
id: root
panelWidth: 380 * scaling
panelHeight: 500 * scaling
panelAnchorRight: true
property string passwordPromptSsid: ""
property string passwordInput: ""
property bool showPasswordPrompt: false
onOpened: {
if (Settings.data.network.wifiEnabled && wifiPanel.visible) {
NetworkService.refreshNetworks()
}
}
panelContent: Rectangle {
color: Color.transparent
anchors.fill: parent
anchors.margins: Style.marginL * scaling
ColumnLayout {
anchors.fill: parent
// Header
RowLayout {
NIcon {
text: "wifi"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
NText {
text: "WiFi"
font.pointSize: Style.fontSizeL * scaling
font.bold: true
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "refresh"
tooltipText: "Refresh Networks"
sizeMultiplier: 0.8
enabled: Settings.data.network.wifiEnabled && !NetworkService.isLoading
onClicked: {
NetworkService.refreshNetworks()
}
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
root.close()
}
}
}
NDivider {
Layout.fillWidth: true
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
// Loading indicator
ColumnLayout {
anchors.centerIn: parent
visible: Settings.data.network.wifiEnabled && NetworkService.isLoading
spacing: Style.marginM * scaling
NBusyIndicator {
running: NetworkService.isLoading
color: Color.mPrimary
size: Style.baseWidgetSize * scaling
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Scanning for networks..."
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
// WiFi disabled message
ColumnLayout {
anchors.centerIn: parent
visible: !Settings.data.network.wifiEnabled
spacing: Style.marginM * scaling
NIcon {
text: "wifi_off"
font.pointSize: Style.fontSizeXXXL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "WiFi is disabled"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Enable WiFi to see available networks"
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
// Network list
ListView {
id: networkList
anchors.fill: parent
visible: Settings.data.network.wifiEnabled && !NetworkService.isLoading
model: Object.values(NetworkService.networks)
spacing: Style.marginM * scaling
clip: true
delegate: Item {
width: parent ? parent.width : 0
height: modelData.ssid === passwordPromptSsid
&& showPasswordPrompt ? 108 * scaling : Style.baseWidgetSize * 1.5 * scaling
ColumnLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 1.5 * scaling
radius: Style.radiusS * scaling
color: modelData.connected ? Color.mPrimary : (networkMouseArea.containsMouse ? Color.mTertiary : Color.transparent)
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginS * scaling
NIcon {
text: NetworkService.signalIcon(modelData.signal)
font.pointSize: Style.fontSizeXXL * scaling
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS * scaling
// SSID
NText {
text: modelData.ssid || "Unknown Network"
font.pointSize: Style.fontSizeNormal * scaling
elide: Text.ElideRight
Layout.fillWidth: true
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
// Security Protocol
NText {
text: modelData.security && modelData.security !== "--" ? modelData.security : "Open"
font.pointSize: Style.fontSizeXS * scaling
elide: Text.ElideRight
Layout.fillWidth: true
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
NText {
visible: NetworkService.connectStatusSsid === modelData.ssid
&& NetworkService.connectStatus === "error" && NetworkService.connectError.length > 0
text: NetworkService.connectError
color: Color.mError
font.pointSize: Style.fontSizeXS * scaling
elide: Text.ElideRight
Layout.fillWidth: true
}
}
Item {
Layout.preferredWidth: Style.baseWidgetSize * 0.7 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 0.7 * scaling
visible: NetworkService.connectStatusSsid === modelData.ssid
&& (NetworkService.connectStatus !== ""
|| NetworkService.connectingSsid === modelData.ssid)
NBusyIndicator {
visible: NetworkService.connectingSsid === modelData.ssid
running: NetworkService.connectingSsid === modelData.ssid
color: Color.mPrimary
anchors.centerIn: parent
size: Style.baseWidgetSize * 0.7 * scaling
}
}
NText {
visible: modelData.connected
text: "connected"
font.pointSize: Style.fontSizeXS * scaling
color: modelData.connected ? Color.mSurface : (networkMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface)
}
}
MouseArea {
id: networkMouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (modelData.connected) {
NetworkService.disconnectNetwork(modelData.ssid)
} else if (NetworkService.isSecured(modelData.security) && !modelData.existing) {
passwordPromptSsid = modelData.ssid
showPasswordPrompt = true
passwordInput = "" // Clear previous input
Qt.callLater(function () {
passwordInputField.forceActiveFocus()
})
} else {
NetworkService.connectNetwork(modelData.ssid, modelData.security)
}
}
}
}
// Password prompt section
Rectangle {
id: passwordPromptSection
Layout.fillWidth: true
Layout.preferredHeight: modelData.ssid === passwordPromptSsid && showPasswordPrompt ? 60 : 0
Layout.margins: Style.marginS * scaling
visible: modelData.ssid === passwordPromptSsid && showPasswordPrompt
color: Color.mSurfaceVariant
radius: Style.radiusS * scaling
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginS * scaling
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Rectangle {
anchors.fill: parent
radius: Style.radiusXS * scaling
color: Color.transparent
border.color: passwordInputField.activeFocus ? Color.mPrimary : Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
TextInput {
id: passwordInputField
anchors.fill: parent
anchors.margins: Style.marginM * scaling
text: passwordInput
font.pointSize: Style.fontSizeM * scaling
color: Color.mOnSurface
verticalAlignment: TextInput.AlignVCenter
clip: true
focus: true
selectByMouse: true
activeFocusOnTab: true
inputMethodHints: Qt.ImhNone
echoMode: TextInput.Password
onTextChanged: passwordInput = text
onAccepted: {
NetworkService.submitPassword(passwordPromptSsid, passwordInput)
showPasswordPrompt = false
}
MouseArea {
id: passwordInputMouseArea
anchors.fill: parent
onClicked: passwordInputField.forceActiveFocus()
}
}
}
}
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 2.5 * scaling
Layout.preferredHeight: Style.barHeight * scaling
radius: Style.radiusM * scaling
color: Color.mPrimary
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
NText {
anchors.centerIn: parent
text: "Connect"
color: Color.mSurface
font.pointSize: Style.fontSizeXS * scaling
}
MouseArea {
anchors.fill: parent
onClicked: {
NetworkService.submitPassword(passwordPromptSsid, passwordInput)
showPasswordPrompt = false
}
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.color = Qt.darker(Color.mPrimary, 1.1)
onExited: parent.color = Color.mPrimary
}
}
}
}
}
}
}
}
}
}
}

View file

@ -7,227 +7,132 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
NPanel {
id: root
content: Component {
NPanel {
id: calendarPanel
panelWidth: 340 * scaling
panelHeight: 320 * scaling
panelAnchorRight: true
// Override hide function to animate first
function hide() {
// Start hide animation
calendarRect.scaleValue = 0.8
calendarRect.opacityValue = 0.0
// Main Column
panelContent: ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: Style.marginXS * scaling
// Hide after animation completes
hideTimer.start()
// Header: Month/Year with navigation
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginM * scaling
Layout.rightMargin: Style.marginM * scaling
spacing: Style.marginS * scaling
NIconButton {
icon: "chevron_left"
tooltipText: "Previous Month"
onClicked: {
let newDate = new Date(grid.year, grid.month - 1, 1)
grid.year = newDate.getFullYear()
grid.month = newDate.getMonth()
}
}
// Connect to NPanel's dismissed signal to handle external close events
NText {
text: grid.title
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
}
NIconButton {
icon: "chevron_right"
tooltipText: "Next Month"
onClicked: {
let newDate = new Date(grid.year, grid.month + 1, 1)
grid.year = newDate.getFullYear()
grid.month = newDate.getMonth()
}
}
}
// Divider between header and weekdays
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginS * scaling
Layout.bottomMargin: Style.marginM * scaling
}
// Columns label (respects locale's first day of week)
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginS * scaling // Align with grid
Layout.rightMargin: Style.marginS * scaling
spacing: 0
Repeater {
model: 7
NText {
text: {
// Use the locale's first day of week setting
let firstDay = Qt.locale().firstDayOfWeek
let dayIndex = (firstDay + index) % 7
return Qt.locale().dayName(dayIndex, Locale.ShortFormat)
}
color: Color.mSecondary
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
Layout.preferredWidth: Style.baseWidgetSize * scaling
}
}
}
// Grids: days
MonthGrid {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true // Take remaining space
Layout.leftMargin: Style.marginS * scaling
Layout.rightMargin: Style.marginS * scaling
spacing: 0
month: Time.date.getMonth()
year: Time.date.getFullYear()
locale: Qt.locale() // Use system locale
// Optionally, update when the panel becomes visible
Connections {
target: calendarPanel
function onDismissed() {
// Start hide animation
calendarRect.scaleValue = 0.8
calendarRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
function onVisibleChanged() {
if (calendarPanel.visible) {
grid.month = Time.date.getMonth()
grid.year = Time.date.getFullYear()
}
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && calendarRect.opacityValue > 0) {
// Start hide animation
calendarRect.scaleValue = 0.8
calendarRect.opacityValue = 0.0
delegate: Rectangle {
width: (Style.baseWidgetSize * scaling)
height: (Style.baseWidgetSize * scaling)
radius: Style.radiusS * scaling
color: model.today ? Color.mPrimary : Color.transparent
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
calendarPanel.visible = false
calendarPanel.dismissed()
}
}
Rectangle {
id: calendarRect
color: Color.mSurface
radius: Style.radiusM * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderM * scaling)
width: 340 * scaling
height: 320 * scaling // Reduced height to eliminate bottom space
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.marginXS * scaling
anchors.rightMargin: Style.marginXS * scaling
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
NText {
anchors.centerIn: parent
text: model.day
color: model.today ? Color.mOnPrimary : Color.mOnSurface
opacity: model.month === grid.month ? Style.opacityHeavy : Style.opacityLight
font.pointSize: (Style.fontSizeM * scaling)
font.weight: model.today ? Style.fontWeightBold : Style.fontWeightRegular
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
// Main Column
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: Style.marginXS * scaling
// Header: Month/Year with navigation
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginM * scaling
Layout.rightMargin: Style.marginM * scaling
spacing: Style.marginS * scaling
NIconButton {
icon: "chevron_left"
tooltipText: "Previous Month"
onClicked: {
let newDate = new Date(grid.year, grid.month - 1, 1)
grid.year = newDate.getFullYear()
grid.month = newDate.getMonth()
}
}
NText {
text: grid.title
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
}
NIconButton {
icon: "chevron_right"
tooltipText: "Next Month"
onClicked: {
let newDate = new Date(grid.year, grid.month + 1, 1)
grid.year = newDate.getFullYear()
grid.month = newDate.getMonth()
}
}
}
// Divider between header and weekdays
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginS * scaling
Layout.bottomMargin: Style.marginM * scaling
}
// Columns label (respects locale's first day of week)
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginS * scaling // Align with grid
Layout.rightMargin: Style.marginS * scaling
spacing: 0
Repeater {
model: 7
NText {
text: {
// Use the locale's first day of week setting
let firstDay = Qt.locale().firstDayOfWeek
let dayIndex = (firstDay + index) % 7
return Qt.locale().dayName(dayIndex, Locale.ShortFormat)
}
color: Color.mSecondary
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
Layout.preferredWidth: Style.baseWidgetSize * scaling
}
}
}
// Grids: days
MonthGrid {
id: grid
Layout.fillWidth: true
Layout.fillHeight: true // Take remaining space
Layout.leftMargin: Style.marginS * scaling
Layout.rightMargin: Style.marginS * scaling
spacing: 0
month: Time.date.getMonth()
year: Time.date.getFullYear()
locale: Qt.locale() // Use system locale
// Optionally, update when the panel becomes visible
Connections {
target: calendarPanel
function onVisibleChanged() {
if (calendarPanel.visible) {
grid.month = Time.date.getMonth()
grid.year = Time.date.getFullYear()
}
}
}
delegate: Rectangle {
width: (Style.baseWidgetSize * scaling)
height: (Style.baseWidgetSize * scaling)
radius: Style.radiusS * scaling
color: model.today ? Color.mPrimary : Color.transparent
NText {
anchors.centerIn: parent
text: model.day
color: model.today ? Color.mOnPrimary : Color.mOnSurface
opacity: model.month === grid.month ? Style.opacityHeavy : Style.opacityLight
font.pointSize: (Style.fontSizeM * scaling)
font.weight: model.today ? Style.fontWeightBold : Style.fontWeightRegular
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}

View file

@ -1,307 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
id: root
content: Component {
NPanel {
id: demoPanel
property real sliderValue: 1.0
// Override hide function to animate first
function hide() {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: demoPanel
function onDismissed() {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && bgRect.opacityValue > 0) {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
demoPanel.visible = false
demoPanel.dismissed()
}
}
// Ensure panel shows itself once created
Component.onCompleted: {
show()
}
Rectangle {
id: bgRect
color: Color.mSurfaceVariant
radius: Style.radiusM * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
width: 500 * scaling
height: 900 * scaling
anchors.centerIn: parent
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginXL * scaling
NText {
text: "DemoPanel"
color: Color.mPrimary
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold
Layout.alignment: Qt.AlignHCenter
}
ColumnLayout {
spacing: Style.marginM * scaling
// NSlider
ColumnLayout {
spacing: Style.marginL * scaling
NText {
text: "NSlider"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NText {
text: `${Math.round(sliderValue * 100)}%`
Layout.alignment: Qt.AlignVCenter
}
RowLayout {
spacing: Style.marginS * scaling
NSlider {
id: scaleSlider
from: 1.0
to: 2.0
stepSize: 0.01
value: sliderValue
onPressedChanged: {
sliderValue = value
}
Layout.fillWidth: true
}
NIconButton {
icon: "refresh"
tooltipText: "Reset Scaling"
fontPointSize: Style.fontSizeL * scaling
onClicked: {
sliderValue = 1.0
}
}
}
NDivider {
Layout.fillWidth: true
}
}
// NIconButton
ColumnLayout {
spacing: Style.marginL * scaling
NText {
text: "NIconButton"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NIconButton {
id: myIconButton
icon: "celebration"
tooltipText: "A nice tooltip"
fontPointSize: Style.fontSizeL * scaling
}
NDivider {
Layout.fillWidth: true
}
}
// NToggle
ColumnLayout {
spacing: Style.marginM * scaling
NText {
text: "NToggle"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NToggle {
label: "Label"
description: "Description"
onToggled: checked => {
Logger.log("DemoPanel", "NToggle:", checked)
}
}
NDivider {
Layout.fillWidth: true
}
}
// NComboBox
ColumnLayout {
spacing: Style.marginM * scaling
NText {
text: "NComboBox"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NComboBox {
label: "Animal"
description: "What's your favorite?"
model: ListModel {
ListElement {
key: "cat"
name: "Cat"
}
ListElement {
key: "dog"
name: "Dog"
}
ListElement {
key: "bird"
name: "Bird"
}
ListElement {
key: "fish"
name: "Fish"
}
ListElement {
key: "turtle"
name: "Turtle"
}
ListElement {
key: "elephant"
name: "Elephant"
}
ListElement {
key: "tiger"
name: "Tiger"
}
}
currentKey: "dog"
onSelected: function (key) {
Logger.log("DemoPanel", "NComboBox: selected ", key)
}
}
NDivider {
Layout.fillWidth: true
}
}
// NTextInput
ColumnLayout {
spacing: Style.marginM * scaling
NText {
text: "NTextInput"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NTextInput {
label: "Input label"
description: "A cool description"
text: "Type anything"
Layout.fillWidth: true
onEditingFinished: {
}
}
NDivider {
Layout.fillWidth: true
}
}
// NBusyIndicator
ColumnLayout {
spacing: Style.marginM * scaling
NText {
text: "NBusyIndicator"
color: Color.mSecondary
font.weight: Style.fontWeightBold
}
NBusyIndicator {}
NDivider {
Layout.fillWidth: true
}
}
}
}
}
}
}
}

View file

@ -9,9 +9,9 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
isLoaded: (Settings.data.dock.monitors.length > 0)
content: Component {
Loader {
active: (Settings.data.dock.monitors.length > 0)
sourceComponent: Component {
Variants {
model: Quickshell.screens

View file

@ -1,5 +1,7 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
Item {
id: root
@ -8,7 +10,7 @@ Item {
target: "settings"
function toggle() {
settingsPanel.isLoaded = !settingsPanel.isLoaded
settingsPanel.toggle(Quickshell.screens[0])
}
}
@ -16,7 +18,7 @@ Item {
target: "notifications"
function toggleHistory() {
notificationHistoryPanel.isLoaded = !notificationHistoryPanel.isLoaded
notificationHistoryPanel.toggle(Quickshell.screens[0])
}
function toggleDoNotDisturb() {// TODO
@ -26,7 +28,8 @@ Item {
IpcHandler {
target: "idleInhibitor"
function toggle() {// TODO
function toggle() {
return IdleInhibitorService.manualToggle()
}
}
@ -34,7 +37,7 @@ Item {
target: "appLauncher"
function toggle() {
appLauncherPanel.isLoaded = !appLauncherPanel.isLoaded
appLauncherPanel.toggle(Quickshell.screens[0])
}
}
@ -42,7 +45,11 @@ Item {
target: "lockScreen"
function toggle() {
lockScreen.isLoaded = !lockScreen.isLoaded
// Only lock if not already locked (prevents the red screen issue)
// Note: No unlock via IPC for security reasons
if (!lockScreen.active) {
lockScreen.active = true
}
}
}

View file

@ -12,27 +12,43 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
Loader {
id: lockScreen
active: false
// Log state changes to help debug lock screen issues
onActiveChanged: {
Logger.log("LockScreen", "State changed:", active)
}
// Allow a small grace period after unlocking so the compositor releases the lock surfaces
Timer {
id: unloadAfterUnlockTimer
interval: 250
repeat: false
onTriggered: lockScreen.isLoaded = false
onTriggered: {
Logger.log("LockScreen", "Unload timer triggered - deactivating")
lockScreen.active = false
}
}
function scheduleUnloadAfterUnlock() {
Logger.log("LockScreen", "Scheduling unload after unlock")
unloadAfterUnlockTimer.start()
}
content: Component {
sourceComponent: Component {
WlSessionLock {
id: lock
// Tie session lock to loader visibility
locked: lockScreen.isLoaded
locked: lockScreen.active
// Lockscreen is a different beast, needs a capital 'S' in 'Screen' to access the current screen
// Also we use a different scaling algorithm based on the resolution, as the design is full screen
readonly property real scaling: ScalingService.dynamicScale(Screen)
readonly property real scaling: {
var tt = ScalingService.dynamicScale(Screen)
console.log(tt)
return tt
}
property string errorMessage: ""
property bool authenticating: false
@ -233,13 +249,13 @@ NLoader {
// Time display - Large and prominent with pulse animation
Column {
spacing: Style.marginS * scaling
spacing: Style.marginXS * scaling
Layout.alignment: Qt.AlignHCenter
Text {
NText {
id: timeText
text: Qt.formatDateTime(new Date(), "HH:mm")
font.family: "Inter"
font.family: Settings.data.ui.fontBillboard
font.pointSize: Style.fontSizeXXXL * 6 * scaling
font.weight: Font.Bold
font.letterSpacing: -2 * scaling
@ -261,10 +277,10 @@ NLoader {
}
}
Text {
NText {
id: dateText
text: Qt.formatDateTime(new Date(), "dddd, MMMM d")
font.family: "Inter"
font.family: Settings.data.ui.fontBillboard
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Font.Light
color: Color.mOnSurface
@ -404,10 +420,10 @@ NLoader {
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
Text {
NText {
text: "SECURE TERMINAL"
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
font.weight: Font.Bold
Layout.fillWidth: true
@ -424,10 +440,10 @@ NLoader {
color: batteryIndicator.charging ? Color.mPrimary : Color.mOnSurface
}
Text {
NText {
text: Math.round(batteryIndicator.percent) + "%"
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeM * scaling
font.weight: Font.Bold
}
@ -450,19 +466,19 @@ NLoader {
Layout.fillWidth: true
spacing: Style.marginM * scaling
Text {
NText {
text: "root@noctalia:~$"
color: Color.mPrimary
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
font.weight: Font.Bold
}
Text {
NText {
id: welcomeText
text: ""
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
property int currentIndex: 0
property string fullText: "Welcome back, " + Quickshell.env("USER") + "!"
@ -488,18 +504,18 @@ NLoader {
Layout.fillWidth: true
spacing: Style.marginM * scaling
Text {
NText {
text: "root@noctalia:~$"
color: Color.mPrimary
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
font.weight: Font.Bold
}
Text {
NText {
text: "sudo unlock-session"
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
}
@ -509,7 +525,7 @@ NLoader {
width: 0
height: 0
visible: false
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
echoMode: TextInput.Password
@ -535,11 +551,11 @@ NLoader {
}
// Visual password display with integrated cursor
Text {
NText {
id: asterisksText
text: "*".repeat(passwordInput.text.length)
color: Color.mOnSurface
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeL * scaling
visible: passwordInput.activeFocus
@ -585,7 +601,7 @@ NLoader {
}
// Status messages
Text {
NText {
text: lock.authenticating ? "Authenticating..." : (lock.errorMessage !== "" ? "Authentication failed." : "")
color: lock.authenticating ? Color.mPrimary : (lock.errorMessage !== "" ? Color.mError : Color.transparent)
font.family: "DejaVu Sans Mono"
@ -618,11 +634,11 @@ NLoader {
Layout.alignment: Qt.AlignRight
Layout.bottomMargin: -12 * scaling
Text {
NText {
anchors.centerIn: parent
text: lock.authenticating ? "EXECUTING" : "EXECUTE"
color: executeButtonArea.containsMouse ? Color.mOnPrimary : Color.mPrimary
font.family: "DejaVu Sans Mono"
font.family: Settings.data.ui.fontFixed
font.pointSize: Style.fontSizeM * scaling
font.weight: Font.Bold
}

View file

@ -47,13 +47,29 @@ Variants {
// Connect to animation signal from service
Component.onCompleted: {
NotificationService.animateAndRemove.connect(function (notification, index) {
// Find the delegate and trigger its animation
if (notificationStack.children && notificationStack.children[index]) {
let delegate = notificationStack.children[index]
if (delegate && delegate.animateOut) {
delegate.animateOut()
// Prefer lookup by identity to avoid index mismatches
var delegate = null
if (notificationStack.children && notificationStack.children.length > 0) {
for (var i = 0; i < notificationStack.children.length; i++) {
var child = notificationStack.children[i]
if (child && child.model && child.model.rawNotification === notification) {
delegate = child
break
}
}
}
// Fallback to index if identity lookup failed
if (!delegate && notificationStack.children && notificationStack.children[index]) {
delegate = notificationStack.children[index]
}
if (delegate && delegate.animateOut) {
delegate.animateOut()
} else {
// As a last resort, force-remove without animation to avoid stuck popups
NotificationService.forceRemoveNotification(notification)
}
})
}

View file

@ -8,267 +8,180 @@ import qs.Commons
import qs.Services
import qs.Widgets
// Loader for Notification History panel
NLoader {
// Notification History panel
NPanel {
id: root
content: Component {
NPanel {
id: notificationPanel
panelWidth: 380 * scaling
panelHeight: 500 * scaling
panelAnchorRight: true
// Override hide function to animate first
function hide() {
// Start hide animation
notificationRect.scaleValue = 0.8
notificationRect.opacityValue = 0.0
panelContent: Rectangle {
id: notificationRect
color: Color.transparent
// Hide after animation completes
hideTimer.start()
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
Connections {
target: notificationPanel
ignoreUnknownSignals: true
function onDismissed() {
// Start hide animation
notificationRect.scaleValue = 0.8
notificationRect.opacityValue = 0.0
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
// Hide after animation completes
hideTimer.start()
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && notificationRect.opacityValue > 0) {
// Start hide animation
notificationRect.scaleValue = 0.8
notificationRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
notificationPanel.visible = false
notificationPanel.dismissed()
}
}
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Rectangle {
id: notificationRect
color: Color.mSurface
radius: Style.radiusL * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
width: 400 * scaling
height: 500 * scaling
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: Style.marginXS * scaling
anchors.rightMargin: Style.marginXS * scaling
clip: true
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
NIcon {
text: "notifications"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
NText {
text: "Notification History"
font.pointSize: Style.fontSizeL * scaling
font.bold: true
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "delete"
tooltipText: "Clear History"
sizeMultiplier: 0.8
onClicked: NotificationService.clearHistory()
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
root.close()
}
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
NDivider {
Layout.fillWidth: true
}
// Empty state when no notifications
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: NotificationService.historyModel.count === 0
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
anchors.centerIn: parent
spacing: Style.marginM * scaling
NIcon {
text: "notifications_off"
font.pointSize: Style.fontSizeXXXL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "No notifications"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Notifications will appear here when you receive them"
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
}
ListView {
id: notificationList
Layout.fillWidth: true
Layout.fillHeight: true
model: NotificationService.historyModel
spacing: Style.marginM * scaling
clip: true
boundsBehavior: Flickable.StopAtBounds
visible: NotificationService.historyModel.count > 0
delegate: Rectangle {
width: notificationList ? (notificationList.width - 20) : 380 * scaling
height: Math.max(80, notificationContent.height + 30)
radius: Style.radiusM * scaling
color: notificationMouseArea.containsMouse ? Color.mPrimary : Color.mSurfaceVariant
RowLayout {
Layout.fillWidth: true
anchors {
fill: parent
margins: Style.marginM * scaling
}
spacing: Style.marginM * scaling
NIcon {
text: "notifications"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
NText {
text: "Notification History"
font.pointSize: Style.fontSizeL * scaling
font.bold: true
color: Color.mOnSurface
// Notification content
Column {
id: notificationContent
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.marginXXS * scaling
NText {
text: (summary || "No summary").substring(0, 100)
font.pointSize: Style.fontSizeM * scaling
font.weight: Font.Medium
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
wrapMode: Text.Wrap
width: parent.width - 60
maximumLineCount: 2
elide: Text.ElideRight
}
NText {
text: (body || "").substring(0, 150)
font.pointSize: Style.fontSizeXS * scaling
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
wrapMode: Text.Wrap
width: parent.width - 60
maximumLineCount: 3
elide: Text.ElideRight
}
NText {
text: NotificationService.formatTimestamp(timestamp)
font.pointSize: Style.fontSizeXS * scaling
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
}
}
// Trash icon button
NIconButton {
icon: "delete"
tooltipText: "Clear History"
sizeMultiplier: 0.8
onClicked: NotificationService.clearHistory()
}
tooltipText: "Delete Notification"
sizeMultiplier: 0.7
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: {
notificationPanel.hide()
Logger.log("NotificationHistory", "Removing notification:", summary)
NotificationService.historyModel.remove(index)
NotificationService.saveHistory()
}
}
}
NDivider {}
// Empty state when no notifications
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: NotificationService.historyModel.count === 0
ColumnLayout {
anchors.centerIn: parent
spacing: Style.marginM * scaling
NIcon {
text: "notifications_off"
font.pointSize: Style.fontSizeXXXL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "No notifications"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "Notifications will appear here when you receive them"
font.pointSize: Style.fontSizeNormal * scaling
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
MouseArea {
id: notificationMouseArea
anchors.fill: parent
anchors.rightMargin: Style.marginL * 3 * scaling
hoverEnabled: true
}
}
ListView {
id: notificationList
Layout.fillWidth: true
Layout.fillHeight: true
model: NotificationService.historyModel
spacing: Style.marginM * scaling
clip: true
boundsBehavior: Flickable.StopAtBounds
visible: NotificationService.historyModel.count > 0
delegate: Rectangle {
width: notificationList ? (notificationList.width - 20) : 380 * scaling
height: Math.max(80, notificationContent.height + 30)
radius: Style.radiusM * scaling
color: notificationMouseArea.containsMouse ? Color.mPrimary : Color.mSurfaceVariant
RowLayout {
anchors {
fill: parent
margins: Style.marginM * scaling
}
spacing: Style.marginM * scaling
// Notification content
Column {
id: notificationContent
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.marginXXS * scaling
NText {
text: (summary || "No summary").substring(0, 100)
font.pointSize: Style.fontSizeM * scaling
font.weight: Font.Medium
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
wrapMode: Text.Wrap
width: parent.width - 60
maximumLineCount: 2
elide: Text.ElideRight
}
NText {
text: (body || "").substring(0, 150)
font.pointSize: Style.fontSizeXS * scaling
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
wrapMode: Text.Wrap
width: parent.width - 60
maximumLineCount: 3
elide: Text.ElideRight
}
NText {
text: NotificationService.formatTimestamp(timestamp)
font.pointSize: Style.fontSizeXS * scaling
color: notificationMouseArea.containsMouse ? Color.mSurface : Color.mOnSurface
}
}
// Trash icon button
NIconButton {
icon: "delete"
tooltipText: "Delete Notification"
sizeMultiplier: 0.7
onClicked: {
Logger.log("NotificationHistory", "Removing notification:", summary)
NotificationService.historyModel.remove(index)
NotificationService.saveHistory()
}
}
}
MouseArea {
id: notificationMouseArea
anchors.fill: parent
anchors.rightMargin: Style.marginL * 3 * scaling
hoverEnabled: true
// Remove the onClicked handler since we now have a dedicated delete button
}
}
ScrollBar.vertical: ScrollBar {
active: true
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
ScrollBar.vertical: ScrollBar {
active: true
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}

View file

@ -0,0 +1,345 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Wayland
import qs.Commons
import qs.Services
import qs.Widgets
NPanel {
id: root
panelWidth: 440 * scaling
panelHeight: 380 * scaling
panelAnchorCentered: true
// Timer properties
property int timerDuration: 9000 // 9 seconds
property string pendingAction: ""
property bool timerActive: false
property int timeRemaining: 0
// Cancel timer when panel is closing
onClosed: {
cancelTimer()
}
// Timer management
function startTimer(action) {
if (timerActive && pendingAction === action) {
// Second click - execute immediately
executeAction(action)
return
}
pendingAction = action
timeRemaining = timerDuration
timerActive = true
countdownTimer.start()
}
function cancelTimer() {
timerActive = false
pendingAction = ""
timeRemaining = 0
countdownTimer.stop()
}
function executeAction(action) {
// Stop timer but don't reset other properties yet
countdownTimer.stop()
switch (action) {
case "lock":
// Access lockScreen directly like IPCManager does
if (!lockScreen.active) {
lockScreen.active = true
}
break
case "suspend":
CompositorService.suspend()
break
case "reboot":
CompositorService.reboot()
break
case "logout":
CompositorService.logout()
break
case "shutdown":
CompositorService.shutdown()
break
}
// Reset timer state and close panel
cancelTimer()
root.close()
}
// Countdown timer
Timer {
id: countdownTimer
interval: 100
repeat: true
onTriggered: {
timeRemaining -= interval
if (timeRemaining <= 0) {
executeAction(pendingAction)
}
}
}
panelContent: Rectangle {
color: Color.transparent
ColumnLayout {
anchors.fill: parent
anchors.topMargin: Style.marginL * scaling
anchors.leftMargin: Style.marginL * scaling
anchors.rightMargin: Style.marginL * scaling
anchors.bottomMargin: Style.marginM * scaling
spacing: Style.marginS * scaling
// Header with title and close button
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 0.8 * scaling
NText {
text: timerActive ? `${pendingAction.charAt(0).toUpperCase() + pendingAction.slice(1)} in ${Math.ceil(
timeRemaining / 1000)} seconds...` : "Power Options"
font.weight: Style.fontWeightBold
font.pointSize: Style.fontSizeL * scaling
color: timerActive ? Color.mPrimary : Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
verticalAlignment: Text.AlignVCenter
}
Item {
Layout.fillWidth: true
}
NIconButton {
icon: timerActive ? "back_hand" : "close"
tooltipText: timerActive ? "Cancel Timer" : "Close"
Layout.alignment: Qt.AlignVCenter
colorBg: timerActive ? Color.applyOpacity(Color.mError, "20") : Color.transparent
colorFg: timerActive ? Color.mError : Color.mOnSurface
onClicked: {
if (timerActive) {
cancelTimer()
} else {
cancelTimer()
root.close()
}
}
}
}
// Power options
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
// Lock Screen
PowerButton {
Layout.fillWidth: true
icon: "lock_outline"
title: "Lock"
subtitle: "Lock your session"
onClicked: startTimer("lock")
pending: timerActive && pendingAction === "lock"
}
// Suspend
PowerButton {
Layout.fillWidth: true
icon: "bedtime"
title: "Suspend"
subtitle: "Put the system to sleep"
onClicked: startTimer("suspend")
pending: timerActive && pendingAction === "suspend"
}
// Reboot
PowerButton {
Layout.fillWidth: true
icon: "refresh"
title: "Reboot"
subtitle: "Restart the system"
onClicked: startTimer("reboot")
pending: timerActive && pendingAction === "reboot"
}
// Logout
PowerButton {
Layout.fillWidth: true
icon: "exit_to_app"
title: "Logout"
subtitle: "End your session"
onClicked: startTimer("logout")
pending: timerActive && pendingAction === "logout"
}
// Shutdown
PowerButton {
Layout.fillWidth: true
icon: "power_settings_new"
title: "Shutdown"
subtitle: "Turn off the system"
onClicked: startTimer("shutdown")
pending: timerActive && pendingAction === "shutdown"
isShutdown: true
}
}
}
}
// Custom power button component
component PowerButton: Rectangle {
id: buttonRoot
property string icon: ""
property string title: ""
property string subtitle: ""
property bool pending: false
property bool isShutdown: false
signal clicked
height: Style.baseWidgetSize * 1.6 * scaling
radius: Style.radiusS * scaling
color: {
if (pending)
return Color.applyOpacity(Color.mPrimary, "20")
if (mouseArea.containsMouse)
return Color.mTertiary
return Color.transparent
}
border.width: pending ? Math.max(Style.borderM * scaling) : 0
border.color: pending ? Color.mPrimary : Color.mOutline
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Item {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
// Icon on the left
NIcon {
id: iconElement
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: buttonRoot.icon
color: {
if (buttonRoot.pending)
return Color.mPrimary
if (buttonRoot.isShutdown && !mouseArea.containsMouse)
return Color.mError
if (mouseArea.containsMouse)
return Color.mOnTertiary
return Color.mOnSurface
}
font.pointSize: Style.fontSizeXXXL * scaling
width: Style.baseWidgetSize * 0.6 * scaling
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
// Text content in the middle
Column {
anchors.left: iconElement.right
anchors.right: pendingIndicator.visible ? pendingIndicator.left : parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginXL * scaling
anchors.rightMargin: pendingIndicator.visible ? Style.marginM * scaling : 0
spacing: 0
NText {
text: buttonRoot.title
font.weight: Style.fontWeightMedium
font.pointSize: Style.fontSizeM * scaling
color: {
if (buttonRoot.pending)
return Color.mPrimary
if (buttonRoot.isShutdown && !mouseArea.containsMouse)
return Color.mError
if (mouseArea.containsMouse)
return Color.mOnTertiary
return Color.mOnSurface
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
NText {
text: {
if (buttonRoot.pending) {
return "Click again to execute immediately"
}
return buttonRoot.subtitle
}
font.pointSize: Style.fontSizeXS * scaling
color: {
if (buttonRoot.pending)
return Color.mPrimary
if (buttonRoot.isShutdown && !mouseArea.containsMouse)
return Color.mError
if (mouseArea.containsMouse)
return Color.mOnTertiary
return Color.mOnSurfaceVariant
}
opacity: Style.opacityHeavy
wrapMode: Text.WordWrap
}
}
// Pending indicator on the right
Rectangle {
id: pendingIndicator
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 24 * scaling
height: 24 * scaling
radius: width * 0.5
color: Color.mPrimary
visible: buttonRoot.pending
NText {
anchors.centerIn: parent
text: Math.ceil(timeRemaining / 1000)
font.pointSize: Style.fontSizeS * scaling
font.weight: Style.fontWeightBold
color: Color.mOnPrimary
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: buttonRoot.clicked()
}
}
}

View file

@ -8,9 +8,13 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
NPanel {
id: root
panelWidth: Math.max(screen?.width * 0.5, 1280) * scaling
panelHeight: Math.max(screen?.height * 0.5, 720) * scaling
panelAnchorCentered: true
// Tabs enumeration, order is NOT relevant
enum Tab {
About,
@ -28,344 +32,264 @@ NLoader {
}
property int requestedTab: SettingsPanel.Tab.General
property int currentTabIndex: 0
content: Component {
NPanel {
id: panel
Component {
id: generalTab
Tabs.GeneralTab {}
}
Component {
id: barTab
Tabs.BarTab {}
}
Component {
id: audioTab
Tabs.AudioTab {}
}
Component {
id: brightnessTab
Tabs.BrightnessTab {}
}
Component {
id: displayTab
Tabs.DisplayTab {}
}
Component {
id: networkTab
Tabs.NetworkTab {}
}
Component {
id: timeWeatherTab
Tabs.TimeWeatherTab {}
}
Component {
id: colorSchemeTab
Tabs.ColorSchemeTab {}
}
Component {
id: wallpaperTab
Tabs.WallpaperTab {}
}
Component {
id: wallpaperSelectorTab
Tabs.WallpaperSelectorTab {}
}
Component {
id: screenRecorderTab
Tabs.ScreenRecorderTab {}
}
Component {
id: aboutTab
Tabs.AboutTab {}
}
property int currentTabIndex: 0
// Order *DOES* matter
property var tabsModel: [{
"id": SettingsPanel.Tab.General,
"label": "General",
"icon": "tune",
"source": generalTab
}, {
"id": SettingsPanel.Tab.Bar,
"label": "Bar",
"icon": "web_asset",
"source": barTab
}, {
"id": SettingsPanel.Tab.AudioService,
"label": "Audio",
"icon": "volume_up",
"source": audioTab
}, {
"id": SettingsPanel.Tab.Display,
"label": "Display",
"icon": "monitor",
"source": displayTab
}, {
"id": SettingsPanel.Tab.Network,
"label": "Network",
"icon": "lan",
"source": networkTab
}, {
"id": SettingsPanel.Tab.Brightness,
"label": "Brightness",
"icon": "brightness_6",
"source": brightnessTab
}, {
"id": SettingsPanel.Tab.TimeWeather,
"label": "Time & Weather",
"icon": "schedule",
"source": timeWeatherTab
}, {
"id": SettingsPanel.Tab.ColorScheme,
"label": "Color Scheme",
"icon": "palette",
"source": colorSchemeTab
}, {
"id": SettingsPanel.Tab.Wallpaper,
"label": "Wallpaper",
"icon": "image",
"source": wallpaperTab
}, {
"id": SettingsPanel.Tab.WallpaperSelector,
"label": "Wallpaper Selector",
"icon": "wallpaper_slideshow",
"source": wallpaperSelectorTab
}, {
"id": SettingsPanel.Tab.ScreenRecorder,
"label": "Screen Recorder",
"icon": "videocam",
"source": screenRecorderTab
}, {
"id": SettingsPanel.Tab.About,
"label": "About",
"icon": "info",
"source": aboutTab
}]
// Override hide function to animate first
function hide() {
// Start hide animation
bgRect.scaleValue = 0.8
bgRect.opacityValue = 0.0
// Hide after animation completes
hideTimer.start()
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: panel
function onDismissed() {
hide()
// When the panel opens, choose the appropriate tab
onOpened: {
var initialIndex = SettingsPanel.Tab.General
if (root.requestedTab !== null) {
for (var i = 0; i < root.tabsModel.length; i++) {
if (root.tabsModel[i].id === root.requestedTab) {
initialIndex = i
break
}
}
}
// Now that the UI is settled, set the current tab index.
root.currentTabIndex = initialIndex
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
panel.visible = false
panel.dismissed()
}
}
panelContent: Rectangle {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
color: Color.transparent
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
Component {
id: generalTab
Tabs.GeneralTab {}
}
Component {
id: barTab
Tabs.BarTab {}
}
Component {
id: audioTab
Tabs.AudioTab {}
}
Component {
id: brightnessTab
Tabs.BrightnessTab {}
}
Component {
id: displayTab
Tabs.DisplayTab {}
}
Component {
id: networkTab
Tabs.NetworkTab {}
}
Component {
id: timeWeatherTab
Tabs.TimeWeatherTab {}
}
Component {
id: colorSchemeTab
Tabs.ColorSchemeTab {}
}
Component {
id: wallpaperTab
Tabs.WallpaperTab {}
}
Component {
id: wallpaperSelectorTab
Tabs.WallpaperSelectorTab {}
}
Component {
id: screenRecorderTab
Tabs.ScreenRecorderTab {}
}
Component {
id: aboutTab
Tabs.AboutTab {}
}
// Order *DOES* matter
property var tabsModel: [{
"id": SettingsPanel.Tab.General,
"label": "General",
"icon": "tune",
"source": generalTab
}, {
"id": SettingsPanel.Tab.Bar,
"label": "Bar",
"icon": "web_asset",
"source": barTab
}, {
"id": SettingsPanel.Tab.AudioService,
"label": "Audio",
"icon": "volume_up",
"source": audioTab
}, {
"id": SettingsPanel.Tab.Display,
"label": "Display",
"icon": "monitor",
"source": displayTab
}, {
"id": SettingsPanel.Tab.Network,
"label": "Network",
"icon": "lan",
"source": networkTab
}, {
"id": SettingsPanel.Tab.Brightness,
"label": "Brightness",
"icon": "brightness_6",
"source": brightnessTab
}, {
"id": SettingsPanel.Tab.TimeWeather,
"label": "Time & Weather",
"icon": "schedule",
"source": timeWeatherTab
}, {
"id": SettingsPanel.Tab.ColorScheme,
"label": "Color Scheme",
"icon": "palette",
"source": colorSchemeTab
}, {
"id": SettingsPanel.Tab.Wallpaper,
"label": "Wallpaper",
"icon": "image",
"source": wallpaperTab
}, {
"id": SettingsPanel.Tab.WallpaperSelector,
"label": "Wallpaper Selector",
"icon": "wallpaper_slideshow",
"source": wallpaperSelectorTab
}, {
"id": SettingsPanel.Tab.ScreenRecorder,
"label": "Screen Recorder",
"icon": "videocam",
"source": screenRecorderTab
}, {
"id": SettingsPanel.Tab.About,
"label": "About",
"icon": "info",
"source": aboutTab
}]
Component.onCompleted: {
var initialIndex = 0
if (root.requestedTab !== null) {
for (var i = 0; i < panel.tabsModel.length; i++) {
if (panel.tabsModel[i].id === root.requestedTab) {
initialIndex = i
break
}
}
}
// Now that the UI is settled, set the current tab index.
panel.currentTabIndex = initialIndex
show()
}
onVisibleChanged: {
if (!visible && (bgRect.opacityValue > 0)) {
hide()
}
}
RowLayout {
anchors.fill: parent
spacing: Style.marginM * scaling
Rectangle {
id: bgRect
color: Color.mSurface
radius: Style.radiusL * scaling
id: sidebar
Layout.preferredWidth: Style.sliderWidth * 1.3 * scaling
Layout.fillHeight: true
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
layer.enabled: true
width: Math.max(screen.width * 0.5, 1280) * scaling
height: Math.max(screen.height * 0.5, 720) * scaling
anchors.centerIn: parent
radius: Style.radiusM * scaling
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
}
MouseArea {
Column {
anchors.fill: parent
}
anchors.margins: Style.marginS * scaling
spacing: Style.marginXS * 1.5 * scaling
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginL * scaling
Rectangle {
id: sidebar
Layout.preferredWidth: Style.sliderWidth * 1.3 * scaling
Layout.fillHeight: true
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
radius: Style.radiusM * scaling
Column {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginXS * 1.5 * scaling
Repeater {
id: sections
model: panel.tabsModel
delegate: Rectangle {
id: tabItem
width: parent.width
height: 32 * scaling
radius: Style.radiusS * scaling
color: selected ? Color.mPrimary : (tabItem.hovering ? Color.mTertiary : Color.transparent)
readonly property bool selected: index === currentTabIndex
property bool hovering: false
property color tabTextColor: selected ? Color.mOnPrimary : (tabItem.hovering ? Color.mOnTertiary : Color.mOnSurface)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginS * scaling
anchors.rightMargin: Style.marginS * scaling
spacing: Style.marginS * scaling
// Tab icon on the left side
NIcon {
text: modelData.icon
color: tabTextColor
font.pointSize: Style.fontSizeL * scaling
}
// Tab label on the left side
NText {
text: modelData.label
color: tabTextColor
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
Layout.fillWidth: true
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onEntered: tabItem.hovering = true
onExited: tabItem.hovering = false
onCanceled: tabItem.hovering = false
onClicked: currentTabIndex = index
}
Repeater {
id: sections
model: root.tabsModel
delegate: Rectangle {
id: tabItem
width: parent.width
height: 32 * scaling
radius: Style.radiusS * scaling
color: selected ? Color.mPrimary : (tabItem.hovering ? Color.mTertiary : Color.transparent)
readonly property bool selected: index === currentTabIndex
property bool hovering: false
property color tabTextColor: selected ? Color.mOnPrimary : (tabItem.hovering ? Color.mOnTertiary : Color.mOnSurface)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.marginS * scaling
anchors.rightMargin: Style.marginS * scaling
spacing: Style.marginS * scaling
// Tab icon on the left side
NIcon {
text: modelData.icon
color: tabTextColor
font.pointSize: Style.fontSizeL * scaling
}
// Tab label on the left side
NText {
text: modelData.label
color: tabTextColor
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
Layout.fillWidth: true
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onEntered: tabItem.hovering = true
onExited: tabItem.hovering = false
onCanceled: tabItem.hovering = false
onClicked: currentTabIndex = index
}
}
}
}
}
// Content
Rectangle {
id: contentPane
// Content
Rectangle {
id: contentPane
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.radiusM * scaling
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
clip: true
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginS * scaling
RowLayout {
id: headerRow
Layout.fillWidth: true
spacing: Style.marginS * scaling
// Tab label on the main right side
NText {
text: root.tabsModel[currentTabIndex].label
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: "Close"
Layout.alignment: Qt.AlignVCenter
onClicked: root.close()
}
}
NDivider {
Layout.fillWidth: true
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.radiusM * scaling
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
clip: true
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginS * scaling
Repeater {
model: root.tabsModel
RowLayout {
id: headerRow
Layout.fillWidth: true
spacing: Style.marginS * scaling
// Tab label on the main right side
NText {
text: panel.tabsModel[currentTabIndex].label
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: "Close"
Layout.alignment: Qt.AlignVCenter
onClicked: panel.hide()
}
onItemAdded: function (index, item) {
item.sourceComponent = root.tabsModel[index].source
}
NDivider {
Layout.fillWidth: true
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
Repeater {
model: panel.tabsModel
onItemAdded: function (index, item) {
item.sourceComponent = panel.tabsModel[index].source
}
delegate: Loader {
// All loaders will occupy the same space, stacked on top of each other.
anchors.fill: parent
visible: index === panel.currentTabIndex
// The loader is only active (and uses memory) when its page is visible.
active: visible
}
}
delegate: Loader {
// All loaders will occupy the same space, stacked on top of each other.
anchors.fill: parent
visible: index === root.currentTabIndex
// The loader is only active (and uses memory) when its page is visible.
active: visible
}
}
}

View file

@ -120,6 +120,27 @@ ColumnLayout {
}
}
}
// Volume Step Size
ColumnLayout {
spacing: Style.marginS * scaling
Layout.fillWidth: true
Layout.topMargin: Style.marginM * scaling
NSpinBox {
Layout.fillWidth: true
label: "Volume Step Size"
description: "Adjust the step size for volume changes (scroll wheel, keyboard shortcuts)."
minimum: 1
maximum: 25
value: Settings.data.audio.volumeStep
stepSize: 1
suffix: "%"
onValueChanged: {
Settings.data.audio.volumeStep = value
}
}
}
}
NDivider {

View file

@ -49,34 +49,17 @@ Item {
spacing: Style.marginS * scaling
Layout.fillWidth: true
NLabel {
NSpinBox {
Layout.fillWidth: true
label: "Brightness Step Size"
description: "Adjust the step size for brightness changes (scroll wheel, keyboard shortcuts)."
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
NSlider {
Layout.fillWidth: true
from: 1
to: 50
value: Settings.data.brightness.brightnessStep
stepSize: 1
onPressedChanged: {
if (!pressed) {
Settings.data.brightness.brightnessStep = value
}
}
}
NText {
text: Settings.data.brightness.brightnessStep + "%"
Layout.alignment: Qt.AlignVCenter
color: Color.mOnSurface
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
minimum: 1
maximum: 50
value: Settings.data.brightness.brightnessStep
stepSize: 1
suffix: "%"
onValueChanged: {
Settings.data.brightness.brightnessStep = value
}
}
}

View file

@ -157,6 +157,59 @@ ColumnLayout {
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginL * 2 * scaling
Layout.bottomMargin: Style.marginL * scaling
}
NText {
text: "Fonts"
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginS * scaling
}
// Font configuration section
ColumnLayout {
spacing: Style.marginS * scaling
Layout.fillWidth: true
NTextInput {
label: "Default Font"
description: "Main font used throughout the interface."
text: Settings.data.ui.fontDefault
placeholderText: "Roboto"
Layout.fillWidth: true
onEditingFinished: {
Settings.data.ui.fontDefault = text
}
}
NTextInput {
label: "Fixed Width Font"
description: "Monospace font used for terminal and code display."
text: Settings.data.ui.fontFixed
placeholderText: "DejaVu Sans Mono"
Layout.fillWidth: true
onEditingFinished: {
Settings.data.ui.fontFixed = text
}
}
NTextInput {
label: "Billboard Font"
description: "Large font used for clocks and prominent displays."
text: Settings.data.ui.fontBillboard
placeholderText: "Inter"
Layout.fillWidth: true
onEditingFinished: {
Settings.data.ui.fontBillboard = text
}
}
}
}
}
}

View file

@ -16,12 +16,13 @@ NBox {
// PowerProfiles service
property var powerProfiles: PowerProfiles
readonly property bool hasPP: powerProfiles.hasPerformanceProfile
property real spacing: 0
RowLayout {
id: powerRow
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: sidePanel.cardSpacing
spacing: spacing
Item {
Layout.fillWidth: true
}

View file

@ -61,7 +61,7 @@ NBox {
tooltipText: "Open Settings"
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.General
settingsPanel.isLoaded = !settingsPanel.isLoaded
settingsPanel.open(screen)
}
}
@ -70,18 +70,22 @@ NBox {
icon: "power_settings_new"
tooltipText: "Power Menu"
onClicked: {
powerMenu.show()
powerPanel.open(screen)
sidePanel.close()
}
}
NIconButton {
id: closeButton
icon: "close"
tooltipText: "Close Side Panel"
onClicked: {
sidePanel.close()
}
}
}
}
PowerMenu {
id: powerMenu
anchors.top: powerButton.bottom
anchors.right: powerButton.right
}
// ----------------------------------
// Uptime
Timer {

View file

@ -9,6 +9,9 @@ import qs.Widgets
// Utilities: record & wallpaper
NBox {
property real spacing: 0
Layout.fillWidth: true
Layout.preferredWidth: 1
implicitHeight: utilRow.implicitHeight + Style.marginM * 2 * scaling
@ -16,7 +19,7 @@ NBox {
id: utilRow
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: sidePanel.cardSpacing
spacing: spacing
Item {
Layout.fillWidth: true
}
@ -31,13 +34,24 @@ NBox {
}
}
// Idle Inhibitor
NIconButton {
icon: "coffee"
tooltipText: IdleInhibitorService.isInhibited ? "Disable Keep Awake" : "Enable Keep Awake"
colorBg: IdleInhibitorService.isInhibited ? Color.mPrimary : Color.mSurfaceVariant
colorFg: IdleInhibitorService.isInhibited ? Color.mOnPrimary : Color.mPrimary
onClicked: {
IdleInhibitorService.manualToggle()
}
}
// Wallpaper
NIconButton {
icon: "image"
tooltipText: "Open Wallpaper Selector"
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.WallpaperSelector
settingsPanel.isLoaded = true
settingsPanel.open(screen)
}
}

View file

@ -1,376 +0,0 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
import qs.Modules.LockScreen
NPanel {
id: powerMenu
visible: false
property var entriesCount: 5
property var entryHeight: Style.baseWidgetSize * scaling
// Anchors will be set by the parent component
function show() {
visible = true
}
function hide() {
visible = false
}
Rectangle {
width: 160 * scaling
height: (entryHeight * entriesCount) + (Style.marginS * entriesCount * scaling)
radius: Style.radiusM * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
color: Color.mSurface
visible: true
z: 9999
anchors.top: parent.top
anchors.right: parent.right
anchors.rightMargin: Style.marginL * scaling
anchors.topMargin: 86 * scaling
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
onClicked: {
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginXS * scaling
// --------------
// Lock
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: entryHeight
radius: Style.radiusS * scaling
color: lockButtonArea.containsMouse ? Color.mTertiary : Color.transparent
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginM * scaling
anchors.rightMargin: Style.marginM * scaling
Row {
id: lockRow
spacing: Style.marginS * scaling
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
NIcon {
text: "lock_outline"
color: lockButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
NText {
text: "Lock Screen"
color: lockButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
font.pointSize: Style.fontSizeS * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
}
}
MouseArea {
id: lockButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Logger.log("PowerMenu", "Lock screen requested")
// Lock the screen
lockScreen.isLoaded = true
powerMenu.visible = false
}
}
}
// --------------
// Suspend
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: entryHeight
radius: Style.radiusS * scaling
color: suspendButtonArea.containsMouse ? Color.mTertiary : Color.transparent
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginM * scaling
anchors.rightMargin: Style.marginM * scaling
Row {
id: suspendRow
spacing: Style.marginS * scaling
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
NIcon {
text: "bedtime"
color: suspendButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
NText {
text: "Suspend"
color: suspendButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
font.pointSize: Style.fontSizeS * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
}
}
MouseArea {
id: suspendButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
suspend()
powerMenu.visible = false
}
}
}
// --------------
// Reboot
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: entryHeight
radius: Style.radiusS * scaling
color: rebootButtonArea.containsMouse ? Color.mTertiary : Color.transparent
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginM * scaling
anchors.rightMargin: Style.marginM * scaling
Row {
id: rebootRow
spacing: Style.marginS * scaling
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
NIcon {
text: "refresh"
color: rebootButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
NText {
text: "Reboot"
color: rebootButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
font.pointSize: Style.fontSizeS * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
}
}
MouseArea {
id: rebootButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
reboot()
powerMenu.visible = false
}
}
}
// --------------
// Logout
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: entryHeight
radius: Style.radiusS * scaling
color: logoutButtonArea.containsMouse ? Color.mTertiary : Color.transparent
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginM * scaling
anchors.rightMargin: Style.marginM * scaling
Row {
id: logoutRow
spacing: Style.marginS * scaling
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
NIcon {
text: "exit_to_app"
color: logoutButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
NText {
text: "Logout"
color: logoutButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
font.pointSize: Style.fontSizeS * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
}
}
MouseArea {
id: logoutButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
logout()
powerMenu.visible = false
}
}
}
// --------------
// Shutdown
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: entryHeight
radius: Style.radiusS * scaling
color: shutdownButtonArea.containsMouse ? Color.mTertiary : Color.transparent
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginM * scaling
anchors.rightMargin: Style.marginM * scaling
Row {
id: shutdownRow
spacing: Style.marginS * scaling
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
NIcon {
text: "power_settings_new"
color: shutdownButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
NText {
text: "Shutdown"
color: shutdownButtonArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface
font.pointSize: Style.fontSizeS * scaling
verticalAlignment: Text.AlignVCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 1 * scaling
}
}
}
MouseArea {
id: shutdownButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
shutdown()
powerMenu.visible = false
}
}
}
}
}
// ----------------------------------
// System functions
function logout() {
CompositorService.logout()
}
function suspend() {
suspendProcess.running = true
}
function shutdown() {
shutdownProcess.running = true
}
function reboot() {
rebootProcess.running = true
}
Process {
id: shutdownProcess
command: ["shutdown", "-h", "now"]
running: false
}
Process {
id: rebootProcess
command: ["reboot"]
running: false
}
Process {
id: suspendProcess
command: ["systemctl", "suspend"]
running: false
}
Process {
id: logoutProcess
command: ["loginctl", "terminate-user", Quickshell.env("USER")]
running: false
}
// LockScreen instance
LockScreen {
id: lockScreen
}
}

View file

@ -7,205 +7,76 @@ import qs.Commons
import qs.Services
import qs.Widgets
NLoader {
id: root
NPanel {
id: panel
// X coordinate on screen (in pixels) where the panel should align its center.
// Set via openAt(x) from the bar button.
property real anchorX: 0
// Target screen to open on
property var targetScreen: null
panelWidth: 460 * scaling
panelHeight: 700 * scaling
panelAnchorRight: true
function openAt(x, screen) {
anchorX = x
targetScreen = screen
isLoaded = true
// If the panel is already instantiated, update immediately
if (item) {
if (item.anchorX !== undefined)
item.anchorX = anchorX
if (item.screen !== undefined)
item.screen = targetScreen
}
}
panelContent: Item {
id: content
content: Component {
NPanel {
id: sidePanel
property real cardSpacing: Style.marginL * scaling
// Single source of truth for spacing between cards (both axes)
property real cardSpacing: Style.marginL * scaling
// X coordinate from the bar to align this panel under
property real anchorX: root.anchorX
// Ensure this panel attaches to the intended screen
screen: root.targetScreen
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: content.cardSpacing
implicitHeight: layout.implicitHeight
// Override hide function to animate first
function hide() {
// Start hide animation
panelBackground.scaleValue = 0.8
panelBackground.opacityValue = 0.0
// Layout content (not vertically anchored so implicitHeight is valid)
ColumnLayout {
id: layout
// Use the same spacing value horizontally and vertically
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: content.cardSpacing
// Hide after animation completes
hideTimer.start()
// Cards (consistent inter-card spacing via ColumnLayout spacing)
ProfileCard {// Layout.topMargin: 0
// Layout.bottomMargin: 0
}
WeatherCard {// Layout.topMargin: 0
// Layout.bottomMargin: 0
}
// Connect to NPanel's dismissed signal to handle external close events
Connections {
target: sidePanel
function onDismissed() {
// Start hide animation
panelBackground.scaleValue = 0.8
panelBackground.opacityValue = 0.0
// Middle section: media + stats column
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 0
Layout.bottomMargin: 0
spacing: content.cardSpacing
// Hide after animation completes
hideTimer.start()
// Media card
MediaCard {
id: mediaCard
Layout.fillWidth: true
implicitHeight: statsCard.implicitHeight
}
// System monitors combined in one card
SystemMonitorCard {
id: statsCard
}
}
// Also handle visibility changes from external sources
onVisibleChanged: {
if (!visible && panelBackground.opacityValue > 0) {
// Start hide animation
panelBackground.scaleValue = 0.8
panelBackground.opacityValue = 0.0
// Bottom actions (two grouped rows of round buttons)
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 0
Layout.bottomMargin: 0
spacing: content.cardSpacing
// Hide after animation completes
hideTimer.start()
}
}
// Ensure panel shows itself once created
Component.onCompleted: show()
// Inline helpers moved to dedicated widgets: NCard and NCircleStat
Rectangle {
id: panelBackground
color: Color.mSurface
radius: Style.radiusL * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
layer.enabled: true
width: 460 * scaling
property real innerMargin: sidePanel.cardSpacing
// Height scales to content plus vertical padding
height: content.implicitHeight + innerMargin * 2
// Place the panel relative to the bar based on its position
y: Settings.data.bar.position === "top" ? Style.marginS * scaling : undefined
anchors {
bottom: Settings.data.bar.position === "bottom" ? parent.bottom : undefined
bottomMargin: Settings.data.bar.position === "bottom" ? Style.barHeight * scaling + Style.marginS * scaling : undefined
}
// Center horizontally under the anchorX, clamped to the screen bounds
x: Math.max(Style.marginS * scaling, Math.min(parent.width - width - Style.marginS * scaling,
Math.round(anchorX - width / 2)))
// Animation properties
property real scaleValue: 0.8
property real opacityValue: 0.0
scale: scaleValue
opacity: opacityValue
// Animate in when component is completed
Component.onCompleted: {
scaleValue = 1.0
opacityValue = 1.0
// Power Profiles switcher
PowerProfilesCard {
spacing: content.cardSpacing
}
// Timer to hide panel after animation
Timer {
id: hideTimer
interval: Style.animationSlow
repeat: false
onTriggered: {
sidePanel.visible = false
sidePanel.dismissed()
}
}
// Prevent closing when clicking in the panel bg
MouseArea {
anchors.fill: parent
}
// Animation behaviors
Behavior on scale {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.OutExpo
}
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutQuad
}
}
// Content wrapper to ensure childrenRect drives implicit height
Item {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: panelBackground.innerMargin
implicitHeight: layout.implicitHeight
// Layout content (not vertically anchored so implicitHeight is valid)
ColumnLayout {
id: layout
// Use the same spacing value horizontally and vertically
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: sidePanel.cardSpacing
// Cards (consistent inter-card spacing via ColumnLayout spacing)
ProfileCard {
Layout.topMargin: 0
Layout.bottomMargin: 0
}
WeatherCard {
Layout.topMargin: 0
Layout.bottomMargin: 0
}
// Middle section: media + stats column
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 0
Layout.bottomMargin: 0
spacing: sidePanel.cardSpacing
// Media card
MediaCard {
id: mediaCard
Layout.fillWidth: true
implicitHeight: statsCard.implicitHeight
}
// System monitors combined in one card
SystemMonitorCard {
id: statsCard
}
}
// Bottom actions (two grouped rows of round buttons)
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 0
Layout.bottomMargin: 0
spacing: sidePanel.cardSpacing
// Power Profiles switcher
PowerProfilesCard {}
// Utilities buttons
UtilitiesCard {}
}
}
// Utilities buttons
UtilitiesCard {
spacing: content.cardSpacing
}
}
}