Merge branch 'noctalia-dev:main' into fix/heuristic-lookup

This commit is contained in:
Kainoa Kanter 2025-08-24 21:34:47 -07:00 committed by GitHub
commit 81182aa65b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 2680 additions and 1646 deletions

View file

@ -1,9 +1,10 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
pragma Singleton
Singleton {
id: root
@ -116,9 +117,7 @@ Singleton {
id: adapter
// bar
property JsonObject bar
bar: JsonObject {
property JsonObject bar: JsonObject {
property string position: "top" // Possible values: "top", "bottom"
property bool showActiveWindowIcon: true
property bool alwaysShowBatteryPercentage: false
@ -130,14 +129,12 @@ Singleton {
widgets: JsonObject {
property list<string> left: ["SystemMonitor", "ActiveWindow", "MediaMini"]
property list<string> center: ["Workspace"]
property list<string> right: ["ScreenRecorderIndicator", "Tray", "NotificationHistory", "WiFi", "Bluetooth", "Battery", "Volume", "Brightness", "Clock", "SidePanelToggle"]
property list<string> right: ["ScreenRecorderIndicator", "Tray", "ArchUpdater", "NotificationHistory", "WiFi", "Bluetooth", "Battery", "Volume", "Brightness", "Clock", "SidePanelToggle"]
}
}
// general
property JsonObject general
general: JsonObject {
property JsonObject general: JsonObject {
property string avatarImage: defaultAvatar
property bool dimDesktop: false
property bool showScreenCorners: false
@ -145,9 +142,7 @@ Singleton {
}
// location
property JsonObject location
location: JsonObject {
property JsonObject location: JsonObject {
property string name: "Tokyo"
property bool useFahrenheit: false
property bool reverseDayMonth: false
@ -156,9 +151,7 @@ Singleton {
}
// screen recorder
property JsonObject screenRecorder
screenRecorder: JsonObject {
property JsonObject screenRecorder: JsonObject {
property string directory: "~/Videos"
property int frameRate: 60
property string audioCodec: "opus"
@ -171,9 +164,7 @@ Singleton {
}
// wallpaper
property JsonObject wallpaper
wallpaper: JsonObject {
property JsonObject wallpaper: JsonObject {
property string directory: "/usr/share/wallpapers"
property string current: ""
property bool isRandom: false
@ -194,9 +185,7 @@ Singleton {
}
// applauncher
property JsonObject appLauncher
appLauncher: JsonObject {
property JsonObject appLauncher: JsonObject {
// When disabled, Launcher hides clipboard command and ignores cliphist
property bool enableClipboardHistory: true
// Position: center, top_left, top_right, bottom_left, bottom_right
@ -205,43 +194,34 @@ Singleton {
}
// dock
property JsonObject dock
dock: JsonObject {
property JsonObject dock: JsonObject {
property bool autoHide: false
property bool exclusive: false
property list<string> monitors: []
}
// network
property JsonObject network
network: JsonObject {
property JsonObject network: JsonObject {
property bool wifiEnabled: true
property bool bluetoothEnabled: true
}
// notifications
property JsonObject notifications
notifications: JsonObject {
property JsonObject notifications: JsonObject {
property list<string> monitors: []
}
// audio
property JsonObject audio
audio: JsonObject {
property JsonObject audio: JsonObject {
property bool showMiniplayerAlbumArt: false
property bool showMiniplayerCava: false
property string visualizerType: "linear"
property int volumeStep: 5
property int cavaFrameRate: 60
}
// ui
property JsonObject ui
ui: JsonObject {
property JsonObject ui: JsonObject {
property string fontDefault: "Roboto" // Default font for all text
property string fontFixed: "DejaVu Sans Mono" // Fixed width font for terminal
property string fontBillboard: "Inter" // Large bold font for clocks and prominent displays
@ -259,15 +239,11 @@ Singleton {
}
// brightness
property JsonObject brightness
brightness: JsonObject {
property JsonObject brightness: JsonObject {
property int brightnessStep: 5
}
property JsonObject colorSchemes
colorSchemes: JsonObject {
property JsonObject colorSchemes: JsonObject {
property bool useWallpaperColors: false
property string predefinedScheme: ""
property bool darkMode: true

View file

@ -1,88 +0,0 @@
import QtQuick
import qs.Commons
QtObject {
id: root
// Signal emitted when widget loading status changes
signal widgetLoaded(string widgetName)
signal widgetFailed(string widgetName, string error)
signal loadingComplete(int total, int loaded, int failed)
// Properties to track loading status
property int totalWidgets: 0
property int loadedWidgets: 0
property int failedWidgets: 0
// Auto-discover widget components
function getWidgetComponent(widgetName) {
if (!widgetName || widgetName.trim() === "") {
return null
}
const widgetPath = `../Modules/Bar/Widgets/${widgetName}.qml`
// Try to load the widget directly from file
const component = Qt.createComponent(widgetPath)
if (component.status === Component.Ready) {
return component
}
const errorMsg = `Failed to load ${widgetName}.qml widget, status: ${component.status}, error: ${component.errorString(
)}`
Logger.error("WidgetLoader", errorMsg)
return null
}
// Initialize loading tracking
function initializeLoading(widgetList) {
totalWidgets = widgetList.length
loadedWidgets = 0
failedWidgets = 0
}
// Track widget loading success
function onWidgetLoaded(widgetName) {
loadedWidgets++
widgetLoaded(widgetName)
if (loadedWidgets + failedWidgets === totalWidgets) {
Logger.log("WidgetLoader", `Loaded ${loadedWidgets} widgets`)
loadingComplete(totalWidgets, loadedWidgets, failedWidgets)
}
}
// Track widget loading failure
function onWidgetFailed(widgetName, error) {
failedWidgets++
widgetFailed(widgetName, error)
if (loadedWidgets + failedWidgets === totalWidgets) {
loadingComplete(totalWidgets, loadedWidgets, failedWidgets)
}
}
// This is where you should add your Modules/Bar/Widgets/
// so it gets registered in the BarTab
function discoverAvailableWidgets() {
const widgetFiles = ["ActiveWindow", "Battery", "Bluetooth", "Brightness", "Clock", "MediaMini", "NotificationHistory", "ScreenRecorderIndicator", "SidePanelToggle", "SystemMonitor", "Tray", "Volume", "WiFi", "Workspace"]
const availableWidgets = []
widgetFiles.forEach(widgetName => {
// Test if the widget can be loaded
const component = getWidgetComponent(widgetName)
if (component) {
availableWidgets.push({
"key": widgetName,
"name": widgetName
})
}
})
// Sort alphabetically
availableWidgets.sort((a, b) => a.name.localeCompare(b.name))
return availableWidgets
}
}

View file

@ -0,0 +1,230 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
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
// Auto-refresh when service updates
Connections {
target: ArchUpdaterService
function onUpdatePackagesChanged() {
// Force UI update when packages change
if (root.visible) {
// Small delay to ensure data is fully updated
Qt.callLater(() => {
// Force a UI update by triggering a property change
ArchUpdaterService.updatePackages = ArchUpdaterService.updatePackages
}, 100)
}
}
}
panelContent: Rectangle {
color: Color.mSurface
radius: Style.radiusL * scaling
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
// Header
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM * scaling
NIcon {
text: "system_update"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mPrimary
}
Text {
text: "System Updates"
font.pointSize: Style.fontSizeL * scaling
font.family: Settings.data.ui.fontDefault
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: "Close"
sizeMultiplier: 0.8
onClicked: root.close()
}
}
NDivider {
Layout.fillWidth: true
}
// Update summary
Text {
text: ArchUpdaterService.updatePackages.length + " package" + (ArchUpdaterService.updatePackages.length
!== 1 ? "s" : "") + " can be updated"
font.pointSize: Style.fontSizeL * scaling
font.family: Settings.data.ui.fontDefault
font.weight: Style.fontWeightMedium
color: Color.mOnSurface
Layout.fillWidth: true
}
// Package selection info
Text {
text: ArchUpdaterService.selectedPackagesCount + " of " + ArchUpdaterService.updatePackages.length + " packages selected"
font.pointSize: Style.fontSizeS * scaling
font.family: Settings.data.ui.fontDefault
color: Color.mOnSurfaceVariant
Layout.fillWidth: true
}
// Package list
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Color.mSurfaceVariant
radius: Style.radiusM * scaling
ListView {
id: packageListView
anchors.fill: parent
anchors.margins: Style.marginS * scaling
clip: true
model: ArchUpdaterService.updatePackages
spacing: Style.marginXS * scaling
delegate: Rectangle {
width: packageListView.width
height: 50 * scaling
color: Color.transparent
radius: Style.radiusS * scaling
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: Style.marginS * scaling
// Checkbox for selection
NIconButton {
id: checkbox
icon: "check_box_outline_blank"
onClicked: {
const isSelected = ArchUpdaterService.isPackageSelected(modelData.name)
if (isSelected) {
ArchUpdaterService.togglePackageSelection(modelData.name)
icon = "check_box_outline_blank"
colorFg = Color.mOnSurfaceVariant
} else {
ArchUpdaterService.togglePackageSelection(modelData.name)
icon = "check_box"
colorFg = Color.mPrimary
}
}
colorBg: Color.transparent
colorFg: Color.mOnSurfaceVariant
Layout.preferredWidth: 30 * scaling
Layout.preferredHeight: 30 * scaling
Component.onCompleted: {
// Set initial state
if (ArchUpdaterService.isPackageSelected(modelData.name)) {
icon = "check_box"
colorFg = Color.mPrimary
}
}
}
// Package info
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS * scaling
Text {
text: modelData.name
font.pointSize: Style.fontSizeM * scaling
font.family: Settings.data.ui.fontDefault
font.weight: Style.fontWeightMedium
color: Color.mOnSurface
Layout.fillWidth: true
}
Text {
text: modelData.oldVersion + " → " + modelData.newVersion
font.pointSize: Style.fontSizeS * scaling
font.family: Settings.data.ui.fontDefault
color: Color.mOnSurfaceVariant
Layout.fillWidth: true
}
}
}
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
}
}
// Action buttons
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS * scaling
NIconButton {
icon: "refresh"
tooltipText: "Check for updates"
onClicked: {
ArchUpdaterService.doPoll()
}
colorBg: Color.mSurfaceVariant
colorFg: Color.mOnSurface
Layout.fillWidth: true
Layout.preferredHeight: 35 * scaling
}
NIconButton {
icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "system_update"
tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update all packages"
enabled: !ArchUpdaterService.updateInProgress
onClicked: {
ArchUpdaterService.runUpdate()
root.close()
}
colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : Color.mPrimary
colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : Color.mOnPrimary
Layout.fillWidth: true
Layout.preferredHeight: 35 * scaling
}
NIconButton {
icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "settings"
tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update selected packages"
enabled: !ArchUpdaterService.updateInProgress && ArchUpdaterService.selectedPackagesCount > 0
onClicked: {
if (ArchUpdaterService.selectedPackagesCount > 0) {
ArchUpdaterService.runSelectiveUpdate()
root.close()
}
}
colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : (ArchUpdaterService.selectedPackagesCount
> 0 ? Color.mSecondary : Color.mSurfaceVariant)
colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : (ArchUpdaterService.selectedPackagesCount
> 0 ? Color.mOnSecondary : Color.mOnSurfaceVariant)
Layout.fillWidth: true
Layout.preferredHeight: 35 * scaling
}
}
}
}
}

View file

@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.UPower
import qs.Commons
import qs.Services
import qs.Widgets
@ -47,6 +48,7 @@ Variants {
layer.enabled: true
}
// ------------------------------
// Left Section - Dynamic Widgets
Row {
id: leftSection
@ -60,21 +62,19 @@ Variants {
Repeater {
model: Settings.data.bar.widgets.left
delegate: Loader {
id: leftWidgetLoader
sourceComponent: widgetLoader.getWidgetComponent(modelData)
active: true
anchors.verticalCenter: parent.verticalCenter
onStatusChanged: {
if (status === Loader.Error) {
widgetLoader.onWidgetFailed(modelData, "Loader error")
} else if (status === Loader.Ready) {
widgetLoader.onWidgetLoaded(modelData)
sourceComponent: NWidgetLoader {
widgetName: modelData
widgetProps: {
"screen": screen
}
}
anchors.verticalCenter: parent.verticalCenter
}
}
}
// ------------------------------
// Center Section - Dynamic Widgets
Row {
id: centerSection
@ -87,21 +87,19 @@ Variants {
Repeater {
model: Settings.data.bar.widgets.center
delegate: Loader {
id: centerWidgetLoader
sourceComponent: widgetLoader.getWidgetComponent(modelData)
active: true
anchors.verticalCenter: parent.verticalCenter
onStatusChanged: {
if (status === Loader.Error) {
widgetLoader.onWidgetFailed(modelData, "Loader error")
} else if (status === Loader.Ready) {
widgetLoader.onWidgetLoaded(modelData)
sourceComponent: NWidgetLoader {
widgetName: modelData
widgetProps: {
"screen": screen
}
}
anchors.verticalCenter: parent.verticalCenter
}
}
}
// ------------------------------
// Right Section - Dynamic Widgets
Row {
id: rightSection
@ -115,35 +113,17 @@ Variants {
Repeater {
model: Settings.data.bar.widgets.right
delegate: Loader {
id: rightWidgetLoader
sourceComponent: widgetLoader.getWidgetComponent(modelData)
active: true
anchors.verticalCenter: parent.verticalCenter
onStatusChanged: {
if (status === Loader.Error) {
widgetLoader.onWidgetFailed(modelData, "Loader error")
} else if (status === Loader.Ready) {
widgetLoader.onWidgetLoaded(modelData)
sourceComponent: NWidgetLoader {
widgetName: modelData
widgetProps: {
"screen": screen
}
}
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
// Widget loader instance
WidgetLoader {
id: widgetLoader
onWidgetFailed: function (widgetName, error) {
Logger.error("Bar", `Widget failed: ${widgetName} - ${error}`)
}
}
// Initialize widget loading tracking
Component.onCompleted: {
const allWidgets = [...Settings.data.bar.widgets.left, ...Settings.data.bar.widgets.center, ...Settings.data.bar.widgets.right]
widgetLoader.initializeLoading(allWidgets)
}
}
}

View file

@ -9,17 +9,20 @@ import qs.Widgets
Row {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
property bool showingFullTitle: false
property int lastWindowIndex: -1
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
visible: getTitle() !== ""
property bool showingFullTitle: false
property int lastWindowIndex: -1
// Timer to hide full title after window switch
Timer {
id: fullTitleTimer
interval: Style.animationSlow * 4 // Show full title for 2 seconds
interval: 2000
repeat: false
onTriggered: {
showingFullTitle = false
@ -40,8 +43,9 @@ Row {
}
function getTitle() {
const focusedWindow = CompositorService.getFocusedWindow()
return focusedWindow ? (focusedWindow.title || focusedWindow.appId || "") : ""
// Use the service's focusedWindowTitle property which is updated immediately
// when WindowOpenedOrChanged events are received
return CompositorService.focusedWindowTitle !== "(No active window)" ? CompositorService.focusedWindowTitle : ""
}
function getAppIcon() {
@ -62,6 +66,7 @@ Row {
Rectangle {
// Let the Rectangle size itself based on its content (the Row)
visible: root.visible
width: row.width + Style.marginM * scaling * 2
height: Math.round(Style.capsuleHeight * scaling)
radius: Math.round(Style.radiusM * scaling)
@ -100,10 +105,10 @@ Row {
NText {
id: titleText
// If hovered or just switched window, show up to 300 pixels
// If hovered or just switched window, show up to 400 pixels
// If not hovered show up to 150 pixels
width: (showingFullTitle || mouseArea.containsMouse) ? Math.min(fullTitleMetrics.contentWidth,
300 * scaling) : Math.min(
400 * scaling) : Math.min(
fullTitleMetrics.contentWidth, 150 * scaling)
text: getTitle()
font.pointSize: Style.fontSizeS * scaling

View file

@ -0,0 +1,75 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButton {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
sizeMultiplier: 0.8
colorBg: Color.mSurfaceVariant
colorFg: Color.mOnSurface
colorBorder: Color.transparent
colorBorderHover: Color.transparent
// Enhanced icon states with better visual feedback
icon: {
if (ArchUpdaterService.busy)
return "sync"
if (ArchUpdaterService.updatePackages.length > 0) {
// Show different icons based on update count
const count = ArchUpdaterService.updatePackages.length
if (count > 50)
return "system_update_alt" // Many updates
if (count > 10)
return "system_update" // Moderate updates
return "system_update" // Few updates
}
return "task_alt"
}
// Enhanced tooltip with more information
tooltipText: {
if (ArchUpdaterService.busy)
return "Checking for updates…"
var count = ArchUpdaterService.updatePackages.length
if (count === 0)
return "System is up to date ✓"
var header = count === 1 ? "One package can be upgraded:" : (count + " packages can be upgraded:")
var list = ArchUpdaterService.updatePackages || []
var s = ""
var limit = Math.min(list.length, 8)
// Reduced to 8 for better readability
for (var i = 0; i < limit; ++i) {
var p = list[i]
s += (i ? "\n" : "") + (p.name + ": " + p.oldVersion + " → " + p.newVersion)
}
if (list.length > 8)
s += "\n… and " + (list.length - 8) + " more"
return header + "\n\n" + s + "\n\nClick to update system"
}
// Enhanced click behavior with confirmation
onClicked: {
if (ArchUpdaterService.busy)
return
if (ArchUpdaterService.updatePackages.length > 0) {
// Show confirmation dialog for updates
PanelService.getPanel("archUpdaterPanel").toggle(screen)
} else {
// Just refresh if no updates available
ArchUpdaterService.doPoll()
}
}
}

View file

@ -6,89 +6,100 @@ import qs.Commons
import qs.Services
import qs.Widgets
NPill {
Item {
id: root
// Test mode
property bool testMode: false
property int testPercent: 49
property bool testCharging: false
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
property var battery: UPower.displayDevice
property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent)
property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0)
property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false)
property bool show: isReady && percent > 0
implicitWidth: pill.width
implicitHeight: pill.height
// Choose icon based on charge and charging state
function batteryIcon() {
NPill {
id: pill
if (charging)
return "battery_android_bolt"
// Test mode
property bool testMode: false
property int testPercent: 49
property bool testCharging: false
if (percent >= 95)
return "battery_android_full"
property var battery: UPower.displayDevice
property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent)
property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0)
property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false)
// Hardcoded battery symbols
if (percent >= 85)
return "battery_android_6"
if (percent >= 70)
return "battery_android_5"
if (percent >= 55)
return "battery_android_4"
if (percent >= 40)
return "battery_android_3"
if (percent >= 25)
return "battery_android_2"
if (percent >= 10)
return "battery_android_1"
if (percent >= 0)
return "battery_android_0"
}
// Choose icon based on charge and charging state
function batteryIcon() {
visible: testMode || (isReady && battery.isLaptopBattery)
if (!isReady || !battery.isLaptopBattery)
return "battery_android_alert"
icon: root.batteryIcon()
text: Math.round(root.percent) + "%"
textColor: charging ? Color.mPrimary : Color.mOnSurface
forceShown: Settings.data.bar.alwaysShowBatteryPercentage
tooltipText: {
let lines = []
if (charging)
return "battery_android_bolt"
if (testMode) {
lines.push("Time left: " + Time.formatVagueHumanReadableDuration(12345))
if (percent >= 95)
return "battery_android_full"
// Hardcoded battery symbols
if (percent >= 85)
return "battery_android_6"
if (percent >= 70)
return "battery_android_5"
if (percent >= 55)
return "battery_android_4"
if (percent >= 40)
return "battery_android_3"
if (percent >= 25)
return "battery_android_2"
if (percent >= 10)
return "battery_android_1"
if (percent >= 0)
return "battery_android_0"
}
icon: batteryIcon()
text: (isReady && battery.isLaptopBattery) ? Math.round(percent) + "%" : "-"
textColor: charging ? Color.mPrimary : Color.mOnSurface
forceOpen: isReady && battery.isLaptopBattery && Settings.data.bar.alwaysShowBatteryPercentage
disableOpen: (!isReady || !battery.isLaptopBattery)
tooltipText: {
let lines = []
if (testMode) {
lines.push("Time Left: " + Time.formatVagueHumanReadableDuration(12345))
return lines.join("\n")
}
if (!isReady || !battery.isLaptopBattery) {
return "No Battery Detected"
}
if (battery.timeToEmpty > 0) {
lines.push("Time Left: " + Time.formatVagueHumanReadableDuration(battery.timeToEmpty))
}
if (battery.timeToFull > 0) {
lines.push("Time Until Full: " + Time.formatVagueHumanReadableDuration(battery.timeToFull))
}
if (battery.changeRate !== undefined) {
const rate = battery.changeRate
if (rate > 0) {
lines.push(charging ? "Charging Rate: " + rate.toFixed(2) + " W" : "Discharging Rate: " + rate.toFixed(
2) + " W")
} else if (rate < 0) {
lines.push("Discharging Rate: " + Math.abs(rate).toFixed(2) + " W")
} else {
lines.push("Estimating...")
}
} else {
lines.push(charging ? "Charging" : "Discharging")
}
if (battery.healthPercentage !== undefined && battery.healthPercentage > 0) {
lines.push("Health: " + Math.round(battery.healthPercentage) + "%")
}
return lines.join("\n")
}
if (!root.isReady) {
return ""
}
if (root.battery.timeToEmpty > 0) {
lines.push("Time left: " + Time.formatVagueHumanReadableDuration(root.battery.timeToEmpty))
}
if (root.battery.timeToFull > 0) {
lines.push("Time until full: " + Time.formatVagueHumanReadableDuration(root.battery.timeToFull))
}
if (root.battery.changeRate !== undefined) {
const rate = root.battery.changeRate
if (rate > 0) {
lines.push(root.charging ? "Charging rate: " + rate.toFixed(2) + " W" : "Discharging rate: " + rate.toFixed(
2) + " W")
} else if (rate < 0) {
lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W")
} else {
lines.push("Estimating...")
}
} else {
lines.push(root.charging ? "Charging" : "Discharging")
}
if (root.battery.healthPercentage !== undefined && root.battery.healthPercentage > 0) {
lines.push("Health: " + Math.round(root.battery.healthPercentage) + "%")
}
return lines.join("\n")
}
}

View file

@ -10,8 +10,11 @@ import qs.Widgets
NIconButton {
id: root
sizeMultiplier: 0.8
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
visible: Settings.data.network.bluetoothEnabled
sizeMultiplier: 0.8
colorBg: Color.mSurfaceVariant
colorFg: Color.mOnSurface
colorBorder: Color.transparent
@ -28,7 +31,5 @@ NIconButton {
}
}
tooltipText: "Bluetooth Devices"
onClicked: {
bluetoothPanel.toggle(screen)
}
onClicked: PanelService.getPanel("bluetoothPanel")?.toggle(screen)
}

View file

@ -8,13 +8,16 @@ import qs.Widgets
Item {
id: root
width: pill.width
height: pill.height
visible: getMonitor() !== null
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
// Used to avoid opening the pill on Quickshell startup
property bool firstBrightnessReceived: false
implicitWidth: pill.width
implicitHeight: pill.height
visible: getMonitor() !== null
function getMonitor() {
return BrightnessService.getMonitorForScreen(screen) || null
}

View file

@ -1,16 +1,21 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
// Clock Icon with attached calendar
Rectangle {
id: root
width: clock.width + Style.marginM * 2 * scaling
height: Math.round(Style.capsuleHeight * scaling)
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
implicitWidth: clock.width + Style.marginM * 2 * scaling
implicitHeight: Math.round(Style.capsuleHeight * scaling)
radius: Math.round(Style.radiusM * scaling)
color: Color.mSurfaceVariant
// Clock Icon with attached calendar
NClock {
id: clock
anchors.verticalCenter: parent.verticalCenter
@ -24,7 +29,7 @@ Rectangle {
}
onEntered: {
if (!calendarPanel.active) {
if (!PanelService.getPanel("calendarPanel")?.active) {
tooltip.show()
}
}
@ -33,7 +38,7 @@ Rectangle {
}
onClicked: {
tooltip.hide()
calendarPanel.toggle(screen)
PanelService.getPanel("calendarPanel")?.toggle(screen)
}
}
}

View file

@ -0,0 +1,36 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import qs.Commons
import qs.Services
import qs.Widgets
Row {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
// Use the shared service for keyboard layout
property string currentLayout: KeyboardLayoutService.currentLayout
width: pill.width
height: pill.height
NPill {
id: pill
icon: "keyboard_alt"
iconCircleColor: Color.mPrimary
collapsedIconColor: Color.mOnSurface
autoHide: false // Important to be false so we can hover as long as we want
text: currentLayout
tooltipText: "Keyboard Layout: " + currentLayout
onClicked: {
// You could open keyboard settings here if needed
// For now, just show the current layout
}
}
}

View file

@ -9,10 +9,14 @@ import qs.Widgets
Row {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling
visible: MediaService.currentPlayer !== null
width: MediaService.currentPlayer !== null ? implicitWidth : 0
visible: MediaService.currentPlayer !== null && MediaService.canPlay
width: MediaService.currentPlayer !== null && MediaService.canPlay ? implicitWidth : 0
function getTitle() {
return MediaService.trackTitle + (MediaService.trackArtist !== "" ? ` - ${MediaService.trackArtist}` : "")
@ -144,7 +148,7 @@ Row {
NText {
id: titleText
// If hovered or just switched window, show up to 300 pixels
// If hovered or just switched window, show up to 400 pixels
// If not hovered show up to 150 pixels
width: (mouseArea.containsMouse) ? Math.min(fullTitleMetrics.contentWidth,
400 * scaling) : Math.min(fullTitleMetrics.contentWidth,

View file

@ -10,6 +10,9 @@ import qs.Widgets
NIconButton {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
sizeMultiplier: 0.8
icon: "notifications"
tooltipText: "Notification History"
@ -17,8 +20,5 @@ NIconButton {
colorFg: Color.mOnSurface
colorBorder: Color.transparent
colorBorderHover: Color.transparent
onClicked: {
notificationHistoryPanel.toggle(screen)
}
onClicked: PanelService.getPanel("notificationHistoryPanel")?.toggle(screen)
}

View file

@ -0,0 +1,60 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import qs.Commons
import qs.Services
import qs.Widgets
NIconButton {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
property var powerProfiles: PowerProfiles
readonly property bool hasPP: powerProfiles.hasPerformanceProfile
sizeMultiplier: 0.8
visible: hasPP
function profileIcon() {
if (!hasPP)
return "balance"
if (powerProfiles.profile === PowerProfile.Performance)
return "speed"
if (powerProfiles.profile === PowerProfile.Balanced)
return "balance"
if (powerProfiles.profile === PowerProfile.PowerSaver)
return "eco"
}
function profileName() {
if (!hasPP)
return "Unknown"
if (powerProfiles.profile === PowerProfile.Performance)
return "Performance"
if (powerProfiles.profile === PowerProfile.Balanced)
return "Balanced"
if (powerProfiles.profile === PowerProfile.PowerSaver)
return "Power Saver"
}
function changeProfile() {
if (!hasPP)
return
if (powerProfiles.profile === PowerProfile.Performance)
powerProfiles.profile = PowerProfile.PowerSaver
else if (powerProfiles.profile === PowerProfile.Balanced)
powerProfiles.profile = PowerProfile.Performance
else if (powerProfiles.profile === PowerProfile.PowerSaver)
powerProfiles.profile = PowerProfile.Balanced
}
icon: root.profileIcon()
tooltipText: root.profileName()
colorBg: Color.mSurfaceVariant
colorFg: Color.mOnSurface
colorBorder: Color.transparent
colorBorderHover: Color.transparent
onClicked: root.changeProfile()
}

View file

@ -1,18 +1,21 @@
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
// Screen Recording Indicator
NIconButton {
id: screenRecordingIndicator
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
visible: ScreenRecorderService.isRecording
icon: "videocam"
tooltipText: "Screen Recording Active\nClick To Stop Recording"
sizeMultiplier: 0.8
colorBg: Color.mPrimary
colorFg: Color.mOnPrimary
visible: ScreenRecorderService.isRecording
anchors.verticalCenter: parent.verticalCenter
onClicked: {
ScreenRecorderService.toggleRecording()
}
onClicked: ScreenRecorderService.toggleRecording()
}

View file

@ -1,9 +1,14 @@
import Quickshell
import qs.Commons
import qs.Widgets
import qs.Services
NIconButton {
id: sidePanelToggle
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
icon: "widgets"
tooltipText: "Open Side Panel"
sizeMultiplier: 0.8
@ -14,5 +19,5 @@ NIconButton {
colorBorderHover: Color.transparent
anchors.verticalCenter: parent.verticalCenter
onClicked: sidePanel.toggle(screen)
onClicked: PanelService.getPanel("sidePanel")?.toggle(screen)
}

View file

@ -6,6 +6,10 @@ import qs.Widgets
Row {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.marginS * scaling

View file

@ -6,15 +6,20 @@ import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import qs.Commons
import qs.Modules.Bar.Extras
import qs.Services
import qs.Widgets
Rectangle {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
readonly property real itemSize: 24 * scaling
visible: SystemTray.items.values.length > 0
width: tray.width + Style.marginM * scaling * 2
height: Math.round(Style.capsuleHeight * scaling)
implicitWidth: tray.width + Style.marginM * scaling * 2
implicitHeight: Math.round(Style.capsuleHeight * scaling)
radius: Math.round(Style.radiusM * scaling)
color: Color.mSurfaceVariant
@ -134,9 +139,7 @@ Rectangle {
function open() {
visible = true
// Register into the panel service
// so this will autoclose if we open another panel
PanelService.registerOpen(trayPanel)
PanelService.willOpenPanel(trayPanel)
}
function close() {
@ -152,7 +155,7 @@ Rectangle {
Loader {
id: trayMenu
source: "TrayMenu.qml"
source: "../Extras/TrayMenu.qml"
}
}
}

View file

@ -9,12 +9,15 @@ import qs.Widgets
Item {
id: root
width: pill.width
height: pill.height
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
// Used to avoid opening the pill on Quickshell startup
property bool firstVolumeReceived: false
implicitWidth: pill.width
implicitHeight: pill.height
function getIcon() {
if (AudioService.muted) {
return "volume_off"
@ -64,6 +67,7 @@ Item {
}
}
onClicked: {
var settingsPanel = PanelService.getPanel("settingsPanel")
settingsPanel.requestedTab = SettingsPanel.Tab.AudioService
settingsPanel.open(screen)
}

View file

@ -10,6 +10,11 @@ import qs.Widgets
NIconButton {
id: root
property ShellScreen screen
property real scaling: ScalingService.scale(screen)
visible: Settings.data.network.wifiEnabled
sizeMultiplier: 0.8
Component.onCompleted: {
@ -27,6 +32,8 @@ NIconButton {
icon: {
try {
if (NetworkService.ethernet)
return "lan"
let connected = false
let signalStrength = 0
for (const net in NetworkService.networks) {
@ -36,17 +43,17 @@ NIconButton {
break
}
}
return connected ? NetworkService.signalIcon(signalStrength) : "wifi"
return connected ? NetworkService.signalIcon(signalStrength) : "wifi_find"
} catch (error) {
Logger.error("WiFi", "Error getting icon:", error)
return "wifi"
return "signal_wifi_bad"
}
}
tooltipText: "WiFi Networks"
tooltipText: "Network / WiFi"
onClicked: {
try {
Logger.log("WiFi", "Button clicked, toggling panel")
wifiPanel.toggle(screen)
PanelService.getPanel("wifiPanel")?.toggle(screen)
} catch (error) {
Logger.error("WiFi", "Error toggling panel:", error)
}

View file

@ -10,6 +10,10 @@ import qs.Services
Item {
id: root
property ShellScreen screen: null
property real scaling: ScalingService.scale(screen)
property bool isDestroying: false
property bool hovered: false
@ -23,7 +27,8 @@ Item {
signal workspaceChanged(int workspaceId, color accentColor)
width: {
implicitHeight: Math.round(36 * scaling)
implicitWidth: {
let total = 0
for (var i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i)
@ -39,34 +44,35 @@ Item {
return total
}
height: Math.round(36 * scaling)
Component.onCompleted: {
localWorkspaces.clear()
for (var i = 0; i < WorkspaceService.workspaces.count; i++) {
const ws = WorkspaceService.workspaces.get(i)
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
localWorkspaces.append(ws)
}
}
workspaceRepeater.model = localWorkspaces
updateWorkspaceFocus()
refreshWorkspaces()
}
Component.onDestruction: {
root.isDestroying = true
}
onScreenChanged: refreshWorkspaces()
Connections {
target: WorkspaceService
function onWorkspacesChanged() {
localWorkspaces.clear()
refreshWorkspaces()
}
}
function refreshWorkspaces() {
localWorkspaces.clear()
if (screen !== null) {
for (var i = 0; i < WorkspaceService.workspaces.count; i++) {
const ws = WorkspaceService.workspaces.get(i)
if (ws.output.toLowerCase() === screen.name.toLowerCase()) {
localWorkspaces.append(ws)
}
}
workspaceRepeater.model = localWorkspaces
updateWorkspaceFocus()
}
workspaceRepeater.model = localWorkspaces
updateWorkspaceFocus()
}
function triggerUnifiedWave() {
@ -74,6 +80,17 @@ Item {
masterAnimation.restart()
}
function updateWorkspaceFocus() {
for (var i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i)
if (ws.isFocused === true) {
root.triggerUnifiedWave()
root.workspaceChanged(ws.id, Color.mPrimary)
break
}
}
}
SequentialAnimation {
id: masterAnimation
PropertyAction {
@ -101,17 +118,6 @@ Item {
}
}
function updateWorkspaceFocus() {
for (var i = 0; i < localWorkspaces.count; i++) {
const ws = localWorkspaces.get(i)
if (ws.isFocused === true) {
root.triggerUnifiedWave()
root.workspaceChanged(ws.id, Color.mPrimary)
break
}
}
}
Rectangle {
id: workspaceBackground
width: parent.width - Style.marginS * scaling * 2
@ -254,8 +260,4 @@ Item {
}
}
}
Component.onDestruction: {
root.isDestroying = true
}
}

View file

@ -104,17 +104,6 @@ NPanel {
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)

View file

@ -8,7 +8,6 @@ Item {
IpcHandler {
target: "settings"
function toggle() {
settingsPanel.toggle(Quickshell.screens[0])
}
@ -16,43 +15,64 @@ Item {
IpcHandler {
target: "notifications"
function toggleHistory() {
notificationHistoryPanel.toggle(Quickshell.screens[0])
}
function toggleDoNotDisturb() {// TODO
}
}
IpcHandler {
target: "idleInhibitor"
function toggle() {
return IdleInhibitorService.manualToggle()
}
}
// For backward compatibility, should be removed soon(tmc)
IpcHandler {
target: "appLauncher"
function toggle() {
launcherPanel.toggle(Quickshell.screens[0])
}
function clipboard() {
launcherPanel.toggle(Quickshell.screens[0])
// Use the setSearchText function to set clipboard mode
Qt.callLater(() => {
launcherPanel.setSearchText(">clip ")
})
}
function calculator() {
launcherPanel.toggle(Quickshell.screens[0])
// Use the setSearchText function to set calculator mode
Qt.callLater(() => {
launcherPanel.setSearchText(">calc ")
})
}
}
IpcHandler {
target: "launcher"
function toggle() {
launcherPanel.toggle(Quickshell.screens[0])
}
function clipboard() {
launcherPanel.toggle(Quickshell.screens[0])
// Use the setSearchText function to set clipboard mode
Qt.callLater(() => {
launcherPanel.setSearchText(">clip ")
})
}
function calculator() {
launcherPanel.toggle(Quickshell.screens[0])
// Use the setSearchText function to set calculator mode
Qt.callLater(() => {
launcherPanel.setSearchText(">calc ")
})
}
}
IpcHandler {
target: "lockScreen"
function toggle() {
// Only lock if not already locked (prevents the red screen issue)
// Note: No unlock via IPC for security reasons
@ -64,13 +84,25 @@ Item {
IpcHandler {
target: "brightness"
function increase() {
BrightnessService.increaseBrightness()
}
function decrease() {
BrightnessService.decreaseBrightness()
}
}
IpcHandler {
target: "powerPanel"
function toggle() {
powerPanel.toggle(Quickshell.screens[0])
}
}
IpcHandler {
target: "sidePanel"
function toggle() {
sidePanel.toggle(Quickshell.screens[0])
}
}
}

View file

@ -33,7 +33,7 @@ QtObject {
}
} else {
// Fallback to basic evaluation
console.log("AdvancedMath not available, using basic eval")
Logger.warn("Calculator", "AdvancedMath not available, using basic eval")
// Basic preprocessing for common functions
var processed = expression.trim(

View file

@ -24,10 +24,30 @@ NPanel {
panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left"))
panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right"))
// Properties
property string searchText: ""
property bool shouldResetCursor: false
// Add function to set search text programmatically
function setSearchText(text) {
searchText = text
// The searchInput will automatically update via the text binding
// Focus and cursor position will be handled by the TextField's Component.onCompleted
}
onOpened: {
// Reset state when panel opens to avoid sticky modes
if (searchText === "") {
searchText = ""
selectedIndex = 0
}
}
onClosed: {
// Reset search bar when launcher is closed
searchText = ""
selectedIndex = 0
shouldResetCursor = true
}
// Import modular components
@ -50,7 +70,6 @@ NPanel {
// Properties
property var desktopEntries: DesktopEntries.applications.values
property string searchText: ""
property int selectedIndex: 0
// Refresh clipboard when user starts typing clipboard commands
@ -141,15 +160,11 @@ NPanel {
// Command execution functions
function executeCalcCommand() {
searchText = ">calc "
searchInput.text = searchText
searchInput.cursorPosition = searchText.length
setSearchText(">calc ")
}
function executeClipCommand() {
searchText = ">clip "
searchInput.text = searchText
searchInput.cursorPosition = searchText.length
setSearchText(">clip ")
}
// Navigation functions
@ -252,10 +267,20 @@ NPanel {
anchors.leftMargin: Style.marginS * scaling
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: searchText
onTextChanged: {
searchText = text
// Update the parent searchText property
if (searchText !== text) {
searchText = text
}
// Defer selectedIndex reset to avoid binding loops
Qt.callLater(() => selectedIndex = 0)
// Reset cursor position if needed
if (shouldResetCursor && text === "") {
cursorPosition = 0
shouldResetCursor = false
}
}
selectedTextColor: Color.mOnSurface
selectionColor: Color.mPrimary
@ -266,10 +291,14 @@ NPanel {
topPadding: 0
bottomPadding: 0
Component.onCompleted: {
// Focus the search bar by default
// Focus the search bar by default and set cursor position
Qt.callLater(() => {
selectedIndex = 0
searchInput.forceActiveFocus()
// Set cursor to end if there's already text
if (searchText && searchText.length > 0) {
searchInput.cursorPosition = searchText.length
}
})
}
Keys.onDownPressed: selectNext()

View file

@ -0,0 +1,94 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pam
import qs.Commons
Scope {
id: root
signal unlocked
signal failed
property string currentText: ""
property bool unlockInProgress: false
property bool showFailure: false
property string errorMessage: ""
property bool pamAvailable: typeof PamContext !== "undefined"
onCurrentTextChanged: {
if (currentText !== "") {
showFailure = false
errorMessage = ""
}
}
function tryUnlock() {
if (!pamAvailable) {
errorMessage = "PAM not available"
showFailure = true
return
}
if (currentText === "") {
errorMessage = "Password required"
showFailure = true
return
}
root.unlockInProgress = true
errorMessage = ""
showFailure = false
Logger.log("LockContext", "Starting PAM authentication for user:", pam.user)
pam.start()
}
PamContext {
id: pam
config: "login"
user: Quickshell.env("USER")
onPamMessage: {
Logger.log("LockContext", "PAM message:", message, "isError:", messageIsError, "responseRequired:",
responseRequired)
if (messageIsError) {
errorMessage = message
}
if (responseRequired) {
Logger.log("LockContext", "Responding to PAM with password")
respond(root.currentText)
}
}
onResponseRequiredChanged: {
Logger.log("LockContext", "Response required changed:", responseRequired)
if (responseRequired && root.unlockInProgress) {
Logger.log("LockContext", "Automatically responding to PAM")
respond(root.currentText)
}
}
onCompleted: result => {
Logger.log("LockContext", "PAM completed with result:", result)
if (result === PamResult.Success) {
Logger.log("LockContext", "Authentication successful")
root.unlocked()
} else {
Logger.log("LockContext", "Authentication failed")
errorMessage = "Authentication failed"
showFailure = true
root.failed()
}
root.unlockInProgress = false
}
onError: {
Logger.log("LockContext", "PAM error:", error, "message:", message)
errorMessage = message || "Authentication error"
showFailure = true
root.unlockInProgress = false
root.failed()
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -308,6 +308,45 @@ ColumnLayout {
Settings.data.audio.visualizerType = key
}
}
NComboBox {
label: "Frame Rate"
description: "Target frame rate for audio visualizer. (default: 60)"
model: ListModel {
ListElement {
key: "30"
name: "30 FPS"
}
ListElement {
key: "60"
name: "60 FPS"
}
ListElement {
key: "100"
name: "100 FPS"
}
ListElement {
key: "120"
name: "120 FPS"
}
ListElement {
key: "144"
name: "144 FPS"
}
ListElement {
key: "165"
name: "165 FPS"
}
ListElement {
key: "240"
name: "240 FPS"
}
}
currentKey: Settings.data.audio.cavaFrameRate
onSelected: key => {
Settings.data.audio.cavaFrameRate = key
}
}
}
}
}

View file

@ -33,7 +33,6 @@ ColumnLayout {
spacing: Style.marginL * scaling
Layout.fillWidth: true
ColumnLayout {
spacing: Style.marginXXS * scaling
Layout.fillWidth: true
@ -72,7 +71,7 @@ ColumnLayout {
}
}
ColumnLayout {
ColumnLayout {
spacing: Style.marginXXS * scaling
Layout.fillWidth: true
@ -111,7 +110,6 @@ ColumnLayout {
}
}
NToggle {
label: "Show Active Window's Icon"
description: "Display the app icon next to the title of the currently focused window."
@ -130,7 +128,6 @@ ColumnLayout {
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginL * scaling
@ -144,13 +141,14 @@ ColumnLayout {
NText {
text: "Widgets Positioning"
font.pointSize: Style.fontSizeL * scaling
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.bottomMargin: Style.marginS * scaling
}
NText {
text: "Add, remove, or reorder widgets in each section of the bar using the control buttons."
text: "Drag and drop widgets to reorder them within each section, or use the add/remove buttons to manage widgets."
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurfaceVariant
wrapMode: Text.WordWrap
@ -165,7 +163,7 @@ ColumnLayout {
spacing: Style.marginM * scaling
// Left Section
NWidgetCard {
NSectionEditor {
sectionName: "Left"
widgetModel: Settings.data.bar.widgets.left
availableWidgets: availableWidgets
@ -176,7 +174,7 @@ ColumnLayout {
}
// Center Section
NWidgetCard {
NSectionEditor {
sectionName: "Center"
widgetModel: Settings.data.bar.widgets.center
availableWidgets: availableWidgets
@ -187,7 +185,7 @@ ColumnLayout {
}
// Right Section
NWidgetCard {
NSectionEditor {
sectionName: "Right"
widgetModel: Settings.data.bar.widgets.right
availableWidgets: availableWidgets
@ -204,13 +202,13 @@ ColumnLayout {
// Helper functions
function addWidgetToSection(widgetName, section) {
console.log("Adding widget", widgetName, "to section", section)
//Logger.log("BarTab", "Adding widget", widgetName, "to section", section)
var sectionArray = Settings.data.bar.widgets[section]
if (sectionArray) {
// Create a new array to avoid modifying the original
var newArray = sectionArray.slice()
newArray.push(widgetName)
console.log("Widget added. New array:", JSON.stringify(newArray))
//Logger.log("BarTab", "Widget added. New array:", JSON.stringify(newArray))
// Assign the new array
Settings.data.bar.widgets[section] = newArray
@ -218,21 +216,27 @@ ColumnLayout {
}
function removeWidgetFromSection(section, index) {
console.log("Removing widget from section", section, "at index", index)
// Logger.log("BarTab", "Removing widget from section", section, "at index", index)
var sectionArray = Settings.data.bar.widgets[section]
//Logger.log("BarTab", "Current section array:", JSON.stringify(sectionArray))
if (sectionArray && index >= 0 && index < sectionArray.length) {
// Create a new array to avoid modifying the original
var newArray = sectionArray.slice()
newArray.splice(index, 1)
console.log("Widget removed. New array:", JSON.stringify(newArray))
//Logger.log("BarTab", "Widget removed. New array:", JSON.stringify(newArray))
// Assign the new array
Settings.data.bar.widgets[section] = newArray
} else {
//Logger.log("BarTab", "Invalid section or index:", section, index, "array length:",
// sectionArray ? sectionArray.length : "null")
}
}
function reorderWidgetInSection(section, fromIndex, toIndex) {
console.log("Reordering widget in section", section, "from", fromIndex, "to", toIndex)
//Logger.log("BarTab", "Reordering widget in section", section, "from", fromIndex, "to", toIndex)
var sectionArray = Settings.data.bar.widgets[section]
if (sectionArray && fromIndex >= 0 && fromIndex < sectionArray.length && toIndex >= 0
&& toIndex < sectionArray.length) {
@ -242,36 +246,26 @@ ColumnLayout {
var item = newArray[fromIndex]
newArray.splice(fromIndex, 1)
newArray.splice(toIndex, 0, item)
console.log("Widget reordered. New array:", JSON.stringify(newArray))
Logger.log("BarTab", "Widget reordered. New array:", JSON.stringify(newArray))
// Assign the new array
Settings.data.bar.widgets[section] = newArray
}
}
// Widget loader for discovering available widgets
WidgetLoader {
id: widgetLoader
}
// Base list model for all combo boxes
ListModel {
id: availableWidgets
}
Component.onCompleted: {
discoverWidgets()
}
// Automatically discover available widgets using WidgetLoader
function discoverWidgets() {
// Fill out availableWidgets ListModel
availableWidgets.clear()
// Use WidgetLoader to discover available widgets
const discoveredWidgets = widgetLoader.discoverAvailableWidgets()
// Add discovered widgets to the ListModel
discoveredWidgets.forEach(widget => {
availableWidgets.append(widget)
})
BarWidgetRegistry.getAvailableWidgets().forEach(entry => {
availableWidgets.append({
"key": entry,
"name": entry
})
})
}
}

View file

@ -125,10 +125,22 @@ ColumnLayout {
key: "60"
name: "60 FPS"
}
ListElement {
key: "100"
name: "100 FPS"
}
ListElement {
key: "120"
name: "120 FPS"
}
ListElement {
key: "144"
name: "144 FPS"
}
ListElement {
key: "165"
name: "165 FPS"
}
ListElement {
key: "240"
name: "240 FPS"

View file

@ -160,8 +160,6 @@ NBox {
height: 90 * scaling
radius: width * 0.5
color: trackArt.visible ? Color.mPrimary : Color.transparent
border.color: trackArt.visible ? Color.mOutline : Color.transparent
border.width: Math.max(1, Style.borderS * scaling)
clip: true
NImageCircled {

View file

@ -50,6 +50,7 @@ NBox {
icon: "image"
tooltipText: "Open Wallpaper Selector"
onClicked: {
var settingsPanel = PanelService.getPanel("settingsPanel")
settingsPanel.requestedTab = SettingsPanel.Tab.WallpaperSelector
settingsPanel.open(screen)
}

View file

@ -90,7 +90,10 @@ NBox {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginS * scaling
NText {
text: Qt.formatDateTime(new Date(LocationService.data.weather.daily.time[index]), "ddd")
text: {
var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/"))
return Qt.formatDateTime(weatherDate, "ddd")
}
color: Color.mOnSurface
}
NIcon {

View file

@ -17,6 +17,10 @@ Variants {
readonly property real scaling: ScalingService.scale(screen)
screen: modelData
// Only show on screens that have notifications enabled
visible: modelData ? (Settings.data.notifications.monitors.includes(modelData.name)
|| (Settings.data.notifications.monitors.length === 0)) : false
// Position based on bar location, like Notification popup does
anchors {
top: Settings.data.bar.position === "top"
@ -51,11 +55,15 @@ Variants {
hiddenY: Settings.data.bar.position === "top" ? -toast.height - 20 : toast.height + 20
Component.onCompleted: {
// Register this toast with the service
ToastService.currentToast = toast
// Only register toasts for screens that have notifications enabled
if (modelData ? (Settings.data.notifications.monitors.includes(modelData.name)
|| (Settings.data.notifications.monitors.length === 0)) : false) {
// Register this toast with the service
ToastService.allToasts.push(toast)
// Connect dismissal signal
toast.dismissed.connect(ToastService.onToastDismissed)
// Connect dismissal signal
toast.dismissed.connect(ToastService.onToastDismissed)
}
}
}
}

View file

@ -79,6 +79,7 @@ Features a modern modular architecture with a status bar, notification system, c
- `gpu-screen-recorder` - Screen recording functionality
- `brightnessctl` - For internal/laptop monitor brightness
- `ddcutil` - For desktop monitor brightness (might introduce some system instability with certain monitors)
If you want to use the ArchUpdater Widget, make sure you have any polkit agent installed.
---
@ -100,8 +101,30 @@ mkdir -p ~/.config/quickshell && curl -sL https://github.com/noctalia-dev/noctal
# Start the shell
qs
# Toggle launcher
qs ipc call appLauncher toggle
# Launcher
qs ipc call launcher toggle
# SidePanel
qs ipc call sidePanel toggle
# Clipboard History
qs ipc call launcher clipboard
# Calculator
qs ipc call launcher calculator
# Brightness
qs ipc call brightness increase
qs ipc call brightness decrease
# Power Panel
qs ipc call powerPanel toggle
# Idle Inhibitor
qs ipc call idleInhibitor toggle
# Settings Window
qs ipc call settings toggle
# Toggle lock screen
qs ipc call lockScreen toggle
@ -249,6 +272,7 @@ While I actually didn't want to accept donations, more and more people are askin
Thank you to everyone who supports me and this project 💜!
* Gohma
* <a href="https://pika-os.com/" target="_blank">PikaOS</a>
---

View file

@ -0,0 +1,156 @@
pragma Singleton
import Quickshell
import QtQuick
import Quickshell.Io
Singleton {
id: updateService
// Core properties
readonly property bool busy: checkupdatesProcess.running
readonly property int updates: updatePackages.length
property var updatePackages: []
property var selectedPackages: []
property int selectedPackagesCount: 0
property bool updateInProgress: false
// Process for checking updates
Process {
id: checkupdatesProcess
command: ["checkupdates"]
onExited: function (exitCode) {
if (exitCode !== 0 && exitCode !== 2) {
console.warn("[UpdateService] checkupdates failed (code:", exitCode, ")")
updatePackages = []
return
}
}
stdout: StdioCollector {
onStreamFinished: {
parseCheckupdatesOutput(text)
}
}
}
// Parse checkupdates output
function parseCheckupdatesOutput(output) {
const lines = output.trim().split('\n').filter(line => line.trim())
const packages = []
for (const line of lines) {
const m = line.match(/^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/)
if (m) {
packages.push({
"name": m[1],
"oldVersion": m[2],
"newVersion": m[3],
"description": `${m[1]} ${m[2]} -> ${m[3]}`
})
}
}
updatePackages = packages
}
// Check for updates
function doPoll() {
if (busy)
return
checkupdatesProcess.running = true
}
// Update all packages
function runUpdate() {
if (updates === 0) {
doPoll()
return
}
updateInProgress = true
Quickshell.execDetached(["pkexec", "pacman", "-Syu", "--noconfirm"])
// Refresh after updates with multiple attempts
refreshAfterUpdate()
}
// Update selected packages
function runSelectiveUpdate() {
if (selectedPackages.length === 0)
return
updateInProgress = true
const command = ["pkexec", "pacman", "-S", "--noconfirm"].concat(selectedPackages)
Quickshell.execDetached(command)
// Clear selection and refresh
selectedPackages = []
selectedPackagesCount = 0
refreshAfterUpdate()
}
// Package selection functions
function togglePackageSelection(packageName) {
const index = selectedPackages.indexOf(packageName)
if (index > -1) {
selectedPackages.splice(index, 1)
} else {
selectedPackages.push(packageName)
}
selectedPackagesCount = selectedPackages.length
}
function selectAllPackages() {
selectedPackages = updatePackages.map(pkg => pkg.name)
selectedPackagesCount = selectedPackages.length
}
function deselectAllPackages() {
selectedPackages = []
selectedPackagesCount = 0
}
function isPackageSelected(packageName) {
return selectedPackages.indexOf(packageName) > -1
}
// Robust refresh after updates
function refreshAfterUpdate() {
// First refresh attempt after 3 seconds
Qt.callLater(() => {
doPoll()
}, 3000)
// Second refresh attempt after 8 seconds
Qt.callLater(() => {
doPoll()
}, 8000)
// Third refresh attempt after 15 seconds
Qt.callLater(() => {
doPoll()
updateInProgress = false
}, 15000)
// Final refresh attempt after 30 seconds
Qt.callLater(() => {
doPoll()
}, 30000)
}
// Notification helper
function notify(title, body) {
Quickshell.execDetached(["notify-send", "-a", "UpdateService", "-i", "system-software-update", title, body])
}
// Auto-poll every 15 minutes
Timer {
interval: 15 * 60 * 1000 // 15 minutes
repeat: true
running: true
onTriggered: doPoll()
}
// Initial check
Component.onCompleted: doPoll()
}

View file

@ -0,0 +1,99 @@
pragma Singleton
import QtQuick
import Quickshell
import qs.Modules.Bar.Widgets
Singleton {
id: root
// Widget registry object mapping widget names to components
property var widgets: ({
"ActiveWindow": activeWindowComponent,
// "ArchUpdater": archUpdaterComponent,
"Battery": batteryComponent,
"Bluetooth": bluetoothComponent,
"Brightness": brightnessComponent,
"Clock": clockComponent,
"KeyboardLayout": keyboardLayoutComponent,
"MediaMini": mediaMiniComponent,
"NotificationHistory": notificationHistoryComponent,
"PowerProfile": powerProfileComponent,
"ScreenRecorderIndicator": screenRecorderIndicatorComponent,
"SidePanelToggle": sidePanelToggleComponent,
"SystemMonitor": systemMonitorComponent,
"Tray": trayComponent,
"Volume": volumeComponent,
"WiFi": wiFiComponent,
"Workspace": workspaceComponent
})
// Component definitions - these are loaded once at startup
property Component activeWindowComponent: Component {
ActiveWindow {}
}
// property Component archUpdaterComponent: Component {
// ArchUpdater {}
// }
property Component batteryComponent: Component {
Battery {}
}
property Component bluetoothComponent: Component {
Bluetooth {}
}
property Component brightnessComponent: Component {
Brightness {}
}
property Component clockComponent: Component {
Clock {}
}
property Component keyboardLayoutComponent: Component {
KeyboardLayout {}
}
property Component mediaMiniComponent: Component {
MediaMini {}
}
property Component notificationHistoryComponent: Component {
NotificationHistory {}
}
property Component powerProfileComponent: Component {
PowerProfile {}
}
property Component screenRecorderIndicatorComponent: Component {
ScreenRecorderIndicator {}
}
property Component sidePanelToggleComponent: Component {
SidePanelToggle {}
}
property Component systemMonitorComponent: Component {
SystemMonitor {}
}
property Component trayComponent: Component {
Tray {}
}
property Component volumeComponent: Component {
Volume {}
}
property Component wiFiComponent: Component {
WiFi {}
}
property Component workspaceComponent: Component {
Workspace {}
}
// ------------------------------
// Helper function to get widget component by name
function getWidget(name) {
return widgets[name] || null
}
// Helper function to check if widget exists
function hasWidget(name) {
return name in widgets
}
// Get list of available widget names
function getAvailableWidgets() {
return Object.keys(widgets)
}
}

View file

@ -14,7 +14,7 @@ Singleton {
property var config: ({
"general": {
"bars": barsCount,
"framerate": 60,
"framerate": Settings.data.audio.cavaFrameRate,
"autosens": 1,
"sensitivity": 100,
"lower_cutoff_freq": 50,
@ -38,7 +38,7 @@ Singleton {
id: process
stdinEnabled: true
running: (Settings.data.audio.visualizerType !== "none")
&& (PanelService.sidePanel.active || Settings.data.audio.showMiniplayerCava
&& (PanelService.getPanel("sidePanel").active || Settings.data.audio.showMiniplayerCava
|| (PanelService.lockScreen && PanelService.lockScreen.active))
command: ["cava", "-p", "/dev/stdin"]
onExited: {

View file

@ -19,7 +19,7 @@ Singleton {
property ListModel workspaces: ListModel {}
property var windows: []
property int focusedWindowIndex: -1
property string focusedWindowTitle: "(No active window)"
property string focusedWindowTitle: "n/a"
property bool inOverview: false
// Generic events
@ -27,6 +27,7 @@ Singleton {
signal activeWindowChanged
signal overviewStateChanged
signal windowListChanged
signal windowTitleChanged
// Compositor detection
Component.onCompleted: {
@ -308,9 +309,18 @@ Singleton {
// Update focused window index if this window is focused
if (newWindow.isFocused) {
const oldFocusedIndex = focusedWindowIndex
focusedWindowIndex = windows.findIndex(w => w.id === windowData.id)
updateFocusedWindowTitle()
activeWindowChanged()
// Only emit activeWindowChanged if the focused window actually changed
if (oldFocusedIndex !== focusedWindowIndex) {
activeWindowChanged()
}
} else if (existingIndex >= 0 && existingIndex === focusedWindowIndex) {
// If this is the currently focused window (but not newly focused),
// still update the title in case it changed, but don't emit activeWindowChanged
updateFocusedWindowTitle()
}
windowListChanged()
@ -449,11 +459,17 @@ Singleton {
}
function updateFocusedWindowTitle() {
const oldTitle = focusedWindowTitle
if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.length) {
focusedWindowTitle = windows[focusedWindowIndex].title || "(Unnamed window)"
} else {
focusedWindowTitle = "(No active window)"
}
// Emit signal if title actually changed
if (oldTitle !== focusedWindowTitle) {
windowTitleChanged()
}
}
// Generic workspace switching

View file

@ -1,9 +1,10 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
pragma Singleton
// GitHub API logic and caching
Singleton {

View file

@ -0,0 +1,112 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import qs.Commons
import qs.Services
Singleton {
id: root
property string currentLayout: "Unknown"
property int updateInterval: 1000 // Update every second
// Timer to periodically update the layout
Timer {
id: updateTimer
interval: updateInterval
running: true
repeat: true
onTriggered: {
updateLayout()
}
}
// Process to get current keyboard layout using niri msg (Wayland native)
Process {
id: niriLayoutProcess
running: false
command: ["niri", "msg", "-j", "keyboard-layouts"]
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text)
const layoutName = data.names[data.current_idx]
root.currentLayout = mapLayoutNameToCode(layoutName)
} catch (e) {
root.currentLayout = "Unknown"
}
}
}
}
// Process to get current keyboard layout using hyprctl (Hyprland)
Process {
id: hyprlandLayoutProcess
running: false
command: ["hyprctl", "-j", "devices"]
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text)
// Find the main keyboard and get its active keymap
const mainKeyboard = data.keyboards.find(kb => kb.main === true)
if (mainKeyboard && mainKeyboard.active_keymap) {
root.currentLayout = mapLayoutNameToCode(mainKeyboard.active_keymap)
} else {
root.currentLayout = "Unknown"
}
} catch (e) {
root.currentLayout = "Unknown"
}
}
}
}
// Layout name to ISO code mapping
property var layoutMap: {
"German": "de",
"English (US)": "us",
"English (UK)": "gb",
"French": "fr",
"Spanish": "es",
"Italian": "it",
"Portuguese (Brazil)": "br",
"Portuguese": "pt",
"Russian": "ru",
"Polish": "pl",
"Swedish": "se",
"Norwegian": "no",
"Danish": "dk",
"Finnish": "fi",
"Hungarian": "hu",
"Turkish": "tr",
"Czech": "cz",
"Slovak": "sk",
"Japanese": "jp",
"Korean": "kr",
"Chinese": "cn"
}
// Map layout names to ISO codes
function mapLayoutNameToCode(layoutName) {
return layoutMap[layoutName] || layoutName // fallback to raw name if not found
}
Component.onCompleted: {
Logger.log("KeyboardLayout", "Service started")
updateLayout()
}
function updateLayout() {
if (CompositorService.isHyprland) {
hyprlandLayoutProcess.running = true
} else if (CompositorService.isNiri) {
niriLayoutProcess.running = true
} else {
currentLayout = "Unknown"
}
}
}

View file

@ -1,9 +1,10 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
pragma Singleton
// Weather logic and caching
Singleton {
@ -109,8 +110,8 @@ Singleton {
// --------------------------------
function _geocodeLocation(locationName, callback, errorCallback) {
Logger.log("Location", "Geocoding from api.open-meteo.com")
var geoUrl = "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(
Logger.log("Location", "Geocoding location name")
var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(
locationName) + "&language=en&format=json"
var xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
@ -119,8 +120,8 @@ Singleton {
try {
var geoData = JSON.parse(xhr.responseText)
// Logger.logJSON.stringify(geoData))
if (geoData.results && geoData.results.length > 0) {
callback(geoData.results[0].latitude, geoData.results[0].longitude)
if (geoData.lat != null) {
callback(geoData.lat, geoData.lng)
} else {
errorCallback("Location", "could not resolve location name")
}

View file

@ -16,6 +16,7 @@ Singleton {
property string detectedInterface: ""
property string lastConnectedNetwork: ""
property bool isLoading: false
property bool ethernet: false
Component.onCompleted: {
Logger.log("Network", "Service started")
@ -43,6 +44,7 @@ Singleton {
function refreshNetworks() {
isLoading = true
checkEthernet.running = true
existingNetwork.running = true
}
@ -416,6 +418,24 @@ Singleton {
}
}
property Process checkEthernet: Process {
id: checkEthernet
running: false
command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"]
stdout: StdioCollector {
onStreamFinished: {
var lines = text.split("\n")
for (var i = 0; i < lines.length; ++i) {
var parts = lines[i].split(":")
if (parts[1] === "ethernet" && parts[2] === "connected") {
root.ethernet = true
break
}
}
}
}
}
property Process addConnectionProcess: Process {
id: addConnectionProcess
property string ifname: ""

View file

@ -1,10 +1,11 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
import Quickshell.Services.Notifications
pragma Singleton
QtObject {
id: root

View file

@ -1,20 +1,38 @@
pragma Singleton
import Quickshell
import qs.Commons
Singleton {
id: root
// A ref. to the sidePanel, so it's accessible from other services
property var sidePanel: null
// A ref. to the lockScreen, so it's accessible from other services
// A ref. to the lockScreen, so it's accessible from anywhere
// This is not a panel...
property var lockScreen: null
// Currently opened panel
property var openedPanel: null
function registerOpen(panel) {
property var registeredPanels: ({})
// Register this panel
function registerPanel(panel) {
registeredPanels[panel.objectName] = panel
Logger.log("PanelService", "Registered:", panel.objectName)
}
// Returns a panel
function getPanel(name) {
return registeredPanels[name] || null
}
// Check if a panel exists
function hasPanel(name) {
return name in registeredPanels
}
// Helper to keep only one panel open at any time
function willOpenPanel(panel) {
if (openedPanel && openedPanel != panel) {
openedPanel.close()
}

View file

@ -30,12 +30,22 @@ Singleton {
videoDir += "/"
}
outputPath = videoDir + filename
var command = `gpu-screen-recorder -w ${settings.videoSource} -f ${settings.frameRate} -ac ${settings.audioCodec} -k ${settings.videoCodec} -a ${settings.audioSource} -q ${settings.quality} -cursor ${settings.showCursor ? "yes" : "no"} -cr ${settings.colorRange} -o ${outputPath}`
var flags = `-w ${settings.videoSource} -f ${settings.frameRate} -ac ${settings.audioCodec} -k ${settings.videoCodec} -a ${settings.audioSource} -q ${settings.quality} -cursor ${settings.showCursor ? "yes" : "no"} -cr ${settings.colorRange} -o ${outputPath}`
var command = `
_gpuscreenrecorder_flatpak_installed() {
flatpak list --app | grep -q "com.dec05eba.gpu_screen_recorder"
}
if command -v gpu-screen-recorder >/dev/null 2>&1; then
gpu-screen-recorder ${flags}
elif command -v flatpak >/dev/null 2>&1 && _gpuscreenrecorder_flatpak_installed; then
flatpak run --command=gpu-screen-recorder --file-forwarding com.dec05eba.gpu_screen_recorder ${flags}
else
notify-send "gpu-screen-recorder not installed!" -u critical
fi`
//Logger.log("ScreenRecorder", command)
Quickshell.execDetached(["sh", "-c", command])
Logger.log("ScreenRecorder", "Started recording")
//Logger.log("ScreenRecorder", command)
}
// Stop recording using Quickshell.execDetached

View file

@ -1,18 +1,77 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
QtObject {
Singleton {
id: root
// Queue of pending toast messages
property var messageQueue: []
property bool isShowingToast: false
// Reference to the current toast instance (set by ToastManager)
property var currentToast: null
// Reference to all toast instances (set by ToastManager)
property var allToasts: []
// Properties for command checking
property var commandCheckCallback: null
property string commandCheckSuccessMessage: ""
property string commandCheckFailMessage: ""
// Properties for command running
property var commandRunCallback: null
property string commandRunSuccessMessage: ""
property string commandRunFailMessage: ""
// Properties for delayed toast
property string delayedToastMessage: ""
property string delayedToastType: "notice"
// Process for command checking
Process {
id: commandCheckProcess
command: ["which", "test"]
onExited: function (exitCode) {
if (exitCode === 0) {
showNotice(commandCheckSuccessMessage)
if (commandCheckCallback)
commandCheckCallback()
} else {
showWarning(commandCheckFailMessage)
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Process for command running
Process {
id: commandRunProcess
command: ["echo", "test"]
onExited: function (exitCode) {
if (exitCode === 0) {
showNotice(commandRunSuccessMessage)
if (commandRunCallback)
commandRunCallback()
} else {
showWarning(commandRunFailMessage)
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Timer for delayed toast
Timer {
id: delayedToastTimer
interval: 1000
repeat: false
onTriggered: {
showToast(delayedToastMessage, delayedToastType)
}
}
// Methods to show different types of messages
function showNotice(label, description = "", persistent = false, duration = 3000) {
@ -25,37 +84,14 @@ QtObject {
// Utility function to check if a command exists and show appropriate toast
function checkCommandAndToast(command, successMessage, failMessage, onSuccess = null) {
var checkProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: checkProc
command: ["which", "${command}"]
running: true
// Store callback for use in the process
commandCheckCallback = onSuccess
commandCheckSuccessMessage = successMessage
commandCheckFailMessage = failMessage
property var onSuccessCallback: null
property bool hasFinished: false
onExited: {
if (!hasFinished) {
hasFinished = true
if (exitCode === 0) {
ToastService.showNotice("${successMessage}")
if (onSuccessCallback) onSuccessCallback()
} else {
ToastService.showWarning("${failMessage}")
}
checkProc.destroy()
}
}
// Fallback collectors to prevent issues
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`, root)
checkProcess.onSuccessCallback = onSuccess
// Start the command check process
commandCheckProcess.command = ["which", command]
commandCheckProcess.running = true
}
// Simple function to show a random toast (useful for testing or fun messages)
@ -95,37 +131,14 @@ QtObject {
// Generic command runner with toast feedback
function runCommandWithToast(command, args, successMessage, failMessage, onSuccess = null) {
var fullCommand = [command].concat(args || [])
var runProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: runProc
command: ${JSON.stringify(fullCommand)}
running: true
// Store callback for use in the process
commandRunCallback = onSuccess
commandRunSuccessMessage = successMessage
commandRunFailMessage = failMessage
property var onSuccessCallback: null
property bool hasFinished: false
onExited: {
if (!hasFinished) {
hasFinished = true
if (exitCode === 0) {
ToastService.showNotice("${successMessage}")
if (onSuccessCallback) onSuccessCallback()
} else {
ToastService.showWarning("${failMessage}")
}
runProc.destroy()
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`, root)
runProcess.onSuccessCallback = onSuccess
// Start the command run process
commandRunProcess.command = [command].concat(args || [])
commandRunProcess.running = true
}
// Check if a file/directory exists
@ -135,18 +148,10 @@ QtObject {
// Show toast after a delay (useful for delayed feedback)
function delayedToast(message, type = "notice", delayMs = 1000) {
var timer = Qt.createQmlObject(`
import QtQuick
Timer {
interval: ${delayMs}
repeat: false
running: true
onTriggered: {
ToastService.showToast("${message}", "${type}")
destroy()
}
}
`, root)
delayedToastMessage = message
delayedToastType = type
delayedToastTimer.interval = delayMs
delayedToastTimer.restart()
}
// Generic method to show a toast
@ -171,7 +176,7 @@ QtObject {
// Process the message queue
function processQueue() {
if (messageQueue.length === 0 || !currentToast) {
if (messageQueue.length === 0 || allToasts.length === 0) {
isShowingToast = false
return
}
@ -184,24 +189,37 @@ QtObject {
var toastData = messageQueue.shift()
isShowingToast = true
// Configure and show toast
currentToast.label = toastData.label
currentToast.description = toastData.description
currentToast.type = toastData.type
currentToast.persistent = toastData.persistent
currentToast.duration = toastData.duration
currentToast.show()
// Configure and show toast on all screens
for (var i = 0; i < allToasts.length; i++) {
var toast = allToasts[i]
toast.label = toastData.label
toast.description = toastData.description
toast.type = toastData.type
toast.persistent = toastData.persistent
toast.duration = toastData.duration
toast.show()
}
}
// Called when a toast is dismissed
function onToastDismissed() {
// Check if all toasts are dismissed
var allDismissed = true
for (var i = 0; i < allToasts.length; i++) {
if (allToasts[i].visible) {
allDismissed = false
break
}
}
isShowingToast = false
if (allDismissed) {
isShowingToast = false
// Small delay before showing next toast
Qt.callLater(function () {
processQueue()
})
// Small delay before showing next toast
Qt.callLater(function () {
processQueue()
})
}
}
// Clear all pending messages
@ -212,8 +230,10 @@ QtObject {
// Hide current toast
function hideCurrentToast() {
if (currentToast && isShowingToast) {
currentToast.hide()
if (isShowingToast) {
for (var i = 0; i < allToasts.length; i++) {
allToasts[i].hide()
}
}
}

View file

@ -1,16 +0,0 @@
import QtQuick
import qs.Commons
import qs.Services
// Generic card container
Rectangle {
id: root
implicitWidth: childrenRect.width
implicitHeight: childrenRect.height
color: Color.mSurface
radius: Style.radiusM * scaling
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
}

View file

@ -8,14 +8,14 @@ import qs.Widgets
ColumnLayout {
id: root
readonly property real preferredHeight: Style.baseWidgetSize * 1.25 * scaling
readonly property real preferredHeight: Style.baseWidgetSize * 1.35 * scaling
property string label: ""
property string description: ""
property ListModel model: {
}
property string currentKey: ''
property string currentKey: ""
property string placeholder: ""
signal selected(string key)
@ -39,7 +39,8 @@ ColumnLayout {
ComboBox {
id: combo
Layout.fillWidth: true
Layout.preferredWidth: 320 * scaling
Layout.preferredHeight: height
model: model
currentIndex: findIndexByKey(currentKey)
@ -128,5 +129,13 @@ ColumnLayout {
radius: Style.radiusM * scaling
}
}
// Update the currentIndex if the currentKey is changed externalyu
Connections {
target: root
function onCurrentKeyChanged() {
combo.currentIndex = root.findIndexByKey(currentKey)
}
}
}
}

View file

@ -31,6 +31,12 @@ Loader {
signal opened
signal closed
Component.onCompleted: {
// console.log("Oh Yeah")
// console.log(objectName)
PanelService.registerPanel(root)
}
// -----------------------------------------
function toggle(aScreen) {
if (!active || isClosing) {
@ -53,7 +59,7 @@ Loader {
opacityValue = 1.0
}
PanelService.registerOpen(root)
PanelService.willOpenPanel(root)
active = true
root.opened()

View file

@ -16,10 +16,11 @@ Item {
property color collapsedIconColor: Color.mOnSurface
property real sizeMultiplier: 0.8
property bool autoHide: false
// When true, keep the pill expanded regardless of hover state
property bool forceShown: false
property bool forceOpen: false
property bool disableOpen: false
// Effective shown state (true if hovered/animated open or forced)
readonly property bool effectiveShown: forceShown || showPill
readonly property bool effectiveShown: forceOpen || showPill
signal shown
signal hidden
@ -85,7 +86,7 @@ Item {
height: iconSize
radius: width * 0.5
// When forced shown, match pill background; otherwise use accent when hovered
color: forceShown ? pillColor : (showPill ? iconCircleColor : Color.mSurfaceVariant)
color: forceOpen ? pillColor : (showPill ? iconCircleColor : Color.mSurfaceVariant)
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
@ -100,7 +101,7 @@ Item {
text: root.icon
font.pointSize: Style.fontSizeM * scaling
// When forced shown, use pill text color; otherwise accent color when hovered
color: forceShown ? textColor : (showPill ? iconTextColor : Color.mOnSurface)
color: forceOpen ? textColor : (showPill ? iconTextColor : Color.mOnSurface)
anchors.centerIn: parent
}
}
@ -194,18 +195,21 @@ Item {
anchors.fill: parent
hoverEnabled: true
onEntered: {
if (!forceShown) {
root.entered()
tooltip.show()
if (disableOpen) {
return
}
if (!forceOpen) {
showDelayed()
}
tooltip.show()
root.entered()
}
onExited: {
if (!forceShown) {
root.exited()
if (!forceOpen) {
hide()
}
tooltip.hide()
root.exited()
}
onClicked: {
root.clicked()
@ -226,7 +230,7 @@ Item {
}
function hide() {
if (forceShown) {
if (forceOpen) {
return
}
if (showPill) {
@ -245,8 +249,8 @@ Item {
}
}
onForceShownChanged: {
if (forceShown) {
onForceOpenChanged: {
if (forceOpen) {
// Immediately lock open without animations
showAnim.stop()
hideAnim.stop()

319
Widgets/NSectionEditor.qml Normal file
View file

@ -0,0 +1,319 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
NBox {
id: root
property string sectionName: ""
property var widgetModel: []
property var availableWidgets: []
property var scrollView: null
signal addWidget(string widgetName, string section)
signal removeWidget(string section, int index)
signal reorderWidget(string section, int fromIndex, int toIndex)
color: Color.mSurface
Layout.fillWidth: true
Layout.minimumHeight: {
var widgetCount = widgetModel.length
if (widgetCount === 0)
return 140 * scaling
var availableWidth = scrollView ? scrollView.availableWidth - (Style.marginM * scaling * 2) : 400 * scaling
var avgWidgetWidth = 150 * scaling
var widgetsPerRow = Math.max(1, Math.floor(availableWidth / avgWidgetWidth))
var rows = Math.ceil(widgetCount / widgetsPerRow)
return (50 + 20 + (rows * 48) + ((rows - 1) * Style.marginS) + 20) * scaling
}
// Generate widget color from name checksum
function getWidgetColor(name) {
const totalSum = name.split('').reduce((acc, character) => {
return acc + character.charCodeAt(0)
}, 0)
switch (totalSum % 5) {
case 0:
return Color.mPrimary
case 1:
return Color.mSecondary
case 2:
return Color.mTertiary
case 3:
return Color.mError
case 4:
return Color.mOnSurface
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
RowLayout {
Layout.fillWidth: true
NText {
text: sectionName + " Section"
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: Color.mSecondary
Layout.alignment: Qt.AlignVCenter
}
Item {
Layout.fillWidth: true
}
NComboBox {
id: comboBox
model: availableWidgets
label: ""
description: ""
placeholder: "Add widget to the " + sectionName.toLowerCase() + " section..."
onSelected: key => {
comboBox.currentKey = key
}
Layout.alignment: Qt.AlignVCenter
}
NIconButton {
icon: "add"
colorBg: Color.mPrimary
colorFg: Color.mOnPrimary
colorBgHover: Color.mSecondary
colorFgHover: Color.mOnSecondary
enabled: comboBox.selectedKey !== ""
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: Style.marginS * scaling
onClicked: {
if (comboBox.currentKey !== "") {
addWidget(comboBox.currentKey, sectionName.toLowerCase())
comboBox.currentKey = "battery"
}
}
}
}
// Drag and Drop Widget Area
Flow {
id: widgetFlow
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 65 * scaling
spacing: Style.marginS * scaling
flow: Flow.LeftToRight
Repeater {
model: widgetModel
delegate: Rectangle {
id: widgetItem
required property int index
required property string modelData
width: widgetContent.implicitWidth + 16 * scaling
height: 40 * scaling
radius: Style.radiusL * scaling
color: root.getWidgetColor(modelData)
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
// Drag properties
Drag.keys: ["widget"]
Drag.active: mouseArea.drag.active
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
// Store the widget index for drag operations
property int widgetIndex: index
// Visual feedback during drag
states: State {
when: mouseArea.drag.active
PropertyChanges {
target: widgetItem
scale: 1.1
opacity: 0.9
z: 1000
}
}
RowLayout {
id: widgetContent
anchors.centerIn: parent
spacing: Style.marginXS * scaling
NText {
text: modelData
font.pointSize: Style.fontSizeS * scaling
color: Color.mOnPrimary
horizontalAlignment: Text.AlignHCenter
}
NIconButton {
icon: "close"
size: 20 * scaling
colorBorder: Color.applyOpacity(Color.mOutline, "40")
colorBg: Color.mOnSurface
colorFg: Color.mOnPrimary
colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40")
colorFgHover: Color.mOnPrimary
onClicked: {
removeWidget(sectionName.toLowerCase(), index)
}
}
}
// Mouse area for drag and drop
MouseArea {
id: mouseArea
anchors.fill: parent
drag.target: parent
onPressed: mouse => {
// Check if the click is on the close button area
const closeButtonX = widgetContent.x + widgetContent.width - 20 * scaling
const closeButtonY = widgetContent.y
const closeButtonWidth = 20 * scaling
const closeButtonHeight = 20 * scaling
if (mouseX >= closeButtonX && mouseX <= closeButtonX + closeButtonWidth
&& mouseY >= closeButtonY && mouseY <= closeButtonY + closeButtonHeight) {
// Click is on the close button, don't start drag
mouse.accepted = false
return
}
Logger.log("NSectionEditor", `Started dragging widget: ${modelData} at index ${index}`)
// Bring to front when starting drag
widgetItem.z = 1000
}
onReleased: {
Logger.log("NSectionEditor", `Released widget: ${modelData} at index ${index}`)
// Reset z-index when drag ends
widgetItem.z = 0
// Get the global mouse position
const globalDropX = mouseArea.mouseX + widgetItem.x + widgetFlow.x
const globalDropY = mouseArea.mouseY + widgetItem.y + widgetFlow.y
// Find which widget the drop position is closest to
let targetIndex = -1
let minDistance = Infinity
for (var i = 0; i < widgetModel.length; i++) {
if (i !== index) {
// Get the position of other widgets
const otherWidget = widgetFlow.children[i]
if (otherWidget && otherWidget.widgetIndex !== undefined) {
// Calculate the center of the other widget
const otherCenterX = otherWidget.x + otherWidget.width / 2 + widgetFlow.x
const otherCenterY = otherWidget.y + otherWidget.height / 2 + widgetFlow.y
// Calculate distance to the center of this widget
const distance = Math.sqrt(Math.pow(globalDropX - otherCenterX,
2) + Math.pow(globalDropY - otherCenterY, 2))
if (distance < minDistance) {
minDistance = distance
targetIndex = otherWidget.widgetIndex
}
}
}
}
// Only reorder if we found a valid target and it's different from current position
if (targetIndex !== -1 && targetIndex !== index) {
const fromIndex = index
const toIndex = targetIndex
Logger.log(
"NSectionEditor",
`Dropped widget from index ${fromIndex} to position ${toIndex} (distance: ${minDistance.toFixed(
2)})`)
reorderWidget(sectionName.toLowerCase(), fromIndex, toIndex)
} else {
Logger.log("NSectionEditor", `No valid drop target found for widget at index ${index}`)
}
}
}
}
}
}
// Drop zone at the beginning (positioned absolutely)
DropArea {
id: startDropZone
width: 40 * scaling
height: 40 * scaling
x: widgetFlow.x
y: widgetFlow.y + (widgetFlow.height - height) / 2
keys: ["widget"]
z: 1001 // Above the Flow
Rectangle {
anchors.fill: parent
color: startDropZone.containsDrag ? Color.applyOpacity(Color.mPrimary, "20") : Color.transparent
border.color: startDropZone.containsDrag ? Color.mPrimary : Color.transparent
border.width: startDropZone.containsDrag ? 2 : 0
radius: Style.radiusS * scaling
}
onEntered: function (drag) {
Logger.log("NSectionEditor", "Entered start drop zone")
}
onDropped: function (drop) {
Logger.log("NSectionEditor", "Dropped on start zone")
if (drop.source && drop.source.widgetIndex !== undefined) {
const fromIndex = drop.source.widgetIndex
const toIndex = 0 // Insert at the beginning
if (fromIndex !== toIndex) {
Logger.log("NSectionEditor", `Dropped widget from index ${fromIndex} to beginning`)
reorderWidget(sectionName.toLowerCase(), fromIndex, toIndex)
}
}
}
}
// Drop zone at the end (positioned absolutely)
DropArea {
id: endDropZone
width: 40 * scaling
height: 40 * scaling
x: widgetFlow.x + widgetFlow.width - width
y: widgetFlow.y + (widgetFlow.height - height) / 2
keys: ["widget"]
z: 1001 // Above the Flow
Rectangle {
anchors.fill: parent
color: endDropZone.containsDrag ? Color.applyOpacity(Color.mPrimary, "20") : Color.transparent
border.color: endDropZone.containsDrag ? Color.mPrimary : Color.transparent
border.width: endDropZone.containsDrag ? 2 : 0
radius: Style.radiusS * scaling
}
onEntered: function (drag) {
Logger.log("NSectionEditor", "Entered end drop zone")
}
onDropped: function (drop) {
Logger.log("NSectionEditor", "Dropped on end zone")
if (drop.source && drop.source.widgetIndex !== undefined) {
const fromIndex = drop.source.widgetIndex
const toIndex = widgetModel.length // Insert at the end
if (fromIndex !== toIndex) {
Logger.log("NSectionEditor", `Dropped widget from index ${fromIndex} to end`)
reorderWidget(sectionName.toLowerCase(), fromIndex, toIndex)
}
}
}
}
}
}

View file

@ -1,158 +0,0 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
NCard {
id: root
property string sectionName: ""
property var widgetModel: []
property var availableWidgets: []
property var scrollView: null
signal addWidget(string widgetName, string section)
signal removeWidget(string section, int index)
signal reorderWidget(string section, int fromIndex, int toIndex)
Layout.fillWidth: true
Layout.minimumHeight: {
var widgetCount = widgetModel.length
if (widgetCount === 0)
return 140 * scaling
var availableWidth = scrollView ? scrollView.availableWidth - (Style.marginM * scaling * 2) : 400 * scaling
var avgWidgetWidth = 150 * scaling
var widgetsPerRow = Math.max(1, Math.floor(availableWidth / avgWidgetWidth))
var rows = Math.ceil(widgetCount / widgetsPerRow)
return (50 + 20 + (rows * 48) + ((rows - 1) * Style.marginS) + 20) * scaling
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
RowLayout {
Layout.fillWidth: true
NText {
text: sectionName + " Section"
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
}
Item {
Layout.fillWidth: true
}
NComboBox {
id: comboBox
width: 120 * scaling
model: availableWidgets
label: ""
description: ""
placeholder: "Add widget to " + sectionName.toLowerCase() + " section"
onSelected: key => {
comboBox.selectedKey = key
}
}
NIconButton {
icon: "add"
size: 24 * scaling
colorBg: Color.mPrimary
colorFg: Color.mOnPrimary
colorBgHover: Color.mPrimaryContainer
colorFgHover: Color.mOnPrimaryContainer
enabled: comboBox.selectedKey !== ""
Layout.alignment: Qt.AlignVCenter
onClicked: {
if (comboBox.selectedKey !== "") {
addWidget(comboBox.selectedKey, sectionName.toLowerCase())
comboBox.reset()
}
}
}
}
Flow {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 65 * scaling
spacing: Style.marginS * scaling
flow: Flow.LeftToRight
Repeater {
model: widgetModel
delegate: Rectangle {
width: widgetContent.implicitWidth + 16 * scaling
height: 48 * scaling
radius: Style.radiusS * scaling
color: Color.mPrimary
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
RowLayout {
id: widgetContent
anchors.centerIn: parent
spacing: Style.marginXS * scaling
NIconButton {
icon: "chevron_left"
size: 20 * scaling
colorBg: Color.applyOpacity(Color.mOnPrimary, "20")
colorFg: Color.mOnPrimary
colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40")
colorFgHover: Color.mOnPrimary
enabled: index > 0
onClicked: {
if (index > 0) {
reorderWidget(sectionName.toLowerCase(), index, index - 1)
}
}
}
NText {
text: modelData
font.pointSize: Style.fontSizeS * scaling
color: Color.mOnPrimary
horizontalAlignment: Text.AlignHCenter
}
NIconButton {
icon: "chevron_right"
size: 20 * scaling
colorBg: Color.applyOpacity(Color.mOnPrimary, "20")
colorFg: Color.mOnPrimary
colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40")
colorFgHover: Color.mOnPrimary
enabled: index < widgetModel.length - 1
onClicked: {
if (index < widgetModel.length - 1) {
reorderWidget(sectionName.toLowerCase(), index, index + 1)
}
}
}
NIconButton {
icon: "close"
size: 20 * scaling
colorBg: Color.applyOpacity(Color.mOnPrimary, "20")
colorFg: Color.mOnPrimary
colorBgHover: Color.applyOpacity(Color.mOnPrimary, "40")
colorFgHover: Color.mOnPrimary
onClicked: {
removeWidget(sectionName.toLowerCase(), index)
}
}
}
}
}
}
}
}

46
Widgets/NWidgetLoader.qml Normal file
View file

@ -0,0 +1,46 @@
import QtQuick
import Quickshell
import qs.Services
Item {
id: root
property string widgetName: ""
property var widgetProps: ({})
property bool enabled: true
// Don't reserve space unless the loaded widget is really visible
implicitWidth: loader.item ? loader.item.visible ? loader.item.implicitWidth : 0 : 0
implicitHeight: loader.item ? loader.item.visible ? loader.item.implicitHeight : 0 : 0
Loader {
id: loader
anchors.fill: parent
active: enabled && widgetName !== ""
sourceComponent: {
if (!active) {
return null
}
return BarWidgetRegistry.getWidget(widgetName)
}
onLoaded: {
if (item && widgetProps) {
// Apply properties to loaded widget
for (var prop in widgetProps) {
if (item.hasOwnProperty(prop)) {
item[prop] = widgetProps[prop]
}
}
}
}
}
// Error handling
onWidgetNameChanged: {
if (widgetName && !BarWidgetRegistry.hasWidget(widgetName)) {
Logger.warn("WidgetLoader", "Widget not found in registry:", widgetName)
}
}
}

View file

@ -27,6 +27,7 @@ import qs.Modules.PowerPanel
import qs.Modules.SidePanel
import qs.Modules.Toast
import qs.Modules.WiFiPanel
import qs.Modules.ArchUpdaterPanel
import qs.Services
import qs.Widgets
@ -39,55 +40,67 @@ ShellRoot {
Bar {}
Dock {}
Launcher {
id: launcherPanel
}
SidePanel {
id: sidePanel
}
Calendar {
id: calendarPanel
}
SettingsPanel {
id: settingsPanel
}
Notification {
id: notification
}
NotificationHistoryPanel {
id: notificationHistoryPanel
}
LockScreen {
id: lockScreen
}
PowerPanel {
id: powerPanel
}
WiFiPanel {
id: wifiPanel
}
BluetoothPanel {
id: bluetoothPanel
}
ToastManager {}
IPCManager {}
Component.onCompleted: {
// Save a ref. to our sidePanel so we can access it from services
PanelService.sidePanel = sidePanel
// ------------------------------
// All the panels
Launcher {
id: launcherPanel
objectName: "launcherPanel"
}
// Save a ref. to our lockScreen so we can access it from services
SidePanel {
id: sidePanel
objectName: "sidePanel"
}
Calendar {
id: calendarPanel
objectName: "calendarPanel"
}
SettingsPanel {
id: settingsPanel
objectName: "settingsPanel"
}
NotificationHistoryPanel {
id: notificationHistoryPanel
objectName: "notificationHistoryPanel"
}
PowerPanel {
id: powerPanel
objectName: "powerPanel"
}
WiFiPanel {
id: wifiPanel
objectName: "wifiPanel"
}
BluetoothPanel {
id: bluetoothPanel
objectName: "bluetoothPanel"
}
ArchUpdaterPanel {
id: archUpdaterPanel
objectName: "archUpdaterPanel"
}
Component.onCompleted: {
// Save a ref. to our lockScreen so we can access it easily
PanelService.lockScreen = lockScreen
// Ensure our singleton is created as soon as possible so we start fetching weather asap