This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -0,0 +1,791 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Pipewire
import Quickshell.Widgets
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Media
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(420 * Style.uiScaleRatio)
panelContent: Item {
id: panelContent
// Volume state (lazy-loaded with panelContent)
property real localOutputVolume: AudioService.volume || 0
property bool localOutputVolumeChanging: false
property int lastSinkId: -1
property real localInputVolume: AudioService.inputVolume || 0
property bool localInputVolumeChanging: false
property int lastSourceId: -1
readonly property bool outputVolumeGuard: outputVolumeSlider.sliderActive || localOutputVolumeChanging
readonly property bool inputVolumeGuard: inputVolumeSlider.sliderActive || localInputVolumeChanging
// UI state (lazy-loaded with panelContent)
property int currentTabIndex: 0
Component.onCompleted: {
var vol = AudioService.volume;
localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
var inputVol = AudioService.inputVolume;
localInputVolume = (inputVol !== undefined && !isNaN(inputVol)) ? inputVol : 0;
if (AudioService.sink) {
lastSinkId = AudioService.sink.id;
}
if (AudioService.source) {
lastSourceId = AudioService.source.id;
}
}
// Reset local volume when device changes - use current device's volume
Connections {
target: AudioService
function onSinkChanged() {
if (AudioService.sink) {
const newSinkId = AudioService.sink.id;
if (newSinkId !== panelContent.lastSinkId) {
panelContent.lastSinkId = newSinkId;
// Immediately set local volume to current device's volume
var vol = AudioService.volume;
panelContent.localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
}
} else {
panelContent.lastSinkId = -1;
panelContent.localOutputVolume = 0;
}
}
}
Connections {
target: AudioService
function onSourceChanged() {
if (AudioService.source) {
const newSourceId = AudioService.source.id;
if (newSourceId !== panelContent.lastSourceId) {
panelContent.lastSourceId = newSourceId;
// Immediately set local volume to current device's volume
var vol = AudioService.inputVolume;
panelContent.localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
}
} else {
panelContent.lastSourceId = -1;
panelContent.localInputVolume = 0;
}
}
}
// Connections to update local volumes when AudioService changes
Connections {
target: AudioService
function onVolumeChanged() {
if (!panelContent.outputVolumeGuard && !AudioService.isSettingOutputVolume && AudioService.sink && AudioService.sink.id === panelContent.lastSinkId) {
var vol = AudioService.volume;
panelContent.localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
}
}
}
Connections {
target: AudioService
function onInputVolumeChanged() {
if (!panelContent.inputVolumeGuard && !AudioService.isSettingInputVolume && AudioService.source && AudioService.source.id === panelContent.lastSourceId) {
var vol = AudioService.inputVolume;
panelContent.localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
}
}
}
Connections {
target: outputVolumeSlider
function onSliderActiveChanged() {
if (!outputVolumeSlider.sliderActive && AudioService.sink && AudioService.sink.id === panelContent.lastSinkId) {
var vol = AudioService.volume;
panelContent.localOutputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
}
}
}
Connections {
target: inputVolumeSlider
function onSliderActiveChanged() {
if (!inputVolumeSlider.sliderActive && AudioService.source && AudioService.source.id === panelContent.lastSourceId) {
var vol = AudioService.inputVolume;
panelContent.localInputVolume = (vol !== undefined && !isNaN(vol)) ? vol : 0;
}
}
}
// Timer to debounce volume changes
// Only sync if the device hasn't changed (check by comparing IDs)
Timer {
interval: 100
running: true
repeat: true
onTriggered: {
// Only sync if sink hasn't changed
if (AudioService.sink && AudioService.sink.id === panelContent.lastSinkId) {
if (Math.abs(panelContent.localOutputVolume - AudioService.volume) >= 0.01) {
AudioService.setVolume(panelContent.localOutputVolume);
}
}
// Only sync if source hasn't changed
if (AudioService.source && AudioService.source.id === panelContent.lastSourceId) {
if (Math.abs(panelContent.localInputVolume - AudioService.inputVolume) >= 0.01) {
AudioService.setInputVolume(panelContent.localInputVolume);
}
}
}
}
// Find application streams that are actually playing audio (connected to default sink)
// Use linkGroups to find nodes connected to the default audio sink
// Note: We need to use link IDs since source/target properties require binding
readonly property var appStreams: AudioService.appStreams
// Use implicitHeight from content + margins to avoid binding loops
property real contentPreferredHeight: mainColumn.implicitHeight + Style.margin2L
ColumnLayout {
id: mainColumn
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// HEADER
NBox {
Layout.fillWidth: true
implicitHeight: header.implicitHeight + Style.margin2M
ColumnLayout {
id: header
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
RowLayout {
NIcon {
icon: "settings-audio"
pointSize: Style.fontSizeXXL
color: Color.mPrimary
}
NText {
text: I18n.tr("panels.audio.title")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
root.close();
}
}
}
NTabBar {
id: tabBar
Layout.fillWidth: true
margins: Style.marginS
currentIndex: panelContent.currentTabIndex
distributeEvenly: true
onCurrentIndexChanged: panelContent.currentTabIndex = currentIndex
NTabButton {
text: I18n.tr("common.volumes")
tabIndex: 0
checked: tabBar.currentIndex === 0
}
NTabButton {
text: I18n.tr("common.devices")
tabIndex: 1
checked: tabBar.currentIndex === 1
}
}
}
}
// Content Stack
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: panelContent.currentTabIndex
// Applications Tab (Volume)
NScrollView {
id: volumeScrollView
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
contentWidth: availableWidth
reserveScrollbarSpace: false
gradientColor: Color.mSurface
ColumnLayout {
spacing: Style.marginM
width: volumeScrollView.availableWidth
// Output Volume
NBox {
Layout.fillWidth: true
Layout.preferredHeight: outputVolumeColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: outputVolumeColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginM
RowLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NText {
text: I18n.tr("common.output")
pointSize: Style.fontSizeM
color: Color.mPrimary
}
NText {
text: AudioService.sink ? (" - " + (AudioService.sink.description || AudioService.sink.name || "")) : ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
elide: Text.ElideRight
Layout.fillWidth: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NValueSlider {
id: outputVolumeSlider
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localOutputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: function (value) {
localOutputVolume = value;
}
onPressedChanged: function (pressed) {
localOutputVolumeChanging = pressed;
}
}
NText {
text: Math.round((panelContent.outputVolumeGuard ? localOutputVolume : AudioService.volume) * 100) + "%"
pointSize: Style.fontSizeM
family: Settings.data.ui.fontFixed
color: Color.mOnSurface
opacity: enabled ? 1.0 : 0.6
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: 45 * Style.uiScaleRatio
horizontalAlignment: Text.AlignRight
}
NIconButton {
icon: AudioService.getOutputIcon()
tooltipText: I18n.tr("tooltips.output-muted")
baseSize: Style.baseWidgetSize * 0.7
onClicked: {
AudioService.suppressOutputOSD();
AudioService.setOutputMuted(!AudioService.muted);
}
}
}
}
}
// Input Volume
NBox {
Layout.fillWidth: true
Layout.preferredHeight: inputVolumeColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: inputVolumeColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginM
RowLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NText {
text: I18n.tr("common.input")
pointSize: Style.fontSizeM
color: Color.mPrimary
}
NText {
text: AudioService.source ? (" - " + (AudioService.source.description || AudioService.source.name || "")) : ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
elide: Text.ElideRight
Layout.fillWidth: true
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NValueSlider {
id: inputVolumeSlider
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localInputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: function (value) {
localInputVolume = value;
}
onPressedChanged: function (pressed) {
localInputVolumeChanging = pressed;
}
}
NText {
text: Math.round((panelContent.inputVolumeGuard ? localInputVolume : AudioService.inputVolume) * 100) + "%"
pointSize: Style.fontSizeM
family: Settings.data.ui.fontFixed
color: Color.mOnSurface
opacity: enabled ? 1.0 : 0.6
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: 45 * Style.uiScaleRatio
horizontalAlignment: Text.AlignRight
}
NIconButton {
icon: AudioService.getInputIcon()
tooltipText: I18n.tr("tooltips.input-muted")
baseSize: Style.baseWidgetSize * 0.7
onClicked: {
AudioService.suppressInputOSD();
AudioService.setInputMuted(!AudioService.inputMuted);
}
}
}
}
}
// Bind all app stream nodes to access their audio properties
PwObjectTracker {
id: appStreamsTracker
objects: panelContent.appStreams
}
Repeater {
model: panelContent.appStreams
NBox {
id: appBox
required property PwNode modelData
Layout.fillWidth: true
Layout.preferredHeight: appRow.implicitHeight + Style.margin2M
visible: !isCaptureStream
// Track individual node to ensure properties are bound
PwObjectTracker {
objects: modelData ? [modelData] : []
}
property PwNodeAudio nodeAudio: (modelData && modelData.audio) ? modelData.audio : null
property real appVolume: (nodeAudio && nodeAudio.volume !== undefined) ? nodeAudio.volume : 0.0
property bool appMuted: (nodeAudio && nodeAudio.muted !== undefined) ? nodeAudio.muted : false
// Check if this is a capture stream (after node is bound)
readonly property bool isCaptureStream: {
if (!modelData || !modelData.properties)
return false;
const props = modelData.properties;
// Exclude capture streams - check for stream.capture.sink property
if (props["stream.capture.sink"] !== undefined) {
return true;
}
const mediaClass = props["media.class"] || "";
// Exclude Stream/Input (capture) but allow Stream/Output (playback)
if (mediaClass.includes("Capture") || mediaClass === "Stream/Input" || mediaClass === "Stream/Input/Audio") {
return true;
}
const mediaRole = props["media.role"] || "";
if (mediaRole === "Capture") {
return true;
}
return false;
}
// Helper function to validate if a fuzzy match is actually related
function isValidMatch(searchTerm, entry) {
if (!entry)
return false;
var search = searchTerm.toLowerCase();
var id = (entry.id || "").toLowerCase();
var name = (entry.name || "").toLowerCase();
var icon = (entry.icon || "").toLowerCase();
// Match is valid if search term appears in entry or entry appears in search
return id.includes(search) || name.includes(search) || icon.includes(search) || search.includes(id.split('.').pop()) || search.includes(name.replace(/\s+/g, ''));
}
readonly property string appName: {
if (!modelData)
return "Unknown App";
var props = modelData.properties;
var desc = modelData.description || "";
var name = modelData.name || "";
if (!props) {
if (desc)
return desc;
if (name) {
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0 && nameParts[0])
return nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1);
return name;
}
return "Unknown App";
}
var binaryName = props["application.process.binary"] || "";
// Try binary name first (fixes Electron apps like vesktop)
if (binaryName) {
var binParts = binaryName.split("/");
if (binParts.length > 0) {
var binName = binParts[binParts.length - 1].toLowerCase();
var entry = ThemeIcons.findAppEntry(binName);
// Only use entry if it's actually related to binary name
if (entry && entry.name && isValidMatch(binName, entry))
return entry.name;
}
}
var computedAppName = props["application.name"] || "";
var mediaName = props["media.name"] || "";
var mediaTitle = props["media.title"] || "";
var appId = props["application.id"] || "";
if (appId) {
var entry = ThemeIcons.findAppEntry(appId);
if (entry && entry.name && isValidMatch(appId, entry))
return entry.name;
if (!computedAppName) {
var parts = appId.split(".");
if (parts.length > 0 && parts[0])
computedAppName = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
}
}
if (!computedAppName && binaryName) {
var binParts = binaryName.split("/");
if (binParts.length > 0 && binParts[binParts.length - 1])
computedAppName = binParts[binParts.length - 1].charAt(0).toUpperCase() + binParts[binParts.length - 1].slice(1);
}
var result = computedAppName || mediaTitle || mediaName || binaryName || desc || name;
if (!result || result === "" || result === "Unknown App") {
if (name) {
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0 && nameParts[0])
result = nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1);
}
}
return result || "Unknown App";
}
readonly property string appIcon: {
if (!modelData)
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
var props = modelData.properties;
if (!props) {
var name = modelData.name || "";
if (name) {
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0) {
var entry = ThemeIcons.findAppEntry(nameParts[0].toLowerCase());
if (entry && entry.icon && isValidMatch(nameParts[0].toLowerCase(), entry))
return ThemeIcons.iconFromName(entry.icon, "application-x-executable");
}
}
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
}
var binaryName = props["application.process.binary"] || "";
if (binaryName) {
var binParts = binaryName.split("/");
if (binParts.length > 0) {
var binName = binParts[binParts.length - 1].toLowerCase();
var entry = ThemeIcons.findAppEntry(binName);
if (entry && entry.icon && isValidMatch(binName, entry))
return ThemeIcons.iconFromName(entry.icon, "");
}
}
var iconName = props["application.icon-name"] || "";
if (iconName && ThemeIcons.iconExists(iconName)) {
var iconPath = ThemeIcons.iconFromName(iconName, "");
if (iconPath && iconPath !== "")
return iconPath;
}
var appId = props["application.id"] || "";
if (appId) {
var entry = ThemeIcons.findAppEntry(appId);
if (entry && entry.icon && isValidMatch(appId, entry))
return ThemeIcons.iconFromName(entry.icon, "");
}
var appName = props["application.name"] || "";
if (appName) {
var entry = ThemeIcons.findAppEntry(appName.toLowerCase());
if (entry && entry.icon && isValidMatch(appName.toLowerCase(), entry))
return ThemeIcons.iconFromName(entry.icon, "");
}
var name = modelData.name || "";
if (name) {
var nameParts = name.split(/[-_]/);
if (nameParts.length > 0) {
var entry = ThemeIcons.findAppEntry(nameParts[0].toLowerCase());
if (entry && entry.icon && isValidMatch(nameParts[0].toLowerCase(), entry))
return ThemeIcons.iconFromName(entry.icon, "");
}
}
return ThemeIcons.iconFromName("application-x-executable", "application-x-executable");
}
RowLayout {
id: appRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
// App Icon
IconImage {
id: appIconImage
Layout.preferredWidth: Style.baseWidgetSize
Layout.preferredHeight: Style.baseWidgetSize
source: appBox.appIcon
smooth: true
asynchronous: true
// Fallback icon if image fails to load
NIcon {
anchors.fill: parent
icon: "apps"
pointSize: Style.fontSizeXL
color: Color.mPrimary
visible: appIconImage.status === Image.Error || appIconImage.status === Image.Null || appBox.appIcon === ""
}
}
// App Name and Volume Slider
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NText {
text: appBox.appName || "Unknown App"
pointSize: Style.fontSizeM
color: Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NValueSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: (appBox.appVolume !== undefined) ? appBox.appVolume : 0.0
stepSize: 0.01
heightRatio: 0.5
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
onMoved: function (value) {
if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) {
appBox.nodeAudio.volume = value;
var key = AudioService.getAppKey(appBox.modelData);
if (key)
AudioService.setAppStreamVolume(key, value);
}
}
}
NText {
text: Math.round((appBox.appVolume !== undefined ? appBox.appVolume : 0.0) * 100) + "%"
pointSize: Style.fontSizeM
family: Settings.data.ui.fontFixed
color: Color.mOnSurface
opacity: enabled ? 1.0 : 0.6
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: 45 * Style.uiScaleRatio
horizontalAlignment: Text.AlignRight
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
}
// Mute Button
NIconButton {
icon: (appBox.appMuted === true) ? "volume-mute" : "volume-high"
tooltipText: (appBox.appMuted === true) ? I18n.tr("tooltips.unmute") : I18n.tr("tooltips.mute")
baseSize: Style.baseWidgetSize * 0.7
enabled: !!(appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true)
onClicked: {
if (appBox.nodeAudio && appBox.modelData && appBox.modelData.ready === true) {
var newMuted = !appBox.appMuted;
appBox.nodeAudio.muted = newMuted;
var key = AudioService.getAppKey(appBox.modelData);
if (key)
AudioService.setAppStreamMuted(key, newMuted);
}
}
}
}
}
}
}
}
// Empty state
NText {
visible: panelContent.appStreams.length === 0
text: I18n.tr("panels.audio.panel-applications-empty")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
Layout.topMargin: Style.marginXL
}
}
}
// Devices Tab
NScrollView {
id: devicesScrollView
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
contentWidth: availableWidth
reserveScrollbarSpace: false
gradientColor: Color.mSurface
// AudioService Devices
ColumnLayout {
spacing: Style.marginM
width: devicesScrollView.availableWidth
// -------------------------------
// Output Devices
ButtonGroup {
id: sinks
}
NBox {
Layout.fillWidth: true
Layout.preferredHeight: outputColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: outputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginS
NText {
text: I18n.tr("panels.audio.devices-output-device-label")
pointSize: Style.fontSizeL
color: Color.mPrimary
}
Repeater {
model: AudioService.sinks
NRadioButton {
ButtonGroup.group: sinks
required property PwNode modelData
pointSize: Style.fontSizeS
text: modelData.description
checked: AudioService.sink?.id === modelData.id
onClicked: {
AudioService.setAudioSink(modelData);
localOutputVolume = AudioService.volume;
}
Layout.fillWidth: true
}
}
}
}
// -------------------------------
// Input Devices
ButtonGroup {
id: sources
}
NBox {
Layout.fillWidth: true
Layout.preferredHeight: inputColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: inputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Style.marginM
spacing: Style.marginS
NText {
text: I18n.tr("panels.audio.devices-input-device-label")
pointSize: Style.fontSizeL
color: Color.mPrimary
}
Repeater {
model: AudioService.sources
NRadioButton {
ButtonGroup.group: sources
required property PwNode modelData
pointSize: Style.fontSizeS
text: modelData.description
checked: AudioService.source?.id === modelData.id
onClicked: AudioService.setAudioSource(modelData)
Layout.fillWidth: true
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,401 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Hardware
import qs.Services.Networking
import qs.Services.Power
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(460 * Style.uiScaleRatio)
panelContent: Item {
id: panelContent
property real contentPreferredHeight: mainLayout.implicitHeight + Style.margin2L
property var batteryWidgetInstance: BarService.lookupWidget("Battery", screen ? screen.name : null)
readonly property var batteryWidgetSettings: batteryWidgetInstance ? batteryWidgetInstance.widgetSettings : null
readonly property var batteryWidgetMetadata: BarWidgetRegistry.widgetMetadata["Battery"]
readonly property bool powerProfileAvailable: PowerProfileService.available
readonly property var powerProfiles: [PowerProfile.PowerSaver, PowerProfile.Balanced, PowerProfile.Performance]
readonly property bool profilesAvailable: PowerProfileService.available
property int profileIndex: profileToIndex(PowerProfileService.profile)
readonly property bool showPowerProfiles: panelID ? panelID.showPowerProfiles : resolveWidgetSetting("showPowerProfiles", false)
readonly property bool showNoctaliaPerformance: panelID ? panelID.showNoctaliaPerformance : resolveWidgetSetting("showNoctaliaPerformance", false)
readonly property bool isLowBattery: BatteryService.isLowBattery
readonly property bool isCriticalBattery: BatteryService.isCriticalBattery
readonly property var primaryDevice: BatteryService.primaryDevice
function profileToIndex(p) {
return powerProfiles.indexOf(p) ?? 1;
}
function indexToProfile(idx) {
return powerProfiles[idx] ?? PowerProfile.Balanced;
}
function setProfileByIndex(idx) {
var prof = indexToProfile(idx);
profileIndex = idx;
PowerProfileService.setProfile(prof);
}
function resolveWidgetSetting(key, defaultValue) {
if (batteryWidgetSettings && batteryWidgetSettings[key] !== undefined)
return batteryWidgetSettings[key];
if (batteryWidgetMetadata && batteryWidgetMetadata[key] !== undefined)
return batteryWidgetMetadata[key];
return defaultValue;
}
Connections {
target: PowerProfileService
function onProfileChanged() {
panelContent.profileIndex = panelContent.profileToIndex(PowerProfileService.profile);
}
}
Connections {
target: BarService
function onActiveWidgetsChanged() {
panelContent.batteryWidgetInstance = BarService.lookupWidget("Battery", screen ? screen.name : null);
}
}
ColumnLayout {
id: mainLayout
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// HEADER
NBox {
Layout.fillWidth: true
implicitHeight: headerRow.implicitHeight + Style.margin2M
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
NIcon {
pointSize: Style.fontSizeXXL
color: (BatteryService.isCharging(primaryDevice) || BatteryService.isPluggedIn(primaryDevice)) ? Color.mPrimary : (BatteryService.isCriticalBattery(primaryDevice) || BatteryService.isLowBattery(primaryDevice)) ? Color.mError : Color.mOnSurface
icon: BatteryService.getIcon(BatteryService.getPercentage(primaryDevice), BatteryService.isCharging(primaryDevice), BatteryService.isPluggedIn(primaryDevice), BatteryService.isDeviceReady(primaryDevice))
}
ColumnLayout {
spacing: Style.marginXXS
Layout.fillWidth: true
NText {
text: I18n.tr("common.battery")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
elide: Text.ElideRight
}
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
baseSize: Style.baseWidgetSize * 0.8
onClicked: root.close()
}
}
}
// Charge level + health/time
NBox {
Layout.fillWidth: true
implicitHeight: chargeLayout.implicitHeight + Style.margin2L
visible: BatteryService.laptopBatteries.length > 0 || BatteryService.bluetoothBatteries.length > 0
ColumnLayout {
id: chargeLayout
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginL
// Laptop batteries section
Repeater {
model: BatteryService.laptopBatteries
delegate: ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
RowLayout {
Item {
id: batteryInfoItem
implicitWidth: batteryInfoRow.implicitWidth
implicitHeight: batteryInfoRow.implicitHeight
RowLayout {
id: batteryInfoRow
anchors.fill: parent
NIcon {
icon: BatteryService.getIcon(BatteryService.getPercentage(modelData), BatteryService.isCharging(modelData), BatteryService.isPluggedIn(modelData), BatteryService.isDeviceReady(modelData))
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
}
NText {
readonly property string dName: BatteryService.getDeviceName(modelData)
text: dName ? dName : I18n.tr("common.battery")
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
pointSize: Style.fontSizeS
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: {
if (modelData.healthSupported) {
TooltipService.show(batteryInfoItem, `${I18n.tr("battery.battery-health")}: ${Math.round(modelData.healthPercentage)}%`);
}
}
onExited: TooltipService.hide(batteryInfoItem)
}
}
Item {
Layout.fillWidth: true
}
NText {
text: BatteryService.getTimeRemainingText(modelData)
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
Rectangle {
Layout.fillWidth: true
height: Math.round(8 * Style.uiScaleRatio)
radius: Math.min(Style.radiusL, height / 2)
color: Color.mSurface
Rectangle {
anchors.verticalCenter: parent.verticalCenter
height: parent.height
radius: parent.radius
width: {
var p = BatteryService.getPercentage(modelData);
var ratio = Math.max(0, Math.min(1, p / 100));
return parent.width * ratio;
}
color: Color.mPrimary
}
}
NText {
Layout.preferredWidth: 40 * Style.uiScaleRatio
horizontalAlignment: Text.AlignRight
text: `${BatteryService.getPercentage(modelData)}%`
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
pointSize: Style.fontSizeS
font.weight: Style.fontWeightBold
}
}
}
}
}
}
NDivider {
Layout.fillWidth: true
visible: BatteryService.laptopBatteries.length > 0 && BatteryService.bluetoothBatteries.length > 0
}
// Other devices (Bluetooth) section
Repeater {
model: BatteryService.bluetoothBatteries
delegate: ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
icon: BluetoothService.getDeviceIcon(modelData)
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
}
NText {
readonly property string dName: BatteryService.getDeviceName(modelData)
text: dName ? dName : I18n.tr("common.bluetooth")
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
pointSize: Style.fontSizeS
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
Rectangle {
Layout.fillWidth: true
height: Math.round(8 * Style.uiScaleRatio)
radius: Math.min(Style.radiusL, height / 2)
color: Color.mSurface
Rectangle {
anchors.verticalCenter: parent.verticalCenter
height: parent.height
radius: parent.radius
width: {
var p = BatteryService.getPercentage(modelData);
var ratio = Math.max(0, Math.min(1, p / 100));
return parent.width * ratio;
}
color: Color.mPrimary
}
}
NText {
Layout.preferredWidth: 40 * Style.uiScaleRatio
horizontalAlignment: Text.AlignRight
text: `${BatteryService.getPercentage(modelData)}%`
color: (BatteryService.isCharging(modelData) || BatteryService.isPluggedIn(modelData)) ? Color.mPrimary : (BatteryService.isCriticalBattery(modelData) || BatteryService.isLowBattery(modelData)) ? Color.mError : Color.mOnSurface
pointSize: Style.fontSizeS
font.weight: Style.fontWeightBold
}
}
}
}
}
}
NBox {
Layout.fillWidth: true
height: controlsLayout.implicitHeight + Style.margin2L
visible: showPowerProfiles || showNoctaliaPerformance
ColumnLayout {
id: controlsLayout
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
ColumnLayout {
visible: powerProfileAvailable && showPowerProfiles
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NText {
text: I18n.tr("battery.power-profile")
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NText {
text: PowerProfileService.getName(profileIndex)
color: Color.mOnSurfaceVariant
}
}
NValueSlider {
Layout.fillWidth: true
from: 0
to: 2
stepSize: 1
snapAlways: true
heightRatio: 0.5
value: profileIndex
enabled: profilesAvailable
onPressedChanged: (pressed, v) => {
if (!pressed) {
setProfileByIndex(v);
}
}
onMoved: v => {
profileIndex = v;
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
icon: "powersaver"
pointSize: Style.fontSizeS
color: PowerProfileService.getIcon() === "powersaver" ? Color.mPrimary : Color.mOnSurfaceVariant
}
NIcon {
icon: "balanced"
pointSize: Style.fontSizeS
color: PowerProfileService.getIcon() === "balanced" ? Color.mPrimary : Color.mOnSurfaceVariant
Layout.fillWidth: true
}
NIcon {
icon: "performance"
pointSize: Style.fontSizeS
color: PowerProfileService.getIcon() === "performance" ? Color.mPrimary : Color.mOnSurfaceVariant
}
}
}
NDivider {
Layout.fillWidth: true
visible: showPowerProfiles && PowerProfileService.available && showNoctaliaPerformance
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
visible: showNoctaliaPerformance
NText {
text: I18n.tr("toast.noctalia-performance.label")
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIcon {
icon: PowerProfileService.noctaliaPerformanceMode ? "rocket" : "rocket-off"
pointSize: Style.fontSizeL
color: PowerProfileService.noctaliaPerformanceMode ? Color.mPrimary : Color.mOnSurfaceVariant
}
NToggle {
checked: PowerProfileService.noctaliaPerformanceMode
onToggled: checked => PowerProfileService.noctaliaPerformanceMode = checked
}
}
}
}
}
}
}
@@ -0,0 +1,208 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import "../Settings/Tabs/Connections" as BluetoothPrefs
import qs.Commons
import qs.Modules.MainScreen
import qs.Modules.Panels.Settings
import qs.Services.Hardware
import qs.Services.Networking
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(500 * Style.uiScaleRatio)
panelContent: Rectangle {
id: panelContent
color: "transparent"
property real contentPreferredHeight: Math.min(root.preferredHeight, mainColumn.implicitHeight + Style.margin2L)
ColumnLayout {
id: mainColumn
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// Header
NBox {
Layout.fillWidth: true
Layout.preferredHeight: headerRow.implicitHeight + Style.margin2M
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: Style.marginM
NIcon {
icon: BluetoothService.enabled ? "bluetooth" : "bluetooth-off"
pointSize: Style.fontSizeXXL
color: BluetoothService.enabled ? Color.mPrimary : Color.mOnSurfaceVariant
}
NLabel {
label: I18n.tr("common.bluetooth")
Layout.fillWidth: true
}
NToggle {
id: bluetoothSwitch
checked: BluetoothService.enabled
enabled: !NetworkService.airplaneModeEnabled && BluetoothService.bluetoothAvailable
onToggled: checked => BluetoothService.setBluetoothEnabled(checked)
baseSize: Style.baseWidgetSize * 0.65
}
NIconButton {
icon: Settings.data.network.bluetoothAutoConnect ? "bluetooth-connected" : "bluetooth"
tooltipText: Settings.data.network.bluetoothAutoConnect ? I18n.tr("tooltips.bluetooth-auto-connect-on") : I18n.tr("tooltips.bluetooth-auto-connect-off")
colorFg: Settings.data.network.bluetoothAutoConnect ? Color.mPrimary : Color.mOnSurfaceVariant
baseSize: Style.baseWidgetSize * 0.8
onClicked: Settings.data.network.bluetoothAutoConnect = !Settings.data.network.bluetoothAutoConnect
}
NIconButton {
icon: "settings"
tooltipText: I18n.tr("tooltips.open-settings")
baseSize: Style.baseWidgetSize * 0.8
onClicked: SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 1, screen)
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
root.close();
}
}
}
}
NScrollView {
id: bluetoothScrollView
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
reserveScrollbarSpace: false
gradientColor: Color.mSurface
ColumnLayout {
id: devicesList
width: bluetoothScrollView.availableWidth
spacing: Style.marginM
// Adapter not available of disabled
NBox {
id: disabledBox
visible: !BluetoothService.enabled
Layout.fillWidth: true
Layout.preferredHeight: disabledColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: disabledColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "bluetooth-off"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("bluetooth.panel.disabled")
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("bluetooth.panel.enable-message")
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
Item {
Layout.fillHeight: true
}
}
}
// Empty state when no paired devices
NBox {
id: emptyBox
visible: {
if (!BluetoothService.enabled || !BluetoothService.devices)
return false;
// Pulling pairedDevices count from the source component
return (btSource.pairedDevices.length === 0 && btSource.connectedDevices.length === 0);
}
Layout.fillWidth: true
Layout.preferredHeight: emptyColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: emptyColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "bluetooth"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("bluetooth.panel.no-devices")
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NButton {
text: I18n.tr("common.settings")
icon: "settings"
Layout.alignment: Qt.AlignHCenter
onClicked: SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 1, screen)
}
Item {
Layout.fillHeight: true
}
}
}
// Pull connected/paired lists from BluetoothSubTab
BluetoothPrefs.BluetoothSubTab {
id: btSource
Layout.fillWidth: true
showOnlyLists: true
visible: !disabledBox.visible && !emptyBox.visible
}
}
}
}
}
}
@@ -0,0 +1,285 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Compositor
import qs.Services.Hardware
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(420 * Style.uiScaleRatio)
panelContent: Item {
id: panelContent
property real contentPreferredHeight: mainColumn.implicitHeight + Style.margin2L
property var brightnessWidgetInstance: BarService.lookupWidget("Brightness", screen ? screen.name : null)
readonly property var brightnessWidgetSettings: brightnessWidgetInstance ? brightnessWidgetInstance.widgetSettings : null
readonly property var brightnessWidgetMetadata: BarWidgetRegistry.widgetMetadata["Brightness"]
function resolveWidgetSetting(key, defaultValue) {
if (brightnessWidgetSettings && brightnessWidgetSettings[key] !== undefined)
return brightnessWidgetSettings[key];
if (brightnessWidgetMetadata && brightnessWidgetMetadata[key] !== undefined)
return brightnessWidgetMetadata[key];
return defaultValue;
}
Connections {
target: BarService
function onActiveWidgetsChanged() {
panelContent.brightnessWidgetInstance = BarService.lookupWidget("Brightness", screen ? screen.name : null);
}
}
property real globalBrightness: 0
property bool globalBrightnessChanging: false
property int globalBrightnessCapableMonitors: 0
function getIcon(brightness) {
return brightness <= 0.5 ? "brightness-low" : "brightness-high";
}
function getControllableMonitors() {
var monitors = BrightnessService.monitors || [];
return monitors.filter(m => m && m.brightnessControlAvailable);
}
function updateGlobalBrightness() {
var monitors = getControllableMonitors();
panelContent.globalBrightnessCapableMonitors = monitors.length;
if (panelContent.globalBrightnessChanging)
return;
if (monitors.length === 0) {
panelContent.globalBrightness = 0;
return;
}
var total = 0;
monitors.forEach(m => {
var brightnessValue = isNaN(m.brightness) ? 0 : m.brightness;
total += brightnessValue;
});
panelContent.globalBrightness = total / monitors.length;
}
function applyGlobalBrightness(value) {
var monitors = BrightnessService.monitors || [];
monitors.forEach(m => {
if (m && m.brightnessControlAvailable) {
m.setBrightness(value);
}
});
}
Component.onCompleted: updateGlobalBrightness()
Connections {
target: BrightnessService
function onMonitorBrightnessChanged(monitor, newBrightness) {
panelContent.updateGlobalBrightness();
}
function onMonitorsChanged() {
panelContent.updateGlobalBrightness();
}
function onDdcMonitorsChanged() {
panelContent.updateGlobalBrightness();
}
}
ColumnLayout {
id: mainColumn
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// HEADER
NBox {
Layout.fillWidth: true
implicitHeight: headerRow.implicitHeight + Style.margin2M
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
NIcon {
icon: "settings-display"
pointSize: Style.fontSizeXXL
color: Color.mPrimary
}
NText {
text: I18n.tr("panels.display.title")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
root.close();
}
}
}
}
NScrollView {
id: brightnessScrollView
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
contentWidth: availableWidth
reserveScrollbarSpace: false
gradientColor: Color.mSurface
// AudioService Devices
ColumnLayout {
spacing: Style.marginM
width: brightnessScrollView.availableWidth
NBox {
Layout.fillWidth: true
visible: panelContent.globalBrightnessCapableMonitors > 1 && panelContent.resolveWidgetSetting("applyToAllMonitors", false)
implicitHeight: globalBrightnessContent.implicitHeight + (Style.marginXL)
ColumnLayout {
id: globalBrightnessContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginS
NLabel {
label: I18n.tr("panels.display.monitors-global-brightness-label")
description: I18n.tr("panels.display.monitors-global-brightness-description")
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
icon: panelContent.getIcon(panelContent.globalBrightness)
pointSize: Style.fontSizeXL
color: Color.mOnSurface
}
NValueSlider {
id: globalBrightnessSlider
from: 0
to: 1
value: panelContent.globalBrightness
stepSize: 0.01
enabled: panelContent.globalBrightnessCapableMonitors > 0
onMoved: value => {
panelContent.globalBrightness = value;
panelContent.applyGlobalBrightness(value);
}
onPressedChanged: (pressed, value) => {
panelContent.globalBrightnessChanging = pressed;
panelContent.globalBrightness = value;
panelContent.applyGlobalBrightness(value);
}
Layout.fillWidth: true
text: ""
}
NText {
text: panelContent.globalBrightnessCapableMonitors > 0 ? Math.round(panelContent.globalBrightness * 100) + "%" : "N/A"
Layout.preferredWidth: 55
horizontalAlignment: Text.AlignRight
Layout.alignment: Qt.AlignVCenter
}
}
}
}
Repeater {
model: Quickshell.screens || []
delegate: NBox {
Layout.fillWidth: true
Layout.preferredHeight: outputColumn.implicitHeight + Style.margin2M
property var brightnessMonitor: BrightnessService.getMonitorForScreen(modelData)
readonly property real compositorScale: {
const info = CompositorService.displayScales[modelData.name];
return (info && info.scale) ? info.scale : 1.0;
}
ColumnLayout {
id: outputColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM
spacing: Style.marginS
NLabel {
label: modelData.name || "Unknown"
labelColor: Color.mPrimary
description: {
I18n.tr("system.monitor-description", {
"model": modelData.model,
"width": modelData.width * compositorScale,
"height": modelData.height * compositorScale,
"scale": compositorScale
});
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
icon: getIcon(brightnessMonitor ? brightnessMonitor.brightness : 0)
pointSize: Style.fontSizeXL
color: Color.mOnSurface
}
NValueSlider {
id: brightnessSlider
from: 0
to: 1
value: brightnessMonitor ? brightnessMonitor.brightness : 0.5
stepSize: 0.01
enabled: brightnessMonitor ? brightnessMonitor.brightnessControlAvailable : false
onMoved: value => {
if (brightnessMonitor && brightnessMonitor.brightnessControlAvailable) {
brightnessMonitor.setBrightness(value);
}
}
onPressedChanged: (pressed, value) => {
if (brightnessMonitor && brightnessMonitor.brightnessControlAvailable) {
brightnessMonitor.setBrightness(value);
}
}
Layout.fillWidth: true
text: brightnessMonitor ? Math.round(brightnessSlider.value * 100) + "%" : "N/A"
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,202 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Noctalia
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(820 * Style.uiScaleRatio)
preferredHeight: Math.round(620 * Style.uiScaleRatio)
panelAnchorHorizontalCenter: true
panelAnchorVerticalCenter: true
panelContent: Rectangle {
id: panelContent
color: Color.mSurfaceVariant
radius: Style.radiusM
border.color: Color.mOutline
border.width: Style.borderS
readonly property string currentVersion: UpdateService.currentVersion
readonly property string previousVersion: UpdateService.previousVersion
readonly property bool hasPreviousVersion: previousVersion && previousVersion.length > 0
readonly property string releaseContent: UpdateService.releaseContent || ""
function colorizeHeaders(text) {
if (!text)
return "";
const color = Color.mPrimary;
return text.replace(/^# (.*)$/gm, `<br/><h1 style="color:${color}">$1</h1><br/>`).replace(/^## (.*)$/gm, `<br/><h2 style="color:${color}">$1</h2><br/>`);
}
readonly property string subtitleText: hasPreviousVersion ? I18n.tr("changelog.panel.subtitle-updated", {
"previousVersion": previousVersion
}) : I18n.tr("changelog.panel.subtitle-fresh")
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
RowLayout {
Layout.fillWidth: true
spacing: Style.marginM
NIcon {
icon: "sparkles"
color: Color.mPrimary
pointSize: Style.fontSizeXXL
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NText {
text: I18n.tr("changelog.panel.title", {
"version": currentVersion || UpdateService.currentVersion
})
pointSize: Style.fontSizeXL
font.weight: Style.fontWeightBold
color: Color.mPrimary
wrapMode: Text.WordWrap
}
NText {
text: subtitleText
color: Color.mOnSurface
opacity: Style.opacityMedium
wrapMode: Text.WordWrap
}
}
Item {
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
onClicked: root.close()
Layout.alignment: Qt.AlignTop | Qt.AlignRight
Layout.preferredHeight: Style.baseWidgetSize
Layout.preferredWidth: Style.baseWidgetSize
}
}
Rectangle {
clip: true
Layout.fillWidth: true
color: Qt.alpha(Color.mPrimary, 0.08)
radius: Style.radiusS
border.color: Color.mPrimary
border.width: Style.borderS
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginS
NText {
text: hasPreviousVersion ? previousVersion : I18n.tr("changelog.panel.version-new-user")
font.weight: Style.fontWeightSemiBold
color: Color.mPrimary
}
NIcon {
icon: "arrow-right"
color: Color.mPrimary
}
NText {
text: currentVersion || UpdateService.currentVersion
font.weight: Style.fontWeightSemiBold
color: Color.mPrimary
}
}
}
NDivider {
Layout.fillWidth: true
}
NScrollView {
id: changelogScrollView
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
padding: 0
ColumnLayout {
width: changelogScrollView.availableWidth
spacing: Style.marginM
NText {
visible: UpdateService.fetchError !== ""
text: UpdateService.fetchError
color: Color.mError
wrapMode: Text.WordWrap
}
NText {
Layout.fillWidth: true
visible: releaseContent.length > 0
text: panelContent.colorizeHeaders(releaseContent)
wrapMode: Text.WordWrap
elide: Text.ElideNone
textFormat: Text.MarkdownText
color: Color.mOnSurface
}
NText {
visible: releaseContent.length === 0
text: I18n.tr("changelog.panel.empty")
color: Color.mOnSurfaceVariant
wrapMode: Text.WordWrap
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NButton {
Layout.fillWidth: true
icon: "brand-discord"
text: I18n.tr("changelog.panel.buttons-discord")
outlined: true
onClicked: UpdateService.openDiscord()
}
NButton {
Layout.fillWidth: true
visible: UpdateService.feedbackUrl !== ""
icon: "forms"
text: I18n.tr("changelog.panel.buttons-feedback")
outlined: true
onClicked: UpdateService.openFeedbackForm()
}
NButton {
Layout.fillWidth: true
icon: "check"
text: I18n.tr("changelog.panel.buttons-dismiss")
onClicked: root.close()
}
}
}
}
onClosed: {
if (UpdateService && UpdateService.changelogCurrentVersion) {
UpdateService.markChangelogSeen(UpdateService.changelogCurrentVersion);
}
}
}
@@ -0,0 +1,76 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Modules.Cards
import qs.Modules.MainScreen
import qs.Services.Location
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
panelContent: Item {
id: panelContent
anchors.fill: parent
readonly property real contentPreferredWidth: Math.round((Settings.data.location.showWeekNumberInCalendar ? 440 : 420) * Style.uiScaleRatio)
readonly property real contentPreferredHeight: content.implicitHeight + Style.margin2L
ColumnLayout {
id: content
x: Style.marginL
y: Style.marginL
width: parent.width - Style.margin2L
spacing: Style.marginL
// All clock panel cards
Repeater {
model: Settings.data.calendar.cards
Loader {
active: modelData.enabled && (modelData.id !== "weather-card" || Settings.data.location.weatherEnabled)
visible: active
Layout.fillWidth: true
sourceComponent: {
switch (modelData.id) {
case "calendar-header-card":
return calendarHeaderCard;
case "calendar-month-card":
return calendarMonthCard;
case "weather-card":
return weatherCard;
default:
return null;
}
}
}
}
}
}
Component {
id: calendarHeaderCard
CalendarHeaderCard {
Layout.fillWidth: true
}
}
Component {
id: calendarMonthCard
CalendarMonthCard {
Layout.fillWidth: true
}
}
Component {
id: weatherCard
WeatherCard {
Layout.fillWidth: true
forecastDays: 5
showLocation: false
}
}
}
@@ -0,0 +1,191 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.Cards
import qs.Modules.MainScreen
import qs.Services.Media
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
// Positioning
readonly property string controlCenterPosition: Settings.data.controlCenter.position
// Check if there's a bar on this screen
readonly property bool hasBarOnScreen: {
var monitors = Settings.data.bar.monitors || [];
return monitors.length === 0 || monitors.includes(screen?.name);
}
// When position is "close_to_bar_button" but there's no bar, fall back to center
readonly property bool shouldCenter: controlCenterPosition === "close_to_bar_button" && !hasBarOnScreen
panelAnchorHorizontalCenter: shouldCenter || (controlCenterPosition !== "close_to_bar_button" && (controlCenterPosition.endsWith("_center") || controlCenterPosition === "center"))
panelAnchorVerticalCenter: shouldCenter || controlCenterPosition === "center"
panelAnchorLeft: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_left")
panelAnchorRight: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_right")
panelAnchorBottom: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("bottom_")
panelAnchorTop: !shouldCenter && controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("top_")
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: {
var height = 0;
var count = 0;
for (var i = 0; i < Settings.data.controlCenter.cards.length; i++) {
const card = Settings.data.controlCenter.cards[i];
if (!card.enabled)
continue;
const contributes = (card.id !== "weather-card" || Settings.data.location.weatherEnabled);
if (!contributes)
continue;
count++;
switch (card.id) {
case "profile-card":
height += profileHeight;
break;
case "shortcuts-card":
height += shortcutsHeight;
break;
case "audio-card":
height += audioHeight;
break;
case "brightness-card":
height += brightnessHeight;
break;
case "weather-card":
height += weatherHeight;
break;
case "media-sysmon-card":
height += mediaSysMonHeight;
break;
default:
break;
}
}
return height + (count + 1) * Style.marginL;
}
readonly property int profileHeight: Math.round(64 * Style.uiScaleRatio)
readonly property int shortcutsHeight: Math.round(52 * Style.uiScaleRatio)
readonly property int audioHeight: Math.round(60 * Style.uiScaleRatio)
readonly property int brightnessHeight: Math.round(60 * Style.uiScaleRatio)
readonly property int mediaSysMonHeight: Math.round(260 * Style.uiScaleRatio)
// We keep a dynamic weather height due to a more complex layout and font scaling
property int weatherHeight: Math.round(210 * Style.uiScaleRatio)
onOpened: {
MediaService.autoSwitchingPaused = true;
}
onClosed: {
MediaService.autoSwitchingPaused = false;
}
panelContent: Item {
id: panelContent
ColumnLayout {
id: layout
x: Style.marginL
y: Style.marginL
width: parent.width - Style.margin2L
spacing: Style.marginL
Repeater {
model: Settings.data.controlCenter.cards
Loader {
active: modelData.enabled && (modelData.id !== "weather-card" || Settings.data.location.weatherEnabled)
visible: active
Layout.fillWidth: true
Layout.preferredHeight: {
switch (modelData.id) {
case "profile-card":
return profileHeight;
case "shortcuts-card":
return shortcutsHeight;
case "audio-card":
return audioHeight;
case "brightness-card":
return brightnessHeight;
case "weather-card":
return weatherHeight;
case "media-sysmon-card":
return mediaSysMonHeight;
default:
return 0;
}
}
sourceComponent: {
switch (modelData.id) {
case "profile-card":
return profileCard;
case "shortcuts-card":
return shortcutsCard;
case "audio-card":
return audioCard;
case "brightness-card":
return brightnessCard;
case "weather-card":
return weatherCard;
case "media-sysmon-card":
return mediaSysMonCard;
}
}
}
}
}
Component {
id: profileCard
ProfileCard {}
}
Component {
id: shortcutsCard
ShortcutsCard {}
}
Component {
id: audioCard
AudioCard {}
}
Component {
id: brightnessCard
BrightnessCard {}
}
Component {
id: weatherCard
WeatherCard {
Component.onCompleted: {
root.weatherHeight = this.height;
}
}
}
Component {
id: mediaSysMonCard
RowLayout {
spacing: Style.marginL
// Media card
MediaCard {
Layout.fillWidth: true
Layout.fillHeight: true
}
// System monitors combined in one card
SystemMonitorCard {
Layout.preferredWidth: Math.round(Style.baseWidgetSize * 2.625)
Layout.fillHeight: true
}
}
}
}
}
@@ -0,0 +1,114 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
Item {
id: root
required property string widgetId
required property var widgetScreen
required property var widgetProps
property string section: widgetProps && widgetProps.section || ""
property int sectionIndex: widgetProps && widgetProps.sectionWidgetIndex || 0
// Don't reserve space unless the loaded widget is really visible
implicitWidth: getImplicitSize(loader.item, "implicitWidth")
implicitHeight: getImplicitSize(loader.item, "implicitHeight")
function getImplicitSize(item, prop) {
return (item && item.visible) ? item[prop] : 0;
}
readonly property bool _isPlugin: ControlCenterWidgetRegistry.isPluginWidget(widgetId)
function _loadPluginWidget() {
var comp = ControlCenterWidgetRegistry.getWidget(widgetId);
if (!comp)
return;
var pluginId = widgetId.substring(7); // Remove "plugin:" prefix
var api = PluginService.getPluginAPI(pluginId);
loader.setSource(comp.url, api ? {
"pluginApi": api
} : {});
}
Loader {
id: loader
anchors.fill: parent
asynchronous: false
// Core widgets use sourceComponent; plugin widgets use setSource()
// so pluginApi is available from the first binding evaluation.
Component.onCompleted: {
if (root._isPlugin) {
root._loadPluginWidget();
} else {
sourceComponent = Qt.binding(function () {
return ControlCenterWidgetRegistry.getWidget(widgetId);
});
}
}
onLoaded: {
if (!item)
return;
// Apply properties to loaded widget
for (var prop in widgetProps) {
if (item.hasOwnProperty(prop)) {
item[prop] = widgetProps[prop];
}
}
// Set screen property
if (item.hasOwnProperty("screen")) {
item.screen = widgetScreen;
}
// Call custom onLoaded if it exists
if (item.hasOwnProperty("onLoaded")) {
item.onLoaded();
}
}
Component.onDestruction: {
// Explicitly clear references
widgetProps = null;
}
}
// Error handling
Component.onCompleted: {
if (!ControlCenterWidgetRegistry.hasWidget(widgetId)) {
Logger.w("ControlCenterWidgetLoader", "Widget not found in registry:", widgetId);
// Retry briefly in case the registry initializes after this component
retryTimer.start();
}
}
// Retry mechanism to cope with early evaluation before registry is ready
Timer {
id: retryTimer
interval: 150
repeat: true
running: false
property int attempts: 0
onTriggered: {
attempts += 1;
if (ControlCenterWidgetRegistry.hasWidget(widgetId)) {
loader.sourceComponent = ControlCenterWidgetRegistry.getWidget(widgetId);
stop();
attempts = 0;
return;
}
if (attempts >= 20) { // ~3s max
stop();
attempts = 0;
Logger.w("ControlCenterWidgetLoader", "Giving up waiting for widget:", widgetId);
}
}
}
}
@@ -0,0 +1,18 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Networking
import qs.Services.UI
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: !NetworkService.airplaneModeEnabled ? "plane-off" : "plane"
hot: NetworkService.airplaneModeEnabled
tooltipText: I18n.tr("toast.airplane-mode.title")
onClicked: {
NetworkService.setAirplaneMode(!NetworkService.airplaneModeEnabled);
}
enabled: NetworkService.wifiAvailable && BluetoothService.bluetoothAvailable
}
@@ -0,0 +1,23 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Networking
import qs.Services.UI
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: !BluetoothService.enabled ? "bluetooth-off" : ((BluetoothService.connectedDevices && BluetoothService.connectedDevices.length > 0) ? "bluetooth-connected" : "bluetooth")
tooltipText: I18n.tr("common.bluetooth")
onClicked: {
var p = PanelService.getPanel("bluetoothPanel", screen);
if (p)
p.toggle(this);
}
onRightClicked: {
if (!NetworkService.airplaneModeEnabled) {
BluetoothService.setBluetoothEnabled(!BluetoothService.enabled);
}
}
}
@@ -0,0 +1,178 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Widgets
Item {
id: root
property string widgetId: "CustomButton"
property var widgetSettings: null
property string onClickedCommand: ""
property string onRightClickedCommand: ""
property string onMiddleClickedCommand: ""
property string stateChecksJson: "[]" // Store state checks as JSON string
property string generalTooltipText: ""
property bool enableOnStateLogic: false
property bool showExecTooltip: true
property bool _hasCustomTooltip: false
property string _currentIcon: "heart" // Default icon
property bool _isHot: false
property var _parsedStateChecks: [] // Local array for parsed state checks
property int _currentStateCheckIndex: -1
property string _activeStateIcon: ""
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
Connections {
target: root
function _updatePropertiesFromSettings() {
if (!widgetSettings) {
return;
}
onClickedCommand = widgetSettings.onClicked || "";
onRightClickedCommand = widgetSettings.onRightClicked || "";
onMiddleClickedCommand = widgetSettings.onMiddleClicked || "";
stateChecksJson = widgetSettings.stateChecksJson || "[]"; // Populate from widgetSettings
try {
_parsedStateChecks = JSON.parse(stateChecksJson);
} catch (e) {
Logger.e("CustomButton", "Failed to parse stateChecksJson:", e.message);
_parsedStateChecks = [];
}
generalTooltipText = widgetSettings.generalTooltipText || "";
_hasCustomTooltip = !!(widgetSettings.generalTooltipText && widgetSettings.generalTooltipText.trim() !== "");
enableOnStateLogic = widgetSettings.enableOnStateLogic || false;
showExecTooltip = widgetSettings.showExecTooltip !== undefined ? widgetSettings.showExecTooltip : true;
updateState();
}
function onWidgetSettingsChanged() {
if (widgetSettings) {
_updatePropertiesFromSettings();
}
}
}
Process {
id: stateCheckProcessExecutor
running: false
command: _currentStateCheckIndex !== -1 && _parsedStateChecks.length > _currentStateCheckIndex ? ["sh", "-lc", _parsedStateChecks[_currentStateCheckIndex].command] : []
onExited: function (exitCode, stdout, stderr) {
var currentCheckItem = _parsedStateChecks[_currentStateCheckIndex];
var currentCommand = currentCheckItem.command;
if (exitCode === 0) {
// Command succeeded, this is the active state
_isHot = true;
_activeStateIcon = currentCheckItem.icon || widgetSettings.icon || "heart";
} else {
// Command failed, try next one
_currentStateCheckIndex++;
_checkNextState();
}
}
}
Timer {
id: stateUpdateTimer
interval: 200
running: false
repeat: false
onTriggered: {
if (enableOnStateLogic && _parsedStateChecks.length > 0) {
_currentStateCheckIndex = 0;
_checkNextState();
} else {
_isHot = false;
_activeStateIcon = widgetSettings.icon || "heart";
}
}
}
function _checkNextState() {
if (_currentStateCheckIndex < _parsedStateChecks.length) {
var currentCheckItem = _parsedStateChecks[_currentStateCheckIndex];
if (currentCheckItem && currentCheckItem.command) {
stateCheckProcessExecutor.running = true;
} else {
_currentStateCheckIndex++;
_checkNextState();
}
} else {
// All checks failed
_isHot = false;
_activeStateIcon = widgetSettings.icon || "heart";
}
}
function updateState() {
if (!enableOnStateLogic || _parsedStateChecks.length === 0) {
_isHot = false;
_activeStateIcon = widgetSettings.icon || "heart";
return;
}
stateUpdateTimer.restart();
}
function _buildTooltipText() {
// Build tooltip based on settings
let tooltip = "";
// If user set custom text, always show it
// If no custom text and showExecTooltip is off, show i18n default
if (_hasCustomTooltip) {
tooltip = generalTooltipText;
} else if (!showExecTooltip) {
tooltip = I18n.tr("bar.custom-button.default-tooltip");
}
// Add command details if enabled
if (showExecTooltip) {
if (onClickedCommand) {
tooltip += (tooltip ? "\n" : "") + I18n.tr("bar.custom-button.left-click-label") + `: ${onClickedCommand}`;
}
if (onRightClickedCommand) {
tooltip += (tooltip ? "\n" : "") + I18n.tr("bar.custom-button.right-click-label") + `: ${onRightClickedCommand}`;
}
if (onMiddleClickedCommand) {
tooltip += (tooltip ? "\n" : "") + I18n.tr("bar.custom-button.middle-click-label") + `: ${onMiddleClickedCommand}`;
}
}
return tooltip;
}
NIconButtonHot {
id: button
icon: _activeStateIcon
hot: _isHot
tooltipText: _buildTooltipText()
onClicked: {
if (onClickedCommand) {
Quickshell.execDetached(["sh", "-lc", onClickedCommand]);
updateState();
}
}
onRightClicked: {
if (onRightClickedCommand) {
Quickshell.execDetached(["sh", "-lc", onRightClickedCommand]);
updateState();
}
}
onMiddleClicked: {
if (onMiddleClickedCommand) {
Quickshell.execDetached(["sh", "-lc", onMiddleClickedCommand]);
updateState();
}
}
}
}
@@ -0,0 +1,13 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Power
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: "dark-mode"
tooltipText: Settings.data.colorSchemes.darkMode ? I18n.tr("tooltips.switch-to-light-mode") : I18n.tr("tooltips.switch-to-dark-mode")
onClicked: Settings.data.colorSchemes.darkMode = !Settings.data.colorSchemes.darkMode
}
@@ -0,0 +1,14 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Power
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: IdleInhibitorService.isInhibited ? "keep-awake-on" : "keep-awake-off"
hot: IdleInhibitorService.isInhibited
tooltipText: I18n.tr("tooltips.keep-awake")
onClicked: IdleInhibitorService.manualToggle()
}
@@ -0,0 +1,21 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Networking
import qs.Services.UI
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: NetworkService.getIcon()
tooltipText: NetworkService.getStatusText(true)
onClicked: {
var panel = PanelService.getPanel("networkPanel", screen);
panel?.toggle(this);
}
onRightClicked: {
if (!NetworkService.airplaneModeEnabled) {
NetworkService.setWifiEnabled(!NetworkService.wifiEnabled);
}
}
}
@@ -0,0 +1,34 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.Panels.Settings
import qs.Services.System
import qs.Services.UI
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
enabled: ProgramCheckerService.wlsunsetAvailable
icon: Settings.data.nightLight.enabled ? (Settings.data.nightLight.forced ? "nightlight-forced" : "nightlight-on") : "nightlight-off"
hot: Settings.data.nightLight.enabled
tooltipText: I18n.tr("common.night-light")
onClicked: {
if (!Settings.data.nightLight.enabled) {
Settings.data.nightLight.enabled = true;
Settings.data.nightLight.forced = false;
} else if (Settings.data.nightLight.enabled && !Settings.data.nightLight.forced) {
Settings.data.nightLight.forced = true;
} else {
Settings.data.nightLight.enabled = false;
Settings.data.nightLight.forced = false;
}
}
onRightClicked: {
var settingsPanel = PanelService.getPanel("settingsPanel", screen);
settingsPanel.requestedTab = SettingsPanel.Tab.Display;
settingsPanel.open();
}
}
@@ -0,0 +1,14 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Power
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: PowerProfileService.noctaliaPerformanceMode ? "rocket" : "rocket-off"
tooltipText: I18n.tr("tooltips.noctalia-performance-enabled")
hot: PowerProfileService.noctaliaPerformanceMode
onClicked: PowerProfileService.toggleNoctaliaPerformance()
}
@@ -0,0 +1,16 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.System
import qs.Services.UI
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: NotificationService.doNotDisturb ? "bell-off" : "bell"
hot: NotificationService.doNotDisturb
tooltipText: I18n.tr("common.notifications")
onClicked: PanelService.getPanel("notificationHistoryPanel", screen)?.toggle(this)
onRightClicked: NotificationService.doNotDisturb = !NotificationService.doNotDisturb
}
@@ -0,0 +1,19 @@
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import qs.Commons
import qs.Services.Power
import qs.Widgets
// Performance
NIconButtonHot {
property ShellScreen screen
readonly property bool hasPP: PowerProfileService.available
enabled: hasPP
icon: PowerProfileService.getIcon()
hot: !PowerProfileService.isDefault()
tooltipText: I18n.tr("control-center.power-profile.tooltip-action")
onClicked: PowerProfileService.cycleProfile()
}
@@ -0,0 +1,15 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
enabled: Settings.data.wallpaper.enabled
icon: "wallpaper-selector"
tooltipText: I18n.tr("wallpaper.panel.title")
onClicked: PanelService.getPanel("wallpaperPanel", screen)?.toggle()
onRightClicked: WallpaperService.setRandomWallpaper()
}
@@ -0,0 +1,615 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Modules.Dock
import qs.Modules.MainScreen
SmartPanel {
id: root
panelBackgroundColor: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity)
readonly property string dockPosition: Settings.data.dock.position
readonly property bool isVertical: dockPosition === "left" || dockPosition === "right"
readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false
readonly property bool barAtSameEdge: hasBar && Settings.getBarPositionForScreen(modelData?.name) === dockPosition
readonly property bool isFramed: Settings.data.bar.barType === "framed" && hasBar
property bool isDockHovered: false
property bool panelHovered: false
readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1))
readonly property int maxWidth: screen ? screen.width * 0.8 : 1000
readonly property int maxHeight: screen ? screen.height * 0.8 : 1000
readonly property bool autoHide: false
readonly property int hideDelay: 1000
readonly property int showDelay: 100
// Shared state with dock content
property bool dockHovered: false
property bool anyAppHovered: false
property bool menuHovered: false
property bool hidden: false
property bool peekHovered: false
// Track the currently open context menu
property var currentContextMenu: null
// Combined model of running apps and pinned apps
property var dockApps: []
property var groupCycleIndices: ({})
// Track the session order of apps (transient reordering)
property var sessionAppOrder: []
// Drag and Drop state for visual feedback
property int dragSourceIndex: -1
property int dragTargetIndex: -1
// Revision counter to force icon re-evaluation
property int iconRevision: 0
property alias hideTimer: hideTimer
property alias showTimer: showTimer
onMenuHoveredChanged: {
if (!menuHovered && !panelHovered) {
hoverCloseTimer.restart();
}
}
onAnyAppHoveredChanged: {
if (anyAppHovered) {
hoverCloseTimer.stop();
} else if (!panelHovered && !menuHovered && !dockHovered && !isDockHovered) {
hoverCloseTimer.restart();
}
}
onClosed: {
hoverCloseTimer.stop();
isDockHovered = false;
}
onOpened: {
// Don't auto-start close timer here — the peek zone's onExited in Dock.qml
// starts hideTimer which checks panel.isDockHovered, giving the user time
// to move into the panel without it closing prematurely.
}
panelAnchorTop: dockPosition === "top"
panelAnchorBottom: dockPosition === "bottom"
panelAnchorLeft: dockPosition === "left"
panelAnchorRight: dockPosition === "right"
panelAnchorHorizontalCenter: !isVertical
panelAnchorVerticalCenter: isVertical
forceAttachToBar: hasBar
exclusiveKeyboard: false
// when dragging ended but the cursor is outside the dock area, restart the timer
onDragSourceIndexChanged: {
if (dragSourceIndex === -1) {
if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) {
hideTimer.restart();
}
}
}
// Function to close any open context menu
function closeAllContextMenus() {
if (currentContextMenu && currentContextMenu.visible) {
currentContextMenu.hide();
}
}
function getAppKey(appData) {
if (!appData)
return null;
if (Settings.data.dock.groupApps) {
return appData.appId;
}
// Use stable appId for pinned apps to maintain their slot regardless of running state
if (appData.type === "pinned" || appData.type === "pinned-running") {
return appData.appId;
}
// prefer toplevel object identity for unpinned running apps to distinguish instances
if (appData.toplevel)
return appData.toplevel;
// fallback to appId
return appData.appId;
}
function sortDockApps(apps) {
if (!sessionAppOrder || sessionAppOrder.length === 0) {
return apps;
}
const sorted = [];
const remaining = [...apps];
// Pick apps that are in the session order
for (let i = 0; i < sessionAppOrder.length; i++) {
const key = sessionAppOrder[i];
// Pick ALL matching apps (e.g. all instances of a pinned app)
while (true) {
const idx = remaining.findIndex(app => getAppKey(app) === key);
if (idx !== -1) {
sorted.push(remaining[idx]);
remaining.splice(idx, 1);
} else {
break;
}
}
}
// Append any new/remaining apps
remaining.forEach(app => sorted.push(app));
return sorted;
}
function reorderApps(fromIndex, toIndex) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= dockApps.length || toIndex >= dockApps.length)
return;
const list = [...dockApps];
const item = list.splice(fromIndex, 1)[0];
list.splice(toIndex, 0, item);
dockApps = list;
sessionAppOrder = dockApps.map(getAppKey);
savePinnedOrder();
}
function savePinnedOrder() {
const currentPinned = Settings.data.dock.pinnedApps || [];
const newPinned = [];
const seen = new Set();
// Extract pinned apps in their current visual order
dockApps.forEach(app => {
if (app.appId && !seen.has(app.appId)) {
const isPinned = currentPinned.some(p => normalizeAppId(p) === normalizeAppId(app.appId));
if (isPinned) {
newPinned.push(app.appId);
seen.add(app.appId);
}
}
});
// Check if any pinned apps were missed (unlikely if dockApps is correct)
currentPinned.forEach(p => {
if (!seen.has(p)) {
newPinned.push(p);
seen.add(p);
}
});
if (JSON.stringify(currentPinned) !== JSON.stringify(newPinned)) {
Settings.data.dock.pinnedApps = newPinned;
}
}
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
return "";
let id = appId.toLowerCase().trim();
if (id.endsWith(".desktop"))
id = id.substring(0, id.length - 8);
return id;
}
// Helper function to check if an app ID matches a pinned app (case-insensitive)
function isAppIdPinned(appId, pinnedApps) {
if (!appId || !pinnedApps || pinnedApps.length === 0)
return false;
const normalizedId = normalizeAppId(appId);
// Direct match
if (pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId))
return true;
// Resolve via desktop entry lookup (handles StartupWMClass != .desktop filename)
const resolved = resolveToDesktopEntryId(appId);
if (resolved !== appId) {
const normalizedResolved = normalizeAppId(resolved);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedResolved);
}
return false;
}
// Desktop entry ID resolution cache (cleared when DesktopEntries change)
property var _desktopEntryIdCache: ({})
// Resolve a toplevel appId to its canonical .desktop entry ID via heuristic lookup.
function resolveToDesktopEntryId(appId) {
if (!appId)
return appId;
if (_desktopEntryIdCache.hasOwnProperty(appId))
return _desktopEntryIdCache[appId];
try {
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.id) {
_desktopEntryIdCache[appId] = entry.id;
return entry.id;
}
}
} catch (e) {}
_desktopEntryIdCache[appId] = appId;
return appId;
}
// Helper function to get app name from desktop entry
function getAppNameFromDesktopEntry(appId) {
if (!appId)
return appId;
try {
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) {
const entry = DesktopEntries.heuristicLookup(appId);
if (entry && entry.name) {
return entry.name;
}
}
if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) {
const entry = DesktopEntries.byId(appId);
if (entry && entry.name) {
return entry.name;
}
}
} catch (e)
// Fall through to return original appId
{}
// Return original appId if we can't find a desktop entry
return appId;
}
function getToplevelsForEntry(appData) {
if (!appData)
return [];
if (appData.toplevels && appData.toplevels.length > 0) {
return appData.toplevels.filter(toplevel => toplevel && (!Settings.data.dock.onlySameOutput || !toplevel.screens || toplevel.screens.includes(screen)));
}
if (!appData.toplevel)
return [];
if (Settings.data.dock.onlySameOutput && appData.toplevel.screens && !appData.toplevel.screens.includes(screen))
return [];
return [appData.toplevel];
}
function getPrimaryToplevelForEntry(appData) {
const toplevels = getToplevelsForEntry(appData);
if (toplevels.length === 0)
return null;
if (ToplevelManager && ToplevelManager.activeToplevel && toplevels.includes(ToplevelManager.activeToplevel))
return ToplevelManager.activeToplevel;
return toplevels[0];
}
// Build grouped render model without mutating the raw toplevel list.
function buildGroupedDockApps(apps) {
if (!Settings.data.dock.groupApps) {
return apps.map(app => {
const entry = Object.assign({}, app);
entry.toplevels = getToplevelsForEntry(app);
return entry;
});
}
const grouped = [];
const groupedById = new Map();
apps.forEach(app => {
const appId = app.appId;
const toplevels = getToplevelsForEntry(app);
const existing = groupedById.get(appId);
if (existing) {
toplevels.forEach(toplevel => {
if (!existing.toplevels.includes(toplevel)) {
existing.toplevels.push(toplevel);
}
});
if (app.type === "pinned" || app.type === "pinned-running") {
existing.isPinned = true;
}
} else {
const entry = {
"type": app.type,
"appId": appId,
"title": app.title,
"toplevels": toplevels.slice(),
"isPinned": app.type === "pinned" || app.type === "pinned-running"
};
grouped.push(entry);
groupedById.set(appId, entry);
}
});
grouped.forEach(entry => {
entry.toplevel = getPrimaryToplevelForEntry(entry);
if (entry.toplevels.length > 0 && entry.isPinned) {
entry.type = "pinned-running";
} else if (entry.toplevels.length > 0) {
entry.type = "running";
} else {
entry.type = "pinned";
}
if (entry.toplevel && entry.toplevel.title && entry.toplevel.title.trim() !== "") {
entry.title = entry.toplevel.title;
}
});
return grouped;
}
// Function to update the combined dock apps model
function updateDockApps() {
const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : [];
const pinnedApps = Settings.data.dock.pinnedApps || [];
const combined = [];
const processedToplevels = new Set();
const processedPinnedAppIds = new Set();
// push an app onto combined with the given appType
function pushApp(appType, toplevel, appId, title) {
// Use canonical ID for pinned apps to ensure key stability
const canonicalId = isAppIdPinned(appId, pinnedApps) ? (pinnedApps.find(p => normalizeAppId(p) === normalizeAppId(appId)) || appId) : appId;
// For running apps, track by toplevel object to allow multiple instances
if (toplevel) {
if (processedToplevels.has(toplevel)) {
return;
}
if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(screen)) {
return;
}
combined.push({
"type": appType,
"toplevel": toplevel,
"toplevels": toplevel ? [toplevel] : [],
"appId": canonicalId,
"title": title
});
processedToplevels.add(toplevel);
} else {
// For pinned apps that aren't running, track by appId to avoid duplicates
if (processedPinnedAppIds.has(canonicalId)) {
return;
}
combined.push({
"type": appType,
"toplevel": toplevel,
"toplevels": [],
"appId": canonicalId,
"title": title
});
processedPinnedAppIds.add(canonicalId);
}
}
function pushRunning(first) {
runningApps.forEach(toplevel => {
if (toplevel) {
// Use robust matching to check if pinned
const isPinned = isAppIdPinned(toplevel.appId, pinnedApps);
if (!first && isPinned && processedToplevels.has(toplevel)) {
return; // Already added by pushPinned()
}
pushApp((first && isPinned) ? "pinned-running" : "running", toplevel, toplevel.appId, toplevel.title);
}
});
}
function pushPinned() {
pinnedApps.forEach(pinnedAppId => {
// Find all running instances of this pinned app using robust matching
// Also resolve toplevel appId via desktop entry lookup to handle
// StartupWMClass != .desktop filename (e.g. zen -> zen-browser-bin)
const normalizedPinned = normalizeAppId(pinnedAppId);
const matchingToplevels = runningApps.filter(app => {
if (!app)
return false;
if (normalizeAppId(app.appId) === normalizedPinned)
return true;
const resolved = resolveToDesktopEntryId(app.appId);
return resolved !== app.appId && normalizeAppId(resolved) === normalizedPinned;
});
if (matchingToplevels.length > 0) {
// Add all running instances as pinned-running
matchingToplevels.forEach(toplevel => {
pushApp("pinned-running", toplevel, pinnedAppId, toplevel.title);
});
} else {
// App is pinned but not running - add once
pushApp("pinned", null, pinnedAppId, getAppNameFromDesktopEntry(pinnedAppId) || pinnedAppId);
}
});
}
// if pinnedStatic then push all pinned and then all remaining running apps
if (Settings.data.dock.pinnedStatic) {
pushPinned();
pushRunning(false);
// else add all running apps and then remaining pinned apps
} else {
pushRunning(true);
pushPinned();
}
const sortedApps = sortDockApps(combined);
dockApps = buildGroupedDockApps(sortedApps);
const cycleState = root.groupCycleIndices || {};
const nextCycleState = {};
dockApps.forEach(app => {
if (app && app.appId && cycleState[app.appId] !== undefined) {
nextCycleState[app.appId] = cycleState[app.appId];
}
});
root.groupCycleIndices = nextCycleState;
// Sync session order if needed
if (!sessionAppOrder || sessionAppOrder.length === 0) {
sessionAppOrder = dockApps.map(getAppKey);
} else {
const currentKeys = new Set(dockApps.map(getAppKey));
const existingKeys = new Set();
const newOrder = [];
// Keep existing keys that are still present
sessionAppOrder.forEach(key => {
if (currentKeys.has(key)) {
newOrder.push(key);
existingKeys.add(key);
}
});
// Add new keys at the end
dockApps.forEach(app => {
const key = getAppKey(app);
if (!existingKeys.has(key)) {
newOrder.push(key);
existingKeys.add(key);
}
});
if (JSON.stringify(newOrder) !== JSON.stringify(sessionAppOrder)) {
sessionAppOrder = newOrder;
}
}
}
// Update dock apps when toplevels change
Connections {
target: ToplevelManager ? ToplevelManager.toplevels : null
function onValuesChanged() {
updateDockApps();
}
}
// Update dock apps when pinned apps change
Connections {
target: Settings.data.dock
function onPinnedAppsChanged() {
updateDockApps();
}
function onOnlySameOutputChanged() {
updateDockApps();
}
function onGroupAppsChanged() {
updateDockApps();
}
}
// Initial update when component is ready
Component.onCompleted: {
if (ToplevelManager) {
updateDockApps();
}
}
// Refresh icons when DesktopEntries becomes available
Connections {
target: DesktopEntries.applications
function onValuesChanged() {
root.iconRevision++;
root._desktopEntryIdCache = {};
}
}
Timer {
id: hideTimer
interval: hideDelay
onTriggered: {}
}
Timer {
id: showTimer
interval: showDelay
onTriggered: {}
}
Timer {
id: hoverCloseTimer
interval: hideDelay
onTriggered: {
if (root.dockHovered || root.isDockHovered || root.anyAppHovered || root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) {
restart();
return;
}
root.isDockHovered = false;
root.close();
}
}
panelContent: Item {
id: panelContent
property bool allowAttach: true
property real frameThickness: isFramed && !barAtSameEdge && !Settings.data.dock.sitOnFrame ? Settings.data.bar.frameThickness : 0
property real contentPreferredWidth: Math.round(dockContainerWrapper.width) - (isVertical ? frameThickness : 0)
property real contentPreferredHeight: Math.round(dockContainerWrapper.height) - (!isVertical ? frameThickness : 0)
Item {
id: hoverArea
anchors.fill: dockContainerWrapper
anchors.margins: -frameThickness
// Detect hover over dock area including frame thickness
HoverHandler {
id: dockHoverArea
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onHoveredChanged: {
root.panelHovered = hovered;
if (hovered) {
root.isDockHovered = true;
hoverCloseTimer.stop();
} else {
root.isDockHovered = false;
if (root.menuHovered || (root.currentContextMenu && root.currentContextMenu.visible)) {
hoverCloseTimer.stop();
} else {
hoverCloseTimer.restart();
}
}
}
}
}
Item {
id: dockContainerWrapper
readonly property real frameThickness: isFramed ? Settings.data.bar.frameThickness : 0
width: dockContent.dockContainer.width
height: dockContent.dockContainer.height
anchors.top: root.dockPosition === "bottom" ? parent.top : undefined
anchors.bottom: root.dockPosition === "top" ? parent.bottom : undefined
anchors.left: root.dockPosition === "right" ? parent.left : undefined
anchors.right: root.dockPosition === "left" ? parent.right : undefined
DockContent {
id: dockContent
anchors.fill: parent
dockRoot: root
extraTop: 0
extraBottom: 0
extraLeft: 0
extraRight: 0
}
}
}
}
@@ -0,0 +1,195 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.Keyboard
import qs.Widgets
Item {
id: previewPanel
property var currentItem: null
property string fullContent: ""
property string imageDataUrl: ""
property bool loadingFullContent: false
property bool isImageContent: false
implicitHeight: contentArea.implicitHeight + Style.margin2L
function loadContent() {
if (!currentItem || !currentItem.clipboardId)
return;
if (isImageContent) {
// For images, check cache first then decode
imageDataUrl = ClipboardService.getImageData(currentItem.clipboardId) || "";
loadingFullContent = !imageDataUrl;
if (!imageDataUrl && currentItem.mime) {
ClipboardService.decodeToDataUrl(currentItem.clipboardId, currentItem.mime, null);
}
} else {
// For text, check sync cache first
const cached = ClipboardService.getContent(currentItem.clipboardId);
if (cached) {
fullContent = cached;
loadingFullContent = false;
} else {
// Show preview while loading full content
fullContent = currentItem.preview || "";
loadingFullContent = true;
// Async decode as fallback
var requestedId = currentItem.clipboardId;
ClipboardService.decode(requestedId, function (content) {
if (!previewPanel || !previewPanel.currentItem) {
return;
}
if (previewPanel.currentItem.clipboardId === requestedId) {
var trimmed = content ? content.trim() : "";
if (trimmed !== "") {
previewPanel.fullContent = trimmed;
}
previewPanel.loadingFullContent = false;
}
});
}
}
}
Connections {
target: previewPanel
function onCurrentItemChanged() {
fullContent = "";
imageDataUrl = "";
loadingFullContent = false;
isImageContent = currentItem && currentItem.isImage;
if (currentItem && currentItem.clipboardId) {
loadContent();
}
}
}
readonly property int _rev: ClipboardService.revision
on_RevChanged: {
// When cache updates, try to load content if we're still showing loading or preview
if (currentItem && currentItem.clipboardId && !isImageContent && loadingFullContent) {
const cached = ClipboardService.getContent(currentItem.clipboardId);
if (cached) {
fullContent = cached;
loadingFullContent = false;
}
}
}
Timer {
id: imageUpdateTimer
interval: 200
running: currentItem && currentItem.isImage && imageDataUrl === ""
repeat: currentItem && currentItem.isImage && imageDataUrl === ""
onTriggered: {
if (currentItem && currentItem.clipboardId) {
const newData = ClipboardService.getImageData(currentItem.clipboardId) || "";
if (newData !== imageDataUrl) {
imageDataUrl = newData;
if (newData) {
loadingFullContent = false;
}
}
}
}
}
Item {
id: contentArea
anchors.fill: parent
anchors.margins: Style.marginS
BusyIndicator {
anchors.centerIn: parent
running: loadingFullContent
visible: loadingFullContent
width: Style.baseWidgetSize
height: width
}
// Preview for image entry
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginS
visible: isImageContent && !loadingFullContent && imageDataUrl !== ""
NImageRounded {
id: previewImage
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.marginS
imagePath: imageDataUrl
imageFillMode: Image.PreserveAspectFit
}
NDivider {
Layout.fillWidth: true
Layout.bottomMargin: Style.marginS
}
NText {
Layout.fillWidth: true
text: {
const meta = ClipboardService.parseImageMeta(currentItem?.preview);
if (meta)
return `${meta.fmt} ${meta.w}×${meta.h} ${meta.size}`;
// Fallback to basic info
const format = (currentItem?.mime || "image").split("/")[1]?.toUpperCase() || "Image";
return `${format} ${previewImage.implicitWidth}×${previewImage.implicitHeight}`;
}
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
// Preview for text entry
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginS
visible: !isImageContent && !loadingFullContent
NScrollView {
id: clipboardScrollView
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: Settings.data.appLauncher.clipboardWrapText ? ScrollBar.AlwaysOff : ScrollBar.AsNeeded
NText {
text: fullContent
width: Settings.data.appLauncher.clipboardWrapText ? clipboardScrollView.availableWidth : implicitWidth
wrapMode: Settings.data.appLauncher.clipboardWrapText ? Text.Wrap : Text.NoWrap
textFormat: Text.PlainText
font.pointSize: Style.fontSizeM
font.family: Settings.data.ui.fontFixed
color: Color.mOnSurface
}
}
NDivider {
Layout.fillWidth: true
Layout.bottomMargin: Style.marginS
}
NText {
Layout.fillWidth: true
visible: fullContent.length > 0
text: {
const chars = fullContent.length;
const words = fullContent.split(/\s+/).filter(w => w.length > 0).length;
const lines = fullContent.split('\n').length;
return `${chars} chars, ${words} words, ${lines} lines`;
}
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
}
@@ -0,0 +1,134 @@
function selectNext(selectedIndex, resultsLength) {
if (resultsLength > 0 && selectedIndex < resultsLength - 1)
return selectedIndex + 1;
return selectedIndex;
}
function selectPrevious(selectedIndex, resultsLength) {
if (resultsLength > 0 && selectedIndex > 0)
return selectedIndex - 1;
return selectedIndex;
}
function selectNextWrapped(selectedIndex, resultsLength, allowWrap) {
if (resultsLength > 0) {
if (allowWrap)
return (selectedIndex + 1) % resultsLength;
return selectNext(selectedIndex, resultsLength);
}
return selectedIndex;
}
function selectPreviousWrapped(selectedIndex, resultsLength, allowWrap) {
if (resultsLength > 0) {
if (allowWrap)
return (((selectedIndex - 1) % resultsLength) + resultsLength) % resultsLength;
return selectPrevious(selectedIndex, resultsLength);
}
return selectedIndex;
}
function selectFirst() {
return 0;
}
function selectLast(resultsLength) {
return resultsLength > 0 ? resultsLength - 1 : 0;
}
function selectNextPage(selectedIndex, resultsLength, entryHeight) {
if (resultsLength > 0) {
var page = Math.max(1, Math.floor(600 / entryHeight));
return Math.min(selectedIndex + page, resultsLength - 1);
}
return selectedIndex;
}
function selectPreviousPage(selectedIndex, resultsLength, entryHeight) {
if (resultsLength > 0) {
var page = Math.max(1, Math.floor(600 / entryHeight));
return Math.max(selectedIndex - page, 0);
}
return selectedIndex;
}
function selectPreviousRow(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0 || gridColumns <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
if (currentRow > 0) {
var targetRow = currentRow - 1;
var itemsInTargetRow = Math.min(gridColumns, resultsLength - targetRow * gridColumns);
if (currentCol < itemsInTargetRow)
return targetRow * gridColumns + currentCol;
return targetRow * gridColumns + itemsInTargetRow - 1;
}
// Wrap to last row, same column
var totalRows = Math.ceil(resultsLength / gridColumns);
var lastRow = totalRows - 1;
var itemsInLastRow = Math.min(gridColumns, resultsLength - lastRow * gridColumns);
if (currentCol < itemsInLastRow)
return lastRow * gridColumns + currentCol;
return resultsLength - 1;
}
function selectNextRow(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0 || gridColumns <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
var totalRows = Math.ceil(resultsLength / gridColumns);
if (currentRow < totalRows - 1) {
var targetRow = currentRow + 1;
var targetIndex = targetRow * gridColumns + currentCol;
if (targetIndex < resultsLength)
return targetIndex;
var itemsInTargetRow = resultsLength - targetRow * gridColumns;
if (itemsInTargetRow > 0)
return targetRow * gridColumns + itemsInTargetRow - 1;
return Math.min(currentCol, resultsLength - 1);
}
// Wrap to first row, same column
return Math.min(currentCol, resultsLength - 1);
}
function selectPreviousColumn(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
if (currentCol > 0)
return currentRow * gridColumns + (currentCol - 1);
if (currentRow > 0)
return (currentRow - 1) * gridColumns + (gridColumns - 1);
var totalRows = Math.ceil(resultsLength / gridColumns);
var lastRowIndex = (totalRows - 1) * gridColumns + (gridColumns - 1);
return Math.min(lastRowIndex, resultsLength - 1);
}
function selectNextColumn(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
var itemsInCurrentRow = Math.min(gridColumns, resultsLength - currentRow * gridColumns);
if (currentCol < itemsInCurrentRow - 1)
return currentRow * gridColumns + (currentCol + 1);
var totalRows = Math.ceil(resultsLength / gridColumns);
if (currentRow < totalRows - 1)
return (currentRow + 1) * gridColumns;
return 0;
}
@@ -0,0 +1,182 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
// Disable when overlay mode is enabled (LauncherOverlayWindow handles it)
enabled: !Settings.data.appLauncher.overviewLayer
visible: !Settings.data.appLauncher.overviewLayer
// Reference to core (set after panelContent loads)
property var launcherCoreRef: null
// Expose core launcher for external access (e.g., IPC)
readonly property string searchText: launcherCoreRef ? launcherCoreRef.searchText : ""
readonly property int selectedIndex: launcherCoreRef ? launcherCoreRef.selectedIndex : 0
readonly property var results: launcherCoreRef ? launcherCoreRef.results : []
readonly property var activeProvider: launcherCoreRef ? launcherCoreRef.activeProvider : null
readonly property var currentProvider: launcherCoreRef ? launcherCoreRef.currentProvider : null
readonly property bool isGridView: launcherCoreRef ? launcherCoreRef.isGridView : false
readonly property int gridColumns: launcherCoreRef ? launcherCoreRef.gridColumns : 5
function setSearchText(text) {
if (launcherCoreRef)
launcherCoreRef.setSearchText(text);
}
// Preview panel support
readonly property bool previewActive: {
if (!launcherCoreRef)
return false;
var provider = launcherCoreRef.activeProvider;
if (!provider || !provider.hasPreview)
return false;
if (!Settings.data.appLauncher.enableClipPreview)
return false;
return selectedIndex >= 0 && results && !!results[selectedIndex];
}
readonly property int previewPanelWidth: Math.round(400 * Style.uiScaleRatio)
// Panel sizing
readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio)
readonly property int totalBaseWidth: listPanelWidth + Style.margin2L
preferredWidth: totalBaseWidth
preferredHeight: Math.round(600 * Style.uiScaleRatio)
preferredWidthRatio: 0.25
preferredHeightRatio: 0.5
// Positioning
readonly property string screenBarPosition: Settings.getBarPositionForScreen(screen?.name)
readonly property string panelPosition: {
if (Settings.data.appLauncher.position === "follow_bar") {
if (screenBarPosition === "left" || screenBarPosition === "right") {
return `center_${screenBarPosition}`;
} else {
return `${screenBarPosition}_center`;
}
} else {
return Settings.data.appLauncher.position;
}
}
panelAnchorHorizontalCenter: !root.useButtonPosition && (panelPosition === "center" || panelPosition.endsWith("_center"))
panelAnchorVerticalCenter: !root.useButtonPosition && panelPosition === "center"
panelAnchorLeft: !root.useButtonPosition && panelPosition !== "center" && panelPosition.endsWith("_left")
panelAnchorRight: !root.useButtonPosition && panelPosition !== "center" && panelPosition.endsWith("_right")
panelAnchorBottom: !root.useButtonPosition && panelPosition.startsWith("bottom_")
panelAnchorTop: !root.useButtonPosition && panelPosition.startsWith("top_")
panelContent: Rectangle {
id: ui
color: "transparent"
opacity: launcherCore.resultsReady ? 1.0 : 0.0
Component.onCompleted: root.launcherCoreRef = launcherCore
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
// Preview Panel (external) - uses provider's preview component
NDropShadow {
source: previewBox
anchors.fill: previewBox
autoPaddingEnabled: true
visible: previewBox.visible
z: previewBox.z - 1
}
NBox {
id: previewBox
visible: root.previewActive
width: root.previewPanelWidth
height: Math.round(400 * Style.uiScaleRatio)
forceOpaque: true // no blur for now
x: root.panelAnchorRight ? -(root.previewPanelWidth + Style.marginM) : ui.width + Style.marginM
y: {
var view = launcherCore.resultsView;
if (!view)
return Style.marginL;
var row = launcherCore.isGridView ? Math.floor(launcherCore.selectedIndex / launcherCore.gridColumns) : launcherCore.selectedIndex;
var gridCellSize = Math.floor((root.listPanelWidth - (2 * Style.marginXS) - ((launcherCore.targetGridColumns - 1) * Style.marginS)) / launcherCore.targetGridColumns);
var itemHeight = launcherCore.isGridView ? (gridCellSize + Style.marginXXS) : (launcherCore.entryHeight + (view.spacing || 0));
var yPos = row * itemHeight - (view.contentY || 0);
var mapped = view.mapToItem(ui, 0, yPos);
return Math.max(Style.marginL, Math.min(mapped.y, ui.height - previewBox.height - Style.marginL));
}
z: -1
opacity: visible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on y {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Loader {
id: previewLoader
anchors.fill: parent
active: root.previewActive
source: {
if (!active)
return "";
var provider = launcherCore.activeProvider;
if (provider && provider.previewComponentPath)
return provider.previewComponentPath;
return "";
}
onLoaded: updatePreviewItem()
onItemChanged: updatePreviewItem()
function updatePreviewItem() {
if (!item || launcherCore.selectedIndex < 0 || !launcherCore.results[launcherCore.selectedIndex])
return;
var provider = launcherCore.activeProvider;
if (provider && provider.getPreviewData) {
item.currentItem = provider.getPreviewData(launcherCore.results[launcherCore.selectedIndex]);
} else {
item.currentItem = launcherCore.results[launcherCore.selectedIndex];
}
}
}
}
// Core launcher (state, providers, UI)
LauncherCore {
id: launcherCore
anchors.fill: parent
screen: root.screen
isOpen: root.isPanelOpen
onRequestClose: root.close()
// Defer so the signal emission completes before SmartPanel
// sets isPanelOpen=false and the contentLoader destroys us.
onRequestCloseImmediately: Qt.callLater(root.closeImmediately)
}
// Update preview when selection changes
Connections {
target: launcherCore
function onSelectedIndexChanged() {
if (previewLoader.item)
previewLoader.updatePreviewItem();
}
}
}
}
@@ -0,0 +1,909 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import "Helpers/LauncherNavigation.js" as LauncherNav
import "Providers"
import qs.Commons
import qs.Services.Keyboard
import qs.Services.UI
import qs.Widgets
// Core launcher logic and UI - shared between SmartPanel (Launcher.qml) and overlay (LauncherOverlayWindow.qml)
Rectangle {
id: root
color: "transparent"
// External interface - set by parent
property var screen: null
property bool isOpen: false
signal requestClose
signal requestCloseImmediately
function closeImmediately() {
requestCloseImmediately();
}
// Expose for preview panel positioning
readonly property var resultsView: resultsSwapView.item
// State
property string searchText: ""
property int selectedIndex: 0
property var results: []
property var providers: []
property var activeProvider: null
property bool resultsReady: false
property var pluginProviderInstances: ({})
property bool ignoreMouseHover: true // Transient flag, should always be true on init
// Global mouse tracking for movement detection across delegates
property real globalLastMouseX: 0
property real globalLastMouseY: 0
property bool globalMouseInitialized: false
property bool mouseTrackingReady: false // Delay tracking until panel is settled
readonly property bool animationsDisabled: Settings.data.general.animationDisabled
Timer {
id: mouseTrackingDelayTimer
interval: root.animationsDisabled ? 0 : (Style.animationNormal + 50) // Wait for panel animation to complete + safety margin
repeat: false
onTriggered: {
root.mouseTrackingReady = true;
root.globalMouseInitialized = false; // Reset so we get fresh initial position
}
}
readonly property var defaultProvider: appsProvider
readonly property var currentProvider: activeProvider || defaultProvider
readonly property string launcherDensity: (currentProvider && currentProvider.ignoreDensity === false) ? (Settings.data.appLauncher.density || "default") : "comfortable"
readonly property int effectiveIconSize: launcherDensity === "comfortable" ? 48 : (launcherDensity === "default" ? 36 : 24)
readonly property int badgeSize: Math.round(effectiveIconSize * Style.uiScaleRatio)
readonly property int entryHeight: Math.round(badgeSize + (launcherDensity === "compact" ? (Style.marginL + Style.marginXXS) : (Style.marginXL + Style.marginS)))
readonly property bool providerShowsCategories: (currentProvider.showsCategories !== undefined ? currentProvider.showsCategories : true) && providerCategories.length > 0
readonly property var providerCategories: {
if (currentProvider.availableCategories && currentProvider.availableCategories.length > 0) {
return currentProvider.availableCategories;
}
return currentProvider.categories || [];
}
readonly property bool showProviderCategories: {
if (!providerShowsCategories || providerCategories.length === 0)
return false;
if (currentProvider === defaultProvider)
return Settings.data.appLauncher.showCategories;
return true;
}
readonly property bool providerHasDisplayString: results.length > 0 && !!results[0].displayString
readonly property string providerSupportedLayouts: {
if (activeProvider && activeProvider.supportedLayouts)
return activeProvider.supportedLayouts;
if (results.length > 0 && results[0].provider && results[0].provider.supportedLayouts)
return results[0].provider.supportedLayouts;
if (defaultProvider && defaultProvider.supportedLayouts)
return defaultProvider.supportedLayouts;
return "both";
}
readonly property bool showLayoutToggle: !providerHasDisplayString && providerSupportedLayouts === "both"
readonly property string layoutMode: {
if (searchText === ">")
return "list";
if (providerSupportedLayouts === "grid")
return "grid";
if (providerSupportedLayouts === "list")
return "list";
if (providerSupportedLayouts === "single")
return "single";
if (providerHasDisplayString)
return "grid";
return Settings.data.appLauncher.viewMode;
}
readonly property bool isGridView: layoutMode === "grid"
readonly property bool isSingleView: layoutMode === "single"
readonly property bool isCompactDensity: launcherDensity === "compact"
readonly property int targetGridColumns: {
let base = 5;
if (launcherDensity === "comfortable")
base = 4;
else if (launcherDensity === "compact")
base = 6;
if (!activeProvider || activeProvider === defaultProvider)
return base;
if (activeProvider.preferredGridColumns) {
let multiplier = base / 5.0;
return Math.max(1, Math.round(activeProvider.preferredGridColumns * multiplier));
}
return base;
}
readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio)
readonly property int gridContentWidth: listPanelWidth - (2 * Style.marginXS)
readonly property int gridCellSize: Math.floor((gridContentWidth - ((targetGridColumns - 1) * Style.marginS)) / targetGridColumns)
readonly property int gridColumns: targetGridColumns
// Check if current provider allows wrap navigation (default true)
readonly property bool allowWrapNavigation: {
var provider = activeProvider || currentProvider;
return provider && provider.wrapNavigation !== undefined ? provider.wrapNavigation : true;
}
// Listen for plugin provider registry changes
Connections {
target: LauncherProviderRegistry
function onPluginProviderRegistryUpdated() {
root.syncPluginProviders();
}
}
// Lifecycle
onIsOpenChanged: {
if (isOpen) {
onOpened();
} else {
onClosed();
}
}
onSearchTextChanged: {
if (isOpen) {
updateResults();
}
}
function onOpened() {
ignoreMouseHover = true;
globalMouseInitialized = false;
mouseTrackingReady = false;
mouseTrackingDelayTimer.restart();
// Show launcher immediately, results will populate asynchronously
resultsReady = true;
focusSearchInput();
Qt.callLater(() => {
syncPluginProviders();
for (let provider of providers) {
if (provider.onOpened)
provider.onOpened();
}
updateResults();
});
}
function onClosed() {
searchText = "";
ignoreMouseHover = true;
if (resultsSwapView)
resultsSwapView.resetVisuals();
for (let provider of providers) {
if (provider.onClosed)
provider.onClosed();
}
}
function close() {
requestClose();
}
function applyCategorySelection(tabIndex, categories) {
const categoryList = categories || providerCategories;
if (!categoryList || tabIndex < 0 || tabIndex >= categoryList.length)
return false;
currentProvider.selectCategory(categoryList[tabIndex]);
categoryTabs.currentIndex = tabIndex;
return true;
}
function selectCategoryWithSlide(tabIndex) {
if (!showProviderCategories || !currentProvider || !currentProvider.selectCategory)
return;
const cats = providerCategories;
if (!cats || tabIndex < 0 || tabIndex >= cats.length)
return;
const currentIdx = cats.indexOf(currentProvider.selectedCategory);
if (tabIndex === currentIdx)
return;
const canAnimate = !animationsDisabled && resultsSwapView.width > 0 && resultsSwapView.height > 0;
if (!canAnimate) {
applyCategorySelection(tabIndex, cats);
return;
}
const direction = tabIndex > currentIdx ? 1 : -1;
resultsSwapView.swap(direction, () => applyCategorySelection(tabIndex, providerCategories));
}
// Public API
function setSearchText(text) {
searchText = text;
}
function focusSearchInput() {
if (searchInput.inputItem) {
searchInput.inputItem.forceActiveFocus();
}
}
// Provider registration
function registerProvider(provider) {
providers.push(provider);
provider.launcher = root;
if (provider.init)
provider.init();
}
function syncPluginProviders() {
var registeredIds = LauncherProviderRegistry.getPluginProviders();
var changed = false;
// Remove providers that are no longer registered
for (var existingId in pluginProviderInstances) {
if (registeredIds.indexOf(existingId) === -1) {
var idx = providers.indexOf(pluginProviderInstances[existingId]);
if (idx >= 0)
providers.splice(idx, 1);
delete pluginProviderInstances[existingId];
Logger.d("Launcher", "Removed plugin provider:", existingId);
changed = true;
}
}
// Adopt persistent instances from the registry
for (var i = 0; i < registeredIds.length; i++) {
var providerId = registeredIds[i];
if (!pluginProviderInstances[providerId]) {
var instance = LauncherProviderRegistry.getProviderInstance(providerId);
if (instance) {
pluginProviderInstances[providerId] = instance;
providers.push(instance);
instance.launcher = root;
Logger.d("Launcher", "Adopted plugin provider:", providerId);
changed = true;
}
}
}
// Update results only if providers changed
if (changed && root.isOpen) {
updateResults();
}
}
// Search handling
function updateResults() {
results = [];
var newActiveProvider = null;
// Check for command mode
if (searchText.startsWith(">")) {
for (let provider of providers) {
if (provider.handleCommand && provider.handleCommand(searchText)) {
newActiveProvider = provider;
results = provider.getResults(searchText);
break;
}
}
// Show available commands if just ">" or filter commands if partial match
if (!newActiveProvider) {
let allCommands = [];
for (let provider of providers) {
if (provider.commands)
allCommands = allCommands.concat(provider.commands());
}
if (searchText === ">") {
results = allCommands;
} else if (searchText.length > 1) {
const query = searchText.substring(1);
if (typeof FuzzySort !== 'undefined') {
const fuzzyResults = FuzzySort.go(query, allCommands, {
"keys": ["name"],
"limit": 50
});
results = fuzzyResults.map(result => result.obj);
} else {
const queryLower = query.toLowerCase();
results = allCommands.filter(cmd => (cmd.name || "").toLowerCase().includes(queryLower));
}
}
}
} else {
// Regular search - let providers contribute results
let allResults = [];
for (let provider of providers) {
if (provider.handleSearch) {
const providerResults = provider.getResults(searchText);
allResults = allResults.concat(providerResults);
}
}
// Sort by _score (higher = better match), items without _score go first
if (searchText.trim() !== "") {
const boostByUsage = Settings.data.appLauncher.sortByMostUsed;
allResults.sort((a, b) => {
let sa = a._score !== undefined ? a._score : 0;
let sb = b._score !== undefined ? b._score : 0;
// Boost scores for frequently used items from tracked providers
// _score is normalized 01, so boost is scaled to nudge, not overwhelm
if (boostByUsage) {
if (a.provider && a.provider.trackUsage && a.usageKey) {
sa += 0.1 * Math.log2(1 + ShellState.getLauncherUsageCount(a.usageKey));
}
if (b.provider && b.provider.trackUsage && b.usageKey) {
sb += 0.1 * Math.log2(1 + ShellState.getLauncherUsageCount(b.usageKey));
}
}
return sb - sa;
});
}
results = allResults;
}
// Update activeProvider only after computing new state to avoid UI flicker
activeProvider = newActiveProvider;
selectedIndex = 0;
}
// Navigation functions (delegated to LauncherNavigation.js)
function selectNext() {
selectedIndex = LauncherNav.selectNext(selectedIndex, results.length);
}
function selectPrevious() {
selectedIndex = LauncherNav.selectPrevious(selectedIndex, results.length);
}
function selectNextWrapped() {
selectedIndex = LauncherNav.selectNextWrapped(selectedIndex, results.length, allowWrapNavigation);
}
function selectPreviousWrapped() {
selectedIndex = LauncherNav.selectPreviousWrapped(selectedIndex, results.length, allowWrapNavigation);
}
function selectFirst() {
selectedIndex = LauncherNav.selectFirst();
}
function selectLast() {
selectedIndex = LauncherNav.selectLast(results.length);
}
function selectNextPage() {
selectedIndex = LauncherNav.selectNextPage(selectedIndex, results.length, entryHeight);
}
function selectPreviousPage() {
selectedIndex = LauncherNav.selectPreviousPage(selectedIndex, results.length, entryHeight);
}
function selectPreviousRow() {
selectedIndex = LauncherNav.selectPreviousRow(selectedIndex, results.length, gridColumns);
}
function selectNextRow() {
selectedIndex = LauncherNav.selectNextRow(selectedIndex, results.length, gridColumns);
}
function selectPreviousColumn() {
selectedIndex = LauncherNav.selectPreviousColumn(selectedIndex, results.length, gridColumns);
}
function selectNextColumn() {
selectedIndex = LauncherNav.selectNextColumn(selectedIndex, results.length, gridColumns);
}
function activate() {
if (results.length > 0 && results[selectedIndex]) {
const item = results[selectedIndex];
const provider = item.provider || currentProvider;
// Track usage for providers that opt in (cross-provider "most used" tracking)
if (Settings.data.appLauncher.sortByMostUsed && provider && provider.trackUsage && item.usageKey) {
ShellState.recordLauncherUsage(item.usageKey);
}
// Check if auto-paste is enabled and provider/item supports it
if (Settings.data.appLauncher.autoPasteClipboard && provider && provider.supportsAutoPaste && item.autoPasteText) {
if (item.onAutoPaste)
item.onAutoPaste();
closeImmediately();
Qt.callLater(() => {
ClipboardService.pasteText(item.autoPasteText);
});
return;
}
if (item.onActivate)
item.onActivate();
}
}
function checkKey(event, settingName) {
return Keybinds.checkKey(event, settingName, Settings);
}
// Keyboard handler
function handleKeyPress(event) {
if (checkKey(event, 'escape')) {
close();
event.accepted = true;
return;
}
if (checkKey(event, 'enter')) {
activate();
event.accepted = true;
return;
}
if (checkKey(event, 'up')) {
if (!isSingleView) {
isGridView ? selectPreviousRow() : selectPreviousWrapped();
}
event.accepted = true;
return;
}
if (checkKey(event, 'down')) {
if (!isSingleView) {
isGridView ? selectNextRow() : selectNextWrapped();
}
event.accepted = true;
return;
}
if (checkKey(event, 'left')) {
if (isGridView) {
selectPreviousColumn();
event.accepted = true;
return;
}
}
if (checkKey(event, 'right')) {
if (isGridView) {
selectNextColumn();
event.accepted = true;
return;
}
}
// Static bindings
switch (event.key) {
case Qt.Key_Tab:
if (showProviderCategories) {
var cats = providerCategories;
var idx = cats.indexOf(currentProvider.selectedCategory);
var nextIdx = (idx + 1) % cats.length;
selectCategoryWithSlide(nextIdx);
} else {
selectNextWrapped();
}
event.accepted = true;
break;
case Qt.Key_Backtab:
if (showProviderCategories) {
var cats2 = providerCategories;
var idx2 = cats2.indexOf(currentProvider.selectedCategory);
var prevIdx = ((idx2 - 1) % cats2.length + cats2.length) % cats2.length;
selectCategoryWithSlide(prevIdx);
} else {
selectPreviousWrapped();
}
event.accepted = true;
break;
case Qt.Key_Home:
selectFirst();
event.accepted = true;
break;
case Qt.Key_End:
selectLast();
event.accepted = true;
break;
case Qt.Key_PageUp:
selectPreviousPage();
event.accepted = true;
break;
case Qt.Key_PageDown:
selectNextPage();
event.accepted = true;
break;
case Qt.Key_Delete:
if (selectedIndex >= 0 && results && results[selectedIndex]) {
var item = results[selectedIndex];
var provider = item.provider || currentProvider;
if (provider && provider.canDeleteItem && provider.canDeleteItem(item))
provider.deleteItem(item);
}
event.accepted = true;
break;
}
}
// -----------------------
// Provider components
// -----------------------
ApplicationsProvider {
id: appsProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: ApplicationsProvider");
}
}
ClipboardProvider {
id: clipProvider
Component.onCompleted: {
if (Settings.data.appLauncher.enableClipboardHistory) {
registerProvider(this);
Logger.d("Launcher", "Registered: ClipboardProvider");
}
}
}
CommandProvider {
id: cmdProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: CommandProvider");
}
}
EmojiProvider {
id: emojiProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: EmojiProvider");
}
}
CalculatorProvider {
id: calcProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: CalculatorProvider");
}
}
SettingsProvider {
id: settingsProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: SettingsProvider");
}
}
SessionProvider {
id: sessionProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: SessionProvider");
}
}
WindowsProvider {
id: windowsProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: WindowsProvider");
}
}
// ==================== UI Content ====================
opacity: resultsReady ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
HoverHandler {
id: globalHoverHandler
enabled: !Settings.data.appLauncher.ignoreMouseInput
onPointChanged: {
if (!root.mouseTrackingReady) {
return;
}
if (!root.globalMouseInitialized) {
root.globalLastMouseX = point.position.x;
root.globalLastMouseY = point.position.y;
root.globalMouseInitialized = true;
return;
}
const deltaX = Math.abs(point.position.x - root.globalLastMouseX);
const deltaY = Math.abs(point.position.y - root.globalLastMouseY);
if (deltaX + deltaY >= 5) {
root.ignoreMouseHover = false;
root.globalLastMouseX = point.position.x;
root.globalLastMouseY = point.position.y;
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.topMargin: Style.marginL
anchors.bottomMargin: Style.marginL
spacing: Style.marginL
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
spacing: Style.marginS
NTextInput {
id: searchInput
Layout.fillWidth: true
radius: Style.iRadiusM
text: root.searchText
placeholderText: I18n.tr("placeholders.search-launcher")
fontSize: Style.fontSizeM
onTextChanged: root.searchText = text
Component.onCompleted: {
if (searchInput.inputItem) {
searchInput.inputItem.forceActiveFocus();
searchInput.inputItem.Keys.onPressed.connect(function (event) {
root.handleKeyPress(event);
});
}
}
}
NIconButton {
visible: root.showLayoutToggle
icon: Settings.data.appLauncher.viewMode === "grid" ? "layout-list" : "layout-grid"
tooltipText: Settings.data.appLauncher.viewMode === "grid" ? I18n.tr("tooltips.list-view") : I18n.tr("tooltips.grid-view")
customRadius: Style.iRadiusM
Layout.preferredWidth: searchInput.height
Layout.preferredHeight: searchInput.height
onClicked: Settings.data.appLauncher.viewMode = Settings.data.appLauncher.viewMode === "grid" ? "list" : "grid"
}
}
// Unified category tabs (works with any provider that has categories)
NTabBar {
id: categoryTabs
visible: root.showProviderCategories
Layout.fillWidth: true
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
margins: 0
border.color: Style.boxBorderColor
border.width: Style.borderS
property int computedCurrentIndex: visible && root.providerCategories.length > 0 ? root.providerCategories.indexOf(root.currentProvider.selectedCategory) : 0
currentIndex: computedCurrentIndex
Repeater {
model: root.providerCategories
NTabButton {
required property string modelData
required property int index
icon: root.currentProvider.categoryIcons ? (root.currentProvider.categoryIcons[modelData] || "star") : "star"
tooltipText: root.currentProvider.getCategoryName ? root.currentProvider.getCategoryName(modelData) : modelData
tabIndex: index
checked: categoryTabs.currentIndex === index
onClicked: root.selectCategoryWithSlide(index)
}
}
}
// Results view
NSlideSwapView {
id: resultsSwapView
Layout.fillWidth: true
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
Layout.fillHeight: true
animationsEnabled: !root.animationsDisabled
sourceComponent: root.isSingleView ? singleViewComponent : (root.isGridView ? gridViewComponent : listViewComponent)
}
// --------------------------
// LIST VIEW
Component {
id: listViewComponent
NListView {
id: resultsList
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AlwaysOff
reserveScrollbarSpace: false
gradientColor: Settings.data.ui.panelBackgroundOpacity < 1 ? "transparent" : Color.mSurface
wheelScrollMultiplier: 4.0
width: parent.width
height: parent.height
spacing: Style.marginS
model: root.results
currentIndex: root.selectedIndex
cacheBuffer: resultsList.height * 2
interactive: !Settings.data.appLauncher.ignoreMouseInput
onCurrentIndexChanged: {
cancelFlick();
if (currentIndex >= 0) {
positionViewAtIndex(currentIndex, ListView.Contain);
}
}
onModelChanged: {}
delegate: LauncherListDelegate {
launcher: root
}
}
}
// --------------------------
// SINGLE ITEM VIEW
Component {
id: singleViewComponent
Item {
Layout.fillWidth: true
Layout.fillHeight: true
NBox {
anchors.fill: parent
color: Color.mSurfaceVariant
forceOpaque: true
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
Layout.fillWidth: true
Layout.fillHeight: true
Item {
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
NText {
text: root.results.length > 0 ? root.results[0].name : ""
pointSize: Style.fontSizeL
font.weight: Font.Bold
color: Color.mPrimary
}
}
NScrollView {
id: descriptionScrollView
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
Layout.topMargin: Style.fontSizeL + Style.marginXL
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
reserveScrollbarSpace: false
NText {
width: descriptionScrollView.availableWidth
text: root.results.length > 0 ? root.results[0].description : ""
pointSize: Style.fontSizeM
font.weight: Font.Bold
color: Color.mOnSurface
horizontalAlignment: Text.AlignHLeft
verticalAlignment: Text.AlignTop
wrapMode: Text.Wrap
markdownTextEnabled: true
}
}
}
}
}
}
// // --------------------------
// GRID VIEW
Component {
id: gridViewComponent
NGridView {
id: resultsGrid
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AlwaysOff
reserveScrollbarSpace: false
gradientColor: Settings.data.ui.panelBackgroundOpacity < 1 ? "transparent" : Color.mSurface
wheelScrollMultiplier: 4.0
trackedSelectionIndex: root.selectedIndex
width: parent.width
height: parent.height
cellWidth: parent.width / root.targetGridColumns
cellHeight: {
var cellWidth = parent.width / root.targetGridColumns;
// Use provider's preferred ratio if available
if (root.currentProvider && root.currentProvider.preferredGridCellRatio) {
return cellWidth * root.currentProvider.preferredGridCellRatio;
}
return cellWidth;
}
leftMargin: 0
rightMargin: 0
topMargin: 0
bottomMargin: 0
model: root.results
cacheBuffer: resultsGrid.height * 2
keyNavigationEnabled: false
focus: false
interactive: !Settings.data.appLauncher.ignoreMouseInput
// Completely disable GridView key handling
Keys.enabled: false
// Handle scrolling to show selected item when it changes
Connections {
target: root
enabled: root.isGridView
function onSelectedIndexChanged() {
if (!root.isGridView || root.selectedIndex < 0 || !resultsGrid) {
return;
}
Qt.callLater(() => {
if (root.isGridView && resultsGrid && resultsGrid.cancelFlick) {
resultsGrid.cancelFlick();
resultsGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain);
}
});
}
}
delegate: LauncherGridDelegate {
launcher: root
}
}
}
ColumnLayout {
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
NDivider {
Layout.fillWidth: true
Layout.bottomMargin: Style.marginS
}
NText {
Layout.fillWidth: true
text: {
if (root.results.length === 0) {
if (root.searchText) {
return I18n.tr("common.no-results");
}
// Use provider's empty browsing message if available
var provider = root.currentProvider;
if (provider && provider.emptyBrowsingMessage) {
return provider.emptyBrowsingMessage;
}
return "";
}
var prefix = root.activeProvider && root.activeProvider.name ? root.activeProvider.name + ": " : "";
return prefix + I18n.trp("common.result-count", root.results.length);
}
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignCenter
}
}
}
}
@@ -0,0 +1,272 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import qs.Commons
import qs.Widgets
Item {
id: gridEntryContainer
required property var modelData
required property int index
required property var launcher
width: GridView.view.cellWidth
height: GridView.view.cellHeight
property bool isSelected: (!launcher.ignoreMouseHover && mouseArea.containsMouse) || (index === launcher.selectedIndex)
// Prepare item when it becomes visible (e.g., decode images)
Component.onCompleted: {
var provider = modelData.provider;
if (provider && provider.prepareItem) {
provider.prepareItem(modelData);
}
}
NBox {
id: gridEntry
anchors.fill: parent
anchors.margins: Style.marginXXS
color: gridEntryContainer.isSelected ? Color.mHover : Color.mSurfaceVariant
forceOpaque: gridEntryContainer.isSelected
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: launcher.isCompactDensity ? Style.marginXS : Style.marginS
anchors.bottomMargin: launcher.isCompactDensity ? Style.marginXS : Style.marginS
spacing: launcher.isCompactDensity ? 0 : Style.marginXXS
// Icon badge or Image preview or Emoji
Item {
// Size image at 65% of cell dimensions.
Layout.preferredWidth: Math.round(gridEntry.width * 0.65)
Layout.preferredHeight: Math.round(gridEntry.height * 0.65)
Layout.alignment: Qt.AlignHCenter
// Icon background
Rectangle {
anchors.fill: parent
radius: Style.radiusM
color: Color.mSurface
visible: Settings.data.appLauncher.showIconBackground && !modelData.isImage
}
// Image preview - uses provider's getImageUrl if available
NImageRounded {
id: gridImagePreview
anchors.fill: parent
visible: !!modelData.isImage && !modelData.displayString
radius: Style.radiusM
// Use provider's image revision for reactive updates
readonly property int _rev: modelData.provider && modelData.provider.imageRevision ? modelData.provider.imageRevision : 0
// Get image URL from provider
imagePath: {
_rev;
var provider = modelData.provider;
if (provider && provider.getImageUrl) {
return provider.getImageUrl(modelData);
}
return "";
}
Rectangle {
anchors.fill: parent
visible: parent.status === Image.Loading
color: Color.mSurfaceVariant
BusyIndicator {
anchors.centerIn: parent
running: true
width: Style.baseWidgetSize * 0.5
height: width
}
}
onStatusChanged: status => {
if (status === Image.Error) {
gridIconLoader.visible = true;
gridImagePreview.visible = false;
}
}
}
Loader {
id: gridIconLoader
anchors.fill: parent
anchors.margins: Style.marginXS
visible: (!modelData.isImage && !modelData.displayString) || (!!modelData.isImage && gridImagePreview.status === Image.Error)
active: visible
sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? gridTablerIconComponent : gridSystemIconComponent
Component {
id: gridTablerIconComponent
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeXXXL
visible: modelData.icon && !modelData.displayString
color: (gridEntryContainer.isSelected && !Settings.data.appLauncher.showIconBackground) ? Color.mOnHover : Color.mOnSurface
}
}
Component {
id: gridSystemIconComponent
IconImage {
anchors.fill: parent
source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== "" && !modelData.displayString
asynchronous: true
}
}
}
// String display
NText {
id: gridStringDisplay
anchors.centerIn: parent
visible: !!modelData.displayString || (!gridImagePreview.visible && !gridIconLoader.visible)
text: modelData.displayString ? modelData.displayString : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?")
pointSize: {
if (modelData.displayString) {
// Use custom size if provided, otherwise default scaling
if (modelData.displayStringSize) {
return modelData.displayStringSize * Style.uiScaleRatio;
}
if (launcher.providerHasDisplayString) {
// Scale with cell width but cap at reasonable maximum
const cellBasedSize = gridEntry.width * 0.4;
const maxSize = Style.fontSizeXXXL * Style.uiScaleRatio;
return Math.min(cellBasedSize, maxSize);
}
return Style.fontSizeXXL * 2 * Style.uiScaleRatio;
}
// Scale font size relative to cell width for low res, but cap at maximum
const cellBasedSize = gridEntry.width * 0.25;
const baseSize = Style.fontSizeXL * Style.uiScaleRatio;
const maxSize = Style.fontSizeXXL * Style.uiScaleRatio;
return Math.min(Math.max(cellBasedSize, baseSize), maxSize);
}
font.weight: Style.fontWeightBold
color: modelData.displayString ? Color.mOnSurface : Color.mOnPrimary
}
// Badge icon overlay (generic indicator for any provider)
Rectangle {
visible: !!modelData.badgeIcon
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: height
height: Style.fontSizeM + Style.marginXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NIcon {
anchors.centerIn: parent
icon: modelData.badgeIcon || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
// Text content (hidden when hideLabel is true)
NText {
visible: !modelData.hideLabel
text: modelData.name || "Unknown"
pointSize: {
if (launcher.providerHasDisplayString && modelData.displayString) {
return Style.fontSizeS * Style.uiScaleRatio;
}
// Scale font size relative to cell width for low res, but cap at maximum
const cellBasedSize = gridEntry.width * 0.1;
const baseSize = Style.fontSizeXS * Style.uiScaleRatio;
const maxSize = Style.fontSizeS * Style.uiScaleRatio;
return Math.min(Math.max(cellBasedSize, baseSize), maxSize);
}
font.weight: Style.fontWeightSemiBold
color: gridEntryContainer.isSelected ? Color.mOnHover : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
Layout.maximumWidth: gridEntry.width - 8
Layout.leftMargin: (launcher.providerHasDisplayString && modelData.displayString) ? Style.marginS : 0
Layout.rightMargin: (launcher.providerHasDisplayString && modelData.displayString) ? Style.marginS : 0
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
// Action buttons (overlay in top-right corner) - dynamically populated from provider
Row {
visible: gridEntryContainer.isSelected && gridItemActions.length > 0
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Style.marginXS
z: 10
spacing: Style.marginXXS
property var gridItemActions: {
if (!gridEntryContainer.isSelected)
return [];
var provider = modelData.provider || launcher.currentProvider;
if (provider && provider.getItemActions) {
return provider.getItemActions(modelData);
}
return [];
}
Repeater {
model: parent.gridItemActions
NIconButton {
required property var modelData
icon: modelData.icon
baseSize: Style.baseWidgetSize * 0.75
tooltipText: modelData.tooltip
z: 11
handleWheel: true
onClicked: {
if (modelData.action) {
modelData.action();
}
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !Settings.data.appLauncher.ignoreMouseInput
onEntered: {
if (!launcher.ignoreMouseHover) {
launcher.selectedIndex = gridEntryContainer.index;
}
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
launcher.selectedIndex = gridEntryContainer.index;
launcher.activate();
mouse.accepted = true;
}
}
acceptedButtons: Qt.LeftButton
}
}
@@ -0,0 +1,294 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import qs.Commons
import qs.Widgets
NBox {
id: entry
required property var modelData
required property int index
required property var launcher
property bool isSelected: (!launcher.ignoreMouseHover && mouseArea.containsMouse) || (index === launcher.selectedIndex)
width: ListView.view.width
implicitHeight: launcher.entryHeight
clip: true
color: entry.isSelected ? Color.mHover : Color.mSurfaceVariant
forceOpaque: entry.isSelected
// Prepare item when it becomes visible (e.g., decode images)
Component.onCompleted: {
var provider = modelData.provider;
if (provider && provider.prepareItem) {
provider.prepareItem(modelData);
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: launcher.isCompactDensity ? Style.marginXS : Style.marginM
spacing: launcher.isCompactDensity ? Style.marginXS : Style.marginM
// Top row - Main entry content with action buttons
RowLayout {
Layout.fillWidth: true
spacing: launcher.isCompactDensity ? Style.marginS : Style.marginM
// Icon badge or Image preview or Emoji
Item {
visible: !modelData.hideIcon
Layout.preferredWidth: modelData.hideIcon ? 0 : launcher.badgeSize
Layout.preferredHeight: modelData.hideIcon ? 0 : launcher.badgeSize
// Icon background
Rectangle {
anchors.fill: parent
radius: Style.radiusXS
color: Color.mSurface
visible: Settings.data.appLauncher.showIconBackground && !modelData.isImage
}
// Image preview - uses provider's getImageUrl if available
NImageRounded {
id: imagePreview
anchors.fill: parent
visible: !!modelData.isImage && !modelData.displayString
radius: Style.radiusXS
borderColor: Color.mOnSurface
borderWidth: Style.borderM
imageFillMode: Image.PreserveAspectCrop
// Use provider's image revision for reactive updates
readonly property int _rev: modelData.provider && modelData.provider.imageRevision ? modelData.provider.imageRevision : 0
// Get image URL from provider
imagePath: {
_rev;
var provider = modelData.provider;
if (provider && provider.getImageUrl) {
return provider.getImageUrl(modelData);
}
return "";
}
Rectangle {
anchors.fill: parent
visible: parent.status === Image.Loading
color: Color.mSurfaceVariant
BusyIndicator {
anchors.centerIn: parent
running: true
width: Style.baseWidgetSize * 0.5
height: width
}
}
onStatusChanged: status => {
if (status === Image.Error) {
iconLoader.visible = true;
imagePreview.visible = false;
}
}
}
// Color swatch - shown for clipboard color entries
Rectangle {
anchors.fill: parent
radius: Style.radiusXS
color: modelData.colorHex || "transparent"
visible: !!modelData.colorHex
border.color: Color.mOnSurface
border.width: Style.borderM
}
Loader {
id: iconLoader
anchors.fill: parent
anchors.margins: Style.marginXS
visible: (!modelData.isImage && !modelData.displayString && !modelData.colorHex) || (!!modelData.isImage && imagePreview.status === Image.Error)
active: visible
sourceComponent: Component {
Loader {
anchors.fill: parent
sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? tablerIconComponent : systemIconComponent
}
}
Component {
id: tablerIconComponent
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeXXXL
visible: modelData.icon && !modelData.displayString
color: (entry.isSelected && !Settings.data.appLauncher.showIconBackground) ? Color.mOnHover : Color.mOnSurface
}
}
Component {
id: systemIconComponent
IconImage {
anchors.fill: parent
source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== "" && !modelData.displayString
asynchronous: true
}
}
}
// String display - takes precedence when displayString is present
NText {
id: stringDisplay
anchors.centerIn: parent
visible: !!modelData.displayString || (!imagePreview.visible && !iconLoader.visible)
text: modelData.displayString ? modelData.displayString : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?")
pointSize: modelData.displayString ? (modelData.displayStringSize || Style.fontSizeXXXL) : Style.fontSizeXXL
font.weight: Style.fontWeightBold
color: modelData.displayString ? Color.mOnSurface : Color.mOnPrimary
}
// Image type indicator overlay
Rectangle {
visible: !!modelData.isImage && imagePreview.visible
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: formatLabel.width + Style.marginXS
height: formatLabel.height + Style.marginXXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NText {
id: formatLabel
anchors.centerIn: parent
text: {
if (!modelData.isImage)
return "";
const desc = modelData.description || "";
const parts = desc.split(" \u2022 ");
return parts[0] || "IMG";
}
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
}
// Badge icon overlay (generic indicator for any provider)
Rectangle {
visible: !!modelData.badgeIcon
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: height
height: Style.fontSizeM + Style.marginXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NIcon {
anchors.centerIn: parent
icon: modelData.badgeIcon || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
// Text content
ColumnLayout {
Layout.fillWidth: true
spacing: 0
NText {
text: modelData.name || "Unknown"
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: entry.isSelected ? Color.mOnHover : Color.mOnSurface
elide: Text.ElideRight
maximumLineCount: 1
wrapMode: Text.Wrap
clip: true
Layout.fillWidth: true
}
NText {
text: modelData.description || ""
pointSize: Style.fontSizeS
color: entry.isSelected ? Color.mOnHover : Color.mOnSurfaceVariant
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
visible: text !== "" && !launcher.isCompactDensity
}
}
// Action buttons row - dynamically populated from provider
RowLayout {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
spacing: Style.marginXS
visible: entry.isSelected && itemActions.length > 0
property var itemActions: {
if (!entry.isSelected)
return [];
var provider = modelData.provider || launcher.currentProvider;
if (provider && provider.getItemActions) {
return provider.getItemActions(modelData);
}
return [];
}
Repeater {
model: parent.itemActions
NIconButton {
required property var modelData
icon: modelData.icon
baseSize: Style.baseWidgetSize * 0.75
tooltipText: modelData.tooltip
z: 1
handleWheel: true
onClicked: {
if (modelData.action) {
modelData.action();
}
}
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !Settings.data.appLauncher.ignoreMouseInput
onEntered: {
if (!launcher.ignoreMouseHover) {
launcher.selectedIndex = entry.index;
}
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
launcher.selectedIndex = entry.index;
launcher.activate();
mouse.accepted = true;
}
}
acceptedButtons: Qt.LeftButton
}
}
@@ -0,0 +1,426 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Modules.MainScreen.Backgrounds
import qs.Services.UI
import qs.Widgets
// Standalone launcher window for Overlay layer mode.
// This window appears above fullscreen windows and does not attach to the bar.
Variants {
id: launcherVariants
model: Quickshell.screens.filter(screen => Settings.data.appLauncher.overviewLayer)
delegate: Loader {
id: windowLoader
required property ShellScreen modelData
active: PanelService.overlayLauncherOpen && PanelService.overlayLauncherScreen === modelData
sourceComponent: PanelWindow {
id: launcherWindow
screen: windowLoader.modelData
anchors {
top: true
bottom: true
left: true
right: true
}
color: "transparent"
WlrLayershell.namespace: "noctalia-launcher-overlay-" + (screen?.name || "unknown")
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
BackgroundEffect.blurRegion: Settings.data.general.enableBlurBehind ? launcherBlurRegion : null
Region {
id: launcherBlurRegion
Region {
x: Math.round(launcherPanel.x)
y: Math.round(launcherPanel.y)
width: Math.round(launcherPanel.width)
height: Math.round(launcherPanel.height)
radius: Style.radiusL
topLeftCorner: launcherPanel.topLeftCornerState
topRightCorner: launcherPanel.topRightCornerState
bottomLeftCorner: launcherPanel.bottomLeftCornerState
bottomRightCorner: launcherPanel.bottomRightCornerState
}
Region {
x: Math.round(previewBox.visible ? previewBox.x : 0)
y: Math.round(previewBox.visible ? previewBox.y : 0)
width: Math.round(previewBox.visible ? previewBox.width : 0)
height: Math.round(previewBox.visible ? previewBox.height : 0)
radius: Style.radiusL
}
}
// Positioning logic (respects settings but doesn't attach to bar)
readonly property string barPosition: Settings.data.bar.position
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
readonly property int barThickness: Math.round(Style.barHeight + Style.marginL)
readonly property string panelPosition: {
var pos = Settings.data.appLauncher.position;
if (pos === "follow_bar") {
if (barIsVertical) {
return "center_" + barPosition;
} else {
return barPosition + "_center";
}
}
return pos;
}
// Preview panel support
readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio)
readonly property int previewPanelWidth: Math.round(400 * Style.uiScaleRatio)
readonly property bool previewActive: {
if (!launcherCore)
return false;
var provider = launcherCore.activeProvider;
if (!provider || !provider.hasPreview)
return false;
if (!Settings.data.appLauncher.enableClipPreview)
return false;
return launcherCore.selectedIndex >= 0 && launcherCore.results && !!launcherCore.results[launcherCore.selectedIndex];
}
// Dimmer background (click to close)
Rectangle {
anchors.fill: parent
color: Qt.alpha(Color.mSurface, Settings.data.general.dimmerOpacity)
MouseArea {
anchors.fill: parent
onClicked: PanelService.closeOverlayLauncher()
}
}
// Shadow for launcher panel
NDropShadow {
source: launcherPanel
anchors.fill: launcherPanel
autoPaddingEnabled: true
}
// Launcher panel with position-based anchoring
Item {
id: launcherPanel
width: Math.round(Math.max(parent.width * 0.25, launcherWindow.listPanelWidth + Style.margin2L * 2))
height: Math.round(Math.max(parent.height * 0.5, 600 * Style.uiScaleRatio))
clip: false
// Entrance animation
opacity: 0
transformOrigin: {
if (touchingTop && touchingLeft)
return Item.TopLeft;
if (touchingTop && touchingRight)
return Item.TopRight;
if (touchingBottom && touchingLeft)
return Item.BottomLeft;
if (touchingBottom && touchingRight)
return Item.BottomRight;
if (touchingTop)
return Item.Top;
if (touchingBottom)
return Item.Bottom;
if (touchingLeft)
return Item.Left;
if (touchingRight)
return Item.Right;
return Item.Center;
}
Component.onCompleted: {
opacity = 1;
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
// Horizontal positioning
anchors.horizontalCenter: (panelPosition === "center" || panelPosition.endsWith("_center")) ? parent.horizontalCenter : undefined
anchors.left: panelPosition.endsWith("_left") ? parent.left : undefined
anchors.right: panelPosition.endsWith("_right") ? parent.right : undefined
// Vertical positioning
anchors.verticalCenter: (panelPosition === "center" || panelPosition.startsWith("center_")) ? parent.verticalCenter : undefined
anchors.top: panelPosition.startsWith("top_") ? parent.top : undefined
anchors.bottom: panelPosition.startsWith("bottom_") ? parent.bottom : undefined
// Margins - only add bar clearance on the bar's edge
anchors.leftMargin: barPosition === "left" ? barThickness : 0
anchors.rightMargin: barPosition === "right" ? barThickness : 0
anchors.topMargin: barPosition === "top" ? barThickness : 0
anchors.bottomMargin: barPosition === "bottom" ? barThickness : 0
// Edge detection - based on position setting and bar location
readonly property bool touchingLeft: panelPosition.endsWith("_left") && barPosition !== "left"
readonly property bool touchingRight: panelPosition.endsWith("_right") && barPosition !== "right"
readonly property bool touchingTop: panelPosition.startsWith("top_") && barPosition !== "top"
readonly property bool touchingBottom: panelPosition.startsWith("bottom_") && barPosition !== "bottom"
// Corner states based on edge touching
// State 0: Normal rounded, State 1: Horizontal inversion, State 2: Vertical inversion
readonly property int topLeftCornerState: {
if (touchingLeft && touchingTop)
return 0;
if (touchingLeft)
return 2;
if (touchingTop)
return 1;
return 0;
}
readonly property int topRightCornerState: {
if (touchingRight && touchingTop)
return 0;
if (touchingRight)
return 2;
if (touchingTop)
return 1;
return 0;
}
readonly property int bottomLeftCornerState: {
if (touchingLeft && touchingBottom)
return 0;
if (touchingLeft)
return 2;
if (touchingBottom)
return 1;
return 0;
}
readonly property int bottomRightCornerState: {
if (touchingRight && touchingBottom)
return 0;
if (touchingRight)
return 2;
if (touchingBottom)
return 1;
return 0;
}
// Background with inverted corners - extends beyond panel for inverted corners
Shape {
id: panelShape
// Extend shape to allow inverted corners to render outside panel bounds
x: -radius
y: -radius
width: launcherPanel.width + radius * 2
height: launcherPanel.height + radius * 2
visible: panelW > 0 && panelH > 0
opacity: launcherPanel.opacity
layer.enabled: true
readonly property real radius: Style.radiusL
// Panel dimensions (for path calculations)
readonly property real panelW: launcherPanel.width
readonly property real panelH: launcherPanel.height
// Helper functions for corner rendering
function getMultX(state) {
return state === 1 ? -1 : 1;
}
function getMultY(state) {
return state === 2 ? -1 : 1;
}
function getArcDir(multX, multY) {
return ((multX < 0) !== (multY < 0)) ? PathArc.Counterclockwise : PathArc.Clockwise;
}
readonly property real tlMultX: getMultX(launcherPanel.topLeftCornerState)
readonly property real tlMultY: getMultY(launcherPanel.topLeftCornerState)
readonly property real trMultX: getMultX(launcherPanel.topRightCornerState)
readonly property real trMultY: getMultY(launcherPanel.topRightCornerState)
readonly property real blMultX: getMultX(launcherPanel.bottomLeftCornerState)
readonly property real blMultY: getMultY(launcherPanel.bottomLeftCornerState)
readonly property real brMultX: getMultX(launcherPanel.bottomRightCornerState)
readonly property real brMultY: getMultY(launcherPanel.bottomRightCornerState)
ShapePath {
strokeWidth: -1
fillColor: Qt.alpha(Color.mSurface, Color.adaptiveOpacity(Settings.data.ui.panelBackgroundOpacity))
// Offset by radius to account for Shape's extended bounds
startX: panelShape.radius + panelShape.radius * panelShape.tlMultX
startY: panelShape.radius
// Top edge
PathLine {
relativeX: panelShape.panelW - panelShape.radius * panelShape.tlMultX - panelShape.radius * panelShape.trMultX
relativeY: 0
}
// Top-right corner
PathArc {
relativeX: panelShape.radius * panelShape.trMultX
relativeY: panelShape.radius * panelShape.trMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.trMultX, panelShape.trMultY)
}
// Right edge
PathLine {
relativeX: 0
relativeY: panelShape.panelH - panelShape.radius * panelShape.trMultY - panelShape.radius * panelShape.brMultY
}
// Bottom-right corner
PathArc {
relativeX: -panelShape.radius * panelShape.brMultX
relativeY: panelShape.radius * panelShape.brMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.brMultX, panelShape.brMultY)
}
// Bottom edge
PathLine {
relativeX: -(panelShape.panelW - panelShape.radius * panelShape.brMultX - panelShape.radius * panelShape.blMultX)
relativeY: 0
}
// Bottom-left corner
PathArc {
relativeX: -panelShape.radius * panelShape.blMultX
relativeY: -panelShape.radius * panelShape.blMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.blMultX, panelShape.blMultY)
}
// Left edge
PathLine {
relativeX: 0
relativeY: -(panelShape.panelH - panelShape.radius * panelShape.blMultY - panelShape.radius * panelShape.tlMultY)
}
// Top-left corner
PathArc {
relativeX: panelShape.radius * panelShape.tlMultX
relativeY: -panelShape.radius * panelShape.tlMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.tlMultX, panelShape.tlMultY)
}
}
}
// Border
Rectangle {
anchors.fill: parent
color: "transparent"
radius: Style.radiusL
border.color: Style.boxBorderColor
border.width: Style.borderS
visible: !launcherPanel.touchingLeft && !launcherPanel.touchingRight && !launcherPanel.touchingTop && !launcherPanel.touchingBottom
}
LauncherCore {
id: launcherCore
anchors.fill: parent
screen: windowLoader.modelData
isOpen: true
onRequestClose: PanelService.closeOverlayLauncher()
onRequestCloseImmediately: PanelService.closeOverlayLauncherImmediately()
Component.onCompleted: PanelService.overlayLauncherCore = launcherCore
Component.onDestruction: PanelService.overlayLauncherCore = null
}
}
// Preview Panel - positioned as sibling of launcherPanel to avoid shadow bleed
NDropShadow {
source: previewBox
anchors.fill: previewBox
autoPaddingEnabled: true
visible: previewBox.visible
z: previewBox.z - 1
}
NBox {
id: previewBox
visible: launcherWindow.previewActive
width: launcherWindow.previewPanelWidth
height: Math.round(400 * Style.uiScaleRatio)
forceOpaque: true
x: {
if (panelPosition.endsWith("_right"))
return launcherPanel.x - launcherWindow.previewPanelWidth - Style.marginM;
return launcherPanel.x + launcherPanel.width + Style.marginM;
}
y: {
var view = launcherCore.resultsView;
if (!view)
return launcherPanel.y + Style.marginL;
var row = launcherCore.isGridView ? Math.floor(launcherCore.selectedIndex / launcherCore.gridColumns) : launcherCore.selectedIndex;
var gridCellSize = Math.floor((launcherWindow.listPanelWidth - (2 * Style.marginXS) - ((launcherCore.targetGridColumns - 1) * Style.marginS)) / launcherCore.targetGridColumns);
var itemHeight = launcherCore.isGridView ? (gridCellSize + Style.marginXXS) : (launcherCore.entryHeight + (view.spacing || 0));
var yPos = row * itemHeight - (view.contentY || 0);
var mapped = view.mapToItem(launcherWindow.contentItem, 0, yPos);
return Math.max(launcherPanel.y + Style.marginL, Math.min(mapped.y, launcherPanel.y + launcherPanel.height - previewBox.height - Style.marginL));
}
opacity: visible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on y {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Loader {
id: previewLoader
anchors.fill: parent
active: launcherWindow.previewActive
source: {
if (!active)
return "";
var provider = launcherCore.activeProvider;
if (provider && provider.previewComponentPath)
return provider.previewComponentPath;
return "";
}
onLoaded: updatePreviewItem()
onItemChanged: updatePreviewItem()
function updatePreviewItem() {
if (!item || launcherCore.selectedIndex < 0 || !launcherCore.results[launcherCore.selectedIndex])
return;
var provider = launcherCore.activeProvider;
if (provider && provider.getPreviewData) {
item.currentItem = provider.getPreviewData(launcherCore.results[launcherCore.selectedIndex]);
} else {
item.currentItem = launcherCore.results[launcherCore.selectedIndex];
}
}
}
}
// Update preview when selection changes
Connections {
target: launcherCore
function onSelectedIndexChanged() {
if (previewLoader.item)
previewLoader.updatePreviewItem();
}
}
}
}
}
@@ -0,0 +1,654 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
import qs.Services.System
Item {
id: root
property var launcher: null
property string name: I18n.tr("launcher.providers.applications")
property bool handleSearch: true
property var entries: []
property string supportedLayouts: "both"
property bool isDefaultProvider: true // This provider handles empty search
property bool ignoreDensity: false // Apps should scale with launcher density
property bool trackUsage: true // Track usage frequency for "most used" sorting
// Category support
property string selectedCategory: "all"
property bool showsCategories: true // Default to showing categories
property var categories: ["all", "Pinned", "AudioVideo", "Chat", "Development", "Education", "Game", "Graphics", "Network", "Office", "System", "Misc", "WebBrowser"]
property var availableCategories: ["all"] // Reactive property for available categories
property var categoryIcons: ({
"all": "apps",
"Pinned": "pin",
"AudioVideo": "music",
"Chat": "message-circle",
"Development": "code",
"Education": "school" // Includes Science
,
"Game": "device-gamepad",
"Graphics": "brush",
"Network": "wifi",
"Office": "file-text",
"System": "device-desktop" // Includes Settings and Utility
,
"Misc": "dots",
"WebBrowser": "world"
})
function getCategoryName(category) {
const names = {
"all": I18n.tr("launcher.categories.all"),
"Pinned": I18n.tr("launcher.categories.pinned"),
"AudioVideo": I18n.tr("launcher.categories.audiovideo"),
"Chat": I18n.tr("launcher.categories.chat"),
"Development": I18n.tr("launcher.categories.development"),
"Education": I18n.tr("launcher.categories.education"),
"Game": I18n.tr("launcher.categories.game"),
"Graphics": I18n.tr("launcher.categories.graphics"),
"Network": I18n.tr("common.network"),
"Office": I18n.tr("launcher.categories.office"),
"System": I18n.tr("launcher.categories.system"),
"Misc": I18n.tr("launcher.categories.misc"),
"WebBrowser": I18n.tr("launcher.categories.webbrowser")
};
return names[category] || category;
}
function init() {
loadApplications();
migrateLegacyUsageKeys();
}
function onOpened() {
// Just update available categories in case pinned apps changed
updateAvailableCategories();
// Default to Pinned if there are pinned apps, otherwise all
if (availableCategories.includes("Pinned")) {
selectedCategory = "Pinned";
} else {
selectedCategory = "all";
}
// Set category mode initially (will be updated when getResults is called)
showsCategories = true;
}
// Reload applications when desktop entries change on disk
Connections {
target: typeof DesktopEntries !== 'undefined' ? DesktopEntries.applications : null
function onValuesChanged() {
Logger.d("ApplicationsProvider", "Desktop entries changed, reloading applications");
loadApplications();
}
}
function selectCategory(category) {
selectedCategory = category;
if (launcher) {
launcher.updateResults();
}
}
function getAppCategories(app) {
if (!app)
return [];
const result = [];
if (app.categories) {
if (Array.isArray(app.categories)) {
for (let cat of app.categories) {
if (cat && cat.trim && cat.trim() !== '') {
result.push(cat.trim());
} else if (cat && typeof cat === 'string' && cat.trim() !== '') {
result.push(cat.trim());
}
}
} else if (typeof app.categories === 'string') {
const cats = app.categories.split(';').filter(c => c && c.trim() !== '');
for (let cat of cats) {
const trimmed = cat.trim();
if (trimmed && !result.includes(trimmed)) {
result.push(trimmed);
}
}
} else if (app.categories.length !== undefined) {
try {
for (let i = 0; i < app.categories.length; i++) {
const cat = app.categories[i];
if (cat && cat.trim && typeof cat.trim === 'function' && cat.trim() !== '') {
result.push(cat.trim());
} else if (cat && typeof cat === 'string' && cat.trim() !== '') {
result.push(cat.trim());
}
}
} catch (e) {}
}
}
if (app.Categories) {
const cats = app.Categories.split(';').filter(c => c && c.trim() !== '');
for (let cat of cats) {
const trimmed = cat.trim();
if (trimmed && !result.includes(trimmed)) {
result.push(trimmed);
}
}
}
return result;
}
function getAppCategory(app) {
const appCategories = getAppCategories(app);
if (appCategories.length === 0)
return null;
const priorityCategories = ["AudioVideo", "Chat", "WebBrowser", "Game", "Development", "Graphics", "Office", "Education", "System", "Network", "Misc"];
for (let cat of appCategories) {
if (cat === "AudioVideo" || cat === "Audio" || cat === "Video") {
return "AudioVideo";
}
}
if (appCategories.includes("Chat") || appCategories.includes("InstantMessaging")) {
return "Chat";
}
if (appCategories.includes("WebBrowser")) {
return "WebBrowser";
}
// Map Science to Education
if (appCategories.includes("Science")) {
return "Education";
}
// Map Settings to System
if (appCategories.includes("Settings")) {
return "System";
}
// Map Utility to System
if (appCategories.includes("Utility")) {
return "System";
}
for (let priorityCat of priorityCategories) {
if (appCategories.includes(priorityCat) && root.categories.includes(priorityCat)) {
return priorityCat;
}
}
return "Misc";
}
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
return "";
return appId.toLowerCase().trim();
}
// Helper function to check if an app is pinned
function isAppPinned(app) {
if (!app)
return false;
const pinnedApps = Settings.data.appLauncher.pinnedApps || [];
const appId = getAppKey(app);
const normalizedId = normalizeAppId(appId);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId);
}
function appMatchesCategory(app, category) {
// Check if app matches the selected category
if (category === "all")
return true;
// Handle Pinned category separately
if (category === "Pinned") {
return isAppPinned(app);
}
// Get the primary category for this app (first matching standard category)
const primaryCategory = getAppCategory(app);
// If app has no matching standard category, don't show it in any category (only in "all")
if (!primaryCategory)
return false;
// Map Audio/Video to AudioVideo
if (category === "AudioVideo") {
const appCategories = getAppCategories(app);
// Show if app has AudioVideo, Audio, or Video
return appCategories.includes("AudioVideo") || appCategories.includes("Audio") || appCategories.includes("Video");
}
// Map Science to Education
if (category === "Education") {
const appCategories = getAppCategories(app);
return appCategories.includes("Education") || appCategories.includes("Science");
}
// Map Settings and Utility to System
if (category === "System") {
const appCategories = getAppCategories(app);
return appCategories.includes("System") || appCategories.includes("Settings") || appCategories.includes("Utility");
}
// Only show app in its primary category to avoid overlap
// This ensures each app appears in exactly one category tab
return category === primaryCategory;
}
function getAvailableCategories() {
const categorySet = new Set();
let hasAudioVideo = false;
let hasEducation = false;
let hasSystem = false;
let hasPinned = false;
// Check if there are any pinned apps
const pinnedApps = Settings.data.appLauncher.pinnedApps || [];
if (pinnedApps.length > 0) {
// Verify that at least one pinned app exists in entries
for (let app of entries) {
if (isAppPinned(app)) {
hasPinned = true;
break;
}
}
}
for (let app of entries) {
const appCategories = getAppCategories(app);
const primaryCategory = getAppCategory(app);
if (appCategories.includes("AudioVideo") || appCategories.includes("Audio") || appCategories.includes("Video")) {
hasAudioVideo = true;
} else if (appCategories.includes("Education") || appCategories.includes("Science")) {
hasEducation = true;
} else if (appCategories.includes("System") || appCategories.includes("Settings") || appCategories.includes("Utility")) {
hasSystem = true;
} else if (primaryCategory && root.categories.includes(primaryCategory)) {
categorySet.add(primaryCategory);
}
}
const result = [];
// Add Pinned category first if there are pinned apps
if (hasPinned) {
result.push("Pinned");
}
result.push("all");
if (hasAudioVideo) {
categorySet.add("AudioVideo");
}
if (hasEducation) {
categorySet.add("Education");
}
if (hasSystem) {
categorySet.add("System");
}
for (let cat of root.categories) {
if (cat !== "all" && cat !== "Pinned" && cat !== "Misc" && categorySet.has(cat)) {
result.push(cat);
}
}
if (categorySet.has("Misc")) {
result.push("Misc");
}
if (result.length === 1) {
const fallback = root.categories.filter(c => c !== "Misc");
fallback.push("Misc");
return fallback;
}
return result;
}
function loadApplications() {
if (typeof DesktopEntries === 'undefined') {
Logger.w("ApplicationsProvider", "DesktopEntries service not available");
return;
}
const allApps = DesktopEntries.applications.values || [];
const seen = new Map(); // Map of appId -> exec command
entries = allApps.filter(app => {
if (!app || app.noDisplay || app.hidden)
return false;
const appId = app.id || app.name;
const execCmd = getExecutableName(app);
// Check if we've seen this app ID before
if (seen.has(appId)) {
const previousExec = seen.get(appId);
// If exec is different, it's a legitimate different entry - keep it
if (previousExec !== execCmd) {
Logger.d("ApplicationsProvider", `Keeping variant of ${appId}: ${execCmd} (differs from ${previousExec})`);
// Add with modified ID to make it unique
app.id = `${appId}_${execCmd}`;
seen.set(app.id, execCmd);
return true;
}
// Same appId AND same exec = true duplicate, skip it
Logger.d("ApplicationsProvider", `Skipping duplicate: ${appId}`);
return false;
}
seen.set(appId, execCmd);
return true;
}).map(app => {
app.executableName = getExecutableName(app);
return app;
});
Logger.d("ApplicationsProvider", `Loaded ${entries.length} applications`);
updateAvailableCategories();
}
function updateAvailableCategories() {
availableCategories = getAvailableCategories();
}
Connections {
target: Settings.data.appLauncher
function onPinnedAppsChanged() {
const wasViewingPinned = selectedCategory === "Pinned";
updateAvailableCategories();
// If we were viewing Pinned category and it's no longer available, switch to "all"
if (wasViewingPinned && !availableCategories.includes("Pinned")) {
selectedCategory = "all";
}
// Update results if we're currently viewing the Pinned category
if (selectedCategory === "Pinned" && launcher) {
launcher.updateResults();
} else if (wasViewingPinned && selectedCategory === "all" && launcher) {
// Also update results when switching to "all"
launcher.updateResults();
}
}
}
function getExecutableName(app) {
if (!app)
return "";
// Try to get executable name from command array
if (app.command && Array.isArray(app.command) && app.command.length > 0) {
const cmd = app.command[0];
// Extract just the executable name from the full path
const parts = cmd.split('/');
const executable = parts[parts.length - 1];
// Remove any arguments or parameters
return executable.split(' ')[0];
}
// Try to get from exec property if available
if (app.exec) {
const parts = app.exec.split('/');
const executable = parts[parts.length - 1];
return executable.split(' ')[0];
}
// Fallback to app id (desktop file name without .desktop)
if (app.id) {
return app.id.replace('.desktop', '');
}
return "";
}
function getResults(query) {
if (!entries || entries.length === 0)
return [];
// Set category mode based on whether there's a query
const isSearching = !!(query && query.trim() !== "");
showsCategories = !isSearching;
// Filter by category only when NOT searching
let filteredEntries = entries;
if (!isSearching && selectedCategory && selectedCategory !== "all") {
filteredEntries = entries.filter(app => appMatchesCategory(app, selectedCategory));
}
if (!query || query.trim() === "") {
// Return filtered apps, optionally sorted by usage
let sorted;
if (Settings.data.appLauncher.sortByMostUsed) {
sorted = filteredEntries.slice().sort((a, b) => {
// Pinned first
const aPinned = isAppPinned(a);
const bPinned = isAppPinned(b);
if (aPinned !== bPinned)
return aPinned ? -1 : 1;
const ua = getUsageCount(a);
const ub = getUsageCount(b);
if (ub !== ua)
return ub - ua;
return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase());
});
} else {
sorted = filteredEntries.slice().sort((a, b) => {
const aPinned = isAppPinned(a);
const bPinned = isAppPinned(b);
if (aPinned !== bPinned)
return aPinned ? -1 : 1;
return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase());
});
}
return sorted.map(app => createResultEntry(app));
}
// Use fuzzy search if available, fallback to simple search
if (typeof FuzzySort !== 'undefined') {
const fuzzyResults = FuzzySort.go(query, filteredEntries, {
"keys": ["name", "comment", "genericName", "executableName"],
"limit": 20
});
// Sort pinned first within fuzzy results while preserving fuzzysort order otherwise
const pinned = [];
const nonPinned = [];
for (const r of fuzzyResults) {
const app = r.obj;
if (isAppPinned(app))
pinned.push(r);
else
nonPinned.push(r);
}
return pinned.concat(nonPinned).map(result => createResultEntry(result.obj, result.score));
} else {
// Fallback to simple search
const searchTerm = query.toLowerCase();
return filteredEntries.filter(app => {
const name = (app.name || "").toLowerCase();
const comment = (app.comment || "").toLowerCase();
const generic = (app.genericName || "").toLowerCase();
const executable = getExecutableName(app).toLowerCase();
return name.includes(searchTerm) || comment.includes(searchTerm) || generic.includes(searchTerm) || executable.includes(searchTerm);
}).sort((a, b) => {
// Prioritize name matches, then executable matches
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aExecutable = getExecutableName(a).toLowerCase();
const bExecutable = getExecutableName(b).toLowerCase();
const aStarts = aName.startsWith(searchTerm);
const bStarts = bName.startsWith(searchTerm);
const aExecStarts = aExecutable.startsWith(searchTerm);
const bExecStarts = bExecutable.startsWith(searchTerm);
// Prioritize name matches first
if (aStarts && !bStarts)
return -1;
if (!aStarts && bStarts)
return 1;
// Then prioritize executable matches
if (aExecStarts && !bExecStarts)
return -1;
if (!aExecStarts && bExecStarts)
return 1;
return aName.localeCompare(bName);
}).slice(0, 20).map(app => createResultEntry(app));
}
}
function createResultEntry(app, score) {
return {
"appId": getAppKey(app),
"usageKey": getAppKey(app),
"name": app.name || "Unknown",
"description": app.genericName || app.comment || "",
"icon": app.icon || "application-x-executable",
"isImage": false,
"_score": (score !== undefined ? score : 0),
"provider": root,
"onActivate": function () {
// Close the launcher/SmartPanel immediately without any animations.
// Ensures we are not preventing the future focusing of the app
launcher.closeImmediately();
// Defer execution to next event loop iteration to ensure panel is fully closed
Qt.callLater(() => {
Logger.d("ApplicationsProvider", `Launching: ${app.name} (App ID: ${app.id || "unknown"})`);
const execString = (app.exec !== undefined && app.exec !== null) ? String(app.exec) : "";
const commandArgs = Array.isArray(app.command) ? app.command : (app.command && app.command.length !== undefined) ? Array.from(app.command) : [];
let hasQuotedArgs = execString.includes("\"") || execString.includes("'");
let hasSpaceArgs = false;
if (!hasQuotedArgs) {
hasQuotedArgs = commandArgs.some(arg => {
const text = String(arg);
return text.includes("\"") || text.includes("'");
});
}
if (!hasSpaceArgs) {
hasSpaceArgs = commandArgs.some(arg => String(arg).includes(" "));
}
if (app.execute && (hasQuotedArgs || hasSpaceArgs)) {
Logger.d("ApplicationsProvider", `Detected quoted/space arguments in Exec for ${app.name}, using app.execute()`);
app.execute();
return;
}
if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) {
// Use custom launch prefix
const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" ");
Logger.d("ApplicationsProvider", `Using custom launch prefix: ${Settings.data.appLauncher.customLaunchPrefix}`);
if (app.runInTerminal) {
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = prefix.concat(terminal.concat(app.command));
Logger.d("ApplicationsProvider", `Executing command (with prefix and terminal): ${command.join(" ")}`);
Quickshell.execDetached(command);
} else {
const command = prefix.concat(app.command);
Logger.d("ApplicationsProvider", `Executing command (with prefix): ${command.join(" ")}`);
Quickshell.execDetached(command);
}
} else {
if (app.runInTerminal) {
Logger.d("ApplicationsProvider", "Executing terminal app manually: " + app.name);
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = terminal.concat(app.command);
Logger.d("ApplicationsProvider", "Executing command (manual terminal): " + command.join(" "));
CompositorService.spawn(command);
} else if (app.command && app.command.length > 0) {
Logger.d("ApplicationsProvider", "Executing command: " + app.command.join(" "));
CompositorService.spawn(app.command);
} else if (app.execute) {
Logger.d("ApplicationsProvider", "Calling app.execute() for: " + app.name);
app.execute();
} else {
Logger.w("ApplicationsProvider", `Could not launch: ${app.name}. No valid launch method.`);
}
}
});
}
};
}
// -------------------------
// Item actions for launcher delegate
function getItemActions(item) {
if (!item || !item.appId)
return [];
return [
{
"icon": isAppPinned({
"id": item.appId
}) ? "unpin" : "pin",
"tooltip": isAppPinned({
"id": item.appId
}) ? I18n.tr("common.unpin") : I18n.tr("common.pin"),
"action": function () {
togglePin(item.appId);
}
}
];
}
function togglePin(appId) {
if (!appId)
return;
const normalizedId = normalizeAppId(appId);
let arr = (Settings.data.appLauncher.pinnedApps || []).slice();
const idx = arr.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId);
if (idx >= 0)
arr.splice(idx, 1);
else
arr.push(appId);
Settings.data.appLauncher.pinnedApps = arr;
}
// -------------------------
// Usage tracking helpers
function getAppKey(app) {
if (app && app.id)
return String(app.id);
if (app && app.command && app.command.join)
return app.command.join(" ");
return String(app && app.name ? app.name : "unknown");
}
function getUsageCount(app) {
return ShellState.getLauncherUsageCount(getAppKey(app));
}
// Migrate legacy command-based usage keys to canonical app-id keys at startup
function migrateLegacyUsageKeys() {
for (let i = 0; i < entries.length; i++) {
const app = entries[i];
if (app && app.id && app.command && app.command.join) {
const key = getAppKey(app);
const legacyKey = app.command.join(" ");
if (legacyKey !== key && ShellState.getLauncherUsageCount(legacyKey) > 0) {
ShellState.migrateLauncherUsage(legacyKey, key);
Logger.d("ApplicationsProvider", `Migrated usage: "${legacyKey}" "${key}"`);
}
}
}
}
}
@@ -0,0 +1,77 @@
import QtQuick
import Quickshell
import "../../../../Helpers/AdvancedMath.js" as AdvancedMath
import qs.Commons
import qs.Services.Keyboard
import qs.Services.UI
Item {
id: root
// Provider metadata
property string name: I18n.tr("launcher.providers.calculator")
property var launcher: null
property string iconMode: Settings.data.appLauncher.iconMode
property bool handleSearch: true // Contribute to regular search
property string supportedLayouts: "list"
// Initialize provider
function init() {
Logger.d("CalculatorProvider", "Initialized");
}
// Get search results - evaluates math expressions inline
function getResults(query) {
if (!query)
return [];
const trimmed = query.trim();
if (!trimmed || !isMathExpression(trimmed))
return [];
try {
const result = AdvancedMath.evaluate(trimmed);
const formattedResult = AdvancedMath.formatResult(result);
return [
{
"name": formattedResult,
"description": I18n.tr("launcher.providers.calculator-press-enter-to-copy"),
"icon": iconMode === "tabler" ? "calculator" : "accessories-calculator",
"isTablerIcon": true,
"isImage": false,
"provider": root,
"onActivate": function () {
// Copy result to clipboard via xclip
Quickshell.execDetached(["sh", "-c", "echo -n '" + formattedResult.replace(/'/g, "'\\''") + "' | wl-copy"]);
ToastService.showNotice(I18n.tr("common.copied-to-clipboard"), formattedResult);
if (launcher)
launcher.close();
}
}
];
} catch (error) {
return [];
}
}
// Check if a string is a valid math expression
function isMathExpression(expr) {
// Allow: digits, operators, parentheses, decimal points, whitespace, letters (for functions), commas
if (!/^[\d\s\+\-\*\/\(\)\.\%\^a-zA-Z,]+$/.test(expr))
return false;
// Must contain at least one operator OR a function call (letter followed by parenthesis)
if (!/[+\-*/%\^]/.test(expr) && !/[a-zA-Z]\s*\(/.test(expr))
return false;
// Reject if ends with an operator (incomplete expression)
if (/[+\-*/%\^]\s*$/.test(expr))
return false;
// Reject if it's just letters (would match app names)
if (/^[a-zA-Z\s]+$/.test(expr))
return false;
return true;
}
}
@@ -0,0 +1,457 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Keyboard
import qs.Services.Noctalia
Item {
id: root
// Provider metadata
property string name: I18n.tr("launcher.providers.clipboard")
property var launcher: null
property string iconMode: Settings.data.appLauncher.iconMode
property string supportedLayouts: "list" // List view for clipboard content
property bool wrapNavigation: false // Don't wrap at end of list
// Provider capabilities
property bool handleSearch: false // Don't handle regular search
// Preview support
property bool hasPreview: Settings.data.appLauncher.enableClipPreview
property string previewComponentPath: "./ClipboardPreview.qml"
// Image handling - expose revision for reactive updates in delegates
readonly property int imageRevision: ClipboardService.revision
// Categories
property var availableCategories: Settings.data.appLauncher.enableClipboardChips ? ["All", "Images", "Links", "Files", "Code", "Colors"] : []
property string selectedCategory: "All"
function selectCategory(cat) {
if (selectedCategory !== cat) {
selectedCategory = cat;
if (launcher) {
launcher.updateResults();
}
}
}
property var categoryIcons: {
"All": iconMode === "tabler" ? "border-all" : "view-grid",
"Images": iconMode === "tabler" ? "photo" : "image",
"Links": iconMode === "tabler" ? "link" : "insert-link",
"Files": iconMode === "tabler" ? "file" : "text-x-generic",
"Code": iconMode === "tabler" ? "code" : "text-x-script",
"Colors": iconMode === "tabler" ? "palette" : "color-picker"
}
// Internal state
property bool isWaitingForData: false
property bool gotResults: false
property string lastSearchText: ""
// Listen for clipboard data updates
Connections {
target: ClipboardService
function onListCompleted() {
if (gotResults && (lastSearchText === searchText)) {
// Do not update results after the first fetch.
// This will avoid the list resetting every 2seconds when the service updates.
return;
}
// Refresh results if we're waiting for data or if clipboard plugin is active
if (isWaitingForData || (launcher && launcher.searchText.startsWith(">clip"))) {
isWaitingForData = false;
gotResults = true;
if (launcher) {
launcher.updateResults();
}
}
}
function onActiveChanged() {
// When active state changes (e.g. dependency check completes), refresh results
if (ClipboardService.active && launcher && launcher.searchText.startsWith(">clip")) {
isWaitingForData = true;
gotResults = false;
ClipboardService.list(100);
}
}
}
// Initialize provider
function init() {
Logger.d("ClipboardProvider", "Initialized");
// Pre-load clipboard data if service is active
if (ClipboardService.active) {
ClipboardService.list(100);
}
}
// Called when launcher opens
function onOpened() {
isWaitingForData = true;
gotResults = false;
lastSearchText = "";
// Refresh clipboard history when launcher opens
if (ClipboardService.active) {
ClipboardService.list(100);
}
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">clip");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">clip",
"description": I18n.tr("launcher.providers.clipboard-search-description"),
"icon": iconMode === "tabler" ? "clipboard" : "diodon",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">clip ");
}
},
{
"name": ">clip clear",
"description": I18n.tr("launcher.providers.clipboard-clear-description"),
"icon": iconMode === "tabler" ? "trash" : "user-trash",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
ClipboardService.wipeAll();
launcher.close();
}
}
];
}
// Get search results
function getResults(searchText) {
if (!searchText.startsWith(">clip")) {
return [];
}
lastSearchText = searchText;
const results = [];
const query = searchText.slice(5).trim();
// Check if clipboard service is not active
if (!ClipboardService.active) {
// If dependency check hasn't completed yet, show loading instead of disabled
if (!ClipboardService.dependencyChecked) {
return [
{
"name": I18n.tr("launcher.providers.clipboard-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
return [
{
"name": I18n.tr("launcher.providers.clipboard-history-disabled"),
"description": I18n.tr("launcher.providers.clipboard-history-disabled-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
// Special command: clear
if (query === "clear") {
return [
{
"name": I18n.tr("launcher.providers.clipboard-clear-history"),
"description": I18n.tr("launcher.providers.clipboard-clear-description-full"),
"icon": iconMode === "tabler" ? "trash" : "user-trash",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
ClipboardService.wipeAll();
launcher.close();
}
}
];
}
// Show loading state if data is being loaded
if (ClipboardService.loading || isWaitingForData) {
return [
{
"name": I18n.tr("launcher.providers.clipboard-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
// Get clipboard items
const items = ClipboardService.items || [];
// If no items and we haven't tried loading yet, trigger a load
if (items.count === 0 && !ClipboardService.loading) {
isWaitingForData = true;
ClipboardService.list(100);
return [
{
"name": I18n.tr("launcher.providers.clipboard-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
// Search clipboard items
const searchTerm = query.toLowerCase();
const now = Date.now() / 1000;
const catMap = {
"Images": "image",
"Links": "link",
"Files": "file",
"Code": "code",
"Colors": "color"
};
// Filter and format results
items.forEach(function (item) {
// Category filter
if (Settings.data.appLauncher.enableClipboardChips && root.selectedCategory !== "All") {
if (item.contentType !== catMap[root.selectedCategory]) {
return;
}
}
const preview = (item.preview || "").toLowerCase();
// Skip if search term doesn't match
if (searchTerm && preview.indexOf(searchTerm) === -1) {
return;
}
const firstSeen = ClipboardService.firstSeenById[item.id] || now;
// Format the result based on type
let entry;
if (item.isImage) {
entry = formatImageEntry(item, firstSeen);
} else {
entry = formatTextEntry(item, firstSeen);
}
// Add activation handler
entry.onActivate = function () {
if (Settings.data.appLauncher.autoPasteClipboard) {
launcher.closeImmediately();
Qt.callLater(() => {
ClipboardService.pasteFromClipboard(item.id, item.mime);
});
} else {
ClipboardService.copyToClipboard(item.id);
launcher.close();
}
};
results.push(entry);
});
// Show empty state if no results
if (results.length === 0) {
results.push({
"name": searchTerm ? "No matching clipboard items" : "Clipboard is empty",
"description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here",
"icon": iconMode === "tabler" ? "clipboard" : "text-x-generic",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {// Do nothing
}
});
}
//Logger.i("ClipboardPlugin", `Returning ${results.length} results for query: "${query}"`)
return results;
}
function formatImageEntry(item, firstSeen) {
const meta = ClipboardService.parseImageMeta(item.preview);
const timeStr = Time.formatRelativeTime(new Date(firstSeen * 1000));
let desc = meta ? `${meta.fmt} ${meta.size}` : item.mime || "Image data";
if (timeStr)
desc += ` ${timeStr}`;
return {
"name": meta ? `Image ${meta.w}×${meta.h}` : "Image",
"description": desc,
"icon": iconMode === "tabler" ? "photo" : "image",
"isTablerIcon": true,
"isImage": true,
"imageWidth": meta ? meta.w : 0,
"imageHeight": meta ? meta.h : 0,
"clipboardId": item.id,
"mime": item.mime,
"preview": item.preview,
"provider": root
};
}
function formatTextEntry(item, firstSeen) {
const preview = (item.preview || "").trim();
const lines = preview.split('\n').filter(l => l.trim());
let title = lines[0] || "Empty text";
if (title.length > 60) {
title = title.substring(0, 57) + "...";
}
let description = "";
if (lines.length > 1) {
description = lines[1];
if (description.length > 80) {
description = description.substring(0, 77) + "...";
}
} else {
// Preview is truncated at ~100 chars, so we can't show exact count
if (preview.length >= 100) {
description = I18n.tr("toast.clipboard.long-text");
} else {
const chars = preview.length;
const words = preview.split(/\s+/).length;
description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}`;
}
}
const timeStr = Time.formatRelativeTime(new Date(firstSeen * 1000));
if (timeStr)
description += ` ${timeStr}`;
let defaultIcon = iconMode === "tabler" ? "clipboard" : "text-x-generic";
let colorHex = "";
if (Settings.data.appLauncher.enableClipboardSmartIcons) {
if (item.contentType === "link")
defaultIcon = iconMode === "tabler" ? "link" : "insert-link";
else if (item.contentType === "file")
defaultIcon = iconMode === "tabler" ? "file" : "text-x-generic";
else if (item.contentType === "code")
defaultIcon = iconMode === "tabler" ? "code" : "text-x-script";
else if (item.contentType === "color") {
defaultIcon = iconMode === "tabler" ? "palette" : "color-picker";
colorHex = preview;
}
}
return {
"name": title,
"description": description,
"icon": defaultIcon,
"isTablerIcon": true,
"isImage": false,
"clipboardId": item.id,
"preview": preview,
"contentType": item.contentType,
"colorHex": colorHex,
"provider": root
};
}
function getImageForItem(clipboardId) {
return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null;
}
// -------------------------
// Item actions for launcher delegate
function getItemActions(item) {
if (!item || !item.clipboardId)
return [];
var actions = [];
// Annotation tool for images
if (item.isImage && Settings.data.appLauncher.screenshotAnnotationTool !== "") {
actions.push({
"icon": "pencil",
"tooltip": I18n.tr("tooltips.open-annotation-tool"),
"action": function () {
var tool = Settings.data.appLauncher.screenshotAnnotationTool;
Quickshell.execDetached(["sh", "-c", "cliphist decode " + item.clipboardId + " | " + tool]);
if (launcher)
launcher.close();
}
});
}
// Delete action
actions.push({
"icon": "trash",
"tooltip": I18n.tr("launcher.providers.clipboard-delete"),
"action": function () {
deleteItem(item);
}
});
return actions;
}
function canDeleteItem(item) {
return item && !!item.clipboardId;
}
function deleteItem(item) {
if (!item || !item.clipboardId)
return;
// Set provider state before deletion so refresh works
gotResults = false;
isWaitingForData = true;
lastSearchText = launcher ? launcher.searchText : "";
// Delete the item
ClipboardService.deleteById(String(item.clipboardId));
}
// Prepare item for display (handles image decoding)
function prepareItem(item) {
if (item && item.isImage && item.clipboardId) {
if (!ClipboardService.getImageData(item.clipboardId)) {
ClipboardService.decodeToDataUrl(item.clipboardId, item.mime, null);
}
}
}
// Get image URL for item (used by delegates)
function getImageUrl(item) {
if (!item || !item.clipboardId)
return "";
return ClipboardService.getImageData(item.clipboardId) || "";
}
// Get preview data for the preview panel
function getPreviewData(item) {
if (!item)
return null;
return {
"clipboardId": item.clipboardId,
"isImage": item.isImage,
"mime": item.mime,
"preview": item.preview
};
}
}
@@ -0,0 +1,51 @@
import QtQuick
import Quickshell
import qs.Commons
Item {
property var launcher: null
property string name: I18n.tr("common.command")
property string iconMode: Settings.data.appLauncher.iconMode
function handleCommand(query) {
return query.startsWith(">cmd");
}
function commands() {
return [
{
"name": ">cmd",
"description": I18n.tr("launcher.providers.command-description"),
"icon": iconMode === "tabler" ? "terminal" : "utilities-terminal",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">cmd ");
}
}
];
}
function getResults(query) {
if (!query.startsWith(">cmd"))
return [];
let expression = query.substring(4).trim();
return [
{
"name": I18n.tr("common.command"),
"description": I18n.tr("launcher.providers.command-description"),
"icon": iconMode === "tabler" ? "terminal" : "utilities-terminal",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.closeImmediately();
Qt.callLater(() => {
Logger.d("CommandProvider", "Executing shell command: " + expression);
Quickshell.execDetached(["sh", "-c", expression]);
});
}
}
];
}
}
@@ -0,0 +1,165 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
// Provider metadata
property string name: I18n.tr("launcher.providers.emoji")
property var launcher: null
property string iconMode: Settings.data.appLauncher.iconMode
property bool handleSearch: false
property string supportedLayouts: "grid" // Only grid layout for emoji
property int preferredGridColumns: 7 // More columns for compact emoji display
property real preferredGridCellRatio: 1.15 // Slightly taller than wide to accommodate label
property bool supportsAutoPaste: true // Emoji can be auto-pasted
property bool ignoreDensity: false // Emoji should scale with launcher density
property string selectedCategory: "recent"
property bool showsCategories: true // Default to showing categories
// Empty state message for category view
readonly property string emptyBrowsingMessage: selectedCategory === "recent" ? I18n.tr("launcher.providers.emoji-no-recent") : ""
property var categoryIcons: ({
"all": "apps",
"recent": "clock",
"people": "user",
"animals": "paw",
"nature": "leaf",
"food": "apple",
"activity": "run",
"travel": "plane",
"objects": "home",
"symbols": "star",
"flags": "flag"
})
property var categories: ["all", "recent", "people", "animals", "nature", "food", "activity", "travel", "objects", "symbols", "flags"]
function getCategoryName(category) {
const names = {
"all": I18n.tr("launcher.categories.all"),
"recent": I18n.tr("launcher.categories.emoji-recent"),
"people": I18n.tr("launcher.categories.emoji-people"),
"animals": I18n.tr("launcher.categories.emoji-animals"),
"nature": I18n.tr("launcher.categories.emoji-nature"),
"food": I18n.tr("launcher.categories.emoji-food"),
"activity": I18n.tr("launcher.categories.emoji-activity"),
"travel": I18n.tr("launcher.categories.emoji-travel"),
"objects": I18n.tr("launcher.categories.emoji-objects"),
"symbols": I18n.tr("launcher.categories.emoji-symbols"),
"flags": I18n.tr("launcher.categories.emoji-flags")
};
return names[category] || category;
}
// Force update results when emoji service loads
Connections {
target: EmojiService
function onLoadedChanged() {
if (EmojiService.loaded && root.launcher) {
root.launcher.updateResults();
}
}
}
// Initialize provider
function init() {
Logger.d("EmojiProvider", "Initialized");
}
function selectCategory(category) {
selectedCategory = category;
if (launcher) {
launcher.updateResults();
}
}
function onOpened() {
// Always reset to "recent" category when opening
selectedCategory = "recent";
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">emoji");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">emoji",
"description": I18n.tr("launcher.providers.emoji-search-description"),
"icon": iconMode === "tabler" ? "mood-smile" : "face-smile",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">emoji ");
}
}
];
}
// Get search results
function getResults(searchText) {
if (!searchText.startsWith(">emoji")) {
return [];
}
if (!EmojiService.loaded) {
return [
{
"name": I18n.tr("launcher.providers.emoji-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
var query = searchText.slice(6).trim();
var emojis = [];
if (query !== "" || selectedCategory === "all") {
emojis = EmojiService.search(query);
} else {
emojis = EmojiService.getEmojisByCategory(selectedCategory);
}
return emojis.map(formatEmojiEntry);
}
// Format an emoji entry for the results list
function formatEmojiEntry(emoji) {
let title = emoji.name;
let description = emoji.keywords.join(", ");
if (emoji.category) {
description += " • Category: " + emoji.category;
}
const emojiChar = emoji.emoji;
return {
"name": title,
"description": description,
"icon": null,
"isImage": false,
"displayString": emojiChar,
"autoPasteText": emojiChar,
"provider": root,
"onAutoPaste": function () {
EmojiService.recordUsage(emojiChar);
},
"onActivate": function () {
EmojiService.copy(emojiChar);
launcher.close();
}
};
}
}
@@ -0,0 +1,194 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
Item {
id: root
// Provider metadata
property string name: I18n.tr("tooltips.session-menu")
property var launcher: null
property bool handleSearch: Settings.data.appLauncher.enableSessionSearch
property string supportedLayouts: "list"
property string iconMode: Settings.data.appLauncher.iconMode
// Session actions with search keywords
readonly property var sessionActions: [
{
"action": "lock",
"labelKey": "common.lock",
"icon": iconMode === "tabler" ? "lock" : "system-lock-screen",
"keywords": ["lock", "screen", "secure"]
},
{
"action": "suspend",
"labelKey": "common.suspend",
"icon": iconMode === "tabler" ? "suspend" : "system-suspend",
"keywords": ["suspend", "sleep", "standby"]
},
{
"action": "hibernate",
"labelKey": "common.hibernate",
"icon": iconMode === "tabler" ? "hibernate" : "system-suspend-hibernate",
"keywords": ["hibernate", "disk"]
},
{
"action": "reboot",
"labelKey": "common.reboot",
"icon": iconMode === "tabler" ? "reboot" : "system-reboot",
"keywords": ["reboot", "restart", "reload"]
},
{
"action": "rebootToUefi",
"labelKey": "common.reboot-to-uefi",
"icon": iconMode === "tabler" ? "reboot" : "system-reboot",
"keywords": ["reboot", "uefi", "firmware", "bios"]
},
{
"action": "userspaceReboot",
"labelKey": "common.userspace-reboot",
"icon": iconMode === "tabler" ? "rotate" : "system-reboot",
"keywords": ["reboot", "restart", "soft", "userspace"]
},
{
"action": "logout",
"labelKey": "common.logout",
"icon": iconMode === "tabler" ? "logout" : "system-log-out",
"keywords": ["logout", "sign out", "exit", "leave"]
},
{
"action": "shutdown",
"labelKey": "common.shutdown",
"icon": iconMode === "tabler" ? "shutdown" : "system-shutdown",
"keywords": ["shutdown", "power off", "turn off", "poweroff"]
}
]
function init() {
Logger.d("SessionProvider", "Initialized");
}
function getEnabledActions() {
var powerOptions = Settings.data.sessionMenu.powerOptions || [];
var enabledSet = {};
for (var i = 0; i < powerOptions.length; i++) {
if (powerOptions[i].enabled) {
enabledSet[powerOptions[i].action] = powerOptions[i];
}
}
var enabled = [];
for (var j = 0; j < sessionActions.length; j++) {
var action = sessionActions[j];
if (enabledSet[action.action]) {
enabled.push({
"action": action.action,
"labelKey": action.labelKey,
"icon": action.icon,
"keywords": action.keywords,
"command": enabledSet[action.action].command || ""
});
}
}
return enabled;
}
function getResults(query) {
if (!query)
return [];
var trimmed = query.trim();
if (!trimmed || trimmed.length < 2)
return [];
var enabledActions = getEnabledActions();
if (enabledActions.length === 0)
return [];
// Build searchable items with resolved translations
var items = [];
for (var i = 0; i < enabledActions.length; i++) {
var action = enabledActions[i];
var label = I18n.tr(action.labelKey);
items.push({
"action": action.action,
"icon": action.icon,
"label": label,
"command": action.command,
"searchText": [label.toLowerCase()].concat(action.keywords).join(" ")
});
}
var results = FuzzySort.go(trimmed, items, {
"keys": ["label", "searchText"],
"limit": 6
});
var launcherItems = [];
for (var j = 0; j < results.length; j++) {
var entry = results[j].obj;
var score = results[j].score;
launcherItems.push({
"name": entry.label,
"description": I18n.tr("tooltips.session-menu"),
"icon": entry.icon,
"isTablerIcon": true,
"isImage": false,
"_score": score - 1,
"provider": root,
"onActivate": createActivateHandler(entry.action, entry.command)
});
}
return launcherItems;
}
function createActivateHandler(action, command) {
return function () {
if (launcher)
launcher.close();
// Execute via Qt.callLater, but reference only singletons
// (root may be destroyed after launcher.close() unloads the panel)
Qt.callLater(() => {
switch (action) {
case "lock":
if (PanelService.lockScreen && !PanelService.lockScreen.active) {
PanelService.lockScreen.active = true;
}
break;
case "suspend":
if (Settings.data.general.lockOnSuspend) {
CompositorService.lockAndSuspend();
} else {
CompositorService.suspend();
}
break;
case "hibernate":
CompositorService.hibernate();
break;
case "reboot":
CompositorService.reboot();
break;
case "rebootToUefi":
CompositorService.rebootToUefi();
break;
case "userspaceReboot":
CompositorService.userspaceReboot();
break;
case "logout":
CompositorService.logout();
break;
case "shutdown":
CompositorService.shutdown();
break;
}
});
};
}
}
@@ -0,0 +1,161 @@
import QtQuick
import qs.Commons
import qs.Services.UI
Item {
id: root
// Provider metadata
property string name: I18n.tr("common.settings")
property var launcher: null
property bool handleSearch: Settings.data.appLauncher.enableSettingsSearch
property string supportedLayouts: "list"
property string iconMode: Settings.data.appLauncher.iconMode
function init() {
Logger.d("SettingsProvider", "Initialized");
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">settings");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">settings",
"description": I18n.tr("launcher.providers.settings-search-description"),
"icon": iconMode === "tabler" ? "settings" : "preferences-system",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">settings ");
}
}
];
}
function getResults(query) {
if (!query || SettingsSearchService.searchIndex.length === 0)
return [];
var trimmed = query.trim();
// Handle command mode: ">settings" or ">settings <search>"
var isCommandMode = trimmed.startsWith(">settings");
if (isCommandMode) {
// Extract search term after ">settings "
var searchTerm = trimmed.substring(9).trim();
// In command mode, show all settings if no search term
if (searchTerm.length === 0) {
return getAllSettings();
}
trimmed = searchTerm;
} else {
// Regular search mode - require at least 2 chars
if (!trimmed || trimmed.length < 2)
return [];
}
// Build searchable items with resolved translations, filtering out invisible entries
let items = [];
for (let j = 0; j < SettingsSearchService.searchIndex.length; j++) {
const entry = SettingsSearchService.searchIndex[j];
if (!SettingsSearchService.isEntryVisible(entry))
continue;
items.push({
"labelKey": entry.labelKey,
"descriptionKey": entry.descriptionKey,
"widget": entry.widget,
"tab": entry.tab,
"tabLabel": entry.tabLabel,
"subTab": entry.subTab,
"subTabLabel": entry.subTabLabel || null,
"label": I18n.tr(entry.labelKey),
"description": entry.descriptionKey ? I18n.tr(entry.descriptionKey) : "",
"subTabName": entry.subTabLabel ? I18n.tr(entry.subTabLabel) : ""
});
}
const results = FuzzySort.go(trimmed, items, {
"keys": ["label", "subTabName", "description"],
"limit": 10,
"scoreFn": function (r) {
const labelScore = r[0].score;
const subTabScore = r[1].score * 1.5;
const descScore = r[2].score;
return Math.max(labelScore, subTabScore, descScore);
}
});
let launcherItems = [];
for (let i = 0; i < results.length; i++) {
const entry = results[i].obj;
const score = results[i].score;
const tabName = I18n.tr(entry.tabLabel);
const subTabName = entry.subTabName || "";
const breadcrumb = subTabName ? (tabName + " " + subTabName) : tabName;
launcherItems.push({
"name": entry.label,
"description": breadcrumb,
"icon": iconMode === "tabler" ? "settings" : "preferences-system",
"isTablerIcon": true,
"isImage": false,
"_score": score - 2,
"provider": root,
"onActivate": createActivateHandler(entry)
});
}
return launcherItems;
}
function getAllSettings() {
var launcherItems = [];
for (var j = 0; j < SettingsSearchService.searchIndex.length; j++) {
var entry = SettingsSearchService.searchIndex[j];
if (!SettingsSearchService.isEntryVisible(entry))
continue;
var label = I18n.tr(entry.labelKey);
var tabName = I18n.tr(entry.tabLabel);
var subTabName = entry.subTabLabel ? I18n.tr(entry.subTabLabel) : "";
var breadcrumb = subTabName ? (tabName + " " + subTabName) : tabName;
launcherItems.push({
"name": label,
"description": breadcrumb,
"icon": iconMode === "tabler" ? "settings" : "preferences-system",
"isTablerIcon": true,
"isImage": false,
"_score": 0,
"provider": root,
"onActivate": createActivateHandler({
"labelKey": entry.labelKey,
"descriptionKey": entry.descriptionKey,
"widget": entry.widget,
"tab": entry.tab,
"tabLabel": entry.tabLabel,
"subTab": entry.subTab,
"subTabLabel": entry.subTabLabel || null
})
});
}
return launcherItems;
}
function createActivateHandler(entry) {
return function () {
if (launcher)
launcher.close();
Qt.callLater(() => {
SettingsPanelService.openToEntry(entry, launcher.screen);
});
};
}
}
@@ -0,0 +1,159 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
Item {
id: root
property string name: I18n.tr("common.windows")
property var launcher: null
property bool handleSearch: Settings.data.appLauncher.enableWindowsSearch
property string supportedLayouts: "list"
property string iconMode: Settings.data.appLauncher.iconMode
function init() {
Logger.d("WindowsProvider", "Initialized");
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">win");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">win",
"description": I18n.tr("launcher.providers.windows-search-description"),
"icon": iconMode === "tabler" ? "app-window" : "preferences-system-windows",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">win ");
}
}
];
}
function getResults(query) {
if (!query)
return [];
var trimmed = query.trim();
// Handle command mode: ">win" or ">win <search>"
var isCommandMode = trimmed.startsWith(">win");
if (isCommandMode) {
// Extract search term after ">win "
var searchTerm = trimmed.substring(4).trim();
// In command mode, show all windows if no search term
if (searchTerm.length === 0) {
return getAllWindows();
}
trimmed = searchTerm;
} else {
// Regular search mode - require at least 2 chars
if (trimmed.length < 2)
return [];
}
var items = [];
// Collect all windows from CompositorService
for (var i = 0; i < CompositorService.windows.count; i++) {
var win = CompositorService.windows.get(i);
items.push({
"id": win.id,
"title": win.title || "",
"appId": win.appId || "",
"workspaceId": win.workspaceId,
"isFocused": win.isFocused,
"searchText": (win.title + " " + win.appId).toLowerCase()
});
}
// Fuzzy search on title and appId
var results = FuzzySort.go(trimmed, items, {
"keys": ["title", "appId"],
"limit": 10
});
// Map to launcher items
var launcherItems = [];
for (var j = 0; j < results.length; j++) {
var entry = results[j].obj;
var score = results[j].score;
// Get icon name from DesktopEntry if available, otherwise use appId
var iconName = entry.appId;
var appEntry = ThemeIcons.findAppEntry(entry.appId);
if (appEntry && appEntry.icon) {
iconName = appEntry.icon;
}
launcherItems.push({
"name": entry.title || entry.appId,
"description": entry.appId,
"icon": iconName || "application-x-executable",
"isTablerIcon": false,
"badgeIcon": "app-window",
"_score": score,
"provider": root,
"windowId": entry.id,
"onActivate": createActivateHandler(entry)
});
}
return launcherItems;
}
function getAllWindows() {
var launcherItems = [];
for (var i = 0; i < CompositorService.windows.count; i++) {
var win = CompositorService.windows.get(i);
var iconName = win.appId;
var appEntry = ThemeIcons.findAppEntry(win.appId);
if (appEntry && appEntry.icon) {
iconName = appEntry.icon;
}
launcherItems.push({
"name": win.title || win.appId,
"description": win.appId,
"icon": iconName || "application-x-executable",
"isTablerIcon": false,
"badgeIcon": "app-window",
"_score": 0,
"provider": root,
"windowId": win.id,
"onActivate": createActivateHandler({
"id": win.id
})
});
}
return launcherItems;
}
function createActivateHandler(windowEntry) {
return function () {
if (launcher)
launcher.close();
Qt.callLater(() => {
// Find the actual window object to pass to focusWindow
for (var i = 0; i < CompositorService.windows.count; i++) {
var win = CompositorService.windows.get(i);
if (win.id === windowEntry.id) {
CompositorService.focusWindow(win);
break;
}
}
});
};
}
}
@@ -0,0 +1,576 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Media
import qs.Services.UI
import qs.Widgets
import qs.Widgets.AudioSpectrum
SmartPanel {
id: root
preferredWidth: Math.round((root.isSideBySide ? 480 : 360) * Style.uiScaleRatio)
// Fallback only; SmartPanel uses panelContent.contentPreferredHeight when set.
preferredHeight: Math.round((root.compactMode ? 240 : 400) * Style.uiScaleRatio)
property var mediaMiniSettings: {
const widget = BarService.lookupWidget("MediaMini", screen?.name);
return widget ? widget.widgetSettings : null;
}
function refreshMediaMiniSettings() {
const widget = BarService.lookupWidget("MediaMini", screen?.name);
root.mediaMiniSettings = widget ? widget.widgetSettings : null;
}
Connections {
target: BarService
function onActiveWidgetsChanged() {
root.refreshMediaMiniSettings();
}
}
Connections {
target: Settings
function onSettingsSaved() {
root.refreshMediaMiniSettings();
}
}
readonly property string visualizerType: (mediaMiniSettings && mediaMiniSettings.visualizerType !== undefined) ? mediaMiniSettings.visualizerType : "linear"
readonly property bool showArtistFirst: !!(mediaMiniSettings && mediaMiniSettings.showArtistFirst !== undefined ? mediaMiniSettings.showArtistFirst : true)
readonly property bool showAlbumArt: !!(mediaMiniSettings && mediaMiniSettings.panelShowAlbumArt !== undefined ? mediaMiniSettings.panelShowAlbumArt : true)
readonly property bool showVisualizer: !!(mediaMiniSettings && mediaMiniSettings.showVisualizer !== undefined ? mediaMiniSettings.showVisualizer : true)
readonly property bool compactMode: !!(mediaMiniSettings && mediaMiniSettings.compactMode !== undefined ? mediaMiniSettings.compactMode : false)
readonly property string scrollingMode: (mediaMiniSettings && mediaMiniSettings.scrollingMode !== undefined) ? mediaMiniSettings.scrollingMode : "hover"
readonly property bool isSideBySide: root.compactMode && root.showAlbumArt
readonly property bool needsSpectrum: root.showVisualizer && root.visualizerType !== "" && root.visualizerType !== "none" && root.isPanelOpen
onNeedsSpectrumChanged: {
if (root.needsSpectrum) {
SpectrumService.registerComponent("mediaplayerpanel");
} else {
SpectrumService.unregisterComponent("mediaplayerpanel");
}
}
Component.onCompleted: {
if (root.needsSpectrum) {
SpectrumService.registerComponent("mediaplayerpanel");
}
}
Component.onDestruction: {
SpectrumService.unregisterComponent("mediaplayerpanel");
}
panelContent: Item {
id: playerContent
anchors.fill: parent
// implicitHeight + mainLayout vertical anchors.margins (each Style.marginL).
readonly property real contentPreferredHeight: {
const m = mainLayout.implicitHeight + 2 * Style.marginL;
const scale = Style.uiScaleRatio;
if (root.compactMode)
return Math.max(m, 240 * scale);
if (!root.showAlbumArt)
return Math.max(m, 300 * scale);
return Math.max(m, 260 * scale);
}
property Component visualizerSource: {
switch (root.visualizerType) {
case "linear":
return linearComponent;
case "mirrored":
return mirroredComponent;
case "wave":
return waveComponent;
default:
return null;
}
}
ColumnLayout {
id: mainLayout
anchors.fill: parent
anchors.margins: Style.marginL
spacing: root.compactMode ? Style.marginL : Style.marginM
z: 1
NBox {
Layout.fillWidth: true
Layout.preferredHeight: headerRow.implicitHeight + Style.margin2M
RowLayout {
id: headerRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
NIcon {
icon: "music"
pointSize: Style.fontSizeL
color: Color.mPrimary
}
NText {
text: I18n.tr("common.media-player")
font.weight: Style.fontWeightBold
pointSize: Style.fontSizeL
color: Color.mOnSurface
Layout.fillWidth: true
}
Rectangle {
radius: Style.radiusS
color: playerSelectorMouse.containsMouse ? Color.mPrimary : "transparent"
implicitWidth: playerRow.implicitWidth + Style.marginM
implicitHeight: Style.baseWidgetSize * 0.8
visible: MediaService.getAvailablePlayers().length > 1
RowLayout {
id: playerRow
anchors.centerIn: parent
spacing: Style.marginXS
NText {
text: MediaService.currentPlayer ? MediaService.currentPlayer.identity : "Select Player"
pointSize: Style.fontSizeXS
color: playerSelectorMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurfaceVariant
}
NIcon {
icon: "chevron-down"
pointSize: Style.fontSizeXS
color: playerSelectorMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurfaceVariant
}
}
MouseArea {
id: playerSelectorMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: playerContextMenu.open()
}
Popup {
id: playerContextMenu
x: 0
y: parent.height
width: 160
padding: Style.marginS
background: Rectangle {
color: Color.mSurfaceVariant
border.color: Color.mOutline
border.width: Style.borderS
radius: Style.iRadiusM
}
contentItem: ColumnLayout {
spacing: 0
Repeater {
model: MediaService.getAvailablePlayers()
delegate: Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 30
color: "transparent"
Rectangle {
anchors.fill: parent
color: itemMouse.containsMouse ? Color.mPrimary : "transparent"
radius: Style.iRadiusS
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginS
NIcon {
visible: MediaService.currentPlayer && MediaService.currentPlayer.identity === modelData.identity
icon: "check"
color: itemMouse.containsMouse ? Color.mOnPrimary : Color.mPrimary
pointSize: Style.fontSizeS
}
NText {
text: modelData.identity
pointSize: Style.fontSizeS
color: itemMouse.containsMouse ? Color.mOnPrimary : Color.mOnSurface
Layout.fillWidth: true
elide: Text.ElideRight
}
}
MouseArea {
id: itemMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
MediaService.currentPlayer = modelData;
playerContextMenu.close();
}
}
}
}
}
}
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
baseSize: Style.baseWidgetSize * 0.8
onClicked: root.close()
}
}
}
NBox {
Layout.fillWidth: true
Layout.preferredHeight: mediaContentGrid.implicitHeight + Style.marginM + Style.marginM
// Visualizer background for content area
Loader {
anchors.fill: parent
z: 0
active: !!(root.needsSpectrum && !root.showAlbumArt)
sourceComponent: visualizerSource
}
GridLayout {
id: mediaContentGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: root.compactMode ? Style.marginL : Style.marginM
anchors.rightMargin: root.compactMode ? Style.marginL : Style.marginM
anchors.topMargin: Style.marginM
anchors.bottomMargin: Style.marginM
columns: root.isSideBySide ? 2 : 1
columnSpacing: Style.marginL
rowSpacing: root.compactMode ? Style.marginL : Style.marginM
// Album Art (Vertical in normal, Horizontal in compact)
Item {
id: albumArtItem
readonly property real compactArtSize: Math.round(110 * Style.uiScaleRatio)
readonly property bool artSizeKnown: artSizeProbe.status === Image.Ready && artSizeProbe.sourceSize.width > 0 && artSizeProbe.sourceSize.height > 0
readonly property real artAspectRatio: artSizeKnown ? artSizeProbe.sourceSize.width / artSizeProbe.sourceSize.height : 1
// Non-compact: height from width÷aspect so grid implicit height does not depend on panel height (no layout loop).
readonly property real artBoxW: root.compactMode ? compactArtSize : Math.max(parent.width, 1)
readonly property real artBoxH: root.compactMode ? compactArtSize : (artBoxW / Math.max(artAspectRatio, 0.001))
readonly property real fitArtW: artBoxW / artBoxH > artAspectRatio ? artBoxH * artAspectRatio : artBoxW
readonly property real fitArtH: artBoxW / artBoxH > artAspectRatio ? artBoxH : artBoxW / artAspectRatio
Layout.preferredWidth: fitArtW
Layout.preferredHeight: fitArtH
Layout.minimumWidth: fitArtW
Layout.maximumWidth: fitArtW
Layout.minimumHeight: fitArtH
Layout.maximumHeight: fitArtH
Layout.fillWidth: false
Layout.fillHeight: false
Layout.alignment: Qt.AlignHCenter
visible: root.showAlbumArt
Image {
id: artSizeProbe
visible: false
asynchronous: true
source: MediaService.trackArtUrl
}
NImageRounded {
anchors.fill: parent
radius: root.compactMode ? Style.radiusM : Style.radiusL
imagePath: MediaService.trackArtUrl
imageFillMode: Image.PreserveAspectCrop
fallbackIcon: "disc"
fallbackIconSize: root.compactMode ? Style.fontSizeXXXL * 3 : Style.fontSizeXXXL * 6
borderWidth: 0
}
Loader {
anchors.fill: parent
anchors.margins: Style.marginS
z: 2
active: !!(root.needsSpectrum && root.showAlbumArt)
sourceComponent: visualizerSource
}
}
ColumnLayout {
id: controlsLayout
Layout.preferredWidth: root.compactMode ? -1 : albumArtItem.width
Layout.fillWidth: root.compactMode
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: root.compactMode
spacing: root.compactMode ? Style.marginXS : Style.marginS
ColumnLayout {
Layout.fillWidth: true
spacing: 0
NScrollText {
Layout.fillWidth: true
maxWidth: parent.width
text: {
if (root.showArtistFirst) {
return MediaService.trackArtist || (MediaService.trackAlbum || "Unknown Artist");
} else {
return MediaService.trackTitle || "No Media";
}
}
scrollMode: {
if (root.scrollingMode === "always")
return NScrollText.ScrollMode.Always;
if (root.scrollingMode === "hover")
return NScrollText.ScrollMode.Hover;
return NScrollText.ScrollMode.Never;
}
fadeExtent: 0.01
fadeCornerRadius: Style.radiusM
delegate: NText {
pointSize: root.compactMode ? Style.fontSizeL : Style.fontSizeXL
font.weight: Style.fontWeightBold
color: Color.mOnSurface
horizontalAlignment: root.isSideBySide ? Text.AlignLeft : Text.AlignHCenter
elide: Text.ElideNone
wrapMode: Text.NoWrap
}
}
NScrollText {
Layout.fillWidth: true
maxWidth: parent.width
text: {
if (root.showArtistFirst) {
return MediaService.trackTitle || "No Media";
} else {
return MediaService.trackArtist || (MediaService.trackAlbum || "Unknown Artist");
}
}
scrollMode: {
if (root.scrollingMode === "always")
return NScrollText.ScrollMode.Always;
if (root.scrollingMode === "hover")
return NScrollText.ScrollMode.Hover;
return NScrollText.ScrollMode.Never;
}
fadeExtent: 0.01
fadeCornerRadius: Style.radiusM
delegate: NText {
pointSize: root.compactMode ? Style.fontSizeS : Style.fontSizeM
color: Color.mOnSurfaceVariant
horizontalAlignment: root.isSideBySide ? Text.AlignLeft : Text.AlignHCenter
elide: Text.ElideNone
wrapMode: Text.NoWrap
}
}
}
Item {
id: progressWrapper
visible: (MediaService.currentPlayer && MediaService.trackLength > 0)
Layout.fillWidth: true
Layout.preferredHeight: progressColumn.implicitHeight
property real localSeekRatio: -1
property real lastSentSeekRatio: -1
property real seekEpsilon: 0.01
property real progressRatio: {
if (!MediaService.currentPlayer || MediaService.trackLength <= 0)
return 0;
const r = MediaService.currentPosition / MediaService.trackLength;
if (isNaN(r) || !isFinite(r))
return 0;
return Math.max(0, Math.min(1, r));
}
Timer {
id: seekDebounce
interval: 75
repeat: false
onTriggered: {
if (MediaService.isSeeking && progressWrapper.localSeekRatio >= 0) {
const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio));
if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) {
MediaService.seekByRatio(next);
progressWrapper.lastSentSeekRatio = next;
}
}
}
}
ColumnLayout {
id: progressColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 2
Item {
Layout.fillWidth: true
Layout.preferredHeight: root.compactMode ? (Style.baseWidgetSize * 0.4) : (Style.baseWidgetSize * 0.5)
NSlider {
id: progressSlider
anchors.fill: parent
from: 0
to: 1
stepSize: 0
snapAlways: false
enabled: MediaService.trackLength > 0 && MediaService.canSeek
heightRatio: 0.4
value: (!MediaService.isSeeking) ? progressWrapper.progressRatio : (progressWrapper.localSeekRatio >= 0 ? progressWrapper.localSeekRatio : 0)
onMoved: {
progressWrapper.localSeekRatio = value;
seekDebounce.restart();
}
onPressedChanged: {
if (pressed) {
MediaService.isSeeking = true;
progressWrapper.localSeekRatio = value;
MediaService.seekByRatio(value);
progressWrapper.lastSentSeekRatio = value;
} else {
seekDebounce.stop();
MediaService.seekByRatio(value);
MediaService.isSeeking = false;
progressWrapper.localSeekRatio = -1;
progressWrapper.lastSentSeekRatio = -1;
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 0
NText {
text: MediaService.positionString || "0:00"
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
visible: progressWrapper.visible
}
Item {
Layout.fillWidth: true
Layout.minimumWidth: 0
}
NText {
text: MediaService.lengthString || "0:00"
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignRight
visible: progressWrapper.visible
}
}
}
}
Item {
Layout.preferredHeight: root.isSideBySide ? Style.marginM : Style.marginS
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: root.isSideBySide ? Style.marginL : Style.marginXL
NIconButton {
icon: "media-prev"
baseSize: root.compactMode ? (Style.baseWidgetSize * 0.9) : (Style.baseWidgetSize * 1.2)
onClicked: MediaService.previous()
}
Rectangle {
implicitWidth: root.compactMode ? (Style.baseWidgetSize * 1.3) : (Style.baseWidgetSize * 1.8)
implicitHeight: root.compactMode ? (Style.baseWidgetSize * 1.3) : (Style.baseWidgetSize * 1.8)
radius: root.compactMode ? Style.iRadiusM : Style.iRadiusL
color: Color.mPrimary
NIcon {
anchors.centerIn: parent
icon: MediaService.isPlaying ? "media-pause" : "media-play"
pointSize: root.compactMode ? Style.fontSizeL : Style.fontSizeXXL
color: Color.mOnPrimary
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.color = Color.mPrimary
onClicked: MediaService.playPause()
}
}
NIconButton {
icon: "media-next"
baseSize: root.compactMode ? (Style.baseWidgetSize * 0.9) : (Style.baseWidgetSize * 1.2)
onClicked: MediaService.next()
}
}
}
}
}
}
}
// Visualizer Components
Component {
id: linearComponent
NLinearSpectrum {
width: parent.width - Style.marginS
height: 20
values: SpectrumService.values
fillColor: Color.mPrimary
opacity: 0.4
barPosition: Settings.getBarPositionForScreen(root.screen?.name)
mirrored: Settings.data.audio.spectrumMirrored
}
}
Component {
id: mirroredComponent
NMirroredSpectrum {
width: parent.width - Style.marginS
height: parent.height - Style.marginS
values: SpectrumService.values
fillColor: Color.mPrimary
opacity: 0.4
mirrored: Settings.data.audio.spectrumMirrored
}
}
Component {
id: waveComponent
NWaveSpectrum {
width: parent.width - Style.marginS
height: parent.height - Style.marginS
values: SpectrumService.values
fillColor: Color.mPrimary
opacity: 0.4
mirrored: Settings.data.audio.spectrumMirrored
}
}
}
@@ -0,0 +1,950 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import "../Settings/Tabs/Connections" as WifiPrefs
import qs.Commons
import qs.Modules.MainScreen
import qs.Modules.Panels.Settings
import qs.Services.Networking
import qs.Services.System
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(500 * Style.uiScaleRatio)
// Info panel collapsed by default, view mode persisted in settings
// Ethernet details UI state (mirrors WiFi info behavior)
property bool ethernetInfoExpanded: false
property bool ethernetDetailsGrid: (Settings.data.network.wifiDetailsViewMode === "grid")
property int ipVersion: 4
// Unified panel view mode: "wifi" | "ethernet" (persisted)
property string panelViewMode: "wifi"
property bool panelViewPersistEnabled: false
onPanelViewModeChanged: {
// Persist last view (only after restored the initial value)
if (panelViewPersistEnabled) {
Settings.data.network.networkPanelView = panelViewMode;
}
if (panelViewMode === "wifi") {
ethernetInfoExpanded = false;
if (NetworkService.wifiEnabled && !NetworkService.scanningActive) {
NetworkService.scan();
NetworkService.refreshActiveWifiDetails();
}
} else {
if (NetworkService.ethernetConnected) {
NetworkService.refreshActiveEthernetDetails();
}
}
}
// Effectively visible tracking
readonly property bool effectivelyVisible: root.visible && Window.window && Window.window.visible
onEffectivelyVisibleChanged: {
if (effectivelyVisible) {
SystemStatService.registerComponent("network-panel");
if (NetworkService.wifiEnabled && !NetworkService.scanningActive) {
NetworkService.scan();
NetworkService.refreshActiveWifiDetails();
}
if (NetworkService.ethernetConnected) {
NetworkService.refreshActiveEthernetDetails();
}
} else {
SystemStatService.unregisterComponent("network-panel");
}
}
onOpened: {
// Restore last view if valid, otherwise choose what's available (prefer WiFi when both exist)
if (Settings.data.network.networkPanelView) {
const last = Settings.data.network.networkPanelView;
if (last === "ethernet" && NetworkService.ethernetAvailable) {
panelViewMode = "ethernet";
} else {
panelViewMode = "wifi";
}
} else {
if (!NetworkService.wifiEnabled && NetworkService.ethernetAvailable) {
panelViewMode = "ethernet";
} else {
panelViewMode = "wifi";
}
}
panelViewPersistEnabled = true;
}
panelContent: Rectangle {
color: "transparent"
property real contentPreferredHeight: Math.min(root.preferredHeight, mainColumn.implicitHeight + Style.margin2L)
ColumnLayout {
id: mainColumn
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// Header
NBox {
Layout.fillWidth: true
Layout.preferredHeight: header.implicitHeight + Style.margin2M
ColumnLayout {
id: header
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
RowLayout {
NIcon {
id: modeIcon
icon: panelViewMode === "wifi" ? (NetworkService.wifiEnabled ? "wifi" : "wifi-off") : (NetworkService.ethernetAvailable ? (NetworkService.ethernetConnected ? "ethernet" : "ethernet") : "ethernet-off")
pointSize: Style.fontSizeXXL
color: {
if (panelViewMode === "wifi") {
return NetworkService.wifiEnabled ? Color.mPrimary : Color.mOnSurfaceVariant;
} else {
return NetworkService.ethernetConnected ? Color.mPrimary : Color.mOnSurfaceVariant;
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (panelViewMode === "wifi") {
if (NetworkService.ethernetAvailable) {
panelViewMode = "ethernet";
} else {
TooltipService.show(parent, I18n.tr("wifi.panel.no-ethernet-devices"));
}
} else {
panelViewMode = "wifi";
}
}
onEntered: TooltipService.show(parent, panelViewMode === "wifi" ? I18n.tr("common.wifi") : I18n.tr("common.ethernet"))
onExited: TooltipService.hide()
}
}
NLabel {
label: panelViewMode === "wifi" ? I18n.tr("common.wifi") : I18n.tr("common.ethernet")
Layout.fillWidth: true
}
NToggle {
id: wifiSwitch
visible: panelViewMode === "wifi"
checked: NetworkService.wifiEnabled
enabled: !NetworkService.airplaneModeEnabled && NetworkService.wifiAvailable
onToggled: checked => NetworkService.setWifiEnabled(checked)
baseSize: Style.baseWidgetSize * 0.7 // Slightly smaller
}
NIconButton {
icon: "settings"
tooltipText: I18n.tr("tooltips.open-settings")
baseSize: Style.baseWidgetSize * 0.8
onClicked: SettingsPanelService.openToTab(SettingsPanel.Tab.Connections, 0, screen)
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
baseSize: Style.baseWidgetSize * 0.8
onClicked: root.close()
}
}
// Mode switch (WiFi / Ethernet)
NTabBar {
id: modeTabBar
visible: NetworkService.ethernetAvailable && NetworkService.wifiAvailable
margins: Style.marginS
Layout.fillWidth: true
spacing: Style.marginM
distributeEvenly: true
currentIndex: root.panelViewMode === "wifi" ? 0 : 1
onCurrentIndexChanged: {
root.panelViewMode = (currentIndex === 0) ? "wifi" : "ethernet";
}
NTabButton {
text: I18n.tr("common.wifi")
tabIndex: 0
checked: modeTabBar.currentIndex === 0
}
NTabButton {
text: I18n.tr("common.ethernet")
tabIndex: 1
checked: modeTabBar.currentIndex === 1
}
}
}
}
// Unified scrollable content (WiFi or Ethernet view)
ColumnLayout {
id: wifiSectionContainer
visible: true
Layout.fillWidth: true
spacing: Style.marginM
// Error message
Rectangle {
visible: panelViewMode === "wifi" && NetworkService.lastError.length > 0
Layout.fillWidth: true
Layout.preferredHeight: errorRow.implicitHeight + Style.margin2M
color: Qt.alpha(Color.mError, 0.1)
radius: Style.radiusS
border.width: Style.borderS
border.color: Color.mError
RowLayout {
id: errorRow
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginS
NIcon {
icon: "warning"
pointSize: Style.fontSizeL
color: Color.mError
}
NText {
text: NetworkService.lastError
color: Color.mError
pointSize: Style.fontSizeS
wrapMode: Text.Wrap
Layout.fillWidth: true
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.6
onClicked: NetworkService.lastError = ""
}
}
}
// Unified scrollable content
NScrollView {
id: contentScroll
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
reserveScrollbarSpace: false
gradientColor: Color.mSurface
ColumnLayout {
id: contentColumn
width: contentScroll.availableWidth
spacing: Style.marginM
// WiFi disabled state
NBox {
id: disabledBox
visible: panelViewMode === "wifi" && !NetworkService.wifiEnabled
Layout.fillWidth: true
Layout.preferredHeight: disabledColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: disabledColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "wifi-off"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("wifi.panel.disabled")
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("wifi.panel.enable-message")
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
Item {
Layout.fillHeight: true
}
}
}
// Scanning state (show when no networks and we haven't had any yet)
NBox {
id: scanningBox
visible: panelViewMode === "wifi" && NetworkService.wifiEnabled && Object.keys(NetworkService.networks).length === 0 && NetworkService.scanningActive
Layout.fillWidth: true
Layout.preferredHeight: scanningColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: scanningColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NBusyIndicator {
running: visible && root.effectivelyVisible
color: Color.mPrimary
size: Style.baseWidgetSize
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("wifi.panel.searching")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillHeight: true
}
}
}
// Empty state when no networks (only show after we've had networks before, meaning a real empty result)
NBox {
id: emptyBox
visible: panelViewMode === "wifi" && NetworkService.wifiEnabled && Object.keys(NetworkService.networks).length === 0 && !NetworkService.scanningActive
Layout.fillWidth: true
Layout.preferredHeight: emptyColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: emptyColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "wifi-question"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("wifi.panel.no-networks")
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillHeight: true
}
}
}
// Networks list container (WiFi)
ColumnLayout {
id: networksList
visible: panelViewMode === "wifi" && NetworkService.wifiEnabled && Object.keys(NetworkService.networks).length > 0
width: parent.width
spacing: Style.marginM
WifiPrefs.WifiSubTab {
showOnlyLists: true
}
}
// Ethernet view
NBox {
id: ethernetSection
visible: panelViewMode === "ethernet"
Layout.fillWidth: true
Layout.preferredHeight: ethernetColumn.implicitHeight + Style.margin2M
ColumnLayout {
id: ethernetColumn
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
// Section label
NLabel {
label: I18n.tr("wifi.panel.available-interfaces")
visible: (NetworkService.ethernetInterfaces && NetworkService.ethernetInterfaces.length > 0)
}
// Empty state when no Ethernet devices
ColumnLayout {
id: emptyEthColumn
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
Layout.preferredHeight: emptyEthColumn.implicitHeight + Style.margin2M
visible: !(NetworkService.ethernetInterfaces && NetworkService.ethernetInterfaces.length > 0)
spacing: Style.marginL
Item {
Layout.fillHeight: true
}
NIcon {
icon: "ethernet-off"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("wifi.panel.no-ethernet-devices")
pointSize: Style.fontSizeL
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillHeight: true
}
}
// Interfaces list
ColumnLayout {
id: ethIfacesList
visible: NetworkService.ethernetInterfaces && NetworkService.ethernetInterfaces.length > 0
width: parent.width
spacing: Style.marginXS
Repeater {
model: NetworkService.ethernetInterfaces || []
delegate: NBox {
id: ethItem
function getContentColors(defaultColors = [Color.mSurface, Color.mOnSurface]) {
if (modelData.connected) {
return [Color.mPrimary, Color.mOnPrimary];
}
return defaultColors;
}
Layout.fillWidth: true
Layout.leftMargin: Style.marginXS
Layout.rightMargin: Style.marginXS
implicitHeight: ethItemColumn.implicitHeight + Style.margin2M
radius: Style.radiusM
forceOpaque: true
color: ethItem.getContentColors()[0]
ColumnLayout {
id: ethItemColumn
width: parent.width - Style.margin2M
x: Style.marginM
y: Style.marginM
spacing: Style.marginS
// Main row matching WiFi card style
// Click handling for the whole header row is provided by a sibling MouseArea
// anchored to this row (defined right after this RowLayout).
RowLayout {
id: ethHeaderRow
Layout.fillWidth: true
spacing: Style.marginS
NIcon {
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
horizontalAlignment: Text.AlignLeft
icon: NetworkService.getIcon(true)
pointSize: Style.fontSizeXXL
color: ethItem.getContentColors()[1]
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
NText {
text: modelData.connectionName || modelData.ifname
pointSize: Style.fontSizeM
font.weight: modelData.connected ? Style.fontWeightBold : Style.fontWeightMedium
color: ethItem.getContentColors()[1]
elide: Text.ElideRight
Layout.fillWidth: true
}
RowLayout {
spacing: Style.marginXS
NText {
text: {
if (modelData.connected) {
switch (NetworkService.networkConnectivity) {
case "full":
return I18n.tr("common.connected");
case "limited":
case "unknown":
return I18n.tr("wifi.panel.internet-limited");
case "portal":
return I18n.tr("wifi.panel.action-required");
default:
return NetworkService.networkConnectivity;
}
}
return I18n.tr("common.disconnected");
}
pointSize: Style.fontSizeXXS
color: Qt.alpha(ethItem.getContentColors()[1], Style.opacityHeavy)
}
// Network speed indicators (visible when connected and speed > 0)
RowLayout {
visible: (modelData.connected && NetworkService.networkConnectivity === "full") && (SystemStatService.rxSpeed > 0 || SystemStatService.txSpeed > 0)
spacing: 2
Layout.leftMargin: Style.marginXS
Layout.fillWidth: false
NIcon {
visible: SystemStatService.rxSpeed > 0
icon: "arrow-down"
pointSize: Style.fontSizeXXS
color: Qt.alpha(ethItem.getContentColors()[1], Style.opacityHeavy)
}
NText {
visible: SystemStatService.rxSpeed > 0
text: SystemStatService.formatSpeed(SystemStatService.rxSpeed)
pointSize: Style.fontSizeXXS
color: Qt.alpha(ethItem.getContentColors()[1], Style.opacityHeavy)
elide: Text.ElideNone
}
Item {
visible: SystemStatService.rxSpeed > 0 && SystemStatService.txSpeed > 0
width: Style.marginXS
height: 1
}
NIcon {
visible: SystemStatService.txSpeed > 0
icon: "arrow-up"
pointSize: Style.fontSizeXXS
color: Qt.alpha(ethItem.getContentColors()[1], Style.opacityHeavy)
}
NText {
visible: SystemStatService.txSpeed > 0
text: SystemStatService.formatSpeed(SystemStatService.txSpeed)
pointSize: Style.fontSizeXXS
color: Qt.alpha(ethItem.getContentColors()[1], Style.opacityHeavy)
elide: Text.ElideNone
}
}
}
}
// Info button on the right
NIconButton {
icon: "info"
tooltipText: I18n.tr("common.info")
baseSize: Style.baseWidgetSize * 0.75
colorBg: Color.mSurfaceVariant
colorFg: Color.mOnSurface
colorBorder: "transparent"
colorBorderHover: "transparent"
enabled: true
visible: NetworkService.ethernetConnected
onClicked: {
if (NetworkService.activeEthernetIf === modelData.ifname && ethernetInfoExpanded) {
ethernetInfoExpanded = false;
return;
}
if (NetworkService.activeEthernetIf !== modelData.ifname) {
NetworkService.activeEthernetIf = modelData.ifname;
NetworkService.activeEthernetDetailsTimestamp = 0;
}
ethernetInfoExpanded = true;
NetworkService.refreshActiveEthernetDetails();
}
}
}
// Click handling without anchors in a Layout-managed item
TapHandler {
target: ethHeaderRow
onTapped: {
if (NetworkService.activeEthernetIf === modelData.ifname && ethernetInfoExpanded) {
ethernetInfoExpanded = false;
return;
}
if (NetworkService.activeEthernetIf !== modelData.ifname) {
NetworkService.activeEthernetIf = modelData.ifname;
NetworkService.activeEthernetDetailsTimestamp = 0;
}
ethernetInfoExpanded = true;
NetworkService.refreshActiveEthernetDetails();
}
}
// Inline Ethernet details
Rectangle {
id: ethInfoInline
visible: ethernetInfoExpanded && NetworkService.activeEthernetIf === modelData.ifname
Layout.fillWidth: true
color: Color.mSurfaceVariant
radius: Style.radiusXS
border.width: Style.borderS
border.color: Style.boxBorderColor
implicitHeight: ethInfoGrid.implicitHeight + Style.margin2S
clip: true
Layout.topMargin: Style.marginXS
// Grid/List toggle
NIconButton {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Style.marginS
icon: ethernetDetailsGrid ? "layout-list" : "layout-grid"
tooltipText: ethernetDetailsGrid ? I18n.tr("tooltips.list-view") : I18n.tr("tooltips.grid-view")
baseSize: Style.baseWidgetSize * 0.65
onClicked: {
ethernetDetailsGrid = !ethernetDetailsGrid;
Settings.data.network.wifiDetailsViewMode = ethernetDetailsGrid ? "grid" : "list";
}
z: 1
}
GridLayout {
id: ethInfoGrid
anchors.fill: parent
anchors.margins: Style.marginS
anchors.rightMargin: Style.baseWidgetSize
flow: ethernetDetailsGrid ? GridLayout.TopToBottom : GridLayout.LeftToRight
rows: ethernetDetailsGrid ? 3 : 6
columns: ethernetDetailsGrid ? 2 : 1
columnSpacing: Style.marginM
rowSpacing: Style.marginXS
onColumnsChanged: {
if (ethInfoGrid.forceLayout) {
Qt.callLater(function () {
ethInfoGrid.forceLayout();
});
}
}
// --- Item 1: Interface ---
RowLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Style.marginXS
NIcon {
icon: "ethernet"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(parent, I18n.tr("wifi.panel.interface"))
onExited: TooltipService.hide()
}
}
NText {
text: (NetworkService.activeEthernetDetails.ifname && NetworkService.activeEthernetDetails.ifname.length > 0) ? NetworkService.activeEthernetDetails.ifname : (NetworkService.activeEthernetIf || "-")
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
wrapMode: ethernetDetailsGrid ? Text.NoWrap : Text.WrapAtWordBoundaryOrAnywhere
elide: ethernetDetailsGrid ? Text.ElideRight : Text.ElideNone
maximumLineCount: ethernetDetailsGrid ? 1 : 6
clip: true
// Click-to-copy Ethernet interface name
MouseArea {
anchors.fill: parent
// Guard against undefined by normalizing to empty strings
enabled: ((NetworkService.activeEthernetDetails.ifname || "").length > 0) || ((NetworkService.activeEthernetIf || "").length > 0)
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(parent, I18n.tr("tooltips.copy-address"))
onExited: TooltipService.hide()
onClicked: {
const value = (NetworkService.activeEthernetDetails.ifname && NetworkService.activeEthernetDetails.ifname.length > 0) ? NetworkService.activeEthernetDetails.ifname : (NetworkService.activeEthernetIf || "");
if (value.length > 0) {
Quickshell.execDetached(["wl-copy", value]);
ToastService.showNotice(I18n.tr("common.ethernet"), I18n.tr("common.copied-to-clipboard"), "ethernet");
}
}
}
}
}
// --- Item 2: Hardware Address ---
RowLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Style.marginXS
NIcon {
icon: "hash"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(parent, I18n.tr("bluetooth.panel.device-address"))
onExited: TooltipService.hide()
}
}
NText {
text: NetworkService.activeEthernetDetails.hwAddr || "-"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
wrapMode: ethernetDetailsGrid ? Text.NoWrap : Text.WrapAtWordBoundaryOrAnywhere
elide: ethernetDetailsGrid ? Text.ElideRight : Text.ElideNone
maximumLineCount: ethernetDetailsGrid ? 1 : 6
clip: true
MouseArea {
anchors.fill: parent
enabled: (NetworkService.activeEthernetDetails.hwAddr || "").length > 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(parent, I18n.tr("tooltips.copy-address"))
onExited: TooltipService.hide()
onClicked: {
const value = NetworkService.activeEthernetDetails.hwAddr || "";
if (value.length > 0) {
Quickshell.execDetached(["wl-copy", value]);
ToastService.showNotice(I18n.tr("common.ethernet"), I18n.tr("common.copied-to-clipboard"), "ethernet");
}
}
}
}
}
// --- Item 3: Link speed ---
RowLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Style.marginXS
NIcon {
icon: "gauge"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(parent, I18n.tr("wifi.panel.link-speed"))
onExited: TooltipService.hide()
}
}
NText {
text: (NetworkService.activeEthernetDetails.speed && NetworkService.activeEthernetDetails.speed.length > 0) ? NetworkService.activeEthernetDetails.speed : "-"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
wrapMode: ethernetDetailsGrid ? Text.NoWrap : Text.WrapAtWordBoundaryOrAnywhere
elide: ethernetDetailsGrid ? Text.ElideRight : Text.ElideNone
maximumLineCount: ethernetDetailsGrid ? 1 : 6
clip: true
}
}
// --- Item 4: IPv4 || IPv6 ---
RowLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Style.marginXS
NIcon {
icon: "network"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(parent, root.ipVersion === 4 ? I18n.tr("wifi.panel.ipv4") : I18n.tr("wifi.panel.ipv6"))
onExited: TooltipService.hide()
onClicked: {
root.ipVersion = root.ipVersion === 4 ? 6 : 4;
TooltipService.show(parent, root.ipVersion === 4 ? I18n.tr("wifi.panel.ipv4") : I18n.tr("wifi.panel.ipv6"));
}
}
}
NText {
text: root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.ipv4 || "-") : ((NetworkService.activeEthernetDetails.ipv6 || []).join(", ") || "-")
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
wrapMode: ethernetDetailsGrid ? Text.NoWrap : Text.WrapAtWordBoundaryOrAnywhere
elide: ethernetDetailsGrid ? Text.ElideRight : Text.ElideNone
maximumLineCount: ethernetDetailsGrid ? 1 : 6
clip: true
// Click-to-copy Ethernet IP address
MouseArea {
anchors.fill: parent
enabled: root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.ipv4 || "").length > 0 : (NetworkService.activeEthernetDetails.ipv6 || []).length > 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(parent, I18n.tr("tooltips.copy-address"))
onExited: TooltipService.hide()
onClicked: {
const value = root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.ipv4 || "") : ((NetworkService.activeEthernetDetails.ipv6 || []).join(", ") || "");
if (value.length > 0) {
Quickshell.execDetached(["wl-copy", value]);
ToastService.showNotice(I18n.tr("common.ethernet"), I18n.tr("common.copied-to-clipboard"), "ethernet");
}
}
}
}
}
// --- Item 5: DNS ---
RowLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Style.marginXS
NIcon {
icon: "world"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(parent, root.ipVersion === 4 ? I18n.tr("wifi.panel.dns") + " (" + I18n.tr("wifi.panel.ipv4") + ")" : I18n.tr("wifi.panel.dns") + " (" + I18n.tr("wifi.panel.ipv6") + ")")
onExited: TooltipService.hide()
onClicked: {
root.ipVersion = root.ipVersion === 4 ? 6 : 4;
TooltipService.show(parent, root.ipVersion === 4 ? I18n.tr("wifi.panel.dns") + " (" + I18n.tr("wifi.panel.ipv4") + ")" : I18n.tr("wifi.panel.dns") + " (" + I18n.tr("wifi.panel.ipv6") + ")");
}
}
}
NText {
text: root.ipVersion === 4 ? ((NetworkService.activeEthernetDetails.dns4 || []).join(", ") || "-") : ((NetworkService.activeEthernetDetails.dns6 || []).join(", ") || "-")
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
wrapMode: ethernetDetailsGrid ? Text.NoWrap : Text.WrapAtWordBoundaryOrAnywhere
elide: ethernetDetailsGrid ? Text.ElideRight : Text.ElideNone
maximumLineCount: ethernetDetailsGrid ? 1 : 6
clip: true
// Click-to-copy Ethernet DNS
MouseArea {
anchors.fill: parent
enabled: root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.dns4 || []).length > 0 : (NetworkService.activeEthernetDetails.dns6 || []).length > 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(parent, I18n.tr("tooltips.copy-address"))
onExited: TooltipService.hide()
onClicked: {
const value = root.ipVersion === 4 ? ((NetworkService.activeEthernetDetails.dns4 || []).join(", ") || "") : ((NetworkService.activeEthernetDetails.dns6 || []).join(", ") || "");
if (value.length > 0) {
Quickshell.execDetached(["wl-copy", value]);
ToastService.showNotice(I18n.tr("common.ethernet"), I18n.tr("common.copied-to-clipboard"), "ethernet");
}
}
}
}
}
// --- Item 6: Gateway ---
RowLayout {
Layout.fillWidth: true
Layout.preferredWidth: 1
spacing: Style.marginXS
NIcon {
icon: "router"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(parent, root.ipVersion === 4 ? I18n.tr("common.gateway") + " (" + I18n.tr("wifi.panel.ipv4") + ")" : I18n.tr("common.gateway") + " (" + I18n.tr("wifi.panel.ipv6") + ")")
onExited: TooltipService.hide()
onClicked: {
root.ipVersion = root.ipVersion === 4 ? 6 : 4;
TooltipService.show(parent, root.ipVersion === 4 ? I18n.tr("common.gateway") + " (" + I18n.tr("wifi.panel.ipv4") + ")" : I18n.tr("common.gateway") + " (" + I18n.tr("wifi.panel.ipv6") + ")");
}
}
}
NText {
text: root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.gateway4 || "-") : ((NetworkService.activeEthernetDetails.gateway6 || []).join(", ") || "-")
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
wrapMode: ethernetDetailsGrid ? Text.NoWrap : Text.WrapAtWordBoundaryOrAnywhere
elide: ethernetDetailsGrid ? Text.ElideRight : Text.ElideNone
maximumLineCount: ethernetDetailsGrid ? 1 : 6
clip: true
// Click-to-copy Ethernet Gateway
MouseArea {
anchors.fill: parent
enabled: root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.gateway4 || "").length > 0 : (NetworkService.activeEthernetDetails.gateway6 || []).length > 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(parent, I18n.tr("tooltips.copy-address"))
onExited: TooltipService.hide()
onClicked: {
const value = root.ipVersion === 4 ? (NetworkService.activeEthernetDetails.gateway4 || "") : ((NetworkService.activeEthernetDetails.gateway6 || []).join(", ") || "");
if (value.length > 0) {
Quickshell.execDetached(["wl-copy", value]);
ToastService.showNotice(I18n.tr("common.ethernet"), I18n.tr("common.copied-to-clipboard"), "ethernet");
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,228 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Noctalia
import qs.Services.UI
/**
* Generic plugin panel slot that can be reused for different plugins
*/
SmartPanel {
id: root
// Which plugin slot this is (1 or 2)
property int slotNumber: 1
// Currently loaded plugin ID (empty if no plugin using this slot)
property string currentPluginId: ""
// Plugin instance
property var pluginInstance: null
// Reference to the plugin content loader (set when panel content is created)
property var contentLoader: null
// Pass through anchor properties from plugin panel content
panelAnchorHorizontalCenter: pluginInstance?.panelAnchorHorizontalCenter ?? false
panelAnchorVerticalCenter: pluginInstance?.panelAnchorVerticalCenter ?? false
panelAnchorTop: pluginInstance?.panelAnchorTop ?? false
panelAnchorBottom: pluginInstance?.panelAnchorBottom ?? false
panelAnchorLeft: pluginInstance?.panelAnchorLeft ?? false
panelAnchorRight: pluginInstance?.panelAnchorRight ?? false
// Panel background color
panelBackgroundColor: pluginInstance?.panelBackgroundColor ?? Color.mSurface
// Panel content is dynamically loaded
panelContent: Component {
Item {
id: panelContainer
// Required by SmartPanel for background rendering geometry
readonly property var geometryPlaceholder: pluginContentItem
// Panel properties expected by SmartPanel
// A plugin can opt out of attachment (allowAttach: false) but cannot override
// the global "attach panels to bar" setting — if that setting is off, panels
// stay detached regardless of what the plugin requests.
readonly property bool allowAttach: {
var globalAllow = Settings.data.ui.panelsAttachedToBar || root.forceAttachToBar;
if (!globalAllow)
return false;
if (pluginContentLoader.item && pluginContentLoader.item.allowAttach !== undefined)
return pluginContentLoader.item.allowAttach;
return globalAllow;
}
// Expose preferred dimensions from plugin panel content
// Only define these if the plugin provides them
property var contentPreferredWidth: {
if (pluginContentLoader.item && pluginContentLoader.item.contentPreferredWidth !== undefined && pluginContentLoader.item.contentPreferredWidth > 0) {
return pluginContentLoader.item.contentPreferredWidth;
}
return undefined;
}
property var contentPreferredHeight: {
if (pluginContentLoader.item && pluginContentLoader.item.contentPreferredHeight !== undefined && pluginContentLoader.item.contentPreferredHeight > 0) {
return pluginContentLoader.item.contentPreferredHeight;
}
return undefined;
}
anchors.fill: parent
// Dynamic plugin content
Item {
id: pluginContentItem
anchors.fill: parent
// Plugin content loader, pluginApi is injected synchronously in loadPluginPanel()
Loader {
id: pluginContentLoader
anchors.fill: parent
active: false
}
}
Component.onCompleted: {
// Store reference to the loader so loadPluginPanel can access it
root.contentLoader = pluginContentLoader;
// Load plugin panel content if assigned
if (root.currentPluginId !== "") {
root.loadPluginPanel(root.currentPluginId);
}
}
}
}
// Load a plugin's panel content
function loadPluginPanel(pluginId) {
if (!PluginService.isPluginLoaded(pluginId)) {
Logger.w("PluginPanelSlot", "Plugin not loaded:", pluginId);
return false;
}
var plugin = PluginService.loadedPlugins[pluginId];
if (!plugin || !plugin.manifest) {
Logger.w("PluginPanelSlot", "Plugin data not found:", pluginId);
return false;
}
if (!plugin.manifest.entryPoints || !plugin.manifest.entryPoints.panel) {
Logger.w("PluginPanelSlot", "Plugin does not provide a panel:", pluginId);
return false;
}
// Check if loader is available
if (!root.contentLoader) {
Logger.e("PluginPanelSlot", "Content loader not available yet");
return false;
}
// Clear any stale pluginInstance before loading new content
root.pluginInstance = null;
var pluginDir = PluginRegistry.getPluginDir(pluginId);
var panelPath = pluginDir + "/" + plugin.manifest.entryPoints.panel;
Logger.i("PluginPanelSlot", "Loading panel for plugin:", pluginId, "in slot", root.slotNumber);
// Load the panel component with cache-busting version parameter
var loadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
var component = Qt.createComponent("file://" + panelPath + "?v=" + loadVersion);
if (component.status === Component.Ready) {
finalizePluginLoad(pluginId, component);
return true;
} else if (component.status === Component.Loading) {
// Handle async component loading - wait for it to be ready
Logger.d("PluginPanelSlot", "Component loading asynchronously for:", pluginId);
component.statusChanged.connect(function () {
if (component.status === Component.Ready) {
finalizePluginLoad(pluginId, component);
// Force SmartPanel to recalculate position now that content is ready
if (root.isPanelVisible) {
root.setPosition();
}
} else if (component.status === Component.Error) {
PluginService.recordPluginError(pluginId, "panel", component.errorString());
}
});
return true; // Will be loaded asynchronously
} else if (component.status === Component.Error) {
PluginService.recordPluginError(pluginId, "panel", component.errorString());
return false;
}
return false;
}
// Helper function to finalize plugin content loading
function finalizePluginLoad(pluginId, component) {
// Get plugin API
var api = PluginService.getPluginAPI(pluginId);
// Use setSource with initial properties so pluginApi is available
// from the first binding evaluation (before onLoaded)
root.contentLoader.active = true;
root.contentLoader.setSource(component.url, api ? {
"pluginApi": api
} : {});
if (root.contentLoader.item) {
root.pluginInstance = root.contentLoader.item;
root.currentPluginId = pluginId;
Logger.i("PluginPanelSlot", "Panel loaded for:", pluginId);
} else {
Logger.e("PluginPanelSlot", "Failed to get loader item for:", pluginId);
}
}
// Unload current plugin panel
function unloadPluginPanel() {
if (root.currentPluginId === "") {
return;
}
Logger.i("PluginPanelSlot", "Unloading panel from slot", root.slotNumber);
if (root.contentLoader) {
root.contentLoader.active = false;
root.contentLoader.sourceComponent = null;
}
root.pluginInstance = null;
root.currentPluginId = "";
}
// Register with PanelService
Component.onCompleted: {
PanelService.registerPanel(root);
}
// Update plugin's panelOpenScreen when this panel opens/closes
onOpened: {
if (root.currentPluginId !== "") {
var api = PluginService.getPluginAPI(root.currentPluginId);
if (api) {
api.panelOpenScreen = root.screen;
Logger.d("PluginPanelSlot", "Set panelOpenScreen for", root.currentPluginId, "to", root.screen?.name);
}
}
}
onClosed: {
if (root.currentPluginId !== "") {
var api = PluginService.getPluginAPI(root.currentPluginId);
if (api) {
api.panelOpenScreen = null;
Logger.d("PluginPanelSlot", "Cleared panelOpenScreen for", root.currentPluginId);
}
}
// Clear stale references when panel closes (content is destroyed by SmartPanel)
root.pluginInstance = null;
root.contentLoader = null;
}
}
@@ -0,0 +1,193 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import qs.Commons
import qs.Services.UI
import qs.Widgets
// Widget Settings Dialog Component
Popup {
id: root
property int widgetIndex: -1
property var widgetData: null
property string widgetId: ""
property string sectionId: ""
property var screen: null
property var settingsCache: ({})
readonly property real maxHeight: (screen ? screen.height : (parent ? parent.height : 800)) * 0.8
signal updateWidgetSettings(string section, int index, var settings)
width: Math.max(content.implicitWidth + padding * 2, 640)
height: Math.min(content.implicitHeight + padding * 2, maxHeight)
padding: Style.marginXL
modal: true
closePolicy: Popup.NoAutoClose
dim: false
anchors.centerIn: parent
onOpened: {
// Load settings when popup opens with data
if (widgetData && widgetId) {
loadWidgetSettings();
}
// Request focus to ensure keyboard input works
forceActiveFocus();
}
background: Rectangle {
id: bgRect
color: Color.mSurface
radius: Style.radiusL
border.color: Color.mPrimary
border.width: Style.borderM
}
contentItem: FocusScope {
id: focusScope
focus: true
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.marginM
// Title
RowLayout {
id: titleRow
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
NText {
text: I18n.tr("system.widget-settings-title", {
"widget": root.widgetId
})
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
onClicked: saveAndClose()
}
}
// Separator
Rectangle {
id: separator
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Color.mOutline
}
// Scrollable settings area
NScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 100
gradientColor: Color.mSurface
ColumnLayout {
width: scrollView.availableWidth
spacing: Style.marginM
// Settings based on widget type
// Will be triggered via settingsLoader.setSource()
Loader {
id: settingsLoader
Layout.fillWidth: true
onLoaded: {
// Try to focus the first focusable item in the loaded settings
if (item) {
Qt.callLater(() => {
var firstInput = findFirstFocusable(item);
if (firstInput) {
firstInput.forceActiveFocus();
} else {
focusScope.forceActiveFocus();
}
});
}
}
function findFirstFocusable(item) {
if (!item)
return null;
// Check if this item can accept focus
if (item.focus !== undefined && item.focus === true)
return item;
// Check children
if (item.children) {
for (var i = 0; i < item.children.length; i++) {
var child = item.children[i];
if (child && child.focus !== undefined && child.focus === true)
return child;
var found = findFirstFocusable(child);
if (found)
return found;
}
}
return null;
}
}
}
}
}
}
Timer {
id: saveTimer
running: false
interval: 150
onTriggered: {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, root.settingsCache);
}
}
Connections {
target: settingsLoader.item
ignoreUnknownSignals: true
function onSettingsChanged(newSettings) {
if (newSettings) {
root.settingsCache = newSettings;
saveTimer.start();
}
}
}
function saveAndClose() {
if (settingsLoader.item && typeof settingsLoader.item.saveSettings === 'function') {
var newSettings = settingsLoader.item.saveSettings();
if (newSettings) {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, newSettings);
}
}
root.close();
}
function loadWidgetSettings() {
const source = BarWidgetRegistry.widgetSettingsMap[widgetId];
if (source) {
var currentWidgetData = widgetData;
if (sectionId && widgetIndex >= 0) {
var widgets = Settings.getBarWidgetsForScreen(screen?.name || "")[sectionId];
if (widgets && widgetIndex < widgets.length) {
currentWidgetData = widgets[widgetIndex];
}
}
settingsLoader.setSource(source, {
"screen": screen,
"widgetData": currentWidgetData,
"widgetMetadata": BarWidgetRegistry.widgetMetadata[widgetId]
});
}
}
}
@@ -0,0 +1,230 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import "../../../../Helpers/QtObj2JS.js" as QtObj2JS
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
import qs.Widgets
// Monitor Widgets Configuration (inline)
NBox {
id: root
required property var screen
readonly property string screenName: screen?.name || ""
// determine bar orientation
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
color: Color.mSurfaceVariant
Layout.fillWidth: true
implicitHeight: content.implicitHeight + Style.margin2L
// Helper to get widgets for this screen (ensures override exists)
function _getWidgetsContainer() {
if (!Settings.hasScreenOverride(screenName, "widgets")) {
var currentWidgets = Settings.getBarWidgetsForScreen(screenName);
var widgetsCopy = QtObj2JS.qtObjectToPlainObject(currentWidgets);
Settings.setScreenOverride(screenName, "widgets", widgetsCopy);
}
var entry = Settings.getScreenOverrideEntry(screenName);
return entry ? entry.widgets : Settings.data.bar.widgets;
}
// Persist widget changes by reassigning the override (triggers change detection)
function _saveWidgets(widgets) {
Settings.setScreenOverride(screenName, "widgets", widgets);
}
// Widget manipulation functions
function _addWidgetToSection(widgetId, section) {
var newWidget = {
"id": widgetId
};
if (BarWidgetRegistry.widgetHasUserSettings(widgetId)) {
var metadata = BarWidgetRegistry.widgetMetadata[widgetId];
if (metadata) {
Object.keys(metadata).forEach(function (key) {
newWidget[key] = metadata[key];
});
}
}
var widgets = _getWidgetsContainer();
widgets[section].push(newWidget);
_saveWidgets(widgets);
BarService.widgetsRevision++;
}
function _removeWidgetFromSection(section, index) {
var widgets = _getWidgetsContainer();
if (index >= 0 && index < widgets[section].length) {
var newArray = widgets[section].slice();
var removedWidgets = newArray.splice(index, 1);
widgets[section] = newArray;
_saveWidgets(widgets);
BarService.widgetsRevision++;
if (removedWidgets[0].id === "ControlCenter" && BarService.lookupWidget("ControlCenter") === undefined) {
ToastService.showWarning(I18n.tr("toast.missing-control-center.label"), I18n.tr("toast.missing-control-center.description"), 12000);
}
}
}
function _reorderWidgetInSection(section, fromIndex, toIndex) {
var widgets = _getWidgetsContainer();
if (fromIndex >= 0 && fromIndex < widgets[section].length && toIndex >= 0 && toIndex < widgets[section].length) {
var newArray = widgets[section].slice();
var item = newArray[fromIndex];
newArray.splice(fromIndex, 1);
newArray.splice(toIndex, 0, item);
widgets[section] = newArray;
_saveWidgets(widgets);
BarService.widgetsRevision++;
}
}
// Note: _updateWidgetSettingsInSection does NOT increment revision
// because it only changes settings, not widget structure
function _updateWidgetSettingsInSection(section, index, settings) {
var widgets = _getWidgetsContainer();
widgets[section][index] = settings;
_saveWidgets(widgets);
}
function _moveWidgetBetweenSections(fromSection, index, toSection) {
var widgets = _getWidgetsContainer();
if (index >= 0 && index < widgets[fromSection].length) {
var widget = widgets[fromSection][index];
var sourceArray = widgets[fromSection].slice();
sourceArray.splice(index, 1);
widgets[fromSection] = sourceArray;
var targetArray = widgets[toSection].slice();
targetArray.push(widget);
widgets[toSection] = targetArray;
_saveWidgets(widgets);
BarService.widgetsRevision++;
}
}
// Available widgets ListModel
function updateAvailableWidgetsModel() {
availableWidgetsModel.clear();
var widgetIds = BarWidgetRegistry.getAvailableWidgets();
if (!widgetIds)
return;
for (var i = 0; i < widgetIds.length; i++) {
var id = widgetIds[i];
var displayName = id;
const badges = [];
if (BarWidgetRegistry.isPluginWidget(id)) {
var pluginId = id.replace("plugin:", "");
var manifest = PluginRegistry.getPluginManifest(pluginId);
if (manifest && manifest.name) {
displayName = manifest.name;
} else {
displayName = pluginId;
}
badges.push({
"icon": "plugin",
"color": Color.mSecondary
});
}
if (BarWidgetRegistry.isCpuIntensive(id)) {
badges.push({
"icon": "cpu-intensive",
"color": Color.mSecondary
});
}
availableWidgetsModel.append({
"key": id,
"name": displayName,
"badges": badges
});
}
}
Component.onCompleted: Qt.callLater(updateAvailableWidgetsModel)
ListModel {
id: availableWidgetsModel
}
// Get effective widgets for this screen
readonly property var effectiveWidgets: Settings.getBarWidgetsForScreen(screenName)
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
NText {
text: I18n.tr("panels.bar.widgets-desc")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
// Left Section
NSectionEditor {
sectionName: root.barIsVertical ? I18n.tr("positions.top") : I18n.tr("positions.left")
sectionId: "left"
barIsVertical: root.barIsVertical
screen: root.screen
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml")
widgetRegistry: BarWidgetRegistry
widgetModel: root.effectiveWidgets.left
availableWidgets: availableWidgetsModel
onAddWidget: (widgetId, section) => root._addWidgetToSection(widgetId, section)
onRemoveWidget: (section, index) => root._removeWidgetFromSection(section, index)
onReorderWidget: (section, fromIndex, toIndex) => root._reorderWidgetInSection(section, fromIndex, toIndex)
onUpdateWidgetSettings: (section, index, settings) => root._updateWidgetSettingsInSection(section, index, settings)
onMoveWidget: (fromSection, index, toSection) => root._moveWidgetBetweenSections(fromSection, index, toSection)
onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest)
}
// Center Section
NSectionEditor {
sectionName: I18n.tr("positions.center")
sectionId: "center"
barIsVertical: root.barIsVertical
screen: root.screen
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml")
widgetRegistry: BarWidgetRegistry
widgetModel: root.effectiveWidgets.center
availableWidgets: availableWidgetsModel
onAddWidget: (widgetId, section) => root._addWidgetToSection(widgetId, section)
onRemoveWidget: (section, index) => root._removeWidgetFromSection(section, index)
onReorderWidget: (section, fromIndex, toIndex) => root._reorderWidgetInSection(section, fromIndex, toIndex)
onUpdateWidgetSettings: (section, index, settings) => root._updateWidgetSettingsInSection(section, index, settings)
onMoveWidget: (fromSection, index, toSection) => root._moveWidgetBetweenSections(fromSection, index, toSection)
onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest)
}
// Right Section
NSectionEditor {
sectionName: root.barIsVertical ? I18n.tr("positions.bottom") : I18n.tr("positions.right")
sectionId: "right"
barIsVertical: root.barIsVertical
screen: root.screen
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml")
widgetRegistry: BarWidgetRegistry
widgetModel: root.effectiveWidgets.right
availableWidgets: availableWidgetsModel
onAddWidget: (widgetId, section) => root._addWidgetToSection(widgetId, section)
onRemoveWidget: (section, index) => root._removeWidgetFromSection(section, index)
onReorderWidget: (section, fromIndex, toIndex) => root._reorderWidgetInSection(section, fromIndex, toIndex)
onUpdateWidgetSettings: (section, index, settings) => root._updateWidgetSettingsInSection(section, index, settings)
onMoveWidget: (fromSection, index, toSection) => root._moveWidgetBetweenSections(fromSection, index, toSection)
onOpenPluginSettingsRequested: manifest => pluginSettingsDialog.openPluginSettings(manifest)
}
}
// Plugin settings dialog
NPluginSettingsPopup {
id: pluginSettingsDialog
parent: Overlay.overlay
showToastOnSave: false
}
}
@@ -0,0 +1,168 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property bool valueShowIcon: widgetData.showIcon !== undefined ? widgetData.showIcon : widgetMetadata.showIcon
property bool valueShowText: widgetData.showText !== undefined ? widgetData.showText : widgetMetadata.showText
property string valueHideMode: widgetData.hideMode !== undefined ? widgetData.hideMode : widgetMetadata.hideMode
property string valueScrollingMode: widgetData.scrollingMode || widgetMetadata.scrollingMode
property int valueMaxWidth: widgetData.maxWidth !== undefined ? widgetData.maxWidth : widgetMetadata.maxWidth
property bool valueUseFixedWidth: widgetData.useFixedWidth !== undefined ? widgetData.useFixedWidth : widgetMetadata.useFixedWidth
property bool valueColorizeIcons: widgetData.colorizeIcons !== undefined ? widgetData.colorizeIcons : widgetMetadata.colorizeIcons
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
Component.onCompleted: {
if (widgetData && widgetData.hideMode !== undefined) {
valueHideMode = widgetData.hideMode;
}
}
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.hideMode = valueHideMode;
settings.showIcon = valueShowIcon;
settings.showText = valueShowText;
settings.scrollingMode = valueScrollingMode;
settings.maxWidth = parseInt(widthInput.text) || widgetMetadata.maxWidth;
settings.useFixedWidth = valueUseFixedWidth;
settings.colorizeIcons = valueColorizeIcons;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.hide-mode-label")
description: I18n.tr("bar.active-window.hide-mode-description")
model: [
{
"key": "visible",
"name": I18n.tr("hide-modes.visible")
},
{
"key": "hidden",
"name": I18n.tr("hide-modes.hidden")
},
{
"key": "transparent",
"name": I18n.tr("hide-modes.transparent")
}
]
currentKey: root.valueHideMode
onSelected: key => {
root.valueHideMode = key;
saveSettings();
}
defaultValue: widgetMetadata.hideMode
}
NColorChoice {
label: I18n.tr("common.select-color")
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.active-window.show-app-text-label")
description: I18n.tr("bar.active-window.show-app-text-description")
checked: root.valueShowText
onToggled: checked => {
root.valueShowText = checked;
saveSettings();
}
defaultValue: widgetMetadata.showText
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.active-window.show-app-icon-label")
description: I18n.tr("bar.active-window.show-app-icon-description")
checked: root.valueShowIcon
onToggled: checked => {
root.valueShowIcon = checked;
saveSettings();
}
defaultValue: widgetMetadata.showIcon
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.tray.colorize-icons-label")
description: I18n.tr("bar.active-window.colorize-icons-description")
checked: root.valueColorizeIcons
onToggled: checked => {
root.valueColorizeIcons = checked;
saveSettings();
}
visible: root.valueShowIcon
defaultValue: widgetMetadata.colorizeIcons
}
NTextInput {
id: widthInput
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.max-width-label")
description: I18n.tr("bar.media-mini.max-width-description")
placeholderText: widgetMetadata.maxWidth
text: valueMaxWidth
onTextChanged: saveSettings()
defaultValue: String(widgetMetadata.maxWidth)
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.media-mini.use-fixed-width-label")
description: I18n.tr("bar.media-mini.use-fixed-width-description")
checked: valueUseFixedWidth
onToggled: checked => {
valueUseFixedWidth = checked;
saveSettings();
}
defaultValue: widgetMetadata.useFixedWidth
}
NComboBox {
label: I18n.tr("bar.media-mini.scrolling-mode-label")
description: I18n.tr("bar.active-window.scrolling-mode-description")
model: [
{
"key": "always",
"name": I18n.tr("options.scrolling-modes.always")
},
{
"key": "hover",
"name": I18n.tr("options.scrolling-modes.hover")
},
{
"key": "never",
"name": I18n.tr("options.scrolling-modes.never")
}
]
currentKey: valueScrollingMode
defaultValue: widgetMetadata.scrollingMode
onSelected: key => {
valueScrollingMode = key;
saveSettings();
}
minimumWidth: 200
}
}
@@ -0,0 +1,63 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property bool valueHideWhenIdle: widgetData.hideWhenIdle !== undefined ? widgetData.hideWhenIdle : widgetMetadata.hideWhenIdle
property string valueColorName: widgetData.colorName !== undefined ? widgetData.colorName : widgetMetadata.colorName
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.width = parseInt(widthInput.text) || widgetMetadata.width;
settings.hideWhenIdle = valueHideWhenIdle;
settings.colorName = valueColorName;
settingsChanged(settings);
}
NTextInput {
id: widthInput
Layout.fillWidth: true
label: I18n.tr("common.width")
description: I18n.tr("bar.audio-visualizer.width-description")
text: widgetData.width || widgetMetadata.width
placeholderText: I18n.tr("placeholders.enter-width-pixels")
onTextChanged: saveSettings()
defaultValue: String(widgetMetadata.width)
}
NColorChoice {
Layout.fillWidth: true
label: I18n.tr("bar.audio-visualizer.color-name-label")
description: I18n.tr("bar.audio-visualizer.color-name-description")
currentKey: root.valueColorName
onSelected: key => {
root.valueColorName = key;
saveSettings();
}
defaultValue: widgetMetadata.colorName
}
NToggle {
label: I18n.tr("bar.audio-visualizer.hide-when-idle-label")
description: I18n.tr("bar.audio-visualizer.hide-when-idle-description")
checked: valueHideWhenIdle
onToggled: checked => {
valueHideWhenIdle = checked;
saveSettings();
}
defaultValue: widgetMetadata.hideWhenIdle
}
}
@@ -0,0 +1,138 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.Hardware
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueDeviceNativePath: widgetData.deviceNativePath !== undefined ? widgetData.deviceNativePath : widgetMetadata.deviceNativePath
property bool valueShowPowerProfiles: widgetData.showPowerProfiles !== undefined ? widgetData.showPowerProfiles : widgetMetadata.showPowerProfiles
property bool valueShowNoctaliaPerformance: widgetData.showNoctaliaPerformance !== undefined ? widgetData.showNoctaliaPerformance : widgetMetadata.showNoctaliaPerformance
property bool valueHideIfNotDetected: widgetData.hideIfNotDetected !== undefined ? widgetData.hideIfNotDetected : widgetMetadata.hideIfNotDetected
property bool valueHideIfIdle: widgetData.hideIfIdle !== undefined ? widgetData.hideIfIdle : widgetMetadata.hideIfIdle
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
if (widgetData && widgetData.id) {
settings.id = widgetData.id;
}
settings.displayMode = valueDisplayMode;
settings.showPowerProfiles = valueShowPowerProfiles;
settings.showNoctaliaPerformance = valueShowNoctaliaPerformance;
settings.hideIfNotDetected = valueHideIfNotDetected;
settings.hideIfIdle = valueHideIfIdle;
settings.deviceNativePath = valueDeviceNativePath;
settingsChanged(settings);
}
NComboBox {
id: deviceComboBox
Layout.fillWidth: true
label: I18n.tr("bar.battery.device-label")
description: I18n.tr("bar.battery.device-description")
minimumWidth: 240
model: BatteryService.deviceModel
currentKey: root.valueDeviceNativePath
onSelected: key => {
root.valueDeviceNativePath = key;
saveSettings();
}
defaultValue: widgetMetadata.deviceNativePath
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.battery.display-mode-description")
minimumWidth: 240
model: [
{
"key": "graphic",
"name": I18n.tr("bar.battery.display-mode-graphic")
},
{
"key": "graphic-clean",
"name": I18n.tr("bar.battery.display-mode-graphic-clean")
},
{
"key": "icon-hover",
"name": I18n.tr("bar.battery.display-mode-icon-hover")
},
{
"key": "icon-always",
"name": I18n.tr("bar.battery.display-mode-icon-always")
},
{
"key": "icon-only",
"name": I18n.tr("bar.battery.display-mode-icon-only")
}
]
currentKey: root.valueDisplayMode
onSelected: key => {
root.valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NToggle {
label: I18n.tr("bar.battery.hide-if-not-detected-label")
description: I18n.tr("bar.battery.hide-if-not-detected-description")
checked: valueHideIfNotDetected
onToggled: checked => {
valueHideIfNotDetected = checked;
saveSettings();
}
defaultValue: widgetMetadata.hideIfNotDetected
}
NToggle {
label: I18n.tr("bar.battery.hide-if-idle-label")
description: I18n.tr("bar.battery.hide-if-idle-description")
checked: valueHideIfIdle
onToggled: checked => {
valueHideIfIdle = checked;
saveSettings();
}
defaultValue: widgetMetadata.hideIfIdle
}
NDivider {
Layout.fillWidth: true
}
NToggle {
label: I18n.tr("bar.battery.show-power-profile-label")
description: I18n.tr("bar.battery.show-power-profile-description")
checked: valueShowPowerProfiles
onToggled: checked => {
valueShowPowerProfiles = checked;
saveSettings();
}
defaultValue: widgetMetadata.showPowerProfiles
}
NToggle {
label: I18n.tr("bar.battery.show-noctalia-performance-label")
description: I18n.tr("bar.battery.show-noctalia-performance-description")
checked: valueShowNoctaliaPerformance
onToggled: checked => {
valueShowNoctaliaPerformance = checked;
saveSettings();
}
defaultValue: widgetMetadata.showNoctaliaPerformance
}
}
@@ -0,0 +1,74 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "alwaysShow",
"name": I18n.tr("display-modes.always-show")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: root.valueDisplayMode
onSelected: key => {
root.valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
}
@@ -0,0 +1,93 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
property bool valueApplyToAllMonitors: widgetData.applyToAllMonitors !== undefined ? widgetData.applyToAllMonitors : widgetMetadata.applyToAllMonitors
readonly property bool hasMultipleMonitors: (Quickshell.screens || []).length > 1
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settings.applyToAllMonitors = valueApplyToAllMonitors;
settingsChanged(settings);
}
NComboBox {
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "alwaysShow",
"name": I18n.tr("display-modes.always-show")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: valueDisplayMode
onSelected: key => {
valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
NToggle {
visible: hasMultipleMonitors
Layout.fillWidth: true
label: I18n.tr("bar.brightness.apply-all-label")
description: I18n.tr("bar.brightness.apply-all-description")
checked: valueApplyToAllMonitors
onToggled: checked => {
valueApplyToAllMonitors = checked;
saveSettings();
}
defaultValue: widgetMetadata.applyToAllMonitors
}
}
@@ -0,0 +1,315 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.System
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
width: 700
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueClockColor: widgetData.clockColor !== undefined ? widgetData.clockColor : widgetMetadata.clockColor
property bool valueUseCustomFont: widgetData.useCustomFont !== undefined ? widgetData.useCustomFont : widgetMetadata.useCustomFont
property string valueCustomFont: widgetData.customFont !== undefined ? widgetData.customFont : widgetMetadata.customFont
property string valueFormatHorizontal: widgetData.formatHorizontal !== undefined ? widgetData.formatHorizontal : widgetMetadata.formatHorizontal
property string valueFormatVertical: widgetData.formatVertical !== undefined ? widgetData.formatVertical : widgetMetadata.formatVertical
property string valueTooltipFormat: widgetData.tooltipFormat !== undefined ? widgetData.tooltipFormat : widgetMetadata.tooltipFormat
readonly property color textColor: Color.resolveColorKey(valueClockColor)
// Track the currently focused input field
property var focusedInput: null
property int focusedLineIndex: -1
readonly property var now: Time.now
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.clockColor = valueClockColor;
settings.useCustomFont = valueUseCustomFont;
settings.customFont = valueCustomFont;
settings.formatHorizontal = valueFormatHorizontal.trim();
settings.formatVertical = valueFormatVertical.trim();
settings.tooltipFormat = valueTooltipFormat.trim();
settingsChanged(settings);
}
// Function to insert token at cursor position in the focused input
function insertToken(token) {
if (!focusedInput || !focusedInput.inputItem) {
// If no input is focused, default to horiz
if (inputHoriz.inputItem) {
inputHoriz.inputItem.focus = true;
focusedInput = inputHoriz;
}
}
if (focusedInput && focusedInput.inputItem) {
var input = focusedInput.inputItem;
var cursorPos = input.cursorPosition;
var currentText = input.text;
// Insert token at cursor position
var newText = currentText.substring(0, cursorPos) + token + currentText.substring(cursorPos);
input.text = newText + " ";
// Move cursor after the inserted token
input.cursorPosition = cursorPos + token.length + 1;
// Ensure the input keeps focus
input.focus = true;
}
}
NColorChoice {
currentKey: valueClockColor
onSelected: key => {
valueClockColor = key;
saveSettings();
}
defaultValue: widgetMetadata.clockColor
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.clock.use-custom-font-label")
description: I18n.tr("bar.clock.use-custom-font-description")
checked: valueUseCustomFont
onToggled: checked => {
valueUseCustomFont = checked;
saveSettings();
}
defaultValue: widgetMetadata.useCustomFont
}
NSearchableComboBox {
Layout.fillWidth: true
visible: valueUseCustomFont
label: I18n.tr("bar.clock.custom-font-label")
description: I18n.tr("bar.clock.custom-font-description")
model: FontService.availableFonts
currentKey: valueCustomFont
placeholder: I18n.tr("bar.clock.custom-font-placeholder")
searchPlaceholder: I18n.tr("bar.clock.custom-font-search-placeholder")
popupHeight: 420
minimumWidth: 300
onSelected: function (key) {
valueCustomFont = key;
saveSettings();
}
defaultValue: Settings.data.ui.fontDefault
}
NDivider {
Layout.fillWidth: true
}
NHeader {
label: I18n.tr("bar.clock.clock-display-label")
description: I18n.tr("bar.clock.clock-display-description")
}
RowLayout {
id: main
spacing: Style.marginL
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
ColumnLayout {
spacing: Style.marginM
Layout.fillWidth: true
Layout.preferredWidth: 1 // Equal sizing hint
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
NTextInput {
id: inputHoriz
Layout.fillWidth: true
label: I18n.tr("bar.clock.horizontal-bar-label")
description: I18n.tr("bar.clock.horizontal-bar-description")
placeholderText: "HH:mm ddd, MMM dd"
text: valueFormatHorizontal
onTextChanged: {
valueFormatHorizontal = text;
saveSettings();
}
Component.onCompleted: {
if (inputItem) {
inputItem.onActiveFocusChanged.connect(function () {
if (inputItem.activeFocus) {
root.focusedInput = inputHoriz;
}
});
}
}
defaultValue: widgetMetadata.formatHorizontal
}
Item {
Layout.fillHeight: true
}
NTextInput {
id: inputVert
Layout.fillWidth: true
label: I18n.tr("bar.clock.vertical-bar-label")
description: I18n.tr("bar.clock.vertical-bar-description")
// Tokens are Qt format tokens and must not be localized
placeholderText: "HH mm dd MM"
text: valueFormatVertical
onTextChanged: {
valueFormatVertical = text;
saveSettings();
}
Component.onCompleted: {
if (inputItem) {
inputItem.onActiveFocusChanged.connect(function () {
if (inputItem.activeFocus) {
root.focusedInput = inputVert;
}
});
}
}
defaultValue: widgetMetadata.formatVertical
}
NTextInput {
id: inputTooltip
Layout.fillWidth: true
label: I18n.tr("bar.clock.tooltip-format-label")
description: I18n.tr("bar.clock.tooltip-format-description")
placeholderText: "HH:mm, ddd MMM dd"
text: valueTooltipFormat
onTextChanged: {
valueTooltipFormat = text;
saveSettings();
}
Component.onCompleted: {
if (inputItem) {
inputItem.onActiveFocusChanged.connect(function () {
if (inputItem.activeFocus) {
root.focusedInput = inputTooltip;
}
});
}
}
defaultValue: widgetMetadata.tooltipFormat
}
}
// --------------
// Preview
ColumnLayout {
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
Layout.fillWidth: false
NLabel {
label: I18n.tr("bar.clock.preview")
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
}
Rectangle {
Layout.preferredWidth: 320
Layout.preferredHeight: 160 // Fixed height instead of fillHeight
color: Color.mSurfaceVariant
radius: Style.radiusM
border.color: Color.mSecondary
border.width: Style.borderS
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
ColumnLayout {
spacing: Style.marginM
anchors.centerIn: parent
ColumnLayout {
spacing: -2
Layout.alignment: Qt.AlignHCenter
// Horizontal
Repeater {
Layout.topMargin: Style.marginM
model: I18n.locale.toString(now, valueFormatHorizontal.trim()).split("\\n")
delegate: NText {
visible: text !== ""
text: modelData
family: valueUseCustomFont && valueCustomFont ? valueCustomFont : Settings.data.ui.fontDefault
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: textColor
wrapMode: Text.WordWrap
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
NDivider {
Layout.fillWidth: true
}
// Vertical
ColumnLayout {
spacing: -2
Layout.alignment: Qt.AlignHCenter
Repeater {
Layout.topMargin: Style.marginM
model: I18n.locale.toString(now, valueFormatVertical.trim()).split(" ")
delegate: NText {
visible: text !== ""
text: modelData
family: valueUseCustomFont && valueCustomFont ? valueCustomFont : Settings.data.ui.fontDefault
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: textColor
wrapMode: Text.WordWrap
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}
}
}
NDivider {
Layout.topMargin: Style.marginM
Layout.bottomMargin: Style.marginM
}
NDateTimeTokens {
Layout.fillWidth: true
Layout.preferredHeight: 300
// Connect to token clicked signal if NDateTimeTokens provides it
onTokenClicked: token => root.insertToken(token)
}
}
@@ -0,0 +1,134 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueIcon: widgetData.icon !== undefined ? widgetData.icon : widgetMetadata.icon
property bool valueUseDistroLogo: widgetData.useDistroLogo !== undefined ? widgetData.useDistroLogo : widgetMetadata.useDistroLogo
property string valueCustomIconPath: widgetData.customIconPath !== undefined ? widgetData.customIconPath : widgetMetadata.customIconPath
property bool valueEnableColorization: widgetData.enableColorization !== undefined ? widgetData.enableColorization : widgetMetadata.enableColorization
property string valueColorizeSystemIcon: widgetData.colorizeSystemIcon !== undefined ? widgetData.colorizeSystemIcon : widgetMetadata.colorizeSystemIcon
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.icon = valueIcon;
settings.useDistroLogo = valueUseDistroLogo;
settings.customIconPath = valueCustomIconPath;
settings.enableColorization = valueEnableColorization;
settings.colorizeSystemIcon = valueColorizeSystemIcon;
settingsChanged(settings);
}
NToggle {
label: I18n.tr("bar.control-center.use-distro-logo-label")
description: I18n.tr("bar.control-center.use-distro-logo-description")
checked: valueUseDistroLogo
onToggled: checked => {
valueUseDistroLogo = checked;
saveSettings();
}
defaultValue: widgetMetadata.useDistroLogo
}
NToggle {
label: I18n.tr("bar.custom-button.enable-colorization-label")
description: I18n.tr("bar.control-center.enable-colorization-description")
checked: valueEnableColorization
onToggled: checked => {
valueEnableColorization = checked;
saveSettings();
}
defaultValue: widgetMetadata.enableColorization
}
NColorChoice {
visible: valueEnableColorization
label: I18n.tr("common.select-icon-color")
description: I18n.tr("bar.control-center.color-selection-description")
currentKey: valueColorizeSystemIcon
onSelected: function (key) {
valueColorizeSystemIcon = key;
saveSettings();
}
defaultValue: widgetMetadata.colorizeSystemIcon
}
RowLayout {
spacing: Style.marginM
NLabel {
label: I18n.tr("common.icon")
description: I18n.tr("bar.control-center.icon-description")
}
NImageRounded {
Layout.preferredWidth: Style.fontSizeXL * 2
Layout.preferredHeight: Style.fontSizeXL * 2
Layout.alignment: Qt.AlignVCenter
radius: Math.min(Style.radiusL, Layout.preferredWidth / 2)
imagePath: valueCustomIconPath
visible: valueCustomIconPath !== "" && !valueUseDistroLogo
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: valueIcon
pointSize: Style.fontSizeXXL * 1.5
visible: valueIcon !== "" && valueCustomIconPath === "" && !valueUseDistroLogo
}
}
RowLayout {
spacing: Style.marginM
NButton {
enabled: !valueUseDistroLogo
text: I18n.tr("bar.control-center.browse-library")
onClicked: iconPicker.open()
}
NButton {
enabled: !valueUseDistroLogo
text: I18n.tr("bar.control-center.browse-file")
onClicked: imagePicker.openFilePicker()
}
}
NIconPicker {
id: iconPicker
initialIcon: valueIcon
onIconSelected: iconName => {
valueIcon = iconName;
valueCustomIconPath = "";
saveSettings();
}
}
NFilePicker {
id: imagePicker
title: I18n.tr("bar.control-center.select-custom-icon")
selectionMode: "files"
nameFilters: ImageCacheService.basicImageFilters.concat(["*.svg"])
initialPath: Quickshell.env("HOME")
onAccepted: paths => {
if (paths.length > 0) {
valueCustomIconPath = paths[0]; // Use first selected file
saveSettings();
}
}
}
}
@@ -0,0 +1,512 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import qs.Commons
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIcon: widgetData.icon !== undefined ? widgetData.icon : widgetMetadata.icon
property bool valueTextStream: widgetData.textStream !== undefined ? widgetData.textStream : widgetMetadata.textStream
property bool valueParseJson: widgetData.parseJson !== undefined ? widgetData.parseJson : widgetMetadata.parseJson
property int valueMaxTextLengthHorizontal: widgetData?.maxTextLength?.horizontal ?? widgetMetadata?.maxTextLength?.horizontal
property int valueMaxTextLengthVertical: widgetData?.maxTextLength?.vertical ?? widgetMetadata?.maxTextLength?.vertical
property string valueHideMode: (widgetData.hideMode !== undefined) ? widgetData.hideMode : widgetMetadata.hideMode
property bool valueShowIcon: (widgetData.showIcon !== undefined) ? widgetData.showIcon : widgetMetadata.showIcon
property bool valueShowExecTooltip: widgetData.showExecTooltip !== undefined ? widgetData.showExecTooltip : widgetMetadata.showExecTooltip
property bool valueShowTextTooltip: widgetData.showTextTooltip !== undefined ? widgetData.showTextTooltip : widgetMetadata.showTextTooltip
property bool valueEnableColorization: widgetData.enableColorization !== undefined ? widgetData.enableColorization : widgetMetadata.enableColorization
property string valueColorizeSystemIcon: widgetData.colorizeSystemIcon !== undefined ? widgetData.colorizeSystemIcon : widgetMetadata.colorizeSystemIcon
property string valueIpcIdentifier: widgetData.ipcIdentifier !== undefined ? widgetData.ipcIdentifier : widgetMetadata.ipcIdentifier
property string valueGeneralTooltipText: widgetData.generalTooltipText !== undefined ? widgetData.generalTooltipText : widgetMetadata.generalTooltipText
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.icon = valueIcon;
settings.leftClickExec = leftClickExecInput.text;
settings.leftClickUpdateText = leftClickUpdateText.checked;
settings.rightClickExec = rightClickExecInput.text;
settings.rightClickUpdateText = rightClickUpdateText.checked;
settings.middleClickExec = middleClickExecInput.text;
settings.middleClickUpdateText = middleClickUpdateText.checked;
settings.wheelMode = separateWheelToggle.internalChecked ? "separate" : "unified";
settings.wheelExec = wheelExecInput.text;
settings.wheelUpExec = wheelUpExecInput.text;
settings.wheelDownExec = wheelDownExecInput.text;
settings.wheelUpdateText = wheelUpdateText.checked;
settings.wheelUpUpdateText = wheelUpUpdateText.checked;
settings.wheelDownUpdateText = wheelDownUpdateText.checked;
settings.textCommand = textCommandInput.text;
settings.textCollapse = textCollapseInput.text;
settings.textStream = valueTextStream;
settings.parseJson = valueParseJson;
settings.showIcon = valueShowIcon;
settings.showExecTooltip = valueShowExecTooltip;
settings.showTextTooltip = valueShowTextTooltip;
settings.hideMode = valueHideMode;
settings.maxTextLength = {
"horizontal": valueMaxTextLengthHorizontal,
"vertical": valueMaxTextLengthVertical
};
settings.textIntervalMs = parseInt(textIntervalInput.text || textIntervalInput.placeholderText, 10);
settings.enableColorization = valueEnableColorization;
settings.colorizeSystemIcon = valueColorizeSystemIcon;
settings.ipcIdentifier = valueIpcIdentifier;
settings.generalTooltipText = valueGeneralTooltipText;
settingsChanged(settings);
}
RowLayout {
spacing: Style.marginM
NLabel {
label: I18n.tr("common.icon")
description: I18n.tr("bar.custom-button.icon-description")
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: valueIcon
pointSize: Style.fontSizeXL
visible: valueIcon !== ""
}
NButton {
text: I18n.tr("common.browse")
onClicked: iconPicker.open()
}
}
NIconPicker {
id: iconPicker
initialIcon: valueIcon
onIconSelected: function (iconName) {
valueIcon = iconName;
saveSettings();
}
}
NToggle {
id: showIconToggle
label: I18n.tr("bar.custom-button.show-icon-label")
description: I18n.tr("bar.custom-button.show-icon-description")
checked: valueShowIcon
onToggled: checked => {
valueShowIcon = checked;
saveSettings();
}
visible: textCommandInput.text !== ""
defaultValue: widgetMetadata.showIcon
}
NToggle {
label: I18n.tr("bar.custom-button.enable-colorization-label")
description: I18n.tr("bar.custom-button.enable-colorization-description")
checked: valueEnableColorization
onToggled: checked => {
valueEnableColorization = checked;
saveSettings();
}
defaultValue: widgetMetadata.enableColorization
}
NColorChoice {
visible: valueEnableColorization
label: I18n.tr("common.select-icon-color")
description: I18n.tr("bar.custom-button.color-selection-description")
currentKey: valueColorizeSystemIcon
onSelected: key => {
valueColorizeSystemIcon = key;
saveSettings();
}
defaultValue: widgetMetadata.colorizeSystemIcon
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.general-tooltip-text-label")
description: I18n.tr("bar.custom-button.general-tooltip-text-description")
placeholderText: I18n.tr("placeholders.enter-tooltip")
text: valueGeneralTooltipText
onTextChanged: {
valueGeneralTooltipText = text;
saveSettings();
}
defaultValue: widgetMetadata.generalTooltipText
}
NToggle {
id: showExecTooltipToggle
label: I18n.tr("bar.custom-button.show-exec-tooltip-label")
description: I18n.tr("bar.custom-button.show-exec-tooltip-description")
checked: valueShowExecTooltip
onToggled: checked => {
valueShowExecTooltip = checked;
saveSettings();
}
defaultValue: widgetMetadata.showExecTooltip
}
NToggle {
id: showTextTooltipToggle
label: I18n.tr("bar.custom-button.show-text-tooltip-label")
description: I18n.tr("bar.custom-button.show-text-tooltip-description")
checked: valueShowTextTooltip
onToggled: checked => {
valueShowTextTooltip = checked;
saveSettings();
}
defaultValue: widgetMetadata.showTextTooltip
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.ipc-identifier-label")
description: I18n.tr("bar.custom-button.ipc-identifier-description")
placeholderText: I18n.tr("placeholders.enter-ipc-identifier")
text: valueIpcIdentifier
onTextChanged: {
valueIpcIdentifier = text;
saveSettings();
}
defaultValue: widgetMetadata.ipcIdentifier
}
RowLayout {
spacing: Style.marginM
NTextInput {
id: leftClickExecInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.left-click-label")
description: I18n.tr("bar.custom-button.left-click-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.leftClickExec || widgetMetadata.leftClickExec
onTextChanged: saveSettings()
defaultValue: widgetMetadata.leftClickExec
}
NToggle {
id: leftClickUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(leftClickUpdateText, I18n.tr("bar.custom-button.left-click-update-text"))
onExited: TooltipService.hide()
checked: widgetData?.leftClickUpdateText ?? widgetMetadata.leftClickUpdateText
onToggled: isChecked => {
checked = isChecked;
saveSettings();
}
defaultValue: widgetMetadata.leftClickUpdateText
}
}
RowLayout {
spacing: Style.marginM
NTextInput {
id: rightClickExecInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.right-click-label")
description: I18n.tr("bar.custom-button.right-click-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.rightClickExec || widgetMetadata.rightClickExec
onTextChanged: saveSettings()
defaultValue: widgetMetadata.rightClickExec
}
NToggle {
id: rightClickUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(rightClickUpdateText, I18n.tr("bar.custom-button.right-click-update-text"))
onExited: TooltipService.hide()
checked: widgetData?.rightClickUpdateText ?? widgetMetadata.rightClickUpdateText
onToggled: isChecked => {
checked = isChecked;
saveSettings();
}
defaultValue: widgetMetadata.rightClickUpdateText
}
}
RowLayout {
spacing: Style.marginM
NTextInput {
id: middleClickExecInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.middle-click-label")
description: I18n.tr("bar.custom-button.middle-click-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData.middleClickExec || widgetMetadata.middleClickExec
onTextChanged: saveSettings()
defaultValue: widgetMetadata.middleClickExec
}
NToggle {
id: middleClickUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(middleClickUpdateText, I18n.tr("bar.custom-button.middle-click-update-text"))
onExited: TooltipService.hide()
checked: widgetData?.middleClickUpdateText ?? widgetMetadata.middleClickUpdateText
onToggled: isChecked => {
checked = isChecked;
saveSettings();
}
defaultValue: widgetMetadata.middleClickUpdateText
}
}
// Wheel command settings
NToggle {
id: separateWheelToggle
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.wheel-mode-separate-label")
description: I18n.tr("bar.custom-button.wheel-mode-separate-description")
property bool internalChecked: (widgetData?.wheelMode || widgetMetadata?.wheelMode) === "separate"
checked: internalChecked
onToggled: checked => {
internalChecked = checked;
saveSettings();
}
defaultValue: widgetMetadata.wheelMode === "separate"
}
ColumnLayout {
Layout.fillWidth: true
RowLayout {
id: unifiedWheelLayout
visible: !separateWheelToggle.checked
spacing: Style.marginM
NTextInput {
id: wheelExecInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.wheel-label")
description: I18n.tr("bar.custom-button.wheel-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.wheelExec || widgetMetadata?.wheelExec
onTextChanged: saveSettings()
defaultValue: widgetMetadata.wheelExec
}
NToggle {
id: wheelUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(wheelUpdateText, I18n.tr("bar.custom-button.wheel-update-text"))
onExited: TooltipService.hide()
checked: widgetData?.wheelUpdateText ?? widgetMetadata?.wheelUpdateText
onToggled: isChecked => {
checked = isChecked;
saveSettings();
}
defaultValue: widgetMetadata.wheelUpdateText
}
}
ColumnLayout {
id: separatedWheelLayout
Layout.fillWidth: true
visible: separateWheelToggle.checked
RowLayout {
spacing: Style.marginM
NTextInput {
id: wheelUpExecInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.wheel-up-label")
description: I18n.tr("bar.custom-button.wheel-up-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.wheelUpExec || widgetMetadata?.wheelUpExec
onTextChanged: saveSettings()
defaultValue: widgetMetadata.wheelUpExec
}
NToggle {
id: wheelUpUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(wheelUpUpdateText, I18n.tr("bar.custom-button.wheel-update-text"))
onExited: TooltipService.hide()
checked: (widgetData?.wheelUpUpdateText !== undefined) ? widgetData.wheelUpUpdateText : widgetMetadata?.wheelUpUpdateText
onToggled: isChecked => {
checked = isChecked;
saveSettings();
}
defaultValue: widgetMetadata.wheelUpUpdateText
}
}
RowLayout {
spacing: Style.marginM
NTextInput {
id: wheelDownExecInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.wheel-down-label")
description: I18n.tr("bar.custom-button.wheel-down-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: widgetData?.wheelDownExec || widgetMetadata?.wheelDownExec
onTextChanged: saveSettings()
defaultValue: widgetMetadata.wheelDownExec
}
NToggle {
id: wheelDownUpdateText
enabled: !valueTextStream
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
Layout.bottomMargin: Style.marginS
onEntered: TooltipService.show(wheelDownUpdateText, I18n.tr("bar.custom-button.wheel-update-text"))
onExited: TooltipService.hide()
checked: (widgetData?.wheelDownUpdateText !== undefined) ? widgetData.wheelDownUpdateText : widgetMetadata?.wheelDownUpdateText
onToggled: isChecked => {
checked = isChecked;
saveSettings();
}
defaultValue: widgetMetadata.wheelDownUpdateText
}
}
}
}
NDivider {
Layout.fillWidth: true
}
NHeader {
label: I18n.tr("bar.custom-button.dynamic-text")
}
NSpinBox {
label: I18n.tr("bar.custom-button.max-text-length-horizontal-label")
description: I18n.tr("bar.custom-button.max-text-length-horizontal-description")
from: 0
to: 100
value: valueMaxTextLengthHorizontal
onValueChanged: {
valueMaxTextLengthHorizontal = value;
saveSettings();
}
defaultValue: widgetMetadata.maxTextLength.horizontal
}
NSpinBox {
label: I18n.tr("bar.custom-button.max-text-length-vertical-label")
description: I18n.tr("bar.custom-button.max-text-length-vertical-description")
from: 0
to: 100
value: valueMaxTextLengthVertical
onValueChanged: {
valueMaxTextLengthVertical = value;
saveSettings();
}
defaultValue: widgetMetadata.maxTextLength.vertical
}
NToggle {
id: textStreamInput
label: I18n.tr("bar.custom-button.text-stream-label")
description: I18n.tr("bar.custom-button.text-stream-description")
checked: valueTextStream
onToggled: checked => {
valueTextStream = checked;
saveSettings();
}
defaultValue: widgetMetadata.textStream
}
NToggle {
id: parseJsonInput
label: I18n.tr("bar.custom-button.parse-json-label")
description: I18n.tr("bar.custom-button.parse-json-description")
checked: valueParseJson
onToggled: checked => {
valueParseJson = checked;
saveSettings();
}
defaultValue: widgetMetadata.parseJson
}
NTextInput {
id: textCommandInput
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.display-command-output-label")
description: valueTextStream ? I18n.tr("bar.custom-button.display-command-output-stream-description") : I18n.tr("bar.custom-button.display-command-output-description")
placeholderText: I18n.tr("placeholders.command-example")
text: widgetData?.textCommand || widgetMetadata.textCommand
onTextChanged: saveSettings()
defaultValue: widgetMetadata.textCommand
}
NTextInput {
id: textCollapseInput
Layout.fillWidth: true
visible: valueTextStream
label: I18n.tr("bar.custom-button.collapse-condition-label")
description: I18n.tr("bar.custom-button.collapse-condition-description")
placeholderText: I18n.tr("placeholders.enter-text-to-collapse")
text: widgetData?.textCollapse || widgetMetadata.textCollapse
onTextChanged: saveSettings()
defaultValue: widgetMetadata.textCollapse
}
NTextInput {
id: textIntervalInput
Layout.fillWidth: true
visible: !valueTextStream
label: I18n.tr("bar.custom-button.refresh-interval-label")
description: I18n.tr("bar.custom-button.refresh-interval-description")
placeholderText: String(widgetMetadata.textIntervalMs)
text: widgetData && widgetData.textIntervalMs !== undefined ? String(widgetData.textIntervalMs) : ""
onTextChanged: saveSettings()
defaultValue: String(widgetMetadata.textIntervalMs)
}
NComboBox {
id: hideModeComboBox
label: I18n.tr("bar.custom-button.hide-mode-label")
description: I18n.tr("bar.custom-button.hide-mode-description")
model: [
{
name: I18n.tr("bar.custom-button.hide-mode-always-expanded"),
key: "alwaysExpanded"
},
{
name: I18n.tr("bar.custom-button.hide-mode-expand-with-output"),
key: "expandWithOutput"
},
{
name: I18n.tr("bar.custom-button.hide-mode-max-transparent"),
key: "maxTransparent"
}
]
currentKey: valueHideMode
onSelected: key => {
valueHideMode = key;
saveSettings();
}
visible: textCommandInput.text !== "" && valueTextStream == true
defaultValue: widgetMetadata.hideMode
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
}
@@ -0,0 +1,45 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
}
@@ -0,0 +1,89 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property bool valueShowIcon: widgetData.showIcon !== undefined ? widgetData.showIcon : widgetMetadata.showIcon
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.showIcon = valueShowIcon;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
visible: valueShowIcon // Hide display mode setting when icon is disabled
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "forceOpen",
"name": I18n.tr("display-modes.force-open")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: valueDisplayMode
onSelected: key => {
valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NToggle {
label: I18n.tr("bar.custom-button.show-icon-label")
description: I18n.tr("bar.keyboard-layout.show-icon-description")
checked: valueShowIcon
onToggled: checked => {
valueShowIcon = checked;
saveSettings();
}
defaultValue: widgetMetadata.showIcon
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
}
@@ -0,0 +1,134 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueIcon: widgetData.icon !== undefined ? widgetData.icon : widgetMetadata.icon
property bool valueUseDistroLogo: widgetData.useDistroLogo !== undefined ? widgetData.useDistroLogo : widgetMetadata.useDistroLogo
property string valueCustomIconPath: widgetData.customIconPath !== undefined ? widgetData.customIconPath : widgetMetadata.customIconPath
property bool valueEnableColorization: widgetData.enableColorization !== undefined ? widgetData.enableColorization : widgetMetadata.enableColorization
property string valueColorizeSystemIcon: widgetData.colorizeSystemIcon !== undefined ? widgetData.colorizeSystemIcon : widgetMetadata.colorizeSystemIcon
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.icon = valueIcon;
settings.useDistroLogo = valueUseDistroLogo;
settings.customIconPath = valueCustomIconPath;
settings.enableColorization = valueEnableColorization;
settings.colorizeSystemIcon = valueColorizeSystemIcon;
settingsChanged(settings);
}
NToggle {
label: I18n.tr("bar.control-center.use-distro-logo-label")
description: I18n.tr("bar.control-center.use-distro-logo-description")
checked: valueUseDistroLogo
onToggled: checked => {
valueUseDistroLogo = checked;
saveSettings();
}
defaultValue: widgetMetadata.useDistroLogo
}
NToggle {
label: I18n.tr("bar.custom-button.enable-colorization-label")
description: I18n.tr("bar.control-center.enable-colorization-description")
checked: valueEnableColorization
onToggled: checked => {
valueEnableColorization = checked;
saveSettings();
}
defaultValue: widgetMetadata.enableColorization
}
NColorChoice {
visible: valueEnableColorization
label: I18n.tr("common.select-icon-color")
description: I18n.tr("bar.control-center.color-selection-description")
currentKey: valueColorizeSystemIcon
onSelected: function (key) {
valueColorizeSystemIcon = key;
saveSettings();
}
defaultValue: widgetMetadata.colorizeSystemIcon
}
RowLayout {
spacing: Style.marginM
NLabel {
label: I18n.tr("common.icon")
description: I18n.tr("bar.control-center.icon-description")
}
NImageRounded {
Layout.preferredWidth: Style.fontSizeXL * 2
Layout.preferredHeight: Style.fontSizeXL * 2
Layout.alignment: Qt.AlignVCenter
radius: Math.min(Style.radiusL, Layout.preferredWidth / 2)
imagePath: valueCustomIconPath
visible: valueCustomIconPath !== "" && !valueUseDistroLogo
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: valueIcon
pointSize: Style.fontSizeXXL * 1.5
visible: valueIcon !== "" && valueCustomIconPath === "" && !valueUseDistroLogo
}
}
RowLayout {
spacing: Style.marginM
NButton {
enabled: !valueUseDistroLogo
text: I18n.tr("bar.control-center.browse-library")
onClicked: iconPicker.open()
}
NButton {
enabled: !valueUseDistroLogo
text: I18n.tr("bar.control-center.browse-file")
onClicked: imagePicker.openFilePicker()
}
}
NIconPicker {
id: iconPicker
initialIcon: valueIcon
onIconSelected: iconName => {
valueIcon = iconName;
valueCustomIconPath = "";
saveSettings();
}
}
NFilePicker {
id: imagePicker
title: I18n.tr("bar.control-center.select-custom-icon")
selectionMode: "files"
nameFilters: ImageCacheService.basicImageFilters.concat(["*.svg"])
initialPath: Quickshell.env("HOME")
onAccepted: paths => {
if (paths.length > 0) {
valueCustomIconPath = paths[0];
saveSettings();
}
}
}
}
@@ -0,0 +1,170 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property bool valueShowCapsLock: widgetData.showCapsLock !== undefined ? widgetData.showCapsLock : widgetMetadata.showCapsLock
property bool valueShowNumLock: widgetData.showNumLock !== undefined ? widgetData.showNumLock : widgetMetadata.showNumLock
property bool valueShowScrollLock: widgetData.showScrollLock !== undefined ? widgetData.showScrollLock : widgetMetadata.showScrollLock
property string capsIcon: widgetData.capsLockIcon !== undefined ? widgetData.capsLockIcon : widgetMetadata.capsLockIcon
property string numIcon: widgetData.numLockIcon !== undefined ? widgetData.numLockIcon : widgetMetadata.numLockIcon
property string scrollIcon: widgetData.scrollLockIcon !== undefined ? widgetData.scrollLockIcon : widgetMetadata.scrollLockIcon
property bool valueHideWhenOff: widgetData.hideWhenOff !== undefined ? widgetData.hideWhenOff : (widgetMetadata.hideWhenOff !== undefined ? widgetMetadata.hideWhenOff : false)
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.showCapsLock = valueShowCapsLock;
settings.showNumLock = valueShowNumLock;
settings.showScrollLock = valueShowScrollLock;
settings.capsLockIcon = capsIcon;
settings.numLockIcon = numIcon;
settings.scrollLockIcon = scrollIcon;
settings.hideWhenOff = valueHideWhenOff;
settingsChanged(settings);
}
RowLayout {
spacing: Style.marginM
NToggle {
label: I18n.tr("bar.lock-keys.show-caps-lock-label")
description: I18n.tr("bar.lock-keys.show-caps-lock-description")
checked: valueShowCapsLock
onToggled: checked => {
valueShowCapsLock = checked;
saveSettings();
}
defaultValue: widgetMetadata.showCapsLock
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: capsIcon
pointSize: Style.fontSizeXL
visible: capsIcon !== ""
}
NButton {
text: I18n.tr("common.browse")
onClicked: capsPicker.open()
enabled: valueShowCapsLock
}
}
NIconPicker {
id: capsPicker
initialIcon: capsIcon
query: "letter-c"
onIconSelected: function (iconName) {
capsIcon = iconName;
saveSettings();
}
}
RowLayout {
spacing: Style.marginM
NToggle {
label: I18n.tr("bar.lock-keys.show-num-lock-label")
description: I18n.tr("bar.lock-keys.show-num-lock-description")
checked: valueShowNumLock
onToggled: checked => {
valueShowNumLock = checked;
saveSettings();
}
defaultValue: widgetMetadata.showNumLock
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: numIcon
pointSize: Style.fontSizeXL
visible: numIcon !== ""
}
NButton {
text: I18n.tr("common.browse")
onClicked: numPicker.open()
enabled: valueShowNumLock
}
}
NIconPicker {
id: numPicker
initialIcon: numIcon
query: "letter-n"
onIconSelected: function (iconName) {
numIcon = iconName;
saveSettings();
}
}
RowLayout {
spacing: Style.marginM
NToggle {
label: I18n.tr("bar.lock-keys.show-scroll-lock-label")
description: I18n.tr("bar.lock-keys.show-scroll-lock-description")
checked: valueShowScrollLock
onToggled: checked => {
valueShowScrollLock = checked;
saveSettings();
}
defaultValue: widgetMetadata.showScrollLock
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: scrollIcon
pointSize: Style.fontSizeXL
visible: scrollIcon !== ""
}
NButton {
text: I18n.tr("common.browse")
onClicked: scrollPicker.open()
enabled: valueShowScrollLock
}
}
NIconPicker {
id: scrollPicker
initialIcon: scrollIcon
query: "letter-s"
onIconSelected: function (iconName) {
scrollIcon = iconName;
saveSettings();
}
}
NDivider {
Layout.fillWidth: true
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.lock-keys.hide-when-off-label")
description: I18n.tr("bar.lock-keys.hide-when-off-description")
checked: valueHideWhenOff
onToggled: checked => {
valueHideWhenOff = checked;
saveSettings();
}
defaultValue: widgetMetadata.hideWhenOff
}
}
@@ -0,0 +1,248 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueHideMode: widgetData.hideMode !== undefined ? widgetData.hideMode : widgetMetadata.hideMode
// Deprecated: hideWhenIdle now folded into hideMode = "idle"
property bool valueHideWhenIdle: widgetData.hideWhenIdle !== undefined ? widgetData.hideWhenIdle : widgetMetadata.hideWhenIdle
property bool valueShowAlbumArt: widgetData.showAlbumArt !== undefined ? widgetData.showAlbumArt : widgetMetadata.showAlbumArt
property bool valuePanelShowAlbumArt: widgetData.panelShowAlbumArt !== undefined ? widgetData.panelShowAlbumArt : widgetMetadata.panelShowAlbumArt
property bool valueShowArtistFirst: widgetData.showArtistFirst !== undefined ? widgetData.showArtistFirst : widgetMetadata.showArtistFirst
property bool valueShowVisualizer: widgetData.showVisualizer !== undefined ? widgetData.showVisualizer : widgetMetadata.showVisualizer
property string valueVisualizerType: widgetData.visualizerType !== undefined ? widgetData.visualizerType : widgetMetadata.visualizerType
property string valueScrollingMode: widgetData.scrollingMode !== undefined ? widgetData.scrollingMode : widgetMetadata.scrollingMode
property int valueMaxWidth: widgetData.maxWidth !== undefined ? widgetData.maxWidth : widgetMetadata.maxWidth
property bool valueUseFixedWidth: widgetData.useFixedWidth !== undefined ? widgetData.useFixedWidth : widgetMetadata.useFixedWidth
property bool valueShowProgressRing: widgetData.showProgressRing !== undefined ? widgetData.showProgressRing : widgetMetadata.showProgressRing
property bool valueCompactMode: widgetData.compactMode !== undefined ? widgetData.compactMode : widgetMetadata.compactMode
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
Component.onCompleted: {
if (widgetData && widgetData.hideMode !== undefined) {
valueHideMode = widgetData.hideMode;
}
}
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.hideMode = valueHideMode;
// No longer store hideWhenIdle separately; kept for backward compatibility only
settings.showAlbumArt = valueShowAlbumArt;
settings.panelShowAlbumArt = valuePanelShowAlbumArt;
settings.showArtistFirst = valueShowArtistFirst;
settings.showVisualizer = valueShowVisualizer;
settings.visualizerType = valueVisualizerType;
settings.scrollingMode = valueScrollingMode;
settings.maxWidth = parseInt(widthInput.text) || widgetMetadata.maxWidth;
settings.useFixedWidth = valueUseFixedWidth;
settings.showProgressRing = valueShowProgressRing;
settings.compactMode = valueCompactMode;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.hide-mode-label")
description: I18n.tr("bar.media-mini.hide-mode-description")
model: [
{
"key": "visible",
"name": I18n.tr("hide-modes.visible")
},
{
"key": "hidden",
"name": I18n.tr("hide-modes.hidden")
},
{
"key": "transparent",
"name": I18n.tr("hide-modes.transparent")
},
{
"key": "idle",
"name": I18n.tr("hide-modes.idle")
}
]
currentKey: root.valueHideMode
onSelected: key => {
root.valueHideMode = key;
saveSettings();
}
defaultValue: widgetMetadata.hideMode
}
NToggle {
label: I18n.tr("bar.media-mini.show-album-art-label")
description: I18n.tr("bar.media-mini.show-album-art-description")
checked: valueShowAlbumArt
onToggled: checked => {
valueShowAlbumArt = checked;
saveSettings();
}
defaultValue: widgetMetadata.showAlbumArt
}
NToggle {
label: I18n.tr("bar.media-mini.show-artist-first-label")
description: I18n.tr("bar.media-mini.show-artist-first-description")
checked: valueShowArtistFirst
onToggled: checked => {
valueShowArtistFirst = checked;
saveSettings();
}
defaultValue: widgetMetadata.showArtistFirst
}
NToggle {
label: I18n.tr("bar.media-mini.show-visualizer-label")
description: I18n.tr("bar.media-mini.show-visualizer-description")
checked: valueShowVisualizer
onToggled: checked => {
valueShowVisualizer = checked;
saveSettings();
}
defaultValue: widgetMetadata.showVisualizer
}
NComboBox {
visible: valueShowVisualizer
label: I18n.tr("bar.media-mini.visualizer-type-label")
description: I18n.tr("bar.media-mini.visualizer-type-description")
model: [
{
"key": "linear",
"name": I18n.tr("options.visualizer-types.linear")
},
{
"key": "mirrored",
"name": I18n.tr("options.visualizer-types.mirrored")
},
{
"key": "wave",
"name": I18n.tr("options.visualizer-types.wave")
}
]
currentKey: valueVisualizerType
onSelected: key => {
valueVisualizerType = key;
saveSettings();
}
minimumWidth: 200
defaultValue: widgetMetadata.visualizerType
}
NTextInput {
id: widthInput
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.max-width-label")
description: I18n.tr("bar.media-mini.max-width-description")
placeholderText: widgetMetadata.maxWidth
text: valueMaxWidth
onTextChanged: saveSettings()
defaultValue: String(widgetMetadata.maxWidth)
}
NToggle {
label: I18n.tr("bar.media-mini.use-fixed-width-label")
description: I18n.tr("bar.media-mini.use-fixed-width-description")
checked: valueUseFixedWidth
onToggled: checked => {
valueUseFixedWidth = checked;
saveSettings();
}
defaultValue: widgetMetadata.useFixedWidth
}
NToggle {
label: I18n.tr("bar.media-mini.show-progress-ring-label")
description: I18n.tr("bar.media-mini.show-progress-ring-description")
checked: valueShowProgressRing
onToggled: checked => {
valueShowProgressRing = checked;
saveSettings();
}
defaultValue: widgetMetadata.showProgressRing
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
NComboBox {
label: I18n.tr("bar.media-mini.scrolling-mode-label")
description: I18n.tr("bar.media-mini.scrolling-mode-description")
model: [
{
"key": "always",
"name": I18n.tr("options.scrolling-modes.always")
},
{
"key": "hover",
"name": I18n.tr("options.scrolling-modes.hover")
},
{
"key": "never",
"name": I18n.tr("options.scrolling-modes.never")
}
]
currentKey: valueScrollingMode
onSelected: key => {
valueScrollingMode = key;
saveSettings();
}
minimumWidth: 200
defaultValue: widgetMetadata.scrollingMode
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginS
}
NLabel {
label: I18n.tr("bar.media-mini.panel-section-label")
description: I18n.tr("bar.media-mini.panel-section-description")
labelColor: Color.mPrimary
}
NToggle {
label: I18n.tr("bar.media-mini.show-album-art-label")
description: I18n.tr("bar.media-mini.show-album-art-description")
checked: valuePanelShowAlbumArt
onToggled: checked => {
valuePanelShowAlbumArt = checked;
saveSettings();
}
defaultValue: widgetMetadata.panelShowAlbumArt
}
NToggle {
label: I18n.tr("bar.media-mini.compact-mode-label")
description: I18n.tr("bar.media-mini.compact-mode-description")
checked: valueCompactMode
onToggled: checked => {
valueCompactMode = checked;
saveSettings();
}
defaultValue: widgetMetadata.compactMode
}
}
@@ -0,0 +1,90 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueMiddleClickCommand: widgetData.middleClickCommand !== undefined ? widgetData.middleClickCommand : widgetMetadata.middleClickCommand
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.middleClickCommand = valueMiddleClickCommand;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "alwaysShow",
"name": I18n.tr("display-modes.always-show")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: valueDisplayMode
onSelected: key => {
valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
// Middle click command
NTextInput {
label: I18n.tr("bar.custom-button.middle-click-label")
description: I18n.tr("panels.audio.on-middle-clicked-description")
placeholderText: I18n.tr("panels.audio.external-mixer-placeholder")
text: valueMiddleClickCommand
onTextChanged: {
valueMiddleClickCommand = text;
saveSettings();
}
defaultValue: widgetMetadata.middleClickCommand
}
}
@@ -0,0 +1,74 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "alwaysShow",
"name": I18n.tr("display-modes.always-show")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: root.valueDisplayMode
onSelected: key => {
root.valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
}
@@ -0,0 +1,90 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property bool valueShowUnreadBadge: widgetData.showUnreadBadge !== undefined ? widgetData.showUnreadBadge : widgetMetadata.showUnreadBadge
property bool valueHideWhenZero: widgetData.hideWhenZero !== undefined ? widgetData.hideWhenZero : widgetMetadata.hideWhenZero
property bool valueHideWhenZeroUnread: widgetData.hideWhenZeroUnread !== undefined ? widgetData.hideWhenZeroUnread : widgetMetadata.hideWhenZeroUnread
property string valueUnreadBadgeColor: widgetData.unreadBadgeColor !== undefined ? widgetData.unreadBadgeColor : widgetMetadata.unreadBadgeColor
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.showUnreadBadge = valueShowUnreadBadge;
settings.hideWhenZero = valueHideWhenZero;
settings.hideWhenZeroUnread = valueHideWhenZeroUnread;
settings.unreadBadgeColor = valueUnreadBadgeColor;
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NToggle {
label: I18n.tr("bar.notification-history.show-unread-badge-label")
description: I18n.tr("bar.notification-history.show-unread-badge-description")
checked: valueShowUnreadBadge
onToggled: checked => {
valueShowUnreadBadge = checked;
saveSettings();
}
defaultValue: widgetMetadata.showUnreadBadge
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
label: I18n.tr("bar.notification-history.unread-badge-color-label")
description: I18n.tr("bar.notification-history.unread-badge-color-description")
currentKey: valueUnreadBadgeColor
onSelected: key => {
valueUnreadBadgeColor = key;
saveSettings();
}
visible: valueShowUnreadBadge
defaultValue: widgetMetadata.unreadBadgeColor
}
NToggle {
label: I18n.tr("bar.notification-history.hide-widget-when-zero-label")
description: I18n.tr("bar.notification-history.hide-widget-when-zero-description")
checked: valueHideWhenZero
onToggled: checked => {
valueHideWhenZero = checked;
saveSettings();
}
enabled: !valueHideWhenZeroUnread
defaultValue: widgetMetadata.hideWhenZero
}
NToggle {
label: I18n.tr("bar.notification-history.hide-widget-when-zero-unread-label")
description: I18n.tr("bar.notification-history.hide-widget-when-zero-unread-description")
checked: valueHideWhenZeroUnread
onToggled: checked => {
valueHideWhenZeroUnread = checked;
saveSettings();
}
defaultValue: widgetMetadata.hideWhenZeroUnread
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
}
@@ -0,0 +1,38 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.System
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
width: 700
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: root.valueIconColor
onSelected: key => {
root.valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
}
@@ -0,0 +1,35 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
valueDefault: widgetMetadata.iconColor
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.width = parseInt(widthInput.text) || widgetMetadata.width;
settingsChanged(settings);
}
NTextInput {
id: widthInput
Layout.fillWidth: true
label: I18n.tr("common.width")
description: I18n.tr("bar.spacer.width-description")
text: widgetData.width || widgetMetadata.width
placeholderText: I18n.tr("placeholders.enter-width-pixels")
onTextChanged: saveSettings()
defaultValue: String(widgetMetadata.width)
}
}
@@ -0,0 +1,325 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.System
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right"
// Local, editable state for checkboxes
property bool valueCompactMode: widgetData.compactMode !== undefined ? widgetData.compactMode : widgetMetadata.compactMode
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
property bool valueUseMonospaceFont: widgetData.useMonospaceFont !== undefined ? widgetData.useMonospaceFont : widgetMetadata.useMonospaceFont
property bool valueUsePadding: widgetData.usePadding !== undefined ? widgetData.usePadding : widgetMetadata.usePadding
property bool valueShowCpuUsage: widgetData.showCpuUsage !== undefined ? widgetData.showCpuUsage : widgetMetadata.showCpuUsage
property bool valueShowCpuCores: widgetData.showCpuCores !== undefined ? widgetData.showCpuCores : widgetMetadata.showCpuCores
property bool valueShowCpuFreq: widgetData.showCpuFreq !== undefined ? widgetData.showCpuFreq : widgetMetadata.showCpuFreq
property bool valueShowCpuTemp: widgetData.showCpuTemp !== undefined ? widgetData.showCpuTemp : widgetMetadata.showCpuTemp
property bool valueShowGpuTemp: widgetData.showGpuTemp !== undefined ? widgetData.showGpuTemp : widgetMetadata.showGpuTemp
property bool valueShowLoadAverage: widgetData.showLoadAverage !== undefined ? widgetData.showLoadAverage : widgetMetadata.showLoadAverage
property bool valueShowMemoryUsage: widgetData.showMemoryUsage !== undefined ? widgetData.showMemoryUsage : widgetMetadata.showMemoryUsage
property bool valueShowMemoryAsPercent: widgetData.showMemoryAsPercent !== undefined ? widgetData.showMemoryAsPercent : widgetMetadata.showMemoryAsPercent
property bool valueShowSwapUsage: widgetData.showSwapUsage !== undefined ? widgetData.showSwapUsage : widgetMetadata.showSwapUsage
property bool valueShowNetworkStats: widgetData.showNetworkStats !== undefined ? widgetData.showNetworkStats : widgetMetadata.showNetworkStats
property bool valueShowDiskUsage: widgetData.showDiskUsage !== undefined ? widgetData.showDiskUsage : widgetMetadata.showDiskUsage
property bool valueShowDiskUsageAsPercent: widgetData.showDiskUsageAsPercent !== undefined ? widgetData.showDiskUsageAsPercent : widgetMetadata.showDiskUsageAsPercent
property bool valueShowDiskAvailable: widgetData.showDiskAvailable !== undefined ? widgetData.showDiskAvailable : widgetMetadata.showDiskAvailable
property string valueDiskPath: widgetData.diskPath !== undefined ? widgetData.diskPath : widgetMetadata.diskPath
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.compactMode = valueCompactMode;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settings.useMonospaceFont = valueUseMonospaceFont;
settings.usePadding = valueUsePadding;
settings.showCpuUsage = valueShowCpuUsage;
settings.showCpuCores = valueShowCpuCores;
settings.showCpuFreq = valueShowCpuFreq;
settings.showCpuTemp = valueShowCpuTemp;
settings.showGpuTemp = valueShowGpuTemp;
settings.showLoadAverage = valueShowLoadAverage;
settings.showMemoryUsage = valueShowMemoryUsage;
settings.showMemoryAsPercent = valueShowMemoryAsPercent;
settings.showSwapUsage = valueShowSwapUsage;
settings.showNetworkStats = valueShowNetworkStats;
settings.showDiskUsage = valueShowDiskUsage;
settings.showDiskUsageAsPercent = valueShowDiskUsageAsPercent;
settings.showDiskAvailable = valueShowDiskAvailable;
settings.diskPath = valueDiskPath;
settingsChanged(settings);
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.compact-mode-label")
description: I18n.tr("bar.system-monitor.compact-mode-description")
checked: valueCompactMode
onToggled: checked => {
valueCompactMode = checked;
saveSettings();
}
defaultValue: widgetMetadata.compactMode
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
visible: !valueCompactMode
defaultValue: widgetMetadata.textColor
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.use-monospace-font-label")
description: I18n.tr("bar.system-monitor.use-monospace-font-description")
checked: valueUseMonospaceFont
onToggled: checked => {
valueUseMonospaceFont = checked;
saveSettings();
}
visible: !valueCompactMode
defaultValue: widgetMetadata.useMonospaceFont
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.use-padding-label")
description: isVerticalBar ? I18n.tr("bar.system-monitor.use-padding-description-disabled-vertical") : !valueUseMonospaceFont ? I18n.tr("bar.system-monitor.use-padding-description-disabled-monospace-font") : I18n.tr("bar.system-monitor.use-padding-description")
checked: valueUsePadding && !isVerticalBar && valueUseMonospaceFont
onToggled: checked => {
valueUsePadding = checked;
saveSettings();
}
visible: !valueCompactMode
enabled: !isVerticalBar && valueUseMonospaceFont
defaultValue: widgetMetadata.usePadding
}
NDivider {
Layout.fillWidth: true
}
NToggle {
id: showCpuUsage
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.cpu-usage-label")
description: I18n.tr("bar.system-monitor.cpu-usage-description")
checked: valueShowCpuUsage
onToggled: checked => {
valueShowCpuUsage = checked;
saveSettings();
}
defaultValue: widgetMetadata.showCpuUsage
}
NToggle {
id: showCpuCores
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.cpu-cores-label")
description: I18n.tr("bar.system-monitor.cpu-cores-description")
checked: valueShowCpuCores
onToggled: checked => {
valueShowCpuCores = checked;
saveSettings();
}
visible: valueCompactMode
}
NToggle {
id: showCpuFreq
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.cpu-frequency-label")
description: I18n.tr("bar.system-monitor.cpu-frequency-description")
checked: valueShowCpuFreq
onToggled: checked => {
valueShowCpuFreq = checked;
saveSettings();
}
defaultValue: widgetMetadata.showCpuFreq
}
NToggle {
id: showCpuTemp
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.cpu-temperature-label")
description: I18n.tr("bar.system-monitor.cpu-temperature-description")
checked: valueShowCpuTemp
onToggled: checked => {
valueShowCpuTemp = checked;
saveSettings();
}
defaultValue: widgetMetadata.showCpuTemp
}
NToggle {
id: showLoadAverage
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.load-average-label")
description: I18n.tr("bar.system-monitor.load-average-description")
checked: valueShowLoadAverage
onToggled: checked => {
valueShowLoadAverage = checked;
saveSettings();
}
defaultValue: widgetMetadata.showLoadAverage
}
NToggle {
id: showGpuTemp
Layout.fillWidth: true
label: I18n.tr("panels.system-monitor.gpu-section-label")
description: I18n.tr("bar.system-monitor.gpu-temperature-description")
checked: valueShowGpuTemp
onToggled: checked => {
valueShowGpuTemp = checked;
saveSettings();
}
visible: SystemStatService.gpuAvailable
defaultValue: widgetMetadata.showGpuTemp
}
NToggle {
id: showMemoryUsage
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.memory-usage-label")
description: I18n.tr("bar.system-monitor.memory-usage-description")
checked: valueShowMemoryUsage
onToggled: checked => {
valueShowMemoryUsage = checked;
saveSettings();
}
defaultValue: widgetMetadata.showMemoryUsage
}
NToggle {
id: showMemoryAsPercent
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.memory-percentage-label")
description: I18n.tr("bar.system-monitor.memory-percentage-description")
checked: valueShowMemoryAsPercent
onToggled: checked => {
valueShowMemoryAsPercent = checked;
saveSettings();
}
visible: valueShowMemoryUsage
defaultValue: widgetMetadata.showMemoryAsPercent
}
NToggle {
id: showSwapUsage
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.swap-usage-label")
description: I18n.tr("bar.system-monitor.swap-usage-description")
checked: valueShowSwapUsage
onToggled: checked => {
valueShowSwapUsage = checked;
saveSettings();
}
defaultValue: widgetMetadata.showSwapUsage
}
NToggle {
id: showNetworkStats
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.network-traffic-label")
description: I18n.tr("bar.system-monitor.network-traffic-description")
checked: valueShowNetworkStats
onToggled: checked => {
valueShowNetworkStats = checked;
saveSettings();
}
defaultValue: widgetMetadata.showNetworkStats
}
NDivider {
Layout.fillWidth: true
}
NToggle {
id: showDiskUsage
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.storage-usage-label")
description: I18n.tr("bar.system-monitor.storage-usage-description")
checked: valueShowDiskUsage
onToggled: checked => {
valueShowDiskUsage = checked;
saveSettings();
}
defaultValue: widgetMetadata.showDiskUsage
}
NToggle {
id: showDiskUsageAsPercent
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.storage-as-percentage-label")
description: I18n.tr("bar.system-monitor.storage-as-percentage-description")
checked: valueShowDiskUsageAsPercent
onToggled: checked => {
valueShowDiskUsageAsPercent = checked;
saveSettings();
}
defaultValue: widgetMetadata.showDiskUsageAsPercent
}
NToggle {
id: showDiskAvailable
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.storage-available-label")
description: I18n.tr("bar.system-monitor.storage-available-description")
checked: valueShowDiskAvailable
onToggled: checked => {
valueShowDiskAvailable = checked;
saveSettings();
}
defaultValue: widgetMetadata.showDiskAvailable
}
NComboBox {
id: diskPathComboBox
Layout.fillWidth: true
label: I18n.tr("bar.system-monitor.disk-path-label")
description: I18n.tr("bar.system-monitor.disk-path-description")
model: {
const paths = Object.keys(SystemStatService.diskPercents).sort();
return paths.map(path => ({
key: path,
name: path
}));
}
currentKey: valueDiskPath
onSelected: key => {
valueDiskPath = key;
saveSettings();
}
defaultValue: widgetMetadata.diskPath
}
}
@@ -0,0 +1,201 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
readonly property bool isVerticalBar: Settings.data.bar.position === "left" || Settings.data.bar.position === "right"
// Local state
property string valueHideMode: widgetData.hideMode !== undefined ? widgetData.hideMode : widgetMetadata.hideMode
property bool valueOnlyActiveWorkspaces: widgetData.onlyActiveWorkspaces !== undefined ? widgetData.onlyActiveWorkspaces : widgetMetadata.onlyActiveWorkspaces
property bool valueOnlySameOutput: widgetData.onlySameOutput !== undefined ? widgetData.onlySameOutput : widgetMetadata.onlySameOutput
property bool valueColorizeIcons: widgetData.colorizeIcons !== undefined ? widgetData.colorizeIcons : widgetMetadata.colorizeIcons
property bool valueShowTitle: isVerticalBar ? false : widgetData.showTitle !== undefined ? widgetData.showTitle : widgetMetadata.showTitle
property bool valueSmartWidth: widgetData.smartWidth !== undefined ? widgetData.smartWidth : widgetMetadata.smartWidth
property int valueMaxTaskbarWidth: widgetData.maxTaskbarWidth !== undefined ? widgetData.maxTaskbarWidth : widgetMetadata.maxTaskbarWidth
property int valueTitleWidth: widgetData.titleWidth !== undefined ? widgetData.titleWidth : widgetMetadata.titleWidth
property bool valueShowPinnedApps: widgetData.showPinnedApps !== undefined ? widgetData.showPinnedApps : widgetMetadata.showPinnedApps
property real valueIconScale: widgetData.iconScale !== undefined ? widgetData.iconScale : widgetMetadata.iconScale
Component.onCompleted: {
if (widgetData && widgetData.hideMode !== undefined) {
valueHideMode = widgetData.hideMode;
} else if (widgetMetadata && widgetMetadata.hideMode !== undefined) {
valueHideMode = widgetMetadata.hideMode;
}
}
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.hideMode = valueHideMode;
settings.onlySameOutput = valueOnlySameOutput;
settings.onlyActiveWorkspaces = valueOnlyActiveWorkspaces;
settings.colorizeIcons = valueColorizeIcons;
settings.showTitle = valueShowTitle;
settings.smartWidth = valueSmartWidth;
settings.maxTaskbarWidth = valueMaxTaskbarWidth;
settings.titleWidth = parseInt(titleWidthInput.text) || widgetMetadata.titleWidth;
settings.showPinnedApps = valueShowPinnedApps;
settings.iconScale = valueIconScale;
settingsChanged(settings);
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.hide-mode-label")
description: I18n.tr("bar.taskbar.hide-mode-description")
model: [
{
"key": "visible",
"name": I18n.tr("hide-modes.visible")
},
{
"key": "hidden",
"name": I18n.tr("hide-modes.hidden")
},
{
"key": "transparent",
"name": I18n.tr("hide-modes.transparent")
}
]
currentKey: root.valueHideMode
onSelected: key => {
root.valueHideMode = key;
saveSettings();
}
defaultValue: widgetMetadata.hideMode
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.only-same-monitor-label")
description: I18n.tr("bar.taskbar.only-same-monitor-description")
checked: root.valueOnlySameOutput
onToggled: checked => {
root.valueOnlySameOutput = checked;
saveSettings();
}
defaultValue: widgetMetadata.onlySameOutput
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.only-active-workspaces-label")
description: I18n.tr("bar.taskbar.only-active-workspaces-description")
checked: root.valueOnlyActiveWorkspaces
onToggled: checked => {
root.valueOnlyActiveWorkspaces = checked;
saveSettings();
}
defaultValue: widgetMetadata.onlyActiveWorkspaces
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.tray.colorize-icons-label")
description: I18n.tr("bar.taskbar.colorize-icons-description")
checked: root.valueColorizeIcons
onToggled: checked => {
root.valueColorizeIcons = checked;
saveSettings();
}
defaultValue: widgetMetadata.colorizeIcons
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.show-pinned-apps-label")
description: I18n.tr("bar.taskbar.show-pinned-apps-description")
checked: root.valueShowPinnedApps
onToggled: checked => {
root.valueShowPinnedApps = checked;
saveSettings();
}
defaultValue: widgetMetadata.showPinnedApps
}
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.icon-scale-label")
description: I18n.tr("bar.taskbar.icon-scale-description")
from: 0.5
to: 1
stepSize: 0.01
showReset: true
value: root.valueIconScale
defaultValue: widgetMetadata.iconScale
onMoved: value => {
root.valueIconScale = value;
saveSettings();
}
text: Math.round(root.valueIconScale * 100) + "%"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.show-title-label")
description: isVerticalBar ? I18n.tr("bar.taskbar.show-title-description-disabled") : I18n.tr("bar.taskbar.show-title-description")
checked: root.valueShowTitle
onToggled: checked => {
root.valueShowTitle = checked;
saveSettings();
}
enabled: !isVerticalBar
defaultValue: widgetMetadata.showTitle
}
NTextInput {
id: titleWidthInput
visible: root.valueShowTitle && !isVerticalBar
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.title-width-label")
description: I18n.tr("bar.taskbar.title-width-description")
text: widgetData.titleWidth || widgetMetadata.titleWidth
placeholderText: I18n.tr("placeholders.enter-width-pixels")
onTextChanged: saveSettings()
defaultValue: String(widgetMetadata.titleWidth)
}
NToggle {
Layout.fillWidth: true
visible: !isVerticalBar && root.valueShowTitle
label: I18n.tr("bar.taskbar.smart-width-label")
description: I18n.tr("bar.taskbar.smart-width-description")
checked: root.valueSmartWidth
onToggled: checked => {
root.valueSmartWidth = checked;
saveSettings();
}
defaultValue: widgetMetadata.smartWidth
}
NValueSlider {
visible: root.valueSmartWidth && !isVerticalBar
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.max-width-label")
description: I18n.tr("bar.taskbar.max-width-description")
from: 10
to: 100
stepSize: 5
showReset: true
value: root.valueMaxTaskbarWidth
defaultValue: widgetMetadata.maxTaskbarWidth
onMoved: value => {
root.valueMaxTaskbarWidth = Math.round(value);
saveSettings();
}
text: Math.round(root.valueMaxTaskbarWidth) + "%"
}
}
@@ -0,0 +1,199 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property var localBlacklist: widgetData.blacklist || []
property bool valueColorizeIcons: widgetData.colorizeIcons !== undefined ? widgetData.colorizeIcons : widgetMetadata.colorizeIcons
property string valueChevronColor: widgetData.chevronColor !== undefined ? widgetData.chevronColor : widgetMetadata.chevronColor
property bool valueDrawerEnabled: widgetData.drawerEnabled !== undefined ? widgetData.drawerEnabled : widgetMetadata.drawerEnabled
property bool valueHidePassive: widgetData.hidePassive !== undefined ? widgetData.hidePassive : widgetMetadata.hidePassive
ListModel {
id: blacklistModel
}
function populateBlacklist() {
for (var i = 0; i < localBlacklist.length; i++) {
blacklistModel.append({
"rule": localBlacklist[i]
});
}
}
Component.onCompleted: {
Qt.callLater(populateBlacklist);
}
spacing: Style.marginM
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.tray.drawer-enabled-label")
description: I18n.tr("bar.tray.drawer-enabled-description")
checked: root.valueDrawerEnabled
onToggled: checked => {
root.valueDrawerEnabled = checked;
saveSettings();
}
defaultValue: widgetMetadata.drawerEnabled
}
NColorChoice {
label: I18n.tr("bar.tray.chevron-color-label")
description: I18n.tr("bar.tray.chevron-color-description")
currentKey: root.valueChevronColor
onSelected: key => {
root.valueChevronColor = key;
saveSettings();
}
visible: root.valueDrawerEnabled
defaultValue: widgetMetadata.chevronColor
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.tray.colorize-icons-label")
description: I18n.tr("bar.tray.colorize-icons-description")
checked: root.valueColorizeIcons
onToggled: checked => {
root.valueColorizeIcons = checked;
saveSettings();
}
defaultValue: widgetMetadata.colorizeIcons
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.tray.hide-passive-label")
description: I18n.tr("bar.tray.hide-passive-description")
checked: root.valueHidePassive
onToggled: checked => {
root.valueHidePassive = checked;
saveSettings();
}
defaultValue: widgetMetadata.hidePassive
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
NLabel {
label: I18n.tr("panels.bar.tray-blacklist-label")
description: I18n.tr("panels.bar.tray-blacklist-description")
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NTextInputButton {
id: newRuleInput
Layout.fillWidth: true
placeholderText: I18n.tr("panels.bar.tray-blacklist-placeholder")
buttonIcon: "add"
onButtonClicked: {
if (newRuleInput.text.length > 0) {
var newRule = newRuleInput.text.trim();
var exists = false;
for (var i = 0; i < blacklistModel.count; i++) {
if (blacklistModel.get(i).rule === newRule) {
exists = true;
break;
}
}
if (!exists) {
blacklistModel.append({
"rule": newRule
});
newRuleInput.text = "";
saveSettings();
}
}
}
}
}
}
// List of current blacklist items
NListView {
Layout.fillWidth: true
Layout.preferredHeight: 150
Layout.topMargin: Style.marginL // Increased top margin
gradientColor: Color.mSurface
model: blacklistModel
delegate: Item {
width: ListView.width
height: 40
Rectangle {
id: itemBackground
anchors.fill: parent
anchors.margins: Style.marginXS
color: "transparent" // Make background transparent
border.color: Color.mOutline
border.width: Style.borderS
radius: Style.radiusS
visible: model.rule !== undefined && model.rule !== "" // Only visible if rule exists
}
Row {
anchors.fill: parent
anchors.leftMargin: Style.marginS
anchors.rightMargin: Style.marginS
spacing: Style.marginS
NText {
anchors.verticalCenter: parent.verticalCenter
text: model.rule
elide: Text.ElideRight
}
NIconButton {
anchors.verticalCenter: parent.verticalCenter
icon: "close"
baseSize: 12 * Style.uiScaleRatio
colorBg: Color.mSurfaceVariant
colorFg: Color.mOnSurfaceVariant
colorBgHover: Color.mError
colorFgHover: Color.mOnError
onClicked: {
blacklistModel.remove(index);
saveSettings();
}
}
}
}
}
// This function will be called by the dialog to get the new settings
function saveSettings() {
var newBlacklist = [];
for (var i = 0; i < blacklistModel.count; i++) {
newBlacklist.push(blacklistModel.get(i).rule);
}
// Return the updated settings for this widget instance
var settings = Object.assign({}, widgetData || {});
settings.blacklist = newBlacklist;
settings.colorizeIcons = root.valueColorizeIcons;
settings.chevronColor = root.valueChevronColor;
settings.drawerEnabled = root.valueDrawerEnabled;
settings.hidePassive = root.valueHidePassive;
settingsChanged(settings);
}
}
@@ -0,0 +1,70 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "alwaysShow",
"name": I18n.tr("display-modes.always-show")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: root.valueDisplayMode
onSelected: key => {
root.valueDisplayMode = key;
saveSettings();
}
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
}
}
@@ -0,0 +1,90 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
// Local state
property string valueDisplayMode: widgetData.displayMode !== undefined ? widgetData.displayMode : widgetMetadata.displayMode
property string valueMiddleClickCommand: widgetData.middleClickCommand !== undefined ? widgetData.middleClickCommand : widgetMetadata.middleClickCommand
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
property string valueTextColor: widgetData.textColor !== undefined ? widgetData.textColor : widgetMetadata.textColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.displayMode = valueDisplayMode;
settings.middleClickCommand = valueMiddleClickCommand;
settings.iconColor = valueIconColor;
settings.textColor = valueTextColor;
settingsChanged(settings);
}
NComboBox {
label: I18n.tr("common.display-mode")
description: I18n.tr("bar.volume.display-mode-description")
minimumWidth: 200
model: [
{
"key": "onhover",
"name": I18n.tr("display-modes.on-hover")
},
{
"key": "alwaysShow",
"name": I18n.tr("display-modes.always-show")
},
{
"key": "alwaysHide",
"name": I18n.tr("display-modes.always-hide")
}
]
currentKey: valueDisplayMode
onSelected: key => {
valueDisplayMode = key;
saveSettings();
}
defaultValue: widgetMetadata.displayMode
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
defaultValue: widgetMetadata.iconColor
}
NColorChoice {
currentKey: valueTextColor
onSelected: key => {
valueTextColor = key;
saveSettings();
}
defaultValue: widgetMetadata.textColor
}
// Middle click command
NTextInput {
label: I18n.tr("bar.custom-button.middle-click-label")
description: I18n.tr("panels.audio.on-middle-clicked-description")
placeholderText: I18n.tr("panels.audio.external-mixer-placeholder")
text: valueMiddleClickCommand
onTextChanged: {
valueMiddleClickCommand = text;
saveSettings();
}
defaultValue: widgetMetadata.middleClickCommand
}
}
@@ -0,0 +1,33 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueIconColor: widgetData.iconColor !== undefined ? widgetData.iconColor : widgetMetadata.iconColor
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.iconColor = valueIconColor;
settingsChanged(settings);
}
NColorChoice {
label: I18n.tr("common.select-icon-color")
currentKey: valueIconColor
onSelected: key => {
valueIconColor = key;
saveSettings();
}
}
}
@@ -0,0 +1,323 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
// Properties to receive data from parent
property var screen: null
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueLabelMode: widgetData.labelMode !== undefined ? widgetData.labelMode : widgetMetadata.labelMode
property bool valueHideUnoccupied: widgetData.hideUnoccupied !== undefined ? widgetData.hideUnoccupied : widgetMetadata.hideUnoccupied
property bool valueFollowFocusedScreen: widgetData.followFocusedScreen !== undefined ? widgetData.followFocusedScreen : widgetMetadata.followFocusedScreen
property int valueCharacterCount: widgetData.characterCount !== undefined ? widgetData.characterCount : widgetMetadata.characterCount
// Grouped mode settings
property bool valueShowApplications: widgetData.showApplications !== undefined ? widgetData.showApplications : widgetMetadata.showApplications
property bool valueShowApplicationsHover: widgetData.showApplicationsHover !== undefined ? widgetData.showApplicationsHover : widgetMetadata.showApplicationsHover
property bool valueShowLabelsOnlyWhenOccupied: widgetData.showLabelsOnlyWhenOccupied !== undefined ? widgetData.showLabelsOnlyWhenOccupied : widgetMetadata.showLabelsOnlyWhenOccupied
property bool valueColorizeIcons: widgetData.colorizeIcons !== undefined ? widgetData.colorizeIcons : widgetMetadata.colorizeIcons
property real valueUnfocusedIconsOpacity: widgetData.unfocusedIconsOpacity !== undefined ? widgetData.unfocusedIconsOpacity : widgetMetadata.unfocusedIconsOpacity
property real valueGroupedBorderOpacity: widgetData.groupedBorderOpacity !== undefined ? widgetData.groupedBorderOpacity : widgetMetadata.groupedBorderOpacity
property bool valueEnableScrollWheel: widgetData.enableScrollWheel !== undefined ? widgetData.enableScrollWheel : widgetMetadata.enableScrollWheel
property real valueIconScale: widgetData.iconScale !== undefined ? widgetData.iconScale : widgetMetadata.iconScale
property string valueFocusedColor: widgetData.focusedColor !== undefined ? widgetData.focusedColor : widgetMetadata.focusedColor
property string valueOccupiedColor: widgetData.occupiedColor !== undefined ? widgetData.occupiedColor : widgetMetadata.occupiedColor
property string valueEmptyColor: widgetData.emptyColor !== undefined ? widgetData.emptyColor : widgetMetadata.emptyColor
property bool valueShowBadge: widgetData.showBadge !== undefined ? widgetData.showBadge : widgetMetadata.showBadge
property real valuePillSize: widgetData.pillSize !== undefined ? widgetData.pillSize : widgetMetadata.pillSize
property string valueFontWeight: widgetData.fontWeight !== undefined ? widgetData.fontWeight : widgetMetadata.fontWeight
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.labelMode = valueLabelMode;
settings.hideUnoccupied = valueHideUnoccupied;
settings.characterCount = valueCharacterCount;
settings.followFocusedScreen = valueFollowFocusedScreen;
settings.showApplications = valueShowApplications;
settings.showApplicationsHover = valueShowApplicationsHover;
settings.showLabelsOnlyWhenOccupied = valueShowLabelsOnlyWhenOccupied;
settings.colorizeIcons = valueColorizeIcons;
settings.unfocusedIconsOpacity = valueUnfocusedIconsOpacity;
settings.groupedBorderOpacity = valueGroupedBorderOpacity;
settings.enableScrollWheel = valueEnableScrollWheel;
settings.iconScale = valueIconScale;
settings.focusedColor = valueFocusedColor;
settings.occupiedColor = valueOccupiedColor;
settings.emptyColor = valueEmptyColor;
settings.showBadge = valueShowBadge;
settings.pillSize = valuePillSize;
settings.fontWeight = valueFontWeight;
settingsChanged(settings);
}
NComboBox {
id: labelModeCombo
label: I18n.tr("bar.workspace.label-mode-label")
description: I18n.tr("bar.workspace.label-mode-description")
model: [
{
"key": "none",
"name": I18n.tr("common.none")
},
{
"key": "index",
"name": I18n.tr("options.workspace-labels.index")
},
{
"key": "name",
"name": I18n.tr("options.workspace-labels.name")
},
{
"key": "index+name",
"name": I18n.tr("options.workspace-labels.index-and-name")
}
]
currentKey: widgetData.labelMode || widgetMetadata.labelMode
onSelected: key => {
valueLabelMode = key;
saveSettings();
}
minimumWidth: 200
}
NSpinBox {
label: I18n.tr("bar.workspace.character-count-label")
description: I18n.tr("bar.workspace.character-count-description")
from: 1
to: 10
value: valueCharacterCount
onValueChanged: {
valueCharacterCount = value;
saveSettings();
}
visible: valueLabelMode === "name"
}
NValueSlider {
label: I18n.tr("bar.workspace.pill-size-label")
description: I18n.tr("bar.workspace.pill-size-description")
from: 0.4
to: 1.0
stepSize: 0.01
value: valuePillSize
defaultValue: widgetMetadata.pillSize
showReset: true
onMoved: value => {
valuePillSize = value;
saveSettings();
}
text: Math.round(valuePillSize * 100) + "%"
visible: !valueShowApplications
}
NComboBox {
id: fontWeightCombo
label: I18n.tr("bar.workspace.font-weight-label")
description: I18n.tr("bar.workspace.font-weight-description")
model: [
{
"key": "regular",
"name": I18n.tr("common.font-weight-regular")
},
{
"key": "medium",
"name": I18n.tr("common.font-weight-medium")
},
{
"key": "semibold",
"name": I18n.tr("common.font-weight-semibold")
},
{
"key": "bold",
"name": I18n.tr("common.font-weight-bold")
},
]
currentKey: widgetData.fontWeight || widgetMetadata.fontWeight
onSelected: key => {
valueFontWeight = key;
saveSettings();
}
minimumWidth: 200
}
NToggle {
label: I18n.tr("bar.workspace.hide-unoccupied-label")
description: I18n.tr("bar.workspace.hide-unoccupied-description")
checked: valueHideUnoccupied
onToggled: checked => {
valueHideUnoccupied = checked;
saveSettings();
}
}
NToggle {
label: I18n.tr("bar.workspace.show-labels-only-when-occupied-label")
description: I18n.tr("bar.workspace.show-labels-only-when-occupied-description")
checked: valueShowLabelsOnlyWhenOccupied
onToggled: checked => {
valueShowLabelsOnlyWhenOccupied = checked;
saveSettings();
}
}
NToggle {
label: I18n.tr("bar.workspace.follow-focused-screen-label")
description: I18n.tr("bar.workspace.follow-focused-screen-description")
checked: valueFollowFocusedScreen
onToggled: checked => {
valueFollowFocusedScreen = checked;
saveSettings();
}
}
NToggle {
label: I18n.tr("bar.workspace.enable-scrollwheel-label")
description: I18n.tr("bar.workspace.enable-scrollwheel-description")
checked: valueEnableScrollWheel
onToggled: checked => {
valueEnableScrollWheel = checked;
saveSettings();
}
}
NDivider {
Layout.fillWidth: true
}
NToggle {
label: I18n.tr("bar.workspace.show-applications-label")
description: I18n.tr("bar.workspace.show-applications-description")
checked: valueShowApplications
onToggled: checked => {
valueShowApplications = checked;
saveSettings();
}
}
NToggle {
label: I18n.tr("bar.workspace.show-applications-hover-label")
description: I18n.tr("bar.workspace.show-applications-hover-description")
checked: valueShowApplicationsHover
onToggled: checked => {
valueShowApplicationsHover = checked;
saveSettings();
}
visible: valueShowApplications
}
NToggle {
label: I18n.tr("bar.workspace.show-badge-label")
description: I18n.tr("bar.workspace.show-badge-description")
checked: valueShowBadge
onToggled: checked => {
valueShowBadge = checked;
saveSettings();
}
visible: valueShowApplications
}
NToggle {
label: I18n.tr("bar.tray.colorize-icons-label")
description: I18n.tr("bar.active-window.colorize-icons-description")
checked: valueColorizeIcons
onToggled: checked => {
valueColorizeIcons = checked;
saveSettings();
}
visible: valueShowApplications
}
NValueSlider {
label: I18n.tr("bar.workspace.unfocused-icons-opacity-label")
description: I18n.tr("bar.workspace.unfocused-icons-opacity-description")
from: 0
to: 1
stepSize: 0.01
showReset: true
value: valueUnfocusedIconsOpacity
defaultValue: widgetMetadata.unfocusedIconsOpacity
onMoved: value => {
valueUnfocusedIconsOpacity = value;
saveSettings();
}
text: Math.floor(valueUnfocusedIconsOpacity * 100) + "%"
visible: valueShowApplications
}
NValueSlider {
label: I18n.tr("bar.workspace.grouped-border-opacity-label")
description: I18n.tr("bar.workspace.grouped-border-opacity-description")
from: 0
to: 1
stepSize: 0.01
showReset: true
value: valueGroupedBorderOpacity
defaultValue: widgetMetadata.groupedBorderOpacity
onMoved: value => {
valueGroupedBorderOpacity = value;
saveSettings();
}
text: Math.floor(valueGroupedBorderOpacity * 100) + "%"
visible: valueShowApplications
}
NValueSlider {
label: I18n.tr("bar.taskbar.icon-scale-label")
description: I18n.tr("bar.taskbar.icon-scale-description")
from: 0.5
to: 1
stepSize: 0.01
showReset: true
value: valueIconScale
defaultValue: widgetMetadata.iconScale
onMoved: value => {
valueIconScale = value;
saveSettings();
}
text: Math.round(valueIconScale * 100) + "%"
visible: valueShowApplications
}
NDivider {
Layout.fillWidth: true
}
NColorChoice {
label: I18n.tr("bar.workspace.focused-color-label")
description: I18n.tr("bar.workspace.focused-color-description")
currentKey: valueFocusedColor
onSelected: key => {
valueFocusedColor = key;
saveSettings();
}
}
NColorChoice {
label: I18n.tr("bar.workspace.occupied-color-label")
description: I18n.tr("bar.workspace.occupied-color-description")
currentKey: valueOccupiedColor
onSelected: key => {
valueOccupiedColor = key;
saveSettings();
}
}
NColorChoice {
label: I18n.tr("bar.workspace.empty-color-label")
description: I18n.tr("bar.workspace.empty-color-description")
currentKey: valueEmptyColor
onSelected: key => {
valueEmptyColor = key;
saveSettings();
}
}
}
@@ -0,0 +1,160 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.UI
import qs.Widgets
// Widget Settings Dialog Component
Popup {
id: root
property int widgetIndex: -1
property var widgetData: null
property string widgetId: ""
property string sectionId: ""
property var screen: null
property var settingsCache: ({})
signal updateWidgetSettings(string section, int index, var settings)
readonly property real maxHeight: (screen ? screen.height : (parent ? parent.height : 800)) * 0.8
readonly property real defaultContentWidth: Math.round(600 * Style.uiScaleRatio)
readonly property real settingsContentWidth: {
if (settingsLoader.item && settingsLoader.item.implicitWidth > 0) {
return settingsLoader.item.implicitWidth;
}
return defaultContentWidth;
}
readonly property real dialogPadding: Style.marginXL
width: Math.max(settingsContentWidth + dialogPadding * 2, 500)
height: Math.min(content.implicitHeight + dialogPadding * 2, maxHeight)
padding: 0
modal: true
closePolicy: Popup.NoAutoClose
anchors.centerIn: parent
onOpened: {
if (widgetData && widgetId) {
loadWidgetSettings();
}
}
background: Rectangle {
color: Color.mSurface
radius: Style.radiusL
border.color: Color.mPrimary
border.width: Style.borderM
}
contentItem: ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: dialogPadding
spacing: Style.marginM
// Title
RowLayout {
id: titleRow
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
NText {
text: I18n.tr("system.widget-settings-title", {
"widget": root.widgetId
})
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
onClicked: saveAndClose()
}
}
// Separator
Rectangle {
id: separator
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Color.mOutline
}
// Scrollable settings area
NScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 100
gradientColor: Color.mSurface
reserveScrollbarSpace: false
ColumnLayout {
width: scrollView.availableWidth
spacing: Style.marginM
Loader {
id: settingsLoader
Layout.fillWidth: true
onItemChanged: {
if (item) {
// Force width recalculation when content loads
Qt.callLater(() => {
root.width = Math.max(root.settingsContentWidth + root.dialogPadding * 2, 500);
});
}
}
}
}
}
}
Timer {
id: saveTimer
running: false
interval: 150
onTriggered: {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, root.settingsCache);
}
}
Connections {
target: settingsLoader.item
ignoreUnknownSignals: true
function onSettingsChanged(newSettings) {
if (newSettings) {
root.settingsCache = newSettings;
saveTimer.start();
}
}
}
function saveAndClose() {
if (settingsLoader.item && typeof settingsLoader.item.saveSettings === 'function') {
var newSettings = settingsLoader.item.saveSettings();
if (newSettings) {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, newSettings);
}
}
root.close();
}
function loadWidgetSettings() {
const widgetSettingsMap = {
"CustomButton": "WidgetSettings/CustomButtonSettings.qml"
};
const source = widgetSettingsMap[widgetId];
if (source) {
settingsLoader.setSource(source, {
"widgetData": widgetData,
"widgetMetadata": ControlCenterWidgetRegistry.widgetMetadata[widgetId]
});
}
}
}
@@ -0,0 +1,303 @@
import QtQml.Models
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
Layout.preferredWidth: Math.round(600 * Style.uiScaleRatio)
implicitWidth: Layout.preferredWidth
property var widgetData: null
property var widgetMetadata: null
property var rootSettings: null
signal settingsChanged(var settings)
QtObject {
id: _settings
property string icon: widgetData.icon !== undefined ? widgetData.icon : widgetMetadata.icon
property string onClicked: widgetData.onClicked !== undefined ? widgetData.onClicked : widgetMetadata.onClicked
property string onRightClicked: widgetData.onRightClicked !== undefined ? widgetData.onRightClicked : widgetMetadata.onRightClicked
property string onMiddleClicked: widgetData.onMiddleClicked !== undefined ? widgetData.onMiddleClicked : widgetMetadata.onMiddleClicked
property ListModel _stateChecksListModel: ListModel {}
property string stateChecksJson: "[]"
property string generalTooltipText: widgetData.generalTooltipText !== undefined ? widgetData.generalTooltipText : widgetMetadata.generalTooltipText
property bool enableOnStateLogic: widgetData.enableOnStateLogic !== undefined ? widgetData.enableOnStateLogic : widgetMetadata.enableOnStateLogic
property bool showExecTooltip: widgetData.showExecTooltip !== undefined ? widgetData.showExecTooltip : widgetMetadata.showExecTooltip
function populateStateChecks() {
try {
var initialChecks = JSON.parse(stateChecksJson);
if (initialChecks && Array.isArray(initialChecks)) {
for (var i = 0; i < initialChecks.length; i++) {
var item = initialChecks[i];
if (item && typeof item === "object") {
_settings._stateChecksListModel.append({
"command": item.command || "",
"icon": item.icon || ""
});
} else {
Logger.w("CustomButtonSettings", "Invalid stateChecks entry at index " + i + ":", item);
}
}
}
} catch (e) {
Logger.e("CustomButtonSettings", "Failed to parse stateChecksJson:", e.message);
}
}
Component.onCompleted: {
root.rootSettings = _settings;
stateChecksJson = (widgetData && widgetData.stateChecksJson !== undefined) ? widgetData.stateChecksJson : (widgetMetadata && widgetMetadata.stateChecksJson ? widgetMetadata.stateChecksJson : "[]");
Qt.callLater(populateStateChecks);
}
function saveSettings() {
var savedStateChecksArray = [];
for (var i = 0; i < _settings._stateChecksListModel.count; i++) {
savedStateChecksArray.push(_settings._stateChecksListModel.get(i));
}
_settings.stateChecksJson = JSON.stringify(savedStateChecksArray);
return {
"id": widgetData.id,
"icon": _settings.icon,
"onClicked": _settings.onClicked,
"onRightClicked": _settings.onRightClicked,
"onMiddleClicked": _settings.onMiddleClicked,
"stateChecksJson": _settings.stateChecksJson,
"generalTooltipText": _settings.generalTooltipText,
"enableOnStateLogic": _settings.enableOnStateLogic,
"showExecTooltip": _settings.showExecTooltip
};
}
}
function saveSettings() {
return _settings.saveSettings();
}
function updateStateCheck(index, command, icon) {
_settings._stateChecksListModel.set(index, {
"command": command,
"icon": icon
});
_settings.saveSettings();
}
function removeStateCheck(index) {
_settings._stateChecksListModel.remove(index);
_settings.saveSettings();
}
function addStateCheck() {
_settings._stateChecksListModel.append({
"command": "",
"icon": ""
});
_settings.saveSettings();
}
RowLayout {
spacing: Style?.marginM ?? 8
NLabel {
label: I18n.tr("common.icon")
description: I18n.tr("panels.control-center.shortcuts-custom-button-icon-description")
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: _settings.icon || (widgetMetadata && widgetMetadata.icon ? widgetMetadata.icon : "")
pointSize: Style?.fontSizeXL ?? 24
visible: (_settings.icon || (widgetMetadata && widgetMetadata.icon ? widgetMetadata.icon : "")) !== ""
}
NButton {
text: I18n.tr("common.browse")
onClicked: iconPicker.open()
}
}
NIconPicker {
id: iconPicker
initialIcon: _settings.icon
onIconSelected: function (iconName) {
_settings.icon = iconName;
saveSettings();
}
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.general-tooltip-text-label")
description: I18n.tr("bar.custom-button.general-tooltip-text-description")
placeholderText: I18n.tr("placeholders.enter-tooltip")
text: _settings.generalTooltipText
onTextChanged: {
_settings.generalTooltipText = text;
saveSettings();
}
defaultValue: widgetMetadata.generalTooltipText
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.show-exec-tooltip-label")
description: I18n.tr("bar.custom-button.show-exec-tooltip-description")
checked: _settings.showExecTooltip
onToggled: checked => {
_settings.showExecTooltip = checked;
saveSettings();
}
defaultValue: widgetMetadata.showExecTooltip
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.left-click-label")
description: I18n.tr("bar.custom-button.left-click-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: _settings.onClicked
onTextChanged: {
_settings.onClicked = text;
saveSettings();
}
defaultValue: widgetMetadata.onClicked
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.right-click-label")
description: I18n.tr("bar.custom-button.right-click-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: _settings.onRightClicked
onTextChanged: {
_settings.onRightClicked = text;
saveSettings();
}
defaultValue: widgetMetadata.onRightClicked
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("bar.custom-button.middle-click-label")
description: I18n.tr("bar.custom-button.middle-click-description")
placeholderText: I18n.tr("placeholders.enter-command")
text: _settings.onMiddleClicked
onTextChanged: {
_settings.onMiddleClicked = text;
saveSettings();
}
defaultValue: widgetMetadata.onMiddleClicked
}
NDivider {}
NToggle {
id: enableOnStateLogicToggle
Layout.fillWidth: true
label: I18n.tr("panels.control-center.shortcuts-custom-button-enable-on-state-logic-label")
description: I18n.tr("panels.control-center.shortcuts-custom-button-enable-on-state-logic-description")
checked: _settings.enableOnStateLogic
onToggled: checked => {
_settings.enableOnStateLogic = checked;
saveSettings();
}
defaultValue: widgetMetadata.enableOnStateLogic
}
ColumnLayout {
Layout.fillWidth: true
visible: root.rootSettings && root.rootSettings.enableOnStateLogic
spacing: Style?.marginM ?? 8
NLabel {
label: I18n.tr("panels.control-center.shortcuts-custom-button-state-checks-label")
}
Repeater {
model: root.rootSettings ? root.rootSettings._stateChecksListModel : null
delegate: Item {
property int currentIndex: index
implicitHeight: contentRow.implicitHeight
Layout.fillWidth: true
RowLayout {
id: contentRow
anchors.fill: parent
spacing: Style?.marginM ?? 8
NTextInput {
Layout.fillWidth: true
placeholderText: I18n.tr("panels.control-center.shortcuts-custom-button-state-checks-command")
text: model.command
onTextChanged: {
updateStateCheck(currentIndex, text, model.icon);
}
}
RowLayout {
Layout.alignment: Qt.AlignVCenter
spacing: Style?.marginS ?? 4
NIcon {
icon: model.icon
pointSize: Style?.fontSizeL ?? 20
visible: model.icon !== undefined && model.icon !== ""
}
NIconButton {
icon: "folder"
tooltipText: I18n.tr("common.browse")
baseSize: Style?.buttonSizeS ?? 24
onClicked: iconPickerDelegate.open()
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("panels.control-center.shortcuts-custom-button-state-checks-remove")
baseSize: Style?.buttonSizeS ?? 24
colorBorder: Qt.alpha(Color.mOutline, Style.opacityLight)
colorBg: Color.mError
colorFg: Color.mOnError
colorBgHover: Qt.alpha(Color.mError, Style.opacityMedium)
colorFgHover: Color.mOnError
onClicked: {
removeStateCheck(currentIndex);
}
}
}
}
NIconPicker {
id: iconPickerDelegate
initialIcon: model.icon
onIconSelected: function (iconName) {
updateStateCheck(currentIndex, model.command, iconName);
}
}
}
}
Item {
Layout.fillWidth: true
}
RowLayout {
Layout.fillWidth: true
spacing: Style?.marginM ?? 8
NButton {
text: I18n.tr("panels.control-center.shortcuts-custom-button-state-checks-add")
onClicked: addStateCheck()
}
}
}
}
@@ -0,0 +1,276 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
import qs.Widgets
Popup {
id: root
property int widgetIndex: -1
property var widgetData: null
property string widgetId: ""
property string sectionId: "" // Not used for desktop widgets, but required by NSectionEditor
property var screen: null
property var settingsCache: ({})
readonly property real maxHeight: (screen ? screen.height : (parent ? parent.height : 800)) * 0.8
signal updateWidgetSettings(string section, int index, var settings)
width: Math.max(content.implicitWidth + padding * 2, 500)
height: Math.min(content.implicitHeight + padding * 2, maxHeight)
padding: Style.marginXL
modal: true
closePolicy: Popup.NoAutoClose
dim: false
// Center in parent
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
onOpened: {
if (widgetData && widgetId) {
loadWidgetSettings();
}
forceActiveFocus();
}
background: Rectangle {
id: bgRect
color: Color.mSurface
radius: Style.radiusL
border.color: Color.mPrimary
border.width: Style.borderM
}
contentItem: FocusScope {
id: focusScope
focus: true
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.marginM
RowLayout {
id: titleRow
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight
NText {
text: I18n.tr("system.widget-settings-title", {
"widget": DesktopWidgetRegistry.getWidgetDisplayName(root.widgetId)
})
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
onClicked: saveAndClose()
}
}
Rectangle {
id: separator
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Color.mOutline
}
// Scrollable settings area
NScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 100
gradientColor: Color.mSurface
ColumnLayout {
width: scrollView.availableWidth
spacing: Style.marginM
Loader {
id: settingsLoader
Layout.fillWidth: true
onLoaded: {
if (item) {
Qt.callLater(() => {
var firstInput = findFirstFocusable(item);
if (firstInput) {
firstInput.forceActiveFocus();
} else {
focusScope.forceActiveFocus();
}
});
}
}
function findFirstFocusable(item) {
if (!item)
return null;
if (item.focus !== undefined && item.focus === true)
return item;
if (item.children) {
for (var i = 0; i < item.children.length; i++) {
var child = item.children[i];
if (child && child.focus !== undefined && child.focus === true)
return child;
var found = findFirstFocusable(child);
if (found)
return found;
}
}
return null;
}
}
}
}
}
}
// Mouse area for a draggable popup
MouseArea {
x: titleRow.x
y: titleRow.y
width: titleRow.width
height: titleRow.height
z: -1
cursorShape: Qt.OpenHandCursor
property real pressX: 0
property real pressY: 0
onPressed: mouse => {
pressX = mouse.x;
pressY = mouse.y;
cursorShape = Qt.ClosedHandCursor;
}
onReleased: {
cursorShape = Qt.OpenHandCursor;
}
onPositionChanged: mouse => {
if (pressed) {
var deltaX = mouse.x - pressX;
var deltaY = mouse.y - pressY;
root.x += deltaX;
root.y += deltaY;
}
}
}
Timer {
id: saveTimer
running: false
interval: 150
onTriggered: {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, root.settingsCache);
}
}
Connections {
target: settingsLoader.item
ignoreUnknownSignals: true
function onSettingsChanged(newSettings) {
if (newSettings) {
root.settingsCache = newSettings;
saveTimer.start();
}
}
function onSettingsSaved(newSettings) {
if (newSettings) {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, newSettings);
}
}
}
function saveAndClose() {
if (settingsLoader.item && typeof settingsLoader.item.saveSettings === 'function') {
var newSettings = settingsLoader.item.saveSettings();
if (newSettings) {
root.updateWidgetSettings(root.sectionId, root.widgetIndex, newSettings);
}
}
root.close();
}
function loadWidgetSettings() {
// Handle plugin widgets
if (DesktopWidgetRegistry.isPluginWidget(widgetId)) {
var pluginId = widgetId.replace("plugin:", "");
var manifest = PluginRegistry.getPluginManifest(pluginId);
var pluginDir = PluginRegistry.getPluginDir(pluginId);
var loadVersion = PluginRegistry.pluginLoadVersions[pluginId] || 0;
var api = PluginService.getPluginAPI(pluginId);
var settingsPath;
if (manifest && manifest.entryPoints && manifest.entryPoints.desktopWidgetSettings) {
settingsPath = "file://" + pluginDir + "/" + manifest.entryPoints.desktopWidgetSettings;
var widgetSettings = {};
widgetSettings.data = widgetData || {};
widgetSettings.metadata = DesktopWidgetRegistry.widgetMetadata[widgetId] || {};
widgetSettings.save = function () {
var newSettings = Object.assign({}, widgetSettings.data);
root.settingsCache = newSettings;
saveTimer.start();
};
settingsLoader.setSource(settingsPath + "?v=" + loadVersion, {
"pluginApi": api,
"widgetSettings": widgetSettings
});
} else {
Logger.w("DesktopWidgetSettingsDialog", "Plugin does not have desktop widget settings:", pluginId);
// Fallback to the plugin settings
if (manifest && manifest.entryPoints && manifest.entryPoints.settings) {
settingsPath = "file://" + pluginDir + "/" + manifest.entryPoints.settings;
settingsLoader.setSource(settingsPath + "?v=" + loadVersion, {
"pluginApi": api
});
} else {
Logger.w("DesktopWidgetSettingsDialog", "Plugin does not have settings:", pluginId);
}
}
return;
}
// Handle core widgets
const source = DesktopWidgetRegistry.widgetSettingsMap[widgetId];
if (source) {
var currentWidgetData = widgetData;
var monitorWidgets = Settings.data.desktopWidgets.monitorWidgets || [];
for (var i = 0; i < monitorWidgets.length; i++) {
if (monitorWidgets[i].name === sectionId) {
var widgets = monitorWidgets[i].widgets || [];
if (widgetIndex >= 0 && widgetIndex < widgets.length) {
currentWidgetData = widgets[widgetIndex];
}
break;
}
}
var fullPath = Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/DesktopWidgets/" + source);
settingsLoader.setSource(fullPath, {
"widgetData": currentWidgetData,
"widgetMetadata": DesktopWidgetRegistry.widgetMetadata[widgetId]
});
}
}
}
@@ -0,0 +1,154 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property int valueWidth: widgetData.width !== undefined ? widgetData.width : widgetMetadata.width
property int valueHeight: widgetData.height !== undefined ? widgetData.height : widgetMetadata.height
property string valueVisualizerType: widgetData.visualizerType !== undefined ? widgetData.visualizerType : widgetMetadata.visualizerType
property string valueColorName: widgetData.colorName !== undefined ? widgetData.colorName : widgetMetadata.colorName
property bool valueHideWhenIdle: widgetData.hideWhenIdle !== undefined ? widgetData.hideWhenIdle : widgetMetadata.hideWhenIdle
property bool valueShowBackground: widgetData.showBackground !== undefined ? widgetData.showBackground : widgetMetadata.showBackground
property bool valueRoundedCorners: widgetData.roundedCorners !== undefined ? widgetData.roundedCorners : widgetMetadata.roundedCorners
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.width = valueWidth;
settings.height = valueHeight;
settings.visualizerType = valueVisualizerType;
settings.colorName = valueColorName;
settings.hideWhenIdle = valueHideWhenIdle;
settings.showBackground = valueShowBackground;
settings.roundedCorners = valueRoundedCorners;
settingsChanged(settings);
}
NTextInput {
id: widthInput
Layout.fillWidth: true
label: I18n.tr("common.width")
description: I18n.tr("bar.audio-visualizer.width-description")
text: String(valueWidth)
placeholderText: I18n.tr("placeholders.enter-width-pixels")
inputMethodHints: Qt.ImhDigitsOnly
onEditingFinished: {
const parsed = parseInt(text);
if (!isNaN(parsed) && parsed > 0) {
valueWidth = parsed;
saveSettings();
} else {
text = String(valueWidth);
}
}
defaultValue: String(widgetMetadata.width)
}
NTextInput {
id: heightInput
Layout.fillWidth: true
label: I18n.tr("common.height")
description: I18n.tr("bar.audio-visualizer.height-description")
text: String(valueHeight)
placeholderText: I18n.tr("placeholders.enter-width-pixels")
inputMethodHints: Qt.ImhDigitsOnly
onEditingFinished: {
const parsed = parseInt(text);
if (!isNaN(parsed) && parsed > 0) {
valueHeight = parsed;
saveSettings();
} else {
text = String(valueHeight);
}
}
defaultValue: String(widgetMetadata.height)
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.audio.visualizer-type-label")
description: I18n.tr("panels.desktop-widgets.media-player-visualizer-type-description")
model: [
{
"key": "linear",
"name": I18n.tr("options.visualizer-types.linear")
},
{
"key": "mirrored",
"name": I18n.tr("options.visualizer-types.mirrored")
},
{
"key": "wave",
"name": I18n.tr("options.visualizer-types.wave")
}
]
currentKey: valueVisualizerType
onSelected: key => {
valueVisualizerType = key;
saveSettings();
}
defaultValue: widgetMetadata.visualizerType
}
NColorChoice {
Layout.fillWidth: true
label: I18n.tr("bar.audio-visualizer.color-name-label")
description: I18n.tr("bar.audio-visualizer.color-name-description")
currentKey: valueColorName
onSelected: key => {
valueColorName = key;
saveSettings();
}
defaultValue: widgetMetadata.colorName
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.audio-visualizer.hide-when-idle-label")
description: I18n.tr("bar.audio-visualizer.hide-when-idle-description")
checked: valueHideWhenIdle
onToggled: checked => {
valueHideWhenIdle = checked;
saveSettings();
}
defaultValue: widgetMetadata.hideWhenIdle
}
NDivider {
Layout.fillWidth: true
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-show-background-label")
description: I18n.tr("panels.desktop-widgets.media-player-show-background-description")
checked: valueShowBackground
onToggled: checked => {
valueShowBackground = checked;
saveSettings();
}
defaultValue: widgetMetadata.showBackground
}
NToggle {
Layout.fillWidth: true
visible: valueShowBackground
label: I18n.tr("panels.desktop-widgets.clock-rounded-corners-label")
description: I18n.tr("panels.desktop-widgets.media-player-rounded-corners-description")
checked: valueRoundedCorners
onToggled: checked => {
valueRoundedCorners = checked;
saveSettings();
}
defaultValue: widgetMetadata.roundedCorners
}
}
@@ -0,0 +1,293 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.System
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
width: 700
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property bool valueShowBackground: widgetData.showBackground !== undefined ? widgetData.showBackground : widgetMetadata.showBackground
property bool valueRoundedCorners: widgetData.roundedCorners !== undefined ? widgetData.roundedCorners : widgetMetadata.roundedCorners
property string valueClockStyle: widgetData.clockStyle !== undefined ? widgetData.clockStyle : widgetMetadata.clockStyle
property string valueClockColor: widgetData.clockColor !== undefined ? widgetData.clockColor : widgetMetadata.clockColor
property bool valueUseCustomFont: widgetData.useCustomFont !== undefined ? widgetData.useCustomFont : widgetMetadata.useCustomFont
property string valueCustomFont: widgetData.customFont !== undefined ? widgetData.customFont : widgetMetadata.custonFont
property string valueFormat: widgetData.format !== undefined ? widgetData.format : widgetMetadata.format
// Track the currently focused input field
property var focusedInput: null
readonly property bool isMinimalMode: valueClockStyle === "minimal"
readonly property var now: Time.now
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.showBackground = valueShowBackground;
settings.roundedCorners = valueRoundedCorners;
settings.clockStyle = valueClockStyle;
settings.clockColor = valueClockColor;
settings.useCustomFont = valueUseCustomFont;
settings.customFont = valueCustomFont;
settings.format = valueFormat.trim();
settingsChanged(settings);
}
// Function to insert token at cursor position in the focused input
function insertToken(token) {
if (!focusedInput || !focusedInput.inputItem) {
// If no input is focused, default to format input
if (formatInput.inputItem) {
formatInput.inputItem.focus = true;
focusedInput = formatInput;
}
}
if (focusedInput && focusedInput.inputItem) {
var input = focusedInput.inputItem;
var cursorPos = input.cursorPosition;
var currentText = input.text;
// Insert token at cursor position
var newText = currentText.substring(0, cursorPos) + token + currentText.substring(cursorPos);
input.text = newText + " ";
// Move cursor after the inserted token
input.cursorPosition = cursorPos + token.length + 1;
// Ensure the input keeps focus
input.focus = true;
saveSettings();
}
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-style-label")
description: I18n.tr("panels.desktop-widgets.clock-style-description")
currentKey: valueClockStyle
minimumWidth: 260 * Style.uiScaleRatio
model: [
{
"key": "minimal",
"name": I18n.tr("panels.desktop-widgets.clock-style-minimal")
},
{
"key": "digital",
"name": I18n.tr("panels.desktop-widgets.clock-style-digital")
},
{
"key": "analog",
"name": I18n.tr("panels.desktop-widgets.clock-style-analog")
},
{
"key": "binary",
"name": I18n.tr("panels.desktop-widgets.clock-style-binary")
}
]
onSelected: key => {
valueClockStyle = key;
saveSettings();
}
defaultValue: widgetMetadata.clockStyle
}
NColorChoice {
label: I18n.tr("common.select-color")
description: I18n.tr("common.select-color-description")
currentKey: valueClockColor
onSelected: key => {
valueClockColor = key;
saveSettings();
}
defaultValue: widgetMetadata.clockColor
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.clock.use-custom-font-label")
description: I18n.tr("bar.clock.use-custom-font-description")
checked: valueUseCustomFont
onToggled: checked => {
valueUseCustomFont = checked;
saveSettings();
}
defaultValue: widgetMetadata.useCustomFont
}
NSearchableComboBox {
Layout.fillWidth: true
visible: valueUseCustomFont
label: I18n.tr("bar.clock.custom-font-label")
description: I18n.tr("bar.clock.custom-font-description")
model: FontService.availableFonts
currentKey: valueCustomFont
placeholder: I18n.tr("bar.clock.custom-font-placeholder")
searchPlaceholder: I18n.tr("bar.clock.custom-font-search-placeholder")
popupHeight: 420
minimumWidth: 300
onSelected: function (key) {
valueCustomFont = key;
saveSettings();
}
enabled: valueClockStyle === "minimal"
defaultValue: Settings.data.ui.fontDefault
}
NDivider {
Layout.fillWidth: true
visible: isMinimalMode
}
NHeader {
visible: isMinimalMode
label: I18n.tr("bar.clock.clock-display-label")
description: I18n.tr("bar.clock.clock-display-description")
}
// Format editor - only visible in minimal mode
RowLayout {
id: main
visible: isMinimalMode
spacing: Style.marginL
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
ColumnLayout {
spacing: Style.marginM
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
NTextInput {
id: formatInput
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-format-label")
description: I18n.tr("bar.clock.horizontal-bar-description")
placeholderText: "HH:mm\\nd MMMM yyyy"
text: valueFormat
onTextChanged: {
valueFormat = text;
settingsChanged(saveSettings());
}
Component.onCompleted: {
if (inputItem) {
inputItem.onActiveFocusChanged.connect(function () {
if (inputItem.activeFocus) {
root.focusedInput = formatInput;
}
});
}
}
defaultValue: widgetMetadata.format
}
}
// Preview
ColumnLayout {
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
Layout.fillWidth: false
NLabel {
label: I18n.tr("bar.clock.preview")
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
}
Rectangle {
Layout.preferredWidth: 320
Layout.preferredHeight: 160
color: Color.mSurfaceVariant
radius: Style.radiusM
border.color: Color.mSecondary
border.width: Style.borderS
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
ColumnLayout {
spacing: Style.marginM
anchors.centerIn: parent
ColumnLayout {
spacing: -2
Layout.alignment: Qt.AlignHCenter
Repeater {
Layout.topMargin: Style.marginM
model: I18n.locale.toString(now, valueFormat.trim()).split("\\n")
delegate: NText {
visible: text !== ""
text: modelData
family: valueUseCustomFont && valueCustomFont ? valueCustomFont : Settings.data.ui.fontDefault
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: Color.resolveColorKey(valueClockColor)
wrapMode: Text.WordWrap
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}
}
}
NDivider {
Layout.topMargin: Style.marginM
Layout.bottomMargin: Style.marginM
visible: isMinimalMode
}
NDateTimeTokens {
Layout.fillWidth: true
height: 200
visible: isMinimalMode
onTokenClicked: token => root.insertToken(token)
}
NDivider {
Layout.fillWidth: true
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-show-background-label")
description: I18n.tr("panels.desktop-widgets.clock-show-background-description")
checked: valueShowBackground
onToggled: checked => {
valueShowBackground = checked;
saveSettings();
}
defaultValue: widgetMetadata.showBackground
}
NToggle {
Layout.fillWidth: true
visible: valueShowBackground
label: I18n.tr("panels.desktop-widgets.clock-rounded-corners-label")
description: I18n.tr("panels.desktop-widgets.clock-rounded-corners-description")
checked: valueRoundedCorners
onToggled: checked => {
valueRoundedCorners = checked;
saveSettings();
}
defaultValue: widgetMetadata.roundedCorners
}
}
@@ -0,0 +1,148 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property bool valueShowBackground: widgetData.showBackground !== undefined ? widgetData.showBackground : widgetMetadata.showBackground
property string valueVisualizerType: widgetData.visualizerType ? widgetData.visualizerType : widgetMetadata.visualizerType
property string valueHideMode: widgetData.hideMode !== undefined ? widgetData.hideMode : widgetMetadata.hideMode
property bool valueShowButtons: widgetData.showButtons !== undefined ? widgetData.showButtons : widgetMetadata.showButtons
property bool valueShowAlbumArt: widgetData.showAlbumArt !== undefined ? widgetData.showAlbumArt : widgetMetadata.showAlbumArt
property bool valueShowVisualizer: widgetData.showVisualizer !== undefined ? widgetData.showVisualizer : widgetMetadata.showVisualizer
property bool valueRoundedCorners: widgetData.roundedCorners !== undefined ? widgetData.roundedCorners : widgetMetadata.roundedCorners
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.showBackground = valueShowBackground;
settings.visualizerType = valueVisualizerType;
settings.hideMode = valueHideMode;
settings.showButtons = valueShowButtons;
settings.showAlbumArt = valueShowAlbumArt;
settings.showVisualizer = valueShowVisualizer;
settings.roundedCorners = valueRoundedCorners;
settingsChanged(settings);
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-show-background-label")
description: I18n.tr("panels.desktop-widgets.media-player-show-background-description")
checked: valueShowBackground
onToggled: checked => {
valueShowBackground = checked;
saveSettings();
}
defaultValue: widgetMetadata.showBackground
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-rounded-corners-label")
description: I18n.tr("panels.desktop-widgets.media-player-rounded-corners-description")
checked: valueRoundedCorners
onToggled: checked => {
valueRoundedCorners = checked;
saveSettings();
}
defaultValue: widgetMetadata.roundedCorners
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.media-player-show-album-art-label")
description: I18n.tr("panels.desktop-widgets.media-player-show-album-art-description")
checked: valueShowAlbumArt
onToggled: checked => {
valueShowAlbumArt = checked;
saveSettings();
}
defaultValue: widgetMetadata.showAlbumArt
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("bar.media-mini.show-visualizer-label")
description: I18n.tr("panels.desktop-widgets.media-player-show-visualizer-description")
checked: valueShowVisualizer
onToggled: checked => {
valueShowVisualizer = checked;
saveSettings();
}
defaultValue: widgetMetadata.showVisualizer
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.media-player-show-buttons-label")
description: I18n.tr("panels.desktop-widgets.media-player-show-buttons-description")
checked: valueShowButtons
onToggled: checked => {
valueShowButtons = checked;
saveSettings();
}
defaultValue: widgetMetadata.showButtons
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.audio.visualizer-type-label")
description: I18n.tr("panels.desktop-widgets.media-player-visualizer-type-description")
enabled: valueShowVisualizer
model: [
{
"key": "linear",
"name": I18n.tr("options.visualizer-types.linear")
},
{
"key": "mirrored",
"name": I18n.tr("options.visualizer-types.mirrored")
},
{
"key": "wave",
"name": I18n.tr("options.visualizer-types.wave")
}
]
currentKey: valueVisualizerType
onSelected: key => {
valueVisualizerType = key;
saveSettings();
}
defaultValue: widgetMetadata.visualizerType
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("bar.taskbar.hide-mode-label")
description: I18n.tr("bar.media-mini.hide-mode-description")
model: [
{
"key": "hidden",
"name": I18n.tr("hide-modes.hidden")
},
{
"key": "idle",
"name": I18n.tr("hide-modes.idle")
},
{
"key": "visible",
"name": I18n.tr("hide-modes.visible")
}
]
currentKey: valueHideMode
onSelected: key => {
valueHideMode = key;
saveSettings();
}
defaultValue: widgetMetadata.hideMode
}
}
@@ -0,0 +1,148 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.System
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
width: 700
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property string valueStatType: widgetData.statType !== undefined ? widgetData.statType : widgetMetadata.statType
property string valueDiskPath: widgetData.diskPath !== undefined ? widgetData.diskPath : widgetMetadata.diskPath
property bool valueShowBackground: widgetData.showBackground !== undefined ? widgetData.showBackground : widgetMetadata.showBackground
property bool valueRoundedCorners: widgetData.roundedCorners !== undefined ? widgetData.roundedCorners : widgetMetadata.roundedCorners
property string valueLayout: widgetData.layout !== undefined ? widgetData.layout : widgetMetadata.layout
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.statType = valueStatType;
settings.diskPath = valueDiskPath;
settings.showBackground = valueShowBackground;
settings.roundedCorners = valueRoundedCorners;
settings.layout = valueLayout;
settingsChanged(settings);
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.system-stat-stat-type-label")
description: I18n.tr("panels.desktop-widgets.system-stat-stat-type-description")
currentKey: valueStatType
minimumWidth: 260 * Style.uiScaleRatio
model: {
let items = [
{
"key": "CPU",
"name": I18n.tr("system-monitor.cpu-usage")
},
{
"key": "Memory",
"name": I18n.tr("common.memory")
},
{
"key": "Network",
"name": I18n.tr("bar.system-monitor.network-traffic-label")
},
{
"key": "Disk",
"name": I18n.tr("system-monitor.disk")
}
];
if (Settings.data.systemMonitor.enableDgpuMonitoring)
items.push({
"key": "GPU",
"name": I18n.tr("panels.system-monitor.gpu-section-label")
});
return items;
}
onSelected: key => {
valueStatType = key;
saveSettings();
}
defaultValue: widgetMetadata.statType
}
NComboBox {
Layout.fillWidth: true
visible: valueStatType === "Disk"
label: I18n.tr("bar.system-monitor.disk-path-label")
description: I18n.tr("bar.system-monitor.disk-path-description")
model: {
const paths = Object.keys(SystemStatService.diskPercents).sort();
return paths.map(path => ({
key: path,
name: path
}));
}
currentKey: valueDiskPath
onSelected: key => {
valueDiskPath = key;
saveSettings();
}
defaultValue: widgetMetadata.diskPath
}
NDivider {
Layout.fillWidth: true
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.system-stat-show-background-label")
description: I18n.tr("panels.desktop-widgets.system-stat-show-background-description")
checked: valueShowBackground
onToggled: checked => {
valueShowBackground = checked;
saveSettings();
}
defaultValue: widgetMetadata.showBackground
}
NToggle {
Layout.fillWidth: true
visible: valueShowBackground
label: I18n.tr("panels.desktop-widgets.system-stat-rounded-corners-label")
description: I18n.tr("panels.desktop-widgets.system-stat-rounded-corners-description")
checked: valueRoundedCorners
onToggled: checked => {
valueRoundedCorners = checked;
saveSettings();
}
defaultValue: widgetMetadata.roundedCorners
}
NDivider {
Layout.fillWidth: true
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.system-stat-layout-label")
description: I18n.tr("panels.desktop-widgets.system-stat-layout-description")
currentKey: valueLayout
minimumWidth: 260 * Style.uiScaleRatio
model: [
{
"key": "side",
"name": I18n.tr("panels.desktop-widgets.system-stat-layout-side")
},
{
"key": "bottom",
"name": I18n.tr("panels.desktop-widgets.system-stat-layout-bottom")
}
]
onSelected: key => {
valueLayout = key;
saveSettings();
}
defaultValue: widgetMetadata.layout
}
}
@@ -0,0 +1,50 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginM
property var widgetData: null
property var widgetMetadata: null
signal settingsChanged(var settings)
property bool valueShowBackground: widgetData.showBackground !== undefined ? widgetData.showBackground : widgetMetadata.showBackground
property bool valueRoundedCorners: widgetData.roundedCorners !== undefined ? widgetData.roundedCorners : widgetMetadata.roundedCorners
function saveSettings() {
var settings = Object.assign({}, widgetData || {});
settings.showBackground = valueShowBackground;
settings.roundedCorners = valueRoundedCorners;
settingsChanged(settings);
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.desktop-widgets.clock-show-background-label")
description: I18n.tr("panels.desktop-widgets.weather-show-background-description")
checked: valueShowBackground
onToggled: checked => {
valueShowBackground = checked;
saveSettings();
}
defaultValue: widgetMetadata.showBackground
}
NToggle {
Layout.fillWidth: true
visible: valueShowBackground
label: I18n.tr("panels.desktop-widgets.clock-rounded-corners-label")
description: I18n.tr("panels.desktop-widgets.clock-rounded-corners-description")
checked: valueRoundedCorners
onToggled: checked => {
valueRoundedCorners = checked;
saveSettings();
}
defaultValue: widgetMetadata.roundedCorners
}
}
@@ -0,0 +1,290 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(840 * Style.uiScaleRatio)
preferredHeight: Math.round(910 * Style.uiScaleRatio)
// Settings panel mode: "centered", "attached", "window"
readonly property string settingsPanelMode: Settings.data.ui.settingsPanelMode
readonly property bool isWindowMode: settingsPanelMode === "window"
readonly property bool attachToBar: settingsPanelMode === "attached"
readonly property string barDensity: Settings.data.bar.density
readonly property string barPosition: Settings.getBarPositionForScreen(screen?.name)
readonly property bool barFloating: Settings.data.bar.barType === "floating"
readonly property real barMarginH: barFloating ? Math.ceil(Settings.data.bar.marginHorizontal) : 0
readonly property real barMarginV: barFloating ? Math.ceil(Settings.data.bar.marginVertical) : 0
forceAttachToBar: attachToBar
panelAnchorHorizontalCenter: !root.useButtonPosition && (attachToBar ? (barPosition === "top" || barPosition === "bottom") : true)
panelAnchorVerticalCenter: !root.useButtonPosition && (attachToBar ? (barPosition === "left" || barPosition === "right") : true)
panelAnchorTop: !root.useButtonPosition && attachToBar && barPosition === "top"
panelAnchorBottom: !root.useButtonPosition && attachToBar && barPosition === "bottom"
panelAnchorLeft: !root.useButtonPosition && attachToBar && barPosition === "left"
panelAnchorRight: !root.useButtonPosition && attachToBar && barPosition === "right"
onAttachToBarChanged: {
if (isPanelOpen) {
Qt.callLater(root.setPosition);
}
}
onBarPositionChanged: {
if (isPanelOpen) {
Qt.callLater(root.setPosition);
}
}
onBarDensityChanged: {
if (isPanelOpen) {
Qt.callLater(root.setPosition);
}
}
onBarFloatingChanged: {
if (isPanelOpen) {
Qt.callLater(root.setPosition);
}
}
onBarMarginHChanged: {
if (isPanelOpen) {
Qt.callLater(root.setPosition);
}
}
onBarMarginVChanged: {
if (isPanelOpen) {
Qt.callLater(root.setPosition);
}
}
// Tabs enumeration, order is NOT relevant
enum Tab {
About,
Audio,
Bar,
ColorScheme,
LockScreen,
ControlCenter,
DesktopWidgets,
OSD,
Display,
Dock,
General,
Hooks,
Idle,
Launcher,
Location,
Connections,
Notifications,
Plugins,
SessionMenu,
System,
UserInterface,
Wallpaper
}
property int requestedTab: SettingsPanel.Tab.General
property int requestedSubTab: -1
property var requestedEntry: null
// Content state - these are synced with SettingsContent when panel opens
property int currentTabIndex: 0
property var tabsModel: []
property var activeScrollView: null
// Internal reference to the content (set when panel content loads)
property var _settingsContent: null
// Override toggle to handle window mode
function toggle(buttonItem, buttonName) {
if (isWindowMode) {
SettingsPanelService.toggleWindow(requestedTab);
return;
}
// Call parent toggle
if (isPanelOpen) {
close();
} else {
open(buttonItem, buttonName);
}
}
// Override open to handle window mode
function open(buttonItem, buttonName) {
if (isWindowMode) {
SettingsPanelService.openWindow(requestedTab);
return;
}
// Panel mode: replicate SmartPanel.open() logic
if (!buttonItem && buttonName) {
if (typeof buttonName === "object" && buttonName.x !== undefined && buttonName.y !== undefined) {
root.buttonItem = null;
root.buttonPosition = buttonName;
root.buttonWidth = 0;
root.buttonHeight = 0;
root.useButtonPosition = true;
} else {
buttonItem = BarService.lookupWidget(buttonName, screen.name);
}
}
if (buttonItem) {
root.buttonItem = buttonItem;
var buttonPos = buttonItem.mapToItem(null, 0, 0);
root.buttonPosition = Qt.point(buttonPos.x, buttonPos.y);
root.buttonWidth = buttonItem.width;
root.buttonHeight = buttonItem.height;
root.useButtonPosition = true;
} else if (!(buttonName && typeof buttonName === "object" && buttonName.x !== undefined && buttonName.y !== undefined)) {
root.buttonItem = null;
root.useButtonPosition = false;
}
isPanelOpen = true;
PanelService.willOpenPanel(root);
}
// Open to a specific tab and optionally a subtab
function openToTab(tab, subTab, buttonItem, buttonName) {
requestedTab = tab !== undefined ? tab : SettingsPanel.Tab.General;
requestedSubTab = subTab !== undefined ? subTab : -1;
open(buttonItem, buttonName);
}
// When the panel opens, initialize content
onOpened: {
if (_settingsContent) {
if (requestedEntry) {
_settingsContent.requestedTab = requestedEntry.tab;
_settingsContent.initialize();
const entry = requestedEntry;
requestedEntry = null;
Qt.callLater(() => _settingsContent.navigateToResult(entry));
} else {
_settingsContent.requestedTab = requestedTab;
if (requestedSubTab >= 0) {
_settingsContent._pendingSubTab = requestedSubTab;
requestedSubTab = -1;
}
_settingsContent.initialize();
}
}
}
// Scroll functions - delegate to content
function scrollDown() {
if (_settingsContent)
_settingsContent.scrollDown();
}
function scrollUp() {
if (_settingsContent)
_settingsContent.scrollUp();
}
function scrollPageDown() {
if (_settingsContent)
_settingsContent.scrollPageDown();
}
function scrollPageUp() {
if (_settingsContent)
_settingsContent.scrollPageUp();
}
// Navigation functions - delegate to content
function selectNextTab() {
if (_settingsContent)
_settingsContent.selectNextTab();
}
function selectPreviousTab() {
if (_settingsContent)
_settingsContent.selectPreviousTab();
}
// Override keyboard handlers from SmartPanel
function onTabPressed() {
selectNextTab();
}
function onBackTabPressed() {
selectPreviousTab();
}
function onUpPressed() {
if (_settingsContent && _settingsContent.searchText.trim() !== "") {
_settingsContent.searchSelectPrevious();
} else {
scrollUp();
}
}
function onDownPressed() {
if (_settingsContent && _settingsContent.searchText.trim() !== "") {
_settingsContent.searchSelectNext();
} else {
scrollDown();
}
}
function onPageUpPressed() {
scrollPageUp();
}
function onPageDownPressed() {
scrollPageDown();
}
function onCtrlJPressed() {
if (_settingsContent && _settingsContent.searchText.trim() !== "") {
_settingsContent.searchSelectNext();
} else {
scrollDown();
}
}
function onCtrlKPressed() {
if (_settingsContent && _settingsContent.searchText.trim() !== "") {
_settingsContent.searchSelectPrevious();
} else {
scrollUp();
}
}
panelContent: Rectangle {
id: panelContent
color: "transparent"
SettingsContent {
id: settingsContent
anchors.fill: parent
screen: root.screen
onCloseRequested: root.close()
Component.onCompleted: {
root._settingsContent = settingsContent;
root.tabsModel = Qt.binding(function () {
return settingsContent.tabsModel;
});
root.currentTabIndex = Qt.binding(function () {
return settingsContent.currentTabIndex;
});
root.activeScrollView = Qt.binding(function () {
return settingsContent.activeScrollView;
});
}
}
}
}
@@ -0,0 +1,130 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services.UI
import qs.Widgets
FloatingWindow {
id: root
title: "Noctalia"
minimumSize: Qt.size(840 * Style.uiScaleRatio, 910 * Style.uiScaleRatio)
implicitWidth: Math.round(840 * Style.uiScaleRatio)
implicitHeight: Math.round(910 * Style.uiScaleRatio)
color: "transparent"
visible: false
// Register with SettingsPanelService
Component.onCompleted: {
SettingsPanelService.settingsWindow = root;
}
property bool isInitialized: false
// Navigate to a specific tab and optional subtab.
// Works whether the window is already visible or just becoming visible.
function navigateTo(tab, subTab) {
const tabId = tab !== undefined ? tab : 0;
const subTabId = (subTab !== undefined && subTab !== null && subTab >= 0) ? subTab : -1;
if (isInitialized) {
settingsContent.navigateToTab(tabId, subTabId);
} else {
settingsContent.requestedTab = tabId;
if (subTabId >= 0)
settingsContent._pendingSubTab = subTabId;
settingsContent.initialize();
isInitialized = true;
// Tab content persists in window mode; if no subtab specified and the
// tab content is still loaded (same tab), reset to first subtab
if (subTabId < 0 && settingsContent.activeTabContent)
settingsContent.setSubTabIndex(0);
}
}
// Navigate to a search result entry.
// Works whether the window is already visible or just becoming visible.
function navigateToEntry(entry) {
if (isInitialized) {
Qt.callLater(() => settingsContent.navigateToResult(entry));
} else {
settingsContent.requestedTab = entry.tab;
settingsContent.initialize();
Qt.callLater(() => settingsContent.navigateToResult(entry));
isInitialized = true;
}
}
// Sync visibility with service
onVisibleChanged: {
if (visible) {
SettingsPanelService.isWindowOpen = true;
} else {
isInitialized = false;
SettingsPanelService.isWindowOpen = false;
}
}
// Keyboard shortcuts
Shortcut {
sequence: "Escape"
enabled: !PanelService.isKeybindRecording
onActivated: SettingsPanelService.closeWindow()
}
Shortcut {
sequence: "Tab"
enabled: !PanelService.isKeybindRecording
onActivated: settingsContent.selectNextTab()
}
Shortcut {
sequence: "Backtab"
enabled: !PanelService.isKeybindRecording
onActivated: settingsContent.selectPreviousTab()
}
Instantiator {
model: Settings.data.general.keybinds.keyUp || []
Shortcut {
sequence: modelData
enabled: !PanelService.isKeybindRecording
onActivated: {
if (settingsContent.searchText.trim() !== "")
settingsContent.searchSelectPrevious();
else
settingsContent.scrollUp();
}
}
}
Instantiator {
model: Settings.data.general.keybinds.keyDown || []
Shortcut {
sequence: modelData
enabled: !PanelService.isKeybindRecording
onActivated: {
if (settingsContent.searchText.trim() !== "")
settingsContent.searchSelectNext();
else
settingsContent.scrollDown();
}
}
}
// Main content
Rectangle {
anchors.fill: parent
color: Qt.alpha(Color.mSurface, Settings.data.ui.panelBackgroundOpacity)
radius: Style.radiusL
SettingsContent {
id: settingsContent
anchors.fill: parent
onCloseRequested: SettingsPanelService.closeWindow()
}
}
}
@@ -0,0 +1,48 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
NTabBar {
id: subTabBar
Layout.fillWidth: true
Layout.bottomMargin: Style.marginM
distributeEvenly: true
currentIndex: tabView.currentIndex
NTabButton {
text: I18n.tr("common.info")
tabIndex: 0
checked: subTabBar.currentIndex === 0
}
NTabButton {
text: I18n.tr("common.contributors")
tabIndex: 1
checked: subTabBar.currentIndex === 1
}
NTabButton {
text: I18n.tr("common.supporters")
tabIndex: 2
checked: subTabBar.currentIndex === 2
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.marginL
}
NTabView {
id: tabView
currentIndex: subTabBar.currentIndex
VersionSubTab {}
ContributorsSubTab {}
SupportersSubTab {}
}
}
@@ -0,0 +1,253 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Noctalia
import qs.Widgets
ColumnLayout {
id: root
property var contributors: GitHubService.contributors
property int avatarCacheVersion: 0
readonly property int topContributorsCount: 20
Connections {
target: GitHubService
function onCachedAvatarsChanged() {
root.avatarCacheVersion++;
}
}
spacing: Style.marginL
NHeader {
description: I18n.trp("panels.about.contributors-description", root.contributors.length)
enableDescriptionRichText: true
}
// Top 20 contributors with full cards (avoids GridView shader crashes on Qt 6.8)
Flow {
id: topContributorsFlow
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
spacing: Style.marginM
Repeater {
model: Math.min(root.contributors.length, root.topContributorsCount)
delegate: Rectangle {
width: Math.max(Math.round(topContributorsFlow.width / 2 - Style.marginM - 1), Math.round(Style.baseWidgetSize * 4))
height: Math.round(Style.baseWidgetSize * 2.3)
radius: Style.radiusM
color: contributorArea.containsMouse ? Color.mHover : "transparent"
border.width: 1
border.color: contributorArea.containsMouse ? Color.mPrimary : Color.mOutline
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
// Avatar container with rectangular design (modern, no shader issues)
Item {
id: wrapper
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: Style.baseWidgetSize * 1.8
Layout.preferredHeight: Style.baseWidgetSize * 1.8
property bool isRounded: false
// Background and image container
Item {
anchors.fill: parent
// Simple circular image (pre-rendered, no shaders)
Image {
anchors.fill: parent
source: {
// Depend on avatarCacheVersion to trigger re-evaluation
var _ = root.avatarCacheVersion;
// Try cached circular version first
var username = root.contributors[index].login;
var cached = GitHubService.getAvatarPath(username);
if (cached) {
wrapper.isRounded = true;
return cached;
}
// Fall back to original avatar URL
return root.contributors[index].avatar_url || "";
}
fillMode: Image.PreserveAspectFit // Fit since image is already circular with transparency
mipmap: true
smooth: true
asynchronous: true
visible: root.contributors[index].avatar_url !== undefined && root.contributors[index].avatar_url !== ""
opacity: status === Image.Ready ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
}
// Fallback icon
NIcon {
anchors.centerIn: parent
visible: !root.contributors[index].avatar_url || root.contributors[index].avatar_url === ""
icon: "person"
pointSize: Style.fontSizeL
color: Color.mPrimary
}
}
Rectangle {
visible: wrapper.isRounded
anchors.fill: parent
color: "transparent"
radius: width * 0.5
border.width: Style.borderM
border.color: Color.mPrimary
}
}
// Info column
ColumnLayout {
spacing: 2
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
NText {
text: root.contributors[index].login || "Unknown"
font.weight: Style.fontWeightBold
color: contributorArea.containsMouse ? Color.mOnHover : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
pointSize: Style.fontSizeS
}
RowLayout {
spacing: Style.marginXS
Layout.fillWidth: true
NIcon {
icon: "git-commit"
pointSize: Style.fontSizeXS
color: contributorArea.containsMouse ? Color.mOnHover : Color.mOnSurfaceVariant
}
NText {
text: `${(root.contributors[index].contributions || 0).toString()} commits`
pointSize: Style.fontSizeXS
color: contributorArea.containsMouse ? Color.mOnHover : Color.mOnSurfaceVariant
}
}
}
// Hover indicator
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: "arrow-right"
pointSize: Style.fontSizeS
color: Color.mPrimary
opacity: contributorArea.containsMouse ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
}
}
MouseArea {
id: contributorArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.contributors[index].html_url)
Quickshell.execDetached(["xdg-open", root.contributors[index].html_url]);
}
}
}
}
}
// Remaining contributors (simple text links)
Flow {
id: remainingContributorsFlow
visible: root.contributors.length > root.topContributorsCount
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
Layout.topMargin: Style.marginL
spacing: Style.marginS
Repeater {
model: Math.max(0, root.contributors.length - root.topContributorsCount)
delegate: Rectangle {
width: nameText.implicitWidth + Style.margin2M
height: nameText.implicitHeight + Style.margin2S
radius: Style.radiusS
color: nameArea.containsMouse ? Color.mHover : "transparent"
border.width: Style.borderS
border.color: nameArea.containsMouse ? Color.mPrimary : Color.mOutline
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
NText {
id: nameText
anchors.centerIn: parent
text: root.contributors[index + root.topContributorsCount].login || "Unknown"
pointSize: Style.fontSizeXS
color: nameArea.containsMouse ? Color.mOnHover : Color.mOnSurface
font.weight: Style.fontWeightMedium
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
MouseArea {
id: nameArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.contributors[index + root.topContributorsCount].html_url)
Quickshell.execDetached(["xdg-open", root.contributors[index + root.topContributorsCount].html_url]);
}
}
}
}
}
}
@@ -0,0 +1,229 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
property var supporters: SupporterService.supporters
property int avatarCacheVersion: 0
Connections {
target: SupporterService
function onCachedAvatarsChanged() {
root.avatarCacheVersion++;
}
}
spacing: Style.marginL
NHeader {
description: root.supporters.length === 0 ? I18n.tr("panels.about.supporters-loading") : I18n.trp("panels.about.supporters-desc", root.supporters.length)
enableDescriptionRichText: true
}
NButton {
Layout.alignment: Qt.AlignHCenter
icon: "heart"
text: I18n.tr("panels.about.become-supporter")
outlined: true
onClicked: {
Quickshell.execDetached(["xdg-open", "https://buymeacoffee.com/noctalia"]);
ToastService.showNotice(I18n.tr("panels.about.support"), I18n.tr("toast.donation-opened"));
}
}
NDivider {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
}
// Supporter cards
Flow {
id: supportersFlow
visible: root.supporters.length > 0
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
spacing: Style.marginL
Repeater {
model: root.supporters.length
delegate: Rectangle {
id: supporterCard
property bool hasGithub: !!root.supporters[index].github_username
width: Math.max(Math.round(supportersFlow.width / 2 - Style.marginL - 1), Math.round(Style.baseWidgetSize * 4.5))
height: Math.round(Style.baseWidgetSize * 2.6)
radius: Style.radiusM
color: supporterArea.containsMouse && hasGithub ? Color.mHover : Qt.alpha(Color.mPrimary, 0.05)
border.width: Style.borderM
border.color: supporterArea.containsMouse && hasGithub ? Color.mPrimary : Qt.alpha(Color.mPrimary, 0.5)
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
// Avatar with heart badge
Item {
id: avatarWrapper
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: Style.baseWidgetSize * 2.0
Layout.preferredHeight: Style.baseWidgetSize * 2.0
property bool isRounded: false
Item {
anchors.fill: parent
Image {
anchors.fill: parent
visible: supporterCard.hasGithub
source: {
if (!supporterCard.hasGithub)
return "";
var _ = root.avatarCacheVersion;
var username = root.supporters[index].github_username;
var cached = SupporterService.getAvatarPath(username);
if (cached) {
avatarWrapper.isRounded = true;
return cached;
}
return "https://github.com/" + username + ".png?size=256";
}
fillMode: Image.PreserveAspectFit
mipmap: true
smooth: true
asynchronous: true
opacity: status === Image.Ready ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
}
// Fallback logo for supporters without GitHub
Rectangle {
anchors.fill: parent
visible: !supporterCard.hasGithub
radius: width * 0.5
color: Qt.alpha(Color.mPrimary, 0.15)
Image {
anchors.centerIn: parent
source: "../../../../../Assets/noctalia.svg"
width: parent.width * 0.75
height: width
fillMode: Image.PreserveAspectFit
mipmap: true
smooth: true
}
}
}
Rectangle {
visible: avatarWrapper.isRounded || !supporterCard.hasGithub
anchors.fill: parent
color: "transparent"
radius: width * 0.5
border.width: Style.borderM
border.color: Color.mPrimary
}
// Heart badge
Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: -2
anchors.bottomMargin: -2
width: Style.fontSizeM + Style.marginS
height: width
radius: width * 0.5
color: Color.mPrimary
NIcon {
anchors.centerIn: parent
icon: "heart-filled"
pointSize: Style.fontSizeXS
color: Color.mOnPrimary
}
}
}
// Info column
ColumnLayout {
spacing: 2
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
NText {
text: root.supporters[index].name || root.supporters[index].github_username || "Unknown"
font.weight: Style.fontWeightBold
color: supporterArea.containsMouse && supporterCard.hasGithub ? Color.mOnHover : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
pointSize: Style.fontSizeS
}
NText {
text: I18n.tr("panels.about.supporter-badge")
pointSize: Style.fontSizeXS
color: Color.mPrimary
font.weight: Style.fontWeightMedium
}
}
// Hover indicator (only for supporters with GitHub)
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: "arrow-right"
pointSize: Style.fontSizeS
color: Color.mPrimary
visible: supporterCard.hasGithub
opacity: supporterArea.containsMouse ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
}
}
MouseArea {
id: supporterArea
anchors.fill: parent
hoverEnabled: true
cursorShape: supporterCard.hasGithub ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
var username = root.supporters[index].github_username;
if (username) {
Quickshell.execDetached(["xdg-open", "https://github.com/" + username]);
}
}
}
}
}
}
}
@@ -0,0 +1,985 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Compositor
import qs.Services.Noctalia
import qs.Services.System
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
opacity: 0
onSystemInfoLoadingChanged: {
if (!systemInfoLoading)
tabAppearAnim.start();
}
NumberAnimation on opacity {
id: tabAppearAnim
from: 0
to: 1
duration: Style.animationSlowest
easing.type: Easing.OutCubic
running: false
}
property string latestVersion: GitHubService.latestVersion
property string currentVersion: UpdateService.currentVersion
property string commitInfo: ""
property string qsVersion: ""
property string qsRevision: ""
readonly property bool isGitVersion: root.currentVersion.endsWith("-git")
readonly property int gigaB: (1024 * 1024 * 1024)
readonly property int gigaD: (1000 * 1000 * 1000)
// Update status: compare versions
readonly property bool updateAvailable: {
if (!root.latestVersion || !root.currentVersion || root.latestVersion === I18n.tr("common.unknown"))
return false;
return UpdateService.compareVersions(root.latestVersion, root.currentVersion) > 0 && !root.isGitVersion;
}
readonly property bool isUpToDate: {
if (!root.latestVersion || !root.currentVersion || root.latestVersion === I18n.tr("common.unknown"))
return false;
return UpdateService.compareVersions(root.latestVersion, root.currentVersion) <= 0;
}
readonly property bool qsUpdateAvailable: {
if (!GitHubService.latestQSVersion || !root.qsVersion || GitHubService.latestQSVersion === I18n.tr("common.unknown"))
return false;
return UpdateService.compareVersions(GitHubService.latestQSVersion, root.qsVersion) > 0;
}
readonly property bool qsIsUpToDate: {
if (!GitHubService.latestQSVersion || !root.qsVersion || GitHubService.latestQSVersion === I18n.tr("common.unknown"))
return false;
return UpdateService.compareVersions(GitHubService.latestQSVersion, root.qsVersion) <= 0;
}
// System info properties
property var systemInfo: null
property bool systemInfoLoading: true
property bool systemInfoAvailable: true
spacing: Style.marginL
function getModule(type) {
if (!root.systemInfo)
return null;
return root.systemInfo.find(m => m.type === type);
}
function getMonitorsText(separator) {
const sep = separator || "\n";
const screens = Quickshell.screens || [];
const scales = CompositorService.displayScales || {};
let lines = [];
for (let i = 0; i < screens.length; i++) {
const screen = screens[i];
const name = screen.name || "Unknown";
const scaleData = scales[name];
const scaleValue = (typeof scaleData === "object" && scaleData !== null) ? (scaleData.scale || 1.0) : (scaleData || 1.0);
lines.push(name + ": " + screen.width + "x" + screen.height + " @ " + scaleValue + "x");
}
return lines.join(sep);
}
function getTelemetryPayload() {
const screens = Quickshell.screens || [];
const scales = CompositorService.displayScales || {};
const monitors = [];
for (let i = 0; i < screens.length; i++) {
const screen = screens[i];
const name = screen.name || "Unknown";
const scaleData = scales[name];
const scaleValue = (typeof scaleData === "object" && scaleData !== null) ? (scaleData.scale || 1.0) : (scaleData || 1.0);
monitors.push({
width: screen.width || 0,
height: screen.height || 0,
scale: scaleValue
});
}
return {
instanceId: TelemetryService.getInstanceId(),
version: UpdateService.currentVersion,
compositor: TelemetryService.getCompositorType(),
os: HostService.osPretty || "Unknown",
ramGb: Math.round((root.getModule("Memory")?.result?.total || 0) / root.gigaB),
monitors: monitors,
ui: {
scaleRatio: Settings.data.general.scaleRatio,
fontDefaultScale: Settings.data.ui.fontDefaultScale,
fontFixedScale: Settings.data.ui.fontFixedScale
}
};
}
function copyTelemetryData() {
const payload = getTelemetryPayload();
const json = JSON.stringify(payload, null, 2);
Quickshell.execDetached(["wl-copy", json]);
ToastService.showNotice(I18n.tr("panels.about.telemetry-title"), I18n.tr("panels.about.telemetry-data-copied"));
}
function copyInfoToClipboard() {
let info = "Noctalia Shell: " + root.currentVersion;
if (root.isGitVersion && root.commitInfo) {
info += " (" + root.commitInfo + ")";
}
info += "\n";
if (root.qsVersion) {
let qsV = root.qsVersion.startsWith("v") ? root.qsVersion : "v" + root.qsVersion;
info += "Noctalia QS: " + qsV;
if (root.qsRevision) {
info += " (" + root.qsRevision + ")";
}
info += "\n";
}
info += "\nSystem Information\n";
info += "==================\n";
if (root.systemInfo) {
const os = root.getModule("OS");
const kernel = root.getModule("Kernel");
const title = root.getModule("Title");
const product = root.getModule("Host");
const board = root.getModule("Board");
const cpu = root.getModule("CPU");
const gpu = root.getModule("GPU");
const mem = root.getModule("Memory");
const wm = root.getModule("WM");
info += "OS: " + (os?.result?.prettyName || "N/A") + "\n";
info += "Kernel: " + (kernel?.result?.release || "N/A") + "\n";
info += "Host: " + (title?.result?.hostName || "N/A") + "\n";
info += "Product: " + (product?.result?.name || "N/A") + "\n";
info += "Board: " + (board?.result?.name || "N/A") + "\n";
info += "CPU: " + (cpu?.result?.cpu || "N/A") + "\n";
if (gpu?.result && Array.isArray(gpu.result) && gpu.result.length > 0) {
info += "GPU: " + gpu.result.map(g => g.name || "Unknown").join(", ") + "\n";
}
if (mem?.result) {
info += "Memory: " + (mem.result.total / root.gigaB).toFixed(1) + " GB \n";
}
if (wm?.result) {
info += "WM: " + (wm.result.prettyName || wm.result.processName || "N/A") + "\n";
}
}
const monitors = getMonitorsText("\n").split("\n");
for (const mon of monitors) {
info += "Monitor: " + mon + "\n";
}
info += "\nSettings\n";
info += "========\n";
info += "UI Scale: " + Settings.data.general.scaleRatio + "\n";
info += "Default Font: " + (Settings.data.ui.fontDefault || "default") + " @ " + Settings.data.ui.fontDefaultScale + "x\n";
info += "Fixed Font: " + (Settings.data.ui.fontFixed || "default") + " @ " + Settings.data.ui.fontFixedScale + "x\n";
Quickshell.execDetached(["wl-copy", info]);
ToastService.showNotice(I18n.tr("panels.about.title"), I18n.tr("panels.about.info-copied"));
}
Component.onCompleted: {
// Check if fastfetch is available before trying to run it
checkFastfetchProcess.running = true;
qsVersionProcess.running = true;
Logger.d("VersionSubTab", "Current version:", root.currentVersion);
Logger.d("VersionSubTab", "Is git version:", root.isGitVersion);
// Only fetch commit info for -git versions
if (root.isGitVersion) {
// On NixOS, extract commit hash from the store path first
if (HostService.isNixOS) {
var shellDir = Quickshell.shellDir || "";
Logger.d("VersionSubTab", "Component.onCompleted - NixOS detected, shellDir:", shellDir);
if (shellDir) {
// Extract commit hash from path like: /nix/store/...-noctalia-shell-2025-11-30_225e6d3/share/noctalia-shell
// Pattern matches: noctalia-shell-YYYY-MM-DD_<commit_hash>
var match = shellDir.match(/noctalia-shell-\d{4}-\d{2}-\d{2}_([0-9a-f]{7,})/i);
if (match && match[1]) {
// Use first 7 characters of the commit hash
root.commitInfo = match[1].substring(0, 7);
Logger.d("VersionSubTab", "Component.onCompleted - Extracted commit from NixOS path:", root.commitInfo);
return;
} else {
Logger.d("VersionSubTab", "Component.onCompleted - Could not extract commit from NixOS path, trying fallback");
}
}
}
fetchGitCommit();
}
}
function fetchGitCommit() {
var shellDir = Quickshell.shellDir || "";
Logger.d("VersionSubTab", "fetchGitCommit - shellDir:", shellDir);
if (!shellDir) {
Logger.d("VersionSubTab", "fetchGitCommit - Cannot determine shell directory, skipping git commit fetch");
return;
}
gitProcess.workingDirectory = shellDir;
gitProcess.running = true;
}
Process {
id: gitProcess
command: ["git", "rev-parse", "--short", "HEAD"]
running: false
onExited: function (exitCode) {
Logger.d("VersionSubTab", "gitProcess - Process exited with code:", exitCode);
if (exitCode === 0) {
var gitOutput = stdout.text.trim();
Logger.d("VersionSubTab", "gitProcess - gitOutput:", gitOutput);
if (gitOutput) {
root.commitInfo = gitOutput;
Logger.d("VersionSubTab", "gitProcess - Set commitInfo to:", root.commitInfo);
}
} else {
Logger.d("VersionSubTab", "gitProcess - Git command failed. Exit code:", exitCode);
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
Process {
id: qsVersionProcess
command: ["qs", "--version"]
running: false
onExited: function (exitCode) {
if (exitCode === 0) {
var output = stdout.text.trim();
// Format (old): "noctalia-qs 0.3.0, revision abc12345, distributed by: ..."
// Format (new): "noctalia-qs 0.0.9 (revision b602b69c81d96a1d7c645328feb7b1e1d4b7b7a4, distributed by Unset)"
// Only set if this is actually noctalia-qs; leave empty for upstream quickshell
var match = output.match(/noctalia-qs\s+(\S+?)[\s,(]+revision\s*([0-9a-f]*)/i);
if (match) {
root.qsVersion = match[1];
root.qsRevision = match[2] ? match[2].substring(0, 9) : "";
}
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
// Check if fastfetch is available before attempting to run it
Process {
id: checkFastfetchProcess
command: ["sh", "-c", "command -v fastfetch"]
running: false
onExited: function (exitCode) {
if (exitCode === 0) {
// fastfetch is available, run it
Logger.d("VersionSubTab", "fastfetch found, running it");
fastfetchProcess.running = true;
} else {
// fastfetch not found, show error state immediately
Logger.w("VersionSubTab", "fastfetch not found");
root.systemInfoLoading = false;
root.systemInfoAvailable = false;
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
Process {
id: fastfetchProcess
command: ["fastfetch", "--format", "json", "--config", Quickshell.shellDir + "/Assets/Services/fastfetch/system-info.jsonc"]
running: false
onExited: function (exitCode) {
root.systemInfoLoading = false;
if (exitCode === 0) {
try {
root.systemInfo = JSON.parse(stdout.text);
root.systemInfoAvailable = true;
} catch (e) {
Logger.w("VersionSubTab", "Failed to parse fastfetch JSON: " + e);
root.systemInfoAvailable = false;
}
} else {
root.systemInfoAvailable = false;
}
}
stdout: StdioCollector {}
stderr: StdioCollector {}
}
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginXL
// Noctalia logo
Image {
source: "../../../../../Assets/noctalia.svg"
width: 96 * Style.uiScaleRatio
height: width
fillMode: Image.PreserveAspectFit
sourceSize.width: width
sourceSize.height: height
mipmap: true
smooth: true
Layout.alignment: Qt.AlignBottom
rotation: Settings.isDebug ? 180 : 0
Behavior on rotation {
NumberAnimation {
duration: Style.animationSlowest
easing.type: Easing.OutBack
}
}
property int debugTapCount: 0
Timer {
id: debugTapTimer
interval: 5000
onTriggered: parent.debugTapCount = 0
}
MouseArea {
anchors.fill: parent
onClicked: {
if (parent.debugTapCount === 0) {
debugTapTimer.restart();
}
parent.debugTapCount++;
if (parent.debugTapCount >= 8) {
parent.debugTapCount = 0;
debugTapTimer.stop();
Settings.isDebug = !Settings.isDebug;
if (Settings.isDebug) {
ToastService.showNotice("Debug", I18n.tr("panels.about.debug-enabled"));
} else {
ToastService.showNotice("Debug", I18n.tr("panels.about.debug-disabled"));
}
}
}
}
}
ColumnLayout {
NHeader {
label: "Noctalia Shell"
}
// Versions
GridLayout {
columns: 2
rowSpacing: Style.marginXS
columnSpacing: Style.marginM
// Installed Version (Shell)
NText {
text: "Noctalia Shell:"
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
}
RowLayout {
spacing: Style.marginS
NText {
text: root.currentVersion
color: Color.mOnSurface
font.weight: Style.fontWeightBold
}
// Git commit in parentheses
NText {
id: commitText
visible: root.isGitVersion
text: "(" + (root.commitInfo || I18n.tr("common.loading")) + ")"
color: commitMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurfaceVariant
pointSize: Style.fontSizeXS
font.underline: commitMouseArea.containsMouse && root.commitInfo
MouseArea {
id: commitMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.commitInfo ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: {
if (root.commitInfo) {
TooltipService.show(commitText, I18n.tr("panels.about.view-commit"));
}
}
onExited: TooltipService.hide()
onClicked: {
if (root.commitInfo) {
Quickshell.execDetached(["xdg-open", "https://github.com/noctalia-dev/noctalia-shell/commit/" + root.commitInfo]);
}
}
}
}
// Update status indicator
NIcon {
id: upToDateIcon
visible: root.isUpToDate
icon: "circle-check"
pointSize: Style.fontSizeM
color: Color.mPrimary
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(upToDateIcon, I18n.tr("panels.about.up-to-date"))
onExited: TooltipService.hide()
}
}
NIcon {
id: updateAvailableIcon
visible: root.updateAvailable
icon: "arrow-up-circle"
pointSize: Style.fontSizeS
color: Color.mPrimary
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(updateAvailableIcon, I18n.tr("panels.about.update-available"))
onExited: TooltipService.hide()
}
}
}
// Latest Version (Shell)
NText {
visible: root.updateAvailable
text: I18n.tr("panels.about.noctalia-available")
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
}
NText {
visible: root.updateAvailable
text: root.latestVersion
color: Color.mOnSurface
font.weight: Style.fontWeightBold
}
// Divider-like spacing
Item {
visible: root.qsUpdateAvailable || root.updateAvailable
Layout.columnSpan: 2
Layout.preferredHeight: Style.marginXS
}
// Quickshell Version
NText {
visible: root.qsVersion !== ""
text: "Noctalia QS:"
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
}
RowLayout {
visible: root.qsVersion !== ""
spacing: Style.marginS
NText {
text: root.qsVersion.startsWith("v") ? root.qsVersion : "v" + root.qsVersion
color: Color.mOnSurface
font.weight: Style.fontWeightBold
}
// Git revision in parentheses
NText {
id: qsRevisionText
visible: root.qsRevision !== ""
text: "(" + root.qsRevision + ")"
color: qsRevisionMouseArea.containsMouse ? Color.mPrimary : Color.mOnSurfaceVariant
pointSize: Style.fontSizeXS
font.underline: qsRevisionMouseArea.containsMouse
MouseArea {
id: qsRevisionMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(qsRevisionText, I18n.tr("panels.about.view-commit"))
onExited: TooltipService.hide()
onClicked: {
Quickshell.execDetached(["xdg-open", "https://github.com/noctalia-dev/noctalia-qs/commit/" + root.qsRevision]);
}
}
}
// Update status indicator
NIcon {
id: qsUpToDateIcon
visible: root.qsIsUpToDate
icon: "circle-check"
pointSize: Style.fontSizeM
color: Color.mPrimary
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(qsUpToDateIcon, I18n.tr("panels.about.up-to-date"))
onExited: TooltipService.hide()
}
}
NIcon {
id: qsUpdateAvailableIcon
visible: root.qsUpdateAvailable
icon: "arrow-up-circle"
pointSize: Style.fontSizeS
color: Color.mPrimary
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: TooltipService.show(qsUpdateAvailableIcon, I18n.tr("panels.about.update-available"))
onExited: TooltipService.hide()
}
}
}
// Latest Quickshell Version
NText {
visible: root.qsUpdateAvailable
text: I18n.tr("panels.about.noctalia-available")
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
}
NText {
visible: root.qsUpdateAvailable
text: GitHubService.latestQSVersion
color: Color.mOnSurface
font.weight: Style.fontWeightBold
}
}
}
}
GridLayout {
id: actionsGrid
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Style.marginM
Layout.bottomMargin: Style.marginM
rowSpacing: Style.marginM
columnSpacing: Style.marginM
columns: (changelogBtn.implicitWidth + copyBtn.implicitWidth + supportBtn.implicitWidth + 2 * columnSpacing) < root.width ? 3 : 1
NButton {
id: changelogBtn
icon: "sparkles"
text: I18n.tr("panels.about.changelog")
outlined: true
Layout.alignment: Qt.AlignHCenter
onClicked: {
var screen = PanelService.openedPanel?.screen || SettingsPanelService.settingsWindow?.screen || PanelService.findScreenForPanels();
SettingsPanelService.close(screen);
UpdateService.viewChangelog(screen);
}
}
NButton {
id: copyBtn
icon: "copy"
text: I18n.tr("panels.about.copy-info")
outlined: true
Layout.alignment: Qt.AlignHCenter
onClicked: root.copyInfoToClipboard()
}
NButton {
id: supportBtn
icon: "heart"
text: I18n.tr("panels.about.support")
outlined: true
Layout.alignment: Qt.AlignHCenter
onClicked: {
Quickshell.execDetached(["xdg-open", "https://buymeacoffee.com/noctalia"]);
ToastService.showNotice(I18n.tr("panels.about.support"), I18n.tr("toast.donation-opened"));
}
}
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.about.changelog-on-startup")
description: I18n.tr("panels.about.changelog-on-startup-desc")
checked: Settings.data.general.showChangelogOnStartup
onToggled: checked => Settings.data.general.showChangelogOnStartup = checked
}
// System Information Section
NDivider {
Layout.fillWidth: true
}
NHeader {
label: I18n.tr("panels.about.system-title")
}
// Error state (fastfetch not installed)
ColumnLayout {
visible: !root.systemInfoAvailable
Layout.fillWidth: true
spacing: Style.marginS
NText {
text: I18n.tr("panels.about.system-not-installed")
color: Color.mOnSurfaceVariant
}
NText {
text: I18n.tr("panels.about.system-install-hint")
color: Color.mOnSurfaceVariant
pointSize: Style.fontSizeXS
}
}
// System info grid
GridLayout {
id: sysInfo
readonly property real textSize: Style.fontSizeS
visible: root.systemInfoAvailable && root.systemInfo
Layout.fillWidth: true
columns: 2
rowSpacing: Style.marginXS
columnSpacing: Style.marginM
// OS
NText {
text: I18n.tr("panels.about.system-os")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const os = root.getModule("OS");
return os?.result?.prettyName || "N/A";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Kernel
NText {
text: I18n.tr("panels.about.system-kernel")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const kernel = root.getModule("Kernel");
return kernel?.result?.release || "N/A";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Host
NText {
text: I18n.tr("panels.about.system-host")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const title = root.getModule("Title");
return title?.result?.hostName || "N/A";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Product name
NText {
text: I18n.tr("panels.about.system-product")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const title = root.getModule("Host");
return title?.result?.name || "N/A";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Board name
NText {
text: I18n.tr("panels.about.system-board")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const title = root.getModule("Board");
return title?.result?.name || "N/A";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Uptime
NText {
text: I18n.tr("panels.about.system-uptime")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const value = root.getModule("Uptime")?.result?.uptime;
return value ? Time.formatVagueHumanReadableDuration(value / 1000) : "-";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// CPU
NText {
text: I18n.tr("panels.about.system-cpu")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const cpu = root.getModule("CPU");
if (!cpu?.result)
return "N/A";
let cpuText = cpu.result.cpu || "N/A";
const cores = cpu.result.cores;
if (cores?.logical) {
cpuText += " (" + cores.logical + " threads)";
}
return cpuText;
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// GPU
NText {
text: I18n.tr("panels.about.system-gpu")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const gpu = root.getModule("GPU");
if (!gpu?.result || !Array.isArray(gpu.result) || gpu.result.length === 0)
return "N/A";
return gpu.result.map(g => g.name || "Unknown").join(", ");
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Memory
NText {
text: I18n.tr("panels.about.system-memory")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const mem = root.getModule("Memory");
if (!mem?.result)
return "N/A";
const used = (mem.result.used / root.gigaB).toFixed(1);
const total = (mem.result.total / root.gigaB).toFixed(1);
return used + " GiB / " + total + " GiB";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Disk
NText {
text: I18n.tr("panels.about.system-disk")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const disk = root.getModule("Disk");
if (!disk?.result || !Array.isArray(disk.result) || disk.result.length === 0)
return "N/A";
const rootDisk = disk.result.find(d => d.mountpoint === "/");
if (!rootDisk?.bytes)
return "N/A";
const used = (rootDisk.bytes.used / root.gigaD).toFixed(1);
const total = (rootDisk.bytes.total / root.gigaD).toFixed(1);
return used + " GB / " + total + " GB" + " (" + rootDisk.filesystem + ")";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// WM
NText {
text: I18n.tr("panels.about.system-wm")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const wm = root.getModule("WM");
if (!wm?.result)
return "N/A";
let wmText = wm.result.prettyName || wm.result.processName || "N/A";
if (wm.result.protocolName) {
wmText += " (" + wm.result.protocolName + ")";
}
return wmText;
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Packages
NText {
text: I18n.tr("panels.about.system-packages")
color: Color.mOnSurfaceVariant
pointSize: sysInfo.textSize
}
NText {
text: {
const pkg = root.getModule("Packages");
if (!pkg?.result)
return "N/A";
const result = pkg.result;
if (result.all) {
const managers = [];
if (result.rpm > 0)
managers.push("rpm: " + result.rpm);
if (result.pacman > 0)
managers.push("pacman: " + result.pacman);
if (result.dpkg > 0)
managers.push("dpkg: " + result.dpkg);
if (result.flatpakSystem > 0 || result.flatpakUser > 0) {
const flatpak = (result.flatpakSystem || 0) + (result.flatpakUser || 0);
managers.push("flatpak: " + flatpak);
}
if (result.snap > 0)
managers.push("snap: " + result.snap);
if (result.nixSystem > 0 || result.nixUser > 0 || result.nixDefault > 0) {
const nix = (result.nixSystem || 0) + (result.nixUser || 0) + (result.nixDefault || 0);
managers.push("nix: " + nix);
}
if (result.brew > 0)
managers.push("brew: " + result.brew);
if (managers.length > 0) {
return result.all + " (" + managers.join(", ") + ")";
}
return result.all.toString();
}
return "N/A";
}
color: Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: true
wrapMode: Text.Wrap
}
// Monitors (2 items per screen: label + value)
Repeater {
model: Quickshell.screens.length * 2
NText {
readonly property int screenIndex: Math.floor(index / 2)
readonly property bool isLabel: index % 2 === 0
readonly property var screen: Quickshell.screens[screenIndex]
text: {
if (isLabel)
return I18n.tr("panels.about.system-monitor");
const name = screen?.name || "Unknown";
const scales = CompositorService.displayScales || {};
const scaleData = scales[name];
const scaleValue = (typeof scaleData === "object" && scaleData !== null) ? (scaleData.scale || 1.0) : (scaleData || 1.0);
return name + ": " + (screen?.width || 0) + "x" + (screen?.height || 0) + " @ " + scaleValue + "x";
}
color: isLabel ? Color.mOnSurfaceVariant : Color.mOnSurface
pointSize: sysInfo.textSize
Layout.fillWidth: !isLabel
wrapMode: Text.Wrap
}
}
}
// Telemetry Section
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginL
}
NHeader {
label: I18n.tr("panels.about.telemetry-title")
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.about.telemetry-enabled")
description: I18n.tr("panels.about.telemetry-desc")
checked: Settings.data.general.telemetryEnabled
onToggled: checked => Settings.data.general.telemetryEnabled = checked
}
RowLayout {
spacing: Style.marginM
NButton {
icon: "eye"
text: I18n.tr("panels.about.telemetry-show-data")
outlined: true
onClicked: root.copyTelemetryData()
}
NButton {
icon: "shield-lock"
text: I18n.tr("panels.about.privacy-policy")
outlined: true
onClicked: Quickshell.execDetached(["xdg-open", "https://noctalia.dev/privacy"])
}
}
}
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
NTabBar {
id: subTabBar
Layout.fillWidth: true
Layout.bottomMargin: Style.marginM
distributeEvenly: true
currentIndex: tabView.currentIndex
NTabButton {
text: I18n.tr("common.volumes")
tabIndex: 0
checked: subTabBar.currentIndex === 0
}
NTabButton {
text: I18n.tr("common.devices")
tabIndex: 1
checked: subTabBar.currentIndex === 1
}
NTabButton {
text: I18n.tr("common.media")
tabIndex: 2
checked: subTabBar.currentIndex === 2
}
NTabButton {
text: I18n.tr("common.visualizer")
tabIndex: 3
checked: subTabBar.currentIndex === 3
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.marginL
}
NTabView {
id: tabView
currentIndex: subTabBar.currentIndex
VolumesSubTab {}
DevicesSubTab {}
MediaSubTab {}
VisualizerSubTab {}
}
}
@@ -0,0 +1,70 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Services.Pipewire
import qs.Commons
import qs.Services.Media
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
// Output Devices
ButtonGroup {
id: sinks
}
ColumnLayout {
spacing: Style.marginXS
Layout.fillWidth: true
Layout.bottomMargin: Style.marginL
NLabel {
label: I18n.tr("panels.audio.devices-output-device-label")
description: I18n.tr("panels.audio.devices-output-device-description")
}
Repeater {
model: AudioService.sinks
NRadioButton {
ButtonGroup.group: sinks
required property PwNode modelData
text: modelData.description
checked: AudioService.sink?.id === modelData.id
onClicked: {
AudioService.setAudioSink(modelData);
}
Layout.fillWidth: true
}
}
}
// Input Devices
ButtonGroup {
id: sources
}
ColumnLayout {
spacing: Style.marginXS
Layout.fillWidth: true
NLabel {
label: I18n.tr("panels.audio.devices-input-device-label")
description: I18n.tr("panels.audio.devices-input-device-description")
}
Repeater {
model: AudioService.sources
NRadioButton {
ButtonGroup.group: sources
required property PwNode modelData
text: modelData.description
checked: AudioService.source?.id === modelData.id
onClicked: AudioService.setAudioSource(modelData)
Layout.fillWidth: true
}
}
}
}
@@ -0,0 +1,104 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.Media
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
// Preferred player
NTextInput {
label: I18n.tr("panels.audio.media-primary-player-label")
description: I18n.tr("panels.audio.media-primary-player-description")
placeholderText: I18n.tr("panels.audio.media-primary-player-placeholder")
text: Settings.data.audio.preferredPlayer
defaultValue: Settings.getDefaultValue("audio.preferredPlayer")
onTextChanged: {
Settings.data.audio.preferredPlayer = text;
MediaService.updateCurrentPlayer();
}
}
// Blacklist editor
ColumnLayout {
spacing: Style.marginS
Layout.fillWidth: true
NTextInputButton {
id: blacklistInput
label: I18n.tr("panels.audio.media-excluded-player-label")
description: I18n.tr("panels.audio.media-excluded-player-description")
placeholderText: I18n.tr("panels.audio.media-excluded-player-placeholder")
buttonIcon: "add"
Layout.fillWidth: true
onButtonClicked: {
const val = (blacklistInput.text || "").trim();
if (val !== "") {
const arr = (Settings.data.audio.mprisBlacklist || []);
if (!arr.find(x => String(x).toLowerCase() === val.toLowerCase())) {
Settings.data.audio.mprisBlacklist = [...arr, val];
blacklistInput.text = "";
MediaService.updateCurrentPlayer();
}
}
}
}
// Current blacklist entries
Flow {
Layout.fillWidth: true
Layout.leftMargin: Style.marginS
spacing: Style.marginS
Repeater {
model: Settings.data.audio.mprisBlacklist
delegate: Rectangle {
required property string modelData
property real pad: Style.marginS
color: Qt.alpha(Color.mOnSurface, 0.125)
border.color: Qt.alpha(Color.mOnSurface, Style.opacityLight)
border.width: Style.borderS
RowLayout {
id: chipRow
spacing: Style.marginXS
anchors.fill: parent
anchors.margins: pad
NText {
text: modelData
color: Color.mOnSurface
pointSize: Style.fontSizeS
Layout.alignment: Qt.AlignVCenter
Layout.leftMargin: Style.marginS
}
NIconButton {
icon: "close"
baseSize: Style.baseWidgetSize * 0.8
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: Style.marginXS
onClicked: {
const arr = (Settings.data.audio.mprisBlacklist || []);
const idx = arr.findIndex(x => String(x) === modelData);
if (idx >= 0) {
arr.splice(idx, 1);
Settings.data.audio.mprisBlacklist = arr;
MediaService.updateCurrentPlayer();
}
}
}
}
implicitWidth: chipRow.implicitWidth + pad * 2
implicitHeight: Math.max(chipRow.implicitHeight + pad * 2, Style.baseWidgetSize * 0.8)
radius: Style.radiusM
}
}
}
}
}
@@ -0,0 +1,97 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
NComboBox {
label: I18n.tr("panels.audio.visualizer-type-label")
description: I18n.tr("panels.audio.visualizer-type-description")
model: [
{
"key": "none",
"name": I18n.tr("common.none")
},
{
"key": "linear",
"name": I18n.tr("options.visualizer-types.linear")
},
{
"key": "mirrored",
"name": I18n.tr("options.visualizer-types.mirrored")
},
{
"key": "wave",
"name": I18n.tr("options.visualizer-types.wave")
}
]
currentKey: Settings.data.audio.visualizerType
defaultValue: Settings.getDefaultValue("audio.visualizerType")
onSelected: key => Settings.data.audio.visualizerType = key
}
NToggle {
label: I18n.tr("panels.audio.spectrum-mirrored-label")
description: I18n.tr("panels.audio.spectrum-mirrored-description")
checked: Settings.data.audio.spectrumMirrored
defaultValue: Settings.getDefaultValue("audio.spectrumMirrored")
onToggled: Settings.data.audio.spectrumMirrored = checked
}
NComboBox {
label: I18n.tr("panels.audio.media-frame-rate-label")
description: I18n.tr("panels.audio.media-frame-rate-description")
model: [
{
"key": "30",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "30"
})
},
{
"key": "60",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "60"
})
},
{
"key": "100",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "100"
})
},
{
"key": "120",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "120"
})
},
{
"key": "144",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "144"
})
},
{
"key": "165",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "165"
})
},
{
"key": "240",
"name": I18n.tr("options.frame-rates-fps", {
"fps": "240"
})
}
]
currentKey: Settings.data.audio.spectrumFrameRate
defaultValue: Settings.getDefaultValue("audio.spectrumFrameRate")
onSelected: key => Settings.data.audio.spectrumFrameRate = key
}
}
@@ -0,0 +1,196 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Media
import qs.Services.System
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
property real localVolume: AudioService.volume
Connections {
target: AudioService
function onSinkChanged() {
localVolume = AudioService.volume;
}
function onVolumeChanged() {
localVolume = AudioService.volume;
}
}
Connections {
target: AudioService.sink?.audio ? AudioService.sink?.audio : null
function onVolumeChanged() {
localVolume = AudioService.volume;
}
}
// Output Volume
ColumnLayout {
spacing: Style.marginXXS
Layout.fillWidth: true
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.osd.types-volume-label")
description: I18n.tr("panels.audio.volumes-output-volume-description")
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localVolume
stepSize: 0.01
text: Math.round(AudioService.volume * 100) + "%"
onMoved: value => localVolume = value
}
Timer {
interval: 100
running: true
repeat: true
onTriggered: {
if (!AudioService.isSwitchingSink && Math.abs(localVolume - AudioService.volume) >= 0.01) {
AudioService.setVolume(localVolume);
}
}
}
}
// Mute Toggle
ColumnLayout {
spacing: Style.marginS
Layout.fillWidth: true
NToggle {
label: I18n.tr("panels.audio.volumes-mute-output-label")
description: I18n.tr("panels.audio.volumes-mute-output-description")
checked: AudioService.muted
onToggled: checked => AudioService.setOutputMuted(checked)
}
}
// Volume Feedback sound Toggle
ColumnLayout {
spacing: Style.marginS
Layout.fillWidth: true
NToggle {
label: I18n.tr("panels.audio.volumes-volume-feedback-label")
description: I18n.tr("panels.audio.volumes-volume-feedback-description")
checked: Settings.data.audio.volumeFeedback
defaultValue: Settings.getDefaultValue("audio.volumeFeedback")
onToggled: checked => Settings.data.audio.volumeFeedback = checked
}
ColumnLayout {
enabled: SoundService.multimediaAvailable && Settings.data.audio.volumeFeedback
spacing: Style.marginXXS
Layout.fillWidth: true
NLabel {
label: I18n.tr("panels.audio.volumes-feedback-sound-file-label")
description: I18n.tr("panels.audio.volumes-feedback-sound-file-description")
}
NTextInputButton {
enabled: parent.enabled
Layout.fillWidth: true
placeholderText: I18n.tr("panels.notifications.sounds-files-placeholder")
text: Settings.data.audio.volumeFeedbackSoundFile ?? ""
buttonIcon: "folder-open"
buttonTooltip: I18n.tr("panels.notifications.sounds-files-select-file")
onInputTextChanged: text => Settings.data.audio.volumeFeedbackSoundFile = text
onButtonClicked: volumeFeedbackFilePicker.open()
}
}
}
NDivider {
Layout.fillWidth: true
}
// Input Volume
ColumnLayout {
spacing: Style.marginXS
Layout.fillWidth: true
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.osd.types-input-volume-label")
description: I18n.tr("panels.audio.volumes-input-volume-description")
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: AudioService.inputVolume
stepSize: 0.01
text: Math.round(AudioService.inputVolume * 100) + "%"
onMoved: value => AudioService.setInputVolume(value)
}
}
// Input Mute Toggle
ColumnLayout {
spacing: Style.marginS
Layout.fillWidth: true
NToggle {
label: I18n.tr("panels.audio.volumes-mute-input-label")
description: I18n.tr("panels.audio.volumes-mute-input-description")
checked: AudioService.inputMuted
onToggled: checked => AudioService.setInputMuted(checked)
}
}
// Volume Step Size
ColumnLayout {
spacing: Style.marginS
Layout.fillWidth: true
NSpinBox {
Layout.fillWidth: true
label: I18n.tr("panels.audio.volumes-step-size-label")
description: I18n.tr("panels.audio.volumes-step-size-description")
minimum: 1
maximum: 25
value: Settings.data.audio.volumeStep
stepSize: 1
suffix: "%"
defaultValue: Settings.getDefaultValue("audio.volumeStep")
onValueChanged: Settings.data.audio.volumeStep = value
}
}
NDivider {
Layout.fillWidth: true
}
// Raise maximum volume above 100%
ColumnLayout {
spacing: Style.marginS
Layout.fillWidth: true
NToggle {
label: I18n.tr("panels.audio.volumes-volume-overdrive-label")
description: I18n.tr("panels.audio.volumes-volume-overdrive-description")
checked: Settings.data.audio.volumeOverdrive
defaultValue: Settings.getDefaultValue("audio.volumeOverdrive")
onToggled: checked => Settings.data.audio.volumeOverdrive = checked
}
}
NFilePicker {
id: volumeFeedbackFilePicker
title: I18n.tr("panels.audio.volumes-feedback-sound-file-select-title")
selectionMode: "files"
initialPath: Quickshell.env("HOME")
nameFilters: ["*.wav", "*.mp3", "*.ogg", "*.flac", "*.m4a", "*.aac"]
onAccepted: paths => {
if (paths.length > 0) {
Settings.data.audio.volumeFeedbackSoundFile = paths[0];
}
}
}
}
@@ -0,0 +1,379 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.Compositor
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-position-label")
description: I18n.tr("panels.bar.appearance-position-description")
model: [
{
"key": "top",
"name": I18n.tr("positions.top")
},
{
"key": "bottom",
"name": I18n.tr("positions.bottom")
},
{
"key": "left",
"name": I18n.tr("positions.left")
},
{
"key": "right",
"name": I18n.tr("positions.right")
}
]
currentKey: Settings.data.bar.position
defaultValue: Settings.getDefaultValue("bar.position")
onSelected: key => Settings.data.bar.position = key
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-density-label")
description: I18n.tr("panels.bar.appearance-density-description")
model: [
{
"key": "mini",
"name": I18n.tr("options.bar.density-mini")
},
{
"key": "compact",
"name": I18n.tr("options.bar.density-compact")
},
{
"key": "default",
"name": I18n.tr("options.bar.density-default")
},
{
"key": "comfortable",
"name": I18n.tr("options.bar.density-comfortable")
},
{
"key": "spacious",
"name": I18n.tr("options.bar.density-spacious")
}
]
currentKey: Settings.data.bar.density
defaultValue: Settings.getDefaultValue("bar.density")
onSelected: key => Settings.data.bar.density = key
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-type-label")
description: I18n.tr("panels.bar.appearance-type-description")
model: [
{
"key": "simple",
"name": I18n.tr("options.bar.type-simple")
},
{
"key": "floating",
"name": I18n.tr("options.bar.type-floating")
},
{
"key": "framed",
"name": I18n.tr("options.bar.type-framed")
}
]
currentKey: Settings.data.bar.barType
defaultValue: Settings.getDefaultValue("bar.barType")
onSelected: key => {
Settings.data.bar.barType = key;
}
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("common.display-mode")
description: I18n.tr("panels.bar.appearance-display-mode-description")
model: [
{
"key": "always_visible",
"name": I18n.tr("hide-modes.visible")
},
{
"key": "non_exclusive",
"name": I18n.tr("hide-modes.non-exclusive")
},
{
"key": "auto_hide",
"name": I18n.tr("hide-modes.auto-hide")
}
]
currentKey: Settings.data.bar.displayMode
defaultValue: Settings.getDefaultValue("bar.displayMode")
onSelected: key => Settings.data.bar.displayMode = key
}
NToggle {
label: I18n.tr("panels.bar.appearance-use-separate-opacity-label")
description: I18n.tr("panels.bar.appearance-use-separate-opacity-description")
checked: Settings.data.bar.useSeparateOpacity
defaultValue: Settings.getDefaultValue("bar.useSeparateOpacity")
onToggled: checked => Settings.data.bar.useSeparateOpacity = checked
}
NValueSlider {
Layout.fillWidth: true
visible: Settings.data.bar.useSeparateOpacity
label: I18n.tr("panels.bar.appearance-background-opacity-label")
description: I18n.tr("panels.bar.appearance-background-opacity-description")
from: 0
to: 1
stepSize: 0.01
showReset: true
value: Settings.data.bar.backgroundOpacity
defaultValue: Settings.getDefaultValue("bar.backgroundOpacity")
onMoved: value => Settings.data.bar.backgroundOpacity = value
text: Math.floor(Settings.data.bar.backgroundOpacity * 100) + "%"
}
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-font-scale-label")
description: I18n.tr("panels.bar.appearance-font-scale-description")
from: 0.5
to: 2.0
stepSize: 0.01
showReset: true
value: Settings.data.bar.fontScale
defaultValue: Settings.getDefaultValue("bar.fontScale")
onMoved: value => Settings.data.bar.fontScale = value
text: Math.floor(Settings.data.bar.fontScale * 100) + "%"
}
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-widget-spacing-label")
description: I18n.tr("panels.bar.appearance-widget-spacing-description")
from: 0
to: 30
stepSize: 1
showReset: true
value: Settings.data.bar.widgetSpacing
defaultValue: Settings.getDefaultValue("bar.widgetSpacing")
onMoved: value => Settings.data.bar.widgetSpacing = value
text: Settings.data.bar.widgetSpacing + "px"
}
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-content-padding-label")
description: I18n.tr("panels.bar.appearance-content-padding-description")
from: 0
to: 30
stepSize: 1
showReset: true
value: Settings.data.bar.contentPadding
defaultValue: Settings.getDefaultValue("bar.contentPadding")
onMoved: value => Settings.data.bar.contentPadding = value
text: Settings.data.bar.contentPadding + "px"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-show-outline-label")
description: I18n.tr("panels.bar.appearance-show-outline-description")
checked: Settings.data.bar.showOutline
defaultValue: Settings.getDefaultValue("bar.showOutline")
onToggled: checked => Settings.data.bar.showOutline = checked
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-show-capsule-label")
description: I18n.tr("panels.bar.appearance-show-capsule-description")
checked: Settings.data.bar.showCapsule
defaultValue: Settings.getDefaultValue("bar.showCapsule")
onToggled: checked => Settings.data.bar.showCapsule = checked
}
NColorChoice {
Layout.fillWidth: true
visible: Settings.data.bar.showCapsule
label: I18n.tr("panels.bar.appearance-capsule-color-label")
description: I18n.tr("panels.bar.appearance-capsule-color-description")
noneColor: Color.mSurfaceVariant
noneOnColor: Color.mOnSurfaceVariant
currentKey: Settings.data.bar.capsuleColorKey
onSelected: key => Settings.data.bar.capsuleColorKey = key
}
NValueSlider {
Layout.fillWidth: true
visible: Settings.data.bar.showCapsule
label: I18n.tr("panels.bar.appearance-capsule-opacity-label")
description: I18n.tr("panels.bar.appearance-capsule-opacity-description")
from: 0
to: 1
stepSize: 0.01
showReset: true
value: Settings.data.bar.capsuleOpacity
defaultValue: Settings.getDefaultValue("bar.capsuleOpacity")
onMoved: value => Settings.data.bar.capsuleOpacity = value
text: Math.floor(Settings.data.bar.capsuleOpacity * 100) + "%"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-enable-exclusion-zone-inset-label")
description: I18n.tr("panels.bar.appearance-enable-exclusion-zone-inset-description")
checked: Settings.data.bar.enableExclusionZoneInset
defaultValue: Settings.getDefaultValue("bar.enableExclusionZoneInset")
onToggled: checked => Settings.data.bar.enableExclusionZoneInset = checked
}
NToggle {
Layout.fillWidth: true
visible: CompositorService.isNiri
label: I18n.tr("panels.bar.appearance-hide-on-overview-label")
description: I18n.tr("panels.bar.appearance-hide-on-overview-description")
checked: Settings.data.bar.hideOnOverview
defaultValue: Settings.getDefaultValue("bar.hideOnOverview")
onToggled: checked => Settings.data.bar.hideOnOverview = checked
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-outer-corners-label")
description: I18n.tr("panels.bar.appearance-outer-corners-description")
checked: Settings.data.bar.outerCorners
visible: Settings.data.bar.barType === "simple"
defaultValue: Settings.getDefaultValue("bar.outerCorners")
onToggled: checked => Settings.data.bar.outerCorners = checked
}
ColumnLayout {
visible: Settings.data.bar.barType === "framed"
spacing: Style.marginS
Layout.fillWidth: true
NLabel {
label: I18n.tr("panels.bar.appearance-frame-settings-label")
description: I18n.tr("panels.bar.appearance-frame-settings-description")
}
RowLayout {
Layout.fillWidth: true
spacing: Style.marginL
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-frame-thickness")
from: 4
to: 24
stepSize: 1
showReset: true
value: Settings.data.bar.frameThickness
defaultValue: Settings.getDefaultValue("bar.frameThickness")
onMoved: value => Settings.data.bar.frameThickness = value
text: Settings.data.bar.frameThickness + "px"
}
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-frame-radius")
from: 4
to: 24
stepSize: 1
showReset: true
value: Settings.data.bar.frameRadius
defaultValue: Settings.getDefaultValue("bar.frameRadius")
onMoved: value => Settings.data.bar.frameRadius = value
text: Settings.data.bar.frameRadius + "px"
}
}
}
ColumnLayout {
visible: Settings.data.bar.barType === "floating"
spacing: Style.marginL
Layout.fillWidth: true
NDivider {
Layout.fillWidth: true
}
NSpinBox {
label: I18n.tr("panels.bar.appearance-margins-vertical")
description: I18n.tr("panels.bar.appearance-margins-description")
from: 0
to: 500
suffix: "px"
value: Settings.data.bar.marginVertical
defaultValue: Settings.getDefaultValue("bar.marginVertical")
onValueChanged: Settings.data.bar.marginVertical = value
}
NSpinBox {
label: I18n.tr("panels.bar.appearance-margins-horizontal")
description: I18n.tr("panels.bar.appearance-margins-description")
from: 0
to: 500
suffix: "px"
value: Settings.data.bar.marginHorizontal
defaultValue: Settings.getDefaultValue("bar.marginHorizontal")
onValueChanged: Settings.data.bar.marginHorizontal = value
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginS
visible: Settings.data.bar.displayMode === "auto_hide"
}
ColumnLayout {
visible: Settings.data.bar.displayMode === "auto_hide"
spacing: Style.marginS
Layout.fillWidth: true
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-auto-hide-delay-label")
description: I18n.tr("panels.bar.appearance-auto-hide-delay-description")
from: 100
to: 2000
stepSize: 100
showReset: true
value: Settings.data.bar.autoHideDelay
defaultValue: Settings.getDefaultValue("bar.autoHideDelay")
onMoved: value => Settings.data.bar.autoHideDelay = value
text: Settings.data.bar.autoHideDelay + "ms"
}
NValueSlider {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-auto-show-delay-label")
description: I18n.tr("panels.bar.appearance-auto-show-delay-description")
from: 0
to: 500
stepSize: 50
showReset: true
value: Settings.data.bar.autoShowDelay
defaultValue: Settings.getDefaultValue("bar.autoShowDelay")
onMoved: value => Settings.data.bar.autoShowDelay = value
text: Settings.data.bar.autoShowDelay + "ms"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-show-on-workspace-switch-label")
description: I18n.tr("panels.bar.appearance-show-on-workspace-switch-description")
checked: Settings.data.bar.showOnWorkspaceSwitch
defaultValue: Settings.getDefaultValue("bar.showOnWorkspaceSwitch")
onToggled: checked => Settings.data.bar.showOnWorkspaceSwitch = checked
}
}
}
@@ -0,0 +1,234 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
// Helper functions to update arrays immutably
function addMonitor(list, name) {
const arr = (list || []).slice();
if (!arr.includes(name))
arr.push(name);
return arr;
}
function removeMonitor(list, name) {
return (list || []).filter(function (n) {
return n !== name;
});
}
// Signal functions for widgets sub-tab (global widgets only).
// These intentionally edit Settings.data.bar.widgets (global defaults),
// not per-screen overrides. Per-screen editing is handled by MonitorWidgetsConfig.qml.
function _addWidgetToSection(widgetId, section) {
var newWidget = {
"id": widgetId
};
if (BarWidgetRegistry.widgetHasUserSettings(widgetId)) {
var metadata = BarWidgetRegistry.widgetMetadata[widgetId];
if (metadata) {
Object.keys(metadata).forEach(function (key) {
newWidget[key] = metadata[key];
});
}
}
Settings.data.bar.widgets[section].push(newWidget);
BarService.widgetsRevision++;
}
function _removeWidgetFromSection(section, index) {
var widgets = Settings.data.bar.widgets;
if (index >= 0 && index < widgets[section].length) {
var newArray = widgets[section].slice();
var removedWidgets = newArray.splice(index, 1);
widgets[section] = newArray;
BarService.widgetsRevision++;
if (removedWidgets[0].id === "ControlCenter" && BarService.lookupWidget("ControlCenter") === undefined) {
ToastService.showWarning(I18n.tr("toast.missing-control-center.label"), I18n.tr("toast.missing-control-center.description"), 6000);
}
}
}
function _reorderWidgetInSection(section, fromIndex, toIndex) {
var widgets = Settings.data.bar.widgets;
if (fromIndex >= 0 && fromIndex < widgets[section].length && toIndex >= 0 && toIndex < widgets[section].length) {
var newArray = widgets[section].slice();
var item = newArray[fromIndex];
newArray.splice(fromIndex, 1);
newArray.splice(toIndex, 0, item);
widgets[section] = newArray;
BarService.widgetsRevision++;
}
}
// Note: _updateWidgetSettingsInSection does NOT increment revision
// because it only changes settings, not widget structure
function _updateWidgetSettingsInSection(section, index, settings) {
Settings.data.bar.widgets[section][index] = settings;
}
function _moveWidgetBetweenSections(fromSection, index, toSection) {
var widgets = Settings.data.bar.widgets;
if (index >= 0 && index < widgets[fromSection].length) {
var widget = widgets[fromSection][index];
var sourceArray = widgets[fromSection].slice();
sourceArray.splice(index, 1);
widgets[fromSection] = sourceArray;
var targetArray = widgets[toSection].slice();
targetArray.push(widget);
widgets[toSection] = targetArray;
BarService.widgetsRevision++;
Logger.d("BarTab", "_moveWidgetBetweenSections: revision now", BarService.widgetsRevision);
}
}
function getWidgetLocations(widgetId) {
if (!BarService)
return [];
const instances = BarService.getAllRegisteredWidgets();
const locations = {};
for (var i = 0; i < instances.length; i++) {
if (instances[i].widgetId === widgetId) {
const section = instances[i].section;
if (section === "left")
locations["arrow-bar-to-left"] = true;
else if (section === "center")
locations["layout-columns"] = true;
else if (section === "right")
locations["arrow-bar-to-right"] = true;
}
}
return Object.keys(locations);
}
function createBadges(isPlugin, locations) {
const badges = [];
if (isPlugin) {
badges.push({
"icon": "plugin",
"color": Color.mSecondary
});
}
locations.forEach(function (location) {
badges.push({
"icon": location,
"color": Color.mOnSurfaceVariant
});
});
return badges;
}
function updateAvailableWidgetsModel() {
availableWidgets.clear();
const widgets = BarWidgetRegistry.getAvailableWidgets();
widgets.forEach(entry => {
const isPlugin = BarWidgetRegistry.isPluginWidget(entry);
let displayName = entry;
if (isPlugin) {
const pluginId = entry.replace("plugin:", "");
const manifest = PluginRegistry.getPluginManifest(pluginId);
if (manifest && manifest.name) {
displayName = manifest.name;
} else {
displayName = pluginId;
}
}
availableWidgets.append({
"key": entry,
"name": displayName,
"badges": createBadges(isPlugin, getWidgetLocations(entry))
});
});
}
ListModel {
id: availableWidgets
}
Component.onCompleted: {
Qt.callLater(updateAvailableWidgetsModel);
}
Connections {
target: BarService
function onActiveWidgetsChanged() {
updateAvailableWidgetsModel();
}
}
Connections {
target: BarWidgetRegistry
function onPluginWidgetRegistryUpdated() {
updateAvailableWidgetsModel();
}
}
NTabBar {
id: subTabBar
Layout.fillWidth: true
Layout.bottomMargin: Style.marginM
distributeEvenly: true
currentIndex: tabView.currentIndex
NTabButton {
text: I18n.tr("common.appearance")
tabIndex: 0
checked: subTabBar.currentIndex === 0
}
NTabButton {
text: I18n.tr("common.widgets")
tabIndex: 1
checked: subTabBar.currentIndex === 1
}
NTabButton {
text: I18n.tr("common.behavior")
tabIndex: 2
checked: subTabBar.currentIndex === 2
}
NTabButton {
text: I18n.tr("common.monitors")
tabIndex: 3
checked: subTabBar.currentIndex === 3
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.marginS
}
NTabView {
id: tabView
currentIndex: subTabBar.currentIndex
AppearanceSubTab {}
WidgetsSubTab {
availableWidgets: availableWidgets
addWidgetToSection: root._addWidgetToSection
removeWidgetFromSection: root._removeWidgetFromSection
reorderWidgetInSection: root._reorderWidgetInSection
updateWidgetSettingsInSection: root._updateWidgetSettingsInSection
moveWidgetBetweenSections: root._moveWidgetBetweenSections
onOpenPluginSettings: manifest => pluginSettingsDialog.openPluginSettings(manifest)
}
BehaviorSubTab {}
MonitorsSubTab {
addMonitor: root.addMonitor
removeMonitor: root.removeMonitor
}
}
NPluginSettingsPopup {
id: pluginSettingsDialog
parent: Overlay.overlay
showToastOnSave: false
}
}
@@ -0,0 +1,172 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.Compositor
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
readonly property string effectiveWheelAction: Settings.data.bar.mouseWheelAction || "none"
readonly property string effectiveMiddleClickAction: Settings.data.bar.middleClickAction || "none"
readonly property string effectiveRightClickAction: Settings.data.bar.rightClickAction || "controlCenter"
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-workspace-scroll-label")
description: I18n.tr("panels.bar.behavior-workspace-scroll-description")
model: {
var items = [
{
"key": "none",
"name": I18n.tr("common.none")
},
{
"key": "volume",
"name": I18n.tr("common.volume")
},
{
"key": "workspace",
"name": I18n.tr("panels.bar.behavior-workspace-scroll-option-workspace")
}
];
if (CompositorService.isNiri) {
items.push({
"key": "content",
"name": I18n.tr("panels.bar.behavior-workspace-scroll-option-content")
});
}
return items;
}
currentKey: root.effectiveWheelAction
defaultValue: Settings.getDefaultValue("bar.mouseWheelAction")
onSelected: key => Settings.data.bar.mouseWheelAction = key
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.general.reverse-scrolling-label")
description: I18n.tr("panels.general.reverse-scrolling-description")
checked: Settings.data.bar.reverseScroll
defaultValue: Settings.getDefaultValue("bar.reverseScroll")
onToggled: checked => Settings.data.bar.reverseScroll = checked
visible: Settings.data.bar.mouseWheelAction !== "none"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-wheel-wrap-label")
description: I18n.tr("panels.bar.behavior-wheel-wrap-description")
checked: Settings.data.bar.mouseWheelWrap
defaultValue: Settings.getDefaultValue("bar.mouseWheelWrap")
onToggled: checked => Settings.data.bar.mouseWheelWrap = checked
visible: Settings.data.bar.mouseWheelAction === "workspace"
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-middle-click-label")
description: I18n.tr("panels.bar.behavior-middle-click-description")
model: [
{
"key": "none",
"name": I18n.tr("common.none")
},
{
"key": "controlCenter",
"name": I18n.tr("tooltips.open-control-center")
},
{
"key": "settings",
"name": I18n.tr("tooltips.open-settings")
},
{
"key": "launcherPanel",
"name": I18n.tr("actions.open-launcher")
},
{
"key": "command",
"name": I18n.tr("actions.run-custom-command")
}
]
currentKey: root.effectiveMiddleClickAction
defaultValue: Settings.getDefaultValue("bar.middleClickAction")
onSelected: key => Settings.data.bar.middleClickAction = key
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-middle-click-command-label")
description: I18n.tr("panels.bar.behavior-middle-click-command-description")
placeholderText: I18n.tr("panels.bar.behavior-middle-click-command-placeholder")
text: Settings.data.bar.middleClickCommand
fontFamily: Settings.data.ui.fontFixed
onTextChanged: Settings.data.bar.middleClickCommand = text
visible: Settings.data.bar.middleClickAction === "command"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-middle-click-follow-mouse-label")
description: I18n.tr("panels.bar.behavior-middle-click-follow-mouse-description")
checked: Settings.data.bar.middleClickFollowMouse
defaultValue: Settings.getDefaultValue("bar.middleClickFollowMouse")
onToggled: checked => Settings.data.bar.middleClickFollowMouse = checked
visible: Settings.data.bar.middleClickAction !== "none" && Settings.data.bar.middleClickAction !== "command" && !(Settings.data.bar.middleClickAction === "settings" && Settings.data.ui.settingsPanelMode === "window")
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-right-click-label")
description: I18n.tr("panels.bar.behavior-right-click-description")
model: [
{
"key": "none",
"name": I18n.tr("common.none")
},
{
"key": "controlCenter",
"name": I18n.tr("tooltips.open-control-center")
},
{
"key": "settings",
"name": I18n.tr("tooltips.open-settings")
},
{
"key": "launcherPanel",
"name": I18n.tr("actions.open-launcher")
},
{
"key": "command",
"name": I18n.tr("actions.run-custom-command")
}
]
currentKey: root.effectiveRightClickAction
defaultValue: Settings.getDefaultValue("bar.rightClickAction")
onSelected: key => Settings.data.bar.rightClickAction = key
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-right-click-command-label")
description: I18n.tr("panels.bar.behavior-right-click-command-description")
placeholderText: I18n.tr("panels.bar.behavior-right-click-command-placeholder")
text: Settings.data.bar.rightClickCommand
fontFamily: Settings.data.ui.fontFixed
onTextChanged: Settings.data.bar.rightClickCommand = text
visible: Settings.data.bar.rightClickAction === "command"
}
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.behavior-right-click-follow-mouse-label")
description: I18n.tr("panels.bar.behavior-right-click-follow-mouse-description")
checked: Settings.data.bar.rightClickFollowMouse
defaultValue: Settings.getDefaultValue("bar.rightClickFollowMouse")
onToggled: checked => Settings.data.bar.rightClickFollowMouse = checked
visible: Settings.data.bar.rightClickAction !== "none" && Settings.data.bar.rightClickAction !== "command" && !(Settings.data.bar.rightClickAction === "settings" && Settings.data.ui.settingsPanelMode === "window")
}
}
@@ -0,0 +1,276 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import "../../Bar" as BarSettings
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
property var addMonitor
property var removeMonitor
NText {
text: I18n.tr("panels.bar.monitors-desc-new")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
// Monitor cards
Repeater {
model: Quickshell.screens || []
delegate: NBox {
id: monitorCard
Layout.fillWidth: true
implicitHeight: cardContent.implicitHeight + Style.margin2L
color: Color.mSurface
required property var modelData
readonly property string screenName: modelData.name || "Unknown"
readonly property real compositorScale: {
const info = CompositorService.displayScales[screenName];
return (info && info.scale) ? info.scale : 1.0;
}
readonly property bool barEnabled: (Settings.data.bar.monitors || []).indexOf(screenName) !== -1
readonly property bool hasOverride: Settings.hasScreenOverride(screenName)
// Track if override is enabled (controls both visibility AND whether overrides are applied)
readonly property bool overrideEnabled: Settings.isScreenOverrideEnabled(screenName)
// Get effective values for this screen
readonly property string effectivePosition: Settings.getBarPositionForScreen(screenName)
readonly property string effectiveDensity: Settings.getBarDensityForScreen(screenName)
ColumnLayout {
id: cardContent
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
RowLayout {
Layout.fillWidth: true
// Header: Monitor name and specs
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS
NText {
Layout.fillWidth: true
text: monitorCard.screenName
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: Color.mOnSurface
}
NText {
text: {
return I18n.tr("system.monitor-description", {
"model": monitorCard.modelData.model || I18n.tr("common.unknown"),
"width": Math.round(monitorCard.modelData.width * monitorCard.compositorScale),
"height": Math.round(monitorCard.modelData.height * monitorCard.compositorScale),
"scale": monitorCard.compositorScale
});
}
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
// Enable bar toggle
NToggle {
Layout.fillWidth: true
checked: monitorCard.barEnabled
onToggled: checked => {
if (checked) {
Settings.data.bar.monitors = root.addMonitor(Settings.data.bar.monitors, monitorCard.screenName);
} else {
Settings.data.bar.monitors = root.removeMonitor(Settings.data.bar.monitors, monitorCard.screenName);
}
}
}
}
NDivider {
Layout.fillWidth: true
visible: Settings.data.bar.monitors.includes(monitorCard.screenName)
}
// Override section (only visible when bar is enabled)
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
visible: monitorCard.barEnabled
// Override toggle
NToggle {
Layout.fillWidth: true
label: I18n.tr("panels.bar.monitor-override-settings")
description: I18n.tr("panels.bar.monitor-override-settings-description")
checked: monitorCard.overrideEnabled
onToggled: checked => {
Settings.setScreenOverride(monitorCard.screenName, "enabled", checked);
BarService.widgetsRevision++;
}
}
// Override controls (only visible when override toggle is on)
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
visible: monitorCard.overrideEnabled
// Position override
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-position-label")
description: I18n.tr("panels.bar.appearance-position-description")
model: [
{
"key": "top",
"name": I18n.tr("positions.top")
},
{
"key": "bottom",
"name": I18n.tr("positions.bottom")
},
{
"key": "left",
"name": I18n.tr("positions.left")
},
{
"key": "right",
"name": I18n.tr("positions.right")
}
]
currentKey: monitorCard.effectivePosition
onSelected: key => Settings.setScreenOverride(monitorCard.screenName, "position", key)
}
}
// Density override
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.bar.appearance-density-label")
description: I18n.tr("panels.bar.appearance-density-description")
model: [
{
"key": "mini",
"name": I18n.tr("options.bar.density-mini")
},
{
"key": "compact",
"name": I18n.tr("options.bar.density-compact")
},
{
"key": "default",
"name": I18n.tr("options.bar.density-default")
},
{
"key": "comfortable",
"name": I18n.tr("options.bar.density-comfortable")
},
{
"key": "spacious",
"name": I18n.tr("options.bar.density-spacious")
}
]
currentKey: monitorCard.effectiveDensity
onSelected: key => Settings.setScreenOverride(monitorCard.screenName, "density", key)
}
}
// DisplayMode override
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NComboBox {
Layout.fillWidth: true
label: I18n.tr("common.display-mode")
description: I18n.tr("panels.bar.appearance-display-mode-description")
model: [
{
"key": "always_visible",
"name": I18n.tr("hide-modes.visible")
},
{
"key": "non_exclusive",
"name": I18n.tr("hide-modes.non-exclusive")
},
{
"key": "auto_hide",
"name": I18n.tr("hide-modes.auto-hide")
}
]
currentKey: Settings.getBarDisplayModeForScreen(monitorCard.screenName)
onSelected: key => Settings.setScreenOverride(monitorCard.screenName, "displayMode", key)
}
}
// Widgets configuration button and Reset all
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NButton {
id: widgetConfigButton
property bool expanded: false
Layout.fillWidth: true
fontSize: Style.fontSizeS
text: I18n.tr("panels.bar.monitor-configure-widgets")
icon: expanded ? "chevron-up" : "layout-grid"
onClicked: expanded = !expanded
}
NButton {
visible: Settings.hasScreenOverride(monitorCard.screenName, "widgets")
Layout.fillWidth: true
fontSize: Style.fontSizeS
text: I18n.tr("panels.bar.use-global-widgets")
icon: "refresh"
onClicked: {
Settings.clearScreenOverride(monitorCard.screenName, "widgets");
BarService.widgetsRevision++;
}
}
NButton {
Layout.fillWidth: true
fontSize: Style.fontSizeS
text: I18n.tr("panels.bar.monitor-reset-all")
icon: "restore"
onClicked: {
Settings.clearScreenOverride(monitorCard.screenName);
BarService.widgetsRevision++;
}
}
}
// Inline widget configuration
BarSettings.MonitorWidgetsConfig {
visible: widgetConfigButton.expanded
screen: monitorCard.modelData
Layout.fillWidth: true
Layout.topMargin: Style.marginS
}
}
}
}
}
}
}
@@ -0,0 +1,97 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
property var availableWidgets
property var addWidgetToSection
property var removeWidgetFromSection
property var reorderWidgetInSection
property var updateWidgetSettingsInSection
property var moveWidgetBetweenSections
signal openPluginSettings(var manifest)
// This sub-tab edits the global default widget configuration (Settings.data.bar.widgets).
// Per-screen widget overrides are edited in MonitorWidgetsConfig.qml (Monitors sub-tab).
// determine bar orientation
readonly property string barPosition: Settings.data.bar.position
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
function getSectionIcons() {
return {
"left": "arrow-bar-to-up",
"center": "layout-distribute-horizontal",
"right": "arrow-bar-to-down"
};
}
NText {
text: I18n.tr("panels.bar.widgets-desc")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
// Left Section
NSectionEditor {
sectionName: root.barIsVertical ? I18n.tr("positions.top") : I18n.tr("positions.left")
sectionId: "left"
barIsVertical: root.barIsVertical
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml")
widgetRegistry: BarWidgetRegistry
widgetModel: Settings.data.bar.widgets.left
sectionIcons: root.getSectionIcons()
availableWidgets: root.availableWidgets
onAddWidget: (widgetId, section) => root.addWidgetToSection(widgetId, section)
onRemoveWidget: (section, index) => root.removeWidgetFromSection(section, index)
onReorderWidget: (section, fromIndex, toIndex) => root.reorderWidgetInSection(section, fromIndex, toIndex)
onUpdateWidgetSettings: (section, index, settings) => root.updateWidgetSettingsInSection(section, index, settings)
onMoveWidget: (fromSection, index, toSection) => root.moveWidgetBetweenSections(fromSection, index, toSection)
onOpenPluginSettingsRequested: manifest => root.openPluginSettings(manifest)
}
// Center Section
NSectionEditor {
sectionName: I18n.tr("positions.center")
sectionId: "center"
barIsVertical: root.barIsVertical
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml")
widgetRegistry: BarWidgetRegistry
widgetModel: Settings.data.bar.widgets.center
sectionIcons: root.getSectionIcons()
availableWidgets: root.availableWidgets
onAddWidget: (widgetId, section) => root.addWidgetToSection(widgetId, section)
onRemoveWidget: (section, index) => root.removeWidgetFromSection(section, index)
onReorderWidget: (section, fromIndex, toIndex) => root.reorderWidgetInSection(section, fromIndex, toIndex)
onUpdateWidgetSettings: (section, index, settings) => root.updateWidgetSettingsInSection(section, index, settings)
onMoveWidget: (fromSection, index, toSection) => root.moveWidgetBetweenSections(fromSection, index, toSection)
onOpenPluginSettingsRequested: manifest => root.openPluginSettings(manifest)
}
// Right Section
NSectionEditor {
sectionName: root.barIsVertical ? I18n.tr("positions.bottom") : I18n.tr("positions.right")
sectionId: "right"
barIsVertical: root.barIsVertical
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Panels/Settings/Bar/BarWidgetSettingsDialog.qml")
widgetRegistry: BarWidgetRegistry
widgetModel: Settings.data.bar.widgets.right
sectionIcons: root.getSectionIcons()
availableWidgets: root.availableWidgets
onAddWidget: (widgetId, section) => root.addWidgetToSection(widgetId, section)
onRemoveWidget: (section, index) => root.removeWidgetFromSection(section, index)
onReorderWidget: (section, fromIndex, toIndex) => root.reorderWidgetInSection(section, fromIndex, toIndex)
onUpdateWidgetSettings: (section, index, settings) => root.updateWidgetSettingsInSection(section, index, settings)
onMoveWidget: (fromSection, index, toSection) => root.moveWidgetBetweenSections(fromSection, index, toSection)
onOpenPluginSettingsRequested: manifest => root.openPluginSettings(manifest)
}
}
@@ -0,0 +1,104 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import "."
import qs.Commons
import qs.Services.System
import qs.Services.Theming
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: 0
// Time dropdown options (00:00 .. 23:30)
ListModel {
id: timeOptions
}
property var screen
function populateTimeOptions() {
for (var h = 0; h < 24; h++) {
for (var m = 0; m < 60; m += 30) {
var hh = ("0" + h).slice(-2);
var mm = ("0" + m).slice(-2);
var key = hh + ":" + mm;
timeOptions.append({
"key": key,
"name": key
});
}
}
}
Component.onCompleted: {
Qt.callLater(populateTimeOptions);
}
// Download popup
Loader {
id: downloadPopupLoader
active: false
sourceComponent: SchemeDownloader {
parent: Overlay.overlay
}
property bool pendingOpen: false
function open() {
pendingOpen = true;
active = true;
if (item) {
item.open();
pendingOpen = false;
}
}
onItemChanged: {
if (item && pendingOpen) {
item.open();
pendingOpen = false;
}
}
}
NTabBar {
id: subTabBar
Layout.fillWidth: true
Layout.bottomMargin: Style.marginM
distributeEvenly: true
currentIndex: tabView.currentIndex
NTabButton {
text: I18n.tr("common.colors")
tabIndex: 0
checked: subTabBar.currentIndex === 0
}
NTabButton {
text: I18n.tr("common.templates")
tabIndex: 1
checked: subTabBar.currentIndex === 1
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.marginL
}
NTabView {
id: tabView
currentIndex: subTabBar.currentIndex
ColorsSubTab {
screen: root.screen
timeOptions: timeOptions
onOpenDownloadPopup: downloadPopupLoader.open()
}
TemplatesSubTab {}
}
}
@@ -0,0 +1,458 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.System
import qs.Services.Theming
import qs.Services.UI
import qs.Widgets
ColumnLayout {
id: root
spacing: Style.marginL
Layout.fillWidth: true
property var timeOptions
property var schemeColorsCache: ({})
property int cacheVersion: 0
property var screen
signal openDownloadPopup
function extractSchemeName(schemePath) {
var pathParts = schemePath.split("/");
var filename = pathParts[pathParts.length - 1];
var schemeName = filename.replace(".json", "");
if (schemeName === "Noctalia-default") {
schemeName = "Noctalia (default)";
} else if (schemeName === "Noctalia-legacy") {
schemeName = "Noctalia (legacy)";
} else if (schemeName === "Tokyo-Night") {
schemeName = "Tokyo Night";
} else if (schemeName === "Rosepine") {
schemeName = "Rose Pine";
}
return schemeName;
}
function getSchemeColor(schemeName, colorKey) {
var _ = cacheVersion;
if (schemeColorsCache[schemeName]) {
var entry = schemeColorsCache[schemeName];
var variant = entry;
if (entry.dark || entry.light) {
variant = Settings.data.colorSchemes.darkMode ? (entry.dark || entry.light) : (entry.light || entry.dark);
}
if (variant && variant[colorKey]) {
return variant[colorKey];
}
}
if (colorKey === "mSurface")
return Color.mSurfaceVariant;
if (colorKey === "mPrimary")
return Color.mPrimary;
if (colorKey === "mSecondary")
return Color.mSecondary;
if (colorKey === "mTertiary")
return Color.mTertiary;
if (colorKey === "mError")
return Color.mError;
return Color.mOnSurfaceVariant;
}
function schemeLoaded(schemeName, jsonData) {
var value = jsonData || {};
schemeColorsCache[schemeName] = value;
cacheVersion++;
}
Connections {
target: ColorSchemeService
function onSchemesChanged() {
root.schemeColorsCache = {};
root.cacheVersion++;
}
}
Item {
id: fileLoaders
visible: false
Repeater {
model: ColorSchemeService.schemes
delegate: Item {
FileView {
path: modelData
blockLoading: false
onLoaded: {
var schemeName = root.extractSchemeName(path);
try {
var jsonData = JSON.parse(text());
root.schemeLoaded(schemeName, jsonData);
} catch (e) {
Logger.w("ColorSchemeTab", "Failed to parse JSON for scheme:", schemeName, e);
root.schemeLoaded(schemeName, null);
}
}
}
}
}
}
NToggle {
label: I18n.tr("tooltips.switch-to-dark-mode")
description: I18n.tr("panels.color-scheme.dark-mode-switch-description")
checked: Settings.data.colorSchemes.darkMode
onToggled: checked => {
Settings.data.colorSchemes.darkMode = checked;
root.cacheVersion++;
}
}
NToggle {
label: I18n.tr("panels.color-scheme.sync-gsettings-label")
description: I18n.tr("panels.color-scheme.sync-gsettings-description")
checked: Settings.data.colorSchemes.syncGsettings
onToggled: checked => {
Settings.data.colorSchemes.syncGsettings = checked;
if (checked)
ColorSchemeService.pushSystemColorScheme();
}
}
NComboBox {
label: I18n.tr("panels.color-scheme.dark-mode-mode-label")
description: I18n.tr("panels.color-scheme.dark-mode-mode-description")
model: [
{
"name": I18n.tr("panels.color-scheme.dark-mode-mode-off"),
"key": "off"
},
{
"name": I18n.tr("panels.color-scheme.dark-mode-mode-manual"),
"key": "manual"
},
{
"name": I18n.tr("common.location"),
"key": "location"
}
]
currentKey: Settings.data.colorSchemes.schedulingMode
onSelected: key => {
Settings.data.colorSchemes.schedulingMode = key;
AppThemeService.generate();
}
}
ColumnLayout {
spacing: Style.marginS
visible: Settings.data.colorSchemes.schedulingMode === "manual"
NLabel {
label: I18n.tr("panels.display.night-light-manual-schedule-label")
description: I18n.tr("panels.display.night-light-manual-schedule-description")
}
RowLayout {
Layout.fillWidth: false
spacing: Style.marginS
NText {
text: I18n.tr("panels.display.night-light-manual-schedule-sunrise")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
}
NComboBox {
model: root.timeOptions
currentKey: Settings.data.colorSchemes.manualSunrise
placeholder: I18n.tr("panels.display.night-light-manual-schedule-select-start")
onSelected: key => Settings.data.colorSchemes.manualSunrise = key
minimumWidth: 120
}
Item {
Layout.preferredWidth: 20
}
NText {
text: I18n.tr("panels.display.night-light-manual-schedule-sunset")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
}
NComboBox {
model: root.timeOptions
currentKey: Settings.data.colorSchemes.manualSunset
placeholder: I18n.tr("panels.display.night-light-manual-schedule-select-stop")
onSelected: key => Settings.data.colorSchemes.manualSunset = key
minimumWidth: 120
}
}
}
NDivider {
Layout.fillWidth: true
}
NToggle {
label: I18n.tr("panels.color-scheme.color-source-use-wallpaper-colors-label")
description: I18n.tr("panels.color-scheme.color-source-use-wallpaper-colors-description")
checked: Settings.data.colorSchemes.useWallpaperColors
onToggled: checked => {
Settings.data.colorSchemes.useWallpaperColors = checked;
if (checked) {
AppThemeService.generate();
} else {
ToastService.showNotice(I18n.tr("toast.wallpaper-colors.label"), I18n.tr("toast.wallpaper-colors.disabled"), "settings-color-scheme");
if (Settings.data.colorSchemes.predefinedScheme) {
ColorSchemeService.applyScheme(Settings.data.colorSchemes.predefinedScheme);
}
}
}
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.color-scheme.wallpaper-monitor-source-label")
description: I18n.tr("panels.color-scheme.wallpaper-monitor-source-description")
enabled: Settings.data.colorSchemes.useWallpaperColors
model: {
var m = [];
if (Quickshell.screens) {
for (var i = 0; i < Quickshell.screens.length; i++) {
var screen = Quickshell.screens[i];
var name = screen.name;
var displayName = name + " (" + screen.width + "x" + screen.height + ")";
m.push({
"key": name,
"name": displayName
});
}
}
return m;
}
currentKey: Settings.data.colorSchemes.monitorForColors || (screen ? screen.name : "")
onSelected: key => {
Settings.data.colorSchemes.monitorForColors = key;
AppThemeService.generate();
}
defaultValue: ""
}
NComboBox {
Layout.fillWidth: true
label: I18n.tr("panels.color-scheme.wallpaper-method-label")
description: I18n.tr("panels.color-scheme.wallpaper-method-description")
enabled: Settings.data.colorSchemes.useWallpaperColors
model: TemplateProcessor.schemeTypes
currentKey: Settings.data.colorSchemes.generationMethod
onSelected: key => {
Settings.data.colorSchemes.generationMethod = key;
AppThemeService.generate();
}
}
NBox {
visible: Settings.data.colorSchemes.useWallpaperColors
Layout.fillWidth: true
implicitHeight: descriptionColumn.implicitHeight + Style.margin2L
color: Color.mSurface
Column {
id: descriptionColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginL
spacing: Style.marginM
NText {
width: parent.width
wrapMode: Text.WordWrap
text: I18n.tr("panels.color-scheme.method-description." + Settings.data.colorSchemes.generationMethod)
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
Row {
id: colorPreviewRow
spacing: Style.marginS
property int diameter: 16 * Style.uiScaleRatio
Repeater {
model: [Color.mPrimary, Color.mSecondary, Color.mTertiary, Color.mError]
Rectangle {
width: colorPreviewRow.diameter
height: colorPreviewRow.diameter
radius: width * 0.5
color: modelData
}
}
}
}
}
NDivider {
Layout.fillWidth: true
}
ColumnLayout {
spacing: Style.marginM
Layout.fillWidth: true
enabled: !Settings.data.colorSchemes.useWallpaperColors
NHeader {
label: I18n.tr("panels.color-scheme.predefined-title")
description: I18n.tr("panels.color-scheme.predefined-desc")
Layout.fillWidth: true
}
GridLayout {
columns: 2
rowSpacing: Style.marginM
columnSpacing: Style.marginM
Layout.fillWidth: true
Repeater {
model: ColorSchemeService.schemes
Rectangle {
id: schemeItem
property string schemePath: modelData
property string schemeName: root.extractSchemeName(modelData)
opacity: enabled ? 1.0 : 0.6
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
height: 50 * Style.uiScaleRatio
radius: Style.radiusS
color: root.getSchemeColor(schemeName, "mSurface")
border.width: Style.borderL
border.color: {
if ((Settings.data.colorSchemes.predefinedScheme === schemeName) && schemeItem.enabled) {
return Color.mSecondary;
}
if (itemMouseArea.containsMouse) {
return Color.mHover;
}
return Color.mOutline;
}
RowLayout {
id: scheme
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginS
NText {
text: schemeItem.schemeName
pointSize: Style.fontSizeS
color: Color.mOnSurface
Layout.fillWidth: true
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
maximumLineCount: 1
}
property int diameter: 16 * Style.uiScaleRatio
Rectangle {
width: scheme.diameter
height: scheme.diameter
radius: scheme.diameter * 0.5
color: root.getSchemeColor(schemeItem.schemeName, "mPrimary")
}
Rectangle {
width: scheme.diameter
height: scheme.diameter
radius: scheme.diameter * 0.5
color: root.getSchemeColor(schemeItem.schemeName, "mSecondary")
}
Rectangle {
width: scheme.diameter
height: scheme.diameter
radius: scheme.diameter * 0.5
color: root.getSchemeColor(schemeItem.schemeName, "mTertiary")
}
Rectangle {
width: scheme.diameter
height: scheme.diameter
radius: scheme.diameter * 0.5
color: root.getSchemeColor(schemeItem.schemeName, "mError")
}
}
MouseArea {
id: itemMouseArea
anchors.fill: parent
enabled: schemeItem.enabled
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Settings.data.colorSchemes.useWallpaperColors = false;
Logger.i("ColorSchemeTab", "Disabled wallpaper colors");
Settings.data.colorSchemes.predefinedScheme = schemeItem.schemeName;
ColorSchemeService.applyScheme(Settings.data.colorSchemes.predefinedScheme);
}
}
Rectangle {
visible: (Settings.data.colorSchemes.predefinedScheme === schemeItem.schemeName) && schemeItem.enabled
anchors.right: parent.right
anchors.top: parent.top
anchors.rightMargin: 0
anchors.topMargin: -3
width: 20
height: 20
radius: Math.min(Style.radiusL, width / 2)
color: Color.mSecondary
border.width: Style.borderS
border.color: Color.mOnSecondary
NIcon {
icon: "check"
pointSize: Style.fontSizeXS
color: Color.mOnSecondary
anchors.centerIn: parent
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationNormal
}
}
}
}
}
NButton {
text: I18n.tr("panels.color-scheme.download-button")
icon: "download"
onClicked: root.openDownloadPopup()
Layout.alignment: Qt.AlignRight
Layout.topMargin: Style.marginS
}
}
}
@@ -0,0 +1,972 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Theming
import qs.Services.UI
import qs.Widgets
Popup {
id: root
property var availableSchemes: []
property bool fetching: false
property bool downloading: false
property bool hasInitialData: false // Track if we've loaded data at least once
property string downloadError: ""
property string downloadingScheme: ""
property string pendingApplyScheme: "" // Scheme name to apply after reload
property string lastStderrOutput: "" // Store stderr from download process
property real lastApiFetchTime: 0 // Track when we last fetched from API to prevent rapid calls
property int minApiFetchInterval: 60 // Minimum seconds between API fetches (1 minute)
// Cache for remote scheme colors
property var schemeColorsCache: ({})
property int cacheVersion: 0
// Cache for available schemes list (uses ShellState singleton)
property int schemesCacheUpdateFrequency: 2 * 60 * 60 // 2 hours in seconds
// Cache for repo branch info (to reduce API calls during downloads)
property string cachedBranch: "main"
property string cachedBranchSha: ""
width: Math.max(500, contentColumn.implicitWidth + Style.margin2XL)
height: Math.min(800, contentColumn.implicitHeight + Style.margin2XL)
padding: Style.marginXL
modal: true
closePolicy: Popup.NoAutoClose
dim: false
anchors.centerIn: parent
// Helper function to get color from cached scheme data
function getSchemeColor(schemeName, colorKey) {
var _ = cacheVersion; // Create dependency
if (schemeColorsCache[schemeName]) {
var entry = schemeColorsCache[schemeName];
var variant = entry;
// Check if scheme has dark/light variants
if (entry.dark || entry.light) {
variant = Settings.data.colorSchemes.darkMode ? (entry.dark || entry.light) : (entry.light || entry.dark);
}
if (variant && variant[colorKey]) {
return variant[colorKey];
}
}
// Return visible defaults while loading
var defaults = {
"mSurface": Color.mSurfaceVariant,
"mPrimary": Color.mPrimary,
"mSecondary": Color.mSecondary,
"mTertiary": Color.mTertiary,
"mError": Color.mError,
"mOnSurface": Color.mOnSurfaceVariant
};
return defaults[colorKey] || Color.mOnSurfaceVariant;
}
// Colors are now provided directly in the registry, no need to fetch individual files
function fetchSchemeColors(scheme) {
return;
}
background: Rectangle {
color: Color.mSurface
radius: Style.radiusL
border.color: Color.mPrimary
border.width: Style.borderM
}
function loadSchemesFromCache() {
try {
const now = Time.timestamp;
const cacheData = ShellState.getColorSchemesList();
const cachedSchemes = cacheData.schemes || [];
const cachedTimestamp = cacheData.timestamp || 0;
// Check if cache is expired or missing
if (!cachedTimestamp || (now >= cachedTimestamp + schemesCacheUpdateFrequency)) {
// Migration is now handled in Settings.qml
// Only fetch from API if we haven't fetched recently (prevent rapid repeated calls)
const timeSinceLastFetch = now - lastApiFetchTime;
if (timeSinceLastFetch >= minApiFetchInterval) {
Logger.d("ColorSchemeDownload", "Cache expired or missing, fetching new schemes");
fetchAvailableSchemesFromAPI();
return;
} else {
// Use cached data even if expired, to avoid rate limits
Logger.d("ColorSchemeDownload", "Cache expired but recent API call detected, using cached data");
if (cachedSchemes.length > 0) {
availableSchemes = cachedSchemes;
hasInitialData = true;
fetching = false;
return;
}
}
}
const ageMinutes = Math.round((now - cachedTimestamp) / 60);
Logger.d("ColorSchemeDownload", "Loading cached schemes from ShellState (age:", ageMinutes, "minutes)");
if (cachedSchemes.length > 0) {
availableSchemes = cachedSchemes;
// Restore color cache from cached schemes
for (var i = 0; i < cachedSchemes.length; i++) {
var cachedScheme = cachedSchemes[i];
if (cachedScheme.dark || cachedScheme.light) {
schemeColorsCache[cachedScheme.name] = {
"dark": cachedScheme.dark || null,
"light": cachedScheme.light || null
};
}
}
hasInitialData = true;
cacheVersion++;
fetching = false;
} else {
// Cache is empty, only fetch if we haven't fetched recently
const timeSinceLastFetch = now - lastApiFetchTime;
if (timeSinceLastFetch >= minApiFetchInterval) {
fetchAvailableSchemesFromAPI();
} else {
Logger.d("ColorSchemeDownload", "Cache empty but recent API call detected, skipping fetch");
fetching = false;
}
}
} catch (error) {
Logger.e("ColorSchemeDownload", "Failed to load schemes from cache:", error);
fetching = false;
}
}
function saveSchemesToCache() {
try {
ShellState.setColorSchemesList({
schemes: availableSchemes,
timestamp: Time.timestamp
});
Logger.d("ColorSchemeDownload", "Schemes list saved to ShellState");
} catch (error) {
Logger.e("ColorSchemeDownload", "Failed to save schemes to cache:", error);
}
}
function fetchAvailableSchemes() {
if (fetching) {
return;
}
// Try to load from ShellState cache first
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
loadSchemesFromCache();
} else {
// ShellState not ready, fetch directly from API
fetchAvailableSchemesFromAPI();
}
}
function fetchAvailableSchemesFromAPI() {
if (fetching) {
return;
}
// Check if we've fetched recently to prevent rapid repeated calls
const now = Time.timestamp;
const timeSinceLastFetch = now - lastApiFetchTime;
if (timeSinceLastFetch < minApiFetchInterval) {
Logger.d("ColorSchemeDownload", "Skipping API fetch - too soon since last fetch (", Math.round(timeSinceLastFetch), "s ago)");
return;
}
fetching = true;
lastApiFetchTime = now;
// Don't clear availableSchemes immediately to prevent flicker - keep showing old list while fetching
// availableSchemes = [];
downloadError = "";
// Fetch registry.json
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
fetching = false;
if (xhr.status === 200) {
try {
var registry = JSON.parse(xhr.responseText);
if (registry && registry.themes && Array.isArray(registry.themes)) {
var schemes = [];
// Process themes
for (var i = 0; i < registry.themes.length; i++) {
var theme = registry.themes[i];
schemes.push({
"name": theme.name,
"path": theme.path,
"dark": theme.dark || null,
"light": theme.light || null
});
schemeColorsCache[theme.name] = {
"dark": theme.dark || null,
"light": theme.light || null
};
}
availableSchemes = schemes;
hasInitialData = true;
cacheVersion++;
Logger.d("ColorSchemeDownload", "Fetched", schemes.length, "available schemes from registry.json");
// Save to cache
saveSchemesToCache();
} else {
downloadError = I18n.tr("panels.color-scheme.download-error-invalid-response");
Logger.e("ColorSchemeDownload", downloadError);
}
} catch (e) {
downloadError = I18n.tr("panels.color-scheme.download-error-parse-failed", {
"error": e.toString()
});
Logger.e("ColorSchemeDownload", downloadError);
}
} else if (xhr.status === 403) {
// Rate limit hit - try to use cache if available
downloadError = I18n.tr("panels.color-scheme.download-error-rate-limit");
Logger.w("ColorSchemeDownload", downloadError);
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
const cacheData = ShellState.getColorSchemesList();
const cachedSchemes = cacheData.schemes || [];
if (cachedSchemes.length > 0) {
availableSchemes = cachedSchemes;
// Restore color cache from cached schemes
for (var j = 0; j < cachedSchemes.length; j++) {
var cachedScheme = cachedSchemes[j];
if (cachedScheme.dark || cachedScheme.light) {
schemeColorsCache[cachedScheme.name] = {
"dark": cachedScheme.dark || null,
"light": cachedScheme.light || null
};
}
}
hasInitialData = true;
cacheVersion++;
Logger.i("ColorSchemeDownload", "Using cached schemes due to rate limit");
}
}
} else {
downloadError = I18n.tr("panels.color-scheme.download-error-api-error", {
"status": xhr.status
});
Logger.e("ColorSchemeDownload", downloadError);
}
}
};
xhr.open("GET", "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/main/registry.json");
xhr.send();
}
function downloadScheme(scheme) {
if (downloading) {
return;
}
downloading = true;
downloadingScheme = scheme.name;
downloadError = "";
Logger.i("ColorSchemeDownload", "Downloading scheme:", scheme.name);
// Use cached branch/SHA if available, otherwise fetch
if (cachedBranchSha) {
// Use cached SHA directly
getSchemeTreeWithSha(scheme, cachedBranch, cachedBranchSha);
} else if (cachedBranch) {
// We have branch name, just need SHA
getSchemeTree(scheme, cachedBranch);
} else {
// Need to fetch branch info first
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var repoInfo = JSON.parse(xhr.responseText);
var defaultBranch = repoInfo.default_branch || "main";
cachedBranch = defaultBranch;
// Now get the tree for the scheme directory
getSchemeTree(scheme, defaultBranch);
} catch (e) {
// Fallback: try to get files directly
getSchemeFilesDirect(scheme);
}
} else {
// Fallback: try to get files directly
getSchemeFilesDirect(scheme);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes");
xhr.send();
}
}
function getSchemeTree(scheme, branch) {
// First get the SHA of the branch
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var refResponse = JSON.parse(xhr.responseText);
var sha = refResponse.object ? refResponse.object.sha : null;
if (sha) {
// Cache the SHA for future downloads
cachedBranchSha = sha;
// Now get the tree
getSchemeTreeWithSha(scheme, branch, sha);
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
} catch (e) {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/git/refs/heads/" + branch);
xhr.send();
}
function getSchemeTreeWithSha(scheme, branch, sha) {
// Use git trees API to get all files recursively
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (response.tree && Array.isArray(response.tree)) {
// Filter files that belong to this scheme
var files = [];
for (var i = 0; i < response.tree.length; i++) {
var item = response.tree[i];
if (item.type === "blob" && item.path.startsWith(scheme.path + "/")) {
files.push({
"path": item.path,
"url": "https://raw.githubusercontent.com/noctalia-dev/noctalia-colorschemes/" + branch + "/" + item.path,
"name": item.path.split("/").pop()
});
}
}
downloadSchemeFiles(scheme.name, files);
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
} catch (e) {
downloadError = I18n.tr("panels.color-scheme.download-error-parse-failed", {
"error": e.toString()
});
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
} else {
// Fallback to direct method
getSchemeFilesDirect(scheme);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/git/trees/" + sha + "?recursive=1");
xhr.send();
}
function getSchemeFilesDirect(scheme) {
// Fallback: get files directly using contents API (non-recursive, but works)
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
var response = JSON.parse(xhr.responseText);
if (Array.isArray(response)) {
// Recursively get all files
getAllFilesRecursive(scheme, response, []);
} else {
downloadError = I18n.tr("panels.color-scheme.download-error-invalid-response");
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
} catch (e) {
downloadError = I18n.tr("panels.color-scheme.download-error-parse-failed", {
"error": e.toString()
});
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
} else {
downloadError = I18n.tr("panels.color-scheme.download-error-api-error", {
"status": xhr.status
});
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
}
}
};
xhr.open("GET", "https://api.github.com/repos/noctalia-dev/noctalia-colorschemes/contents/" + scheme.path);
xhr.send();
}
function getAllFilesRecursive(scheme, items, allFiles, callback) {
if (!callback) {
callback = function () {
downloadSchemeFiles(scheme.name, allFiles);
};
}
if (items.length === 0) {
callback();
return;
}
var pending = 0;
var completed = 0;
function checkComplete() {
completed++;
if (completed === items.length && pending === 0) {
callback();
}
}
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.type === "file") {
allFiles.push({
"path": item.path,
"url": item.download_url,
"name": item.name
});
checkComplete();
} else if (item.type === "dir") {
pending++;
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function (dirItem) {
return function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
pending--;
if (xhr.status === 200) {
try {
var dirResponse = JSON.parse(xhr.responseText);
if (Array.isArray(dirResponse)) {
getAllFilesRecursive(scheme, dirResponse, allFiles, function () {
checkComplete();
});
} else {
checkComplete();
}
} catch (e) {
Logger.e("ColorSchemeDownload", "Failed to parse directory response:", e);
checkComplete();
}
} else {
checkComplete();
}
}
};
}(item);
xhr.open("GET", item.url);
xhr.send();
} else {
checkComplete();
}
}
}
function downloadSchemeFiles(schemeName, files) {
if (files.length === 0) {
downloadError = I18n.tr("panels.color-scheme.download-error-no-files");
downloading = false;
downloadingScheme = "";
Logger.e("ColorSchemeDownload", downloadError);
return;
}
var targetDir = ColorSchemeService.downloadedSchemesDirectory + "/" + schemeName;
var downloadScript = "mkdir -p '" + targetDir + "'\n";
// Build download script for all files
for (var i = 0; i < files.length; i++) {
var file = files[i];
var filePath = file.path;
// Remove scheme name and leading / from path
var relativePath = filePath;
if (filePath.startsWith(schemeName + "/")) {
relativePath = filePath.substring(schemeName.length + 1);
} else if (filePath.startsWith("/" + schemeName + "/")) {
relativePath = filePath.substring(schemeName.length + 2);
}
var localPath = targetDir + "/" + relativePath;
var localDir = localPath.substring(0, localPath.lastIndexOf('/'));
downloadScript += "mkdir -p '" + localDir + "'\n";
var downloadUrl = file.url || file.download_url;
if (downloadUrl) {
downloadScript += "curl -L -s -o '" + localPath + "' '" + downloadUrl + "' || wget -q -O '" + localPath + "' '" + downloadUrl + "'\n";
}
}
Logger.d("ColorSchemeDownload", "Downloading", files.length, "files for scheme", schemeName);
// Execute download script
var stderrOutput = "";
var downloadProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
import qs.Commons
Process {
id: downloadProcess
command: ["sh", "-c", ` + JSON.stringify(downloadScript) + `]
stderr: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
Logger.e("ColorSchemeDownload", "Download stderr:", text);
root.lastStderrOutput = text.trim();
}
}
}
}
`, root, "DownloadProcess_" + schemeName);
downloadProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.i("ColorSchemeDownload", "Scheme downloaded successfully:", schemeName);
ToastService.showNotice(I18n.tr("panels.color-scheme.download-success-title"), I18n.tr("panels.color-scheme.download-success-description", {
"scheme": schemeName
}), "settings-color-scheme");
// Set pending scheme to apply after reload
pendingApplyScheme = schemeName;
// Reload color schemes
ColorSchemeService.loadColorSchemes();
downloading = false;
downloadingScheme = "";
} else {
var errorDetails = "Exit code: " + exitCode;
if (root.lastStderrOutput) {
errorDetails += " - " + root.lastStderrOutput;
}
downloadError = I18n.tr("panels.color-scheme.download-error-download-failed", {
"code": exitCode
}) + "\n" + errorDetails;
Logger.e("ColorSchemeDownload", downloadError);
ToastService.showError(I18n.tr("panels.color-scheme.download-error-title"), I18n.tr("panels.color-scheme.download-error-description", {
"scheme": schemeName
}) + "\n" + errorDetails);
// Clean up the partially downloaded directory on failure
var cleanupScript = "rm -rf '" + targetDir + "'";
var cleanupProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: cleanupProcess
command: ["sh", "-c", ` + JSON.stringify(cleanupScript) + `]
}
`, root, "CleanupProcess_" + schemeName);
cleanupProcess.exited.connect(function (cleanupExitCode) {
if (cleanupExitCode === 0) {
Logger.d("ColorSchemeDownload", "Partially downloaded scheme directory cleaned up:", targetDir);
} else {
Logger.w("ColorSchemeDownload", "Failed to clean up partially downloaded scheme directory:", targetDir);
}
downloading = false;
downloadingScheme = "";
cleanupProcess.destroy();
});
cleanupProcess.running = true;
}
root.lastStderrOutput = "";
downloadProcess.destroy();
});
downloadProcess.running = true;
}
function isSchemeInstalled(schemeName) {
// Check if scheme already exists in ColorSchemeService
for (var i = 0; i < ColorSchemeService.schemes.length; i++) {
var path = ColorSchemeService.schemes[i];
if (path.indexOf("/" + schemeName + "/") !== -1 || path.indexOf("/" + schemeName + ".json") !== -1) {
return true;
}
}
return false;
}
function isSchemeDownloaded(schemeName) {
// Check if scheme is in the downloaded directory (not preinstalled)
for (var i = 0; i < ColorSchemeService.schemes.length; i++) {
var path = ColorSchemeService.schemes[i];
if ((path.indexOf("/" + schemeName + "/") !== -1 || path.indexOf("/" + schemeName + ".json") !== -1) && path.indexOf(ColorSchemeService.downloadedSchemesDirectory) !== -1) {
return true;
}
}
return false;
}
function deleteScheme(schemeName) {
if (downloading) {
return;
}
Logger.i("ColorSchemeDownload", "Deleting scheme:", schemeName);
// Check if the deleted scheme is the currently active one
var currentScheme = Settings.data.colorSchemes.predefinedScheme || "";
var deletedSchemeDisplayName = ColorSchemeService.getBasename(schemeName);
var needsReset = (currentScheme === deletedSchemeDisplayName);
// Only allow deleting downloaded schemes, not preinstalled ones
var targetDir = ColorSchemeService.downloadedSchemesDirectory + "/" + schemeName;
var deleteScript = "rm -rf '" + targetDir + "'";
var deleteProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
id: deleteProcess
command: ["sh", "-c", ` + JSON.stringify(deleteScript) + `]
}
`, root, "DeleteProcess_" + schemeName);
deleteProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.i("ColorSchemeDownload", "Scheme deleted successfully:", schemeName);
ToastService.showNotice(I18n.tr("panels.color-scheme.delete-success-title"), I18n.tr("panels.color-scheme.delete-success-description", {
"scheme": schemeName
}), "settings-color-scheme");
// If the deleted scheme was the active one, reset to default BEFORE reloading
if (needsReset) {
Logger.i("ColorSchemeDownload", "Deleted scheme was active, resetting to Noctalia (default)");
// Clear the setting immediately so ColorSchemeService won't try to apply the deleted scheme
Settings.data.colorSchemes.predefinedScheme = "Noctalia (default)";
// Apply the default scheme immediately
ColorSchemeService.setPredefinedScheme("Noctalia (default)");
}
// Reload color schemes
ColorSchemeService.loadColorSchemes();
} else {
Logger.e("ColorSchemeDownload", "Delete failed with exit code:", exitCode);
ToastService.showError(I18n.tr("panels.color-scheme.delete-error-title"), I18n.tr("panels.color-scheme.delete-error-description", {
"scheme": schemeName
}));
}
deleteProcess.destroy();
});
deleteProcess.running = true;
}
Connections {
target: ColorSchemeService
function onScanningChanged() {
// When scanning completes and we have a pending scheme, apply it
if (!ColorSchemeService.scanning && pendingApplyScheme !== "") {
var schemeToApply = pendingApplyScheme;
pendingApplyScheme = ""; // Clear pending before applying
// Wait a tiny bit to ensure schemes array is updated
applyTimer.schemeName = schemeToApply;
applyTimer.restart();
}
}
}
Timer {
id: applyTimer
property string schemeName: ""
interval: 100 // Small delay to ensure schemes array is populated
onTriggered: {
if (schemeName !== "") {
Logger.i("ColorSchemeDownload", "Auto-applying downloaded scheme:", schemeName);
// Use setPredefinedScheme which will apply and set it as the current scheme
ColorSchemeService.setPredefinedScheme(schemeName);
schemeName = "";
}
}
}
onOpened: {
fetchAvailableSchemes();
}
function preFetchSchemeColors() {
if (availableSchemes.length > 0 && visible) {
Qt.callLater(function () {
// Just trigger a cache version update to ensure UI refreshes
cacheVersion++;
});
}
}
onAvailableSchemesChanged: preFetchSchemeColors()
onVisibleChanged: {
preFetchSchemeColors();
// Load schemes from ShellState when popup becomes visible
if (visible) {
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
loadSchemesFromCache();
}
}
}
Connections {
target: typeof ShellState !== 'undefined' ? ShellState : null
function onIsLoadedChanged() {
if (root.visible && ShellState.isLoaded) {
loadSchemesFromCache();
}
}
}
contentItem: ColumnLayout {
id: contentColumn
width: parent.width
spacing: Style.marginL
// Header
RowLayout {
Layout.fillWidth: true
NText {
text: I18n.tr("panels.color-scheme.download-title")
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
color: Color.mPrimary
Layout.fillWidth: true
}
NIconButton {
icon: "refresh"
tooltipText: I18n.tr("common.refresh")
enabled: !fetching && !downloading
onClicked: {
// Force refresh by clearing cache timestamp and fetching directly from API
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
ShellState.setColorSchemesList({
schemes: [],
timestamp: 0
});
}
// Fetch directly from API to avoid cache check delay
fetchAvailableSchemesFromAPI();
}
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("common.close")
onClicked: root.close()
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Color.mOutline
}
// Error message
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: errorText.implicitHeight + Style.marginM
visible: downloadError !== ""
color: Color.mError
radius: Style.radiusS
NText {
id: errorText
anchors.fill: parent
anchors.margins: Style.marginM
text: downloadError
pointSize: Style.fontSizeS
color: Color.mOnError
wrapMode: Text.WordWrap
}
}
// Loading indicator - only show on initial load, not during refresh
RowLayout {
Layout.fillWidth: true
visible: fetching && !hasInitialData
spacing: Style.marginM
NBusyIndicator {
Layout.preferredWidth: 20
Layout.preferredHeight: 20
}
NText {
text: I18n.tr("panels.color-scheme.download-fetching")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
}
}
// Schemes list - keep visible during refresh to prevent flicker
NScrollView {
id: schemesScrollView
Layout.fillWidth: true
Layout.preferredHeight: 400
visible: hasInitialData && availableSchemes.length > 0
verticalPolicy: ScrollBar.AsNeeded
horizontalPolicy: ScrollBar.AlwaysOff
gradientColor: Color.mSurface
ColumnLayout {
width: schemesScrollView.availableWidth
spacing: Style.marginS
Repeater {
model: availableSchemes
Rectangle {
id: schemeItem
Layout.fillWidth: true
Layout.preferredHeight: 50 * Style.uiScaleRatio
radius: Style.radiusS
property string schemeName: modelData.name
color: root.getSchemeColor(schemeName, "mSurface")
border.width: Style.borderL
border.color: hoverHandler.hovered ? root.getSchemeColor(schemeName, "mPrimary") : Color.mOutline
HoverHandler {
id: hoverHandler
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
Component.onCompleted: {
if (root.visible && !root.schemeColorsCache[schemeName]) {
root.fetchSchemeColors(modelData);
}
}
RowLayout {
id: schemeRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginL
anchors.rightMargin: Style.marginL
spacing: Style.marginS
property string schemeName: modelData.name
property int diameter: 16 * Style.uiScaleRatio
property var colorKeys: ["mPrimary", "mSecondary", "mTertiary", "mError"]
NText {
text: schemeRow.schemeName
pointSize: Style.fontSizeS
color: Color.mOnSurface
Layout.fillWidth: true
elide: Text.ElideRight
Layout.alignment: Qt.AlignVCenter
}
// Color swatches
Repeater {
model: schemeRow.colorKeys
Rectangle {
width: schemeRow.diameter
height: schemeRow.diameter
radius: schemeRow.diameter * 0.5
color: root.getSchemeColor(schemeRow.schemeName, modelData)
Layout.alignment: Qt.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.InOutCubic
}
}
}
}
// Download/Delete button
NIconButton {
property bool isDownloading: downloading && downloadingScheme === schemeRow.schemeName
property bool isInstalled: root.isSchemeInstalled(schemeRow.schemeName)
property bool isDownloaded: root.isSchemeDownloaded(schemeRow.schemeName)
icon: isDownloading ? "" : (isDownloaded ? "trash" : "download")
tooltipText: isDownloading ? I18n.tr("panels.color-scheme.download-downloading") : (isDownloaded ? I18n.tr("common.delete") : I18n.tr("common.download"))
enabled: !downloading
Layout.alignment: Qt.AlignVCenter
visible: !isInstalled || isDownloaded // Show button only if not installed (can download) or if downloaded (can delete)
onClicked: isDownloaded ? root.deleteScheme(schemeRow.schemeName) : root.downloadScheme(modelData)
NBusyIndicator {
anchors.centerIn: parent
width: 16 * Style.uiScaleRatio
height: 16 * Style.uiScaleRatio
visible: parent.isDownloading
}
}
}
}
}
}
}
// Empty state
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginM
visible: !fetching && availableSchemes.length === 0 && downloadError === ""
NIcon {
icon: "package"
pointSize: 48
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
NText {
text: I18n.tr("panels.color-scheme.download-empty")
pointSize: Style.fontSizeM
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More