Refactor: Panels and Bar background are now drawn separately with Shapes.

This commit is contained in:
ItsLemmy
2025-11-06 10:41:48 -05:00
parent 76a182a90c
commit e29c6ee1a6
109 changed files with 2381 additions and 2192 deletions
@@ -0,0 +1,157 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
// Audio controls card: output and input volume controls
NBox {
id: root
property real localOutputVolume: AudioService.volume || 0
property bool localOutputVolumeChanging: false
property real localInputVolume: AudioService.inputVolume || 0
property bool localInputVolumeChanging: false
// Timer to debounce volume changes
Timer {
interval: 100
running: true
repeat: true
onTriggered: {
if (Math.abs(localOutputVolume - AudioService.volume) >= 0.01) {
AudioService.setVolume(localOutputVolume)
}
if (Math.abs(localInputVolume - AudioService.inputVolume) >= 0.01) {
AudioService.setInputVolume(localInputVolume)
}
}
}
// Connections to update local volumes when AudioService changes
Connections {
target: AudioService.sink?.audio ? AudioService.sink?.audio : null
function onVolumeChanged() {
if (!localOutputVolumeChanging) {
localOutputVolume = AudioService.volume
}
}
}
Connections {
target: AudioService.source?.audio ? AudioService.source?.audio : null
function onVolumeChanged() {
if (!localInputVolumeChanging) {
localInputVolume = AudioService.inputVolume
}
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginM
// Output Volume Section
ColumnLayout {
spacing: Style.marginXXS
Layout.fillWidth: true
Layout.preferredWidth: 0
opacity: AudioService.sink ? 1.0 : 0.5
enabled: AudioService.sink
// Output Volume Header
RowLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NIconButton {
icon: AudioService.muted ? "volume-off" : "volume-high"
baseSize: Style.baseWidgetSize * 0.5
colorFg: AudioService.muted ? Color.mError : Color.mOnSurface
colorBg: Color.transparent
colorBgHover: Color.mHover
colorFgHover: Color.mOnHover
onClicked: {
if (AudioService.sink && AudioService.sink.audio) {
AudioService.sink.audio.muted = !AudioService.muted
}
}
}
NText {
text: AudioService.sink ? AudioService.sink.description : "No output device"
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
font.weight: Style.fontWeightMedium
elide: Text.ElideRight
Layout.fillWidth: true
}
}
// Output Volume Slider
NSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localOutputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: localOutputVolume = value
onPressedChanged: localOutputVolumeChanging = pressed
tooltipText: `${Math.round(localOutputVolume * 100)}%`
tooltipDirection: "bottom"
}
}
// Input Volume Section
ColumnLayout {
spacing: Style.marginXXS
Layout.fillWidth: true
Layout.preferredWidth: 0
opacity: AudioService.source ? 1.0 : 0.5
enabled: AudioService.source
// Input Volume Header
RowLayout {
Layout.fillWidth: true
spacing: Style.marginXS
NIconButton {
icon: AudioService.inputMuted ? "microphone-off" : "microphone"
baseSize: Style.baseWidgetSize * 0.5
colorFg: AudioService.inputMuted ? Color.mError : Color.mOnSurface
colorBg: Color.transparent
colorBgHover: Color.mHover
colorFgHover: Color.mOnHover
onClicked: AudioService.setInputMuted(!AudioService.inputMuted)
}
NText {
text: AudioService.source ? AudioService.source.description : "No input device"
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
font.weight: Style.fontWeightMedium
elide: Text.ElideRight
Layout.fillWidth: true
}
}
// Input Volume Slider
NSlider {
Layout.fillWidth: true
from: 0
to: Settings.data.audio.volumeOverdrive ? 1.5 : 1.0
value: localInputVolume
stepSize: 0.01
heightRatio: 0.5
onMoved: localInputVolume = value
onPressedChanged: localInputVolumeChanging = pressed
tooltipText: `${Math.round(localInputVolume * 100)}%`
tooltipDirection: "bottom"
}
}
}
}
@@ -0,0 +1,435 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import qs.Widgets.AudioSpectrum
import qs.Commons
import qs.Services
import qs.Widgets
NBox {
id: root
// Wrapper - rounded rect clipper
Item {
anchors.fill: parent
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskThresholdMin: 0.5
maskSpreadAtMin: 0.0
maskSource: ShaderEffectSource {
sourceItem: Rectangle {
width: root.width
height: root.height
radius: Style.radiusM
color: "white"
}
}
}
// Background image that covers everything
Image {
readonly property int dim: Math.round(256 * Style.uiScaleRatio)
id: bgImage
anchors.fill: parent
source: MediaService.trackArtUrl || WallpaperService.getWallpaper(Screen.name)
sourceSize: Qt.size(dim, dim)
fillMode: Image.PreserveAspectCrop
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.25
blurMax: 16
}
}
// Dark overlay for readability
Rectangle {
anchors.fill: parent
color: Color.mSurfaceVariant
opacity: 0.85
radius: Style.radiusM
}
// Border
Rectangle {
anchors.fill: parent
color: Color.transparent
border.color: Color.mOutline
border.width: 1
radius: Style.radiusM
}
// Background visualizer on top of the artwork
Loader {
anchors.fill: parent
active: Settings.data.audio.visualizerType !== "" && Settings.data.audio.visualizerType !== "none"
sourceComponent: {
switch (Settings.data.audio.visualizerType) {
case "linear":
return linearComponent
case "mirrored":
return mirroredComponent
case "wave":
return waveComponent
default:
return null
}
}
Component {
id: linearComponent
LinearSpectrum {
anchors.fill: parent
values: CavaService.values
fillColor: Color.mPrimary
opacity: 0.5
}
}
Component {
id: mirroredComponent
MirroredSpectrum {
anchors.fill: parent
values: CavaService.values
fillColor: Color.mPrimary
opacity: 0.5
}
}
Component {
id: waveComponent
WaveSpectrum {
anchors.fill: parent
values: CavaService.values
fillColor: Color.mPrimary
opacity: 0.5
}
}
}
}
// Player selector
Rectangle {
id: playerSelectorButton
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.marginXS
anchors.leftMargin: Style.marginM
anchors.rightMargin: Style.marginM
height: Style.barHeight
visible: MediaService.getAvailablePlayers().length > 1
radius: Style.radiusM
color: Color.transparent
property var currentPlayer: MediaService.getAvailablePlayers()[MediaService.selectedPlayerIndex]
RowLayout {
anchors.fill: parent
spacing: Style.marginS
NIcon {
icon: "caret-down"
pointSize: Style.fontSizeXXL
color: Color.mOnSurfaceVariant
}
NText {
text: playerSelectorButton.currentPlayer ? playerSelectorButton.currentPlayer.identity : ""
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
Layout.fillWidth: true
}
}
MouseArea {
id: playerSelectorMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
var menuItems = []
var players = MediaService.getAvailablePlayers()
for (var i = 0; i < players.length; i++) {
menuItems.push({
"label": players[i].identity,
"action": i.toString(),
"icon": "disc",
"enabled": true,
"visible": true
})
}
playerContextMenu.model = menuItems
playerContextMenu.openAtItem(playerSelectorButton, playerSelectorButton.width - playerContextMenu.width, playerSelectorButton.height)
}
}
NContextMenu {
id: playerContextMenu
parent: root
width: 200
verticalPolicy: ScrollBar.AlwaysOff
onTriggered: function (action) {
var index = parseInt(action)
if (!isNaN(index)) {
MediaService.switchToPlayer(index)
}
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginM
// No media player detected
ColumnLayout {
id: fallback
visible: !main.visible
spacing: Style.marginS
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.centerIn: parent
spacing: Style.marginL
Item {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Style.fontSizeXXXL * 4
Layout.preferredHeight: Style.fontSizeXXXL * 4
// Pulsating audio circles (background)
Repeater {
model: 3
Rectangle {
anchors.centerIn: parent
width: parent.width * (1.0 + index * 0.2)
height: width
radius: width / 2
color: "transparent"
border.color: Color.mOnSurfaceVariant
border.width: 2
opacity: 0
SequentialAnimation on opacity {
running: true
loops: Animation.Infinite
PauseAnimation {
duration: index * 600
}
NumberAnimation {
from: 1.0
to: 0
duration: 2000
easing.type: Easing.OutQuad
}
}
SequentialAnimation on scale {
running: true
loops: Animation.Infinite
PauseAnimation {
duration: index * 600
}
NumberAnimation {
from: 0.6
to: 1.2
duration: 2000
easing.type: Easing.OutQuad
}
}
}
}
// Spinning disc
NIcon {
anchors.centerIn: parent
icon: "disc"
pointSize: Style.fontSizeXXXL * 3
color: Color.mOnSurfaceVariant
}
}
// Descriptive text
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginXS
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
}
// MediaPlayer Main Content
ColumnLayout {
id: main
visible: MediaService.currentPlayer && MediaService.canPlay
spacing: Style.marginS
// Spacer to push content down
Item {
Layout.preferredHeight: Style.marginM
}
// Metadata at the bottom left
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft
spacing: Style.marginXS
NText {
visible: MediaService.trackTitle !== ""
text: MediaService.trackTitle
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
elide: Text.ElideRight
wrapMode: Text.Wrap
maximumLineCount: 2
Layout.fillWidth: true
}
NText {
visible: MediaService.trackArtist !== ""
text: MediaService.trackArtist
color: Color.mPrimary
pointSize: Style.fontSizeS
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
visible: MediaService.trackAlbum !== ""
text: MediaService.trackAlbum
color: Color.mOnSurfaceVariant
pointSize: Style.fontSizeM
elide: Text.ElideRight
Layout.fillWidth: true
}
}
// Progress slider
Item {
id: progressWrapper
visible: (MediaService.currentPlayer && MediaService.trackLength > 0)
Layout.fillWidth: true
height: Style.baseWidgetSize * 0.5
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))
}
property real effectiveRatio: (MediaService.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio
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
}
}
}
}
NSlider {
id: progressSlider
anchors.fill: parent
from: 0
to: 1
stepSize: 0
snapAlways: false
enabled: MediaService.trackLength > 0 && MediaService.canSeek
heightRatio: 0.6
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
}
}
}
Binding {
target: progressSlider
property: "value"
value: progressWrapper.progressRatio
when: !MediaService.isSeeking
}
}
// Spacer to push media controls down
Item {
Layout.preferredHeight: Style.marginL
}
// Media controls
RowLayout {
spacing: Style.marginS
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
NIconButton {
icon: "media-prev"
visible: MediaService.canGoPrevious
onClicked: MediaService.canGoPrevious ? MediaService.previous() : {}
}
NIconButton {
icon: MediaService.isPlaying ? "media-pause" : "media-play"
visible: (MediaService.canPlay || MediaService.canPause)
onClicked: (MediaService.canPlay || MediaService.canPause) ? MediaService.playPause() : {}
}
NIconButton {
icon: "media-next"
visible: MediaService.canGoNext
onClicked: MediaService.canGoNext ? MediaService.next() : {}
}
}
}
}
}
@@ -0,0 +1,114 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
import qs.Modules.Panels.ControlCenter.Cards
import qs.Modules.Panels.Settings
// Header card with avatar, user and quick actions
NBox {
id: root
property string uptimeText: "--"
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Style.marginM
spacing: Style.marginM
NImageCircled {
Layout.preferredWidth: Math.round(Style.baseWidgetSize * 1.25 * Style.uiScaleRatio)
Layout.preferredHeight: Math.round(Style.baseWidgetSize * 1.25 * Style.uiScaleRatio)
imagePath: Settings.preprocessPath(Settings.data.general.avatarImage)
fallbackIcon: "person"
borderColor: Color.mPrimary
borderWidth: Style.borderM
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS
NText {
text: Quickshell.env("USER") || "user"
font.weight: Style.fontWeightBold
font.capitalization: Font.Capitalize
}
NText {
text: I18n.tr("system.uptime", {
"uptime": uptimeText
})
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
RowLayout {
spacing: Style.marginS
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Item {
Layout.fillWidth: true
}
NIconButton {
icon: "settings"
tooltipText: I18n.tr("tooltips.open-settings")
onClicked: {
var panel = PanelService.getPanel("settingsPanel", screen)
panel.requestedTab = SettingsPanel.Tab.General
panel.open()
}
}
NIconButton {
icon: "power"
tooltipText: I18n.tr("tooltips.session-menu")
onClicked: {
PanelService.getPanel("sessionMenuPanel", screen)?.open()
PanelService.getPanel("controlCenterPanel", screen)?.close()
}
}
NIconButton {
icon: "close"
tooltipText: I18n.tr("tooltips.close")
onClicked: {
PanelService.getPanel("controlCenterPanel", screen)?.close()
}
}
}
}
// ----------------------------------
// Uptime
Timer {
interval: 60000
repeat: true
running: true
onTriggered: uptimeProcess.running = true
}
Process {
id: uptimeProcess
command: ["cat", "/proc/uptime"]
running: true
stdout: StdioCollector {
onStreamFinished: {
var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0])
uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds)
uptimeProcess.running = false
}
}
}
function updateSystemInfo() {
uptimeProcess.running = true
}
}
@@ -0,0 +1,92 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
import qs.Modules.Panels.ControlCenter
import qs.Modules.Panels.ControlCenter.Cards
RowLayout {
Layout.fillWidth: true
spacing: Style.marginL
NBox {
Layout.fillWidth: true
Layout.preferredHeight: root.shortcutsHeight
RowLayout {
id: leftContent
anchors.fill: parent
spacing: Style.marginS
Item {
Layout.fillWidth: true
}
Repeater {
model: Settings.data.controlCenter.shortcuts.left
delegate: ControlCenterWidgetLoader {
required property var modelData
required property int index
Layout.fillWidth: false
widgetId: (modelData.id !== undefined ? modelData.id : "")
widgetScreen: root.screen
widgetProps: {
"widgetId": modelData.id,
"section": "quickSettings",
"sectionWidgetIndex": index,
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.left.length,
"widgetSettings": modelData
}
Layout.alignment: Qt.AlignVCenter
}
}
Item {
Layout.fillWidth: true
}
}
}
NBox {
Layout.fillWidth: true
Layout.preferredHeight: root.shortcutsHeight
RowLayout {
id: rightContent
anchors.fill: parent
spacing: Style.marginS
Item {
Layout.fillWidth: true
}
Repeater {
model: Settings.data.controlCenter.shortcuts.right
delegate: ControlCenterWidgetLoader {
required property var modelData
required property int index
Layout.fillWidth: false
widgetId: (modelData.id !== undefined ? modelData.id : "")
widgetScreen: root.screen
widgetProps: {
"widgetId": modelData.id,
"section": "quickSettings",
"sectionWidgetIndex": index,
"sectionWidgetsCount": Settings.data.controlCenter.shortcuts.right.length,
"widgetSettings": modelData
}
Layout.alignment: Qt.AlignVCenter
}
}
Item {
Layout.fillWidth: true
}
}
}
}
@@ -0,0 +1,58 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
// Unified system card: monitors CPU, temp, memory, disk
NBox {
id: root
Item {
id: content
anchors.fill: parent
anchors.margins: Style.marginS
property int widgetHeight: Math.round(65 * Style.uiScaleRatio)
ColumnLayout {
anchors.centerIn: parent
spacing: 0
NCircleStat {
value: SystemStatService.cpuUsage
icon: "cpu-usage"
flat: true
contentScale: 0.8
height: content.widgetHeight
Layout.alignment: Qt.AlignHCenter
}
NCircleStat {
value: SystemStatService.cpuTemp
suffix: "°C"
icon: "cpu-temperature"
flat: true
contentScale: 0.8
height: content.widgetHeight
Layout.alignment: Qt.AlignHCenter
}
NCircleStat {
value: SystemStatService.memPercent
icon: "memory"
flat: true
contentScale: 0.8
height: content.widgetHeight
Layout.alignment: Qt.AlignHCenter
}
NCircleStat {
value: SystemStatService.diskPercents["/"] ?? 0
icon: "storage"
flat: true
contentScale: 0.8
height: content.widgetHeight
Layout.alignment: Qt.AlignHCenter
}
}
}
}
@@ -0,0 +1,144 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
// Weather overview card (placeholder data)
NBox {
id: root
property int forecastDays: 7
property bool showLocation: true
readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null)
visible: Settings.data.location.weatherEnabled
implicitHeight: Math.max(100 * Style.uiScaleRatio, content.implicitHeight + (Style.marginXL * 2))
ColumnLayout {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginXL
spacing: Style.marginM
clip: true
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
Item {
Layout.preferredWidth: 0
}
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : ""
pointSize: Style.fontSizeXXXL * 1.75
color: Color.mPrimary
}
ColumnLayout {
spacing: Style.marginXXS
NText {
text: {
// Ensure the name is not too long if one had to specify the country
const chunks = Settings.data.location.name.split(",")
return chunks[0]
}
pointSize: Style.fontSizeL
font.weight: Style.fontWeightBold
visible: showLocation
}
RowLayout {
NText {
visible: weatherReady
text: {
if (!weatherReady) {
return ""
}
var temp = LocationService.data.weather.current_weather.temperature
var suffix = "C"
if (Settings.data.location.useFahrenheit) {
temp = LocationService.celsiusToFahrenheit(temp)
var suffix = "F"
}
temp = Math.round(temp)
return `${temp}°${suffix}`
}
pointSize: showLocation ? Style.fontSizeXL : Style.fontSizeXL * 1.6
font.weight: Style.fontWeightBold
}
NText {
text: weatherReady ? `(${LocationService.data.weather.timezone_abbreviation})` : ""
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
visible: LocationService.data.weather && showLocation
}
}
}
}
NDivider {
visible: weatherReady
Layout.fillWidth: true
}
RowLayout {
visible: weatherReady
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.marginM
Repeater {
model: weatherReady ? Math.min(root.forecastDays, LocationService.data.weather.daily.time.length) : 0
delegate: ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS
Item {
Layout.fillWidth: true
}
NText {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
text: {
var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/"))
return I18n.locale.toString(weatherDate, "ddd")
}
color: Color.mOnSurface
}
NIcon {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index])
pointSize: Style.fontSizeXXL * 1.6
color: Color.mPrimary
}
NText {
Layout.alignment: Qt.AlignHCenter
text: {
var max = LocationService.data.weather.daily.temperature_2m_max[index]
var min = LocationService.data.weather.daily.temperature_2m_min[index]
if (Settings.data.location.useFahrenheit) {
max = LocationService.celsiusToFahrenheit(max)
min = LocationService.celsiusToFahrenheit(min)
}
max = Math.round(max)
min = Math.round(min)
return `${max}°/${min}°`
}
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
}
}
}
}
RowLayout {
visible: !weatherReady
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
NBusyIndicator {}
}
}
}
@@ -0,0 +1,175 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
import qs.Modules.MainScreen
import qs.Modules.Panels.ControlCenter.Cards
SmartPanel {
id: root
panelAnchorHorizontalCenter: true
panelAnchorVerticalCenter: true
preferredWidth: Math.round(460 * 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 "weather-card":
height += weatherHeight
break
case "media-sysmon-card":
height += mediaSysMonHeight
break
default:
break
}
}
return height + (count + 1) * Style.marginL
}
// 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_")
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 weatherHeight: Math.round(190 * Style.uiScaleRatio)
readonly property int mediaSysMonHeight: Math.round(260 * Style.uiScaleRatio)
onOpened: {
MediaService.autoSwitchingPaused = true
}
onClosed: {
MediaService.autoSwitchingPaused = false
}
panelContent: Item {
id: content
ColumnLayout {
id: layout
x: Style.marginL
y: Style.marginL
width: parent.width - (Style.marginL * 2)
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 "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 "weather-card":
return weatherCard
case "media-sysmon-card":
return mediaSysMonCard
}
}
}
}
}
Component {
id: profileCard
ProfileCard {}
}
Component {
id: shortcutsCard
ShortcutsCard {}
}
Component {
id: audioCard
AudioCard {}
}
Component {
id: weatherCard
WeatherCard {}
}
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,64 @@
import QtQuick
import Quickshell
import qs.Services
import qs.Commons
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
}
Loader {
id: loader
anchors.fill: parent
asynchronous: false
sourceComponent: 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)
}
}
}
@@ -0,0 +1,15 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: BluetoothService.enabled ? "bluetooth" : "bluetooth-off"
tooltipText: I18n.tr("quickSettings.bluetooth.tooltip.action")
onClicked: {
PanelService.getPanel("bluetoothPanel", screen)?.toggle(this)
}
}
@@ -0,0 +1,162 @@
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
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) {
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()
}
}
}
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
}
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,14 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: IdleInhibitorService.isInhibited ? "keep-awake-on" : "keep-awake-off"
hot: IdleInhibitorService.isInhibited
tooltipText: I18n.tr("quickSettings.keepAwake.tooltip.action")
onClicked: IdleInhibitorService.manualToggle()
}
@@ -0,0 +1,33 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.Panels.Settings
import qs.Services
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 || Settings.data.nightLight.forced
tooltipText: I18n.tr("quickSettings.nightLight.tooltip.action")
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,15 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: Settings.data.notifications.doNotDisturb ? "bell-off" : "bell"
hot: Settings.data.notifications.doNotDisturb
tooltipText: I18n.tr("quickSettings.notifications.tooltip.action")
onClicked: PanelService.getPanel("notificationHistoryPanel", screen)?.toggle(this)
onRightClicked: Settings.data.notifications.doNotDisturb = !Settings.data.notifications.doNotDisturb
}
@@ -0,0 +1,21 @@
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import qs.Commons
import qs.Services
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("quickSettings.powerProfile.tooltip.action")
onClicked: {
PowerProfileService.cycleProfile()
}
}
@@ -0,0 +1,21 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
enabled: ProgramCheckerService.gpuScreenRecorderAvailable
icon: "camera-video"
hot: ScreenRecorderService.isRecording
tooltipText: I18n.tr("quickSettings.screenRecorder.tooltip.action")
onClicked: {
ScreenRecorderService.toggleRecording()
if (!ScreenRecorderService.isRecording) {
var panel = PanelService.getPanel("controlCenterPanel", screen)
panel?.close()
}
}
}
@@ -0,0 +1,15 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
enabled: Settings.data.wallpaper.enabled
icon: "wallpaper-selector"
tooltipText: I18n.tr("quickSettings.wallpaperSelector.tooltip.action")
onClicked: PanelService.getPanel("wallpaperPanel", screen)?.toggle()
onRightClicked: WallpaperService.setRandomWallpaper()
}
@@ -0,0 +1,33 @@
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
NIconButtonHot {
property ShellScreen screen
icon: {
try {
if (NetworkService.ethernetConnected) {
return "ethernet"
}
let connected = false
let signalStrength = 0
for (const net in NetworkService.networks) {
if (NetworkService.networks[net].connected) {
connected = true
signalStrength = NetworkService.networks[net].signal
break
}
}
return connected ? NetworkService.signalIcon(signalStrength) : "wifi-off"
} catch (error) {
Logger.e("Wi-Fi", "Error getting icon:", error)
return "signal_wifi_bad"
}
}
tooltipText: I18n.tr("quickSettings.wifi.tooltip.action")
onClicked: PanelService.getPanel("wifiPanel", screen)?.toggle(this)
}