refactor(control center): redesign update #8

This commit is contained in:
purian23
2026-09-30 00:15:05 -04:00
parent db903801c3
commit 73759d5752
23 changed files with 1027 additions and 212 deletions
+26 -21
View File
@@ -94,36 +94,41 @@ function firstFit(taken, steps, w, h) {
function packCards(cards, order, columns, width, gap, rowUnit, mirror, isAvailable, step = 1, gravity = false) {
const packed = packCells(cards, order, columns, isAvailable, step, gravity);
const colW = (width - gap * (columns - 1)) / columns;
const slots = packed.cells.map(cell => {
if (!cell)
return null;
const px = cell.col * (colW + gap);
const pw = cell.cols * colW + (cell.cols - 1) * gap;
return {
"x": mirror ? width - px - pw : px,
"y": cell.row * (rowUnit + gap),
"w": pw,
"h": cell.rows * rowUnit + (cell.rows - 1) * gap,
"col": cell.col,
"row": cell.row,
"cols": cell.cols,
"rows": cell.rows
};
});
return {
"slots": slots,
const layout = {
"slots": [],
"rows": packed.rows,
"totalHeight": packed.rows > 0 ? packed.rows * rowUnit + (packed.rows - 1) * gap : 0,
"columns": columns,
"width": width,
"colW": colW,
"colW": (width - gap * (columns - 1)) / columns,
"rowUnit": rowUnit,
"gap": gap,
"step": step,
"mirror": mirror
};
layout.slots = packed.cells.map(cell => cell ? Object.assign(slotRect(layout, cell.col, cell.row, cell.cols, cell.rows), cell) : null);
return layout;
}
function slotRect(layout, col, row, cols, rows) {
const px = col * (layout.colW + layout.gap);
const pw = cols * layout.colW + (cols - 1) * layout.gap;
return {
"x": layout.mirror ? layout.width - px - pw : px,
"y": row * (layout.rowUnit + layout.gap),
"w": pw,
"h": rows * layout.rowUnit + (rows - 1) * layout.gap
};
}
function cellOccupied(layout, col, row) {
const taken = layout.slots.filter(Boolean).map(slot => ({
"x": slot.col,
"y": slot.row,
"w": slot.cols,
"h": slot.rows
}));
return overlaps(taken, col, row, 1, 1);
}
function cellAt(layout, x, y, cols, rows) {
+1 -1
View File
@@ -21,7 +21,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 35
readonly property int settingsConfigVersion: 36
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
+13 -3
View File
@@ -446,19 +446,22 @@ var LOCAL_SPEC = {
id: "settings",
enabled: true,
w: 1,
h: 1
h: 1,
small: true
},
{
id: "lock",
enabled: true,
w: 1,
h: 1
h: 1,
small: true
},
{
id: "power",
enabled: true,
w: 1,
h: 1
h: 1,
small: true
},
{
id: "brightnessSlider",
@@ -531,6 +534,13 @@ var LOCAL_SPEC = {
enabled: true,
w: 4,
h: 1
},
{
id: "runningApps",
enabled: true,
w: 7,
h: 1,
small: true
}
]
},
+17 -2
View File
@@ -17,7 +17,7 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo
var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"];
var CC_HEADER_IDS = ["userCard", "quickActions", "header"];
var CC_ACTION_TILE_IDS = ["settings", "lock", "power"];
var CC_USER_KEYS = ["w", "h", "col", "row", "hostname", "compositor", "uptime", "badge"];
var CC_USER_KEYS = ["w", "h", "col", "row", "hostname", "compositor", "uptime", "badge", "background"];
var REMOVED_KEYS_V21 = ["showBattery", "showCapsLockIndicator", "showClipboard", "showClock", "showControlCenterButton", "showCpuUsage", "showFocusedWindow", "showLauncherButton", "showMemUsage", "showMusic", "showNotificationButton", "showPrivacyButton", "showSystemTray", "showWeather", "showWorkspaceSwitcher", "hideBrightnessSlider", "updaterHideWidget", "workspaceScrolling", "appLauncherViewMode", "spotlightModalViewMode", "audioDeviceScrollVolumeEnabled", "desktopClockX", "desktopClockY", "desktopClockWidth", "desktopClockHeight", "desktopClockDisplayPreferences", "systemMonitorX", "systemMonitorY", "systemMonitorWidth", "systemMonitorHeight", "systemMonitorDisplayPreferences", "systemMonitorVariants"];
@@ -707,6 +707,20 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 35;
}
if (currentVersion < 36 && targetVersion >= 36) {
// The footer's running-apps chip became a grid tile; layouts saved before then never mention it.
const widgets = settings.controlCenterWidgets;
if (Array.isArray(widgets) && !widgets.some(widget => widget?.id === "runningApps"))
settings.controlCenterWidgets = widgets.concat([{
id: "runningApps",
enabled: true,
w: Math.max(1, (settings.controlCenterColumns ?? 8) - 1),
h: 1,
small: true
}]);
settings.configVersion = 36;
}
return settings;
}
@@ -721,7 +735,8 @@ function migrateControlCenterHeader(widgets, fixedHeader, columns) {
id: id,
enabled: true,
w: 1,
h: 1
h: 1,
small: true
}));
const identity = header.length === 0 ? {} : header.find(widget => widget.id === "userCard" || (widget.id === "header" && widget.showUser !== false));
if (!identity || rest.some(widget => widget?.id === "user"))
@@ -56,8 +56,10 @@ Singleton {
readonly property real tileTextGap: Theme.spacingM
readonly property real footerHeight: iconBoxSize
readonly property real footerGap: Theme.spacingM
readonly property real footerAppIconSize: Theme.iconSize * iconScale
readonly property int footerMaxAppIcons: 3
readonly property real runningAppsIconSize: Theme.iconSize * iconScale
readonly property int runningAppsMaxIcons: 3
// Small tile body height as a fraction of a row; the resize corner snaps to the nearer of this and a full row.
readonly property real smallRowFraction: 0.5
readonly property real shapeButtonSize: Theme.iconSize
readonly property real detailDialogInset: Theme.spacingL
@@ -120,6 +122,10 @@ Singleton {
readonly property color tileInactiveSubtitle: Theme.surfaceVariantText
readonly property color tileInactiveIcon: Theme.primary
function actionIconColor(id) {
return id === "power" ? Theme.error : tileInactiveIcon;
}
readonly property bool animationsEnabled: !SettingsData.reduceMotion && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None
function preferredDetailHeight(section, pluginHeight) {
@@ -1,10 +1,8 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Modules.ControlCenter
import qs.Modules.ControlCenter.Widgets
import qs.Widgets
Item {
@@ -14,28 +12,20 @@ Item {
LayoutMirroring.childrenInherit: true
property bool editMode: false
property var toplevels: []
property bool showEdit: true
property string pendingAction: ""
signal runningAppsRequested
signal addWidgetRequested
signal resetRequested
signal clearRequested
signal editToggled
signal cancelRequested
readonly property var appIds: {
const ids = [];
for (const toplevel of toplevels) {
const id = toplevel?.appId ?? "";
if (!ids.includes(id))
ids.push(id);
}
return ids;
}
readonly property real leadingWidth: Math.max(0, width - trailing.width - CcMetrics.footerGap)
readonly property bool occupied: editMode || showEdit
implicitHeight: CcMetrics.footerHeight
implicitHeight: occupied ? CcMetrics.footerHeight : 0
visible: occupied
function cancelConfirmation() {
pendingAction = "";
@@ -52,86 +42,6 @@ Item {
onEditModeChanged: cancelConfirmation()
StyledButton {
id: appsChip
readonly property real iconBox: height - Theme.spacingS * 2
anchors.left: parent.left
height: parent.height
width: Math.min(Theme.spacingS + icons.width + Theme.spacingS + countLabel.implicitWidth + Theme.spacingXS + chevron.width + Theme.spacingM, root.leadingWidth)
radius: Theme.fullRadius(width, height)
color: CcMetrics.tileInactiveColor
border.width: Theme.layerOutlineWidth
border.color: Theme.outlineMedium
visible: !root.editMode && root.toplevels.length > 0
Accessible.name: countLabel.text
onClicked: root.runningAppsRequested()
Row {
id: icons
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Repeater {
model: ScriptModel {
values: root.appIds.slice(0, CcMetrics.footerMaxAppIcons)
}
Rectangle {
required property string modelData
width: appsChip.iconBox
height: width
radius: Theme.fullRadius(width, height)
color: CcMetrics.iconBoxInactiveColor
CcAppIcon {
anchors.centerIn: parent
appId: parent.modelData
iconSize: CcMetrics.footerAppIconSize
}
}
}
}
StyledText {
id: countLabel
anchors.left: icons.right
anchors.leftMargin: Theme.spacingS
anchors.right: chevron.left
anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
text: root.toplevels.length === 1 ? I18n.tr("%1 window", "singular, %1 is 1, open windows in the control center footer").arg(1) : I18n.tr("%1 windows", "plural, %1 is a count of open windows in the control center footer").arg(root.toplevels.length)
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: CcMetrics.tileInactiveContent
elide: Text.ElideRight
}
DankIcon {
id: chevron
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
name: I18n.isRtl ? "chevron_left" : "chevron_right"
size: Theme.iconSizeSmall
color: Theme.onSurfaceVariant
}
FocusRing {
visible: parent.visualFocus
}
StateLayer {
control: parent
stateColor: CcMetrics.tileInactiveContent
}
}
Row {
id: editActions
@@ -230,7 +140,7 @@ Item {
border.width: Theme.layerOutlineWidth
border.color: Theme.outlineMedium
tooltipText: I18n.tr("Edit")
visible: !root.editMode
visible: !root.editMode && root.showEdit
onClicked: root.editToggled()
}
}
@@ -24,8 +24,23 @@ DankEditableGrid {
signal lockRequested
signal powerRequested
signal settingsRequested
signal accountsRequested
signal editRequested
signal closeRequested
property bool tapToClose: false
property var runningToplevels: []
property bool dockEdit: false
readonly property var editDock: {
if (!dockEdit)
return null;
const layout = slotLayout;
const col = layout.columns - 1;
const row = layout.rows - 1;
if (row < 0 || GridUtils.cellOccupied(layout, col, row))
return null;
const rect = GridUtils.slotRect(layout, col, row, 1, 1);
return Qt.point(rect.x, rect.y);
}
readonly property real gridHeight: layoutHeight
readonly property real cellWidth: (width + CcMetrics.gridGap) / columns
@@ -36,7 +51,7 @@ DankEditableGrid {
sourceItems: shownIndices.map(i => Object.assign({}, savedWidgets[i], sizeWithHiddenTwin(i)))
slotLayout: GridUtils.packCards(layoutItems.map(widget => Object.assign({}, widget, WidgetUtils.clampSize(widget, columns, maximumRows))), placementOrder, columns, width, CcMetrics.gridGap, cellWidth - CcMetrics.gridGap, I18n.isRtl, null, CcMetrics.gridStep, true)
placeholderRadius: draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight)
placeholderRadius: draggingSlot?.small ? Theme.fullRadius(draggingSlot.width, draggingSlot.height) : (draggingSlot?.tileItem?.bodyRadius ?? Theme.fullRadius(width, CcMetrics.tileHeight))
onLayoutCommitted: items => model.setLayout(withHidden(items))
@@ -68,6 +83,21 @@ DankEditableGrid {
return widgets;
}
DankActionButton {
x: (root.editDock?.x ?? 0) + root.contentPadding + (root.slotLayout.colW - width) / 2
y: (root.editDock?.y ?? 0) + root.contentPadding + (root.slotLayout.rowUnit - height) / 2
visible: root.editDock !== null
buttonSize: CcMetrics.footerHeight
iconName: "edit"
iconSize: CcMetrics.iconBoxIconSize
iconColor: CcMetrics.tileInactiveContent
backgroundColor: CcMetrics.tileInactiveColor
border.width: Theme.layerOutlineWidth
border.color: Theme.outlineMedium
tooltipText: I18n.tr("Edit")
onClicked: root.editRequested()
}
Repeater {
id: tileRepeater
@@ -13,10 +13,26 @@ DankEditableGridSlot {
readonly property real cols: slot?.cols ?? 1
readonly property real rows: slot?.rows ?? 1
readonly property bool compact: cols <= 2 && rows === 1
readonly property bool small: WidgetUtils.isSmall(grid.layoutItems[index] ?? widgetData, rows)
readonly property var tileItem: tileLoader.item
// Small tiles resize from a virtual half cell so growing them passes through full size first.
property real biasW: 0
property real biasH: 0
readonly property real smallSpanLimit: (1 + CcMetrics.smallRowFraction) / 2
passthrough: tileItem?.passthrough ?? null
function reanchor(small) {
const shift = ((small ? CcMetrics.smallRowFraction : 1) - smallSpanLimit) * grid.cellWidth;
biasH += shift;
if (cols === 1)
biasW += shift;
}
function spanOf(requested, bias) {
return (requested + bias + CcMetrics.gridGap) / grid.cellWidth;
}
onPressAndHold: {
if (!editChrome.hasOptions)
return;
@@ -25,8 +41,13 @@ DankEditableGridSlot {
onResizeRequested: (requestedWidth, requestedHeight) => {
const step = sizeSpec.step;
let width = GridUtils.dimension(Math.round((requestedWidth + CcMetrics.gridGap) / grid.cellWidth / step) * step, sizeSpec.minW, sizeSpec.maxW, sizeSpec.w, step);
let height = GridUtils.dimension(Math.round((requestedHeight + CcMetrics.gridGap) / grid.cellWidth / step) * step, sizeSpec.minH, sizeSpec.maxH, sizeSpec.h, step);
const canShrink = WidgetUtils.canShrink(widgetData.id);
if (canShrink && root.small !== (spanOf(requestedHeight, biasH) < smallSpanLimit))
reanchor(!root.small);
const spanW = spanOf(requestedWidth, biasW);
const spanH = spanOf(requestedHeight, biasH);
let width = GridUtils.dimension(Math.round(spanW / step) * step, sizeSpec.minW, sizeSpec.maxW, sizeSpec.w, step);
let height = GridUtils.dimension(Math.round(spanH / step) * step, sizeSpec.minH, sizeSpec.maxH, sizeSpec.h, step);
const current = WidgetUtils.clampSize(widgetData, grid.columns, grid.maximumRows);
if (WidgetUtils.isSliderWidget(widgetData.id) && width < 2 && height < 2) {
if (width !== current.w && sizeSpec.maxH > 1)
@@ -39,6 +60,9 @@ DankEditableGridSlot {
changes.w = width;
if (height !== current.h)
changes.h = height;
const small = canShrink && height === 1 && spanH < smallSpanLimit;
if (small !== (widgetData.small === true))
changes.small = small;
grid.previewSize(index, changes);
}
@@ -104,6 +128,13 @@ DankEditableGridSlot {
when: root.tileItem !== null
}
Binding {
target: root.tileItem
property: "small"
value: root.small
when: root.tileItem !== null && "small" in root.tileItem
}
Connections {
target: root.tileItem
ignoreUnknownSignals: true
@@ -131,9 +162,13 @@ DankEditableGridSlot {
passthrough: root.passthrough
dragging: root.dragging
resizing: root.resizing
cornerRadius: root.tileItem?.bodyRadius ?? Theme.fullRadius(root.width, root.height)
sizeText: root.cols + "×" + root.rows
onResizeStarted: (px, py) => root.beginResize(px, py)
cornerRadius: root.small ? Theme.fullRadius(root.width, root.height) : (root.tileItem?.bodyRadius ?? Theme.fullRadius(root.width, root.height))
sizeText: (root.small && root.cols === 1 ? CcMetrics.smallRowFraction : root.cols) + "×" + (root.small ? CcMetrics.smallRowFraction : root.rows)
onResizeStarted: (px, py) => {
root.biasH = root.small ? (CcMetrics.smallRowFraction - 1) * root.grid.cellWidth : 0;
root.biasW = root.small && root.cols === 1 ? root.biasH : 0;
root.beginResize(px, py);
}
onResizeMoved: (px, py) => root.resizeTo(px, py)
onResizeEnded: root.finishResize()
onResizeCanceled: root.cancelResize()
@@ -15,6 +15,8 @@ Item {
id: root
property int widgetIndex: -1
property var model: null
property int columns: CcMetrics.gridColumns
property var transientSurfaceTracker: null
property Item _anchor: null
@@ -29,6 +31,8 @@ Item {
readonly property bool isDisk: widgetId === "diskUsage"
readonly property bool isIdleInhibitor: widgetId === "idleInhibitor"
readonly property bool isUser: widgetId === "user"
readonly property bool isRunningApps: widgetId === "runningApps"
readonly property var cardActions: WidgetUtils.cardActions(widgetData)
visible: widgetIndex >= 0 || contextMenu.renderActive
@@ -86,53 +90,96 @@ Item {
}
customContent: Component {
CcGroup {
CcListRow {
visible: root.isPlugin
iconName: "settings"
title: I18n.tr("Plugin settings")
clickable: true
onClicked: {
PopoutService.openSettingsWithTab(SettingsTabs.pluginPrefix + root.widgetId.replace("plugin_", ""));
root.close();
}
}
Column {
width: parent?.width ?? 0
Repeater {
model: root.isUser ? DashRegistry.sheetOptionSpecs("user") : []
CcGroup {
width: parent.width
CcListRow {
visible: root.isPlugin
iconName: "settings"
title: I18n.tr("Plugin settings")
clickable: true
onClicked: {
PopoutService.openSettingsWithTab(SettingsTabs.pluginPrefix + root.widgetId.replace("plugin_", ""));
root.close();
}
}
CcToggleRow {
required property var modelData
visible: root.isUser
text: I18n.tr("Background")
checked: root.widgetData?.background === true
onToggled: checked => root.persistOption("background", checked)
}
text: modelData.text
checked: DashRegistry.optionValue(modelData, root.widgetData?.[modelData.key])
onToggled: checked => root.persistOption(modelData.key, checked)
Repeater {
model: root.isUser ? DashRegistry.sheetOptionSpecs("user") : []
CcToggleRow {
required property var modelData
text: modelData.text
checked: DashRegistry.optionValue(modelData, root.widgetData?.[modelData.key])
onToggled: checked => root.persistOption(modelData.key, checked)
}
}
CcToggleRow {
visible: root.isRunningApps
text: I18n.tr("Show window count", "toggle in control center running apps widget options")
checked: root.widgetData?.showCount !== false
onToggled: checked => root.persistOption("showCount", checked)
}
CcToggleRow {
visible: root.isDisk
text: I18n.tr("Show mount path", "toggle in control center disk usage widget to turn mount path display on or off")
checked: root.widgetData?.showMountPath !== false
onToggled: checked => root.persistOption("showMountPath", checked)
}
CcListRow {
visible: root.isIdleInhibitor
iconName: "timer"
title: I18n.tr("Duration")
body: DankDropdown {
readonly property var presets: IdleInhibitPresets.presetOptions
compactMode: true
dropdownWidth: parent.width
transientSurfaceTracker: contextMenu.transientSurfaceTracker
currentValue: presets.find(p => p.minutes === (root.widgetData?.durationMinutes ?? 0))?.label ?? ""
options: presets.map(p => p.label)
onValueChanged: value => {
const preset = presets.find(p => p.label === value);
if (preset)
root.persistOption("durationMinutes", preset.minutes);
}
}
}
}
CcToggleRow {
visible: root.isDisk
text: I18n.tr("Show mount path", "toggle in control center disk usage widget to turn mount path display on or off")
checked: root.widgetData?.showMountPath !== false
onToggled: checked => root.persistOption("showMountPath", checked)
CcSectionLabel {
width: parent.width
visible: root.isUser
text: I18n.tr("Actions")
}
CcListRow {
visible: root.isIdleInhibitor
iconName: "timer"
title: I18n.tr("Duration")
body: DankDropdown {
readonly property var presets: IdleInhibitPresets.presetOptions
CcGroup {
width: parent.width
visible: root.isUser
compactMode: true
dropdownWidth: parent.width
transientSurfaceTracker: contextMenu.transientSurfaceTracker
currentValue: presets.find(p => p.minutes === (root.widgetData?.durationMinutes ?? 0))?.label ?? ""
options: presets.map(p => p.label)
onValueChanged: value => {
const preset = presets.find(p => p.label === value);
if (preset)
root.persistOption("durationMinutes", preset.minutes);
Repeater {
model: root.isUser ? WidgetUtils.BUTTON_IDS : []
CcToggleRow {
required property string modelData
text: root.model?.getWidgetForId(modelData)?.text ?? modelData
checked: root.cardActions.includes(modelData)
onToggled: checked => root.widgetIndex = WidgetUtils.setCardAction(root.widgetIndex, modelData, checked, root.columns)
}
}
}
@@ -8,6 +8,7 @@ import qs.Modules.ControlCenter.Models
import qs.Modules.ControlCenter.Details
import qs.Widgets
import "./utils/sections.js" as Sections
import "./utils/widgets.js" as WidgetUtils
FocusScope {
id: root
@@ -19,7 +20,7 @@ FocusScope {
readonly property bool pageOpen: (host.expandedSection ?? "") !== ""
readonly property real gridHeight: widgetGrid.gridHeight
readonly property real chromeHeight: Theme.spacingS + footer.height
readonly property real chromeHeight: footer.occupied ? Theme.spacingS + footer.height : 0
readonly property bool widgetSheetOpen: widgetSheetLoader.item?.active ?? false
readonly property real coveredAmount: Math.max(detailPage.opacity, widgetSheetLoader.item?.progress ?? 0)
property bool widgetSheetRequested: false
@@ -30,7 +31,8 @@ FocusScope {
const current = CompositorService.filterCurrentWorkspace(all, host.triggerScreen?.name) || [];
return current.concat(all.filter(toplevel => !current.includes(toplevel)));
}
readonly property string placedWidgetIds: (SettingsData.controlCenterWidgets || []).map(w => w.id).sort().join(",")
readonly property string placedWidgetIds: WidgetUtils.placedIds(SettingsData.controlCenterWidgets).sort().join(",")
readonly property bool editPlaced: placedWidgetIds.split(",").includes("edit")
readonly property real targetImplicitHeight: {
const total = CcMetrics.sheetPadding * 2 + gridHeight + chromeHeight;
if (detailPage.shownSection === "")
@@ -45,7 +47,8 @@ FocusScope {
readonly property vector4d surfaceCornerRadii: host.surfaceCornerRadii ?? Qt.vector4d(Theme.windowRadius, Theme.windowRadius, Theme.windowRadius, Theme.windowRadius)
readonly property int gridColumnCap: host.gridColumnCap ?? CcMetrics.columnCapFor((host.triggerScreen?.width ?? CcMetrics.sheetWidthDefault + Theme.spacingL * 2) - Theme.spacingL * 2)
readonly property int gridColumns: host.gridColumns ?? Math.min(CcMetrics.gridColumns, gridColumnCap)
readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - chromeHeight
// Always reserve the footer: the row cap stays the same in edit mode and cannot loop through the docked edit button.
readonly property real availableGridHeight: (host.availableHeight ?? (host.triggerScreen?.height ?? CcMetrics.fallbackScreenHeight) - CcMetrics.maxHeightInset) - CcMetrics.sheetPadding * 2 - Theme.spacingS - CcMetrics.footerHeight
readonly property vector4d chromeRoom: host.chromeRoom ?? Qt.vector4d(Infinity, Infinity, Infinity, Infinity)
readonly property DankPanelResizer panelResizer: DankPanelResizer {
popout: root.host
@@ -287,7 +290,7 @@ FocusScope {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: footer.top
anchors.bottomMargin: Theme.spacingS
anchors.bottomMargin: footer.occupied ? Theme.spacingS : 0
clip: contentHeight > height
contentWidth: width
contentHeight: Math.max(height, mainColumn.implicitHeight + CcMetrics.sheetPadding)
@@ -320,12 +323,16 @@ FocusScope {
live: root.host.shouldBeVisible && detailPage.shownSection === ""
screenName: root.host.triggerScreen?.name || ""
tapToClose: root.host.headerTogglesClose ?? false
runningToplevels: root.runningToplevels
dockEdit: !root.editPlaced && !root.host.editMode
onExpandClicked: widgetData => root.openWidgetPage(widgetData)
onRemoveWidget: index => widgetModel.removeWidget(index)
onConfigRequested: (index, widgetData, anchor) => root.openConfigOverlay(index, widgetData, anchor)
onColorPickerRequested: root.host.openColorPicker()
onCloseRequested: root.host.close()
onSettingsRequested: root.host.openSettings()
onAccountsRequested: root.host.openAccounts()
onEditRequested: root.host.editMode = true
onLockRequested: {
root.host.close();
root.host.lockRequested();
@@ -353,9 +360,8 @@ FocusScope {
anchors.bottom: parent.bottom
anchors.bottomMargin: CcMetrics.sheetPadding
editMode: root.host.editMode
toplevels: root.runningToplevels
showEdit: !root.editPlaced && widgetGrid.editDock === null
opacity: body.opacity
onRunningAppsRequested: root.navigateTo("runningApps")
onAddWidgetRequested: root.openWidgetSheet()
onResetRequested: widgetModel.resetToDefault()
onClearRequested: widgetModel.clearAll()
@@ -459,6 +465,8 @@ FocusScope {
active: false
sourceComponent: WidgetConfigOverlay {
model: widgetModel
columns: root.gridColumns
transientSurfaceTracker: root.host.transientSurfaceTracker
onVisibleChanged: {
if (visible)
@@ -36,6 +36,11 @@ DankPopout {
close();
}
function openAccounts() {
requestedWindow = "accounts";
close();
}
function openColorPicker() {
if (!colorPickerModal)
return;
@@ -65,6 +70,9 @@ DankPopout {
case "settings":
PopoutService.focusOrToggleSettings();
break;
case "accounts":
PopoutService.openSettingsWithTab("user_accounts");
break;
case "colorPicker":
root.colorPickerModal?.show();
break;
@@ -110,6 +110,9 @@ QtObject {
readonly property Component actionTile: Component {
ActionTile {}
}
readonly property Component runningAppsTile: Component {
RunningAppsTile {}
}
readonly property Component pluginTile: Component {
PluginTile {}
}
@@ -168,7 +171,10 @@ QtObject {
case "lock":
case "power":
case "settings":
case "edit":
return actionTile;
case "runningApps":
return runningAppsTile;
default:
return null;
}
@@ -223,6 +229,24 @@ QtObject {
"category": "system",
"enabled": true
},
{
"id": "edit",
"text": I18n.tr("Edit"),
"description": "",
"icon": "edit",
"type": "action",
"category": "system",
"enabled": true
},
{
"id": "runningApps",
"text": I18n.tr("Running apps"),
"description": "",
"icon": "apps",
"type": "action",
"category": "system",
"enabled": true
},
{
"id": "nightMode",
"text": I18n.tr("Night mode"),
@@ -1,6 +1,6 @@
import QtQuick
import qs.Common
import qs.Modules.ControlCenter
import "../utils/widgets.js" as WidgetUtils
CcTile {
id: root
@@ -10,19 +10,7 @@ CcTile {
toggle: false
iconName: widgetDef?.icon ?? ""
title: widgetDef?.text ?? ""
restIconColor: action === "power" ? Theme.error : CcMetrics.tileInactiveIcon
restIconColor: CcMetrics.actionIconColor(action)
onClicked: {
switch (action) {
case "lock":
host?.lockRequested();
return;
case "power":
host?.powerRequested();
return;
case "settings":
host?.settingsRequested();
return;
}
}
onClicked: WidgetUtils.triggerButton(host, action)
}
@@ -20,17 +20,24 @@ Item {
property real columns: 4
property real rows: 1
property bool compact: columns <= 2 && rows === 1
property bool small: false
readonly property real bodyInset: small ? Math.max(0, (Math.min(width, height) - CcMetrics.iconBoxSize) / 2) : 0
readonly property real bodyWidth: width - bodyInset * 2
readonly property real bodyHeight: height - bodyInset * 2
property bool toggle: !opensPage
property Component expandedContent: null
// Replaces the icon and labels inside the body; the chrome, focus and activation stay.
property Component bodyContent: null
property real expandedMinimumHeight: Theme.listItemHeight
readonly property Item expandedItem: expandedLoader.item
readonly property bool expanded: expandedContent !== null && width >= CcMetrics.expandedTileMinWidth && height >= headerHeight + expandedMinimumHeight + tilePadding * 2 + Theme.spacingM
readonly property real tilePadding: tall && !narrow ? Theme.spacingM : Theme.spacingS
readonly property real baseIconExtent: Math.min(Math.max(Theme.minimumTouchTargetSize, CcMetrics.iconBoxSize), height - tilePadding * 2, width - tilePadding * 2)
readonly property real baseIconExtent: Math.min(Math.max(Theme.minimumTouchTargetSize, CcMetrics.iconBoxSize), bodyHeight - tilePadding * 2, bodyWidth - tilePadding * 2)
readonly property real iconExtent: stacked ? Math.max(0, Math.min(baseIconExtent, height - tilePadding * 2 - Theme.spacingS - titleLabel.implicitHeight)) : baseIconExtent
readonly property real headerHeight: Math.max(baseIconExtent, Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingS)
readonly property bool stacked: tall && !expanded && width <= height
readonly property bool narrow: width < CcMetrics.expandedTileMinWidth
readonly property bool denseText: narrow || small
readonly property real labelHeight: titleLabel.implicitHeight + (showSubtitle ? Theme.spacingXXS + subtitleLabel.implicitHeight : 0)
readonly property bool showSubtitle: subtitle !== "" && (!stacked || height - tilePadding * 2 >= iconExtent + Theme.spacingS + titleLabel.implicitHeight + Theme.spacingXXS + subtitleLabel.implicitHeight)
property bool showExpand: false
@@ -50,10 +57,10 @@ Item {
readonly property bool showsActive: active && interactive
readonly property real restRadius: {
if (showsActive)
return Math.min(CcMetrics.tileActiveRadius, width / 2, height / 2);
return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(width, height);
return Math.min(CcMetrics.tileActiveRadius, bodyWidth / 2, bodyHeight / 2);
return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(bodyWidth, bodyHeight);
}
readonly property bool acceptsInput: interactive && enabled
property bool acceptsInput: interactive && enabled
readonly property bool bodyActive: showsActive && !hasIconBox
readonly property color bodyColor: {
if (!enabled)
@@ -83,7 +90,7 @@ Item {
return showsActive ? CcMetrics.tileActiveColor : CcMetrics.iconBoxInactiveColor;
}
property real bodyRadius: bodyLayer.pressed ? Math.min(Theme.cornerRadiusM, width / 2, height / 2) : restRadius
property real bodyRadius: bodyLayer.pressed ? Math.min(Theme.cornerRadiusM, bodyWidth / 2, bodyHeight / 2) : restRadius
property real iconBoxRadius: {
if (boxLayer.pressed)
return Math.min(Theme.cornerRadiusS, CcMetrics.iconBoxSize / 2);
@@ -151,6 +158,7 @@ Item {
id: body
anchors.fill: parent
anchors.margins: root.bodyInset
radius: root.bodyRadius
color: root.bodyColor
border.width: Theme.layerOutlineWidth
@@ -197,11 +205,11 @@ Item {
id: compactIcon
anchors.centerIn: parent
name: root.iconName
size: CcMetrics.tileIconSize
size: root.small ? CcMetrics.iconBoxIconSize : CcMetrics.tileIconSize
color: root.iconColor
filled: root.showsActive
rotation: root.iconRotation
visible: root.compact
visible: root.compact && root.bodyContent === null
DankBlink {
target: compactIcon
@@ -209,11 +217,17 @@ Item {
}
}
Loader {
anchors.fill: parent
active: root.bodyContent !== null
sourceComponent: root.bodyContent
}
Item {
id: content
anchors.fill: parent
anchors.margins: root.tilePadding
visible: !root.compact
visible: !root.compact && root.bodyContent === null
Rectangle {
id: iconBox
@@ -311,7 +325,7 @@ Item {
width: parent.width
text: root.title
color: root.contentColor
font.pixelSize: root.narrow ? Theme.fontSizeMedium : Theme.fontSizeLarge
font.pixelSize: root.denseText ? Theme.fontSizeMedium : Theme.fontSizeLarge
font.weight: Theme.fontWeightMedium
elide: Text.ElideRight
wrapMode: root.stacked ? Text.Wrap : Text.NoWrap
@@ -326,7 +340,7 @@ Item {
width: parent.width
text: root.subtitle
color: root.subtitleColor
font.pixelSize: root.narrow ? Theme.fontSizeSmall : Theme.fontSizeMedium
font.pixelSize: root.denseText ? Theme.fontSizeSmall : Theme.fontSizeMedium
elide: Text.ElideRight
wrapMode: Text.NoWrap
horizontalAlignment: root.stacked && root.narrow ? Text.AlignHCenter : Text.AlignLeft
@@ -0,0 +1,121 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Modules.ControlCenter
import qs.Widgets
CcTile {
id: root
readonly property var toplevels: host?.runningToplevels ?? []
readonly property var appIds: {
const ids = [];
for (const toplevel of toplevels) {
const id = toplevel?.appId ?? "";
if (!ids.includes(id))
ids.push(id);
}
return ids;
}
readonly property bool showCount: widgetData?.showCount !== false
readonly property string countText: toplevels.length === 1 ? I18n.tr("%1 window", "singular, %1 is 1, open windows in the control center footer").arg(1) : I18n.tr("%1 windows", "plural, %1 is a count of open windows in the control center footer").arg(toplevels.length)
opensPage: true
title: widgetDef?.text ?? ""
subtitle: countText
acceptsInput: interactive && toplevels.length > 0
onClicked: expandClicked()
bodyContent: Component {
Item {
id: body
readonly property real iconBox: Math.max(0, Math.min(CcMetrics.iconBoxSize, height - Theme.spacingS * 2))
readonly property int iconCount: Math.max(1, Math.min(root.appIds.length, CcMetrics.runningAppsMaxIcons, Math.floor((width - Theme.spacingS * 2 + Theme.spacingXS) / (iconBox + Theme.spacingXS))))
readonly property real chevronRoom: chevron.width + Theme.spacingXS
readonly property real textRoom: width - Theme.spacingS * 2 - icons.width - Theme.spacingM
// Drop the wording, then the chevron, then the count itself as the pill narrows.
readonly property string label: {
if (!root.showCount || root.toplevels.length === 0)
return "";
if (fullMetrics.advanceWidth + chevronRoom <= textRoom)
return root.countText;
return shortMetrics.advanceWidth <= textRoom ? String(root.toplevels.length) : "";
}
readonly property bool showChevron: label !== "" && (label === root.countText || shortMetrics.advanceWidth + chevronRoom <= textRoom)
StyledTextMetrics {
id: fullMetrics
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
text: root.countText
}
StyledTextMetrics {
id: shortMetrics
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
text: String(root.toplevels.length)
}
Row {
id: icons
anchors.left: parent.left
anchors.leftMargin: body.label === "" ? (parent.width - width) / 2 : Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Repeater {
model: ScriptModel {
values: root.appIds.length > 0 ? root.appIds.slice(0, body.iconCount) : [""]
}
Rectangle {
required property string modelData
width: body.iconBox
height: width
radius: Theme.fullRadius(width, height)
color: CcMetrics.iconBoxInactiveColor
CcAppIcon {
anchors.centerIn: parent
appId: parent.modelData
iconSize: Math.min(CcMetrics.runningAppsIconSize, parent.width)
}
}
}
}
StyledText {
objectName: "runningAppsLabel"
anchors.left: icons.right
anchors.leftMargin: Theme.spacingS
anchors.right: body.showChevron ? chevron.left : parent.right
anchors.rightMargin: body.showChevron ? Theme.spacingXS : Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: body.label
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: root.contentColor
elide: Text.ElideRight
visible: body.label !== ""
}
DankIcon {
id: chevron
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
name: I18n.isRtl ? "chevron_left" : "chevron_right"
size: Theme.iconSizeSmall
color: Theme.onSurfaceVariant
visible: body.showChevron
}
}
}
}
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import qs.Common
import qs.Modules.ControlCenter
import qs.Modules.DankDash
@@ -21,13 +22,35 @@ Item {
signal optionChanged(string key, var value)
readonly property bool wide: width >= height * 2
readonly property real avatarSide: Math.min(width, height)
readonly property var savedOrder: WidgetUtils.cardOrder(widgetData)
property var previewOrder: null
property string draggedAction: ""
property point dragPosition: Qt.point(0, 0)
property point grabOffset: Qt.point(0, 0)
readonly property var order: previewOrder ?? savedOrder
readonly property int split: order.indexOf(WidgetUtils.CARD_IDENTITY)
readonly property int leadingColumns: WidgetUtils.actionColumns(rows, split)
readonly property int trailingColumns: WidgetUtils.actionColumns(rows, order.length - split - 1)
// Grid pitch keeps card buttons in line with small tiles outside the card.
readonly property real pitch: host?.cellWidth ?? CcMetrics.columnWidth + CcMetrics.gridGap
readonly property real buttonInset: Math.max(0, (pitch - CcMetrics.gridGap - CcMetrics.iconBoxSize) / 2)
readonly property real identityStart: leadingColumns * pitch
readonly property real identityWidth: Math.max(0, width - (leadingColumns + trailingColumns) * pitch)
readonly property bool wide: identityWidth >= height * 2
readonly property bool background: widgetData?.background === true
readonly property bool tall: height >= CcMetrics.gridRowUnit * 2
readonly property real inset: background ? (tall ? Theme.spacingM : Theme.spacingS) : 0
readonly property real avatarSide: Math.min(identityWidth, height) - inset * 2
readonly property string shape: widgetData?.shape ?? WidgetUtils.USER_SHAPES[0]
readonly property bool editMode: host?.editMode ?? false
readonly property bool tapToClose: interactive && (host?.tapToClose ?? false)
readonly property Item passthrough: shapeButton
readonly property real bodyRadius: Theme.fullRadius(avatarSide, avatarSide)
readonly property Item passthrough: passthroughArea
// Only a bare avatar gets the circle outline; any button beside it turns the card into a pill.
readonly property real bodyRadius: {
if (!background && savedOrder.length === 1)
return Theme.fullRadius(avatarSide, avatarSide);
return tall ? Math.min(CcMetrics.tallTileRadius, width / 2, height / 2) : Theme.fullRadius(width, height);
}
readonly property real fallbackGlyphRatio: 0.5
readonly property string avatarSource: PortalService.profileImageUrl
readonly property bool hasImage: picture.status === Image.Ready
@@ -39,12 +62,99 @@ Item {
Accessible.role: Accessible.StaticText
Accessible.name: UserInfoService.fullName || UserInfoService.username
UserIdentity {
function actionSlot(id) {
const index = order.indexOf(id);
const leading = index < split;
const count = leading ? split : order.length - split - 1;
const position = leading ? index : index - split - 1;
const perLine = WidgetUtils.actionColumns(rows, count);
const lines = Math.ceil(count / perLine);
const size = CcMetrics.iconBoxSize;
const start = leading ? 0 : width + CcMetrics.gridGap - trailingColumns * pitch;
const x = start + (position % perLine) * pitch + buttonInset;
return Qt.point(I18n.isRtl ? width - x - size : x, (height - lines * pitch + pitch - size) / 2 + Math.floor(position / perLine) * pitch);
}
// Splitting at the identity's centre stops a swap across it from flickering back.
function reorderTarget(point) {
const identityX = I18n.isRtl ? width - identityStart - identityWidth : identityStart;
if (point.x >= identityX && point.x < identityX + identityWidth) {
const leading = (point.x < identityX + identityWidth / 2) !== I18n.isRtl;
return (order.indexOf(draggedAction) < split) === leading ? "" : WidgetUtils.CARD_IDENTITY;
}
const half = pitch / 2;
const center = CcMetrics.iconBoxSize / 2;
return order.find(id => {
if (id === draggedAction || id === WidgetUtils.CARD_IDENTITY)
return false;
const slot = actionSlot(id);
return Math.abs(point.x - slot.x - center) < half && Math.abs(point.y - slot.y - center) < half;
}) ?? "";
}
function beginReorder(id, scenePoint) {
const point = mapFromItem(null, scenePoint.x, scenePoint.y);
const slot = actionSlot(id);
grabOffset = Qt.point(point.x - slot.x, point.y - slot.y);
dragPosition = slot;
previewOrder = savedOrder.slice();
draggedAction = id;
}
function moveReorder(scenePoint) {
if (draggedAction === "")
return;
const point = mapFromItem(null, scenePoint.x, scenePoint.y);
dragPosition = Qt.point(point.x - grabOffset.x, point.y - grabOffset.y);
const target = reorderTarget(point);
if (target !== "")
previewOrder = WidgetUtils.moveInOrder(previewOrder, draggedAction, target);
}
function finishReorder() {
if (draggedAction === "")
return;
if (JSON.stringify(previewOrder) !== JSON.stringify(savedOrder))
optionChanged("actions", previewOrder);
draggedAction = "";
previewOrder = null;
}
function passesThrough(point) {
const targets = [shapeButton];
for (let i = 0; i < actionRepeater.count; i++)
targets.push(actionRepeater.itemAt(i));
return targets.some(item => item?.visible && item.contains(mapToItem(item, point.x, point.y)));
}
onEditModeChanged: finishReorder()
Rectangle {
anchors.fill: parent
radius: root.bodyRadius
color: CcMetrics.tileInactiveColor
border.width: Theme.layerOutlineWidth
border.color: Theme.outlineMedium
visible: root.background
}
Item {
id: identityArea
anchors.left: parent.left
anchors.leftMargin: root.identityStart
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.identityWidth
}
UserIdentity {
anchors.fill: identityArea
anchors.margins: root.inset
visible: root.wide
live: root.live && root.wide
options: DashRegistry.resolvedOptions("user", root.widgetData)
avatarSize: root.height
avatarSize: height
stacked: root.rows >= 2
contentColor: Theme.onSurface
mutedColor: Theme.onSurfaceVariant
@@ -53,7 +163,7 @@ Item {
Item {
id: avatar
anchors.centerIn: parent
anchors.centerIn: identityArea
width: root.avatarSide
height: root.avatarSide
visible: !root.wide
@@ -111,6 +221,7 @@ Item {
}
StyledButton {
objectName: "userCloseButton"
anchors.fill: parent
visible: root.tapToClose
radius: root.bodyRadius
@@ -127,6 +238,102 @@ Item {
}
}
Repeater {
id: actionRepeater
model: ScriptModel {
values: WidgetUtils.BUTTON_IDS.filter(id => root.savedOrder.includes(id))
}
Item {
id: action
required property string modelData
readonly property var definition: root.host?.model?.getWidgetForId(modelData) ?? null
readonly property point slot: root.actionSlot(modelData)
readonly property bool held: root.draggedAction === modelData
x: held ? root.dragPosition.x : slot.x
y: held ? root.dragPosition.y : slot.y
z: held ? 1 : 0
width: CcMetrics.iconBoxSize
height: width
Behavior on x {
enabled: !action.held && CcMetrics.animationsEnabled
NumberAnimation {
duration: Theme.expressiveDurations.expressiveFastSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
}
}
Behavior on y {
enabled: !action.held && CcMetrics.animationsEnabled
NumberAnimation {
duration: Theme.expressiveDurations.expressiveFastSpatial
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
}
}
DankActionButton {
anchors.fill: parent
buttonSize: CcMetrics.iconBoxSize
iconName: action.definition?.icon ?? ""
iconSize: CcMetrics.iconBoxIconSize
iconColor: CcMetrics.actionIconColor(action.modelData)
backgroundColor: root.background ? "transparent" : CcMetrics.tileInactiveColor
border.width: root.background ? 0 : Theme.layerOutlineWidth
border.color: Theme.outlineMedium
tooltipText: action.definition?.text ?? ""
onClicked: {
if (root.interactive)
WidgetUtils.triggerButton(root.host, action.modelData);
}
}
DragHandler {
target: null
enabled: root.editMode
cursorShape: Qt.ClosedHandCursor
onActiveChanged: {
if (active) {
root.beginReorder(action.modelData, centroid.scenePressPosition);
return;
}
root.finishReorder();
}
onCentroidChanged: {
if (active)
root.moveReorder(centroid.scenePosition);
}
}
}
}
StyledButton {
objectName: "userAvatarButton"
x: identityArea.x + (!root.wide ? (identityArea.width - width) / 2 : I18n.isRtl ? identityArea.width - root.inset - width : root.inset)
y: (root.height - height) / 2
width: root.avatarSide
height: root.avatarSide
radius: Theme.fullRadius(width, height)
visible: root.interactive
Accessible.name: I18n.tr("Users & accounts", "settings sidebar category")
onClicked: root.host?.accountsRequested()
FocusRing {
visible: parent.visualFocus
}
StateLayer {
control: parent
cornerRadius: parent.radius
tooltipText: parent.Accessible.name
}
}
DankActionButton {
id: shapeButton
@@ -141,4 +348,16 @@ Item {
visible: root.editMode && avatar.visible
onClicked: root.optionChanged("shape", WidgetUtils.nextUserShape(root.shape))
}
// Edit-mode grid input skips these points so the shape and card buttons stay clickable.
Item {
id: passthroughArea
anchors.fill: parent
containmentMask: QtObject {
function contains(point: point): bool {
return root.passesThrough(point);
}
}
}
}
@@ -3,14 +3,81 @@
.import qs.Modules.ControlCenter as ControlCenter
.import "../../../Common/GridLayout.js" as GridLayout
var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user"];
var OPTION_IDS = ["diskUsage", "brightnessSlider", "idleInhibitor", "user", "runningApps"];
var ACTION_IDS = ["settings", "lock", "power"];
var BUTTON_IDS = ACTION_IDS.concat(["edit"]);
var SMALL_BY_DEFAULT = BUTTON_IDS.concat(["runningApps"]);
var CARD_IDENTITY = "user";
var USER_SHAPES = ["circle", "cookie4", "cookie7", "cookie12", "clover4", "clover8", "sunny", "pentagon", "arch", "slanted", "gem", "square"];
function isSliderWidget(id) {
return id === "volumeSlider" || id === "brightnessSlider" || id === "inputVolumeSlider";
}
function isButton(id) {
return BUTTON_IDS.includes(id);
}
// A "user" entry marks where the identity sits among the card's buttons.
function cardOrder(widget) {
const saved = Array.isArray(widget?.actions) ? widget.actions : [];
const order = saved.filter((id, i) => (id === CARD_IDENTITY || BUTTON_IDS.includes(id)) && saved.indexOf(id) === i);
return order.includes(CARD_IDENTITY) ? order : [CARD_IDENTITY].concat(order);
}
function cardActions(widget) {
return cardOrder(widget).filter(id => id !== CARD_IDENTITY);
}
function actionColumns(rows, count) {
return count > 0 ? Math.ceil(count / Math.max(1, Math.min(count, Math.floor(Number(rows) || 1)))) : 0;
}
function cardColumns(order, rows) {
const split = order.indexOf(CARD_IDENTITY);
return actionColumns(rows, split) + actionColumns(rows, order.length - split - 1);
}
function moveInOrder(order, id, targetId) {
const from = order.indexOf(id);
const to = order.indexOf(targetId);
if (from < 0 || to < 0 || from === to)
return order;
const moved = order.slice();
moved.splice(from, 1);
moved.splice(to, 0, id);
return moved;
}
function placedIds(widgets) {
return (widgets || []).reduce((ids, widget) => ids.concat([widget?.id], cardActions(widget)), []);
}
function triggerButton(host, id) {
switch (id) {
case "lock":
host?.lockRequested();
return;
case "power":
host?.powerRequested();
return;
case "settings":
host?.settingsRequested();
return;
case "edit":
host?.editRequested();
return;
}
}
function canShrink(id) {
return id !== "user" && !isSliderWidget(id);
}
function isSmall(widget, rows) {
return widget?.small === true && rows === 1 && canShrink(widget.id);
}
function isShown(widget) {
switch (widget?.id) {
case "battery":
@@ -60,9 +127,13 @@ function sizeSpec(widget, columns, rows = Infinity) {
"maxH": rows,
"step": ControlCenter.CcMetrics.gridStep
};
if (widget?.id === "user")
if (widget?.id === "user") {
spec.w = (Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns) - ACTION_IDS.length;
if (ACTION_IDS.includes(widget?.id))
spec.minW = Math.min(columns, 1 + cardColumns(cardOrder(widget), widget.h));
}
if (widget?.id === "runningApps")
spec.w = Number.isFinite(columns) ? columns : ControlCenter.CcMetrics.defaultColumns;
if (isButton(widget?.id))
spec.w = 1;
return spec;
}
@@ -89,6 +160,8 @@ function clampSize(widget, columns, rows = Infinity) {
function addWidget(widgetId, columns) {
const widgets = Common.SettingsData.controlCenterWidgets.slice();
const widget = defaultWidget(widgetId, columns);
if (SMALL_BY_DEFAULT.includes(widgetId))
widget.small = true;
if (widgetId === "diskUsage") {
widget.instanceId = generateUniqueId();
@@ -127,6 +200,50 @@ function setOption(index, key, value) {
Common.SettingsData.set("controlCenterWidgets", widgets);
}
// A button lives in the card or the grid, never both; returns the card's index since tiles moving in or out shift it.
function setCardAction(index, id, enabled, columns) {
const widgets = Common.SettingsData.controlCenterWidgets.slice();
const card = widgets[index];
if (!card)
return index;
const before = cardOrder(card);
const order = before.filter(action => action !== id).concat(enabled ? [id] : []);
const width = clampSize(card, columns).w;
const w = Math.max(1, Math.min(columns, width + cardColumns(order, card.h) - cardColumns(before, card.h)));
const updated = Object.assign({}, card, {
"actions": order,
"w": w
});
widgets[index] = updated;
if (enabled)
return commitCard(widgets.filter(widget => widget?.id !== id), updated);
if (!before.includes(id) || placedIds(widgets).includes(id))
return commitCard(widgets, updated);
const tile = Object.assign(defaultWidget(id, columns), {
"small": true
});
const leading = before.indexOf(id) < before.indexOf(CARD_IDENTITY);
const freed = width - w;
if (isUnplaced(card)) {
widgets.splice(leading ? index : index + 1, 0, tile);
} else if (freed > 0) {
tile.col = leading ? card.col : card.col + w;
tile.row = card.row;
if (leading)
updated.col = card.col + freed;
widgets.splice(index + 1, 0, tile);
} else {
widgets.push(tile);
}
return commitCard(widgets, updated);
}
function commitCard(widgets, card) {
Common.SettingsData.set("controlCenterWidgets", widgets);
return widgets.indexOf(card);
}
function setLayout(widgets) {
Common.SettingsData.set("controlCenterWidgets", widgets);
}
@@ -88,6 +88,10 @@ FocusScope {
root.windowRequested("settings");
}
function openAccounts() {
root.windowRequested("accounts");
}
function openColorPicker() {
if (!PopoutService.colorPickerModal)
return;
@@ -163,6 +163,9 @@ Item {
case "settings":
PopoutService.focusOrToggleSettings();
break;
case "accounts":
PopoutService.openSettingsWithTab("user_accounts");
break;
case "colorPicker":
PopoutService.showColorPicker();
break;
+1 -2
View File
@@ -87,8 +87,7 @@ Item {
const target = root.passthrough;
if (!target?.visible)
return true;
const local = root.mapToItem(target, point.x, point.y);
return local.x < 0 || local.y < 0 || local.x >= target.width || local.y >= target.height;
return !target.contains(root.mapToItem(target, point.x, point.y));
}
}
+1 -2
View File
@@ -231,8 +231,7 @@ Item {
const target = root.passthrough;
if (!target?.visible)
return true;
const local = band.mapToItem(target, point.x, point.y);
return local.x < 0 || local.y < 0 || local.x >= target.width || local.y >= target.height;
return !target.contains(band.mapToItem(target, point.x, point.y));
}
}
}
@@ -29,6 +29,20 @@ ShellRoot {
WidgetModel {
id: registry
}
QtObject {
id: userHost
property bool editMode: false
property bool tapToClose: true
property var model: registry
property int closes: 0
property int accounts: 0
function closeRequested() {
closes++;
}
function accountsRequested() {
accounts++;
}
}
IslandController {
id: island
dashboardAvailableWidth: CcMetrics.sheetWidthFor(20) + controlCenterSheetInset + PopoutMetrics.editOverflow * 2
@@ -256,9 +270,241 @@ ShellRoot {
sliderSlot.resizeRequested(grid.cellWidth * 3.5 - CcMetrics.gridGap, grid.cellWidth - CcMetrics.gridGap);
grid.commitSize();
check(SettingsData.controlCenterWidgets[0].w === 3.5 && sliderSlot.tileItem.columns === 3.5, "resizing snaps to half columns");
SettingsData.controlCenterWidgets = [
{
id: "lock",
w: 1,
h: 1
}
];
const buttonSlot = grid.children.find(child => typeof child.beginResize === "function");
const buttonChrome = buttonSlot.children.find(child => typeof child.resizeStarted === "function");
const resizeButton = delta => {
buttonChrome.resizeStarted(0, 0, 1);
buttonSlot.resizeTo(delta, delta);
buttonSlot.finishResize();
return SettingsData.controlCenterWidgets[0];
};
let button = resizeButton(-grid.cellWidth / 2);
check(button.small === true && button.w === 1 && button.h === 1 && buttonSlot.tileItem.small, "shrinking a 1x1 below its cell keeps the cell and makes the button small " + JSON.stringify(button));
button = resizeButton(0);
check(button.small === true, "grabbing a small button without moving keeps it small");
button = resizeButton(grid.cellWidth * 0.3);
check(button.small === false && button.w === 1 && button.h === 1, "growing a small button passes through a full 1x1 " + JSON.stringify(button));
button = resizeButton(-grid.cellWidth / 2);
button = resizeButton(grid.cellWidth);
check(button.small === false && button.w === 2 && button.h === 2, "past full size a small button grows a cell per cell of travel like a full one " + JSON.stringify(button));
SettingsData.controlCenterWidgets = [
{
id: "darkMode",
w: 4,
h: 1
}
];
const wideSlot = grid.children.find(child => typeof child.beginResize === "function");
const wideChrome = wideSlot.children.find(child => typeof child.resizeStarted === "function");
const resizeWide = delta => {
wideChrome.resizeStarted(0, 0, 1);
wideSlot.resizeTo(0, delta);
wideSlot.finishResize();
return SettingsData.controlCenterWidgets[0];
};
let wide = resizeWide(-grid.cellWidth / 2);
check(wide.small === true && wide.w === 4 && wide.h === 1 && wideSlot.tileItem.small, "wide tiles take the half-height step too " + JSON.stringify(wide));
wide = resizeWide(grid.cellWidth * 0.3);
check(wide.small === false && wide.w === 4 && wide.h === 1, "a half-height tile grows back to a full row " + JSON.stringify(wide));
SettingsData.controlCenterWidgets = [
{
id: "user",
w: 5,
h: 1
},
{
id: "lock",
w: 1,
h: 1,
small: true
},
{
id: "edit",
w: 1,
h: 1,
small: true
}
];
WidgetUtils.setCardAction(0, "lock", true, 8);
WidgetUtils.setCardAction(0, "edit", true, 8);
let card = SettingsData.controlCenterWidgets[0];
check(SettingsData.controlCenterWidgets.length === 1 && JSON.stringify(card.actions) === JSON.stringify(["user", "lock", "edit"]) && card.w === 7, "card actions take their tiles out of the grid and widen the card by their columns " + JSON.stringify(SettingsData.controlCenterWidgets));
check(WidgetUtils.sizeSpec(Object.assign({}, card, {
h: 2
}), 8).minW === 2 && WidgetUtils.sizeSpec(card, 8).minW === 3, "card buttons wrap into rows before they claim columns");
check(WidgetUtils.sizeSpec(Object.assign({}, card, {
h: 2,
actions: ["lock", "user", "edit"]
}), 8).minW === 3, "buttons on both sides of the identity claim a column each");
const cardTile = grid.children.find(child => typeof child.beginResize === "function").tileItem;
const sceneOf = point => cardTile.mapToItem(null, point.x, point.y);
const edit = cardTile.actionSlot("edit");
cardTile.beginReorder("edit", sceneOf(Qt.point(edit.x + 1, edit.y + 1)));
cardTile.moveReorder(sceneOf(Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.75, cardTile.height / 2)));
check(JSON.stringify(cardTile.order) === JSON.stringify(["user", "lock", "edit"]), "a button stays put on its own side of the identity");
cardTile.moveReorder(sceneOf(Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.25, cardTile.height / 2)));
cardTile.moveReorder(sceneOf(Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.4, cardTile.height / 2)));
cardTile.finishReorder();
card = SettingsData.controlCenterWidgets[0];
check(JSON.stringify(card.actions) === JSON.stringify(["edit", "user", "lock"]), "dragging a button past the identity's centre saves it on the other side " + JSON.stringify(card));
check(Math.abs(cardTile.actionSlot("edit").x - cardTile.buttonInset) < 0.5 && cardTile.identityStart === cardTile.pitch, "the identity makes room for leading buttons");
const lock = cardTile.actionSlot("lock");
const lockCenter = Qt.point(lock.x + CcMetrics.iconBoxSize / 2, lock.y + CcMetrics.iconBoxSize / 2);
const dropPoint = Qt.point(cardTile.identityStart + cardTile.identityWidth * 0.25, cardTile.height / 2);
mousePress(cardTile, lockCenter.x, lockCenter.y);
for (let i = 1; i <= 8; i++)
mouseMove(cardTile, lockCenter.x + (dropPoint.x - lockCenter.x) * i / 8, lockCenter.y + (dropPoint.y - lockCenter.y) * i / 8);
check(grid.draggingSourceIndex === -1 && cardTile.draggedAction === "lock", "dragging a card button reorders it instead of moving the card");
mouseRelease(cardTile, dropPoint.x, dropPoint.y);
check(JSON.stringify(SettingsData.controlCenterWidgets[0].actions) === JSON.stringify(["edit", "lock", "user"]), "a mouse drop saves the new order " + JSON.stringify(SettingsData.controlCenterWidgets[0]));
const corner = Qt.point(cardTile.width - 2, cardTile.height - 2);
mousePress(cardTile, corner.x, corner.y);
mouseMove(cardTile, corner.x, corner.y + grid.cellWidth / 2);
mouseMove(cardTile, corner.x, corner.y + grid.cellWidth);
check(grid.sizePreview?.index === 0 && grid.draggingSourceIndex === -1, "the card's resize corner still works around its buttons");
mouseRelease(cardTile, corner.x, corner.y + grid.cellWidth);
SettingsData.controlCenterWidgets = [
{
id: "user",
w: 7,
h: 1,
col: 0,
row: 0,
actions: ["settings", "user", "lock"]
},
{
id: "wifi",
w: 4,
h: 1,
col: 0,
row: 1
}
];
let cardIndex = WidgetUtils.setCardAction(0, "settings", false, 8);
cardIndex = WidgetUtils.setCardAction(cardIndex, "lock", false, 8);
const returned = SettingsData.controlCenterWidgets;
const at = id => grid.slotLayout.slots[SettingsData.controlCenterWidgets.findIndex(widget => widget.id === id)];
check(returned[cardIndex].id === "user" && returned[cardIndex].w === 5 && at("user").col === 1 && at("settings").col === 0 && at("lock").col === 6 && at("settings").row === 0 && at("lock").row === 0 && at("wifi").row === 1 && returned.every(widget => widget.id === "user" || widget.id === "wifi" || widget.small), "buttons taken off a placed card return as small tiles where they sat " + JSON.stringify(returned));
SettingsData.controlCenterWidgets = [
{
id: "wifi",
w: 2,
h: 1
},
{
id: "user",
w: 6,
h: 1,
actions: ["power", "user"]
}
];
cardIndex = WidgetUtils.setCardAction(1, "power", false, 8);
check(cardIndex === 2 && SettingsData.controlCenterWidgets[1].id === "power" && at("power").col === 2 && at("user").col === 3 && at("user").row === 0, "a button taken off an unplaced card packs next to it and the card index follows " + JSON.stringify(SettingsData.controlCenterWidgets));
check(WidgetUtils.setCardAction(cardIndex, "power", true, 8) === 1, "adding a tile before the card to it reports the card's new index");
grid.dockEdit = true;
SettingsData.controlCenterWidgets = [
{
id: "wifi",
w: 8,
h: 1
},
{
id: "runningApps",
w: 7,
h: 1,
small: true
}
];
const dock = grid.editDock;
check(dock !== null && Math.abs(dock.x - grid.cellWidth * 7) < 0.5 && Math.abs(dock.y - grid.cellWidth) < 0.5, "the edit button joins a last row with a free trailing cell " + JSON.stringify(dock));
SettingsData.controlCenterWidgets = [
{
id: "wifi",
w: 4,
h: 1
},
{
id: "runningApps",
w: 8,
h: 1
}
];
check(grid.editDock === null, "a full last row keeps the edit button in the footer");
grid.dockEdit = false;
grid.destroy();
wait(0);
const userTile = registry.componentForWidget({
id: "user"
}).createObject(scene);
size(userTile, 6, 2);
userTile.widgetData = {
id: "user",
actions: ["user", "lock", "power"],
background: true
};
const cardRadius = userTile.bodyRadius;
userTile.widgetData = {
id: "user",
actions: ["user", "lock", "power"]
};
check(userTile.bodyRadius === cardRadius && cardRadius < userTile.height / 2, "a tall card with buttons keeps its card outline without a background");
userTile.host = userHost;
const avatarButton = named(userTile, "userAvatarButton");
const closeButton = named(userTile, "userCloseButton");
const shownAvatar = item => {
if (item !== userTile && item.visible && (item.imageSource !== undefined || item.shape !== undefined))
return item;
for (const child of item.children || []) {
const found = shownAvatar(child);
if (found)
return found;
}
return null;
};
for (const layout of [[5, 1, {}], [6, 2, {
background: true,
actions: ["settings", "user", "lock"]
}], [1, 1, {}], [3, 3, {
background: true
}]]) {
size(userTile, layout[0], layout[1]);
userTile.widgetData = Object.assign({
id: "user"
}, layout[2]);
settle();
const shown = shownAvatar(userTile);
const at = shown.mapToItem(userTile, 0, 0);
const hit = avatarButton.mapToItem(userTile, 0, 0);
check(Math.abs(at.x - hit.x) < 1 && Math.abs(at.y - hit.y) < 1 && Math.abs(shown.width - avatarButton.width) < 1, "the avatar's click area covers the picture at " + layout[0] + "x" + layout[1] + " " + JSON.stringify([at.x, at.y, shown.width, hit.x, hit.y, avatarButton.width]));
}
size(userTile, 5, 1);
userTile.widgetData = {
id: "user"
};
settle();
const avatarCenter = avatarButton.mapToItem(userTile, avatarButton.width / 2, avatarButton.height / 2);
mouseMove(userTile, avatarCenter.x, avatarCenter.y);
check(avatarButton.hovered && !closeButton.hovered, "hovering the avatar lights only the avatar");
mouseClick(userTile, avatarCenter.x, avatarCenter.y);
check(userHost.accounts === 1 && userHost.closes === 0, "clicking the avatar opens accounts without closing through the card");
mouseClick(userTile, userTile.width - Theme.spacingL, userTile.height / 2);
check(userHost.accounts === 1 && userHost.closes === 1, "clicking the rest of the card only closes");
userTile.destroy();
wait(0);
const tile = tileComponent.createObject(scene);
size(tile, 4, 1);
tile.small = true;
settle();
const smallBox = named(tile, "tileIconBox");
check(smallBox.height <= tile.bodyHeight - tile.tilePadding * 2 && named(tile, "tileLabels").height <= tile.bodyHeight, "a half-height tile keeps its icon and labels inside its body");
tile.small = false;
size(tile, 4, 3);
settle();
check(tile.expanded && tile.expandedItem !== null, "large tile exposes inline actions");
@@ -30,3 +30,10 @@ test("header entries become a user row and lock, power and settings tiles", () =
const released = store.migrateToVersion({ configVersion: 18, controlCenterWidgets: [{ id: "wifi", width: 50 }] }, 35);
assert.deepEqual(JSON.parse(JSON.stringify(released.controlCenterWidgets.map(w => [w.id, w.w, w.h]))), [["user", 5, 1], ["settings", 1, 1], ["lock", 1, 1], ["power", 1, 1], ["wifi", 4, 1]]);
});
test("layouts saved before the running apps tile get one appended", () => {
const migrated = store.migrateToVersion({ configVersion: 35, controlCenterColumns: 6, controlCenterWidgets: [{ id: "wifi", w: 4, h: 1 }] }, 36);
assert.deepEqual(JSON.parse(JSON.stringify(migrated.controlCenterWidgets)), [{ id: "wifi", w: 4, h: 1 }, { id: "runningApps", enabled: true, w: 5, h: 1, small: true }]);
const kept = store.migrateToVersion({ configVersion: 35, controlCenterWidgets: [{ id: "runningApps", w: 2, h: 1, col: 0, row: 3 }] }, 36);
assert.deepEqual(JSON.parse(JSON.stringify(kept.controlCenterWidgets)), [{ id: "runningApps", w: 2, h: 1, col: 0, row: 3 }]);
});