dash: replace the tab bar with an m3 navigation bar and inline edit controls

wallpaper: allow SVG wallpapers, with a workaround for a matugen panic I
encountered on passing it an SVG - fixes #1350
This commit is contained in:
bbedward committed 2026-09-19 20:56:05 -04:00
1 parent 67e5f24761
commit 19b4593bfe
38 files changed
+1363 -370

No files matched your search

+2
View File
@@ -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
+4
View File
@@ -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=
+11 -12
View File
@@ -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
}
+71
View File
@@ -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
}
+58
View File
@@ -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 := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect width="200" height="100" fill="#3355ff"/><circle cx="60" cy="50" r="40" fill="#ff8800"/></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()
}
Generated
+3 -3
View File
@@ -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": {
+2
View File
@@ -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()
+6
View File
@@ -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
@@ -603,6 +603,12 @@ var LOCAL_SPEC = {
keybindsFloatingWindow: {
def: false
},
dashTabPosition: {
def: "auto"
},
dashTabsEvenlySpaced: {
def: true
},
dashTabs: {
def: [
{
+2 -2
View File
@@ -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 = "";
+175 -156
View File
@@ -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: {
@@ -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
@@ -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()
}
}
@@ -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()
}
])
}
@@ -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
}
}
+6 -6
View File
@@ -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) {
@@ -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")
@@ -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()
}
}
}
}
@@ -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"
@@ -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)
+2 -2
View File
@@ -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 => {
@@ -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: {
@@ -8,7 +8,7 @@ DashTabFace {
id: root
activityId: "home"
entryId: DashRegistry.fallbackId
entryId: DashRegistry.overviewId
tabComponent: Component {
OverviewTab {
live: root.live
@@ -113,7 +113,7 @@ QtObject {
function dashEntryIdFor(activityId) {
switch (activityId) {
case "home":
return DashRegistry.fallbackId;
return DashRegistry.overviewId;
case "notificationcenter":
return "notifications";
}
+61 -5
View File
@@ -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)
}
}
@@ -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();
@@ -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();
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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`.
+3 -3
View File
@@ -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.<key>` and re-evaluate when a value changes. Values live in `settings.json` under `dashOptions.plugin_<pluginId>`, 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.<key>` and re-evaluate when a value changes. Values live in `settings.json` under `dashOptions.plugin_<pluginId>`, 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_<id>`); a tab hidden from the bar opens as a detail page
- `signal navFocusRequested`: return focus to the dash navigation
+37
View File
@@ -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);
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
import qs.DankCommon.Widgets as DankCommon
DankCommon.DankNavigationBar {}
+1 -1
View File
@@ -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
+527
View File
@@ -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);
}
}
+1 -1
View File
@@ -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"))],
}
@@ -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"