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"