Merge pull request #644 from lonerOrz/feat/control-center-custom-button

feat: Implement Control Center custom button basic framework
This commit is contained in:
Lemmy
2025-11-03 15:58:16 -05:00
committed by GitHub
13 changed files with 941 additions and 22 deletions
@@ -38,7 +38,8 @@ RowLayout {
"widgetId": modelData.id,
"section": "quickSettings",
"sectionWidgetIndex": index,
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.left.length
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.left.length,
"widgetSettings": modelData
}
Layout.alignment: Qt.AlignVCenter
}
@@ -76,7 +77,8 @@ RowLayout {
"widgetId": modelData.id,
"section": "quickSettings",
"sectionWidgetIndex": index,
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.right.length
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.right.length,
"widgetSettings": modelData
}
Layout.alignment: Qt.AlignVCenter
}
@@ -0,0 +1,163 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services
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: "Custom Button"
property bool enableOnStateLogic: false
property string _currentIcon: "heart" // Default icon
property bool _isHot: false
property var _parsedStateChecks: [] // Local array for parsed state checks
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) {
console.error("CustomButton: Failed to parse stateChecksJson:", e.message)
_parsedStateChecks = []
}
generalTooltipText = widgetSettings.generalTooltipText || "Custom Button"
enableOnStateLogic = widgetSettings.enableOnStateLogic || false
updateState()
}
function onWidgetSettingsChanged() {
if (widgetSettings) {
_updatePropertiesFromSettings()
}
}
}
property int _currentStateCheckIndex: -1
property string _activeStateIcon: ""
Process {
id: stateCheckProcessExecutor
running: false
command: _currentStateCheckIndex !== -1 && _parsedStateChecks.length > _currentStateCheckIndex ? ["sh", "-c", _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() {
let tooltip = generalTooltipText
if (onClickedCommand) {
tooltip += `\nLeft click: ${onClickedCommand}`
}
if (onRightClickedCommand) {
tooltip += `\nRight click: ${onRightClickedCommand}`
}
if (onMiddleClickedCommand) {
tooltip += `\nMiddle click: ${onMiddleClickedCommand}`
}
return tooltip
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
NIconButtonHot {
id: button
icon: _activeStateIcon
hot: _isHot
tooltipText: _buildTooltipText()
onClicked: {
if (onClickedCommand) {
Quickshell.execDetached(["sh", "-c", onClickedCommand])
updateState()
}
}
onRightClicked: {
if (onRightClickedCommand) {
Quickshell.execDetached(["sh", "-c", onRightClickedCommand])
updateState()
}
}
onMiddleClicked: {
if (onMiddleClickedCommand) {
Quickshell.execDetached(["sh", "-c", onMiddleClickedCommand])
updateState()
}
}
}
}
@@ -0,0 +1,120 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
import qs.Services
// Widget Settings Dialog Component
Popup {
id: root
property int widgetIndex: -1
property var widgetData: null
property string widgetId: ""
property string sectionId: ""
signal updateWidgetSettings(string section, int index, var settings)
width: Math.max(content.implicitWidth + padding * 2, 500)
height: content.implicitHeight + padding * 2
padding: Style.marginXL
modal: true
anchors.centerIn: parent
onOpened: {
PanelService.willOpenPopup(root)
if (widgetData && widgetId) {
loadWidgetSettings()
}
}
onClosed: {
PanelService.willClosePopup(root)
}
background: Rectangle {
color: Color.mSurface
radius: Style.radiusL
border.color: Color.mPrimary
border.width: Style.borderM
}
contentItem: ColumnLayout {
id: content
width: parent.width
spacing: Style.marginM
// Title
RowLayout {
Layout.fillWidth: true
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("tooltips.close")
onClicked: root.close()
}
}
// Separator
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Color.mOutline
}
Loader {
id: settingsLoader
Layout.fillWidth: true
}
// Action buttons
RowLayout {
Layout.fillWidth: true
Layout.topMargin: Style.marginM
spacing: Style.marginM
Item { Layout.fillWidth: true }
NButton {
text: I18n.tr("settings.control-center.shortcuts.dialog.cancel", "Cancel")
outlined: true
onClicked: root.close()
}
NButton {
text: I18n.tr("settings.control-center.shortcuts.dialog.apply", "Apply")
icon: "check"
onClicked: {
if (settingsLoader.item && settingsLoader.item.saveSettings) {
var newSettings = settingsLoader.item.saveSettings()
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,235 @@
import QtQuick
import QtQuick.Layouts
import QtQml.Models // Import ListModel
import qs.Commons
import qs.Widgets
ColumnLayout {
id: root
property var widgetData: null
property var widgetMetadata: null
QtObject {
id: _settings
property string icon: (widgetData && widgetData.icon !== undefined) ? widgetData.icon : widgetMetadata.icon
property string onClicked: (widgetData && widgetData.onClicked !== undefined) ? widgetData.onClicked : widgetMetadata.onClicked
property string onRightClicked: (widgetData && widgetData.onRightClicked !== undefined) ? widgetData.onRightClicked : widgetMetadata.onRightClicked
property string onMiddleClicked: (widgetData && widgetData.onMiddleClicked !== undefined) ? widgetData.onMiddleClicked : widgetMetadata.onMiddleClicked
property ListModel _stateChecksListModel: ListModel {}
property string stateChecksJson: "[]"
property string generalTooltipText: (widgetData && widgetData.generalTooltipText !== undefined) ? widgetData.generalTooltipText : widgetMetadata.generalTooltipText
property bool enableOnStateLogic: (widgetData && widgetData.enableOnStateLogic !== undefined) ? widgetData.enableOnStateLogic : widgetMetadata.enableOnStateLogic
Component.onCompleted: {
stateChecksJson = (widgetData && widgetData.stateChecksJson !== undefined) ? widgetData.stateChecksJson : widgetMetadata.stateChecksJson || "[]"
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 {
console.warn("⚠️ Invalid stateChecks entry at index " + i + ":", item)
}
}
}
} catch (e) {
console.error("CustomButtonSettings: Failed to parse stateChecksJson:", e.message)
}
}
}
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
}
}
RowLayout {
spacing: Style?.marginM ?? 8
NLabel {
label: I18n.tr("settings.control-center.shortcuts.custom-button.icon.label")
description: I18n.tr("settings.control-center.shortcuts.custom-button.icon.description")
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: _settings.icon || widgetMetadata.icon
pointSize: Style?.fontSizeXL ?? 24
visible: (_settings.icon || widgetMetadata.icon) !== ""
}
NButton {
text: I18n.tr("settings.control-center.shortcuts.custom-button.browse")
onClicked: iconPicker.open()
}
}
NIconPicker {
id: iconPicker
initialIcon: _settings.icon
onIconSelected: function (iconName) {
_settings.icon = iconName
}
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("settings.control-center.shortcuts.custom-button.general-tooltip-text.label")
description: I18n.tr("settings.control-center.shortcuts.custom-button.general-tooltip-text.description")
placeholderText: I18n.tr("placeholders.enter-tooltip")
text: _settings.generalTooltipText
onTextChanged: _settings.generalTooltipText = text
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("settings.control-center.shortcuts.custom-button.on-clicked.label")
description: I18n.tr("settings.control-center.shortcuts.custom-button.on-clicked.description")
placeholderText: I18n.tr("placeholders.enter-command")
text: _settings.onClicked
onTextChanged: _settings.onClicked = text
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("settings.control-center.shortcuts.custom-button.on-right-clicked.label")
description: I18n.tr("settings.control-center.shortcuts.custom-button.on-right-clicked.description")
placeholderText: I18n.tr("placeholders.enter-command")
text: _settings.onRightClicked
onTextChanged: _settings.onRightClicked = text
}
NTextInput {
Layout.fillWidth: true
label: I18n.tr("settings.control-center.shortcuts.custom-button.on-middle-clicked.label")
description: I18n.tr("settings.control-center.shortcuts.custom-button.on-middle-clicked.description")
placeholderText: I18n.tr("placeholders.enter-command")
text: _settings.onMiddleClicked
onTextChanged: _settings.onMiddleClicked = text
}
NDivider {}
NToggle {
id: enableOnStateLogicToggle
Layout.fillWidth: true
label: I18n.tr("settings.control-center.shortcuts.custom-button.enable-on-state-logic.label")
description: I18n.tr("settings.control-center.shortcuts.custom-button.enable-on-state-logic.description")
checked: _settings.enableOnStateLogic
onToggled: checked => _settings.enableOnStateLogic = checked
}
ColumnLayout {
Layout.fillWidth: true
visible: _settings.enableOnStateLogic
spacing: (Style?.marginM ?? 8) * 2
NLabel {
label: I18n.tr("settings.control-center.shortcuts.custom-button.state-checks.label")
}
Repeater {
model: _settings._stateChecksListModel
delegate: Item {
property int currentIndex: index
implicitHeight: contentRow.implicitHeight + ((divider.visible) ? divider.height : 0)
Layout.fillWidth: true
RowLayout {
id: contentRow
anchors.fill: parent
spacing: Style?.marginM ?? 8
NTextInput {
Layout.fillWidth: true
placeholderText: I18n.tr("settings.control-center.shortcuts.custom-button.state-checks.command")
text: model.command
onEditingFinished: _settings._stateChecksListModel.set(currentIndex, { "command": text, "icon": 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("settings.control-center.shortcuts.custom-button.state-checks.browse-icon")
baseSize: Style?.buttonSizeS ?? 24
onClicked: iconPickerDelegate.open()
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("settings.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: _settings._stateChecksListModel.remove(currentIndex)
}
}
}
NIconPicker {
id: iconPickerDelegate
initialIcon: model.icon
onIconSelected: function (iconName) {
_settings._stateChecksListModel.set(currentIndex, { "command": model.command, "icon": iconName })
}
}
NDivider {
id: divider
anchors.bottom: parent.bottom
visible: index < _settings._stateChecksListModel.count - 1 // Only show divider if not the last item
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style?.marginM ?? 8
NButton {
text: I18n.tr("settings.control-center.shortcuts.custom-button.state-checks.add")
onClicked: _settings._stateChecksListModel.append({ command: "", icon: "" })
}
Item {
Layout.fillWidth: true
}
}
}
NDivider {}
}
+15 -10
View File
@@ -68,12 +68,13 @@ ColumnLayout {
Component.onCompleted: {
// Fill out availableWidgets ListModel
availableWidgets.clear()
ControlCenterWidgetRegistry.getAvailableWidgets().forEach(entry => {
availableWidgets.append({
"key": entry,
"name": entry
})
})
var sortedEntries = ControlCenterWidgetRegistry.getAvailableWidgets().slice().sort()
sortedEntries.forEach(entry => {
availableWidgets.append({
"key": entry,
"name": entry
})
})
// Starts empty
cardsModel = []
@@ -226,7 +227,7 @@ ColumnLayout {
NSectionEditor {
sectionName: I18n.tr("settings.control-center.shortcuts.sectionLeft")
sectionId: "left"
settingsDialogComponent: ""
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Settings/ControlCenter/ControlCenterWidgetSettingsDialog.qml")
maxWidgets: 5
widgetRegistry: ControlCenterWidgetRegistry
widgetModel: Settings.data.controlCenter.shortcuts["left"]
@@ -245,7 +246,7 @@ ColumnLayout {
NSectionEditor {
sectionName: I18n.tr("settings.control-center.shortcuts.sectionRight")
sectionId: "right"
settingsDialogComponent: ""
settingsDialogComponent: Qt.resolvedUrl(Quickshell.shellDir + "/Modules/Settings/ControlCenter/ControlCenterWidgetSettingsDialog.qml")
maxWidgets: 5
widgetRegistry: ControlCenterWidgetRegistry
widgetModel: Settings.data.controlCenter.shortcuts["right"]
@@ -329,8 +330,12 @@ ColumnLayout {
}
function _updateWidgetSettingsInSection(section, index, settings) {
// Update the widget settings in the Settings data
Settings.data.controlCenter.shortcuts[section][index] = settings
// Create a new array to trigger QML's change detection for persistence.
// This is crucial for Settings.data to detect the change and persist it.
var newSectionArray = Settings.data.controlCenter.shortcuts[section].slice()
newSectionArray[index] = settings
Settings.data.controlCenter.shortcuts[section] = newSectionArray
Settings.saveImmediate()
}
// Base list model for all combo boxes