diff --git a/core/go.mod b/core/go.mod
index 4d2d22a0e..aa21d7eb4 100644
--- a/core/go.mod
+++ b/core/go.mod
@@ -18,6 +18,8 @@ require (
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6
github.com/spf13/cobra v1.10.2
+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
+ github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780
github.com/stretchr/testify v1.12.1
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
diff --git a/core/go.sum b/core/go.sum
index b7b3f3583..9fd7873fd 100644
--- a/core/go.sum
+++ b/core/go.sum
@@ -184,6 +184,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780 h1:oDMiXaTMyBEuZMU53atpxqYsSB3U1CHkeAu2zr6wTeY=
+github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go
index 6e0088909..31f1a2a16 100644
--- a/core/internal/matugen/matugen.go
+++ b/core/internal/matugen/matugen.go
@@ -221,6 +221,12 @@ func smartSchemePreview(fallback SchemePreview, sourceColor string, contrast flo
opts.Kind = "image"
opts.Value = imagePath
}
+ _, cleanup, err := stageImageSource(opts)
+ if err != nil {
+ log.Warnf("Smart scheme preview failed falling back to tonal-spot: %v", err)
+ return fallback
+ }
+ defer cleanup()
output, err := runMatugenDryRun(opts)
if err != nil {
log.Warnf("Smart scheme preview failed falling back to tonal-spot: %v", err)
@@ -353,6 +359,11 @@ func buildOnce(opts *Options) (bool, error) {
if err != nil {
return false, err
}
+ sourceImage, cleanup, err := stageImageSource(opts)
+ if err != nil {
+ return false, err
+ }
+ defer cleanup()
if err := resolveSmartMode(opts, flags); err != nil {
return false, err
}
@@ -380,7 +391,6 @@ func buildOnce(opts *Options) (bool, error) {
var primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string
var dank16JSON string
var importArgs []string
- var sourceImage string
// Colorful mode resolves the seed here, before matugen is invoked at all,
// by rewriting the source to the extracted hex. Both the dry-run and the
@@ -394,12 +404,6 @@ func buildOnce(opts *Options) (bool, error) {
return false, err
}
log.Infof("Seed color override: %s -> %s", opts.Value, seed)
- if opts.Kind == "image" {
- sourceImage = opts.Value
- if abs, err := filepath.Abs(sourceImage); err == nil {
- sourceImage = abs
- }
- }
opts.Kind = "hex"
opts.Value = seed
opts.SourceMode = SourceModeDominant
@@ -410,11 +414,6 @@ func buildOnce(opts *Options) (bool, error) {
log.Warnf("Colorful source extraction failed for %s, using matugen's own: %v", opts.Value, err)
} else {
log.Infof("Colorful source color: %s -> %s", opts.Value, seed)
- // matugen resolves {{image}} to an absolute path, so match it.
- sourceImage = opts.Value
- if abs, err := filepath.Abs(sourceImage); err == nil {
- sourceImage = abs
- }
opts.Kind = "hex"
opts.Value = seed
}
diff --git a/core/internal/matugen/svgsource.go b/core/internal/matugen/svgsource.go
new file mode 100644
index 000000000..c347379e8
--- /dev/null
+++ b/core/internal/matugen/svgsource.go
@@ -0,0 +1,71 @@
+package matugen
+
+import (
+ "fmt"
+ "image"
+ "image/png"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/srwiley/oksvg"
+ "github.com/srwiley/rasterx"
+)
+
+// stageImageSource returns the absolute path templates see as {{image}} and,
+// for an SVG, swaps opts.Value for a one-shot PNG raster matugen can decode.
+// !TODO: drop the SVG raster once matugen decodes SVG; matugen 4.2.0 (material-colors 0.4.2) panics on an .svg source
+func stageImageSource(opts *Options) (sourceImage string, cleanup func(), err error) {
+ noop := func() {}
+ if opts.Kind != "image" {
+ return "", noop, nil
+ }
+ sourceImage = opts.Value
+ if abs, err := filepath.Abs(sourceImage); err == nil {
+ sourceImage = abs
+ }
+ if !strings.EqualFold(filepath.Ext(opts.Value), ".svg") {
+ return sourceImage, noop, nil
+ }
+ raster, err := rasterizeSVGToPNG(opts.Value)
+ if err != nil {
+ return "", noop, fmt.Errorf("svg source %s: %w", opts.Value, err)
+ }
+ opts.Value = raster
+ return sourceImage, func() { os.Remove(raster) }, nil
+}
+
+func rasterizeSVGToPNG(path string) (string, error) {
+ img, err := rasterizeSVG(path, sourceSampleMaxDim)
+ if err != nil {
+ return "", err
+ }
+ f, err := os.CreateTemp("", "matugen-svg-*.png")
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ if err := png.Encode(f, img); err != nil {
+ os.Remove(f.Name())
+ return "", err
+ }
+ return f.Name(), nil
+}
+
+func rasterizeSVG(path string, maxDim int) (image.Image, error) {
+ icon, err := oksvg.ReadIcon(path, oksvg.IgnoreErrorMode)
+ if err != nil {
+ return nil, err
+ }
+ vw, vh := icon.ViewBox.W, icon.ViewBox.H
+ if vw <= 0 || vh <= 0 {
+ return nil, fmt.Errorf("no usable viewBox")
+ }
+ scale := float64(maxDim) / math.Max(vw, vh)
+ w, h := max(int(vw*scale), 1), max(int(vh*scale), 1)
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ icon.SetTarget(0, 0, float64(w), float64(h))
+ icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, img, img.Bounds())), 1)
+ return img, nil
+}
diff --git a/core/internal/matugen/svgsource_test.go b/core/internal/matugen/svgsource_test.go
new file mode 100644
index 000000000..58f55e0ea
--- /dev/null
+++ b/core/internal/matugen/svgsource_test.go
@@ -0,0 +1,58 @@
+package matugen
+
+import (
+ "image"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStageImageSourceRasterizesSVG(t *testing.T) {
+ svgPath := filepath.Join(t.TempDir(), "wall.svg")
+ svg := ``
+ if err := os.WriteFile(svgPath, []byte(svg), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ opts := &Options{Kind: "image", Value: svgPath}
+ sourceImage, cleanup, err := stageImageSource(opts)
+ if err != nil {
+ t.Fatalf("stageImageSource: %v", err)
+ }
+ assert.Equal(t, svgPath, sourceImage)
+ assert.NotEqual(t, svgPath, opts.Value)
+
+ f, err := os.Open(opts.Value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ img, format, err := image.Decode(f)
+ f.Close()
+ if err != nil {
+ t.Fatalf("decode raster: %v", err)
+ }
+ assert.Equal(t, "png", format)
+ assert.Equal(t, sourceSampleMaxDim, img.Bounds().Dx())
+ assert.Equal(t, sourceSampleMaxDim/2, img.Bounds().Dy())
+ r, g, b, _ := img.At(500, 10).RGBA()
+ assert.Equal(t, [3]uint8{0x33, 0x55, 0xff}, [3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)})
+
+ seed, err := ExtractSourceColor(opts.Value)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, seed)
+
+ cleanup()
+ _, statErr := os.Stat(opts.Value)
+ assert.True(t, os.IsNotExist(statErr))
+}
+
+func TestStageImageSourcePassesRasterThrough(t *testing.T) {
+ opts := &Options{Kind: "image", Value: "/wallpapers/x.jpg"}
+ sourceImage, cleanup, err := stageImageSource(opts)
+ assert.NoError(t, err)
+ assert.Equal(t, "/wallpapers/x.jpg", opts.Value)
+ assert.Equal(t, "/wallpapers/x.jpg", sourceImage)
+ cleanup()
+}
diff --git a/dank-qml-common b/dank-qml-common
index 6f75d9065..69859d278 160000
--- a/dank-qml-common
+++ b/dank-qml-common
@@ -1 +1 @@
-Subproject commit 6f75d9065b2ff708eb1e91eec30ec5008d1ce3ee
+Subproject commit 69859d278c645793127c4920f441b9ee7724b599
diff --git a/flake.lock b/flake.lock
index d1459e7d5..484de9f44 100644
--- a/flake.lock
+++ b/flake.lock
@@ -3,11 +3,11 @@
"dank-qml-common": {
"flake": false,
"locked": {
- "lastModified": 1789851922,
- "narHash": "sha256-0+ix8DGpRVi4tcsu7u3XA+aw5gUXRZZbGxVu5ZgOWgo=",
+ "lastModified": 1789865489,
+ "narHash": "sha256-TUK26uTRz20ifTLf/OP3oRypJ00+1ePXsxSsLQrb5e4=",
"owner": "AvengeMedia",
"repo": "dank-qml-common",
- "rev": "6f75d9065b2ff708eb1e91eec30ec5008d1ce3ee",
+ "rev": "69859d278c645793127c4920f441b9ee7724b599",
"type": "github"
},
"original": {
diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml
index 319d20d35..1596cb6d8 100644
--- a/quickshell/Common/SettingsData.qml
+++ b/quickshell/Common/SettingsData.qml
@@ -466,6 +466,8 @@ Singleton {
"enabled": false
}
]
+ property string dashTabPosition: Spec.SPEC.dashTabPosition.def
+ property bool dashTabsEvenlySpaced: Spec.SPEC.dashTabsEvenlySpaced.def
property var dashTabs: Spec.SPEC.dashTabs.def
onDashTabsChanged: saveSettings()
diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml
index 97c456ba8..9bda286e3 100644
--- a/quickshell/Common/Theme.qml
+++ b/quickshell/Common/Theme.qml
@@ -1323,6 +1323,12 @@ Singleton {
readonly property real spinnerStrokeWidth: 2
readonly property real tabMinWidth: 64
readonly property real tabIndicatorHeight: 3
+ readonly property real navigationHeight: 64
+ readonly property real navigationRailWidth: 96
+ readonly property real navigationItemMinWidth: 80
+ readonly property real navigationIndicatorWidth: 56
+ readonly property real navigationIndicatorHeight: 32
+ readonly property real navigationVerticalPadding: 6
readonly property real tabIndicatorMinWidth: 24
readonly property real tabIndicatorInset: 2
readonly property real launcherTileSize: 120
diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js
index 0cfa9c2a7..9a0247e38 100644
--- a/quickshell/Common/settings/SettingsSpec.js
+++ b/quickshell/Common/settings/SettingsSpec.js
@@ -603,6 +603,12 @@ var LOCAL_SPEC = {
keybindsFloatingWindow: {
def: false
},
+ dashTabPosition: {
+ def: "auto"
+ },
+ dashTabsEvenlySpaced: {
+ def: true
+ },
dashTabs: {
def: [
{
diff --git a/quickshell/Modals/Settings/SettingsModal.qml b/quickshell/Modals/Settings/SettingsModal.qml
index 83bb1e52c..19adae4da 100644
--- a/quickshell/Modals/Settings/SettingsModal.qml
+++ b/quickshell/Modals/Settings/SettingsModal.qml
@@ -243,7 +243,7 @@ DankFloatingWindow {
browserTitle: I18n.tr("Select Profile Image", "profile image file browser title")
browserType: "profile"
showHiddenFiles: true
- fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"]
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr", "*.svg"]
onFileSelected: path => {
PortalService.setProfileImage(path);
close();
@@ -267,7 +267,7 @@ DankFloatingWindow {
browserTitle: I18n.tr("Select Wallpaper", "wallpaper file browser title")
browserType: "wallpaper"
showHiddenFiles: true
- fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"]
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr", "*.svg"]
onFileSelected: path => {
SessionData.setWallpaper(path);
SessionData.wallpaperCyclingFolderPath = "";
diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml
index 4b8d074e3..4053937eb 100644
--- a/quickshell/Modules/DankDash/DankDashPopout.qml
+++ b/quickshell/Modules/DankDash/DankDashPopout.qml
@@ -36,7 +36,7 @@ DankPopout {
property bool dashVisible: false
property var triggerScreen: null
- property string currentTabId: DashRegistry.fallbackId
+ property string currentTabId: DashRegistry.defaultTabId
property string detailTabId: ""
property bool editMode: false
property string overviewFocusId: CacheData.dashFocusCardId || "calendar"
@@ -46,32 +46,53 @@ DankPopout {
contentLoader.item?.panelResizer.cancel();
if (!shouldBeVisible)
return;
- Qt.callLater(() => {
- if (editMode) {
- contentLoader.item?.focusNavigation();
- return;
- }
- contentLoader.item?.focusInitial();
- });
+ Qt.callLater(() => contentLoader.item?.focusInitial());
}
readonly property string activeTabId: detailTabId !== "" ? detailTabId : currentTabId
readonly property var detailEntry: detailTabId !== "" ? DashRegistry.entry(detailTabId) : null
readonly property var orderedTabIds: DashRegistry.visibleTabIds
readonly property int currentTabIndex: orderedTabIds.indexOf(currentTabId)
- readonly property bool showTabs: orderedTabIds.length > 0 && currentTabIndex >= 0
+ readonly property bool showTabs: orderedTabIds.length > 1 && currentTabIndex >= 0 && detailTabId === ""
+ readonly property bool showBack: detailTabId !== "" && currentTabIndex >= 0
+ readonly property int navigationEdge: {
+ switch (SettingsData.dashTabPosition) {
+ case "left":
+ return SettingsData.Left;
+ case "right":
+ return SettingsData.Right;
+ case "bottom":
+ return SettingsData.Bottom;
+ case "center":
+ return SettingsData.Top;
+ }
+ switch (effectiveBarPosition % 4) {
+ case SettingsData.Left:
+ return SettingsData.Right;
+ case SettingsData.Right:
+ return SettingsData.Left;
+ default:
+ return effectiveBarPosition % 4;
+ }
+ }
+ readonly property bool verticalNavigation: showTabs && (navigationEdge === SettingsData.Left || navigationEdge === SettingsData.Right)
+ readonly property real navigationWidth: verticalNavigation ? Theme.navigationRailWidth + DashMetrics.contentGap : 0
- readonly property int columnCap: DashMetrics.columnCapFor(screen?.width, SettingsData.showWeekNumber)
+ readonly property int columnCap: DashMetrics.columnCapFor(screen ? screen.width - navigationWidth : undefined, SettingsData.showWeekNumber)
readonly property real editGutter: editMode ? PopoutMetrics.editOverflow : 0
- popupWidth: DashMetrics.widthFor(SettingsData.showWeekNumber, screen?.width, DashMetrics.panelColumnsFor(activeTabId))
- minimumSurfaceWidth: DashMetrics.widthFor(SettingsData.showWeekNumber, screen?.width, DashRegistry.widestPanelColumns)
- popupHeight: contentLoader.item?.implicitHeight ?? (DashMetrics.tabDefaultHeight + DashMetrics.tabBarBlockHeight + DashMetrics.contentGap + DashMetrics.contentPadding * 2)
+ popupWidth: panelWidthFor(DashMetrics.panelColumnsFor(activeTabId))
+ minimumSurfaceWidth: panelWidthFor(DashRegistry.widestPanelColumns)
+ popupHeight: contentLoader.item?.implicitHeight ?? (DashMetrics.tabDefaultHeight + Theme.navigationHeight + DashMetrics.contentGap + DashMetrics.contentPadding * 2)
triggerWidth: DashMetrics.triggerWidth
screen: triggerScreen
property bool __focusArmed: false
+ function panelWidthFor(columns) {
+ return DashMetrics.widthFor(SettingsData.showWeekNumber, screen ? screen.width - navigationWidth : undefined, columns) + navigationWidth;
+ }
+
function requestTab(tab) {
const id = DashRegistry.resolveId(tab);
if (DashRegistry.isSelectable(id)) {
@@ -84,10 +105,14 @@ DankPopout {
return;
}
detailTabId = "";
- currentTabId = DashRegistry.fallbackId;
+ currentTabId = DashRegistry.defaultTabId;
}
function closeDetail() {
+ if (!showBack) {
+ dashVisible = false;
+ return;
+ }
detailTabId = "";
}
@@ -102,23 +127,23 @@ DankPopout {
}
function focusContent(backwards) {
- contentLoader.item?.focusInitial();
+ contentLoader.item?.focusNavigation(backwards);
}
onActiveTabIdChanged: {
editMode = false;
contentLoader.item?.dismissOptions();
contentLoader.item?.resetScroll();
- if (shouldBeVisible)
+ if (shouldBeVisible && !contentLoader.item?.navigationFocused)
contentLoader.item?.focusInitial();
}
- readonly property var registryVisibleTabIds: DashRegistry.visibleTabIds
- readonly property var registryTabIds: DashRegistry.tabIds
+ readonly property string registryVisibleTabIds: DashRegistry.visibleTabIds.join("\n")
+ readonly property string registryTabIds: DashRegistry.tabIds.join("\n")
onRegistryVisibleTabIdsChanged: {
if (!DashRegistry.isSelectable(currentTabId))
- currentTabId = DashRegistry.fallbackId;
+ currentTabId = DashRegistry.defaultTabId;
}
onRegistryTabIdsChanged: {
@@ -228,8 +253,10 @@ DankPopout {
LayoutMirroring.childrenInherit: true
implicitWidth: root.popupWidth
- implicitHeight: headerRow.height + DashMetrics.contentGap + pages.implicitHeight + DashMetrics.contentPadding * 2
+ implicitHeight: horizontalChromeHeight + pages.implicitHeight + DashMetrics.contentPadding * 2
+ readonly property real horizontalChromeHeight: root.verticalNavigation ? 0 : (root.showTabs ? tabBar.implicitHeight : Theme.minimumTouchTargetSize) + DashMetrics.contentGap
readonly property bool ready: pages.ready
+ readonly property bool navigationFocused: tabBar.activeFocus
focus: true
readonly property bool panelResizing: panelResizer.resizing
@@ -243,7 +270,6 @@ DankPopout {
cardResizeColumns = panelColumns;
cardResizeRows = panelRows;
}
- readonly property bool hasWidgets: headerMenu.hasWidgets
readonly property int panelColumns: DashMetrics.panelColumnsFor(root.activeTabId)
readonly property int contentRows: DashMetrics.rowsForHeight(pages.currentHostImplicitHeight)
readonly property int panelRows: Math.max(DashMetrics.panelFloorRowsFor(root.activeTabId), contentRows)
@@ -251,7 +277,7 @@ DankPopout {
readonly property DankPanelResizer panelResizer: DankPanelResizer {
popout: root
stepWidth: DashMetrics.preferredColumnWidth + DashMetrics.gridGap
- widthFor: columns => DashMetrics.widthFor(SettingsData.showWeekNumber, root.screen?.width, columns)
+ widthFor: columns => root.panelWidthFor(columns)
currentStep: () => mainContainer.panelColumns
currentRows: () => mainContainer.panelRows
minStep: Math.max(DashMetrics.minimumGridColumns, pages.currentItem?.usedColumns ?? 0)
@@ -277,7 +303,7 @@ DankPopout {
}
function headerFocusTargets() {
- const targets = root.editMode ? editControls.focusTargets : [root.detailTabId !== "" ? backButton : tabBar, menuButton];
+ const targets = root.editMode ? pageActions.focusTargets : [backButton, tabBar, pageTitle.focusTarget];
return targets.filter(item => item.visible && item.enabled);
}
@@ -288,12 +314,18 @@ DankPopout {
}
function focusInitial() {
- for (const target of headerFocusTargets())
+ if (root.activeTabId === "") {
+ emptySettings.forceActiveFocus(Qt.OtherFocusReason);
+ return;
+ }
+ for (const target of [backButton, tabBar, pageTitle.focusTarget])
target.focus = false;
+ pageActions.clearFocus();
if (pages.currentHost)
pages.currentHost.focus = false;
mainContainer.forceActiveFocus(Qt.OtherFocusReason);
- pages.currentItem?.restoreFocus?.();
+ if (!root.editMode)
+ pages.currentItem?.restoreFocus?.();
}
function contentEntryFor(key) {
@@ -356,7 +388,7 @@ DankPopout {
function dismissOptions() {
tabOptions.dismiss();
- headerMenu.close();
+ pageActions.closeMenu();
}
Component.onCompleted: {
@@ -373,7 +405,7 @@ DankPopout {
}
Keys.onPressed: function (event) {
- if (headerMenu.open)
+ if (pageActions.menuOpen)
return;
if (tabOptions.shown) {
if (event.key !== Qt.Key_Escape)
@@ -397,6 +429,11 @@ DankPopout {
}
if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
+ if (root.editMode) {
+ mainContainer.focusNavigation(event.key === Qt.Key_Backtab || !!(event.modifiers & Qt.ShiftModifier));
+ event.accepted = true;
+ return;
+ }
if (current?.blocksTabNavigation)
return;
root.cycleTab(event.key === Qt.Key_Backtab || (event.modifiers & Qt.ShiftModifier) ? -1 : 1);
@@ -421,7 +458,7 @@ DankPopout {
Shortcut {
sequence: "Tab"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: {
root.cycleTab(1);
@@ -431,7 +468,7 @@ DankPopout {
Shortcut {
sequence: "Shift+Tab"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: {
root.cycleTab(-1);
@@ -441,7 +478,7 @@ DankPopout {
Shortcut {
sequence: "Ctrl+Tab"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen
context: Qt.WindowShortcut
onActivated: {
root.cycleTab(1);
@@ -451,7 +488,7 @@ DankPopout {
Shortcut {
sequence: "Ctrl+Shift+Tab"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen
context: Qt.WindowShortcut
onActivated: {
root.cycleTab(-1);
@@ -459,58 +496,65 @@ DankPopout {
}
}
+ Shortcut {
+ sequences: ["F2", "Ctrl+E"]
+ enabled: root.shouldBeVisible && root.activeTabId !== "" && !root.editMode && !tabOptions.shown && !pageActions.menuOpen && !pages.currentItem?.blocksTabNavigation
+ context: Qt.WindowShortcut
+ onActivated: root.editMode = true
+ }
+
Shortcut {
sequence: "F6"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: mainContainer.cycleRegion(false)
}
Shortcut {
sequence: "Shift+F6"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: mainContainer.cycleRegion(true)
}
Shortcut {
sequence: "Alt+Tab"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: pages.currentItem?.cycleCardFocus?.(false)
}
Shortcut {
sequence: "Alt+Shift+Tab"
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: pages.currentItem?.cycleCardFocus?.(true)
}
Shortcut {
sequences: ["Alt+Left", "Alt+H"]
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: pages.currentItem?.moveCardFocus?.("left")
}
Shortcut {
sequences: ["Alt+Right", "Alt+L"]
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: pages.currentItem?.moveCardFocus?.("right")
}
Shortcut {
sequences: ["Alt+Up", "Alt+K"]
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: pages.currentItem?.moveCardFocus?.("up")
}
Shortcut {
sequences: ["Alt+Down", "Alt+J"]
- enabled: root.shouldBeVisible && !tabOptions.shown && !headerMenu.open && !root.editMode && !pages.currentItem?.blocksTabNavigation
+ enabled: root.shouldBeVisible && !tabOptions.shown && !pageActions.menuOpen && !root.editMode && !pages.currentItem?.blocksTabNavigation
context: Qt.WindowShortcut
onActivated: pages.currentItem?.moveCardFocus?.("down")
}
@@ -543,21 +587,6 @@ DankPopout {
onResizeCanceled: mainContainer.panelResizer.cancel()
}
- DashPageMenu {
- id: headerMenu
- entryId: root.activeTabId
- tabItem: pages.currentItem
- editMode: root.editMode
- panelResizable: true
- onEditRequested: root.editMode = true
- onOptionsRequested: tabOptions.presentFor(root.activeTabId)
- onSettingsRequested: {
- root.dashVisible = false;
- root.instantClose();
- PopoutService.openSettingsWithTab("dank_dash");
- }
- }
-
Item {
id: contentClip
@@ -574,138 +603,100 @@ DankPopout {
anchors.leftMargin: DashMetrics.contentPadding
anchors.bottomMargin: DashMetrics.contentPadding
width: root.popupWidth - DashMetrics.contentPadding * 2
+ LayoutMirroring.enabled: false
+ LayoutMirroring.childrenInherit: true
Item {
id: headerRow
+ enabled: !tabOptions.shown && !pageActions.menuOpen
- readonly property real stripCenter: (tabBar.y + tabBar.height - Theme.dividerWidth - DashMetrics.contentPadding) / 2
+ x: root.verticalNavigation && root.navigationEdge === SettingsData.Right ? parent.width - width : 0
+ y: !root.verticalNavigation && root.navigationEdge === SettingsData.Bottom ? parent.height - height : 0
+ width: root.verticalNavigation ? Theme.navigationRailWidth : parent.width
+ height: root.verticalNavigation ? parent.height : mainContainer.horizontalChromeHeight - DashMetrics.contentGap
- width: parent.width
- height: root.showTabs ? Math.max(DashMetrics.tabBarBlockHeight, tabBar.y + tabBar.height) : 0
- visible: root.showTabs
-
- Rectangle {
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.leftMargin: -DashMetrics.contentPadding
- anchors.rightMargin: -DashMetrics.contentPadding
- y: tabBar.y + tabBar.height - height
- height: Theme.dividerWidth
- color: Theme.outlineVariant
- visible: tabBar.visible
- }
-
- DankTabBar {
+ DankNavigationBar {
id: tabBar
- anchors.left: parent.left
- anchors.right: menuButton.left
- anchors.rightMargin: Theme.spacingS
- y: -DashMetrics.tabBarLift
- visible: !root.editMode && root.detailTabId === ""
- tabHeight: DashMetrics.tabHeight
- showDivider: false
+ width: parent.width
+ height: parent.height
+ editable: true
+ evenlySpaced: SettingsData.dashTabsEvenlySpaced
+ onEditRequested: root.editMode = true
+ visible: root.showTabs && !root.editMode
+ orientation: root.verticalNavigation ? Qt.Vertical : Qt.Horizontal
currentIndex: root.currentTabIndex
- spacing: Theme.spacingS
- equalWidthTabs: true
- enableArrowNavigation: false
- cycleOnTab: true
- nextFocusTarget: pages.focusTarget
+ nextFocusTarget: pageActions.focusTargets[0] ?? pages.focusTarget
previousFocusTarget: pages.currentItem?.previousFocusTarget ?? pages.focusTarget
model: DashRegistry.tabBarModel
-
- onTabClicked: function (index) {
+ onActivated: index => {
const id = root.orderedTabIds[index];
if (id !== undefined)
root.currentTabId = id;
}
}
- DashEditControls {
- id: editControls
- anchors.left: parent.left
- anchors.right: parent.right
- y: Math.round(headerRow.stripCenter - height / 2)
- visible: root.editMode
- canAdd: (pages.currentItem?.addable?.length ?? 0) > 0
- hasWidgets: mainContainer.hasWidgets
- title: mainContainer.hasWidgets ? I18n.tr("Widgets") : (DashRegistry.entry(root.activeTabId)?.text ?? "")
- onAddRequested: anchor => pages.currentItem?.openAddMenu(anchor)
- onMenuRequested: anchor => headerMenu.openAt(anchor)
- onFinished: root.editMode = false
- }
-
- Item {
- id: detailHeader
-
- anchors.left: parent.left
- anchors.right: parent.right
- y: Math.round(headerRow.stripCenter - height / 2)
- height: DashMetrics.headerActionSize
- visible: root.detailTabId !== "" && !root.editMode
-
- DankActionButton {
- id: backButton
- anchors.left: parent.left
- anchors.verticalCenter: parent.verticalCenter
- buttonSize: DashMetrics.headerActionSize
- iconName: I18n.isRtl ? "arrow_forward" : "arrow_back"
- Accessible.name: I18n.tr("Back")
- KeyNavigation.tab: pages.focusTarget
- KeyNavigation.backtab: pages.focusTarget
- onClicked: root.closeDetail()
- }
-
- StyledText {
- anchors.left: backButton.right
- anchors.right: parent.right
- anchors.leftMargin: Theme.spacingS
- anchors.rightMargin: DashMetrics.headerActionSize + Theme.spacingS
- anchors.verticalCenter: parent.verticalCenter
- text: root.detailEntry?.text ?? ""
- font.pixelSize: Theme.fontSizeLarge
- font.weight: Theme.fontWeightMedium
- color: Theme.surfaceText
- elide: Text.ElideRight
- }
- }
-
DankActionButton {
- id: menuButton
+ id: backButton
+ x: I18n.isRtl ? parent.width - width : 0
+ anchors.verticalCenter: parent.verticalCenter
+ buttonSize: Theme.buttonHeightS
+ iconName: I18n.isRtl ? "arrow_forward" : "arrow_back"
+ tooltipText: I18n.tr("Back")
+ visible: root.showBack && !root.editMode
+ onClicked: root.closeDetail()
+ }
- anchors.right: parent.right
- y: Math.round(headerRow.stripCenter - height / 2)
- buttonSize: DashMetrics.headerActionSize
- iconName: "more_vert"
- iconColor: headerMenu.open ? Theme.primary : Theme.onSurfaceVariant
- backgroundColor: headerMenu.open ? Theme.withAlpha(Theme.primary, Theme.stateLayerFocus) : "transparent"
- Accessible.name: I18n.tr("Options")
- visible: !root.editMode
- KeyNavigation.tab: pages.focusTarget
- KeyNavigation.backtab: root.detailTabId !== "" ? backButton : tabBar
- onClicked: headerMenu.openAt(menuButton)
+ DashPageTitle {
+ id: pageTitle
+ readonly property real backWidth: root.showBack ? backButton.width + Theme.spacingS : 0
+ x: I18n.isRtl ? 0 : backWidth
+ anchors.verticalCenter: parent.verticalCenter
+ width: Math.max(0, parent.width - backWidth)
+ height: implicitHeight
+ entryId: root.activeTabId
+ visible: !root.showTabs && !root.editMode
+ onEditRequested: root.editMode = true
+ }
+
+ DashPageActions {
+ id: pageActions
+ x: root.verticalNavigation ? 0 : (parent.width - width) / 2
+ y: (parent.height - height) / 2
+ width: Math.min(parent.width, implicitWidth)
+ height: Math.min(parent.height, implicitHeight)
+ visible: root.editMode
+ vertical: root.verticalNavigation
+ overlayParent: mainContainer
+ entryId: root.activeTabId
+ tabItem: pages.currentItem
+ editMode: root.editMode
+ panelResizable: true
+ onOptionsRequested: tabOptions.presentFor(root.activeTabId)
+ onFinished: root.editMode = false
}
}
DankFlickable {
id: pages
+ enabled: !tabOptions.shown && !pageActions.menuOpen
property var currentHost: null
property real settledHeight: DashMetrics.tabDefaultHeight
- readonly property var currentItem: currentHost?.item ?? null
- readonly property Item focusTarget: currentHost?.focusTarget ?? null
+ readonly property var currentItem: root.activeTabId !== "" ? currentHost?.item ?? null : null
+ readonly property Item focusTarget: root.activeTabId === "" ? emptySettings : currentHost?.focusTarget ?? null
readonly property bool currentSettled: !!currentHost && (!!currentHost.item || currentHost.failed)
readonly property real currentHostImplicitHeight: currentHost?.implicitHeight ?? 0
- readonly property real targetHeight: currentSettled && currentHost.isCurrent ? DashMetrics.panelHeightFor(root.activeTabId, currentHostImplicitHeight) : -1
- readonly property real bodyHeight: Math.max(settledHeight, currentHostImplicitHeight)
+ readonly property real targetHeight: root.activeTabId === "" ? emptyContent.implicitHeight : currentSettled && currentHost.isCurrent ? DashMetrics.panelHeightFor(root.activeTabId, currentHostImplicitHeight) : -1
+ readonly property real bodyHeight: root.activeTabId === "" ? emptyContent.implicitHeight : Math.max(settledHeight, currentHostImplicitHeight)
readonly property bool ready: targetHeight >= 0 && settledHeight === targetHeight
- readonly property real availableHeight: root.screen ? root.screen.height - headerRow.height - DashMetrics.contentGap - DashMetrics.contentPadding * 2 - Theme.barHeight - Theme.spacingL * 2 : contentHeight
+ readonly property real availableHeight: root.screen ? root.screen.height - mainContainer.horizontalChromeHeight - DashMetrics.contentPadding * 2 - Theme.barHeight - Theme.spacingL * 2 : contentHeight
readonly property int rowBudget: DashMetrics.rowCapFor(availableHeight)
- x: -root.editGutter
- y: (headerRow.visible ? headerRow.height + DashMetrics.contentGap : 0) - root.editGutter
- width: parent.width + root.editGutter * 2
- height: Math.max(0, mainContainer.height - headerRow.height - DashMetrics.contentGap - DashMetrics.contentPadding * 2) + root.editGutter * 2
+ x: (root.verticalNavigation && root.navigationEdge === SettingsData.Left ? root.navigationWidth : 0) - root.editGutter
+ y: (!root.verticalNavigation && root.navigationEdge !== SettingsData.Bottom ? mainContainer.horizontalChromeHeight : 0) - root.editGutter
+ width: parent.width - root.navigationWidth + root.editGutter * 2
+ height: Math.max(0, mainContainer.height - mainContainer.horizontalChromeHeight - DashMetrics.contentPadding * 2) + root.editGutter * 2
implicitHeight: Math.min(settledHeight, Math.max(DashMetrics.gridRowUnit, availableHeight))
contentWidth: width
contentHeight: bodyHeight + root.editGutter * 2
@@ -719,6 +710,30 @@ DankPopout {
onTargetHeightChanged: Qt.callLater(updateContentHeight)
+ Column {
+ id: emptyContent
+ width: parent.width
+ spacing: Theme.spacingM
+ visible: root.activeTabId === ""
+
+ CcEmptyState {
+ iconName: "dashboard"
+ title: I18n.tr("No tabs enabled", "Dashboard empty state when all navigation tabs are hidden")
+ }
+
+ DankButton {
+ id: emptySettings
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: I18n.tr("Settings")
+ iconName: "settings"
+ onClicked: {
+ root.dashVisible = false;
+ root.instantClose();
+ PopoutService.openSettingsWithTab("dank_dash");
+ }
+ }
+ }
+
Repeater {
model: ScriptModel {
values: DashRegistry.tabIds
@@ -740,8 +755,12 @@ DankPopout {
isCurrent: root.activeTabId === modelData
onIsCurrentChanged: {
- if (isCurrent)
+ if (isCurrent) {
pages.currentHost = host;
+ return;
+ }
+ if (pages.currentHost === host)
+ pages.currentHost = null;
}
Component.onCompleted: {
diff --git a/quickshell/Modules/DankDash/DashMetrics.qml b/quickshell/Modules/DankDash/DashMetrics.qml
index 631b596d6..f671e962c 100644
--- a/quickshell/Modules/DankDash/DashMetrics.qml
+++ b/quickshell/Modules/DankDash/DashMetrics.qml
@@ -18,10 +18,6 @@ Singleton {
readonly property real popoutWidthWide: popoutWidth + weekColumnWidth + Theme.spacingS
readonly property real contentPadding: Theme.spacingM
readonly property real contentGap: Theme.spacingM
- readonly property real tabBarLift: contentPadding - Theme.tabIndicatorHeight
- readonly property real tabHeight: Theme.buttonHeightM + tabBarLift
- readonly property real tabBarBlockHeight: tabHeight - tabBarLift
- readonly property real headerActionSize: Theme.iconButtonSize
readonly property real spinnerSize: Theme.iconButtonSize
readonly property real triggerWidth: CcMetrics.triggerWidth
readonly property int transitionDuration: CcMetrics.transitionDuration
diff --git a/quickshell/Modules/DankDash/DashPageActions.qml b/quickshell/Modules/DankDash/DashPageActions.qml
new file mode 100644
index 000000000..3cec46204
--- /dev/null
+++ b/quickshell/Modules/DankDash/DashPageActions.qml
@@ -0,0 +1,81 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import qs.Common
+import qs.Widgets
+import qs.Modules.ControlCenter.Widgets
+import qs.Modules.DankDash.Overview
+import "utils/cards.js" as CardUtils
+
+Item {
+ id: root
+
+ required property string entryId
+ required property Item overlayParent
+ property var tabItem: null
+ property bool vertical: false
+ property bool editMode: false
+ property bool panelResizable: false
+ readonly property bool hasWidgets: entryId === DashRegistry.overviewId || typeof tabItem?.clearWidgets === "function"
+ readonly property bool menuOpen: customMenu.open
+ readonly property bool hasCustomActions: customMenu.visibleItems.length > 0
+ readonly property var focusTargets: editControls.focusTargets
+
+ signal optionsRequested
+ signal finished
+
+ implicitWidth: vertical ? Theme.navigationRailWidth : editControls.implicitWidth
+ implicitHeight: editControls.implicitHeight
+
+ function closeMenu() {
+ editControls.cancelConfirmation();
+ customMenu.close();
+ }
+
+ function clearFocus() {
+ editControls.clearFocus();
+ }
+
+ function reset() {
+ if (panelResizable)
+ DashRegistry.resetPanelSize(entryId);
+ if (entryId === DashRegistry.overviewId) {
+ CardUtils.resetToDefault();
+ return;
+ }
+ tabItem?.resetWidgets?.();
+ }
+
+ function clear() {
+ if (entryId === DashRegistry.overviewId) {
+ CardUtils.clearAll();
+ return;
+ }
+ tabItem?.clearWidgets?.();
+ }
+
+ CcMenu {
+ id: customMenu
+ parent: root.overlayParent
+ items: root.tabItem?.menuActions ?? []
+ }
+
+ DashEditControls {
+ id: editControls
+ anchors.centerIn: parent
+ width: root.width
+ height: root.height
+ visible: root.editMode
+ vertical: root.vertical
+ canAdd: (root.tabItem?.addable?.length ?? 0) > 0
+ hasWidgets: root.hasWidgets
+ hasOptions: DashRegistry.hasOptions(root.entryId)
+ hasCustomActions: root.hasCustomActions
+ onActionsRequested: anchor => customMenu.openAt(anchor)
+ onAddRequested: anchor => root.tabItem?.openAddMenu(anchor)
+ onOptionsRequested: root.optionsRequested()
+ onResetRequested: root.reset()
+ onClearRequested: root.clear()
+ onFinished: root.finished()
+ }
+}
diff --git a/quickshell/Modules/DankDash/DashPageMenu.qml b/quickshell/Modules/DankDash/DashPageMenu.qml
deleted file mode 100644
index 567cacd5c..000000000
--- a/quickshell/Modules/DankDash/DashPageMenu.qml
+++ /dev/null
@@ -1,69 +0,0 @@
-import QtQuick
-import qs.Common
-import qs.Modules.ControlCenter.Widgets
-import "utils/cards.js" as CardUtils
-
-CcMenu {
- id: root
-
- required property string entryId
- property var tabItem: null
- property bool editMode: false
- property bool panelResizable: false
- readonly property bool hasWidgets: root.entryId === DashRegistry.fallbackId || typeof root.tabItem?.clearWidgets === "function"
-
- signal editRequested
- signal optionsRequested
- signal settingsRequested
-
- items: root.editMode ? [
- {
- "label": I18n.tr("Options", "noun, menu entry or button that opens item options"),
- "iconName": "tune",
- "visible": DashRegistry.hasOptions(root.entryId),
- "action": () => root.optionsRequested()
- },
- {
- "label": I18n.tr("Reset to default"),
- "iconName": "settings_backup_restore",
- "action": () => {
- if (root.panelResizable)
- DashRegistry.resetPanelSize(root.entryId);
- if (root.entryId === DashRegistry.fallbackId)
- CardUtils.resetToDefault();
- else
- root.tabItem?.resetWidgets?.();
- }
- },
- {
- "label": I18n.tr("Clear All"),
- "iconName": "clear_all",
- "destructive": true,
- "visible": root.hasWidgets,
- "action": () => {
- if (root.entryId === DashRegistry.fallbackId)
- CardUtils.clearAll();
- else
- root.tabItem?.clearWidgets();
- }
- }
- ] : (root.tabItem?.menuActions ?? []).concat([
- {
- "label": I18n.tr("Edit"),
- "iconName": "edit",
- "visible": root.panelResizable || root.hasWidgets,
- "action": () => root.editRequested()
- },
- {
- "label": I18n.tr("Options"),
- "iconName": "tune",
- "visible": DashRegistry.hasOptions(root.entryId),
- "action": () => root.optionsRequested()
- },
- {
- "label": I18n.tr("Settings"),
- "iconName": "settings",
- "action": () => root.settingsRequested()
- }
- ])
-}
diff --git a/quickshell/Modules/DankDash/DashPageTitle.qml b/quickshell/Modules/DankDash/DashPageTitle.qml
new file mode 100644
index 000000000..e972702cb
--- /dev/null
+++ b/quickshell/Modules/DankDash/DashPageTitle.qml
@@ -0,0 +1,56 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+
+Item {
+ id: root
+
+ required property string entryId
+ property bool editable: entryId !== ""
+ readonly property Item focusTarget: titleIcon
+
+ signal editRequested
+
+ implicitHeight: Theme.buttonHeightS
+ LayoutMirroring.enabled: I18n.isRtl
+ LayoutMirroring.childrenInherit: true
+
+ StyledButton {
+ id: titleIcon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: Theme.buttonHeightS
+ height: Theme.buttonHeightS
+ radius: Theme.fullRadius(width, height)
+ color: Theme.secondaryContainer
+ Accessible.name: root.editable ? I18n.tr("Edit") : I18n.tr("Dashboard")
+ enabled: root.editable
+ onClicked: root.editRequested()
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: root.editable && titleIcon.hovered ? "edit" : DashRegistry.entry(root.entryId)?.icon ?? "dashboard"
+ size: Theme.iconSize
+ color: Theme.onSecondaryContainer
+ }
+
+ StateLayer {
+ control: titleIcon
+ disabled: !root.editable
+ stateColor: Theme.onSecondaryContainer
+ }
+ }
+
+ StyledText {
+ anchors.left: titleIcon.right
+ anchors.right: parent.right
+ anchors.leftMargin: Theme.spacingS
+ anchors.verticalCenter: parent.verticalCenter
+ text: DashRegistry.entry(root.entryId)?.text ?? I18n.tr("Dashboard")
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Theme.fontWeightMedium
+ color: Theme.surfaceText
+ horizontalAlignment: Text.AlignLeft
+ elide: Text.ElideRight
+ }
+}
diff --git a/quickshell/Modules/DankDash/DashRegistry.qml b/quickshell/Modules/DankDash/DashRegistry.qml
index a6cdd5a21..3e4619101 100644
--- a/quickshell/Modules/DankDash/DashRegistry.qml
+++ b/quickshell/Modules/DankDash/DashRegistry.qml
@@ -10,7 +10,8 @@ Singleton {
id: root
readonly property string pluginPrefix: "plugin_"
- readonly property string fallbackId: "overview"
+ readonly property string overviewId: "overview"
+ readonly property string defaultTabId: visibleTabIds[0] ?? ""
readonly property string widgetsKey: "widgets"
readonly property var toneChoices: [
@@ -83,7 +84,6 @@ Singleton {
"text": I18n.tr("Overview", "dashboard tab name"),
"icon": "dashboard",
"description": I18n.tr("Clock, calendar, system info and profile"),
- "locked": true,
"tab": {
"component": overviewTab
}
@@ -398,7 +398,7 @@ Singleton {
continue;
seen[tab.id] = true;
result.push(Object.assign({
- "enabled": tab.enabled || def.locked === true
+ "enabled": tab.enabled
}, def));
}
for (const def of known) {
@@ -616,7 +616,7 @@ Singleton {
}
function indexId(index) {
- return visibleTabIds[index] ?? fallbackId;
+ return visibleTabIds[index] ?? defaultTabId;
}
function resolveId(tab) {
@@ -624,13 +624,13 @@ Singleton {
return indexId(tab);
const raw = String(tab ?? "").trim();
if (raw === "")
- return fallbackId;
+ return defaultTabId;
if (/^\d+$/.test(raw))
return indexId(parseInt(raw));
if (hasTab(raw))
return raw;
const lower = raw.toLowerCase();
- return tabIds.find(id => id.toLowerCase() === lower) ?? fallbackId;
+ return tabIds.find(id => id.toLowerCase() === lower) ?? defaultTabId;
}
function indexOf(id) {
diff --git a/quickshell/Modules/DankDash/NotificationsTab.qml b/quickshell/Modules/DankDash/NotificationsTab.qml
index fa37ec9a4..d91e6478b 100644
--- a/quickshell/Modules/DankDash/NotificationsTab.qml
+++ b/quickshell/Modules/DankDash/NotificationsTab.qml
@@ -158,7 +158,7 @@ FocusScope {
id: historyButton
visible: SettingsData.notificationHistoryEnabled
buttonSize: Theme.iconButtonSize
- iconName: root.currentTab === 1 ? "notifications" : "history"
+ iconName: root.currentTab === 1 ? "inbox" : "history"
backgroundColor: Theme.secondaryContainer
iconColor: Theme.onSecondaryContainer
tooltipText: root.currentTab === 1 ? I18n.tr("Current", "notification center tab") : I18n.tr("History", "notification center tab")
diff --git a/quickshell/Modules/DankDash/Overview/DashEditControls.qml b/quickshell/Modules/DankDash/Overview/DashEditControls.qml
index 4dfaa19ed..284fe7b7d 100644
--- a/quickshell/Modules/DankDash/Overview/DashEditControls.qml
+++ b/quickshell/Modules/DankDash/Overview/DashEditControls.qml
@@ -1,80 +1,186 @@
+pragma ComponentBehavior: Bound
+
import QtQuick
import qs.Common
import qs.Widgets
+import "../../../DankCommon/Common/FocusNavigation.js" as FocusNavigation
Item {
id: root
- LayoutMirroring.enabled: I18n.isRtl
- LayoutMirroring.childrenInherit: true
-
+ property bool vertical: false
property bool canAdd: true
property bool hasWidgets: true
- property string title: I18n.tr("Widgets", "noun, title of widget edit controls and settings card")
- readonly property var focusTargets: [addButton, moreButton, finishButton]
+ property bool hasOptions: false
+ property bool hasCustomActions: false
+ property string pendingAction: ""
+ readonly property var actionButtons: [addButton, optionsButton, customButton, resetButton, clearButton, finishButton]
+ readonly property var focusTargets: actionButtons.filter(item => item.visible && item.enabled)
signal addRequested(var anchor)
- signal menuRequested(var anchor)
+ signal actionsRequested(var anchor)
+ signal optionsRequested
+ signal resetRequested
+ signal clearRequested
signal finished
- height: Theme.buttonHeightS
+ implicitWidth: actions.implicitWidth
+ implicitHeight: actions.implicitHeight
+ readonly property Item focusedItem: Window.activeFocusItem
+ onFocusedItemChanged: revealFocusedItem()
+ onWidthChanged: revealFocusedItem()
+ onHeightChanged: revealFocusedItem()
- StyledText {
- anchors.left: parent.left
- anchors.right: actions.left
- anchors.rightMargin: Theme.spacingM
- anchors.verticalCenter: parent.verticalCenter
- text: root.title
- font.pixelSize: Theme.fontSizeLarge
- font.weight: Theme.fontWeightMedium
- color: Theme.surfaceText
- horizontalAlignment: Text.AlignLeft
- elide: Text.ElideRight
+ function revealFocusedItem() {
+ const target = focusedItem;
+ if (!target || !focusTargets.includes(target))
+ return;
+ const point = target.mapToItem(viewport.contentItem, 0, 0);
+ if (vertical) {
+ viewport.contentY = Math.max(0, Math.min(point.y, Math.max(viewport.contentY, point.y + target.height - viewport.height)));
+ return;
+ }
+ viewport.contentX = Math.max(0, Math.min(point.x, Math.max(viewport.contentX, point.x + target.width - viewport.width)));
}
- Row {
- id: actions
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: Theme.spacingS
+ function cancelConfirmation() {
+ pendingAction = "";
+ }
- DankButton {
- id: addButton
- anchors.verticalCenter: parent.verticalCenter
- buttonHeight: Theme.buttonHeightS
- iconName: "add"
- text: I18n.tr("Add widget")
- backgroundColor: Theme.secondaryContainer
- textColor: Theme.onSecondaryContainer
- visible: root.hasWidgets
- enabled: root.canAdd
- KeyNavigation.tab: moreButton
- KeyNavigation.backtab: finishButton
- onClicked: root.addRequested(addButton)
+ function clearFocus() {
+ for (const target of actionButtons)
+ target.focus = false;
+ }
+
+ onVisibleChanged: cancelConfirmation()
+
+ component EditButton: DankButton {
+ property string label: ""
+ text: root.vertical ? "" : label
+ tooltipText: root.vertical ? label : null
+ buttonHeight: Theme.buttonHeightS
+ }
+
+ component ConfirmButton: EditButton {
+ id: confirmation
+ required property string actionId
+ property color armedColor: Theme.primary
+ property color armedTextColor: Theme.onPrimary
+ readonly property bool armed: root.pendingAction === actionId
+ readonly property string confirmLabel: I18n.tr("Confirm")
+ signal confirmed
+ text: root.vertical ? "" : armed ? confirmLabel : label
+ reserveText: root.vertical ? "" : armed ? label : confirmLabel
+ tooltipText: armed ? confirmLabel : root.vertical ? label : null
+ backgroundColor: armed ? armedColor : Theme.buttonBg
+ textColor: armed ? armedTextColor : Theme.buttonText
+ onClicked: {
+ if (!armed) {
+ root.pendingAction = actionId;
+ return;
+ }
+ root.pendingAction = "";
+ confirmed();
+ }
+ }
+
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape && pendingAction !== "") {
+ cancelConfirmation();
+ event.accepted = true;
+ return;
+ }
+ if (event.key !== Qt.Key_Tab && event.key !== Qt.Key_Backtab)
+ return;
+ const backwards = event.key === Qt.Key_Backtab || !!(event.modifiers & Qt.ShiftModifier);
+ if (!FocusNavigation.moveFocus(focusTargets, backwards))
+ focusTargets[backwards ? focusTargets.length - 1 : 0]?.forceActiveFocus(backwards ? Qt.BacktabFocusReason : Qt.TabFocusReason);
+ event.accepted = true;
+ }
+
+ DankFlickable {
+ id: viewport
+ anchors.fill: parent
+ contentWidth: root.vertical ? width : Math.max(width, actions.implicitWidth)
+ contentHeight: root.vertical ? actions.implicitHeight : height
+ flickableDirection: root.vertical ? Flickable.VerticalFlick : Flickable.HorizontalFlick
+ wheelEnabled: root.vertical
+ clip: true
+
+ WheelHandler {
+ enabled: !root.vertical && viewport.contentWidth > viewport.width
+ onWheel: event => {
+ const delta = event.pixelDelta.x || event.pixelDelta.y || event.angleDelta.x || event.angleDelta.y;
+ viewport.contentX = Math.max(0, Math.min(viewport.contentWidth - viewport.width, viewport.contentX - delta));
+ event.accepted = true;
+ }
}
- DankActionButton {
- id: moreButton
- anchors.verticalCenter: parent.verticalCenter
- buttonSize: Theme.buttonHeightS
- iconName: "more_vert"
- Accessible.name: I18n.tr("Options")
- KeyNavigation.tab: finishButton
- KeyNavigation.backtab: addButton
- onClicked: root.menuRequested(moreButton)
- }
+ Grid {
+ id: actions
+ x: Math.max(0, (viewport.width - implicitWidth) / 2)
+ y: root.vertical ? 0 : Math.max(0, (viewport.height - implicitHeight) / 2)
+ columns: root.vertical ? 1 : root.actionButtons.length
+ horizontalItemAlignment: Grid.AlignHCenter
+ verticalItemAlignment: Grid.AlignVCenter
+ LayoutMirroring.enabled: false
+ spacing: Theme.spacingS
+ layoutDirection: I18n.isRtl && !root.vertical ? Qt.RightToLeft : Qt.LeftToRight
- DankButton {
- id: finishButton
- anchors.verticalCenter: parent.verticalCenter
- buttonHeight: Theme.buttonHeightS
- iconName: "check"
- text: I18n.tr("Finish")
- backgroundColor: Theme.primary
- textColor: Theme.onPrimary
- KeyNavigation.tab: addButton
- KeyNavigation.backtab: moreButton
- onClicked: root.finished()
+ EditButton {
+ id: addButton
+ label: I18n.tr("Add widget")
+ iconName: "add"
+ backgroundColor: Theme.secondaryContainer
+ textColor: Theme.onSecondaryContainer
+ visible: root.hasWidgets
+ enabled: root.canAdd
+ onClicked: root.addRequested(addButton)
+ }
+
+ EditButton {
+ id: optionsButton
+ label: I18n.tr("Options")
+ iconName: "tune"
+ visible: root.hasOptions
+ onClicked: root.optionsRequested()
+ }
+
+ EditButton {
+ id: customButton
+ label: I18n.tr("Actions", "Dashboard page actions supplied by a plugin or built-in page")
+ iconName: "more_horiz"
+ visible: root.hasCustomActions
+ onClicked: root.actionsRequested(customButton)
+ }
+
+ ConfirmButton {
+ id: resetButton
+ actionId: "reset"
+ label: I18n.tr("Reset to default")
+ iconName: "settings_backup_restore"
+ onConfirmed: root.resetRequested()
+ }
+
+ ConfirmButton {
+ id: clearButton
+ actionId: "clear"
+ label: I18n.tr("Clear All")
+ iconName: "clear_all"
+ armedColor: Theme.error
+ armedTextColor: Theme.onError
+ visible: root.hasWidgets
+ onConfirmed: root.clearRequested()
+ }
+
+ EditButton {
+ id: finishButton
+ label: I18n.tr("Finish")
+ iconName: "check"
+ backgroundColor: Theme.primary
+ textColor: Theme.onPrimary
+ onClicked: root.finished()
+ }
}
}
}
diff --git a/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml b/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml
index 4a628dcf7..74cfc2328 100644
--- a/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml
+++ b/quickshell/Modules/DankDash/Overview/DashOptionsSheet.qml
@@ -53,7 +53,7 @@ CcSheetDialog {
DankButton {
anchors.right: parent.right
- text: I18n.tr("Reset")
+ text: I18n.tr("Reset to default")
iconName: "restart_alt"
buttonHeight: Theme.buttonHeightXS
backgroundColor: "transparent"
diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
index 07d4db3d9..7b01c98c8 100644
--- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
+++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml
@@ -32,7 +32,7 @@ Card {
entryId: "media"
clickable: true
- pad: compact ? Theme.spacingS : Theme.spacingM
+ pad: Theme.spacingM
Keys.onShortcutOverride: event => {
if (!root.interactive || event.key !== Qt.Key_F6)
diff --git a/quickshell/Modules/DankDash/WallpaperTab.qml b/quickshell/Modules/DankDash/WallpaperTab.qml
index 8449d7003..bceb771ba 100644
--- a/quickshell/Modules/DankDash/WallpaperTab.qml
+++ b/quickshell/Modules/DankDash/WallpaperTab.qml
@@ -465,7 +465,7 @@ Item {
showDotAndDotDot: false
showHidden: false
caseSensitive: false
- nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"]
+ nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr", "*.svg"]
showFiles: true
showDirs: false
sortField: {
@@ -515,7 +515,7 @@ Item {
browserTitle: I18n.tr("Select Wallpaper Directory", "wallpaper directory file browser title")
browserType: "wallpaper"
showHiddenFiles: false
- fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"]
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr", "*.svg"]
parentPopout: root.parentPopout
onFileSelected: path => {
diff --git a/quickshell/Modules/DankIsland/Activities/DashTabFace.qml b/quickshell/Modules/DankIsland/Activities/DashTabFace.qml
index 237550f6b..4f940aa03 100644
--- a/quickshell/Modules/DankIsland/Activities/DashTabFace.qml
+++ b/quickshell/Modules/DankIsland/Activities/DashTabFace.qml
@@ -68,16 +68,17 @@ FocusScope {
clip: true
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
- KeyNavigation.tab: root.tab?.focusTarget ?? null
+ KeyNavigation.tab: !root.editMode && !tabOptions.shown && !pageActions.menuOpen ? root.tab?.focusTarget ?? null : null
function focusFace() {
+ pageActions.clearFocus();
(root.tab?.focusTarget ?? root).forceActiveFocus();
return true;
}
function focusHeader(backwards) {
- const targets = root.editMode ? editControls.focusTargets : [menuButton];
- targets[backwards ? targets.length - 1 : 0].forceActiveFocus();
+ const targets = root.editMode ? pageActions.focusTargets : [pageTitle.focusTarget];
+ targets[backwards ? targets.length - 1 : 0]?.forceActiveFocus(backwards ? Qt.BacktabFocusReason : Qt.TabFocusReason);
}
function reportHeight() {
@@ -90,7 +91,7 @@ FocusScope {
if (live)
return;
editMode = false;
- headerMenu.close();
+ pageActions.closeMenu();
tabOptions.dismiss();
}
onEditModeChanged: {
@@ -100,16 +101,32 @@ FocusScope {
return;
}
Qt.callLater(() => {
- if (root.live && root.editMode)
- root.focusHeader(false);
+ if (!root.live || !root.editMode)
+ return;
+ pageActions.clearFocus();
+ pageTitle.focusTarget.focus = false;
+ tabLoader.focus = false;
+ root.forceActiveFocus(Qt.OtherFocusReason);
});
}
Keys.onPressed: event => {
+ if (tabOptions.shown || pageActions.menuOpen)
+ return;
+ if (root.editMode && (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab)) {
+ root.focusHeader(event.key === Qt.Key_Backtab || !!(event.modifiers & Qt.ShiftModifier));
+ event.accepted = true;
+ return;
+ }
if (root.tab?.handleKeyEvent?.(event) === true) {
event.accepted = true;
return;
}
+ if (!root.editMode && (event.key === Qt.Key_F2 || (event.key === Qt.Key_E && (event.modifiers & Qt.ControlModifier)))) {
+ root.editMode = true;
+ event.accepted = true;
+ return;
+ }
if (event.key !== Qt.Key_Escape || !root.editMode)
return;
root.editMode = false;
@@ -119,6 +136,7 @@ FocusScope {
Item {
id: header
+ enabled: !tabOptions.shown && !pageActions.menuOpen
anchors {
top: parent.top
left: parent.left
@@ -127,30 +145,29 @@ FocusScope {
leftMargin: DashMetrics.contentPadding + root.editGutter
rightMargin: DashMetrics.contentPadding + root.editGutter
}
- height: root.editMode ? editControls.height : Theme.buttonHeightXS
+ height: Theme.minimumTouchTargetSize
- DankActionButton {
- id: menuButton
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- buttonSize: Theme.buttonHeightXS
- iconName: "more_vert"
- Accessible.name: I18n.tr("Options")
+ DashPageTitle {
+ id: pageTitle
+ anchors.fill: parent
+ entryId: root.entryId
visible: !root.editMode
- KeyNavigation.tab: root.tab?.focusTarget ?? null
- KeyNavigation.backtab: root.tab?.previousFocusTarget ?? root.tab?.focusTarget ?? null
- onClicked: headerMenu.openAt(menuButton)
+ onEditRequested: root.editMode = true
}
- DashEditControls {
- id: editControls
- anchors.left: parent.left
+ DashPageActions {
+ id: pageActions
+ visible: root.editMode
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
- visible: root.editMode
- canAdd: (root.tab?.addable?.length ?? 0) > 0
- onAddRequested: anchor => root.tab?.openAddMenu(anchor)
- onMenuRequested: anchor => headerMenu.openAt(anchor)
+ width: Math.min(parent.width, implicitWidth)
+ height: parent.height
+ overlayParent: root
+ entryId: root.entryId
+ tabItem: root.tab
+ editMode: root.editMode
+ panelResizable: true
+ onOptionsRequested: tabOptions.presentFor(root.entryId)
onFinished: {
root.editMode = false;
root.focusFace();
@@ -160,6 +177,7 @@ FocusScope {
DankFlickable {
id: pages
+ enabled: !tabOptions.shown && !pageActions.menuOpen
anchors {
top: header.bottom
topMargin: Theme.spacingXS
@@ -229,19 +247,6 @@ FocusScope {
onDismissed: root.focusFace()
}
- DashPageMenu {
- id: headerMenu
- entryId: root.entryId
- tabItem: root.tab
- editMode: root.editMode
- onEditRequested: root.editMode = true
- onOptionsRequested: tabOptions.presentFor(root.entryId)
- onSettingsRequested: {
- root.controller.requestCollapse();
- PopoutService.openSettingsWithTab("dank_dash");
- }
- }
-
Component.onDestruction: root.controller.setEditing(root.activityId, false)
Component.onCompleted: {
diff --git a/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml b/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml
index ba8e0873c..2c3e05b5a 100644
--- a/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml
+++ b/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml
@@ -8,7 +8,7 @@ DashTabFace {
id: root
activityId: "home"
- entryId: DashRegistry.fallbackId
+ entryId: DashRegistry.overviewId
tabComponent: Component {
OverviewTab {
live: root.live
diff --git a/quickshell/Modules/DankIsland/IslandController.qml b/quickshell/Modules/DankIsland/IslandController.qml
index afb06e45d..17d3cb947 100644
--- a/quickshell/Modules/DankIsland/IslandController.qml
+++ b/quickshell/Modules/DankIsland/IslandController.qml
@@ -113,7 +113,7 @@ QtObject {
function dashEntryIdFor(activityId) {
switch (activityId) {
case "home":
- return DashRegistry.fallbackId;
+ return DashRegistry.overviewId;
case "notificationcenter":
return "notifications";
}
diff --git a/quickshell/Modules/Settings/DankDashTab.qml b/quickshell/Modules/Settings/DankDashTab.qml
index 96a80653b..424187226 100644
--- a/quickshell/Modules/Settings/DankDashTab.qml
+++ b/quickshell/Modules/Settings/DankDashTab.qml
@@ -52,8 +52,56 @@ FocusScope {
headerActions: DankActionButton {
iconName: "refresh"
iconSize: Theme.iconSizeSmall
- tooltipText: I18n.tr("Reset")
- onClicked: SettingsData.resetDashTabs()
+ tooltipText: I18n.tr("Reset to default")
+ onClicked: {
+ SettingsData.resetDashTabs();
+ SettingsData.resetToDefault(["dashTabPosition", "dashTabsEvenlySpaced"]);
+ }
+ }
+
+ SettingsDropdownRow {
+ text: I18n.tr("Position")
+ readonly property var positions: [
+ {
+ value: "auto",
+ text: I18n.tr("Auto")
+ },
+ {
+ value: "left",
+ text: I18n.tr("Left")
+ },
+ {
+ value: "right",
+ text: I18n.tr("Right")
+ },
+ {
+ value: "bottom",
+ text: I18n.tr("Bottom")
+ },
+ {
+ value: "center",
+ text: I18n.tr("Center")
+ }
+ ]
+ settingKey: "dashTabPosition"
+ tab: "dank_dash"
+ tags: ["dashboard", "tabs", "position", "navigation"]
+ currentValue: (positions.find(option => option.value === SettingsData.dashTabPosition) ?? positions[0]).text
+ options: positions.map(option => option.text)
+ onValueChanged: value => {
+ const option = positions.find(option => option.text === value);
+ if (option)
+ SettingsData.set("dashTabPosition", option.value);
+ }
+ }
+
+ SettingsToggleRow {
+ settingKey: "dashTabsEvenlySpaced"
+ tab: "dank_dash"
+ tags: ["dashboard", "tabs", "spacing", "navigation"]
+ text: I18n.tr("Evenly space tabs")
+ checked: SettingsData.dashTabsEvenlySpaced
+ onToggled: checked => SettingsData.set("dashTabsEvenlySpaced", checked)
}
SettingsReorderList {
@@ -70,25 +118,33 @@ FocusScope {
reorderList: tabList
readonly property bool available: modelData.available !== false
- readonly property bool locked: modelData.locked === true
iconName: modelData.icon
iconColor: available && modelData.enabled ? Theme.primary : Theme.onSurface_38
titleColor: available ? Theme.surfaceText : Theme.onSurface_38
title: modelData.text
subtitle: available ? modelData.description ?? "" : I18n.tr("Disabled")
- clickable: available && !locked
+ clickable: available
onClicked: SettingsData.setDashTabEnabled(modelData.id, !modelData.enabled)
Row {
spacing: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
+ DankActionButton {
+ anchors.verticalCenter: parent.verticalCenter
+ iconName: "edit"
+ tooltipText: I18n.tr("Edit")
+ Accessible.description: tabRow.modelData.text
+ enabled: tabRow.available
+ onClicked: PopoutService.openDankDashEditor(tabRow.modelData.id, root.Window.window?.screen)
+ }
+
DankToggle {
anchors.verticalCenter: parent.verticalCenter
hideText: true
checked: tabRow.modelData.enabled
- enabled: tabRow.available && !tabRow.locked
+ enabled: tabRow.available
onToggled: checked => SettingsData.setDashTabEnabled(tabRow.modelData.id, checked)
}
}
diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml
index 6fe94c35d..0aa82df93 100644
--- a/quickshell/Modules/Settings/LockScreenTab.qml
+++ b/quickshell/Modules/Settings/LockScreenTab.qml
@@ -150,7 +150,7 @@ Item {
browserTitle: I18n.tr("Select lock screen background image")
browserType: "wallpaper"
showHiddenFiles: true
- fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif"]
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.svg"]
onFileSelected: path => {
SettingsData.set("lockScreenWallpaperPath", path);
close();
diff --git a/quickshell/Modules/Settings/WallpaperColorsTab.qml b/quickshell/Modules/Settings/WallpaperColorsTab.qml
index da61ca8d2..40fb7b7e3 100644
--- a/quickshell/Modules/Settings/WallpaperColorsTab.qml
+++ b/quickshell/Modules/Settings/WallpaperColorsTab.qml
@@ -860,7 +860,7 @@ Column {
browserTitle: I18n.tr("Select Wallpaper", "wallpaper file browser title")
browserType: "wallpaper"
showHiddenFiles: true
- fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"]
+ fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr", "*.svg"]
onFileSelected: path => {
root.applyBrowsedPath(path);
close();
diff --git a/quickshell/PLUGINS/DashCardExample/README.md b/quickshell/PLUGINS/DashCardExample/README.md
index 2b554673b..5d0d23a51 100644
--- a/quickshell/PLUGINS/DashCardExample/README.md
+++ b/quickshell/PLUGINS/DashCardExample/README.md
@@ -10,4 +10,4 @@ What it shows:
- `tone` and the card colors (`accentColor`, `contentColor`, `mutedColor`, `chipColor`), so the card follows the Surface, Primary, Secondary and Tertiary tones like the built-in cards.
- `dash.options`: a choice for the period, a toggle for the time left and a choice for the tone. They show up in the card's options sheet in edit mode and under Settings → Dashboard.
-Add it from the dash: open the overview, click the three-dot menu, Edit, then Add widget.
+Add it from the dash: open the overview, hover the selected page icon, click the pencil, then Add widget.
diff --git a/quickshell/PLUGINS/DashTabExample/README.md b/quickshell/PLUGINS/DashTabExample/README.md
index aaf867f02..f35d04e5e 100644
--- a/quickshell/PLUGINS/DashTabExample/README.md
+++ b/quickshell/PLUGINS/DashTabExample/README.md
@@ -9,7 +9,7 @@ What it shows:
- The plugin state API (`loadPluginState` / `savePluginState`) for runtime data. Nothing goes into settings, so no permissions are needed.
- `focusTarget` and `restoreFocus()`. Opening the tab focuses Start without a focus ring, so Space starts it right away.
- `handleKeyEvent`: Space starts or pauses, L records a lap, R resets.
-- `menuActions`: Reset in the tab's three-dot menu.
+- `menuActions`: Reset in the tab's edit-mode controls.
- `dash.options`: a toggle for hundredths and a number for how many laps to list, read as `options.hundredths` and `options.laps`.
Open it with `dms ipc call dash open plugin_dashTabExample`.
diff --git a/quickshell/PLUGINS/README.md b/quickshell/PLUGINS/README.md
index 64f3c8c74..bcbf1011d 100644
--- a/quickshell/PLUGINS/README.md
+++ b/quickshell/PLUGINS/README.md
@@ -1826,7 +1826,7 @@ The dash popout (`dms ipc call dash open`) is a set of entries. Each entry can h
Only enabled plugins show up. A disabled plugin has no tab, no card, no row under Settings → Dashboard and no entry in the Add widget menu. Its saved placement is kept, so re-enabling it puts the card back where it was.
-A plugin tab is enabled in the tab bar as soon as the plugin loads. Users hide it under Settings → Dashboard → Tabs. Cards are never placed automatically: users add them from the dash (three-dot menu → Edit → Add widget). A plugin with both surfaces gets one interaction for free: clicking its card opens its tab, and a tab hidden from the bar opens as a detail page with a back button.
+A plugin tab is enabled in the tab bar as soon as the plugin loads. Users hide it under Settings → Dashboard → Tabs. Cards are never placed automatically: users add them from the dash (hover the selected page icon → click the pencil → Add widget). A plugin with both surfaces gets one interaction for free: clicking its card opens its tab, and a tab hidden from the bar opens as a detail page. It has a back button when enabled destinations exist; otherwise it opens standalone.
Both surfaces load lazily. A tab is created when it becomes current and destroyed when the user switches away. Cards are created with the overview grid. The dash content stays alive after the popout closes, so gate timers, animations and service refs on `live`.
@@ -1887,7 +1887,7 @@ The `dash` block is optional. `icon` and `title` label the tab and fall back to
]
```
-The dash shows these rows in an options sheet (the tune button on a card in edit mode, Options in a tab's three-dot menu) and Settings → Dashboard lists the same rows. The tab and the card read the resolved values as `options.` and re-evaluate when a value changes. Values live in `settings.json` under `dashOptions.plugin_`, apart from `pluginData`, and only non-default values are stored. Reset clears the declared keys only. `widgets` is reserved for the `DashWidgetGrid` layout and is ignored as an option key.
+The dash shows these rows in an options sheet (the tune button on a card in edit mode, Options in a tab's edit-mode controls) and Settings → Dashboard lists the same rows. The tab and the card read the resolved values as `options.` and re-evaluate when a value changes. Values live in `settings.json` under `dashOptions.plugin_`, apart from `pluginData`, and only non-default values are stored. Reset clears the declared keys only. `widgets` is reserved for the `DashWidgetGrid` layout and is ignored as an option key.
`dash.options` only reach the dash surfaces. If a bar widget or daemon of the same plugin needs the value, use a `settings` component and `pluginData` instead.
@@ -1917,7 +1917,7 @@ Optional on the tab:
- `focusTarget`: the item that receives focus when Down enters the tab content
- `restoreFocus()`: called when the dash opens on the tab or returns focus to it. Focus your content here with `Qt.OtherFocusReason` so no focus ring is drawn
- `blocksTabNavigation`: true while an editor or local control group needs native Tab traversal; false returns Tab to the dash navigation
-- `menuActions`: actions shown in the tab's three-dot menu before Edit and Options. Each action has `label`, `iconName`, `action`, and optional `visible` and `enabled`
+- `menuActions`: actions shown in the Actions menu while editing the tab. Each action has `label`, `iconName`, `action`, and optional `visible` and `enabled`
- `signal tabRequested(string id)`: switch the dash to another tab (`"overview"`, `"media"`, `"wallpaper"`, `"weather"`, `"notifications"` or a `plugin_`); a tab hidden from the bar opens as a detail page
- `signal navFocusRequested`: return focus to the dash navigation
diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml
index abf6d7508..afd76951b 100644
--- a/quickshell/Services/PopoutService.qml
+++ b/quickshell/Services/PopoutService.qml
@@ -264,6 +264,7 @@ Singleton {
property bool _dankDashWantsOpen: false
property bool _dankDashWantsToggle: false
+ property bool _dankDashWantsEdit: false
property var _dankDashPendingTab: 0
property real _dankDashPendingX: 0
property real _dankDashPendingY: 0
@@ -282,6 +283,7 @@ Singleton {
}
function openDankDash(tab, x, y, width, section, screen) {
+ _dankDashWantsEdit = false;
_dankDashPendingTab = tab || 0;
if (dankDashPopout) {
if (arguments.length >= 6)
@@ -299,11 +301,13 @@ Singleton {
}
function closeDankDash() {
+ _dankDashWantsEdit = false;
if (dankDashPopout)
dankDashPopout.dashVisible = false;
}
function toggleDankDash(tab, x, y, width, section, screen) {
+ _dankDashWantsEdit = false;
_dankDashPendingTab = tab || 0;
if (dankDashPopout) {
if (arguments.length >= 6)
@@ -328,6 +332,11 @@ Singleton {
if (!dankDashPopout)
return;
+ if (_dankDashWantsEdit) {
+ _showDankDashEditor();
+ return;
+ }
+
if (_dankDashHasPosition)
setPosition(dankDashPopout, _dankDashPendingX, _dankDashPendingY, _dankDashPendingWidth, _dankDashPendingSection, _dankDashPendingScreen);
@@ -348,6 +357,34 @@ Singleton {
}
}
+ function openDankDashEditor(tab, screen) {
+ const target = screen ?? Quickshell.screens.find(candidate => candidate.name === CompositorService.getFocusedScreenName()) ?? Quickshell.screens[0];
+ if (!target || (!dankDashPopout && !dankDashPopoutLoader))
+ return;
+ closeSettings();
+ _dankDashPendingTab = tab;
+ _dankDashPendingScreen = target;
+ _dankDashHasPosition = false;
+ _dankDashWantsOpen = false;
+ _dankDashWantsToggle = false;
+ _dankDashWantsEdit = true;
+ if (dankDashPopout) {
+ _showDankDashEditor();
+ return;
+ }
+ dankDashPopoutLoader.active = true;
+ }
+
+ function _showDankDashEditor() {
+ _dankDashWantsEdit = false;
+ const target = _dankDashPendingScreen;
+ const anchor = BarWidgetService.naturalPopoutAnchor(target, null, "center");
+ dankDashPopout.setTriggerPosition(anchor.trigger.x, anchor.trigger.y, anchor.trigger.width, anchor.section, target, anchor.position, anchor.thickness, anchor.spacing, anchor.config);
+ dankDashPopout.requestTab(_dankDashPendingTab);
+ dankDashPopout.editMode = true;
+ dankDashPopout.dashVisible = true;
+ }
+
function openBattery(x, y, width, section, screen) {
if (batteryPopout) {
setPosition(batteryPopout, x, y, width, section, screen);
diff --git a/quickshell/Services/WallpaperCyclingService.qml b/quickshell/Services/WallpaperCyclingService.qml
index b63eebc2e..4021221a1 100644
--- a/quickshell/Services/WallpaperCyclingService.qml
+++ b/quickshell/Services/WallpaperCyclingService.qml
@@ -135,7 +135,7 @@ Singleton {
}
function findCommand(wallpaperDir) {
- return ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`];
+ return ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" -o -iname "*.svg" \\) 2>/dev/null | sort`];
}
function monitorProcessFor(screenName) {
@@ -292,7 +292,7 @@ Singleton {
if (wallpaperList.length > 1) {
do {
targetIndex = Math.floor(Math.random() * wallpaperList.length);
- } while (targetIndex === currentIndex);
+ } while (targetIndex === currentIndex)
} else {
targetIndex = 0;
}
diff --git a/quickshell/Widgets/DankNavigationBar.qml b/quickshell/Widgets/DankNavigationBar.qml
new file mode 100644
index 000000000..ee06b5011
--- /dev/null
+++ b/quickshell/Widgets/DankNavigationBar.qml
@@ -0,0 +1,3 @@
+import qs.DankCommon.Widgets as DankCommon
+
+DankCommon.DankNavigationBar {}
diff --git a/quickshell/Widgets/DankPopoutHost.qml b/quickshell/Widgets/DankPopoutHost.qml
index fe0666974..f483f5018 100644
--- a/quickshell/Widgets/DankPopoutHost.qml
+++ b/quickshell/Widgets/DankPopoutHost.qml
@@ -777,7 +777,7 @@ Item {
property real renderedAlignedHeight: alignedHeight
// Snap rendered geometry while the entrance morph runs so it doesn't ride a second animation.
readonly property bool _settlingToOpen: shouldBeVisible && (morph.running || travelSpring.running)
- readonly property bool _geometryMotion: animationsEnabled && contentWindow.visible && shouldBeVisible && !_settlingToOpen && !resizing
+ readonly property bool _geometryMotion: animationsEnabled && !SettingsData.reduceMotion && resizeDuration > 0 && contentWindow.visible && shouldBeVisible && !_settlingToOpen && !resizing
property real _bodyGlide: 1
property bool _bodyBatch: false
property real _bodyFromY: 0
diff --git a/quickshell/tests/qml/dash-navigation.qml b/quickshell/tests/qml/dash-navigation.qml
new file mode 100644
index 000000000..656d257ab
--- /dev/null
+++ b/quickshell/tests/qml/dash-navigation.qml
@@ -0,0 +1,527 @@
+import QtQuick
+import QtTest
+import Quickshell
+import qs.Common
+import qs.Services
+import qs.Widgets
+import qs.Modules.DankDash
+import qs.Modules.DankIsland
+import qs.Modules.DankIsland.Activities
+import qs.Modules.Settings as Settings
+import qs.DankCommon.Common as DC
+
+ShellRoot {
+ id: root
+
+ property var dash: null
+ property bool captured: false
+ property int customActions: 0
+ readonly property string captureDirectory: Quickshell.env("DMS_FIXTURE_CAPTURE_DIR")
+
+ Component {
+ id: dashComponent
+ DankDashPopout {}
+ }
+
+ Component {
+ id: settingsComponent
+ Settings.DankDashTab {}
+ }
+
+ QtObject {
+ id: deferredDashLoader
+ property bool active: false
+ }
+
+ Component {
+ id: pluginComponent
+ Item {
+ implicitHeight: DashMetrics.tabMinHeight
+ property var menuActions: [
+ {
+ label: "Test action",
+ iconName: "refresh",
+ action: () => root.customActions++
+ },
+ {
+ label: "Hidden action",
+ visible: false
+ },
+ {
+ label: "Disabled action",
+ enabled: false,
+ action: () => root.customActions++
+ }
+ ]
+ }
+ }
+
+ IslandController {
+ id: island
+ }
+
+ Component {
+ id: islandHome
+ HomeExpanded {
+ controller: island
+ }
+ }
+
+ FloatingWindow {
+ id: window
+ visible: true
+ implicitWidth: 1100
+ implicitHeight: 800
+ color: Theme.surface
+
+ DankNavigationBar {
+ id: navigation
+ width: 800
+ height: implicitHeight
+ currentIndex: 0
+ model: [
+ {
+ icon: "dashboard",
+ text: "Overview"
+ },
+ {
+ icon: "music_note",
+ text: "Media"
+ }
+ ]
+ onActivated: index => currentIndex = index
+ }
+ }
+
+ TestCase {
+ id: tester
+ when: false
+
+ function check(condition, message) {
+ if (!condition)
+ throw new Error(message);
+ }
+
+ function find(item, predicate) {
+ if (!item)
+ return null;
+ if (predicate(item))
+ return item;
+ for (const child of item.children || []) {
+ const found = find(child, predicate);
+ if (found)
+ return found;
+ }
+ return null;
+ }
+
+ function settle() {
+ wait(0);
+ const surface = root.dash?.contentLoader.item;
+ if (surface)
+ check(!isPolishScheduled(surface) || waitForPolish(surface, 3000), "dashboard layout settled");
+ }
+
+ function open(tab) {
+ root.dash.requestTab(tab);
+ root.dash.dashVisible = true;
+ tryVerify(() => root.dash.shouldBeVisible && root.dash.contentLoader.item?.ready, 3000);
+ settle();
+ }
+
+ function pages() {
+ return find(root.dash.contentLoader.item, item => item.currentHost !== undefined);
+ }
+
+ function bar() {
+ return find(root.dash.contentLoader.item, item => typeof item.moveSelection === "function");
+ }
+
+ function actionBounds(...buttons) {
+ return JSON.stringify(buttons.map(button => {
+ const point = button.mapToItem(root.dash.contentLoader.item, 0, 0);
+ return [point.x, point.y, button.width, button.height];
+ }));
+ }
+
+ function capture(name) {
+ if (!root.captureDirectory)
+ return;
+ tryVerify(() => Math.abs(root.dash.contentLoader.item.width - root.dash.popupWidth) < 1, 3000, "rendered panel width matches requested width");
+ tryVerify(() => Math.abs(root.dash.contentLoader.item.height - root.dash.popupHeight) < 1, 3000, "rendered panel height matches requested height");
+ root.captured = false;
+ root.dash.contentLoader.item.grabToImage(result => {
+ check(result.saveToFile(root.captureDirectory + "/" + name + ".png"), "saved " + name);
+ root.captured = true;
+ });
+ tryCompare(root, "captured", true, 3000);
+ }
+
+ function run() {
+ try {
+ SettingsData.weatherEnabled = true;
+ SettingsData.setDashTabEnabled("overview", false);
+ check(!DashRegistry.visibleTabIds.includes("overview"), "Overview can be hidden");
+ check(DashRegistry.hasTab("overview"), "hidden Overview remains available");
+ check(DashRegistry.defaultTabId !== "overview", "default follows enabled destinations");
+ root.dash = dashComponent.createObject(root);
+ root.dash.triggerScreen = Quickshell.screens[0];
+ root.dash.setTriggerPosition(640, 0, 40, "center", Quickshell.screens[0]);
+ open(0);
+ check(root.dash.activeTabId === DashRegistry.visibleTabIds[0], "numeric default uses first enabled destination");
+ open("overview");
+ check(root.dash.detailTabId === "overview" && root.dash.showBack, "hidden Overview has a return destination");
+ root.dash.closeDetail();
+ check(root.dash.activeTabId === DashRegistry.defaultTabId, "Back returns to enabled page");
+
+ for (const id of DashRegistry.tabIds)
+ SettingsData.setDashTabEnabled(id, false);
+ open(0);
+ check(root.dash.activeTabId === "" && !root.dash.showTabs, "zero tabs opens empty dashboard");
+ check(pages().currentItem === null && pages().focusTarget !== null, "empty state releases page and offers settings");
+ check(Math.abs(root.dash.contentLoader.item.height - root.dash.popupHeight) < 1, "zero-motion empty state snaps rendered height");
+ capture("empty");
+ PopoutService.dankDashPopout = root.dash;
+ const settingsPage = settingsComponent.createObject(window.contentItem, {
+ width: 800,
+ height: 600,
+ visible: false
+ });
+ for (const id of ["overview", "media", "weather"]) {
+ const row = find(settingsPage, item => item.modelData?.id === id && item.available !== undefined);
+ const editButton = find(row, item => item.iconName === "edit" && typeof item.clicked === "function");
+ check(!!editButton, "Settings exposes editing for hidden " + id);
+ editButton.clicked();
+ check(root.dash.editMode && root.dash.activeTabId === id && !root.dash.showTabs, "Settings edits hidden " + id + " with zero tabs");
+ }
+ settingsPage.destroy();
+ root.dash.dashVisible = false;
+ root.dash.instantClose();
+ PopoutService.dankDashPopout = null;
+ PopoutService.dankDashPopoutLoader = deferredDashLoader;
+ PopoutService.openDankDashEditor("media", Quickshell.screens[0]);
+ check(deferredDashLoader.active && PopoutService._dankDashWantsEdit, "cold editor request activates loader");
+ PopoutService.dankDashPopout = root.dash;
+ PopoutService._onDankDashPopoutLoaded();
+ check(root.dash.editMode && root.dash.activeTabId === "media" && !PopoutService._dankDashWantsEdit, "cold editor request survives loading");
+ root.dash.editMode = false;
+ for (const id of ["overview", "media", "weather"]) {
+ open(id);
+ check(root.dash.activeTabId === id && !root.dash.showTabs && !root.dash.showBack, "standalone hidden " + id);
+ }
+ capture("standalone-weather");
+ root.dash.closeDetail();
+ check(!root.dash.dashVisible, "standalone Back/Escape closes instead of showing empty state");
+
+ const home = islandHome.createObject(window.contentItem, {
+ width: 800,
+ height: 600
+ });
+ check(home.entryId === "overview", "Island home keeps Overview identity with no enabled tabs");
+ tryVerify(() => !!home.tab, 3000);
+ const islandActions = find(home, item => item.overlayParent !== undefined && item.entryId === "overview");
+ check(!!islandActions && islandActions.hasWidgets, "Island uses shared page actions");
+ parent = window.contentItem;
+ island.expanded = true;
+ home.focusHeader(false);
+ home.editMode = true;
+ settle();
+ check(home.Window.window.activeFocusItem === home, "Island edit entry clears title focus");
+ islandActions.optionsRequested();
+ const islandOptions = find(home, item => typeof item.presentFor === "function" && item.entryId === "overview");
+ check(islandOptions.shown, "Island Options opened before Tab");
+ keyClick(Qt.Key_Tab);
+ check(islandOptions.shown && !islandActions.focusTargets.some(item => item.activeFocus), "Island Options keeps Tab out of edit controls: shown=" + islandOptions.shown + ", focus=" + home.Window.window.activeFocusItem);
+ islandOptions.dismiss();
+ island.expanded = false;
+ home.destroy();
+
+ SettingsData.setDashTabEnabled("media", true);
+ open(0);
+ check(root.dash.activeTabId === "media" && !root.dash.showTabs, "one destination has no navigation");
+ capture("single");
+ SettingsData.setDashTabEnabled("overview", true);
+ SettingsData.setDashTabOrder(["overview", "media"]);
+ open(0);
+ for (const edge of [0, 1, 2, 3, 4, 5, 6, 7]) {
+ SettingsData.animationDuration = edge % 2 === 0 ? 0 : 200;
+ root.dash.setBarContext(edge, 0);
+ settle();
+ const nav = bar();
+ const page = pages();
+ const vertical = edge % 4 >= 2;
+ check(root.dash.showTabs && root.dash.verticalNavigation === vertical, "orientation follows edge " + edge);
+ check(nav.orientation === (vertical ? Qt.Vertical : Qt.Horizontal), "navigation orientation " + edge);
+ check(Math.abs(root.dash.contentLoader.item.width - root.dash.popupWidth) < 1, "zero-motion navigation snaps rendered width " + edge);
+ const point = nav.mapToItem(root.dash.contentLoader.item, 0, 0);
+ const contentPoint = page.mapToItem(root.dash.contentLoader.item, 0, 0);
+ const expectedEdge = [0, 1, 3, 2][edge % 4];
+ check(root.dash.navigationEdge === expectedEdge, "side defaults face away from triggering bar " + edge);
+ switch (expectedEdge) {
+ case 0:
+ check(point.y + nav.height <= contentPoint.y, "top navigation before content");
+ break;
+ case 1:
+ check(point.y >= contentPoint.y + page.height, "bottom navigation after content");
+ break;
+ case 2:
+ check(point.x + nav.width <= contentPoint.x, "left rail before content");
+ break;
+ case 3:
+ check(point.x >= contentPoint.x + page.width, "right rail after content");
+ break;
+ }
+ const size = [root.dash.popupWidth, root.dash.popupHeight];
+ root.dash.editMode = true;
+ settle();
+ check(root.dash.popupWidth === size[0] && root.dash.popupHeight === size[1], "edit preserves panel dimensions " + edge);
+ root.dash.editMode = false;
+ check(Math.abs(page.width - (root.dash.popupWidth - root.dash.navigationWidth - DashMetrics.contentPadding * 2)) < 1, "content width excludes navigation " + edge);
+ if (edge < 4)
+ capture("edge-" + edge);
+ }
+ SettingsData.animationDuration = 0;
+ for (const [position, expectedEdge] of [["left", 2], ["right", 3], ["bottom", 1], ["center", 0]]) {
+ SettingsData.set("dashTabPosition", position);
+ root.dash.setBarContext(0, 0);
+ settle();
+ check(root.dash.navigationEdge === expectedEdge && root.dash.verticalNavigation === (expectedEdge >= 2), "position override " + position);
+ root.dash.setBarContext(3, 0);
+ check(root.dash.navigationEdge === expectedEdge, "override ignores bar position " + position);
+ }
+ SettingsData.set("dashTabPosition", "auto");
+ root.dash.setBarContext(0, 0);
+ SettingsData.resetDashTabs();
+ settle();
+ capture("default-tabs");
+ const embeddedEdit = find(bar(), item => item.revealed !== undefined && item.visible);
+ mouseMove(embeddedEdit, embeddedEdit.width / 2, embeddedEdit.height / 2);
+ tryVerify(() => embeddedEdit.revealed, 1000);
+ capture("hover-edit");
+ mouseClick(embeddedEdit, embeddedEdit.width / 2, embeddedEdit.height / 2);
+ check(root.dash.editMode, "selected icon pencil opens editing");
+ root.dash.editMode = false;
+ SessionData.locale = "ar";
+ root.dash.setBarContext(2, 0);
+ settle();
+ check(I18n.isRtl && root.dash.verticalNavigation, "RTL keeps physical right rail");
+ capture("rtl-right");
+ SessionData.locale = "en";
+ settle();
+ const verticalEdit = find(bar(), item => item.revealed !== undefined && item.visible);
+ const selectedIcon = find(verticalEdit.parent, item => item.filled !== undefined && item.name === "dashboard");
+ check(Math.abs(verticalEdit.x + verticalEdit.width / 2 - selectedIcon.x - selectedIcon.width / 2) < 1 && Math.abs(verticalEdit.y + verticalEdit.height / 2 - selectedIcon.y - selectedIcon.height / 2) < 1, "vertical pencil stays centered on destination icon");
+ mouseMove(verticalEdit, verticalEdit.width / 2, verticalEdit.height / 2);
+ tryVerify(() => verticalEdit.revealed, 1000);
+ capture("hover-vertical");
+ root.dash.setBarContext(0, 0);
+ settle();
+ root.dash.requestTab("overview");
+ root.dash.editMode = true;
+ settle();
+ const clear = find(root.dash.contentLoader.item, item => item.actionId === "clear");
+ const reset = find(root.dash.contentLoader.item, item => item.actionId === "reset");
+ const savedCards = JSON.stringify(SettingsData.dashCards);
+ const horizontalBounds = actionBounds(clear, reset);
+ clear.clicked();
+ settle();
+ check(clear.armed && JSON.stringify(SettingsData.dashCards) === savedCards, "Clear needs confirmation");
+ check(actionBounds(clear, reset) === horizontalBounds, "arming Clear does not move or resize either horizontal action");
+ check(clear.Accessible.name === I18n.tr("Confirm") && clear.color === Theme.error, "armed Clear reads as an emphasized Confirm");
+ capture("confirm-horizontal");
+ reset.clicked();
+ settle();
+ check(actionBounds(clear, reset) === horizontalBounds && !clear.armed && reset.armed, "arming Reset disarms Clear without moving either action: " + horizontalBounds + " vs " + actionBounds(clear, reset));
+ clear.clicked();
+ clear.clicked();
+ check(SettingsData.dashCards.length === 0, "confirmed Clear removes widgets");
+ reset.clicked();
+ check(SettingsData.dashCards.length === 0, "Reset needs confirmation");
+ reset.clicked();
+ check(SettingsData.dashCards.length > 0, "confirmed Reset restores widgets");
+ clear.clicked();
+ root.dash.editMode = false;
+ root.dash.editMode = true;
+ check(!clear.armed, "leaving editing cancels confirmation");
+ settle();
+ const editActions = find(root.dash.contentLoader.item, item => item.overlayParent !== undefined);
+ check(editActions.focusTargets.every(item => !item.visualFocus), "programmatic edit entry has no persistent focus ring");
+ check(root.dash.contentLoader.item.Window.window.activeFocusItem === root.dash.contentLoader.item, "edit entry keeps neutral focus");
+ capture("edit-horizontal");
+ const editViewport = find(editActions, item => item.contentX !== undefined && item.wheelEnabled !== undefined);
+ editActions.width = 280;
+ settle();
+ editActions.focusTargets[editActions.focusTargets.length - 1].forceActiveFocus(Qt.TabFocusReason);
+ check(editViewport.contentX > 0, "overflowed Finish is revealed on keyboard focus");
+ editActions.width = Qt.binding(() => Math.min(editActions.parent.width, editActions.implicitWidth));
+ root.dash.setBarContext(2, 0);
+ settle();
+ capture("edit-vertical");
+ const verticalBounds = actionBounds(clear, reset);
+ reset.clicked();
+ settle();
+ check(actionBounds(clear, reset) === verticalBounds && reset.armed, "arming Reset does not move or resize either vertical action");
+ capture("confirm-vertical");
+ root.dash.editMode = false;
+ root.dash.setBarContext(0, 0);
+ settle();
+ const integratedNav = bar();
+ integratedNav.forceActiveFocus(Qt.TabFocusReason);
+ const start = root.dash.currentTabId;
+ keyClick(Qt.Key_Right);
+ check(integratedNav.activeFocus && root.dash.currentTabId !== start, "first arrow preserves navigation focus");
+ const next = root.dash.currentTabId;
+ keyClick(Qt.Key_Right);
+ check(integratedNav.activeFocus && root.dash.currentTabId !== next, "second arrow continues navigation");
+ root.dash.editMode = true;
+ settle();
+ check(root.dash.contentLoader.item.Window.window.activeFocusItem === root.dash.contentLoader.item, "edit entry releases hidden navigation focus");
+ root.dash.requestTab("weather");
+ root.dash.editMode = true;
+ settle();
+ const weatherActions = find(root.dash.contentLoader.item, item => item.overlayParent !== undefined);
+ keyClick(Qt.Key_Tab);
+ check(weatherActions.focusTargets[0].activeFocus, "Weather edit Tab enters first action");
+ root.dash.contentLoader.item.focusInitial();
+ keyClick(Qt.Key_Backtab);
+ check(weatherActions.focusTargets[weatherActions.focusTargets.length - 1].activeFocus, "Weather edit Backtab enters last action");
+ root.dash.editMode = false;
+
+ PluginService.pluginDashComponents = {
+ navigation_test: pluginComponent
+ };
+ PluginService.loadedPlugins = {
+ navigation_test: {
+ name: "Test",
+ surfaces: ["dash"]
+ }
+ };
+ open("plugin_navigation_test");
+ const actions = find(root.dash.contentLoader.item, item => item.overlayParent !== undefined);
+ check(!actions.visible, "no separate action strip in normal navigation");
+ const nav = bar();
+ check(nav.editable, "selected destination supports embedded editing");
+ nav.editRequested(nav.currentIndex);
+ settle();
+ check(root.dash.editMode && actions.visible, "embedded editing reveals page controls");
+ check(actions.implicitWidth <= actions.parent.width, "edit controls fit the header without scrolling: " + actions.implicitWidth + " > " + actions.parent.width);
+ check(actions.hasCustomActions, "plugin action menu is available");
+ const menu = find(root.dash.contentLoader.item, item => item.visibleItems !== undefined && item.parent === root.dash.contentLoader.item);
+ check(menu.visibleItems.length === 2, "hidden custom action omitted");
+ menu._activate(1);
+ check(root.customActions === 0, "disabled custom action cannot activate");
+ menu._activate(0);
+ check(root.customActions === 1, "custom callback preserved");
+ const lastAction = actions.focusTargets[actions.focusTargets.length - 1];
+ lastAction.forceActiveFocus(Qt.TabFocusReason);
+ keyClick(Qt.Key_Tab);
+ check(root.dash.editMode && root.dash.activeTabId === "plugin_navigation_test", "Tab from Finish stays in editing");
+ const editFocusTargets = actions.focusTargets;
+ root.dash.editMode = false;
+ settle();
+ check(editFocusTargets.every(item => !item.activeFocus), "leaving plugin editing releases hidden action focus");
+ SettingsData.setDashTabEnabled("plugin_navigation_test", false);
+ open("plugin_navigation_test");
+ check(root.dash.detailTabId === "plugin_navigation_test", "hidden plugin opens as detail");
+ PluginService.loadedPlugins = {};
+ settle();
+ check(root.dash.detailTabId === "" && root.dash.activeTabId === DashRegistry.defaultTabId, "removed plugin falls back cleanly");
+
+ root.dash.dashVisible = false;
+ root.dash.instantClose();
+ settle();
+ navigation.width = 800;
+ navigation.orientation = Qt.Horizontal;
+ settle();
+ const first = find(navigation, item => item.index === 0 && item.selected !== undefined);
+ check(Math.abs(first.width * navigation.count + navigation.spacing * (navigation.count - 1) - navigation.width) < 1, "horizontal destinations are evenly distributed");
+ navigation.evenlySpaced = false;
+ settle();
+ navigation.revealCurrent();
+ check(first.width === Theme.navigationItemMinWidth && first.parent.x > 0, "compact horizontal navigation stays centered");
+ navigation.evenlySpaced = true;
+ tester.parent = window.contentItem;
+ navigation.forceActiveFocus(Qt.TabFocusReason);
+ keyClick(Qt.Key_Right);
+ check(navigation.currentIndex === 1, "horizontal arrow selects next destination");
+ navigation.orientation = Qt.Vertical;
+ navigation.width = Theme.navigationRailWidth;
+ navigation.height = 300;
+ settle();
+ navigation.revealCurrent();
+ check(first.height === navigation.destinationHeight && first.parent.y > 0, "vertical destinations stay a centered group");
+ keyClick(Qt.Key_Up);
+ check(navigation.currentIndex === 0, "vertical arrow selects previous destination");
+ navigation.model = Array.from({
+ length: 10
+ }, (_, i) => ({
+ icon: "extension",
+ text: "Destination " + i
+ }));
+ navigation.currentIndex = 9;
+ settle();
+ const scroll = find(navigation, item => item.contentY !== undefined && item.flickableDirection !== undefined);
+ check(scroll.contentY > 0, "overflow selection scrolls into view");
+ navigation.model = [
+ {
+ icon: "extension",
+ text: "A long translated destination label"
+ }
+ ];
+ navigation.currentIndex = 0;
+ settle();
+ const longDestination = find(navigation, item => item.index === 0 && item.selected !== undefined);
+ check(longDestination.width === Theme.navigationRailWidth && longDestination.height > Theme.navigationHeight, "rail wraps long labels without widening");
+ navigation.orientation = Qt.Horizontal;
+ navigation.width = 240;
+ navigation.model = Array.from({
+ length: 10
+ }, (_, i) => ({
+ icon: "extension",
+ text: "Destination " + i
+ }));
+ navigation.currentIndex = 9;
+ settle();
+ check(scroll.contentX > 0, "horizontal overflow selection scrolls into view");
+ SessionData.locale = "ar";
+ navigation.currentIndex = 0;
+ settle();
+ navigation.forceActiveFocus(Qt.OtherFocusReason);
+ keyClick(Qt.Key_Left);
+ check(navigation.currentIndex === 1, "RTL Left selects the next logical destination");
+ SessionData.locale = "en";
+ navigation.orientation = Qt.Vertical;
+ navigation.width = Theme.navigationRailWidth;
+ settle();
+ open("overview");
+ for (const strength of [0, 50, 100]) {
+ SettingsData.set("radiusStrength", strength);
+ settle();
+ const indicator = find(navigation, item => item.width === Theme.navigationIndicatorWidth && item.height === Theme.navigationIndicatorHeight && item.radius !== undefined);
+ check(indicator && indicator.radius === (strength === 0 ? 0 : Theme.navigationIndicatorHeight / 2), "indicator radius at " + strength);
+ capture("radius-" + strength);
+ }
+ SettingsData.set("radiusMode", "fixed");
+ SettingsData.set("fixedRadius", 7);
+ settle();
+ const indicator = find(navigation, item => item.width === Theme.navigationIndicatorWidth && item.height === Theme.navigationIndicatorHeight && item.radius !== undefined);
+ check(indicator.radius === 7, "fixed radius applies to navigation");
+ capture("fixed-radius");
+ console.log("FIXTURE_PASS dashboard navigation and actions");
+ } catch (error) {
+ console.error("FIXTURE_FAIL " + error.message);
+ }
+ Qt.quit();
+ }
+ }
+
+ Component.onCompleted: {
+ Quickshell.watchFiles = false;
+ DC.Style.theme = Theme;
+ DC.Style.settings = SettingsData;
+ DC.I18n.backend = I18n;
+ SettingsData.animationDuration = 0;
+ SettingsData.reduceMotion = true;
+ Qt.callLater(tester.run);
+ }
+}
diff --git a/quickshell/tests/run-qml.py b/quickshell/tests/run-qml.py
index b9ee6ade6..d8acce529 100644
--- a/quickshell/tests/run-qml.py
+++ b/quickshell/tests/run-qml.py
@@ -51,7 +51,7 @@ def main():
parser.error("--jobs must be positive")
selected = args.suites or ["widgets", "media", "qt", "logic"]
commands = {
- "widgets": [sys.executable, "quickshell/tests/run-surface-fixture.py", "--suite", "quickshell/tests/qml/bar-content.qml", "quickshell/tests/qml/frame-bar-flip.qml", "quickshell/tests/qml/instance-routing.qml", "quickshell/tests/qml/font-weights.qml", "quickshell/tests/qml/theme-selected-container.qml", "quickshell/tests/qml/theme-accents.qml", "quickshell/tests/qml/clipboard-preview.qml", "quickshell/tests/qml/launcher-plugin-instances.qml", "quickshell/tests/qml/island-launcher-focus.qml", "quickshell/tests/qml/workspace-switcher.qml", "quickshell/tests/qml/focused-app.qml", "quickshell/tests/qml/slider-wheel.qml", "quickshell/tests/qml/control-center-sizes.qml", "quickshell/tests/qml/grid-edit-layout.qml"],
+ "widgets": [sys.executable, "quickshell/tests/run-surface-fixture.py", "--suite", "quickshell/tests/qml/bar-content.qml", "quickshell/tests/qml/frame-bar-flip.qml", "quickshell/tests/qml/instance-routing.qml", "quickshell/tests/qml/font-weights.qml", "quickshell/tests/qml/theme-selected-container.qml", "quickshell/tests/qml/theme-accents.qml", "quickshell/tests/qml/clipboard-preview.qml", "quickshell/tests/qml/launcher-plugin-instances.qml", "quickshell/tests/qml/island-launcher-focus.qml", "quickshell/tests/qml/workspace-switcher.qml", "quickshell/tests/qml/focused-app.qml", "quickshell/tests/qml/slider-wheel.qml", "quickshell/tests/qml/control-center-sizes.qml", "quickshell/tests/qml/dash-navigation.qml", "quickshell/tests/qml/grid-edit-layout.qml"],
"media": [sys.executable, "quickshell/tests/run-surface-fixture.py", "--suite", "quickshell/tests/qml/media-presentation.qml", "quickshell/tests/qml/media-playback.qml", "quickshell/tests/qml/media-artwork.qml", "quickshell/tests/qml/media-lyrics.qml", "--mpris", "--artwork"],
"logic": ["node", "--test", *sorted(str(path.relative_to(repo)) for path in (repo / "quickshell/tests").glob("*.test.mjs"))],
}
diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json
index 8940ee746..75386451a 100644
--- a/quickshell/translations/settings_search_index.json
+++ b/quickshell/translations/settings_search_index.json
@@ -11999,6 +11999,20 @@
"icon": "space_dashboard",
"description": "Tabs and weather"
},
+ {
+ "section": "dashTabsEvenlySpaced",
+ "label": "Evenly space tabs",
+ "tabIndex": 43,
+ "category": "Dashboard",
+ "keywords": [
+ "dashboard",
+ "evenly",
+ "navigation",
+ "space",
+ "spacing",
+ "tabs"
+ ]
+ },
{
"section": "dashMediaSettings",
"label": "Media player",
@@ -12015,6 +12029,18 @@
],
"icon": "music_note"
},
+ {
+ "section": "dashTabPosition",
+ "label": "Position",
+ "tabIndex": 43,
+ "category": "Dashboard",
+ "keywords": [
+ "dashboard",
+ "navigation",
+ "position",
+ "tabs"
+ ]
+ },
{
"section": "dashTabs",
"label": "Tabs",
@@ -12022,6 +12048,8 @@
"category": "Dashboard",
"keywords": [
"dashboard",
+ "navigation",
+ "position",
"tabs"
],
"icon": "refresh"