diff --git a/docs/IPC.md b/docs/IPC.md index bdfc7fbea..90c51b855 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -517,6 +517,90 @@ dms ipc call bar hide dms ipc call bar status ``` +### Dank Island instances + +One bar instance may be designated as Dank Island (Settings → Island → Island instance) + +## Target: `island` + +Dank Island activity surface. Requires a bar instance designated as the island in Settings (Island → Island instance) and enabled. Commands target the focused screen. + +### Activities + +- `home` — compact clock (default when the activity argument is unrecognized) +- `media` — now-playing (unavailable when no media session is active) +- `launcher` — native island launcher +- `controlcenter` — Control Center (`control-center` and `cc` are aliases) +- `wallpaper` — wallpaper browser +- `weather` — weather (unavailable when weather is disabled) +- `notificationcenter` — notification center (`notifications`, `notification-center`, and `nc` are aliases) + +### Functions + +**`open `** +- Expand the island into the requested activity +- Parameters: `activity` - Activity id (see above) +- Returns: `DANK_ISLAND_OPEN: \t`, `DANK_ISLAND_ACTIVITY_UNAVAILABLE: `, or `DANK_ISLAND_UNAVAILABLE` + +**`toggle `** +- Collapse if the island is expanded; otherwise expand into the requested activity +- Parameters: `activity` - Activity id to open when collapsed +- Returns: `DANK_ISLAND_OPEN: \t`, `DANK_ISLAND_CLOSED: `, `DANK_ISLAND_ACTIVITY_UNAVAILABLE: `, or `DANK_ISLAND_UNAVAILABLE` + +**`show `** +- Switch the compact island face without expanding +- Parameters: `activity` - Activity id +- Returns: `DANK_ISLAND_SHOW: \t`, `DANK_ISLAND_ACTIVITY_UNAVAILABLE: `, or `DANK_ISLAND_UNAVAILABLE` + +**`close`** +- Collapse the island back to its compact face +- Returns: `DANK_ISLAND_CLOSED: ` or `DANK_ISLAND_UNAVAILABLE` + +**`cycle`** +- Cycle island activities (`home` → `media` when available → `launcher` when Launcher → Default Opens is Island → `controlcenter` → `notificationcenter`) +- Returns: `DANK_ISLAND_ACTIVITY: \t` or `DANK_ISLAND_UNAVAILABLE` + +**`status`** +- JSON snapshot of the island on the focused screen +- Returns: Object with `available`, `enabled`, `screen`, `activity`, `expanded`, `mediaAvailable`, `launcherAvailable`, `controlCenterAvailable`, `wallpaperAvailable`, `weatherAvailable`, `notificationCenterAvailable`, `launcherInputFocused`, `launcherResultCount`, and `compactHeight`. When no host exists: `{"available":false,"enabled":true,"launcherAvailable":false}` + +**`notifications`** +- Toggle the notification center as an island activity +- Returns: `DANK_ISLAND_OPEN: notificationcenter\t`, `DANK_ISLAND_CLOSED: `, or `DANK_ISLAND_UNAVAILABLE` + +**`openOn ` / `toggleOn ` / `showOn ` / `closeOn ` / `cycleOn ` / `statusOn ` / `notificationsOn `** +- Same actions on a specific monitor (e.g. `DP-1`) +- A screen name with no island on it returns `DANK_ISLAND_UNAVAILABLE` rather than falling back to another display + +### Shared IPC routing + +When Dank Island is the sole top chrome on the focused display, these existing targets open island activities instead of their popouts: + +- `control-center` `open` / `toggle` / `hide` / `status` +- `notifications` `open` / `toggle` / `close` (the notification modal falls back when the island does not own the screen) +- `dash` `open` / `toggle` `overview` (or no tab), `media`, `wallpaper`, or `weather`, and `dash close` while those activities are open +- `dankdash wallpaper` (deprecated; same wallpaper path) + +`spotlight` and `launcher` open the island launcher when **Launcher → Default Opens** is set to Island, and fall back to Spotlight if the focused screen has no island. + +### Examples +```bash +dms ipc call island toggle home +dms ipc call island open home +dms ipc call island open media +dms ipc call island open launcher +dms ipc call island open controlcenter +dms ipc call island open wallpaper +dms ipc call island open weather +dms ipc call island open notifications +dms ipc call island notifications +dms ipc call island show home +dms ipc call island toggleOn media DP-1 +dms ipc call island cycle +dms ipc call island close +dms ipc call island status +``` + ## Target: `systemupdater` System updater widget control and background update checks. @@ -610,6 +694,8 @@ Application launcher modal control. - Parameters: `query` - Search text to pre-fill in the search box (only used when opening) - Returns: Success confirmation +When **Launcher → Default Opens** is set to Island, `spotlight` and `launcher` open the island launcher on the focused screen (Spotlight is used if that screen has no Dank Island). Direct island control is `island open launcher`. + ### Target: `clipboard` Clipboard history modal control. @@ -670,6 +756,8 @@ Control Center popout containing network, bluetooth, audio, power, and other qui - `close` - Hide the control center - `toggle` - Toggle control center visibility +When Dank Island is enabled and is the sole top chrome on the focused display, these calls open the native Control Center island activity instead of the popout. Use `island` to target a specific screen or activity. + **Examples** ```bash dms ipc call control-center toggle @@ -705,6 +793,8 @@ Dashboard popup control with tab selection for overview, media, and weather info - Parameters: `tab` - Tab to open when showing: "", "overview", "media", or "weather" - Returns: Success/failure message +On displays where Dank Island is the sole top chrome, `open`/`toggle` with `overview` (or no tab), `media`, `wallpaper`, or `weather` route to the matching island activity. `close` collapses those island activities when they are open. + ### Target: `dankdash` DankDash wallpaper browser control. @@ -785,6 +875,12 @@ Displays a live overview of all workspaces across all monitors with window previ # Open application launcher dms ipc call spotlight toggle +# Toggle Dank Island on the focused screen +dms ipc call island toggle home +dms ipc call island open media +dms ipc call island open controlcenter +dms ipc call island cycle + # Open spotlight with pre-filled search dms ipc call spotlight openQuery browser dms ipc call spotlight toggleQuery "!" @@ -855,6 +951,8 @@ These IPC commands are designed to be used with window manager keybindings. ```kdl binds { Mod+Space { spawn "qs" "-c" "dms" "ipc" "call" "spotlight" "toggle"; } + Mod+I { spawn "qs" "-c" "dms" "ipc" "call" "island" "toggle" "home"; } + Mod+Shift+I { spawn "qs" "-c" "dms" "ipc" "call" "island" "open" "controlcenter"; } Mod+V { spawn "qs" "-c" "dms" "ipc" "call" "clipboard" "toggle"; } Mod+P { spawn "qs" "-c" "dms" "ipc" "call" "notepad" "toggle"; } Mod+Shift+P { spawn "qs" "-c" "dms" "ipc" "call" "notepad" "expand"; } @@ -868,6 +966,8 @@ binds { **Example Hyprland configuration:** ```conf bind = SUPER, Space, exec, qs -c dms ipc call spotlight toggle +bind = SUPER, I, exec, qs -c dms ipc call island toggle home +bind = SUPER SHIFT, I, exec, qs -c dms ipc call island open controlcenter bind = SUPER, V, exec, qs -c dms ipc call clipboard toggle bind = SUPER, P, exec, qs -c dms ipc call notepad toggle bind = SUPER SHIFT, P, exec, qs -c dms ipc call notepad expand diff --git a/quickshell/Common/KeybindActions.js b/quickshell/Common/KeybindActions.js index 237d5ce40..9f570a853 100644 --- a/quickshell/Common/KeybindActions.js +++ b/quickshell/Common/KeybindActions.js @@ -11,6 +11,15 @@ const DMS_ACTIONS = [ { id: "spawn dms ipc call spotlight toggle", label: "Default Launcher: Toggle" }, { id: "spawn dms ipc call spotlight open", label: "Default Launcher: Open" }, { id: "spawn dms ipc call spotlight close", label: "Default Launcher: Close" }, + { id: "spawn dms ipc call island toggle home", label: "DankIsland: Toggle" }, + { id: "spawn dms ipc call island open home", label: "DankIsland: Open Clock" }, + { id: "spawn dms ipc call island open media", label: "DankIsland: Open Media" }, + { id: "spawn dms ipc call island open launcher", label: "DankIsland: Open Launcher" }, + { id: "spawn dms ipc call island open controlcenter", label: "DankIsland: Open Control Center" }, + { id: "spawn dms ipc call island open wallpaper", label: "DankIsland: Open Wallpapers" }, + { id: "spawn dms ipc call island open weather", label: "DankIsland: Open Weather" }, + { id: "spawn dms ipc call island cycle", label: "DankIsland: Cycle Activity" }, + { id: "spawn dms ipc call island close", label: "DankIsland: Close" }, { id: "spawn dms ipc call defaultApp browser", label: "Default Web Browser: Open" }, { id: "spawn dms ipc call defaultApp fileManager", label: "Default File Manager: Open" }, { id: "spawn dms ipc call defaultApp mail", label: "Default Mail: Open" }, diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 40282e3a8..f244aa868 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -970,6 +970,43 @@ Singleton { property var notificationRules: [] property bool notificationDndAllowCritical: true property bool notificationFocusedMonitor: false + // Names the bar config rendered as Dank Island instead of a DankBar. Empty = no island. + property string dankIslandBarId: "" + readonly property var islandBarConfig: dankIslandBarId ? (barConfigs.find(cfg => cfg.id === dankIslandBarId) || null) : null + readonly property bool dankIslandEnabled: !!islandBarConfig && (islandBarConfig.enabled ?? false) + readonly property int dankIslandPosition: islandBarConfig?.position ?? SettingsData.Position.Top + readonly property string dankIslandEdge: dankIslandPosition === SettingsData.Position.Bottom ? "bottom" : "top" + readonly property int dankIslandReservedStripHeight: { + const reserve = Math.max(36, Math.min(128, dankIslandReserveHeight)); + const compact = Math.max(36, Math.min(72, dankIslandCompactHeight)); + const gap = Math.max(0, Math.min(48, dankIslandOuterGap)); + return Math.max(reserve, gap + compact); + } + property bool dankIslandFloating: false + property bool dankIslandUseOverlayLayer: false + property int dankIslandReserveHeight: 40 + property int dankIslandCompactHeight: 38 + property int dankIslandOuterGap: 4 + property int dankIslandHorizontalOffset: 0 + property string dankIslandInteractionMode: "hybrid" + property int dankIslandHoverOpenDelay: 220 + property int dankIslandHoverCloseDelay: 400 + property string dankIslandPalette: "default" + property bool dankIslandHighContrast: false + property bool dankIslandMediaClockVisible: true + property bool dankIslandNotificationExpand: false + property string dankIslandHomeMediaSlot: "left" + property string dankIslandHomeStatusSlot: "right" + property string dankIslandHomeWeatherSlot: "hidden" + property bool dankIslandHomeCompactTight: false + property string dankIslandBatteryStyle: "solid" + property bool dankIslandSatellitesEnabled: true + property string dankIslandSatellitePosition: "edges" + property int dankIslandSatelliteGap: 12 + property bool dankIslandReducedMotion: false + property real dankIslandSpringStiffness: 560 + property real dankIslandSpringDamping: 37 + property real dankIslandSpringMass: 1 property bool osdAlwaysShowValue: false property int osdPosition: SettingsData.Position.BottomCenter @@ -1585,7 +1622,13 @@ Singleton { }) function set(key, value) { + if (key === "dankIslandInteractionMode" && value !== "click" && value !== "hybrid") + value = "hybrid"; Spec.set(root, key, value, saveSettings, _hooks); + if (key === "dankIslandBarId" && value && frameEnabled) + set("frameEnabled", false); + if (key === "frameEnabled" && value && dankIslandBarId) + set("dankIslandBarId", ""); } function loadSettings() { @@ -1639,6 +1682,10 @@ Singleton { Store.parse(root, obj); + // set() enforces this pair, but a hand-edited settings.json bypasses set() entirely. + if (frameEnabled && dankIslandBarId) + dankIslandBarId = ""; + if (obj?.directionalAnimationMode === 3 && frameMode !== "connected") frameMode = "connected"; @@ -2044,8 +2091,14 @@ Singleton { return showSeconds ? "h:mm:ss AP" : "h:mm AP"; } + function getEffectiveDateFormat(fallback) { + if (clockDateFormat && clockDateFormat.length > 0) + return clockDateFormat; + return fallback || "ddd d"; + } + function initializeListModels() { - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); if (defaultBar) { Lists.init(leftWidgetsModel, centerWidgetsModel, rightWidgetsModel, defaultBar.leftWidgets, defaultBar.centerWidgets, defaultBar.rightWidgets); } @@ -2059,7 +2112,7 @@ Singleton { function getPopupTriggerPosition(pos, screen, barThickness, widgetWidth, barSpacing, barPosition, barConfig) { const relativeX = pos.x; const relativeY = pos.y; - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); const spacing = barSpacing !== undefined ? barSpacing : (defaultBar?.spacing ?? 4); const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top); const rawBottomGap = barConfig ? (barConfig.bottomGap !== undefined ? barConfig.bottomGap : (defaultBar?.bottomGap ?? 0)) : (defaultBar?.bottomGap ?? 0); @@ -2119,7 +2172,7 @@ Singleton { } const enabledBars = getEnabledBarConfigs(); - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); const position = barPosition !== undefined ? barPosition : (defaultBar?.position ?? SettingsData.Position.Top); let topBar = 0; let bottomBar = 0; @@ -2162,6 +2215,16 @@ Singleton { } } + // The island is not a bar, but it is chrome: popouts still have to clear its strip. + if (!isIslandBarConfig(barConfig)) { + const islandTop = dankIslandEdgeOffset(screen, "top"); + const islandBottom = dankIslandEdgeOffset(screen, "bottom"); + if (islandTop > 0) + topBar = Math.max(topBar, islandTop); + if (islandBottom > 0) + bottomBar = Math.max(bottomBar, islandBottom); + } + return { "topBar": topBar, "bottomBar": bottomBar, @@ -2181,7 +2244,7 @@ Singleton { }; } - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); const wingRadius = (defaultBar?.gothCornerRadiusOverride ?? false) ? (defaultBar?.gothCornerRadiusValue ?? 12) : Theme.cornerRadius; const wingSize = (defaultBar?.gothCornersEnabled ?? false) ? Math.max(0, wingRadius) : 0; const screenWidth = screen.width; @@ -2306,7 +2369,7 @@ Singleton { } function setBarIpcReveal(barId, revealed) { - if (!barId) + if (!barId || barId === dankIslandBarId) return; const nextRevealed = !!revealed; if (!!barIpcRevealStates[barId] === nextRevealed) @@ -2340,6 +2403,8 @@ Singleton { const index = configs.findIndex(cfg => cfg.id === barId); if (index === -1) return; + if (isIslandBarConfig(configs[index]) && updates.position !== undefined && updates.position !== SettingsData.Position.Top && updates.position !== SettingsData.Position.Bottom) + delete updates.position; const positionChanged = updates.position !== undefined && configs[index].position !== updates.position; if (updates.autoHide === false || updates.visible === false) setBarIpcReveal(barId, false); @@ -2358,6 +2423,8 @@ Singleton { return; const configs = barConfigs.filter(cfg => cfg.id !== barId); barConfigs = configs; + if (dankIslandBarId === barId) + dankIslandBarId = ""; if (connectedFrameBarStyleBackups?.[barId] !== undefined) { const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {})); delete nextBackups[barId]; @@ -2367,8 +2434,22 @@ Singleton { updateBarConfigs(); } + // Bar-kind instances only. The island reserves its own edge via dankIslandOwnsEdge. function getEnabledBarConfigs() { - return barConfigs.filter(cfg => cfg.enabled); + return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg)); + } + + function getBarKindConfigs() { + return barConfigs.filter(cfg => !isIslandBarConfig(cfg)); + } + + // Style and popup fallbacks never treat the island as "the bar". + function getPrimaryBarConfig() { + const bars = getBarKindConfigs(); + if (bars.length > 0) + return bars[0]; + const fallback = getBarConfig("default"); + return isIslandBarConfig(fallback) ? null : fallback; } function _sideToPosition(side) { @@ -2385,6 +2466,20 @@ Singleton { return -1; } + function positionToSide(pos) { + switch (pos) { + case SettingsData.Position.Top: + return "top"; + case SettingsData.Position.Bottom: + return "bottom"; + case SettingsData.Position.Left: + return "left"; + case SettingsData.Position.Right: + return "right"; + } + return ""; + } + // Check if a bar occupies the specified screen edge function barOccupiesSide(screen, side) { if (!screen) @@ -2507,6 +2602,35 @@ Singleton { return (bc?.showOnLastDisplay ?? false) && Quickshell.screens.length === 1; } + function isIslandBarConfig(bc) { + return !!bc && !!dankIslandBarId && bc.id === dankIslandBarId; + } + + function getIslandScreens() { + const cfg = islandBarConfig; + if (!cfg || !(cfg.enabled ?? false)) + return []; + return Quickshell.screens.filter(screen => barConfigCoversScreen(cfg, screen)); + } + + function dankIslandCoversScreen(screen) { + const cfg = islandBarConfig; + return !!screen && !!cfg && (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen); + } + + function dankIslandOwnsEdge(screen, edge) { + return dankIslandCoversScreen(screen) && dankIslandEdge === edge; + } + + // Painted strip thickness on `edge`, including when floating drops the exclusive zone. + function dankIslandEdgeOffset(screen, edge) { + return dankIslandOwnsEdge(screen, edge) ? dankIslandReservedStripHeight : 0; + } + + function dankIslandIsSoleBarForScreen(screen) { + return dankIslandCoversScreen(screen) && getActiveBarEdgesForScreen(screen).length === 0; + } + function getActiveBarEdgesForScreen(screen) { if (!screen) return []; @@ -2517,6 +2641,10 @@ Singleton { continue; if (!barConfigCoversScreen(bc, screen)) continue; + if (isIslandBarConfig(bc)) + continue; + if (dankIslandOwnsEdge(screen, positionToSide(bc.position ?? SettingsData.Position.Top))) + continue; switch (bc.position ?? 0) { case SettingsData.Position.Top: edges.push("top"); @@ -2545,6 +2673,10 @@ Singleton { continue; if (!barConfigCoversScreen(bc, screen)) continue; + if (isIslandBarConfig(bc)) + continue; + if (dankIslandOwnsEdge(screen, positionToSide(bc.position ?? SettingsData.Position.Top))) + continue; switch (bc.position ?? 0) { case SettingsData.Position.Top: edges.push("top"); @@ -2727,8 +2859,9 @@ Singleton { function setShowDock(enabled) { showDock = enabled; - const defaultBar = barConfigs[0] || getBarConfig("default"); - const barPos = defaultBar?.position ?? SettingsData.Position.Top; + const defaultBar = getPrimaryBarConfig(); + // -1 matches no Position, so island-only setups have no dock conflict. + const barPos = defaultBar ? (defaultBar.position ?? SettingsData.Position.Top) : -1; if (enabled && dockPosition === barPos) { if (barPos === SettingsData.Position.Top) { setDockPosition(SettingsData.Position.Bottom); @@ -2752,8 +2885,9 @@ Singleton { function setDockPosition(position) { dockPosition = position; - const defaultBar = barConfigs[0] || getBarConfig("default"); - const barPos = defaultBar?.position ?? SettingsData.Position.Top; + const defaultBar = getPrimaryBarConfig(); + // -1 matches no Position, so island-only setups have no dock conflict. + const barPos = defaultBar ? (defaultBar.position ?? SettingsData.Position.Top) : -1; if (position === SettingsData.Position.Bottom && barPos === SettingsData.Position.Bottom && showDock) { setDankBarPosition(SettingsData.Position.Top); } @@ -2771,7 +2905,7 @@ Singleton { } function setDankBarPosition(position) { - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); if (!defaultBar) return; if (position === SettingsData.Position.Bottom && dockPosition === SettingsData.Position.Bottom && showDock) { @@ -2796,7 +2930,7 @@ Singleton { } function setDankBarLeftWidgets(order) { - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); if (defaultBar) { updateBarConfig(defaultBar.id, { "leftWidgets": order @@ -2806,7 +2940,7 @@ Singleton { } function setDankBarCenterWidgets(order) { - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); if (defaultBar) { updateBarConfig(defaultBar.id, { "centerWidgets": order @@ -2816,7 +2950,7 @@ Singleton { } function setDankBarRightWidgets(order) { - const defaultBar = barConfigs[0] || getBarConfig("default"); + const defaultBar = getPrimaryBarConfig(); if (defaultBar) { updateBarConfig(defaultBar.id, { "rightWidgets": order diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index c7c513d77..352a9514b 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -22,7 +22,7 @@ Singleton { readonly property real popupDistance: { if (typeof SettingsData === "undefined") return 4; - const defaultBar = SettingsData.barConfigs[0] || SettingsData.getBarConfig("default"); + const defaultBar = SettingsData.getPrimaryBarConfig(); if (!defaultBar) return 4; const useAuto = defaultBar.popupGapsAuto ?? true; @@ -390,6 +390,8 @@ Singleton { property color surfaceContainer: currentThemeData.surfaceContainer property color surfaceContainerHigh: currentThemeData.surfaceContainerHigh property color surfaceContainerHighest: currentThemeData.surfaceContainerHighest || surfaceContainerHigh + property color surfaceBright: currentThemeData.surfaceBright || (isLightMode ? surface : surfaceContainerHighest) + property color surfaceDim: currentThemeData.surfaceDim || (isLightMode ? surfaceContainer : background) property color primaryContainer: currentThemeData.primaryContainer || blend(surfaceContainerHigh, primary, 0.45) property color secondaryContainer: currentThemeData.secondaryContainer || blend(surfaceContainerHigh, secondary, 0.35) property color tertiaryContainer: currentThemeData.tertiaryContainer || blend(surfaceContainerHigh, tertiary, 0.35) diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index daf8e2882..48ade00d5 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -520,6 +520,32 @@ var SPEC = { notificationRules: { def: [] }, notificationDndAllowCritical: { def: true }, notificationFocusedMonitor: { def: false }, + dankIslandBarId: { def: "", onChange: "updateBarConfigs" }, + dankIslandFloating: { def: false }, + dankIslandUseOverlayLayer: { def: false }, + dankIslandReserveHeight: { def: 40 }, + dankIslandCompactHeight: { def: 38 }, + dankIslandOuterGap: { def: 4 }, + dankIslandHorizontalOffset: { def: 0 }, + dankIslandInteractionMode: { def: "hybrid" }, + dankIslandHoverOpenDelay: { def: 220 }, + dankIslandHoverCloseDelay: { def: 400 }, + dankIslandPalette: { def: "default" }, + dankIslandHighContrast: { def: false }, + dankIslandMediaClockVisible: { def: true }, + dankIslandNotificationExpand: { def: false }, + dankIslandHomeMediaSlot: { def: "left" }, + dankIslandHomeStatusSlot: { def: "right" }, + dankIslandHomeWeatherSlot: { def: "hidden" }, + dankIslandHomeCompactTight: { def: false }, + dankIslandBatteryStyle: { def: "solid" }, + dankIslandSatellitesEnabled: { def: true }, + dankIslandSatellitePosition: { def: "edges" }, + dankIslandSatelliteGap: { def: 12 }, + dankIslandReducedMotion: { def: false }, + dankIslandSpringStiffness: { def: 560 }, + dankIslandSpringDamping: { def: 37 }, + dankIslandSpringMass: { def: 1 }, osdAlwaysShowValue: { def: false }, osdPosition: { def: 5 }, diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 00d77494b..9737fb531 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -13,6 +13,7 @@ import qs.Modals.DankLauncherV2 import qs.Modules import qs.Modules.AppDrawer import qs.Modules.DankDash +import qs.Modules.DankIsland import qs.Modules.ControlCenter import qs.Modules.Dock import qs.Modules.Lock @@ -37,6 +38,18 @@ Item { property bool osdSurfacesLoaded: false property int pendingOsdResumeReloads: 0 + readonly property var dankIslandScreens: SettingsData.getIslandScreens() + readonly property var notificationPopupScreens: { + const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications"); + return root.withoutDankIslandScreens(screens); + } + readonly property var legacySystemLevelOsdScreens: root.withoutDankIslandScreens(SettingsData.getFilteredScreens("osd")) + + function withoutDankIslandScreens(screens) { + if (!SettingsData.dankIslandEnabled) + return screens; + return screens.filter(screen => root.dankIslandScreens.indexOf(screen) === -1); + } function recreateOsdSurfaces() { OSDManager.currentOSDsByScreen = ({}); @@ -386,11 +399,27 @@ Item { } Variants { - model: SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications") + model: root.notificationPopupScreens delegate: NotificationPopupManager {} } + Loader { + id: dankIslandLoader + + property var modalRef: colorPickerModal + property LazyLoader powerModalLoaderRef: powerMenuModalLoader + + active: SettingsData.dankIslandEnabled + asynchronous: false + sourceComponent: DankIsland { + screenModel: root.dankIslandScreens + colorPickerModal: dankIslandLoader.modalRef + powerMenuModalLoader: dankIslandLoader.powerModalLoaderRef + onLockRequested: lock.activate() + } + } + LazyLoader { id: controlCenterLoader @@ -1159,7 +1188,7 @@ Item { sourceComponent: Component { Item { Variants { - model: SettingsData.getFilteredScreens("osd") + model: root.legacySystemLevelOsdScreens delegate: VolumeOSD {} } @@ -1183,7 +1212,7 @@ Item { } Variants { - model: SettingsData.getFilteredScreens("osd") + model: root.legacySystemLevelOsdScreens delegate: BrightnessOSD {} } diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 8e0ade880..0ef88b336 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -217,6 +217,8 @@ Item { IpcHandler { function open(): string { + if (PopoutService.routeControlCenterToIsland(null, "", false)) + return "CONTROL_CENTER_OPEN_SUCCESS"; const bar = root.getPreferredBar("controlCenterButtonRef"); if (bar) { bar.triggerControlCenter(); @@ -226,6 +228,8 @@ Item { } function hide(): string { + if (PopoutService.dankIslandRouter?.closeControlCenter?.() === true) + return "CONTROL_CENTER_HIDE_SUCCESS"; if (root.controlCenterLoader.item && root.controlCenterLoader.item.shouldBeVisible) { root.controlCenterLoader.item.close(); return "CONTROL_CENTER_HIDE_SUCCESS"; @@ -238,6 +242,8 @@ Item { root.controlCenterLoader.item.close(); return "CONTROL_CENTER_TOGGLE_SUCCESS"; } + if (PopoutService.routeControlCenterToIsland(null, "", true)) + return "CONTROL_CENTER_TOGGLE_SUCCESS"; const bar = root.getPreferredBar("controlCenterButtonRef"); if (bar) { @@ -248,6 +254,8 @@ Item { } function status(): string { + if (PopoutService.islandControlCenterOpen) + return "visible"; return (root.controlCenterLoader.item && root.controlCenterLoader.item.shouldBeVisible) ? "visible" : "hidden"; } @@ -303,11 +311,22 @@ Item { } function _openDash(tab, position) { + const tabId = _resolveTabId(tab); + if (!position) { + if (tabId === "overview" && PopoutService.routeOverviewToIsland(null, false)) + return true; + if (tabId === "media" && PopoutService.routeMediaToIsland(null, false)) + return true; + if (tabId === "wallpaper" && PopoutService.routeWallpaperToIsland(null, false)) + return true; + if (tabId === "weather" && PopoutService.routeWeatherToIsland(null, false)) + return true; + } + const bar = _dashBar(position); if (!bar) return false; - const tabId = _resolveTabId(tab); const dash = root.dankDashPopoutLoader.item; if (dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name) { if (position && bar.positionDash) @@ -327,10 +346,22 @@ Item { return true; } + const tabId = _resolveTabId(tab); + if (!position) { + if (tabId === "overview" && PopoutService.routeOverviewToIsland(null, true)) + return true; + if (tabId === "media" && PopoutService.routeMediaToIsland(null, true)) + return true; + if (tabId === "wallpaper" && PopoutService.routeWallpaperToIsland(null, true)) + return true; + if (tabId === "weather" && PopoutService.routeWeatherToIsland(null, true)) + return true; + } + const bar = _dashBar(position); if (!bar) return false; - return bar.triggerDashTab(_resolveTabId(tab), position); + return bar.triggerDashTab(tabId, position); } function resolveTabIndex(tab: string): int { @@ -346,6 +377,8 @@ Item { } function close(): string { + if (PopoutService.closeIslandHome() || PopoutService.closeIslandMedia() || PopoutService.closeIslandWallpaper() || PopoutService.closeIslandWeather()) + return "DASH_CLOSE_SUCCESS"; if (root.dankDashPopoutLoader.item) { root.dankDashPopoutLoader.item.dashVisible = false; return "DASH_CLOSE_SUCCESS"; @@ -752,6 +785,8 @@ Item { // ! TODO - remove for v1.6 IpcHandler { function wallpaper(): string { + if (PopoutService.routeWallpaperToIsland(null, true)) + return "WARN; deprecated, use dms ipc call dash toggle wallpaper instead"; const bar = root.getPreferredBar("clockButtonRef") || root.getPreferredBar(); if (bar) { bar.triggerWallpaperBrowser(); @@ -788,6 +823,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { visible: true }); @@ -801,6 +838,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { visible: false }); @@ -814,6 +853,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { visible: !barConfig.visible }); @@ -837,6 +878,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { autoHide: true }); @@ -850,6 +893,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { autoHide: false }); @@ -863,6 +908,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { autoHide: !barConfig.autoHide }); @@ -876,6 +923,8 @@ Item { } = getBarConfig(selector, value); if (error) return error; + if (SettingsData.isIslandBarConfig(barConfig)) + return "BAR_IS_ISLAND"; if (!barConfig.autoHide) return "BAR_AUTO_HIDE_DISABLED"; if (!(barConfig.visible ?? true)) { @@ -916,6 +965,9 @@ Item { const posValue = positionMap[position.toLowerCase()]; if (posValue === undefined) return "BAR_INVALID_POSITION"; + // The island silhouette only anchors top or bottom. + if (SettingsData.isIslandBarConfig(barConfig) && posValue !== SettingsData.Position.Top && posValue !== SettingsData.Position.Bottom) + return "BAR_IS_ISLAND"; SettingsData.updateBarConfig(barConfig.id, { position: posValue }); diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index 3561690d3..a5b5c1652 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -64,7 +64,8 @@ Item { } readonly property bool useSpotlightBackend: !FrameTransitionState.effectiveConnectedFrameModeActive && SettingsData.launcherStyle === "spotlight" - readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (FrameTransitionState.effectiveConnectedFrameModeActive ? connectedComp : standaloneComp) + readonly property bool useIslandBackend: !FrameTransitionState.effectiveConnectedFrameModeActive && SettingsData.launcherStyle === "island" + readonly property var _desiredBackend: FrameTransitionState.effectiveConnectedFrameModeActive ? connectedComp : useIslandBackend ? islandComp : useSpotlightBackend ? spotlightComp : standaloneComp property var _resolvedBackend: null Component.onCompleted: _resolvedBackend = _desiredBackend @@ -111,6 +112,11 @@ Item { DankLauncherV2ModalSpotlight {} } + Component { + id: islandComp + DankLauncherV2ModalIsland {} + } + function _wireBackend(it) { if (!it) return; diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalIsland.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalIsland.qml new file mode 100644 index 000000000..da2e5b236 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalIsland.qml @@ -0,0 +1,122 @@ +import QtQuick +import qs.Services + +Item { + id: root + + property var modalHandle: root + property bool triggerUsesOverlayLayer: false + property bool usingFallback: false + property bool _islandWasOpen: false + + readonly property var log: Log.scoped("DankLauncherV2ModalIsland") + readonly property var router: PopoutService.dankIslandRouter + readonly property bool spotlightOpen: usingFallback ? fallback.spotlightOpen : (router?.launcherOpen ?? false) + readonly property bool isClosing: usingFallback ? fallback.isClosing : false + readonly property bool keyboardActive: usingFallback ? fallback.keyboardActive : spotlightOpen + readonly property bool contentVisible: usingFallback ? fallback.contentVisible : spotlightOpen + readonly property var spotlightContent: usingFallback ? fallback.spotlightContent : null + readonly property bool openedFromOverview: usingFallback ? fallback.openedFromOverview : false + readonly property var effectiveScreen: usingFallback ? fallback.effectiveScreen : null + readonly property real screenWidth: usingFallback ? fallback.screenWidth : 1920 + readonly property real screenHeight: usingFallback ? fallback.screenHeight : 1080 + readonly property real dpr: usingFallback ? fallback.dpr : 1 + readonly property int modalWidth: usingFallback ? fallback.modalWidth : 680 + readonly property int modalHeight: usingFallback ? fallback.modalHeight : 560 + readonly property real modalX: usingFallback ? fallback.modalX : 0 + readonly property real modalY: usingFallback ? fallback.modalY : 0 + readonly property bool frameOwnsConnectedChrome: false + readonly property string resolvedConnectedBarSide: "" + readonly property bool launcherArcExtenderActive: false + + signal dialogClosed + + function _openIsland(query, mode) { + const accepted = router?.openLauncher?.(query || "", mode || "") ?? false; + if (accepted) { + usingFallback = false; + return true; + } + log.warn("No DankIsland is routed to the focused screen; falling back to Spotlight"); + usingFallback = true; + return false; + } + + function show() { + if (!root._openIsland("", "")) + fallback.show(); + } + + function showWithQuery(query) { + if (!root._openIsland(query, "")) + fallback.showWithQuery(query); + } + + function showWithMode(mode) { + if (!root._openIsland("", mode)) + fallback.showWithMode(mode); + } + + function hide() { + if (usingFallback) { + fallback.hide(); + return; + } + router?.closeLauncher?.(); + } + + function toggle() { + if (usingFallback && fallback.spotlightOpen) { + fallback.toggle(); + return; + } + const accepted = router?.toggleLauncher?.("", "") ?? false; + if (!accepted) { + usingFallback = true; + fallback.toggle(); + } + } + + function toggleWithQuery(query) { + if (spotlightOpen) { + hide(); + return; + } + showWithQuery(query); + } + + function toggleWithMode(mode) { + if (spotlightOpen) { + hide(); + return; + } + showWithMode(mode); + } + + onSpotlightOpenChanged: { + if (usingFallback) + return; + if (spotlightOpen) { + _islandWasOpen = true; + } else if (_islandWasOpen) { + _islandWasOpen = false; + dialogClosed(); + } + } + + DankLauncherV2ModalSpotlight { + id: fallback + + modalHandle: root.modalHandle + triggerUsesOverlayLayer: root.triggerUsesOverlayLayer + } + + Connections { + target: fallback + + function onDialogClosed() { + root.usingFallback = false; + root.dialogClosed(); + } + } +} diff --git a/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml b/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml index 3b2aab3d7..3a7d01039 100644 --- a/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml +++ b/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml @@ -10,16 +10,21 @@ FocusScope { property var parentModal: null property alias searchField: searchInput - property alias controller: searchController + property var controllerOverride: null + readonly property var controller: controllerOverride ?? searchController readonly property alias activeContextMenu: contextMenu property var transientSurfaceTracker: null + property bool showResultsWithoutQuery: false + property bool suspendSearchUpdates: false + property real maxResultsHeight: 0 - readonly property bool _hasQuery: searchInput.text.length > 0 + readonly property bool _hasQuery: root.showResultsWithoutQuery || searchInput.text.length > 0 readonly property real _searchBarH: 56 readonly property real _searchAreaH: _searchBarH + readonly property alias searchAreaHeight: root._searchAreaH readonly property real _statusH: 92 readonly property real _rowH: 64 - readonly property real _maxResultsH: Math.min(430, (parentModal?.screenHeight ?? 900) * 0.55) + readonly property real _maxResultsH: root.maxResultsHeight > 0 ? root.maxResultsHeight : Math.min(430, (parentModal?.screenHeight ?? 900) * 0.55) readonly property var _resultRows: _buildRows() readonly property real _resultsContentH: _resultRows.length > 0 ? _resultRows.length * _rowH + resultsList.bottomInset : _statusH readonly property real _resultsH: _hasQuery ? Math.min(_resultsContentH, _maxResultsH) : 0 @@ -58,14 +63,20 @@ FocusScope { resultsList.resetScroll(); } + function resetSearch() { + root.controller.reset(); + if (root.showResultsWithoutQuery) + root.controller.performSearch(); + } + function closeTransientUi() { transientSurfaceTracker?.closeAll?.(); root.enabled = true; } function _buildRows() { - const flat = searchController.flatModel || []; - const sections = searchController.sections || []; + const flat = root.controller.flatModel || []; + const sections = root.controller.sections || []; const rows = []; const seen = {}; for (let i = 0; i < flat.length; i++) { @@ -106,7 +117,7 @@ FocusScope { switch (event.key) { case Qt.Key_Escape: - if (searchController.clearPluginFilter()) { + if (root.controller.clearPluginFilter()) { event.accepted = true; return; } @@ -115,12 +126,12 @@ FocusScope { return; case Qt.Key_Backspace: if (searchInput.text.length === 0) { - if (searchController.clearPluginFilter()) { + if (root.controller.clearPluginFilter()) { event.accepted = true; return; } - if (searchController.autoSwitchedToFiles) { - searchController.restorePreviousMode(); + if (root.controller.autoSwitchedToFiles) { + root.controller.restorePreviousMode(); event.accepted = true; return; } @@ -128,31 +139,31 @@ FocusScope { event.accepted = false; return; case Qt.Key_Down: - searchController.selectNext(); + root.controller.selectNext(); event.accepted = true; return; case Qt.Key_Up: - searchController.selectPrevious(); + root.controller.selectPrevious(); event.accepted = true; return; case Qt.Key_PageDown: - searchController.selectPageDown(7); + root.controller.selectPageDown(7); event.accepted = true; return; case Qt.Key_PageUp: - searchController.selectPageUp(7); + root.controller.selectPageUp(7); event.accepted = true; return; case Qt.Key_J: if (hasCtrl) { - searchController.selectNext(); + root.controller.selectNext(); event.accepted = true; return; } break; case Qt.Key_K: if (hasCtrl) { - searchController.selectPrevious(); + root.controller.selectPrevious(); event.accepted = true; return; } @@ -168,45 +179,45 @@ FocusScope { case Qt.Key_Return: case Qt.Key_Enter: if (event.modifiers & Qt.ShiftModifier) { - searchController.pasteSelected(); + root.controller.pasteSelected(); } else { - searchController.executeSelected(); + root.controller.executeSelected(); } event.accepted = true; return; case Qt.Key_Menu: case Qt.Key_F10: - if (contextMenu.hasContextMenuActions(searchController.selectedItem)) { + if (contextMenu.hasContextMenuActions(root.controller.selectedItem)) { const scenePos = resultsList.getSelectedItemPosition(); - _showContextMenu(searchController.selectedItem, scenePos.x, scenePos.y, true); + _showContextMenu(root.controller.selectedItem, scenePos.x, scenePos.y, true); event.accepted = true; return; } break; case Qt.Key_1: if (hasCtrl || hasAlt) { - searchController.setMode("all"); + root.controller.setMode("all"); event.accepted = true; return; } break; case Qt.Key_2: if (hasCtrl || hasAlt) { - searchController.setMode("apps"); + root.controller.setMode("apps"); event.accepted = true; return; } break; case Qt.Key_3: if (hasCtrl || hasAlt) { - searchController.setMode("files"); + root.controller.setMode("files"); event.accepted = true; return; } break; case Qt.Key_4: if (hasCtrl || hasAlt) { - searchController.setMode("plugins"); + root.controller.setMode("plugins"); event.accepted = true; return; } @@ -218,19 +229,15 @@ FocusScope { Controller { id: searchController - active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true + active: !root.controllerOverride && (root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true) viewModeContext: "spotlight" forceLinearNavigation: true - - onItemExecuted: { - root.parentModal?.hide(); - } } LauncherContextMenu { id: contextMenu parent: root - controller: searchController + controller: root.controller searchField: searchInput parentHandler: root allowEditActions: false @@ -257,7 +264,13 @@ FocusScope { } Connections { - target: searchController + target: root.controller + + function onItemExecuted() { + root.parentModal?.hide(); + if (SettingsData.spotlightCloseNiriOverview && NiriService.inOverview) + NiriService.toggleOverview(); + } function onModeChanged(mode, userInitiated) { if (!userInitiated || !SettingsData.rememberLastMode) return; @@ -301,7 +314,7 @@ FocusScope { DankIcon { anchors.centerIn: parent - name: searchController.activePluginId ? "extension" : searchController.searchMode === "files" ? "folder" : "search" + name: root.controller.activePluginId ? "extension" : root.controller.searchMode === "files" ? "folder" : "search" size: 20 color: searchInput.getActiveFocus() ? Theme.primary : Theme.surfaceVariantText } @@ -375,7 +388,6 @@ FocusScope { visible: searchInput.text.length > 0 onClicked: { searchInput.text = ""; - searchController.reset(); root._focusSearch(); } } @@ -397,10 +409,12 @@ FocusScope { keyForwardTargets: [searchKeyHandler] onTextChanged: { + if (root.suspendSearchUpdates) + return; if (text.length > 0) { - searchController.setSearchQuery(text); + root.controller.setSearchQuery(text); } else { - searchController.reset(); + root.resetSearch(); } } @@ -433,7 +447,7 @@ FocusScope { SpotlightResultsList { id: resultsList anchors.fill: parent - controller: searchController + controller: root.controller hasQuery: root._hasQuery rows: root._resultRows @@ -463,7 +477,7 @@ FocusScope { ] function _isCategorySelected(cat) { - return searchController.searchMode === cat.mode; + return root.controller.searchMode === cat.mode; } function _cycleCategory(reverse) { @@ -482,9 +496,9 @@ FocusScope { const cat = _categoryModel[index]; if (!cat) return; - searchController.setMode(cat.mode, false); + root.controller.setMode(cat.mode, false); if (root._hasQuery) - searchController.setSearchQuery(searchInput.text); + root.controller.setSearchQuery(searchInput.text); root._focusSearch(); } } diff --git a/quickshell/Modals/NotificationModal.qml b/quickshell/Modals/NotificationModal.qml index c94463779..c8a939a6b 100644 --- a/quickshell/Modals/NotificationModal.qml +++ b/quickshell/Modals/NotificationModal.qml @@ -121,17 +121,24 @@ DankModal { } IpcHandler { + // DankIsland owns this trigger whenever it is the sole bar for the screen. function open(): string { + if (PopoutService.routeNotificationCenterToIsland(null, false)) + return "NOTIFICATION_ISLAND_OPEN_SUCCESS"; notificationModal.show(); return "NOTIFICATION_MODAL_OPEN_SUCCESS"; } function close(): string { + if (PopoutService.closeIslandNotificationCenter()) + return "NOTIFICATION_ISLAND_CLOSE_SUCCESS"; notificationModal.hide(); return "NOTIFICATION_MODAL_CLOSE_SUCCESS"; } function toggle(): string { + if (PopoutService.routeNotificationCenterToIsland(null, true)) + return "NOTIFICATION_ISLAND_TOGGLE_SUCCESS"; notificationModal.toggle(); return "NOTIFICATION_MODAL_TOGGLE_SUCCESS"; } diff --git a/quickshell/Modals/PowerMenuModal.qml b/quickshell/Modals/PowerMenuModal.qml index 3e09b9273..279b7da6d 100644 --- a/quickshell/Modals/PowerMenuModal.qml +++ b/quickshell/Modals/PowerMenuModal.qml @@ -310,8 +310,11 @@ DankModal { positioning: parentBounds.width > 0 ? "custom" : "center" customPosition: { if (parentBounds.width > 0) { - const effectiveBarThickness = Math.max(26 + (SettingsData.barConfigs[0]?.innerPadding ?? 4) * 0.6 + (SettingsData.barConfigs[0]?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (SettingsData.barConfigs[0]?.innerPadding ?? 4))); - const barExclusionZone = effectiveBarThickness + (SettingsData.barConfigs[0]?.spacing ?? 4) + (SettingsData.barConfigs[0]?.bottomGap ?? 0); + const bar = SettingsData.getPrimaryBarConfig(); + const barPadding = bar?.innerPadding ?? 4; + const effectiveBarThickness = Math.max(26 + barPadding * 0.6 + barPadding + 4, Theme.barHeight - 4 - (8 - barPadding)); + const barExclusionZone = effectiveBarThickness + (bar?.spacing ?? 4) + (bar?.bottomGap ?? 0); + const barPosition = bar?.position ?? SettingsData.Position.Top; const screenW = parentScreen?.width ?? 1920; const screenH = parentScreen?.height ?? 1080; const margin = Theme.spacingL; @@ -319,8 +322,10 @@ DankModal { let targetX = parentBounds.x + (parentBounds.width - modalWidth) / 2; let targetY = parentBounds.y + (parentBounds.height - modalHeight) / 2; - const minY = (SettingsData.barConfigs[0]?.position ?? SettingsData.Position.Top) === SettingsData.Position.Top ? barExclusionZone + margin : margin; - const maxY = (SettingsData.barConfigs[0]?.position ?? SettingsData.Position.Top) === SettingsData.Position.Bottom ? screenH - modalHeight - barExclusionZone - margin : screenH - modalHeight - margin; + const topChrome = Math.max(bar && barPosition === SettingsData.Position.Top ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "top")); + const bottomChrome = Math.max(bar && barPosition === SettingsData.Position.Bottom ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "bottom")); + const minY = topChrome + margin; + const maxY = screenH - modalHeight - bottomChrome - margin; targetY = Math.max(minY, Math.min(maxY, targetY)); diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index bf084ea06..4d3c61940 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -683,6 +683,22 @@ FocusScope { } } + Loader { + id: dankIslandLoader + anchors.fill: parent + active: root.currentIndex === 46 + visible: active + focus: active + sourceComponent: DankIslandTab { + parentModal: root.parentModal + } + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } + Loader { id: usersLoader anchors.fill: parent diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 83e26251d..25bf0edd0 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -114,7 +114,7 @@ Rectangle { }, { "id": "dankbar", - "text": I18n.tr("Dank Bar"), + "text": I18n.tr("Primary Bar"), "icon": "toolbar", "children": [ { @@ -146,6 +146,12 @@ Rectangle { "text": I18n.tr("Frame"), "icon": "frame_source", "tabIndex": 33 + }, + { + "id": "dank_island", + "text": I18n.tr("Island"), + "icon": "view_in_ar", + "tabIndex": 46 } ] }, @@ -157,7 +163,7 @@ Rectangle { "children": [ { "id": "dank_dash", - "text": I18n.tr("Dank Dash"), + "text": I18n.tr("Dashboard"), "icon": "space_dashboard", "tabIndex": 43 }, diff --git a/quickshell/Modules/ControlCenter/Components/HeaderPane.qml b/quickshell/Modules/ControlCenter/Components/HeaderPane.qml index 4e4ac6242..d883d180d 100644 --- a/quickshell/Modules/ControlCenter/Components/HeaderPane.qml +++ b/quickshell/Modules/ControlCenter/Components/HeaderPane.qml @@ -10,11 +10,14 @@ Rectangle { LayoutMirroring.childrenInherit: true property bool editMode: false + // Hosts that morph in place reuse the header's dead space as their close target. + property bool tapToClose: false signal powerButtonClicked signal lockRequested signal editModeToggled signal settingsButtonClicked + signal headerTapped Component.onCompleted: DgopService.addRef("system") Component.onDestruction: DgopService.removeRef("system") @@ -25,6 +28,13 @@ Rectangle { border.color: Theme.outlineMedium border.width: Theme.layerOutlineWidth + MouseArea { + anchors.fill: parent + enabled: root.tapToClose + acceptedButtons: Qt.LeftButton + onClicked: root.headerTapped() + } + Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter diff --git a/quickshell/Modules/ControlCenter/ControlCenterContent.qml b/quickshell/Modules/ControlCenter/ControlCenterContent.qml new file mode 100644 index 000000000..a352f0bf5 --- /dev/null +++ b/quickshell/Modules/ControlCenter/ControlCenterContent.qml @@ -0,0 +1,175 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.ControlCenter.Components +import qs.Modules.ControlCenter.Models +import qs.Modules.ControlCenter.Details +import qs.Widgets + +// Host-agnostic Control Center body; popout and island both host this. +Rectangle { + id: root + + required property var host + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + readonly property real targetImplicitHeight: { + let total = Theme.spacingL + headerPane.implicitHeight + Theme.spacingS + widgetGrid.targetImplicitHeight; + if (editControls.visible) + total += Theme.spacingS + editControls.height; + return total + Theme.spacingL; + } + property alias bluetoothCodecSelector: bluetoothCodecSelector + property alias audioPortSelector: audioPortSelector + + implicitHeight: targetImplicitHeight + color: "transparent" + clip: true + + WidgetModel { + id: widgetModel + } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.6) + radius: parent.radius + visible: root.host.powerMenuOpen + z: 5000 + + Behavior on opacity { + enabled: !Theme.isDirectionalEffect + NumberAnimation { + duration: Theme.shortDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: root.host.shouldBeVisible ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + } + } + } + + DankFlickable { + id: contentFlickable + + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: Math.max(height, mainColumn.implicitHeight + Theme.spacingM) + interactive: contentHeight > height + + Column { + id: mainColumn + + width: contentFlickable.width - Theme.spacingL * 2 + x: Theme.spacingL + y: Theme.spacingL + spacing: Theme.spacingS + + HeaderPane { + id: headerPane + + width: parent.width + editMode: root.host.editMode + tapToClose: root.host.headerTogglesClose ?? false + onHeaderTapped: root.host.close() + onEditModeToggled: root.host.editMode = !root.host.editMode + onPowerButtonClicked: { + const loader = root.host.powerMenuModalLoader; + if (!loader) + return; + loader.active = true; + if (loader.item) { + const bounds = Qt.rect(root.host.alignedX, root.host.alignedY, root.host.popupWidth, root.host.popupHeight); + loader.item.openFromControlCenter(bounds, root.host.screen); + } + } + onLockRequested: { + root.host.close(); + root.host.lockRequested(); + } + onSettingsButtonClicked: root.host.close() + } + + DragDropGrid { + id: widgetGrid + + width: parent.width + editMode: root.host.editMode + maxPopoutHeight: { + const screenHeight = (root.host.triggerScreen?.height ?? 1080); + return screenHeight - 100 - Theme.spacingL - headerPane.implicitHeight - Theme.spacingS; + } + expandedSection: root.host.expandedSection + expandedWidgetIndex: root.host.expandedWidgetIndex + expandedWidgetData: root.host.expandedWidgetData + model: widgetModel + bluetoothCodecSelector: bluetoothCodecSelector + audioPortSelector: audioPortSelector + colorPickerModal: root.host.colorPickerModal + screenName: root.host.triggerScreen?.name || "" + screenModel: root.host.triggerScreen?.model || "" + parentScreen: root.host.triggerScreen + onExpandClicked: (widgetData, globalIndex) => { + root.host.expandedWidgetIndex = globalIndex; + root.host.expandedWidgetData = widgetData; + if (widgetData.id === "diskUsage") { + root.host.toggleSection("diskUsage_" + (widgetData.instanceId || "default")); + } else if (widgetData.id === "brightnessSlider") { + root.host.toggleSection("brightnessSlider_" + (widgetData.instanceId || "default")); + } else { + root.host.toggleSection(widgetData.id); + } + } + onRemoveWidget: index => widgetModel.removeWidget(index) + onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex) + onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index) + onCollapseRequested: root.host.collapseAll() + onConfigRequested: (idx, data, anchor) => widgetConfigOverlay.open(idx, data, anchor) + } + + EditControls { + id: editControls + + width: parent.width + visible: root.host.editMode + popupScreen: root.host.screen + popoutX: root.host.alignedX + popoutY: root.host.alignedY + popoutWidth: root.host.alignedWidth + popoutHeight: root.host.alignedHeight + availableWidgets: { + if (!root.host.editMode) + return []; + const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id); + const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets()); + return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id)); + } + onAddWidget: widgetId => widgetModel.addWidget(widgetId) + onResetToDefault: () => widgetModel.resetToDefault() + onClearAll: () => widgetModel.clearAll() + } + } + } + + BluetoothCodecSelector { + id: bluetoothCodecSelector + + anchors.fill: parent + z: 10000 + } + + AudioPortSelector { + id: audioPortSelector + + anchors.fill: parent + z: 10000 + } + + WidgetConfigOverlay { + id: widgetConfigOverlay + + anchors.fill: parent + } +} diff --git a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml index 567bb0dcd..75fe161b4 100644 --- a/quickshell/Modules/ControlCenter/ControlCenterPopout.qml +++ b/quickshell/Modules/ControlCenter/ControlCenterPopout.qml @@ -4,8 +4,6 @@ import qs.Common import qs.Modules.ControlCenter.Details import qs.Services import qs.Widgets -import qs.Modules.ControlCenter.Components -import qs.Modules.ControlCenter.Models import "./utils/state.js" as StateUtils DankPopout { @@ -34,8 +32,9 @@ DankPopout { const item = contentLoader.item; if (!item) return 400; + // The content now reports its own insets, so no popout-side padding. const naturalHeight = item.targetImplicitHeight !== undefined ? item.targetImplicitHeight : item.implicitHeight; - return Math.max(300, naturalHeight + 20); + return Math.max(300, naturalHeight); } function updateTargetPopupHeight() { @@ -162,160 +161,9 @@ DankPopout { } } - WidgetModel { - id: widgetModel - } - content: Component { - Rectangle { - id: controlContent - - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true - - readonly property real targetImplicitHeight: { - let total = headerPane.implicitHeight + Theme.spacingS + widgetGrid.targetImplicitHeight; - if (editControls.visible) - total += Theme.spacingS + editControls.height; - return total + Theme.spacingM; - } - implicitHeight: targetImplicitHeight - property alias bluetoothCodecSelector: bluetoothCodecSelector - property alias audioPortSelector: audioPortSelector - - color: "transparent" - clip: true - - Rectangle { - anchors.fill: parent - color: Qt.rgba(0, 0, 0, 0.6) - radius: parent.radius - visible: root.powerMenuOpen - z: 5000 - - Behavior on opacity { - enabled: !Theme.isDirectionalEffect - NumberAnimation { - duration: Theme.shortDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: root.shouldBeVisible ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve - } - } - } - - DankFlickable { - id: contentFlickable - anchors.fill: parent - clip: true - contentWidth: width - contentHeight: Math.max(height, mainColumn.implicitHeight + Theme.spacingM) - interactive: contentHeight > height - - Column { - id: mainColumn - width: contentFlickable.width - Theme.spacingL * 2 - x: Theme.spacingL - y: Theme.spacingL - spacing: Theme.spacingS - - HeaderPane { - id: headerPane - width: parent.width - editMode: root.editMode - onEditModeToggled: root.editMode = !root.editMode - onPowerButtonClicked: { - if (powerMenuModalLoader) { - powerMenuModalLoader.active = true; - if (powerMenuModalLoader.item) { - const bounds = Qt.rect(root.alignedX, root.alignedY, root.popupWidth, root.popupHeight); - powerMenuModalLoader.item.openFromControlCenter(bounds, root.screen); - } - } - } - onLockRequested: { - root.close(); - root.lockRequested(); - } - onSettingsButtonClicked: { - root.close(); - } - } - - DragDropGrid { - id: widgetGrid - width: parent.width - editMode: root.editMode - maxPopoutHeight: { - const screenHeight = (root.triggerScreen?.height ?? 1080); - return screenHeight - 100 - Theme.spacingL - headerPane.implicitHeight - Theme.spacingS; - } - expandedSection: root.expandedSection - expandedWidgetIndex: root.expandedWidgetIndex - expandedWidgetData: root.expandedWidgetData - model: widgetModel - bluetoothCodecSelector: bluetoothCodecSelector - audioPortSelector: audioPortSelector - colorPickerModal: root.colorPickerModal - screenName: root.triggerScreen?.name || "" - screenModel: root.triggerScreen?.model || "" - parentScreen: root.triggerScreen - onExpandClicked: (widgetData, globalIndex) => { - root.expandedWidgetIndex = globalIndex; - root.expandedWidgetData = widgetData; - if (widgetData.id === "diskUsage") { - root.toggleSection("diskUsage_" + (widgetData.instanceId || "default")); - } else if (widgetData.id === "brightnessSlider") { - root.toggleSection("brightnessSlider_" + (widgetData.instanceId || "default")); - } else { - root.toggleSection(widgetData.id); - } - } - onRemoveWidget: index => widgetModel.removeWidget(index) - onMoveWidget: (fromIndex, toIndex) => widgetModel.moveWidget(fromIndex, toIndex) - onToggleWidgetSize: index => widgetModel.toggleWidgetSize(index) - onCollapseRequested: root.collapseAll() - onConfigRequested: (idx, data, anchor) => widgetConfigOverlay.open(idx, data, anchor) - } - - EditControls { - id: editControls - width: parent.width - visible: editMode - popupScreen: root.screen - popoutX: root.alignedX - popoutY: root.alignedY - popoutWidth: root.alignedWidth - popoutHeight: root.alignedHeight - availableWidgets: { - if (!editMode) - return []; - const existingIds = (SettingsData.controlCenterWidgets || []).map(w => w.id); - const allWidgets = widgetModel.baseWidgetDefinitions.concat(widgetModel.getPluginWidgets()); - return allWidgets.filter(w => w.allowMultiple || !existingIds.includes(w.id)); - } - onAddWidget: widgetId => widgetModel.addWidget(widgetId) - onResetToDefault: () => widgetModel.resetToDefault() - onClearAll: () => widgetModel.clearAll() - } - } - } - - BluetoothCodecSelector { - id: bluetoothCodecSelector - anchors.fill: parent - z: 10000 - } - - AudioPortSelector { - id: audioPortSelector - anchors.fill: parent - z: 10000 - } - - WidgetConfigOverlay { - id: widgetConfigOverlay - anchors.fill: parent - } + ControlCenterContent { + host: root } } diff --git a/quickshell/Modules/DankBar/DankBar.qml b/quickshell/Modules/DankBar/DankBar.qml index c36ff5b26..c08462c99 100644 --- a/quickshell/Modules/DankBar/DankBar.qml +++ b/quickshell/Modules/DankBar/DankBar.qml @@ -165,7 +165,14 @@ Item { const base = Quickshell.screens.filter(screen => SettingsData.barConfigCoversScreen(root.barConfig, screen)); // Connected frame mode renders the bar inside the frame surface; skip the standalone window there // unless this bar wants the overlay layer, which the frame surface cannot provide. - return base.filter(screen => !CompositorService.frameHostsBarForConfig(screen, root.barConfig)); + return base.filter(screen => { + if (CompositorService.frameHostsBarForConfig(screen, root.barConfig)) + return false; + if (SettingsData.isIslandBarConfig(root.barConfig)) + return false; + const edge = SettingsData.positionToSide(root.barConfig?.position ?? SettingsData.Position.Top); + return !SettingsData.dankIslandOwnsEdge(screen, edge); + }); } delegate: DankBarWindow { diff --git a/quickshell/Modules/DankBar/DankBarBody.qml b/quickshell/Modules/DankBar/DankBarBody.qml index 7fe5eb141..6eb652cac 100644 --- a/quickshell/Modules/DankBar/DankBarBody.qml +++ b/quickshell/Modules/DankBar/DankBarBody.qml @@ -586,6 +586,8 @@ Item { return SettingsData.barConfigs.some(bc => { if (!bc.enabled || bc.id === barConfig?.id) return false; + if (SettingsData.isIslandBarConfig(bc)) + return false; if (bc.autoHide) return false; if (!(bc.visible ?? true)) @@ -609,6 +611,8 @@ Item { const result = SettingsData.barConfigs.some(bc => { if (!bc.enabled || bc.id === barConfig?.id) return false; + if (SettingsData.isIslandBarConfig(bc)) + return false; if (bc.autoHide) return false; if (!(bc.visible ?? true)) @@ -633,6 +637,8 @@ Item { const result = SettingsData.barConfigs.some(bc => { if (!bc.enabled || bc.id === barConfig?.id) return false; + if (SettingsData.isIslandBarConfig(bc)) + return false; if (bc.autoHide) return false; if (!(bc.visible ?? true)) @@ -657,6 +663,8 @@ Item { const result = SettingsData.barConfigs.some(bc => { if (!bc.enabled || bc.id === barConfig?.id) return false; + if (SettingsData.isIslandBarConfig(bc)) + return false; if (bc.autoHide) return false; if (!(bc.visible ?? true)) diff --git a/quickshell/Modules/DankBar/Widgets/GpuTemperature.qml b/quickshell/Modules/DankBar/Widgets/GpuTemperature.qml index cf07f3eb2..215e70927 100644 --- a/quickshell/Modules/DankBar/Widgets/GpuTemperature.qml +++ b/quickshell/Modules/DankBar/Widgets/GpuTemperature.qml @@ -31,7 +31,7 @@ BasePill { function updateWidgetPciId(pciId) { const sections = ["left", "center", "right"]; - const defaultBar = SettingsData.barConfigs[0] || SettingsData.getBarConfig("default"); + const defaultBar = SettingsData.getPrimaryBarConfig(); if (!defaultBar) return; for (let s = 0; s < sections.length; s++) { diff --git a/quickshell/Modules/DankBar/Widgets/RunningApps.qml b/quickshell/Modules/DankBar/Widgets/RunningApps.qml index 6a4e28d80..c874ee375 100644 --- a/quickshell/Modules/DankBar/Widgets/RunningApps.qml +++ b/quickshell/Modules/DankBar/Widgets/RunningApps.qml @@ -769,7 +769,7 @@ BasePill { property string edge: "top" // New properties for bar context - property int triggerBarPosition: (SettingsData.barConfigs[0]?.position ?? SettingsData.Position.Top) + property int triggerBarPosition: (SettingsData.getPrimaryBarConfig()?.position ?? SettingsData.Position.Top) property real triggerBarThickness: 0 property real triggerBarSpacing: 0 property var triggerBarConfig: null diff --git a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml index 34ae3ae62..36e5711f4 100644 --- a/quickshell/Modules/DankDash/MediaDropdownOverlay.qml +++ b/quickshell/Modules/DankDash/MediaDropdownOverlay.qml @@ -14,11 +14,30 @@ Item { property int dropdownType: 0 property var activePlayer: null property var allPlayers: [] - // Chromium keeps dead MPRIS services registered w/empty metadata; avoid listing them - readonly property var selectablePlayers: (allPlayers || []).filter(p => p && !MprisController.isIdle(p)) + // Drop idle Chromium MPRIS stubs, and only while the players panel is open. + readonly property var selectablePlayers: root.dropdownType !== 3 ? [] : (allPlayers || []).filter(p => p && !MprisController.isIdle(p)) property point anchorPos: Qt.point(0, 0) property bool isRightEdge: false property var targetWindow: null + property rect availableBounds: Qt.rect(0, 0, 0, 0) + + function clampY(rawY, panelHeight) { + if (availableBounds.height <= 0) + return rawY; + const margin = Theme.spacingS; + const minY = availableBounds.y + margin; + const maxY = availableBounds.y + availableBounds.height - panelHeight - margin; + return Math.max(minY, Math.min(maxY, rawY)); + } + + function clampX(rawX, panelWidth) { + if (availableBounds.width <= 0) + return rawX; + const margin = Theme.spacingS; + const minX = availableBounds.x + margin; + const maxX = availableBounds.x + availableBounds.width - panelWidth - margin; + return Math.max(minX, Math.min(maxX, rawX)); + } property bool __isChromeBrowser: { if (!activePlayer?.identity) @@ -30,6 +49,8 @@ Item { property real currentVolume: usePlayerVolume ? activePlayer.volume : (AudioService.sink?.audio?.volume ?? 0) property bool volumeAvailable: !!((activePlayer && activePlayer.volumeSupported && !__isChromeBrowser) || (AudioService.sink && AudioService.sink.audio)) property var availableDevices: { + if (root.dropdownType !== 2) + return []; const hidden = SessionData.hiddenOutputDeviceNames ?? []; return Pipewire.nodes.values.filter(node => { if (!node.audio || !node.isSink || node.isStream) @@ -113,8 +134,8 @@ Item { visible: dropdownType === 1 && volumeAvailable width: 60 height: 180 - x: isRightEdge ? anchorPos.x : anchorPos.x - width - y: anchorPos.y - height / 2 + x: clampX(isRightEdge ? anchorPos.x : anchorPos.x - width, width) + y: clampY(anchorPos.y - height / 2, height) radius: Theme.cornerRadius * 2 color: Theme.floatingSurface border.color: Theme.outlineStrong @@ -257,8 +278,8 @@ Item { visible: dropdownType === 2 width: 280 height: Math.max(200, Math.min(280, availableDevices.length * 50 + 100)) - x: isRightEdge ? anchorPos.x : anchorPos.x - width - y: anchorPos.y - height / 2 + x: clampX(isRightEdge ? anchorPos.x : anchorPos.x - width, width) + y: clampY(anchorPos.y - height / 2, height) radius: Theme.cornerRadius * 2 color: Theme.floatingSurface border.color: Theme.outlineStrong @@ -462,8 +483,8 @@ Item { visible: dropdownType === 3 width: 240 height: Math.max(180, Math.min(240, (root.selectablePlayers?.length || 0) * 50 + 80)) - x: isRightEdge ? anchorPos.x : anchorPos.x - width - y: anchorPos.y - height / 2 + x: clampX(isRightEdge ? anchorPos.x : anchorPos.x - width, width) + y: clampY(anchorPos.y - height / 2, height) radius: Theme.cornerRadius * 2 color: Theme.floatingSurface border.color: Theme.outlineStrong diff --git a/quickshell/Modules/DankDash/MediaPlayerIslandChrome.qml b/quickshell/Modules/DankDash/MediaPlayerIslandChrome.qml new file mode 100644 index 000000000..b792ef8aa --- /dev/null +++ b/quickshell/Modules/DankDash/MediaPlayerIslandChrome.qml @@ -0,0 +1,568 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Services.Mpris +import Quickshell.Widgets +import qs.Common +import qs.Modules.DankIsland.Activities +import qs.Services +import qs.Widgets + +Item { + id: root + + required property var player + + property alias volumeButton: volumeButton + property alias playerSelectorButton: sourceButton + property alias audioDevicesButton: outputButton + + implicitHeight: 352 + + readonly property var activePlayer: root.player.activePlayer + readonly property color accent: root.player.accent + readonly property color onAccent: root.player.onAccent + readonly property string artUrl: TrackArtService.resolvedArtUrl + readonly property string title: MprisController.stableTitle || I18n.tr("Unknown Track") + readonly property string artist: MprisController.stableArtist || I18n.tr("Unknown Artist") + readonly property string album: MprisController.stableAlbum || "" + + Component { + id: artBlobComponent + + MediaBlobHalo { + accentColor: root.accent + playing: root.activePlayer?.playbackState === MprisPlaybackState.Playing + } + } + + function formatTime(seconds) { + if (!isFinite(seconds) || seconds < 0) + return "0:00"; + const minutes = Math.floor(seconds / 60); + const remainder = Math.floor(seconds % 60); + return minutes + ":" + String(remainder).padStart(2, "0"); + } + + function volumeIcon() { + return root.player.getVolumeIcon(); + } + + function outputIcon() { + return root.player.getAudioDeviceIcon(AudioService.sink); + } + + ClippingRectangle { + anchors { + fill: parent + margins: 1 + } + radius: 30 + color: Theme.surfaceContainerHigh + antialiasing: true + + Image { + id: backdropImage + + anchors.fill: parent + source: root.visible ? root.artUrl : "" + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(320, 320) + visible: backdropImage.status === Image.Ready + opacity: 0.62 + } + + Rectangle { + anchors.fill: parent + visible: backdropImage.status === Image.Ready + + gradient: Gradient { + GradientStop { + position: 0 + color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.9) + } + + GradientStop { + position: 0.4 + color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.46) + } + + GradientStop { + position: 1 + color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.95) + } + } + } + + Rectangle { + anchors.fill: parent + color: Theme.surfaceContainerHigh + visible: backdropImage.status !== Image.Ready + } + + Rectangle { + anchors.fill: parent + visible: backdropImage.status !== Image.Ready + + gradient: Gradient { + GradientStop { + position: 0 + color: Theme.withAlpha(root.accent, 0.16) + } + + GradientStop { + position: 0.55 + color: Theme.withAlpha(root.accent, 0.04) + } + + GradientStop { + position: 1 + color: "transparent" + } + } + } + + DankIcon { + anchors.centerIn: parent + name: "music_note" + size: 120 + color: Theme.withAlpha(root.accent, 0.10) + visible: backdropImage.status !== Image.Ready + } + } + + Column { + anchors.centerIn: parent + spacing: Theme.spacingM + visible: root.player.showNoPlayerNow + z: 2 + + DankIcon { + name: "music_note" + size: Theme.iconSize * 3 + color: Theme.surfaceTextSecondary + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: I18n.tr("No Active Players") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceTextMedium + anchors.horizontalCenter: parent.horizontalCenter + } + } + + Item { + id: cardBody + + anchors { + fill: parent + margins: 18 + } + visible: !root.player._noneAvailable && !root.player.showNoPlayerNow + + Item { + id: headerRow + + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: 132 + clip: false + + Item { + id: artCluster + + anchors { + left: parent.left + top: parent.top + } + width: 120 + height: 120 + clip: false + z: 1 + + Loader { + anchors.centerIn: parent + width: 136 + height: 136 + z: 0 + active: root.visible && root.player.live && SettingsData.audioVisualizerEnabled + sourceComponent: artBlobComponent + } + + Rectangle { + id: artFrame + + anchors.fill: parent + z: 1 + radius: 30 + color: "transparent" + border.width: 1 + border.color: Theme.withAlpha(root.accent, 0.3) + + ClippingRectangle { + anchors { + fill: parent + margins: 2 + } + radius: 28 + color: Theme.primaryContainer + antialiasing: true + + DankIcon { + anchors.centerIn: parent + name: "music_note" + size: 46 + color: Theme.primary + visible: artwork.status !== Image.Ready + } + + Image { + id: artwork + + anchors.fill: parent + source: root.visible ? root.artUrl : "" + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(240, 240) + visible: status === Image.Ready + } + } + } + } + + Column { + anchors { + left: artCluster.right + leftMargin: Theme.spacingL + right: parent.right + top: parent.top + topMargin: 2 + } + spacing: Theme.spacingXS + + StyledText { + width: parent.width + text: root.title + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeXLarge + font.weight: Font.Bold + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + lineHeight: 1.08 + } + + StyledText { + width: parent.width + text: root.artist + color: Theme.surfaceTextMedium + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + width: parent.width + text: root.album + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + maximumLineCount: 1 + visible: text.length > 0 + } + } + } + + Item { + id: iconRail + + anchors { + right: parent.right + top: headerRow.bottom + topMargin: Theme.spacingL + bottom: parent.bottom + } + width: 44 + z: 4 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + TonalIconButton { + id: volumeButton + + width: 44 + height: 44 + radius: 22 + iconName: root.volumeIcon() + iconSize: 20 + iconColor: root.player.volumeAvailable && root.player.currentVolume > 0 ? root.accent : Theme.surfaceTextMedium + containerColor: Theme.withAlpha(Theme.surfaceText, 0.08) + interactive: root.player.volumeAvailable + onTriggered: root.player.toggleMute() + onScrolled: notches => root.player.adjustVolume(notches * AudioService.wheelVolumeStep) + + HoverHandler { + enabled: root.player.menusEnabled && root.player.volumeAvailable + onHoveredChanged: { + if (hovered) + root.player.triggerVolumeDropdown(); + else + root.player.dropdownButtonExited(); + } + } + } + + TonalIconButton { + id: outputButton + + width: 44 + height: 44 + radius: 22 + iconName: root.outputIcon() + iconSize: 20 + iconColor: Theme.surfaceText + containerColor: Theme.withAlpha(Theme.surfaceText, 0.08) + onTriggered: root.player.cycleNextSink() + onScrolled: notches => AudioService.cycleAudioOutputDirection(notches < 0) + + HoverHandler { + enabled: root.player.menusEnabled + onHoveredChanged: { + if (hovered) + root.player.triggerDevicesDropdown(); + else + root.player.dropdownButtonExited(); + } + } + } + + TonalIconButton { + id: sourceButton + + width: 44 + height: 44 + radius: 22 + visible: (root.player.allPlayers?.length || 0) > 0 + iconName: "assistant_device" + iconSize: 20 + iconColor: root.player.playersExpanded ? root.accent : Theme.surfaceText + containerColor: Theme.withAlpha(root.accent, root.player.playersExpanded ? 0.16 : 0.08) + onTriggered: { + if (root.player.playersExpanded) { + root.player.cycleNextPlayer(); + return; + } + root.player.triggerPlayersDropdown(); + } + + HoverHandler { + enabled: root.player.menusEnabled && sourceButton.visible + onHoveredChanged: { + if (hovered) + root.player.triggerPlayersDropdown(); + else + root.player.dropdownButtonExited(); + } + } + } + } + } + + Item { + id: playColumn + + anchors { + left: parent.left + right: iconRail.left + rightMargin: Theme.spacingM + top: headerRow.bottom + topMargin: Theme.spacingL + bottom: parent.bottom + } + + Item { + id: seekBlock + + anchors { + left: parent.left + leftMargin: iconRail.width + Theme.spacingM + right: parent.right + bottom: parent.bottom + bottomMargin: 76 + } + height: 40 + + DankSeekbar { + anchors.top: parent.top + width: parent.width + height: 22 + activePlayer: root.activePlayer + stableLength: root.player.stableLength + accentColor: root.accent + accentTrackColor: MediaAccentService.accentTrack + accentSubtleColor: MediaAccentService.accentSubtle + isSeeking: root.player.isSeeking + onIsSeekingChanged: root.player.isSeeking = isSeeking + } + + StyledText { + anchors { + left: parent.left + bottom: parent.bottom + } + text: root.formatTime(root.activePlayer?.position || 0) + color: root.accent + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + StyledText { + anchors { + right: parent.right + bottom: parent.bottom + } + text: root.player.stableLength > 0 ? root.formatTime(root.player.stableLength) : "--:--" + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + + Row { + anchors { + horizontalCenter: parent.horizontalCenter + horizontalCenterOffset: (iconRail.width + Theme.spacingM) / 2 + bottom: parent.bottom + bottomMargin: 2 + } + spacing: Theme.spacingS + + TonalIconButton { + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 40 + radius: 20 + visible: !!root.activePlayer?.shuffleSupported + iconName: "shuffle" + iconSize: 18 + iconColor: root.activePlayer?.shuffle ? root.accent : Theme.surfaceText + containerColor: Theme.withAlpha(root.accent, root.activePlayer?.shuffle ? 0.16 : 0) + onTriggered: { + if (root.activePlayer?.canControl && root.activePlayer?.shuffleSupported) + root.activePlayer.shuffle = !root.activePlayer.shuffle; + } + } + + TonalIconButton { + anchors.verticalCenter: parent.verticalCenter + width: 52 + height: 52 + radius: 18 + iconName: "skip_previous" + iconSize: 26 + containerColor: Theme.withAlpha(root.accent, 0.14) + interactive: !!root.activePlayer?.canGoPrevious || !!root.activePlayer?.canSeek + onTriggered: MprisController.previousOrRewind() + } + + Rectangle { + id: playHero + + anchors.verticalCenter: parent.verticalCenter + width: 68 + height: 68 + radius: root.activePlayer?.playbackState === MprisPlaybackState.Playing ? 22 : 34 + color: root.accent + opacity: root.activePlayer?.canTogglePlaying ? 1 : 0.38 + + DankIcon { + anchors.centerIn: parent + name: root.activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" + size: 34 + color: root.onAccent + weight: 500 + } + + StateLayer { + stateColor: root.onAccent + disabled: !root.activePlayer || !root.activePlayer.canTogglePlaying + onClicked: root.activePlayer.togglePlaying() + } + } + + TonalIconButton { + anchors.verticalCenter: parent.verticalCenter + width: 52 + height: 52 + radius: 18 + iconName: "skip_next" + iconSize: 26 + containerColor: Theme.withAlpha(root.accent, 0.14) + interactive: !!root.activePlayer?.canGoNext + onTriggered: MprisController.next() + } + + TonalIconButton { + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 40 + radius: 20 + visible: !!root.activePlayer?.loopSupported + iconName: { + if (!root.activePlayer) + return "repeat"; + return root.activePlayer.loopState === MprisLoopState.Track ? "repeat_one" : "repeat"; + } + iconSize: 18 + iconColor: root.activePlayer && root.activePlayer.loopState !== MprisLoopState.None ? root.accent : Theme.surfaceText + containerColor: Theme.withAlpha(root.accent, root.activePlayer && root.activePlayer.loopState !== MprisLoopState.None ? 0.16 : 0) + onTriggered: root.player.cycleLoopState() + } + } + } + } + + component TonalIconButton: Rectangle { + id: tonal + + property string iconName: "" + property real iconSize: 22 + property color iconColor: Theme.surfaceText + property color containerColor: Theme.withAlpha(Theme.surfaceText, 0.08) + property bool interactive: true + + signal triggered + signal scrolled(int notches) + + color: tonal.containerColor + opacity: tonal.interactive ? 1 : 0.38 + + DankIcon { + anchors.centerIn: parent + name: tonal.iconName + size: tonal.iconSize + color: tonal.iconColor + } + + StateLayer { + stateColor: tonal.iconColor + disabled: !tonal.interactive + enabled: tonal.interactive + onClicked: tonal.triggered() + onWheel: wheelEvent => { + wheelEvent.accepted = true; + tonal.scrolled(wheelEvent.angleDelta.y > 0 ? 1 : -1); + } + } + } +} diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 8e9989d90..75e34058a 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -14,7 +14,6 @@ Item { LayoutMirroring.childrenInherit: true property MprisPlayer activePlayer: MprisController.activePlayer - readonly property bool windowVisible: Window.window?.visible ?? false readonly property real stableLength: MprisController.activePlayerStableLength property var allPlayers: MprisController.availablePlayers property var targetScreen: null @@ -25,6 +24,10 @@ Item { property real contentOffsetY: 0 property string section: "" property int barPosition: SettingsData.Position.Top + property string chrome: "dash" + property bool live: Window.window?.visible ?? false + property bool menusEnabled: true + readonly property bool islandChrome: chrome === "island" readonly property color accent: MediaAccentService.accent readonly property color onAccent: MediaAccentService.onAccent @@ -41,6 +44,7 @@ Item { property bool volumeExpanded: false property bool devicesExpanded: false property bool playersExpanded: false + property real previousVolume: 0.0 function resetDropdownStates() { volumeExpanded = false; @@ -80,6 +84,13 @@ Item { onTriggered: _switchHold = false } + onMenusEnabledChanged: { + if (!root.menusEnabled) { + resetDropdownStates(); + hideDropdowns(); + } + } + onActivePlayerChanged: { if (!activePlayer) { isSwitching = false; @@ -109,7 +120,7 @@ Item { } implicitWidth: SettingsData.showWeekNumber ? 736 : 700 - implicitHeight: playerContent.height + playerContent.anchors.topMargin * 2 + implicitHeight: chromeLoader.item ? chromeLoader.item.implicitHeight : 410 Connections { target: activePlayer @@ -184,18 +195,109 @@ Item { } } + function dropdownAnchor(button) { + if (!button) + return null; + if (root.islandChrome) { + const rightEdge = root.mapToItem(null, root.width, 0).x; + const point = button.mapToItem(null, 0, button.height / 2); + return { + "pos": Qt.point(rightEdge + Theme.spacingS, point.y), + "rightEdge": true + }; + } + const buttonsOnRight = !root.isRightEdge; + const btnY = button.mapToItem(root, 0, button.height / 2).y; + return { + "pos": Qt.point(buttonsOnRight ? (root.popoutX + root.popoutWidth) : root.popoutX, root.popoutY + root.contentOffsetY + btnY), + "rightEdge": buttonsOnRight + }; + } + function triggerVolumeDropdown() { - if (!volumeAvailable) + if (!root.menusEnabled || !volumeAvailable) return; if (volumeExpanded) return; + const button = chromeLoader.item?.volumeButton; + const anchor = dropdownAnchor(button); + if (!anchor) + return; hideDropdowns(); volumeExpanded = true; - const buttonsOnRight = !isRightEdge; - const btnY = volumeButton.y + volumeButton.height / 2; - const screenX = buttonsOnRight ? (popoutX + popoutWidth) : popoutX; - const screenY = popoutY + contentOffsetY + btnY; - showVolumeDropdown(Qt.point(screenX, screenY), targetScreen, buttonsOnRight, activePlayer, allPlayers); + showVolumeDropdown(anchor.pos, targetScreen, anchor.rightEdge, activePlayer, allPlayers); + } + + function triggerPlayersDropdown() { + if (!root.menusEnabled) + return; + const button = chromeLoader.item?.playerSelectorButton; + const anchor = dropdownAnchor(button); + if (!anchor) + return; + if (playersExpanded) + return; + hideDropdowns(); + playersExpanded = true; + showPlayersDropdown(anchor.pos, targetScreen, anchor.rightEdge, activePlayer, allPlayers); + } + + function triggerDevicesDropdown() { + if (!root.menusEnabled) + return; + const button = chromeLoader.item?.audioDevicesButton; + const anchor = dropdownAnchor(button); + if (!anchor) + return; + if (devicesExpanded) + return; + hideDropdowns(); + devicesExpanded = true; + showAudioDevicesDropdown(anchor.pos, targetScreen, anchor.rightEdge); + } + + function cycleNextPlayer() { + const players = (root.allPlayers || []).filter(p => p && !MprisController.isIdle(p)); + if (players.length < 2) + return; + let currentIndex = -1; + for (let i = 0; i < players.length; i++) { + if (players[i] === root.activePlayer) { + currentIndex = i; + break; + } + } + MprisController.setActivePlayer(players[(currentIndex + 1) % players.length]); + } + + function cycleNextSink() { + const sinks = AudioService.getAvailableSinks(); + if (!sinks || sinks.length < 2) + return; + let currentIndex = -1; + for (let i = 0; i < sinks.length; i++) { + if (sinks[i]?.name === AudioService.sink?.name) { + currentIndex = i; + break; + } + } + AudioService.setSink(sinks[(currentIndex + 1) % sinks.length]); + } + + function cycleLoopState() { + if (!activePlayer?.canControl || !activePlayer.loopSupported) + return; + switch (activePlayer.loopState) { + case MprisLoopState.None: + activePlayer.loopState = MprisLoopState.Playlist; + break; + case MprisLoopState.Playlist: + activePlayer.loopState = MprisLoopState.Track; + break; + case MprisLoopState.Track: + activePlayer.loopState = MprisLoopState.None; + break; + } } function toggleMute() { @@ -203,14 +305,14 @@ Item { return; SessionData.suppressOSDTemporarily(); if (currentVolume > 0) { - volumeButton.previousVolume = currentVolume; + root.previousVolume = currentVolume; if (usePlayerVolume) { activePlayer.volume = 0; } else if (AudioService.sink?.audio) { AudioService.sink.audio.volume = 0; } } else { - const restoreVolume = volumeButton.previousVolume > 0 ? volumeButton.previousVolume : 0.5; + const restoreVolume = root.previousVolume > 0 ? root.previousVolume : 0.5; if (usePlayerVolume) { activePlayer.volume = restoreVolume; } else if (AudioService.sink?.audio) { @@ -284,13 +386,38 @@ Item { Timer { interval: 1000 - running: root.windowVisible && activePlayer?.playbackState === MprisPlaybackState.Playing && !isSeeking + running: root.live && activePlayer?.playbackState === MprisPlaybackState.Playing && !isSeeking repeat: true onTriggered: activePlayer?.positionChanged() } - Item { - id: bgContainer + Loader { + id: chromeLoader + + anchors.fill: parent + sourceComponent: root.islandChrome ? islandChromeComponent : dashChromeComponent + } + + Component { + id: islandChromeComponent + + MediaPlayerIslandChrome { + player: root + } + } + + Component { + id: dashChromeComponent + + Item { + anchors.fill: parent + implicitHeight: playerContent.height + playerContent.anchors.topMargin * 2 + property alias volumeButton: volumeButton + property alias playerSelectorButton: playerSelectorButton + property alias audioDevicesButton: audioDevicesButton + + Item { + id: bgContainer anchors.fill: parent // Fall back to the live mpris url so the background is never blank. @@ -860,8 +987,6 @@ Item { z: 101 enabled: volumeAvailable - property real previousVolume: 0.0 - DankIcon { anchors.centerIn: parent name: getVolumeIcon() @@ -991,5 +1116,7 @@ Item { dropdownButtonExited(); } } + } + } } } diff --git a/quickshell/Modules/DankDash/Overview/ClockCard.qml b/quickshell/Modules/DankDash/Overview/ClockCard.qml index 4269d6221..b3e06c132 100644 --- a/quickshell/Modules/DankDash/Overview/ClockCard.qml +++ b/quickshell/Modules/DankDash/Overview/ClockCard.qml @@ -7,6 +7,8 @@ import qs.Widgets Card { id: root + property bool live: Window.window?.visible ?? false + Column { anchors.centerIn: parent spacing: 0 @@ -110,13 +112,16 @@ Card { SystemClock { id: systemClock - precision: SettingsData.showSeconds ? SystemClock.Seconds : SystemClock.Minutes + enabled: root.live + precision: root.live && SettingsData.showSeconds ? SystemClock.Seconds : SystemClock.Minutes } Connections { target: SessionService function onSessionResumed() { + if (!root.live) + return; systemClock.enabled = false; systemClock.enabled = true; } diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index 67d6d7e08..9edb56fff 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -11,11 +11,11 @@ Card { signal clicked property MprisPlayer activePlayer: MprisController.activePlayer - readonly property bool windowVisible: Window.window?.visible ?? false + property bool live: Window.window?.visible ?? false Timer { interval: 300 - running: root.windowVisible && activePlayer?.playbackState === MprisPlaybackState.Playing && !isSeeking + running: root.live && activePlayer?.playbackState === MprisPlaybackState.Playing && !isSeeking repeat: true onTriggered: activePlayer?.positionSupported && activePlayer.positionChanged() } @@ -62,7 +62,7 @@ Card { artUrl: TrackArtService.resolvedArtUrl accentColor: MediaAccentService.accent cavaService: CavaService - showAnimation: SettingsData.audioVisualizerEnabled + showAnimation: root.live && SettingsData.audioVisualizerEnabled albumSize: 76 animationScale: 1.05 } diff --git a/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml b/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml index 90c581e0c..07cbe7c40 100644 --- a/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml +++ b/quickshell/Modules/DankDash/Overview/SystemMonitorCard.qml @@ -6,7 +6,7 @@ import qs.Widgets Card { id: root - readonly property bool windowVisible: Window.window?.visible ?? false + property bool live: Window.window?.visible ?? false property bool dgopRefHeld: false function syncDgopRef(wanted) { @@ -20,8 +20,8 @@ Card { DgopService.removeRef(["cpu", "memory", "system"]); } - onWindowVisibleChanged: syncDgopRef(windowVisible) - Component.onCompleted: syncDgopRef(windowVisible) + onLiveChanged: syncDgopRef(live) + Component.onCompleted: syncDgopRef(live) Component.onDestruction: syncDgopRef(false) Row { diff --git a/quickshell/Modules/DankDash/Overview/UserInfoCard.qml b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml index af7c7bb2a..734a14c5f 100644 --- a/quickshell/Modules/DankDash/Overview/UserInfoCard.qml +++ b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml @@ -9,8 +9,23 @@ Card { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true - Component.onCompleted: DgopService.addRef("system") - Component.onDestruction: DgopService.removeRef("system") + property bool live: Window.window?.visible ?? false + property bool dgopRefHeld: false + + function syncDgopRef(wanted) { + if (wanted === dgopRefHeld) + return; + dgopRefHeld = wanted; + if (wanted) { + DgopService.addRef("system"); + return; + } + DgopService.removeRef("system"); + } + + onLiveChanged: syncDgopRef(live) + Component.onCompleted: syncDgopRef(live) + Component.onDestruction: syncDgopRef(false) Row { anchors.left: parent.left diff --git a/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml b/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml index 2cfcdb3de..c0e2053ce 100644 --- a/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/WeatherOverviewCard.qml @@ -11,8 +11,23 @@ Card { signal clicked - Component.onCompleted: WeatherService.addRef() - Component.onDestruction: WeatherService.removeRef() + property bool live: Window.window?.visible ?? false + property bool weatherRefHeld: false + + function syncWeatherRef(wanted) { + if (wanted === weatherRefHeld) + return; + weatherRefHeld = wanted; + if (wanted) { + WeatherService.addRef(); + return; + } + WeatherService.removeRef(); + } + + onLiveChanged: syncWeatherRef(live) + Component.onCompleted: syncWeatherRef(live) + Component.onDestruction: syncWeatherRef(false) Column { anchors.centerIn: parent diff --git a/quickshell/Modules/DankDash/OverviewTab.qml b/quickshell/Modules/DankDash/OverviewTab.qml index 2d545f283..951b0f9c8 100644 --- a/quickshell/Modules/DankDash/OverviewTab.qml +++ b/quickshell/Modules/DankDash/OverviewTab.qml @@ -11,6 +11,7 @@ Item { implicitWidth: SettingsData.showWeekNumber ? 736 : 700 implicitHeight: 410 + property bool live: Window.window?.visible ?? false signal switchToWeatherTab signal switchToMediaTab @@ -29,6 +30,7 @@ Item { y: 0 width: parent.width * 0.2 - Theme.spacingM * 2 height: 180 + live: root.live } // Weather - top middle-left (narrower) @@ -38,6 +40,7 @@ Item { width: SettingsData.weatherEnabled ? parent.width * 0.3 : 0 height: 100 visible: SettingsData.weatherEnabled + live: root.live onClicked: root.switchToWeatherTab() } @@ -48,6 +51,7 @@ Item { y: 0 width: SettingsData.weatherEnabled ? parent.width * 0.5 : parent.width * 0.8 height: 100 + live: root.live } // SystemMonitor - middle left (narrow and shorter) @@ -56,6 +60,7 @@ Item { y: 180 + Theme.spacingM width: parent.width * 0.2 - Theme.spacingM * 2 height: 220 + live: root.live } // Calendar - bottom middle; deferred so the grid stays off the emerge frame. @@ -86,6 +91,7 @@ Item { y: 100 + Theme.spacingM width: parent.width * 0.2 height: 300 + live: root.live onClicked: root.switchToMediaTab() } diff --git a/quickshell/Modules/DankDash/WallpaperTab.qml b/quickshell/Modules/DankDash/WallpaperTab.qml index ced8eeba4..0a6976fca 100644 --- a/quickshell/Modules/DankDash/WallpaperTab.qml +++ b/quickshell/Modules/DankDash/WallpaperTab.qml @@ -40,6 +40,7 @@ Item { property bool sortAscending: true // Forces the page grid to rebuild when the folder model reorders in place. property int gridRevision: 0 + property int pagerCachePages: 1 signal requestTabChange(int newIndex) @@ -64,15 +65,18 @@ Item { return Math.max(0, Math.min(itemsPerPage, wallpaperCount - page * itemsPerPage)); } - function matchesSearch(fileName, filePath) { + function searchTerms() { const query = searchQuery.trim().toLowerCase(); - if (!query) + return query ? query.split(/\s+/).filter(t => t.length > 0) : []; + } + + function matchesTerms(terms, fileName, filePath) { + if (terms.length === 0) return true; const haystack = (fileName + " " + filePath).toLowerCase(); - const terms = query.split(/\s+/); for (let i = 0; i < terms.length; i++) { - if (terms[i] && haystack.indexOf(terms[i]) === -1) + if (haystack.indexOf(terms[i]) === -1) return false; } return true; @@ -81,27 +85,56 @@ Item { function rebuildWallpaperList(preferCurrentWallpaper) { const paths = []; if (wallpaperFolderModel.status === FolderListModel.Ready) { - for (let i = 0; i < wallpaperFolderModel.count; i++) { + const terms = searchTerms(); + const rowCount = wallpaperFolderModel.count; + for (let i = 0; i < rowCount; i++) { const filePath = cleanFilePath(wallpaperFolderModel.get(i, "filePath")); - const fileName = wallpaperFolderModel.get(i, "fileName") || filePath.substring(filePath.lastIndexOf('/') + 1); - if (filePath && matchesSearch(fileName, filePath)) - paths.push(filePath); + if (!filePath) + continue; + if (terms.length > 0) { + const fileName = wallpaperFolderModel.get(i, "fileName") || filePath.substring(filePath.lastIndexOf('/') + 1); + if (!matchesTerms(terms, fileName, filePath)) + continue; + } + paths.push(filePath); } } + const selectCurrent = preferCurrentWallpaper && visible && active; + if (selectCurrent) { + enableAnimation = false; + const currentWallpaper = getCurrentWallpaper(); + let matched = false; + if (currentWallpaper) { + for (let i = 0; i < paths.length; i++) { + if (paths[i] === currentWallpaper) { + currentPage = Math.floor(i / itemsPerPage); + gridIndex = i % itemsPerPage; + matched = true; + break; + } + } + } + if (!matched) { + currentPage = 0; + gridIndex = 0; + } + } else { + const nextTotal = Math.max(1, Math.ceil(paths.length / itemsPerPage)); + if (currentPage >= nextTotal) + currentPage = Math.max(0, nextTotal - 1); + const visibleCount = Math.max(0, Math.min(itemsPerPage, paths.length - currentPage * itemsPerPage)); + gridIndex = visibleCount > 0 ? Math.min(gridIndex, visibleCount - 1) : 0; + } + filteredWallpaperPaths = paths; gridRevision++; - - if (currentPage >= totalPages) - currentPage = Math.max(0, totalPages - 1); - - const visibleCount = pageItemCount(currentPage); - gridIndex = visibleCount > 0 ? Math.min(gridIndex, visibleCount - 1) : 0; - - if (preferCurrentWallpaper && visible && active) - setInitialSelection(); - else - updateSelectedFileName(); + updateSelectedFileName(); + if (selectCurrent) { + Qt.callLater(() => { + enableAnimation = true; + }); + } } function focusSearch() { @@ -514,27 +547,31 @@ Item { } } - FileBrowserSurfaceModal { - id: wallpaperBrowser + Loader { + id: wallpaperBrowserLoader - browserTitle: I18n.tr("Select Wallpaper Directory", "wallpaper directory file browser title") - browserIcon: "folder_open" - browserType: "wallpaper" - showHiddenFiles: false - fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"] - parentPopout: root.parentPopout + active: false - onFileSelected: path => { - const cleanPath = path.replace(/^file:\/\//, ''); - setCurrentWallpaper(cleanPath); + sourceComponent: FileBrowserSurfaceModal { + browserTitle: I18n.tr("Select Wallpaper Directory", "wallpaper directory file browser title") + browserIcon: "folder_open" + browserType: "wallpaper" + showHiddenFiles: false + fileExtensions: ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp", "*.jxl", "*.avif", "*.heif", "*.exr"] + parentPopout: root.parentPopout - const dirPath = cleanPath.substring(0, cleanPath.lastIndexOf('/')); - if (dirPath) { - wallpaperDir = dirPath; - CacheData.wallpaperLastPath = dirPath; - CacheData.saveCache(); + onFileSelected: path => { + const cleanPath = path.replace(/^file:\/\//, ''); + root.setCurrentWallpaper(cleanPath); + + const dirPath = cleanPath.substring(0, cleanPath.lastIndexOf('/')); + if (dirPath) { + root.wallpaperDir = dirPath; + CacheData.wallpaperLastPath = dirPath; + CacheData.saveCache(); + } + close(); } - close(); } } @@ -565,9 +602,14 @@ Item { keyNavigationEnabled: false activeFocusOnTab: false focus: false - cacheBuffer: Math.max(0, height * 2) - reuseItems: true - model: root.totalPages + cacheBuffer: Math.max(0, height * root.pagerCachePages) + reuseItems: false + model: height > 1 ? root.totalPages : 0 + + onCountChanged: { + if (count > 0 && currentIndex !== root.currentPage) + currentIndex = root.currentPage; + } onCurrentIndexChanged: { if (!moving) { @@ -595,7 +637,7 @@ Item { property int pageIndex: index width: pager.width - height: pager.height + height: Math.max(1, pager.height) cellWidth: width / 4 cellHeight: height / 4 interactive: false @@ -811,7 +853,7 @@ Item { DankActionButton { id: sortButton anchors.verticalCenter: parent.verticalCenter - iconName: "sort" + iconName: "filter_list" iconSize: 20 buttonSize: 32 opacity: 0.7 @@ -833,7 +875,10 @@ Item { opacity: 0.7 tooltipText: I18n.tr("Choose wallpaper folder") tooltipSide: "top" - onClicked: wallpaperBrowser.open() + onClicked: { + wallpaperBrowserLoader.active = true; + wallpaperBrowserLoader.item.open(); + } } Item { diff --git a/quickshell/Modules/DankDash/WeatherTab.qml b/quickshell/Modules/DankDash/WeatherTab.qml index 8aeda29b5..570501ab6 100644 --- a/quickshell/Modules/DankDash/WeatherTab.qml +++ b/quickshell/Modules/DankDash/WeatherTab.qml @@ -13,12 +13,26 @@ Item { implicitWidth: SettingsData.showWeekNumber ? 736 : 700 implicitHeight: 410 + property bool live: Window.window?.visible ?? false + property bool weatherRefHeld: false property bool syncing: false property bool showHourly: false property bool available: WeatherService.weather.available - Component.onCompleted: WeatherService.addRef() - Component.onDestruction: WeatherService.removeRef() + function syncWeatherRef(wanted) { + if (wanted === weatherRefHeld) + return; + weatherRefHeld = wanted; + if (wanted) { + WeatherService.addRef(); + return; + } + WeatherService.removeRef(); + } + + onLiveChanged: syncWeatherRef(live) + Component.onCompleted: syncWeatherRef(live) + Component.onDestruction: syncWeatherRef(false) function syncFrom(type) { if (!dailyLoader.item || !hourlyLoader.item) @@ -530,6 +544,7 @@ Item { Loader { anchors.fill: parent + active: root.live asynchronous: true sourceComponent: skyContentComponent opacity: status === Loader.Ready ? 1 : 0 @@ -871,7 +886,7 @@ Item { id: dailyLoader anchors.fill: parent sourceComponent: dailyComponent - active: root.visible && root.available && width > 0 && height > 0 + active: root.live && root.available && width > 0 && height > 0 visible: !root.showHourly asynchronous: true opacity: 0 @@ -888,7 +903,7 @@ Item { id: hourlyLoader anchors.fill: parent sourceComponent: hourlyComponent - active: root.visible && root.available && width > 0 && height > 0 + active: root.live && root.available && width > 0 && height > 0 visible: root.showHourly asynchronous: true opacity: 0 diff --git a/quickshell/Modules/DankIsland/Activities/BatteryPill.qml b/quickshell/Modules/DankIsland/Activities/BatteryPill.qml new file mode 100644 index 000000000..aee680add --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/BatteryPill.qml @@ -0,0 +1,139 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property bool hovered: false + property bool outlined: false + + readonly property real thickness: 14 + readonly property real bodyWidth: 30 + readonly property real level: Math.max(0, Math.min(100, BatteryService.batteryLevel)) + readonly property bool charging: BatteryService.batteryAvailable && BatteryService.isCharging + readonly property bool lowState: BatteryService.batteryAvailable && BatteryService.isLowBattery && !BatteryService.isCharging + readonly property color fillColor: root.lowState ? Theme.error : Theme.primary + readonly property color onFillColor: { + const c = root.fillColor; + const lum = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; + return lum > 0.5 ? Qt.rgba(0, 0, 0, 0.9) : Qt.rgba(1, 1, 1, 0.95); + } + readonly property string numberText: Math.round(root.level).toString() + readonly property int glyphSize: Math.round(root.thickness * 0.62) + readonly property int boltSize: Math.round(root.thickness * 0.7) + readonly property real nubBreadth: Math.round(root.thickness * 0.16) + readonly property real nubSpan: Math.round(root.thickness * 0.46) + + width: root.bodyWidth + root.nubBreadth + height: root.thickness + + component PillGlyphs: Row { + id: glyphs + + property color glyphColor + + spacing: 2 + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + visible: root.charging + name: "bolt" + size: root.boltSize + color: glyphs.glyphColor + } + + StyledText { + id: numberGlyph + + anchors.verticalCenter: parent.verticalCenter + // Digits have no descenders; sink by half the descent to optically center. + anchors.verticalCenterOffset: Math.round(digitMetrics.descent / 2) + text: root.numberText + color: glyphs.glyphColor + font.pixelSize: root.glyphSize + font.weight: Font.Bold + } + + FontMetrics { + id: digitMetrics + + font: numberGlyph.font + } + } + + Rectangle { + id: body + + anchors.left: parent.left + width: root.bodyWidth + height: root.thickness + radius: Math.round(Math.min(width, height) * 0.34) + color: root.outlined ? (root.hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.45) : "transparent") : (root.hovered ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0.9)) + border.width: root.outlined ? 1.2 : 0 + border.color: root.fillColor + + Rectangle { + id: fill + + anchors { + left: parent.left + bottom: parent.bottom + top: parent.top + leftMargin: body.border.width + bottomMargin: body.border.width + topMargin: body.border.width + } + width: Math.round((body.width - body.border.width * 2) * root.level / 100) + radius: Math.max(0, body.radius - body.border.width) + color: root.outlined ? Theme.withAlpha(root.fillColor, 0.32) : root.fillColor + + Behavior on width { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + } + + PillGlyphs { + anchors.centerIn: parent + glyphColor: Theme.surfaceText + } + + Item { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + width: fill.width + clip: true + visible: !root.outlined + + Item { + width: body.width + height: body.height + + PillGlyphs { + anchors.centerIn: parent + glyphColor: root.onFillColor + } + } + } + } + + Rectangle { + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + width: root.nubBreadth + height: root.nubSpan + radius: Math.round(root.nubBreadth * 0.35) + color: root.outlined ? root.fillColor : Theme.withAlpha(Theme.surfaceVariant, 0.9) + } +} diff --git a/quickshell/Modules/DankIsland/Activities/ControlCenterCompact.qml b/quickshell/Modules/DankIsland/Activities/ControlCenterCompact.qml new file mode 100644 index 000000000..7e85ddbf7 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/ControlCenterCompact.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var controller + + function pushMeasuredWidth() { + root.controller.setDestinationContentWidth("controlcenter", compactRow.implicitWidth); + } + + Component.onCompleted: root.pushMeasuredWidth() + + Row { + id: compactRow + + anchors.centerIn: parent + spacing: Theme.spacingS + + onImplicitWidthChanged: root.pushMeasuredWidth() + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "tune" + size: Theme.iconSizeSmall + color: Theme.primary + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Control Center") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.DemiBold + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml b/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml new file mode 100644 index 000000000..1ac10512e --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/ControlCenterExpanded.qml @@ -0,0 +1,175 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.ControlCenter +import qs.Services + +// Shared Control Center body hosted in the island silhouette. +FocusScope { + id: root + + required property var islandController + property var effectiveScreen: null + property var colorPickerModal: null + property var powerMenuModalLoader: null + property real alignedX: 0 + property real alignedY: 0 + property real alignedWidth: 0 + property real alignedHeight: 0 + // Report content height once per turn; the controller clamps it before retargeting the spring. + property real bottomInset: Theme.spacingM + property bool _heightReportPending: false + + signal lockRequested + + clip: true + + function resetState() { + hostContract.editMode = false; + hostContract.expandedSection = ""; + hostContract.expandedWidgetIndex = -1; + hostContract.expandedWidgetData = null; + } + + function beginSession() { + hostContract.editMode = false; + hostContract.expandedSection = root.islandController.controlCenterPendingSection || ""; + hostContract.expandedWidgetIndex = -1; + hostContract.expandedWidgetData = null; + root.queueHeightReport(); + } + + function queueHeightReport() { + if (root._heightReportPending) + return; + root._heightReportPending = true; + Qt.callLater(() => { + root._heightReportPending = false; + root.islandController.setControlCenterContentHeight(content.targetImplicitHeight + Theme.spacingXS + root.bottomInset); + }); + } + + QtObject { + id: hostContract + + property bool editMode: false + property string expandedSection: "" + property int expandedWidgetIndex: -1 + property var expandedWidgetData: null + + readonly property bool shouldBeVisible: root.islandController.activeActivity === "controlcenter" && root.islandController.expanded + // The island has no outside chrome to click, so the header row closes it. + readonly property bool headerTogglesClose: true + readonly property bool powerMenuOpen: root.powerMenuModalLoader?.item?.shouldBeVisible ?? false + readonly property var screen: root.effectiveScreen + readonly property var triggerScreen: root.effectiveScreen + readonly property var colorPickerModal: root.colorPickerModal + readonly property var powerMenuModalLoader: root.powerMenuModalLoader + readonly property real alignedX: root.alignedX + readonly property real alignedY: root.alignedY + readonly property real alignedWidth: root.alignedWidth + readonly property real alignedHeight: root.alignedHeight + readonly property real popupWidth: root.alignedWidth + readonly property real popupHeight: root.alignedHeight + + signal lockRequested + + function close() { + root.islandController.requestCollapse(); + } + + function collapseAll() { + hostContract.expandedSection = ""; + hostContract.expandedWidgetIndex = -1; + hostContract.expandedWidgetData = null; + } + + function toggleSection(section) { + if (hostContract.expandedSection === section) { + hostContract.expandedSection = ""; + hostContract.expandedWidgetIndex = -1; + return; + } + hostContract.expandedSection = section; + } + } + + function releaseScanState() { + if (NetworkService.activeService) + NetworkService.activeService.autoRefreshEnabled = false; + if (BluetoothService.adapter && BluetoothService.adapter.discovering) + BluetoothService.adapter.discovering = false; + } + + function applyPresentationLifecycle() { + if (hostContract.shouldBeVisible) { + if (NetworkService.activeService) + NetworkService.activeService.autoRefreshEnabled = NetworkService.wifiEnabled; + return; + } + root.releaseScanState(); + } + + // Hot-unplug skips the visibility path; release scans here too. + Component.onDestruction: { + if (hostContract.shouldBeVisible) + root.releaseScanState(); + } + + Connections { + target: hostContract + + function onLockRequested() { + root.lockRequested(); + } + + function onShouldBeVisibleChanged() { + Qt.callLater(root.applyPresentationLifecycle); + } + } + + Connections { + target: NetworkService + + function onCredentialsRequestedChanged() { + if (NetworkService.credentialsRequested && hostContract.shouldBeVisible) + root.islandController.requestCollapse(); + } + } + + ControlCenterContent { + id: content + + anchors { + fill: parent + leftMargin: Theme.spacingXS + rightMargin: Theme.spacingXS + topMargin: Theme.spacingXS + bottomMargin: root.bottomInset + } + host: hostContract + + onTargetImplicitHeightChanged: root.queueHeightReport() + } + + Connections { + target: root.islandController + + function onControlCenterSessionSerialChanged() { + Qt.callLater(root.beginSession); + } + + function onExpandedChanged() { + if (!root.islandController.expanded) + root.resetState(); + } + + function onActiveActivityChanged() { + if (root.islandController.activeActivity !== "controlcenter") + root.resetState(); + } + } + + Component.onCompleted: root.islandController.markControlCenterVisualsReady() +} diff --git a/quickshell/Modules/DankIsland/Activities/HomeCompact.qml b/quickshell/Modules/DankIsland/Activities/HomeCompact.qml new file mode 100644 index 000000000..e97c3b211 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/HomeCompact.qml @@ -0,0 +1,289 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + required property var controller + required property var mediaModel + + readonly property string timeText: systemClock.date.toLocaleTimeString(I18n.locale(), SettingsData.getEffectiveTimeFormat()) + readonly property string dateText: systemClock.date.toLocaleDateString(I18n.locale(), SettingsData.getEffectiveDateFormat("ddd MMM d")) + readonly property bool tight: root.controller.homeCompactTight + readonly property real slotSize: root.tight ? 24 : 32 + readonly property real slotSpacing: root.controller.homeSlotSpacing + readonly property bool weatherSlotEnabled: root.controller.homeWeatherSlot !== "hidden" + readonly property var leftActionIds: homeActionsForSide("left") + readonly property var rightActionIds: homeActionsForSide("right") + property bool weatherRefHeld: false + + function homeActionsForSide(side) { + const ids = []; + if (root.controller.homeMediaSlot === side) + ids.push("media"); + if (root.controller.homeWeatherSlot === side) + ids.push("weather"); + if (root.controller.homeStatusSlot === side) + ids.push("status"); + return ids; + } + + function syncWeatherRef(wanted) { + if (wanted === weatherRefHeld) + return; + weatherRefHeld = wanted; + if (wanted) { + WeatherService.addRef(); + return; + } + WeatherService.removeRef(); + } + + onWeatherSlotEnabledChanged: root.syncWeatherRef(root.weatherSlotEnabled) + Component.onCompleted: { + root.syncWeatherRef(root.weatherSlotEnabled); + root.controller.setHomeContentWidth(compactRow.implicitWidth); + } + Component.onDestruction: root.syncWeatherRef(false) + + Connections { + target: compactRow + + function onImplicitWidthChanged() { + root.controller.setHomeContentWidth(compactRow.implicitWidth); + } + } + + SystemClock { + id: systemClock + + precision: SettingsData.showSeconds ? SystemClock.Seconds : SystemClock.Minutes + } + + component HomeActionSlot: Item { + id: slot + + required property string actionId + required property var controller + required property var mediaModel + property real slotSize: 32 + property bool tight: false + + readonly property bool isMedia: actionId === "media" + readonly property bool isStatus: actionId === "status" + readonly property bool isWeather: actionId === "weather" + readonly property bool usesBattery: isStatus && BatteryService.batteryAvailable + readonly property string weatherTempText: { + if (!WeatherService.weather.available) + return "--°"; + const temp = SettingsData.useFahrenheit ? WeatherService.weather.tempF : WeatherService.weather.temp; + return Math.round(Number(temp)) + "°"; + } + + implicitWidth: { + if (usesBattery) + return statusPill.width + 8; + if (isWeather) + return weatherRow.implicitWidth; + return slot.slotSize; + } + implicitHeight: slot.slotSize + width: implicitWidth + height: implicitHeight + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: slotArea.containsMouse && !slot.usesBattery ? Theme.surfaceTextHover : "transparent" + } + + MediaCava { + anchors.centerIn: parent + width: slot.tight ? 16 : 20 + height: slot.tight ? 16 : 20 + visible: slot.isMedia && slot.controller.mediaAvailable + mediaModel: slot.mediaModel + } + + DankIcon { + anchors.centerIn: parent + visible: slot.isMedia && !slot.controller.mediaAvailable + name: "search" + size: slot.tight ? 16 : Theme.iconSizeSmall + color: Theme.surfaceTextMedium + } + + BatteryPill { + id: statusPill + + anchors.centerIn: parent + visible: slot.usesBattery + hovered: slotArea.containsMouse + outlined: SettingsData.dankIslandBatteryStyle === "outline" + } + + DankIcon { + anchors.centerIn: parent + visible: slot.isStatus && !slot.usesBattery + name: "tune" + size: slot.tight ? 16 : Theme.iconSizeSmall + color: Theme.surfaceText + } + + Row { + id: weatherRow + + anchors.centerIn: parent + spacing: 2 + visible: slot.isWeather + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: WeatherService.getWeatherIcon(WeatherService.weather.wCode) + size: slot.tight ? 16 : Theme.iconSizeSmall + color: Theme.primary + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: slot.weatherTempText + color: Theme.surfaceTextSecondary + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + } + + IslandSlotHoverArea { + id: slotArea + + anchors.fill: parent + controller: slot.controller + onClicked: { + if (slot.isMedia) { + if (slot.controller.mediaAvailable) + slot.controller.requestActivity("media", false, false); + else + slot.controller.requestLauncher("", "", false); + return; + } + if (slot.isWeather) { + slot.controller.requestWeather(false); + return; + } + slot.controller.requestControlCenter("", false); + } + } + } + + Row { + id: compactRow + + anchors.centerIn: parent + spacing: root.controller.homeClusterGap + + Row { + id: leftActions + + anchors.verticalCenter: parent.verticalCenter + spacing: root.slotSpacing + visible: root.leftActionIds.length > 0 + + Repeater { + model: root.leftActionIds + + HomeActionSlot { + required property var modelData + actionId: String(modelData) + controller: root.controller + mediaModel: root.mediaModel + slotSize: root.slotSize + tight: root.tight + } + } + } + + Row { + id: clockRow + + anchors.verticalCenter: parent.verticalCenter + spacing: root.tight ? Theme.spacingXS : Theme.spacingS + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.timeText + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: root.tight ? Theme.fontSizeSmall : Theme.fontSizeMedium + font.weight: Font.DemiBold + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: root.tight ? 3 : 4 + height: root.tight ? 3 : 4 + radius: width / 2 + color: Theme.primary + } + + Text { + id: dateLabel + + anchors.verticalCenter: parent.verticalCenter + text: root.dateText + color: Theme.surfaceTextMedium + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + Item { + anchors.verticalCenter: parent.verticalCenter + visible: root.controller.homeNotificationBadge + width: visible ? 10 : 0 + height: 16 + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 1 + width: 5 + height: 5 + radius: 2.5 + color: Theme.secondary + } + + IslandSlotHoverArea { + anchors.fill: parent + controller: root.controller + onClicked: root.controller.requestNotificationCenter(false) + } + } + } + + Row { + id: rightActions + + anchors.verticalCenter: parent.verticalCenter + spacing: root.slotSpacing + visible: root.rightActionIds.length > 0 + + Repeater { + model: root.rightActionIds + + HomeActionSlot { + required property var modelData + actionId: String(modelData) + controller: root.controller + mediaModel: root.mediaModel + slotSize: root.slotSize + tight: root.tight + } + } + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml b/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml new file mode 100644 index 000000000..a2e5999d6 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/HomeExpanded.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.DankDash +import qs.Widgets + +FocusScope { + id: root + + required property var islandController + + property bool contentStaged: false + + readonly property real stageWidth: root.islandController.homeExpandedTarget.width - 2 + readonly property real stageHeight: root.islandController.homeExpandedTarget.height - 2 + readonly property real faceInset: Theme.spacingM + readonly property var overviewTab: tabLoader.item + + clip: true + + function focusOverview() { + root.forceActiveFocus(); + } + + Loader { + id: tabLoader + + x: root.faceInset + width: root.stageWidth - root.faceInset * 2 + height: tabLoader.item?.implicitHeight || 410 + y: Math.round((root.stageHeight - height) / 2) + active: root.contentStaged + asynchronous: true + visible: status === Loader.Ready + sourceComponent: overviewTabComponent + } + + Component { + id: overviewTabComponent + + OverviewTab { + live: root.islandController.expanded && root.islandController.activeActivity === "home" + onCloseDash: root.islandController.requestCollapse() + onNavFocusRequested: root.focusOverview() + onSwitchToMediaTab: root.islandController.requestActivity("media", true, true) + onSwitchToWeatherTab: { + if (SettingsData.weatherEnabled) + root.islandController.requestWeather(false); + } + Component.onCompleted: root.focusOverview() + } + } + + DankSpinner { + anchors.centerIn: parent + size: 40 + visible: !tabLoader.visible + } + + Keys.onPressed: event => { + if (root.overviewTab?.handleKeyEvent(event) === true) { + event.accepted = true; + return; + } + if (event.key === Qt.Key_Escape) { + root.islandController.requestCollapse(); + event.accepted = true; + } + } + + Component.onCompleted: root.contentStaged = true +} diff --git a/quickshell/Modules/DankIsland/Activities/IslandSlotHoverArea.qml b/quickshell/Modules/DankIsland/Activities/IslandSlotHoverArea.qml new file mode 100644 index 000000000..68b16b5b5 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/IslandSlotHoverArea.qml @@ -0,0 +1,29 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +// Compact-face slot click target; hover here suppresses island hover-expand. +MouseArea { + id: root + + required property var controller + property bool hoverCounted: false + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onEntered: { + hoverCounted = true; + controller.slotHoverEntered(); + } + onExited: { + if (!hoverCounted) + return; + hoverCounted = false; + controller.slotHoverExited(); + } + Component.onDestruction: { + if (hoverCounted) + controller.slotHoverExited(); + } +} diff --git a/quickshell/Modules/DankIsland/Activities/LauncherCompact.qml b/quickshell/Modules/DankIsland/Activities/LauncherCompact.qml new file mode 100644 index 000000000..11ca943a6 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/LauncherCompact.qml @@ -0,0 +1,141 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + required property var controller + + readonly property real logoSize: Math.max(12, Theme.iconSizeSmall + SettingsData.launcherLogoSizeOffset) + readonly property string logoMode: SettingsData.launcherLogoMode + readonly property bool compositorAvailable: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc + readonly property bool showCustom: logoMode === "custom" && SettingsData.launcherLogoCustomPath !== "" + readonly property bool showCompositor: logoMode === "compositor" && root.compositorAvailable + readonly property bool showOs: logoMode === "os" + readonly property bool showDank: logoMode === "dank" + readonly property bool showApps: !root.showCustom && !root.showCompositor && !root.showOs && !root.showDank + readonly property color iconColor: Theme.effectiveLogoColor !== "" ? Theme.effectiveLogoColor : Theme.surfaceText + readonly property string compositorSource: { + if (CompositorService.isNiri) + return "file://" + Theme.shellDir + "/assets/niri.svg"; + if (CompositorService.isHyprland) + return "file://" + Theme.shellDir + "/assets/hyprland.svg"; + if (CompositorService.isMango) + return "file://" + Theme.shellDir + "/assets/mango.png"; + if (CompositorService.isSway || CompositorService.isScroll) + return "file://" + Theme.shellDir + "/assets/sway.svg"; + if (CompositorService.isMiracle) + return "file://" + Theme.shellDir + "/assets/miraclewm.svg"; + if (CompositorService.isLabwc) + return "file://" + Theme.shellDir + "/assets/labwc.png"; + return ""; + } + + function pushMeasuredWidth() { + root.controller.setDestinationContentWidth("launcher", compactRow.implicitWidth); + } + + Component.onCompleted: root.pushMeasuredWidth() + + Row { + id: compactRow + + anchors.centerIn: parent + spacing: Theme.spacingS + + onImplicitWidthChanged: root.pushMeasuredWidth() + + Item { + width: root.logoSize + height: root.logoSize + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + visible: root.showApps + anchors.centerIn: parent + name: "apps" + size: root.logoSize + color: root.iconColor + } + + SystemLogo { + visible: root.showOs + anchors.centerIn: parent + width: root.logoSize + height: root.logoSize + colorOverride: root.iconColor + brightnessOverride: SettingsData.launcherLogoBrightness + contrastOverride: SettingsData.launcherLogoContrast + } + + IconImage { + visible: root.showDank + anchors.centerIn: parent + width: root.logoSize + height: root.logoSize + smooth: true + mipmap: true + asynchronous: true + source: "file://" + Theme.shellDir + "/assets/danklogo.svg" + layer.enabled: true + layer.smooth: true + layer.mipmap: true + layer.effect: MultiEffect { + saturation: 0 + colorization: 1 + colorizationColor: root.iconColor + } + } + + IconImage { + visible: root.showCompositor + anchors.centerIn: parent + width: root.logoSize + height: root.logoSize + smooth: true + asynchronous: true + source: root.compositorSource + layer.enabled: true + layer.effect: MultiEffect { + saturation: 0 + colorization: 1 + colorizationColor: root.iconColor + brightness: SettingsData.launcherLogoBrightness + contrast: SettingsData.launcherLogoContrast + } + } + + IconImage { + visible: root.showCustom + anchors.centerIn: parent + width: root.logoSize + height: root.logoSize + smooth: true + asynchronous: true + source: SettingsData.launcherLogoCustomPath ? "file://" + SettingsData.launcherLogoCustomPath.replace("file://", "") : "" + layer.enabled: true + layer.effect: MultiEffect { + saturation: 0 + colorization: 1 + colorizationColor: root.iconColor + brightness: SettingsData.launcherLogoBrightness + contrast: SettingsData.launcherLogoContrast + } + } + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Launcher") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.DemiBold + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/LauncherExpanded.qml b/quickshell/Modules/DankIsland/Activities/LauncherExpanded.qml new file mode 100644 index 000000000..fcd250d5f --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/LauncherExpanded.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modals.DankLauncherV2 + +FocusScope { + id: root + + required property var islandController + required property var launcherController + property var transientSurfaceTracker: null + property var effectiveScreen: null + property real screenWidth: effectiveScreen?.width ?? 1920 + property real screenHeight: effectiveScreen?.height ?? 1080 + property real alignedX: 0 + property real alignedY: 0 + property real bottomInset: 18 + + clip: true + + function focusSearch() { + launcherContent.searchField.forceActiveFocus(); + } + + function initializeSession() { + const targetQuery = islandController.launcherPendingQuery || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : ""); + const targetMode = islandController.launcherPendingMode || SessionData.getLauncherRestoreMode(); + + launcherContent.closeTransientUi?.(); + launcherController.reset(); + launcherController.explicitQuerySession = !!islandController.launcherPendingQuery; + launcherController.searchMode = targetMode; + launcherController.historyIndex = -1; + + launcherContent.suspendSearchUpdates = true; + launcherContent.searchField.text = targetQuery; + launcherContent.suspendSearchUpdates = false; + if (targetQuery.length > 0) + launcherController.setSearchQuery(targetQuery); + else + launcherController.performSearch(); + + launcherContent.resetScroll(); + root.focusSearch(); + if (islandController.launcherPendingQuery) + launcherContent.searchField.cursorPosition = targetQuery.length; + else + launcherContent.searchField.selectAll(); + } + + QtObject { + id: hostContract + + readonly property bool spotlightOpen: root.islandController.launcherSessionActive + readonly property bool isClosing: false + readonly property bool contentVisible: true + readonly property var effectiveScreen: root.effectiveScreen + readonly property real screenWidth: root.screenWidth + readonly property real screenHeight: root.screenHeight + readonly property real alignedX: root.alignedX + readonly property real alignedY: root.alignedY + + function hide() { + root.islandController.requestCollapse(); + } + } + + SpotlightLauncherContent { + id: launcherContent + + anchors.fill: parent + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.topMargin: Theme.spacingM + anchors.bottomMargin: root.bottomInset + parentModal: hostContract + controllerOverride: root.launcherController + transientSurfaceTracker: root.transientSurfaceTracker + showResultsWithoutQuery: true + maxResultsHeight: Math.max(120, islandController.launcherExpandedTarget.height - Theme.spacingM - root.bottomInset - launcherContent.searchAreaHeight) + } + + onActiveFocusChanged: root.islandController.launcherInputFocused = activeFocus + + Connections { + target: root.islandController + + function onLauncherSessionSerialChanged() { + Qt.callLater(root.initializeSession); + } + } + + Component.onCompleted: root.islandController.markLauncherVisualsReady() + Component.onDestruction: root.islandController.launcherInputFocused = false +} diff --git a/quickshell/Modules/DankIsland/Activities/MediaArtwork.qml b/quickshell/Modules/DankIsland/Activities/MediaArtwork.qml new file mode 100644 index 000000000..da40c991f --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/MediaArtwork.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Widgets +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var mediaModel + property real cornerRadius: Math.min(width, height) * 0.22 + readonly property int artPixelSize: Math.max(1, Math.round(Math.max(width, height) * 2)) + + ClippingRectangle { + anchors.fill: parent + radius: root.cornerRadius + color: Theme.primaryContainer + antialiasing: true + + DankIcon { + anchors.centerIn: parent + name: "music_note" + size: Math.min(parent.width, parent.height) * 0.46 + color: Theme.primary + visible: artwork.status !== Image.Ready + } + + Image { + id: artwork + + anchors.fill: parent + source: root.visible && root.width > 0 ? root.mediaModel.artUrl : "" + asynchronous: true + cache: true + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(root.artPixelSize, root.artPixelSize) + visible: status === Image.Ready + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/MediaBlobHalo.qml b/quickshell/Modules/DankIsland/Activities/MediaBlobHalo.qml new file mode 100644 index 000000000..2bddfebfc --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/MediaBlobHalo.qml @@ -0,0 +1,157 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services + +Item { + id: root + + property color accentColor: Theme.primary + property bool playing: false + property real animationScale: 1.0 + + readonly property bool onScreen: visible && enabled && (Window.window?.visible ?? false) + readonly property bool available: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled + readonly property bool blobActive: root.onScreen && root.playing && root.available + + readonly property real blobBaseRadiusFactor: 0.43 + readonly property real blobAmplitudeFactor: 0.115 + readonly property real blobOvershoot: 1.15 + readonly property real blobEnergySensitivity: 1.15 + readonly property real cavaFullScale: 45 + readonly property real blobAttack: 0.75 + readonly property real blobRelease: 0.2 + readonly property real blobBeatBoost: 2.5 + readonly property real blobBeatKick: 4 + readonly property real blobOnsetThreshold: 1.4 + readonly property real blobSpringStiffness: 220 + readonly property real blobSpringDamping: 19 + readonly property real blobMorphSpeed: 0.05 + readonly property real blobMorphBoost: 1.7 + readonly property real blobSpinSpeed: 0.03 + readonly property var fluxWeights: [1.0, 1.0, 0.6, 0.6, 0.35, 0.35] + + property var smoothedBands: [0, 0, 0, 0, 0, 0] + property var slowBands: [0, 0, 0, 0, 0, 0] + property var bandTargets: [0, 0, 0, 0, 0, 0] + property var bandDisplay: [0, 0, 0, 0, 0, 0] + property var prevLevels: [0, 0, 0, 0, 0, 0] + property real fluxAvg: 0.02 + property real loudCtx: 0.1 + property int beatCooldown: 0 + property real energyTarget: 0 + property real energyPos: 0 + property real energyVel: 0 + + function updateBands() { + const vals = CavaService.values; + if (!vals || vals.length < 6) + return; + + const s = smoothedBands; + const slow = slowBands; + const out = bandTargets; + const prev = prevLevels; + const w = fluxWeights; + let flux = 0; + for (let i = 0; i < 6; i++) { + const level = Math.min(Math.max(vals[i], 0), cavaFullScale) / cavaFullScale; + flux += Math.max(0, level - prev[i]) * w[i]; + prev[i] = level; + const alpha = level > s[i] ? blobAttack : blobRelease; + s[i] += alpha * (level - s[i]); + slow[i] += 0.05 * (level - slow[i]); + const punch = Math.max(0, s[i] - slow[i]) * blobBeatBoost; + out[i] = Math.min(1, (0.55 * s[i] + punch) * blobEnergySensitivity); + } + + const ratio = flux / Math.max(fluxAvg, 0.004); + fluxAvg += 0.06 * (flux - fluxAvg); + if (beatCooldown > 0) { + beatCooldown--; + } else if (ratio > blobOnsetThreshold && flux > 0.008) { + energyVel += blobBeatKick * Math.min(2.5, ratio - 1); + beatCooldown = 3; + } + + const loud = 0.7 * Math.max(prev[0], prev[1]) + 0.3 * Math.max(prev[2], prev[3]); + loudCtx += 0.03 * (loud - loudCtx); + const surge = Math.max(0, loud / Math.max(loudCtx, 0.05) - 1); + energyTarget = Math.min(1, 0.5 * loud + 0.6 * Math.min(1, surge)); + } + + function stepBlob(dt) { + energyVel += (blobSpringStiffness * (energyTarget - energyPos) - blobSpringDamping * energyVel) * dt; + energyPos = Math.max(0, Math.min(blobOvershoot, energyPos + energyVel * dt)); + blobEffect.energy = energyPos; + + const d = bandDisplay; + const t = bandTargets; + const f = Math.min(1, dt * 14); + for (let i = 0; i < 6; i++) + d[i] += f * (t[i] - d[i]); + blobEffect.bandsA = Qt.vector4d(d[0], d[1], d[2], d[3]); + blobEffect.bandsB = Qt.vector2d(d[4], d[5]); + + const speed = 1 + energyPos * blobMorphBoost; + blobEffect.phase = (blobEffect.phase + dt * blobMorphSpeed * speed) % 1; + blobEffect.spin = (blobEffect.spin + dt * blobSpinSpeed) % 6.28318530718; + } + + Loader { + active: root.blobActive + sourceComponent: Component { + Ref { + service: CavaService + } + } + } + + Connections { + target: CavaService + enabled: blobEffect.visible && root.available + + function onValuesChanged() { + root.updateBands(); + } + } + + Timer { + running: blobEffect.visible && root.onScreen + interval: 33 + repeat: true + onTriggered: root.stepBlob(0.033) + } + + ShaderEffect { + id: blobEffect + + readonly property real span: Math.min(root.width, root.height) + + width: span * (root.blobBaseRadiusFactor + root.blobAmplitudeFactor * root.blobOvershoot) * 2 * root.animationScale + 4 + height: width + anchors.centerIn: parent + visible: root.blobActive || activation > 0.004 + + property real phase: 0 + property real spin: 0 + property real sizePx: width + property real baseRadiusPx: span * root.blobBaseRadiusFactor * root.animationScale + property real amplitudePx: span * root.blobAmplitudeFactor * root.animationScale + property real activation: root.blobActive ? 1 : 0 + property real energy: 0 + property vector4d bandsA: Qt.vector4d(0, 0, 0, 0) + property vector2d bandsB: Qt.vector2d(0, 0) + property vector4d fillColor: Qt.vector4d(root.accentColor.r, root.accentColor.g, root.accentColor.b, root.accentColor.a) + + Behavior on activation { + NumberAnimation { + duration: 550 + easing.type: Easing.InOutQuad + } + } + + fragmentShader: Qt.resolvedUrl("../../../Shaders/qsb/blob.frag.qsb") + } +} diff --git a/quickshell/Modules/DankIsland/Activities/MediaCava.qml b/quickshell/Modules/DankIsland/Activities/MediaCava.qml new file mode 100644 index 000000000..61dc86101 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/MediaCava.qml @@ -0,0 +1,117 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var mediaModel: null + property bool isPlaying: mediaModel?.playing ?? false + property color barColor: Theme.primary + + readonly property bool available: CavaService.cavaAvailable && SettingsData.audioVisualizerEnabled + readonly property bool live: visible && enabled && (Window.window?.visible ?? false) && root.isPlaying && root.available + readonly property real maxBarHeight: Math.max(root.minBarHeight, height - 2) + readonly property real minBarHeight: 3 + + property vector4d bandsA: Qt.vector4d(0, 0, 0, 0) + property vector2d bandsB: Qt.vector2d(0, 0) + + function applyIdleBands() { + bandsA = Qt.vector4d(0.3, 0.55, 0.7, 0.7); + bandsB = Qt.vector2d(0.55, 0.3); + } + + function updateBands() { + const values = CavaService.values; + if (!values || values.length < 6) + return; + + function normalized(index) { + const value = Math.min(Math.max(values[index], 0), 100) / 100; + return Math.round(Math.sqrt(value + 0.01) * 32) / 32; + } + + const nextA = Qt.vector4d(normalized(0), normalized(1), normalized(2), normalized(3)); + const nextB = Qt.vector2d(normalized(4), normalized(5)); + if (nextA == bandsA && nextB == bandsB) + return; + bandsA = nextA; + bandsB = nextB; + } + + onLiveChanged: { + if (live) + updateBands(); + else + applyIdleBands(); + } + + onAvailableChanged: { + if (!available) + return; + if (live) + updateBands(); + else + applyIdleBands(); + } + + Component.onCompleted: { + if (root.live) + updateBands(); + else + applyIdleBands(); + } + + Loader { + active: root.live && root.available + sourceComponent: Component { + Ref { + service: CavaService + } + } + } + + Connections { + target: CavaService + enabled: root.live && root.available + + function onValuesChanged() { + root.updateBands(); + } + } + + Loader { + active: root.available && root.live + anchors.fill: parent + sourceComponent: cavaShader + } + + Component { + id: cavaShader + + ShaderEffect { + property real widthPx: width + property real heightPx: height + property real minH: root.minBarHeight + property real maxH: root.maxBarHeight + property vector4d bandsA: root.bandsA + property vector2d bandsB: root.bandsB + property vector4d fillColor: Qt.vector4d(root.barColor.r, root.barColor.g, root.barColor.b, root.barColor.a) + + opacity: root.live ? 1 : 0.45 + fragmentShader: Qt.resolvedUrl("../../../Shaders/qsb/viz_bars.frag.qsb") + } + } + + DankIcon { + anchors.centerIn: parent + visible: !root.available || !root.live + name: "graphic_eq" + size: Math.min(parent.width, parent.height) + color: root.barColor + } +} diff --git a/quickshell/Modules/DankIsland/Activities/MediaCompact.qml b/quickshell/Modules/DankIsland/Activities/MediaCompact.qml new file mode 100644 index 000000000..f7d8dbc2a --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/MediaCompact.qml @@ -0,0 +1,241 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Item { + id: root + + required property var mediaModel + required property var controller + + readonly property bool dense: root.controller.compactDense + readonly property real artworkSize: root.controller.compactIconSize + readonly property real textGutter: Theme.spacingXS + readonly property real cavaWidth: 20 + readonly property real minTextWidth: 48 + readonly property string timeText: systemClock.date.toLocaleTimeString(I18n.locale(), SettingsData.getEffectiveTimeFormat()) + readonly property bool clockVisible: SettingsData.dankIslandMediaClockVisible + readonly property real naturalTextWidth: Math.max(root.minTextWidth, titleMetrics.width, root.dense ? 0 : artistMetrics.width) + root.textGutter * 2 + readonly property real measuredWidth: { + let width = Theme.spacingS + root.artworkSize + Theme.spacingXS + root.cavaWidth + Theme.spacingXS + root.naturalTextWidth + Theme.spacingXS; + if (root.clockVisible) + width += clockText.implicitWidth + Theme.spacingXS * 2 + Theme.spacingS; + return width; + } + + function pushMeasuredWidth() { + root.controller.setMediaContentWidth(root.measuredWidth); + } + + onMeasuredWidthChanged: root.pushMeasuredWidth() + Component.onCompleted: root.pushMeasuredWidth() + + TextMetrics { + id: titleMetrics + + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + text: root.mediaModel.title + } + + TextMetrics { + id: artistMetrics + + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + text: root.dense ? "" : root.mediaModel.artist + } + + SystemClock { + id: systemClock + + precision: SettingsData.showSeconds ? SystemClock.Seconds : SystemClock.Minutes + } + + MediaArtwork { + id: artwork + + anchors { + left: parent.left + leftMargin: Theme.spacingS + verticalCenter: parent.verticalCenter + } + width: root.artworkSize + height: width + mediaModel: root.mediaModel + } + + Row { + id: infoRow + + anchors { + left: artwork.right + leftMargin: Theme.spacingXS + right: root.clockVisible ? rightActivity.left : parent.right + rightMargin: Theme.spacingXS + verticalCenter: parent.verticalCenter + } + height: root.artworkSize + spacing: Theme.spacingXS + + MediaCava { + anchors.verticalCenter: parent.verticalCenter + width: root.cavaWidth + height: parent.height + mediaModel: root.mediaModel + } + + Column { + id: textCol + + width: parent.width - root.cavaWidth - parent.spacing + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + Item { + id: titleClip + + width: parent.width - root.textGutter * 2 + x: root.textGutter + height: titleText.implicitHeight + clip: true + + Text { + id: titleText + + readonly property bool onScreen: Window.window?.visible ?? false + readonly property bool needsScrolling: implicitWidth > titleClip.width && SettingsData.scrollTitleEnabled + readonly property bool scrollActive: needsScrolling && titleClip.visible && root.visible && root.enabled && onScreen && root.mediaModel.playing + readonly property real maxScrollOffset: Math.max(0, implicitWidth - titleClip.width + root.textGutter) + property real scrollOffset: 0 + property int scrollDirection: 1 + property real scrollHoldMs: 1600 + + function resetScroll() { + scrollOffset = 0; + scrollDirection = 1; + scrollHoldMs = 1600; + } + + function stepScroll(deltaMs) { + if (scrollHoldMs > 0) { + scrollHoldMs -= deltaMs; + return; + } + const next = scrollOffset + scrollDirection * deltaMs / 62.5; + if (next >= maxScrollOffset) { + scrollOffset = maxScrollOffset; + scrollDirection = -1; + scrollHoldMs = 1200; + return; + } + if (next <= 0) { + scrollOffset = 0; + scrollDirection = 1; + scrollHoldMs = 1600; + return; + } + scrollOffset = next; + } + + onNeedsScrollingChanged: { + if (!needsScrolling) + resetScroll(); + } + onTextChanged: resetScroll() + + height: implicitHeight + width: needsScrolling ? implicitWidth : titleClip.width + x: needsScrolling ? -scrollOffset : 0 + text: root.mediaModel.title + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + wrapMode: Text.NoWrap + elide: needsScrolling ? Text.ElideNone : Text.ElideRight + + Timer { + interval: 60 + repeat: true + running: titleText.scrollActive + onTriggered: { + if (cavaTickWatch.running) + return; + titleText.stepScroll(60); + } + } + + Timer { + id: cavaTickWatch + interval: 150 + } + + Connections { + target: CavaService + enabled: titleText.scrollActive && SettingsData.audioVisualizerEnabled && CavaService.cavaAvailable + function onValuesChanged() { + cavaTickWatch.restart(); + titleText.stepScroll(40); + } + } + } + } + + Text { + x: root.textGutter + width: parent.width - root.textGutter * 2 + visible: !root.dense + text: root.mediaModel.artist + color: Theme.surfaceTextSecondary + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + elide: Text.ElideRight + } + } + } + + Item { + id: rightActivity + + anchors { + right: parent.right + rightMargin: Theme.spacingS + verticalCenter: parent.verticalCenter + } + width: root.clockVisible ? (clockText.implicitWidth + Theme.spacingXS * 2) : 0 + height: root.dense ? root.artworkSize : 36 + visible: root.clockVisible + + Rectangle { + anchors.centerIn: parent + width: clockText.implicitWidth + Theme.spacingXS * 2 + height: root.dense ? Math.max(22, root.artworkSize - Theme.spacingXS) : 32 + radius: height / 2 + color: clockArea.containsMouse ? Theme.surfaceTextHover : "transparent" + + Text { + id: clockText + + anchors.centerIn: parent + text: root.timeText + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + IslandSlotHoverArea { + id: clockArea + + anchors.fill: parent + controller: root.controller + onClicked: root.controller.requestActivity("home", false, false) + } + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml b/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml new file mode 100644 index 000000000..a71194c20 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/MediaExpanded.qml @@ -0,0 +1,99 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Modules.DankDash +import qs.Widgets + +FocusScope { + id: root + + required property var islandController + property var effectiveScreen: null + property real alignedX: 0 + property real alignedY: 0 + property real alignedWidth: 0 + property real alignedHeight: 0 + + property bool contentStaged: false + property bool geometrySettled: false + readonly property bool menusEnabled: islandController.expanded && geometrySettled + readonly property var playerTab: tabLoader.item + + signal showVolumeDropdown(point pos) + signal showAudioDevicesDropdown(point pos) + signal showPlayersDropdown(point pos) + signal hideDropdowns + signal dropdownHoverEnded + + clip: true + + function focusPlayer() { + root.forceActiveFocus(); + return true; + } + + function resetDropdownStates() { + root.playerTab?.resetDropdownStates(); + } + + Loader { + id: tabLoader + + anchors.fill: parent + active: root.contentStaged + asynchronous: true + visible: status === Loader.Ready + sourceComponent: playerTabComponent + } + + Component { + id: playerTabComponent + + MediaPlayerTab { + chrome: "island" + live: root.islandController.expanded && root.islandController.activeActivity === "media" + targetScreen: root.effectiveScreen + popoutX: root.alignedX + popoutY: root.alignedY + popoutWidth: root.alignedWidth + popoutHeight: root.alignedHeight + contentOffsetY: 0 + section: "right" + menusEnabled: root.menusEnabled + onShowVolumeDropdown: (pos, screen, rightEdge, player, players) => root.showVolumeDropdown(pos) + onShowAudioDevicesDropdown: (pos, screen, rightEdge) => root.showAudioDevicesDropdown(pos) + onShowPlayersDropdown: (pos, screen, rightEdge, player, players) => root.showPlayersDropdown(pos) + onHideDropdowns: root.hideDropdowns() + onDropdownButtonExited: root.dropdownHoverEnded() + Component.onCompleted: root.focusPlayer() + } + } + + DankSpinner { + anchors.centerIn: parent + size: 40 + visible: !tabLoader.visible + } + + Keys.onPressed: event => { + if (root.playerTab?.handleKeyEvent(event) === true) { + event.accepted = true; + return; + } + if (event.key === Qt.Key_Escape) { + root.islandController.requestCollapse(); + event.accepted = true; + } + } + + Connections { + target: root.islandController + + function onMediaDropdownOpenChanged() { + if (!root.islandController.mediaDropdownOpen) + root.resetDropdownStates(); + } + } + + Component.onCompleted: root.contentStaged = true +} diff --git a/quickshell/Modules/DankIsland/Activities/NotificationCenterCompact.qml b/quickshell/Modules/DankIsland/Activities/NotificationCenterCompact.qml new file mode 100644 index 000000000..ffbaa9947 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/NotificationCenterCompact.qml @@ -0,0 +1,44 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + required property var controller + + readonly property int unreadCount: NotificationService.notifications.length + + function pushMeasuredWidth() { + root.controller.setDestinationContentWidth("notificationcenter", compactRow.implicitWidth); + } + + Component.onCompleted: root.pushMeasuredWidth() + + Row { + id: compactRow + + anchors.centerIn: parent + spacing: Theme.spacingS + + onImplicitWidthChanged: root.pushMeasuredWidth() + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: root.unreadCount > 0 ? "notifications_active" : "notifications" + size: Theme.iconSizeSmall + color: Theme.primary + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: root.unreadCount > 0 ? I18n.tr("Notifications (%1)").arg(root.unreadCount) : I18n.tr("Notifications") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.DemiBold + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/NotificationCenterExpanded.qml b/quickshell/Modules/DankIsland/Activities/NotificationCenterExpanded.qml new file mode 100644 index 000000000..71c9c546a --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/NotificationCenterExpanded.qml @@ -0,0 +1,136 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.Notifications.Center +import qs.Services + +// Shared notification center body hosted in the island silhouette. +FocusScope { + id: root + + required property var islandController + property var effectiveScreen: null + property var transientSurfaceTracker: null + property real bottomInset: Theme.spacingM + property bool _sessionOpen: false + property bool _heightReportPending: false + + clip: true + + function focusList() { + root.forceActiveFocus(); + keyboardController.rebuildFlatNavigation(); + return true; + } + + function beginSession() { + if (!hostContract.shouldBeVisible) + return; + root._sessionOpen = true; + // Suppresses transient island cards for as long as the list owns them. + NotificationService.onOverlayOpen(); + keyboardController.reset(); + keyboardController.listView = content.notificationList; + content.notificationList.keyboardController = keyboardController; + content.notificationHeader.keyboardController = keyboardController; + keyboardController.rebuildFlatNavigation(); + root.forceActiveFocus(); + root.queueHeightReport(); + } + + function endSession() { + if (!root._sessionOpen) + return; + root._sessionOpen = false; + keyboardController.keyboardNavigationActive = false; + NotificationService.expandedGroups = {}; + NotificationService.expandedMessages = {}; + NotificationService.onOverlayClose(); + } + + function queueHeightReport() { + if (root._heightReportPending) + return; + root._heightReportPending = true; + Qt.callLater(() => { + root._heightReportPending = false; + content.notificationList.syncSessionContentHeight(); + root.islandController.setNotificationCenterContentHeight(content.targetImplicitHeight + root.bottomInset); + }); + } + + function prepareVisuals() { + content.notificationList.syncSessionContentHeight(); + root.islandController.setNotificationCenterContentHeight(content.targetImplicitHeight + root.bottomInset); + root.islandController.markNotificationCenterVisualsReady(); + } + + QtObject { + id: hostContract + + readonly property bool shouldBeVisible: root.islandController.activeActivity === "notificationcenter" && root.islandController.expanded + readonly property var screen: root.effectiveScreen + readonly property var transientSurfaceTracker: root.transientSurfaceTracker + readonly property real maxContentHeight: root.islandController.notificationCenterMaxHeight - root.bottomInset + readonly property bool hostOwnsHeight: true + readonly property bool headerTogglesClose: true + readonly property bool animateCardExpansion: false + readonly property bool lightweightNotifications: true + + function close() { + root.islandController.requestCollapse(); + } + + function requestSettings() { + root.islandController.requestCollapse(); + PopoutService.openSettingsWithTab("notifications", root, () => root.islandController.requestNotificationCenter(false)); + } + } + + NotificationKeyboardController { + id: keyboardController + + listView: null + isOpen: hostContract.shouldBeVisible + onClose: () => root.islandController.requestCollapse() + } + + Keys.onPressed: event => content.handleKey(event) + + NotificationCenterContent { + id: content + + anchors { + top: parent.top + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + bottomMargin: root.bottomInset + } + width: root.islandController.notificationCenterExpandedTarget.width + host: hostContract + externalKeyboardController: keyboardController + onTargetImplicitHeightChanged: root.queueHeightReport() + } + + Connections { + target: root.islandController + + function onNotificationCenterSessionSerialChanged() { + Qt.callLater(root.beginSession); + } + + function onExpandedChanged() { + if (!root.islandController.expanded) + root.endSession(); + } + + function onActiveActivityChanged() { + if (root.islandController.activeActivity !== "notificationcenter") + root.endSession(); + } + } + + Component.onCompleted: Qt.callLater(root.prepareVisuals) + Component.onDestruction: root.endSession() +} diff --git a/quickshell/Modules/DankIsland/Activities/NotificationCompact.qml b/quickshell/Modules/DankIsland/Activities/NotificationCompact.qml new file mode 100644 index 000000000..4ac3c0b2a --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/NotificationCompact.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var notificationModel + property bool dense: false + property real iconSize: 36 + + Row { + anchors { + fill: parent + leftMargin: Theme.spacingS + rightMargin: Theme.spacingS + } + spacing: Theme.spacingS + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: root.notificationModel.critical ? 3 : 0 + height: Math.max(12, parent.height - Theme.spacingS) + radius: 1.5 + color: Theme.error + visible: root.notificationModel.critical + } + + DankCircularImage { + anchors.verticalCenter: parent.verticalCenter + width: root.iconSize + height: width + imageSource: root.notificationModel.imageSource + fallbackIcon: root.notificationModel.fallbackIcon + fallbackText: root.notificationModel.fallbackText + cacheImages: false + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - root.iconSize - parent.spacing - (root.notificationModel.critical ? 3 + parent.spacing : 0) + spacing: 1 + + // A dense pill keeps one line, so the app name folds into the summary. + Text { + width: parent.width + visible: !root.dense + text: root.notificationModel.appName + (root.notificationModel.timeText ? " · " + root.notificationModel.timeText : "") + color: Theme.surfaceTextSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.dense && root.notificationModel.appName ? root.notificationModel.appName + " · " + root.notificationModel.summary : root.notificationModel.summary + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: root.dense ? Theme.fontSizeSmall : Theme.fontSizeMedium + font.weight: Font.DemiBold + elide: Text.ElideRight + } + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/NotificationExpanded.qml b/quickshell/Modules/DankIsland/Activities/NotificationExpanded.qml new file mode 100644 index 000000000..ee058c093 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/NotificationExpanded.qml @@ -0,0 +1,195 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var notificationModel + + Item { + anchors { + fill: parent + margins: 22 + } + + Row { + id: header + + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: 46 + spacing: Theme.spacingM + + DankCircularImage { + anchors.verticalCenter: parent.verticalCenter + width: 46 + height: 46 + imageSource: root.notificationModel.imageSource + fallbackIcon: root.notificationModel.fallbackIcon + fallbackText: root.notificationModel.fallbackText + cacheImages: false + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 46 - closeButton.width - parent.spacing * 2 + spacing: 2 + + Text { + width: parent.width + text: root.notificationModel.appName + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.notificationModel.timeText + color: Theme.surfaceTextSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + Rectangle { + id: closeButton + + anchors.verticalCenter: parent.verticalCenter + width: 38 + height: 38 + radius: 19 + color: closeArea.containsMouse ? Theme.surfaceTextHover : "transparent" + + DankIcon { + anchors.centerIn: parent + name: "close" + size: 21 + color: Theme.surfaceText + } + + MouseArea { + id: closeArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.notificationModel.dismiss() + } + } + } + + Column { + anchors { + left: parent.left + right: parent.right + top: header.bottom + topMargin: Theme.spacingM + bottom: actions.top + bottomMargin: Theme.spacingM + } + spacing: Theme.spacingS + + Text { + width: parent.width + text: root.notificationModel.summary + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 2 + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.notificationModel.body + color: Theme.surfaceTextMedium + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 3 + elide: Text.ElideRight + visible: text.length > 0 + } + } + + Row { + id: actions + + anchors { + right: parent.right + bottom: parent.bottom + } + height: 34 + spacing: Theme.spacingS + + Rectangle { + width: dismissLabel.implicitWidth + Theme.spacingL * 2 + height: parent.height + radius: height / 2 + color: dismissArea.containsMouse ? Theme.surfaceTextHover : "transparent" + border.width: 1 + border.color: Theme.withAlpha(Theme.outline, 0.5) + + Text { + id: dismissLabel + + anchors.centerIn: parent + text: "Dismiss" + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + + MouseArea { + id: dismissArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.notificationModel.dismiss() + } + } + + Rectangle { + visible: root.notificationModel.hasAction + width: actionLabel.implicitWidth + Theme.spacingL * 2 + height: parent.height + radius: height / 2 + color: actionArea.containsMouse ? Theme.primaryContainer : Theme.primary + + Text { + id: actionLabel + + anchors.centerIn: parent + text: root.notificationModel.actionLabel + color: actionArea.containsMouse ? Theme.primary : Theme.onPrimary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + MouseArea { + id: actionArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.notificationModel.activate() + } + } + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml b/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml new file mode 100644 index 000000000..42c283796 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/SystemLevelCompact.qml @@ -0,0 +1,30 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.OSD + +Item { + id: root + + required property var systemModel + property bool dense: false + property real iconSize: 36 + + OsdLevelRow { + anchors.fill: parent + iconName: root.systemModel.iconName + iconSize: Math.min(Theme.iconSize, root.iconSize) + iconColor: root.systemModel.volumeActivity ? Theme.surfaceText : Theme.primary + iconInteractive: root.systemModel.volumeActivity + value: Math.round(root.systemModel.value) + minimum: root.systemModel.minimum + maximum: Math.max(root.systemModel.minimum + 1, Math.round(root.systemModel.maximum)) + unit: root.systemModel.unit + displayText: root.systemModel.displayValue + sliderEnabled: root.systemModel.available + thumbOutlineColor: Theme.surfaceContainerHigh + onIconClicked: root.systemModel.toggleMute() + onSliderValueChanged: newValue => root.systemModel.setRatio(newValue / Math.max(1, Math.round(root.systemModel.maximum))) + } +} diff --git a/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml b/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml new file mode 100644 index 000000000..0ab1b6405 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/SystemLevelExpanded.qml @@ -0,0 +1,129 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var systemModel + + Column { + anchors { + fill: parent + margins: 24 + } + spacing: Theme.spacingL + + Row { + width: parent.width + spacing: Theme.spacingM + + Rectangle { + width: 48 + height: 48 + radius: 18 + color: Theme.primaryContainer + + DankIcon { + anchors.centerIn: parent + name: root.systemModel.iconName + size: 27 + color: Theme.primary + } + + MouseArea { + anchors.fill: parent + enabled: root.systemModel.volumeActivity + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.systemModel.toggleMute() + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 48 - valueText.width - parent.spacing * 2 + spacing: 2 + + Text { + text: root.systemModel.title + color: Theme.surfaceText + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Text { + text: root.systemModel.volumeActivity ? "Click the icon to mute" : "Display level" + color: Theme.surfaceTextSecondary + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Text { + id: valueText + + anchors.verticalCenter: parent.verticalCenter + text: root.systemModel.displayValue + color: Theme.primary + font.family: Theme.fontFamily + font.pixelSize: 28 + font.weight: Font.DemiBold + } + } + + Item { + id: slider + + width: parent.width + height: 40 + + Rectangle { + id: track + + anchors.verticalCenter: parent.verticalCenter + width: parent.width + height: 12 + radius: 6 + color: Theme.withAlpha(Theme.surfaceText, 0.14) + + Rectangle { + width: parent.width * root.systemModel.ratio + height: parent.height + radius: parent.radius + color: Theme.primary + } + + Rectangle { + x: Math.max(0, Math.min(parent.width - width, parent.width * root.systemModel.ratio - width / 2)) + anchors.verticalCenter: parent.verticalCenter + width: 22 + height: 22 + radius: 11 + color: Theme.primary + border.width: 3 + border.color: Theme.surfaceContainerHigh + } + } + + MouseArea { + anchors.fill: parent + enabled: root.systemModel.available + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + function updateValue(mouse) { + root.systemModel.setRatio(mouse.x / width); + } + + onPressed: mouse => updateValue(mouse) + onPositionChanged: mouse => { + if (pressed) + updateValue(mouse); + } + } + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/WallpaperCompact.qml b/quickshell/Modules/DankIsland/Activities/WallpaperCompact.qml new file mode 100644 index 000000000..800a87547 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/WallpaperCompact.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var controller + + function pushMeasuredWidth() { + root.controller.setDestinationContentWidth("wallpaper", compactRow.implicitWidth); + } + + Component.onCompleted: root.pushMeasuredWidth() + + Row { + id: compactRow + + anchors.centerIn: parent + spacing: Theme.spacingS + + onImplicitWidthChanged: root.pushMeasuredWidth() + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "wallpaper" + size: Theme.iconSizeSmall + color: Theme.primary + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Wallpaper") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.DemiBold + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/WallpaperExpanded.qml b/quickshell/Modules/DankIsland/Activities/WallpaperExpanded.qml new file mode 100644 index 000000000..1a01f627e --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/WallpaperExpanded.qml @@ -0,0 +1,98 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.DankDash +import qs.Widgets + +FocusScope { + id: root + + required property var islandController + property var effectiveScreen: null + + property bool contentStaged: false + + readonly property real stageWidth: root.islandController.wallpaperExpandedTarget.width - 2 + readonly property real stageHeight: root.islandController.wallpaperExpandedTarget.height - 2 + readonly property var wallpaperTab: tabLoader.item + + clip: true + + function focusGrid() { + root.forceActiveFocus(); + } + + function beginSession() { + root.wallpaperTab?.collapseSearch(); + root.focusGrid(); + } + + QtObject { + id: hostContract + + property var customKeyboardFocus: null + + onCustomKeyboardFocusChanged: root.islandController.keyboardYielded = hostContract.customKeyboardFocus !== null + } + + Loader { + id: tabLoader + + x: Theme.spacingM + y: Theme.spacingM + width: root.stageWidth - Theme.spacingM * 2 + height: root.stageHeight - Theme.spacingM - Theme.spacingS + active: root.contentStaged + asynchronous: true + visible: status === Loader.Ready + sourceComponent: wallpaperTabComponent + } + + Component { + id: wallpaperTabComponent + + WallpaperTab { + active: true + pagerCachePages: 0 + targetScreen: root.effectiveScreen + parentPopout: hostContract + Component.onCompleted: root.focusGrid() + } + } + + DankSpinner { + anchors.centerIn: parent + size: 40 + visible: !tabLoader.visible + } + + Keys.onPressed: event => { + if (root.wallpaperTab?.handleKeyEvent(event) === true) { + event.accepted = true; + return; + } + if (event.key === Qt.Key_Escape) { + root.islandController.requestCollapse(); + event.accepted = true; + } + } + + Connections { + target: root.islandController + + function onWallpaperSessionSerialChanged() { + Qt.callLater(root.beginSession); + } + } + + Component.onCompleted: { + root.contentStaged = true; + root.islandController.markWallpaperVisualsReady(); + } + + Component.onDestruction: { + root.islandController.keyboardYielded = false; + root.islandController.wallpaperVisualsReady = false; + } +} diff --git a/quickshell/Modules/DankIsland/Activities/WeatherCompact.qml b/quickshell/Modules/DankIsland/Activities/WeatherCompact.qml new file mode 100644 index 000000000..940c376b0 --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/WeatherCompact.qml @@ -0,0 +1,41 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + required property var controller + + function pushMeasuredWidth() { + root.controller.setDestinationContentWidth("weather", compactRow.implicitWidth); + } + + Component.onCompleted: root.pushMeasuredWidth() + + Row { + id: compactRow + + anchors.centerIn: parent + spacing: Theme.spacingS + + onImplicitWidthChanged: root.pushMeasuredWidth() + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "partly_cloudy_day" + size: Theme.iconSizeSmall + color: Theme.primary + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Weather") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.DemiBold + } + } +} diff --git a/quickshell/Modules/DankIsland/Activities/WeatherExpanded.qml b/quickshell/Modules/DankIsland/Activities/WeatherExpanded.qml new file mode 100644 index 000000000..4daabe84e --- /dev/null +++ b/quickshell/Modules/DankIsland/Activities/WeatherExpanded.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.DankDash +import qs.Widgets + +FocusScope { + id: root + + required property var islandController + + property bool contentStaged: false + + readonly property real stageWidth: root.islandController.weatherExpandedTarget.width - 2 + readonly property real stageHeight: root.islandController.weatherExpandedTarget.height - 2 + readonly property real faceInset: Theme.spacingM + readonly property var weatherTab: tabLoader.item + + clip: true + + function focusWeather() { + root.forceActiveFocus(); + } + + Loader { + id: tabLoader + + x: root.faceInset + y: root.faceInset + width: root.stageWidth - root.faceInset * 2 + height: root.stageHeight - root.faceInset * 2 + active: root.contentStaged + asynchronous: true + visible: status === Loader.Ready + sourceComponent: weatherTabComponent + } + + Component { + id: weatherTabComponent + + WeatherTab { + live: root.islandController.expanded && root.islandController.activeActivity === "weather" + Component.onCompleted: root.focusWeather() + } + } + + DankSpinner { + anchors.centerIn: parent + size: 40 + visible: !tabLoader.visible + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + root.islandController.requestCollapse(); + event.accepted = true; + } + } + + Component.onCompleted: { + root.contentStaged = true; + root.islandController.markWeatherVisualsReady(); + } + + Component.onDestruction: { + root.islandController.weatherVisualsReady = false; + } +} diff --git a/quickshell/Modules/DankIsland/DankIsland.qml b/quickshell/Modules/DankIsland/DankIsland.qml new file mode 100644 index 000000000..0de791607 --- /dev/null +++ b/quickshell/Modules/DankIsland/DankIsland.qml @@ -0,0 +1,497 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Services + +Item { + id: root + + property var screenModel: Quickshell.screens + property var colorPickerModal: null + property var powerMenuModalLoader: null + + signal lockRequested + + readonly property bool launcherOpen: root.activityOpen("launcher") + readonly property bool controlCenterOpen: root.activityOpen("controlcenter") + readonly property bool wallpaperOpen: root.activityOpen("wallpaper") + readonly property bool weatherOpen: root.activityOpen("weather") + readonly property bool mediaOpen: root.activityOpen("media") + readonly property bool homeOpen: root.activityOpen("home") + readonly property bool notificationCenterOpen: root.activityOpen("notificationcenter") + + function activityOpen(activityId) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.islandController?.activeActivity === activityId && hosts[i].islandController.expanded) + return true; + } + return false; + } + + function hostWithActivity(activityId) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.islandController?.activeActivity === activityId && hosts[i].islandController.expanded) + return hosts[i]; + } + return null; + } + + function hasHostForScreen(screen) { + if (!screen) + return (islandVariants.instances || []).length > 0; + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return true; + } + return false; + } + + function hostForScreen(screenName) { + const requestedName = (screenName || "").trim(); + const hosts = islandVariants.instances || []; + // An explicitly named screen with no island is an error, not a redirect to another display. + if (requestedName) { + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen?.name === requestedName) + return hosts[i]; + } + return null; + } + const focusedName = CompositorService.getFocusedScreen()?.name ?? ""; + if (focusedName) { + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen?.name === focusedName) + return hosts[i]; + } + } + return hosts.length > 0 ? hosts[0] : null; + } + + function activityName(activity) { + const requested = (activity || "home").trim().toLowerCase(); + if (requested === "media" || requested === "launcher" || requested === "controlcenter" || requested === "wallpaper" || requested === "weather" || requested === "notificationcenter") + return requested; + if (requested === "control-center" || requested === "cc") + return "controlcenter"; + if (requested === "notifications" || requested === "notification-center" || requested === "notification" || requested === "nc") + return "notificationcenter"; + return "home"; + } + + function openActivityOn(host, activityId) { + switch (activityId) { + case "launcher": + return host.islandController.requestLauncher("", "", false); + case "controlcenter": + return host.islandController.requestControlCenter("", false); + case "wallpaper": + return host.islandController.requestWallpaper(false); + case "weather": + return host.islandController.requestWeather(false); + case "notificationcenter": + return host.islandController.requestNotificationCenter(false); + } + return host.islandController.requestActivity(activityId, true, true); + } + + function focusedHost() { + const focusedName = CompositorService.getFocusedScreen()?.name ?? ""; + if (!focusedName) + return null; + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen?.name === focusedName) + return hosts[i]; + } + return null; + } + + function focusedIslandScreen() { + return root.focusedHost()?.screen ?? null; + } + + function activeLauncherHost() { + return root.hostWithActivity("launcher"); + } + + function openLauncher(query, mode): bool { + const host = root.focusedHost(); + return host ? host.islandController.requestLauncher(query || "", mode || "", false) : false; + } + + function toggleLauncher(query, mode): bool { + const openHost = root.activeLauncherHost(); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + const host = root.focusedHost(); + return host ? host.islandController.requestLauncher(query || "", mode || "", false) : false; + } + + function closeLauncher(): bool { + const host = root.activeLauncherHost(); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + function controlCenterHostForScreen(screen) { + if (screen) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return hosts[i]; + } + } + return root.focusedHost(); + } + + function openControlCenter(section, screen): bool { + const host = root.controlCenterHostForScreen(screen); + return host ? host.islandController.requestControlCenter(section || "", false) : false; + } + + function toggleControlCenter(section, screen): bool { + const openHost = root.hostWithActivity("controlcenter"); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + return root.openControlCenter(section, screen); + } + + function closeControlCenter(): bool { + const host = root.hostWithActivity("controlcenter"); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + function wallpaperHostForScreen(screen) { + if (screen) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return hosts[i]; + } + } + return root.focusedHost(); + } + + function openWallpaper(screen): bool { + const host = root.wallpaperHostForScreen(screen); + return host ? host.islandController.requestWallpaper(false) : false; + } + + function toggleWallpaper(screen): bool { + const openHost = root.hostWithActivity("wallpaper"); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + return root.openWallpaper(screen); + } + + function closeWallpaper(): bool { + const host = root.hostWithActivity("wallpaper"); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + function weatherHostForScreen(screen) { + if (screen) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return hosts[i]; + } + } + return root.focusedHost(); + } + + function openWeather(screen): bool { + const host = root.weatherHostForScreen(screen); + return host ? host.islandController.requestWeather(false) : false; + } + + function toggleWeather(screen): bool { + const openHost = root.hostWithActivity("weather"); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + return root.openWeather(screen); + } + + function closeWeather(): bool { + const host = root.hostWithActivity("weather"); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + function notificationHostForScreen(screen) { + if (screen) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return hosts[i]; + } + } + return root.focusedHost(); + } + + function openNotifications(screen): bool { + const host = root.notificationHostForScreen(screen); + return host ? host.islandController.requestNotificationCenter(false) : false; + } + + function toggleNotifications(screen): bool { + const openHost = root.hostWithActivity("notificationcenter"); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + return root.openNotifications(screen); + } + + function closeNotifications(): bool { + const host = root.hostWithActivity("notificationcenter"); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + function mediaHostForScreen(screen) { + if (screen) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return hosts[i]; + } + } + return root.focusedHost(); + } + + function openMedia(screen): bool { + const host = root.mediaHostForScreen(screen); + return host ? host.islandController.requestActivity("media", true, true) : false; + } + + function toggleMedia(screen): bool { + const openHost = root.hostWithActivity("media"); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + return root.openMedia(screen); + } + + function closeMedia(): bool { + const host = root.hostWithActivity("media"); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + function homeHostForScreen(screen) { + if (screen) { + const hosts = islandVariants.instances || []; + for (let i = 0; i < hosts.length; i++) { + if (hosts[i]?.screen === screen || hosts[i]?.screen?.name === screen.name) + return hosts[i]; + } + } + return root.focusedHost(); + } + + function openHome(screen): bool { + const host = root.homeHostForScreen(screen); + return host ? host.islandController.requestActivity("home", true, true) : false; + } + + function toggleHome(screen): bool { + const openHost = root.hostWithActivity("home"); + if (openHost) { + openHost.islandController.requestCollapse(); + return true; + } + return root.openHome(screen); + } + + function closeHome(): bool { + const host = root.hostWithActivity("home"); + if (!host) + return false; + host.islandController.requestCollapse(); + return true; + } + + Component.onCompleted: PopoutService.dankIslandRouter = root + Component.onDestruction: { + if (PopoutService.dankIslandRouter === root) + PopoutService.dankIslandRouter = null; + } + + Variants { + id: islandVariants + + model: root.screenModel + + delegate: DankIslandHostWindow { + required property var modelData + + screen: modelData + colorPickerModal: root.colorPickerModal + powerMenuModalLoader: root.powerMenuModalLoader + onLockRequested: root.lockRequested() + } + } + + IpcHandler { + target: "island" + + function _runOpen(activity, screen) { + const host = root.hostForScreen(screen); + if (!host) + return "DANK_ISLAND_UNAVAILABLE"; + const requested = root.activityName(activity); + const accepted = root.openActivityOn(host, requested); + if (!accepted) + return `DANK_ISLAND_ACTIVITY_UNAVAILABLE: ${requested}`; + return `DANK_ISLAND_OPEN: ${requested}\t${host.screen?.name ?? ""}`; + } + + function _runToggle(activity, screen) { + const host = root.hostForScreen(screen); + if (!host) + return "DANK_ISLAND_UNAVAILABLE"; + if (host.islandController.expanded && !host.islandController.notificationActive) { + host.islandController.requestCollapse(); + return `DANK_ISLAND_CLOSED: ${host.screen?.name ?? ""}`; + } + return _runOpen(activity, screen); + } + + function _runShow(activity, screen) { + const host = root.hostForScreen(screen); + if (!host) + return "DANK_ISLAND_UNAVAILABLE"; + const requested = root.activityName(activity); + if (!host.islandController.requestActivity(requested, false, false)) + return `DANK_ISLAND_ACTIVITY_UNAVAILABLE: ${requested}`; + return `DANK_ISLAND_SHOW: ${requested}\t${host.screen?.name ?? ""}`; + } + + function _runClose(screen) { + const host = root.hostForScreen(screen); + if (!host) + return "DANK_ISLAND_UNAVAILABLE"; + host.islandController.requestCollapse(); + return `DANK_ISLAND_CLOSED: ${host.screen?.name ?? ""}`; + } + + function _runCycle(screen) { + const host = root.hostForScreen(screen); + if (!host) + return "DANK_ISLAND_UNAVAILABLE"; + host.islandController.cycleActivity(1, host.islandController.expanded); + return `DANK_ISLAND_ACTIVITY: ${host.islandController.activeActivity}\t${host.screen?.name ?? ""}`; + } + + function _runStatus(screen) { + const host = root.hostForScreen(screen); + if (!host) + return JSON.stringify({ + "available": false, + "enabled": true, + "launcherAvailable": false + }); + return JSON.stringify({ + "available": true, + "enabled": true, + "screen": host.screen?.name ?? "", + "activity": host.islandController.activeActivity, + "expanded": host.islandController.expanded, + "mediaAvailable": host.islandController.mediaAvailable, + "launcherAvailable": true, + "controlCenterAvailable": true, + "wallpaperAvailable": true, + "weatherAvailable": true, + "notificationCenterAvailable": true, + "launcherInputFocused": host.islandController.launcherInputFocused, + "launcherResultCount": host.launcherResultCount, + "compactHeight": host.islandController.compactHeight + }); + } + + function open(activity: string): string { + return _runOpen(activity, ""); + } + + function toggle(activity: string): string { + return _runToggle(activity, ""); + } + + function show(activity: string): string { + return _runShow(activity, ""); + } + + function close(): string { + return _runClose(""); + } + + function cycle(): string { + return _runCycle(""); + } + + function status(): string { + return _runStatus(""); + } + + function openOn(activity: string, screen: string): string { + return _runOpen(activity, screen); + } + + function toggleOn(activity: string, screen: string): string { + return _runToggle(activity, screen); + } + + function showOn(activity: string, screen: string): string { + return _runShow(activity, screen); + } + + function closeOn(screen: string): string { + return _runClose(screen); + } + + function cycleOn(screen: string): string { + return _runCycle(screen); + } + + function statusOn(screen: string): string { + return _runStatus(screen); + } + + function notifications(): string { + return _runToggle("notificationcenter", ""); + } + + function notificationsOn(screen: string): string { + return _runToggle("notificationcenter", screen); + } + } +} diff --git a/quickshell/Modules/DankIsland/DankIslandHostWindow.qml b/quickshell/Modules/DankIsland/DankIslandHostWindow.qml new file mode 100644 index 000000000..2d61fc591 --- /dev/null +++ b/quickshell/Modules/DankIsland/DankIslandHostWindow.qml @@ -0,0 +1,519 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common +import qs.Modals.DankLauncherV2 as DankLauncher +import qs.Services +import qs.Widgets + +PanelWindow { + id: root + + function requestKeyboardFocus() { + if (!controller.keyboardDismissRequested) { + keyboardActivationTimer.stop(); + keyboardFocusArmed = false; + keyboardRearmTimer.restart(); + return; + } + keyboardRearmTimer.stop(); + keyboardFocusArmed = true; + keyboardActivationTimer.restart(); + } + + function containsGlobalPoint(gx, gy, padding) { + const pad = padding !== undefined ? padding : 16; + const items = [surface.inputMaskItem, surface.mediaDropdownMaskItem, satelliteHost.leftInputItem, satelliteHost.rightInputItem]; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!item || item.width <= 0 || item.height <= 0) + continue; + const topLeft = item.mapToItem(null, 0, 0); + if (!topLeft) + continue; + // mapToItem(null) is window-local; callers pass screen-space cursor coords. + const top = topLeft.y + root.hostOriginY; + if (gx >= topLeft.x - pad && gx < topLeft.x + item.width + pad && gy >= top - pad && gy < top + item.height + pad) + return true; + } + return false; + } + + readonly property int reserveHeight: Math.max(36, Math.min(128, SettingsData.dankIslandReserveHeight)) + readonly property int compactHeight: Math.max(36, Math.min(72, SettingsData.dankIslandCompactHeight)) + readonly property bool floating: SettingsData.dankIslandFloating + readonly property bool usesOverlayLayer: LayerShell.envUsesOverlay("DMS_DANKISLAND_LAYER", SettingsData.dankIslandUseOverlayLayer) + readonly property var islandLayer: LayerShell.fromEnv("DMS_DANKISLAND_LAYER", usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top) + readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom" + readonly property int reservedStripHeight: Math.max(reserveHeight, outerGap + compactHeight) + // Input bands hug the anchored edge, which is the far side of the host when anchored bottom. + readonly property int stripY: root.bottomEdge ? root.height - root.reservedStripHeight : 0 + // Screen-space Y of this strip's local origin. Zero on top, where the two spaces coincide. + readonly property int hostOriginY: root.bottomEdge ? Math.max(0, (root.screen?.height ?? 0) - root.height) : 0 + readonly property int outerGap: Math.max(0, Math.min(48, SettingsData.dankIslandOuterGap)) + // The host is larger than the visible island to cover every clamped activity target. + readonly property int maxActivityHeight: Math.max(560, Math.min(680, (screen?.height ?? 1080) - 200)) + readonly property int hostHeight: outerGap + maxActivityHeight + 8 + readonly property int maxActivityWidth: 736 + readonly property real maximumHorizontalOffset: Math.max(0, (width - maxActivityWidth) / 2 - 8) + property var colorPickerModal: null + property var powerMenuModalLoader: null + property bool keyboardFocusArmed: true + + signal lockRequested + + color: "transparent" + implicitHeight: hostHeight + exclusiveZone: floating ? 0 : reservedStripHeight + readonly property alias islandController: controller + readonly property int launcherResultCount: launcherController.flatModel?.length ?? 0 + + anchors { + top: !root.bottomEdge + bottom: root.bottomEdge + left: true + right: true + } + + WlrLayershell.namespace: "dms:dankisland" + WlrLayershell.layer: islandLayer + WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(controller.keyboardDismissRequested && root.keyboardFocusArmed && !controller.keyboardYielded, null) + + Timer { + id: keyboardActivationTimer + + interval: 60 + onTriggered: { + if (controller.keyboardDismissRequested && !surface.requestActivityFocus()) + islandFocus.forceActiveFocus(Qt.PopupFocusReason); + } + } + + Timer { + id: keyboardRearmTimer + + interval: 80 + onTriggered: root.keyboardFocusArmed = true + } + + DankFocusGrab { + windows: [root] + wanted: KeyboardFocus.wantsGrab(controller.keyboardDismissRequested && !controller.keyboardYielded, null) + } + + Component.onCompleted: KeyboardFocus.registerBarWindow(root) + Component.onDestruction: KeyboardFocus.unregisterBarWindow(root) + + Connections { + target: controller + + function onKeyboardDismissRequestedChanged() { + root.requestKeyboardFocus(); + } + } + + Connections { + target: PopoutManager + + function onPopoutChanged() { + root.popoutRevision++; + } + + function onScreenshotActiveChanged() { + if (!PopoutManager.screenshotActive && controller.keyboardDismissRequested) + root.requestKeyboardFocus(); + } + } + + mask: Region { + Region { + item: controller.inputSuspended ? null : surface.inputMaskItem + } + + Region { + item: controller.inputSuspended ? null : satelliteHost.leftInputItem + } + + Region { + item: controller.inputSuspended ? null : satelliteHost.rightInputItem + } + + Region { + item: controller.inputSuspended ? null : surface.mediaDropdownMaskItem + } + + Region { + item: controller.inputSuspended || !satelliteHost.scrollEnabled ? null : scrollStrip + } + + Region { + item: controller.inputSuspended || !root.satelliteSurfacesOpen ? null : satelliteDismissStrip + } + } + + IslandController { + id: controller + + readonly property string interactionOverride: Quickshell.env("DMS_DANKISLAND_INTERACTION") ?? "" + + interactionMode: { + const raw = interactionOverride || SettingsData.dankIslandInteractionMode; + return raw === "click" ? "click" : "hybrid"; + } + inputSuspended: PopoutManager.screenshotActive + autoDemo: Quickshell.env("DMS_DANKISLAND_DEMO") === "1" + horizontalOffset: Math.max(-root.maximumHorizontalOffset, Math.min(root.maximumHorizontalOffset, SettingsData.dankIslandHorizontalOffset)) + outerGap: root.outerGap + compactHeight: root.compactHeight + launcherCycleEnabled: SettingsData.launcherStyle === "island" + controlCenterMaxHeight: root.maxActivityHeight + notificationCenterMaxHeight: root.maxActivityHeight + notificationExpandAllowed: SettingsData.dankIslandNotificationExpand + unreadNotificationCount: NotificationService.notifications.length + hoverOpenDelay: Math.max(0, Math.min(1000, SettingsData.dankIslandHoverOpenDelay)) + hoverCloseDelay: Math.max(0, Math.min(1000, SettingsData.dankIslandHoverCloseDelay)) + } + + DankLauncher.Controller { + id: launcherController + + active: controller.launcherSessionActive + viewModeContext: "spotlight" + forceLinearNavigation: true + } + + TransientSurfaceTracker { + id: launcherTransientSurfaces + } + + TransientSurfaceTracker { + id: notificationTransientSurfaces + } + + Connections { + target: controller + + function onLauncherSessionActiveChanged() { + if (!controller.launcherSessionActive) + launcherTransientSurfaces.closeAll(); + } + } + + DmsMediaActivitySource { + id: mediaSource + + controller: controller + } + + DmsSystemActivitySource { + id: systemSource + + controller: controller + } + + DmsNotificationActivitySource { + id: notificationSource + + controller: controller + targetScreen: root.screen + } + + // Empty strip dismisses satellite popouts while something is open; click-through otherwise. + // PopoutManager mutates the map in place, so this tracks popoutChanged instead. + property int popoutRevision: 0 + readonly property bool satelliteSurfacesOpen: { + root.popoutRevision; + const screenName = root.screen?.name; + if (!screenName) + return false; + return !!PopoutManager.currentPopoutsByScreen[screenName] || !!ModalManager.currentModalsByScreen[screenName]; + } + + function dismissSatelliteSurfaces() { + const screenName = root.screen?.name; + if (!screenName) + return; + if (PopoutManager.currentPopoutsByScreen[screenName]) + PopoutManager.closeAllPopouts(); + if (ModalManager.currentModalsByScreen[screenName]) + ModalManager.closeAllModalsExcept(null); + TrayMenuManager.closeAllMenus(); + } + + MouseArea { + id: satelliteDismissStrip + + x: 0 + y: root.stripY + z: -2 + width: parent.width + height: root.reservedStripHeight + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: false + enabled: root.satelliteSurfacesOpen && !controller.inputSuspended + onClicked: root.dismissSatelliteSurfaces() + } + + MouseArea { + id: scrollStrip + + x: 0 + y: root.stripY + z: -1 + width: parent.width + height: root.reservedStripHeight + acceptedButtons: Qt.NoButton + hoverEnabled: false + enabled: satelliteHost.scrollEnabled && !controller.inputSuspended + + property real touchpadAccumulatorY: 0 + property real touchpadAccumulatorX: 0 + property real mouseAccumulatorY: 0 + property real mouseAccumulatorX: 0 + property bool actionInProgress: false + + Timer { + id: cooldownTimer + interval: 100 + onTriggered: scrollStrip.actionInProgress = false + } + + function handleScrollAction(behavior, direction) { + switch (behavior) { + case "workspace": + satelliteHost.switchWorkspace(direction); + return true; + case "column": + if (!CompositorService.isNiri) + return false; + if (direction > 0) + NiriService.moveColumnRight(root.screen?.name); + else + NiriService.moveColumnLeft(root.screen?.name); + return true; + default: + return false; + } + } + + function processWheel(wheel) { + if (!satelliteHost.scrollEnabled || actionInProgress) { + wheel.accepted = false; + return; + } + + const deltaY = wheel.angleDelta.y; + const deltaX = wheel.angleDelta.x; + const isTouchpadY = wheel.pixelDelta && wheel.pixelDelta.y !== 0; + const isTouchpadX = wheel.pixelDelta && wheel.pixelDelta.x !== 0; + const xBehavior = satelliteHost.scrollXBehavior || "column"; + const yBehavior = satelliteHost.scrollYBehavior || "workspace"; + const reverse = SettingsData.reverseScrolling ? -1 : 1; + + if (CompositorService.isNiri && xBehavior !== "none" && Math.abs(deltaX) > Math.abs(deltaY)) { + if (isTouchpadX) { + touchpadAccumulatorX += deltaX; + if (Math.abs(touchpadAccumulatorX) >= 500) { + const direction = touchpadAccumulatorX * reverse < 0 ? 1 : -1; + if (handleScrollAction(xBehavior, direction)) { + actionInProgress = true; + cooldownTimer.restart(); + } + touchpadAccumulatorX = 0; + } + } else { + mouseAccumulatorX += deltaX; + if (Math.abs(mouseAccumulatorX) >= 120) { + const direction = mouseAccumulatorX * reverse < 0 ? 1 : -1; + if (handleScrollAction(xBehavior, direction)) { + actionInProgress = true; + cooldownTimer.restart(); + } + mouseAccumulatorX = 0; + } + } + wheel.accepted = false; + return; + } + + if (yBehavior === "none") { + wheel.accepted = false; + return; + } + + if (isTouchpadY) { + touchpadAccumulatorY += deltaY; + if (Math.abs(touchpadAccumulatorY) >= 500) { + const direction = touchpadAccumulatorY * reverse < 0 ? 1 : -1; + if (handleScrollAction(yBehavior, direction)) { + actionInProgress = true; + cooldownTimer.restart(); + } + touchpadAccumulatorY = 0; + } + } else { + mouseAccumulatorY += deltaY; + if (Math.abs(mouseAccumulatorY) >= 120) { + const direction = mouseAccumulatorY * reverse < 0 ? 1 : -1; + if (handleScrollAction(yBehavior, direction)) { + actionInProgress = true; + cooldownTimer.restart(); + } + mouseAccumulatorY = 0; + } + } + + wheel.accepted = false; + } + + onWheel: wheel => processWheel(wheel) + } + + DankIslandSurface { + id: surface + + anchors.fill: parent + controller: controller + mediaModel: mediaSource + systemModel: systemSource + notificationModel: notificationSource + launcherController: launcherController + launcherTransientSurfaceTracker: launcherTransientSurfaces + notificationTransientSurfaceTracker: notificationTransientSurfaces + colorPickerModal: root.colorPickerModal + powerMenuModalLoader: root.powerMenuModalLoader + effectiveScreen: root.screen + hostOriginY: root.hostOriginY + reducedMotion: SettingsData.dankIslandReducedMotion || SettingsData.animationSpeed === SettingsData.AnimationSpeed.None + springStiffness: Math.max(100, Math.min(1200, SettingsData.dankIslandSpringStiffness)) + springDamping: Math.max(10, Math.min(100, SettingsData.dankIslandSpringDamping)) + springMass: Math.max(0.25, Math.min(3, SettingsData.dankIslandSpringMass)) + palette: SettingsData.dankIslandPalette + highContrast: SettingsData.dankIslandHighContrast + onLockRequested: root.lockRequested() + onScrollWheel: wheel => scrollStrip.processWheel(wheel) + } + + IslandSatelliteHost { + id: satelliteHost + + anchors.fill: parent + hostWindow: root + targetScreen: root.screen + controller: controller + islandSurface: surface + outerGap: root.outerGap + } + + FocusScope { + id: islandFocus + + anchors.fill: parent + Keys.onEscapePressed: event => { + controller.requestCollapse(); + event.accepted = true; + } + } + + PanelWindow { + id: dismissWindow + + screen: root.screen + visible: controller.expanded && !PopoutManager.screenshotActive + color: "transparent" + exclusiveZone: -1 + + anchors { + top: true + bottom: true + left: true + right: true + } + + WlrLayershell.namespace: "dms:dankisland:dismiss" + WlrLayershell.layer: root.islandLayer + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + mask: Region { + item: dismissMask + + Region { + item: islandHole + intersection: Intersection.Subtract + } + + Region { + item: leftSatelliteHole + intersection: Intersection.Subtract + } + + Region { + item: rightSatelliteHole + intersection: Intersection.Subtract + } + + Region { + item: mediaDropdownHole + intersection: Intersection.Subtract + } + } + + Rectangle { + id: dismissMask + + anchors.fill: parent + visible: false + color: "transparent" + } + + Item { + id: islandHole + + x: surface.inputMaskItem.x + y: surface.inputMaskItem.y + root.hostOriginY + width: surface.inputMaskItem.width + height: surface.inputMaskItem.height + } + + Item { + id: leftSatelliteHole + + x: satelliteHost.leftInputItem.x + y: satelliteHost.leftInputItem.y + root.hostOriginY + width: satelliteHost.leftInputItem.width + height: satelliteHost.leftInputItem.height + } + + Item { + id: rightSatelliteHole + + x: satelliteHost.rightInputItem.x + y: satelliteHost.rightInputItem.y + root.hostOriginY + width: satelliteHost.rightInputItem.width + height: satelliteHost.rightInputItem.height + } + + Item { + id: mediaDropdownHole + + x: surface.mediaDropdownMaskItem ? surface.mediaDropdownMaskItem.x : 0 + y: surface.mediaDropdownMaskItem ? surface.mediaDropdownMaskItem.y + root.hostOriginY : 0 + width: surface.mediaDropdownMaskItem ? surface.mediaDropdownMaskItem.width : 0 + height: surface.mediaDropdownMaskItem ? surface.mediaDropdownMaskItem.height : 0 + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onPressed: mouse => { + controller.requestCollapse(); + mouse.accepted = true; + } + + onWheel: wheel => scrollStrip.processWheel(wheel) + } + } +} diff --git a/quickshell/Modules/DankIsland/DankIslandLab.qml b/quickshell/Modules/DankIsland/DankIslandLab.qml new file mode 100644 index 000000000..05c5033fe --- /dev/null +++ b/quickshell/Modules/DankIsland/DankIslandLab.qml @@ -0,0 +1,14 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Variants { + model: Quickshell.screens + + delegate: DankIslandLabWindow { + required property var modelData + + screen: modelData + } +} diff --git a/quickshell/Modules/DankIsland/DankIslandLabWindow.qml b/quickshell/Modules/DankIsland/DankIslandLabWindow.qml new file mode 100644 index 000000000..cfae04e3d --- /dev/null +++ b/quickshell/Modules/DankIsland/DankIslandLabWindow.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland + +PanelWindow { + id: root + + property int reserveHeight: 58 + property int hostHeight: 300 + + color: "transparent" + implicitHeight: hostHeight + exclusiveZone: reserveHeight + + anchors { + top: true + left: true + right: true + } + + WlrLayershell.namespace: "dms:dankisland-lab" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + mask: Region { + item: lab.inputMaskItem + } + + DankIslandMotionLab { + id: lab + + anchors.fill: parent + reserveHeight: root.reserveHeight + } +} diff --git a/quickshell/Modules/DankIsland/DankIslandMotionLab.qml b/quickshell/Modules/DankIsland/DankIslandMotionLab.qml new file mode 100644 index 000000000..11fb3f9d9 --- /dev/null +++ b/quickshell/Modules/DankIsland/DankIslandMotionLab.qml @@ -0,0 +1,299 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Item { + id: root + + required property int reserveHeight + + readonly property alias inputMaskItem: inputEnvelope + readonly property var activeMotion: engineMode === "native" ? nativeMotion : vectorMotion + readonly property list targetStates: ["home", "compact", "expanded", "offset-test"] + readonly property var log: Log.scoped("DankIslandMotionLab") + readonly property bool hudEnabled: Quickshell.env("DMS_DANKISLAND_LAB_HUD") !== "0" + readonly property bool metricsLogEnabled: Quickshell.env("DMS_DANKISLAND_LAB_LOG") === "1" + + property string engineMode: Quickshell.env("DMS_DANKISLAND_LAB_ENGINE") === "native" ? "native" : "vector" + property string targetState: "home" + property bool stressRunning: Quickshell.env("DMS_DANKISLAND_LAB_STRESS") === "1" + property real lastFrameMs: 0 + property real smoothFrameMs: 0 + property real peakFrameMs: 0 + property real currentPeakFrameMs: 0 + property int metricFrameCount: 0 + property string statusText: "" + + function targetForState(state) { + switch (state) { + case "compact": + return { + "width": 252, + "height": 48, + "offsetX": 0, + "offsetY": 8, + "topLeftRadius": 24, + "topRightRadius": 24, + "bottomLeftRadius": 24, + "bottomRightRadius": 24 + }; + case "expanded": + return { + "width": 448, + "height": 176, + "offsetX": 0, + "offsetY": 8, + "topLeftRadius": 26, + "topRightRadius": 26, + "bottomLeftRadius": 32, + "bottomRightRadius": 32 + }; + case "offset-test": + return { + "width": 520, + "height": 132, + "offsetX": -72, + "offsetY": 14, + "topLeftRadius": 22, + "topRightRadius": 30, + "bottomLeftRadius": 44, + "bottomRightRadius": 18 + }; + default: + return { + "width": 176, + "height": 42, + "offsetX": 0, + "offsetY": 8, + "topLeftRadius": 21, + "topRightRadius": 21, + "bottomLeftRadius": 21, + "bottomRightRadius": 21 + }; + } + } + + function motionSnapshot(motion) { + return { + "width": motion.currentWidth, + "height": motion.currentHeight, + "offsetX": motion.currentOffsetX, + "offsetY": motion.currentOffsetY, + "topLeftRadius": motion.currentTopLeftRadius, + "topRightRadius": motion.currentTopRightRadius, + "bottomLeftRadius": motion.currentBottomLeftRadius, + "bottomRightRadius": motion.currentBottomRightRadius + }; + } + + function applyState(state) { + targetState = state; + activeMotion.setTarget(targetForState(state)); + } + + function cycleState(direction) { + const currentIndex = targetStates.indexOf(targetState); + const nextIndex = (currentIndex + direction + targetStates.length) % targetStates.length; + applyState(targetStates[nextIndex]); + } + + function toggleEngine() { + const snapshot = motionSnapshot(activeMotion); + const target = targetForState(targetState); + if (engineMode === "vector") { + nativeMotion.snapTo(snapshot); + engineMode = "native"; + nativeMotion.setTarget(target); + } else { + vectorMotion.snapTo(snapshot); + engineMode = "vector"; + vectorMotion.setTarget(target); + } + peakFrameMs = 0; + currentPeakFrameMs = 0; + metricFrameCount = 0; + } + + function updateStatusText() { + if (!hudEnabled) + return; + + const engineLabel = engineMode === "vector" ? "VECTOR" : "NATIVE"; + const motionLabel = activeMotion.running ? "MOVING" : "SETTLED"; + const stressLabel = stressRunning ? " · STRESS" : ""; + statusText = engineLabel + " · " + targetState.toUpperCase() + " · " + motionLabel + stressLabel + "\n" + Math.round(activeMotion.currentWidth) + "×" + + Math.round(activeMotion.currentHeight) + " · " + smoothFrameMs.toFixed(2) + " ms smooth · " + peakFrameMs.toFixed(2) + " ms peak"; + } + + Component.onCompleted: { + const home = targetForState("home"); + nativeMotion.snapTo(home); + vectorMotion.snapTo(home); + updateStatusText(); + } + + NativeSpringMotion { + id: nativeMotion + enabled: root.engineMode === "native" + } + + VectorSpringMotion { + id: vectorMotion + enabled: root.engineMode === "vector" + reducedMotion: SettingsData.dankIslandReducedMotion || SettingsData.animationSpeed === SettingsData.AnimationSpeed.None + stiffness: Math.max(100, Math.min(1200, SettingsData.dankIslandSpringStiffness)) + damping: Math.max(10, Math.min(100, SettingsData.dankIslandSpringDamping)) + mass: Math.max(0.25, Math.min(3, SettingsData.dankIslandSpringMass)) + } + + Timer { + interval: 110 + repeat: true + running: root.stressRunning + onTriggered: root.cycleState(1) + } + + Timer { + interval: 200 + repeat: true + running: root.hudEnabled + triggeredOnStart: true + onTriggered: root.updateStatusText() + } + + FrameAnimation { + running: root.activeMotion.running || root.stressRunning + + onRunningChanged: { + if (running) { + root.peakFrameMs = 0; + root.currentPeakFrameMs = 0; + root.metricFrameCount = 0; + } + } + + onTriggered: { + root.lastFrameMs = frameTime * 1000; + root.smoothFrameMs = smoothFrameTime * 1000; + root.metricFrameCount++; + if (root.metricFrameCount > 8) + root.currentPeakFrameMs = Math.max(root.currentPeakFrameMs, root.lastFrameMs); + } + } + + Timer { + interval: 1000 + repeat: true + running: root.hudEnabled || root.metricsLogEnabled + onTriggered: { + root.peakFrameMs = root.currentPeakFrameMs; + root.currentPeakFrameMs = 0; + if (root.metricsLogEnabled) + root.log.info("DankIslandLabMetrics engine=" + root.engineMode + " state=" + root.targetState + " moving=" + root.activeMotion.running + " frameMs=" + root.lastFrameMs.toFixed(3) + " smoothMs=" + root.smoothFrameMs.toFixed(3) + " peakMs=" + root.peakFrameMs.toFixed(3)); + } + } + + readonly property real currentVisualX: (width - activeMotion.currentWidth) / 2 + activeMotion.currentOffsetX + readonly property real currentVisualY: activeMotion.currentOffsetY + readonly property real targetVisualX: (width - activeMotion.targetWidth) / 2 + activeMotion.targetOffsetX + readonly property real targetVisualY: activeMotion.targetOffsetY + + Item { + id: inputEnvelope + + x: Math.min(root.currentVisualX, root.targetVisualX) + y: Math.min(root.currentVisualY, root.targetVisualY) + width: Math.max(root.currentVisualX + root.activeMotion.currentWidth, root.targetVisualX + root.activeMotion.targetWidth) - x + height: Math.max(root.currentVisualY + root.activeMotion.currentHeight, root.targetVisualY + root.activeMotion.targetHeight) - y + } + + Rectangle { + x: 0 + y: root.reserveHeight - 1 + width: root.width + height: 1 + color: Theme.outline + opacity: 0.22 + visible: root.hudEnabled + } + + Rectangle { + id: island + + x: root.currentVisualX + y: root.currentVisualY + width: root.activeMotion.currentWidth + height: root.activeMotion.currentHeight + topLeftRadius: Math.max(0, root.activeMotion.currentTopLeftRadius) + topRightRadius: Math.max(0, root.activeMotion.currentTopRightRadius) + bottomLeftRadius: Math.max(0, root.activeMotion.currentBottomLeftRadius) + bottomRightRadius: Math.max(0, root.activeMotion.currentBottomRightRadius) + color: Theme.surfaceContainerHigh + border.width: 1 + border.color: Theme.withAlpha(Theme.outline, 0.28) + + Rectangle { + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: 2 + color: root.engineMode === "vector" ? Theme.primary : Theme.tertiary + opacity: 0.85 + } + + Column { + anchors.centerIn: parent + spacing: 2 + visible: root.hudEnabled + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.statusText.split("\n")[0] ?? "" + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: root.statusText.split("\n")[1] ?? "" + color: Theme.withAlpha(Theme.surfaceText, 0.72) + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + visible: island.width >= 230 + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "left: next · right: engine · middle: stress · wheel: retarget" + color: Theme.withAlpha(Theme.surfaceText, 0.62) + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2) + visible: island.width >= 400 && island.height >= 100 + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + root.toggleEngine(); + } else if (mouse.button === Qt.MiddleButton) { + root.stressRunning = !root.stressRunning; + } else { + root.cycleState(1); + } + } + + onWheel: wheel => { + root.cycleState(wheel.angleDelta.y >= 0 ? 1 : -1); + wheel.accepted = true; + } + } + } +} diff --git a/quickshell/Modules/DankIsland/DankIslandSurface.qml b/quickshell/Modules/DankIsland/DankIslandSurface.qml new file mode 100644 index 000000000..def575fb9 --- /dev/null +++ b/quickshell/Modules/DankIsland/DankIslandSurface.qml @@ -0,0 +1,483 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.DankDash +import qs.Modules.DankIsland.Activities +import qs.Services + +Item { + id: root + + required property IslandController controller + required property var mediaModel + required property var systemModel + required property var notificationModel + required property var launcherController + property var launcherTransientSurfaceTracker: null + property var notificationTransientSurfaceTracker: null + property var colorPickerModal: null + property var powerMenuModalLoader: null + property var effectiveScreen: null + property bool reducedMotion: false + property real springStiffness: 560 + property real springDamping: 37 + property real springMass: 1 + // Screen-space Y of the host strip's local origin; non-zero only on the bottom edge. + property real hostOriginY: 0 + property string palette: "default" + property bool highContrast: false + + readonly property color surfaceColor: { + if (root.highContrast) + return Theme.surfaceContainerHighest; + if (root.palette === "bright") + return Theme.surfaceBright; + if (root.palette === "dim") + return Theme.surfaceDim; + return Theme.surfaceContainerHigh; + } + + signal lockRequested + signal scrollWheel(var wheel) + + readonly property alias inputMaskItem: inputEnvelope + readonly property bool motionRunning: motion.running + readonly property Item mediaDropdownMaskItem: mediaDropdowns.__activePanel + property real fadeCompactHeight: 48 + property real fadeExpandedHeight: 352 + property rect motionStartBounds: Qt.rect(0, 0, 0, 0) + property int mediaDropdownType: 0 + property point mediaDropdownAnchor: Qt.point(0, 0) + readonly property real currentVisualX: (width - motion.currentWidth) / 2 + motion.currentOffsetX + readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom" + readonly property real currentVisualY: bottomEdge ? height - motion.currentOffsetY - motion.currentHeight : motion.currentOffsetY + readonly property real currentVisualWidth: motion.currentWidth + readonly property real currentVisualHeight: motion.currentHeight + readonly property real targetVisualX: (width - motion.targetWidth) / 2 + motion.targetOffsetX + readonly property real targetVisualY: bottomEdge ? height - motion.targetOffsetY - motion.targetHeight : motion.targetOffsetY + // Activity hosts hand alignedY to full-screen windows, which need screen space, not surface space. + readonly property real targetScreenY: targetVisualY + root.hostOriginY + readonly property real targetVisualWidth: motion.targetWidth + readonly property real targetVisualHeight: motion.targetHeight + readonly property real morphProgress: { + const span = fadeExpandedHeight - fadeCompactHeight; + if (Math.abs(span) < 1) + return controller.expanded ? 1 : 0; + return Math.max(0, Math.min(1, (motion.currentHeight - fadeCompactHeight) / span)); + } + + function applyTarget() { + if (controller.expanded) + fadeExpandedHeight = controller.expandedTarget.height; + else + fadeCompactHeight = controller.compactTarget.height; + // A retarget leaves the frozen start stale, so widen it to cover where the island is now. + if (motion.running) + root.unionMotionStartBounds(); + motion.setTarget(controller.targetDescriptor); + if (!motion.running) + root.releaseIdleVisuals(); + } + + function unionMotionStartBounds() { + const b = root.motionStartBounds; + const left = Math.min(b.x, root.currentVisualX); + const top = Math.min(b.y, root.currentVisualY); + const right = Math.max(b.x + b.width, root.currentVisualX + motion.currentWidth); + const bottom = Math.max(b.y + b.height, root.currentVisualY + motion.currentHeight); + root.motionStartBounds = Qt.rect(left, top, right - left, bottom - top); + } + + function releaseIdleVisuals() { + root.controller.releaseWallpaperVisuals(); + root.controller.releaseWeatherVisuals(); + root.controller.releaseNotificationCenterVisuals(); + root.controller.releaseLauncherVisuals(); + root.controller.releaseControlCenterVisuals(); + } + + function openMediaDropdown(type, pos) { + stopMediaDropdownCloseTimer(); + mediaDropdownAnchor = pos; + mediaDropdownType = type; + controller.mediaDropdownOpen = true; + } + + function hideMediaDropdowns() { + stopMediaDropdownCloseTimer(); + mediaDropdownType = 0; + controller.mediaDropdownOpen = false; + } + + function startMediaDropdownCloseTimer() { + mediaDropdownCloseTimer.restart(); + } + + function stopMediaDropdownCloseTimer() { + mediaDropdownCloseTimer.stop(); + } + + function requestActivityFocus() { + return contentHost.requestActivityFocus(); + } + + Component.onCompleted: { + fadeCompactHeight = controller.compactTarget.height; + fadeExpandedHeight = controller.expandedTarget.height; + motion.snapTo(controller.targetDescriptor); + } + + Connections { + target: root.controller + + function onTargetDescriptorChanged() { + root.applyTarget(); + } + + function onExpandedChanged() { + if (!controller.expanded || controller.activeActivity !== "media") + root.hideMediaDropdowns(); + } + + function onActiveActivityChanged() { + if (controller.activeActivity !== "media") + root.hideMediaDropdowns(); + } + } + + Connections { + target: motion + + function onRunningChanged() { + if (motion.running) { + root.motionStartBounds = Qt.rect(root.currentVisualX, root.currentVisualY, motion.currentWidth, motion.currentHeight); + if (root.controller.activeActivity === "media") + root.hideMediaDropdowns(); + return; + } + root.releaseIdleVisuals(); + } + } + + VectorSpringMotion { + id: motion + + reducedMotion: root.reducedMotion + stiffness: root.springStiffness + damping: root.springDamping + mass: root.springMass + } + + Timer { + id: mediaDropdownCloseTimer + + interval: 400 + onTriggered: root.hideMediaDropdowns() + } + + // Frozen start/target union so the Wayland mask is not rewritten every spring frame. + Item { + id: inputEnvelope + + // Cover underdamped overshoot for the frozen flight, scaled to travel. + readonly property real overshootBudget: { + if (!motion.running) + return 0; + const zeta = motion.damping / (2 * Math.sqrt(Math.max(1, motion.stiffness * motion.mass))); + if (zeta >= 1) + return 0; + const spanX = Math.abs(root.targetVisualX - root.motionStartBounds.x); + const spanY = Math.abs(root.targetVisualY - root.motionStartBounds.y); + const spanW = Math.abs(motion.targetWidth - root.motionStartBounds.width); + const spanH = Math.abs(motion.targetHeight - root.motionStartBounds.height); + const span = Math.max(spanX, spanY, spanW, spanH); + return Math.ceil(span * Math.exp(-Math.PI * zeta / Math.sqrt(1 - zeta * zeta))); + } + + x: (motion.running ? Math.min(root.motionStartBounds.x, root.targetVisualX) : root.targetVisualX) - overshootBudget + y: (motion.running ? Math.min(root.motionStartBounds.y, root.targetVisualY) : root.targetVisualY) - overshootBudget + width: (motion.running ? Math.max(root.motionStartBounds.x + root.motionStartBounds.width, root.targetVisualX + motion.targetWidth) : root.targetVisualX + motion.targetWidth) + overshootBudget - x + height: (motion.running ? Math.max(root.motionStartBounds.y + root.motionStartBounds.height, root.targetVisualY + motion.targetHeight) : root.targetVisualY + motion.targetHeight) + overshootBudget - y + } + + Rectangle { + id: island + + x: root.currentVisualX + y: root.currentVisualY + width: motion.currentWidth + height: motion.currentHeight + topLeftRadius: Math.max(0, motion.currentTopLeftRadius) + topRightRadius: Math.max(0, motion.currentTopRightRadius) + bottomLeftRadius: Math.max(0, motion.currentBottomLeftRadius) + bottomRightRadius: Math.max(0, motion.currentBottomRightRadius) + color: root.surfaceColor + border.width: root.highContrast ? 2 : 0 + border.color: root.highContrast ? Theme.outlineStrong : "transparent" + + // Full destinations keep blank clicks for their own content instead of collapsing. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + enabled: !root.controller.expanded || !root.controller.activityOwnsBlankClicks + onClicked: root.controller.requestToggle(true) + onWheel: wheel => { + if (root.controller.expanded) { + wheel.accepted = false; + return; + } + root.scrollWheel(wheel); + wheel.accepted = true; + } + } + + IslandContentHost { + id: contentHost + + anchors.fill: parent + morphProgress: root.morphProgress + expanded: root.controller.expanded + pointerInside: root.controller.pointerInside + activityId: root.controller.activeActivity + homeCompactComponent: compactHomeComponent + homeExpandedComponent: expandedHomeComponent + mediaCompactComponent: compactMediaComponent + mediaExpandedComponent: expandedMediaComponent + launcherCompactComponent: compactLauncherComponent + launcherExpandedComponent: expandedLauncherComponent + launcherVisualsRequested: root.controller.launcherVisualsRequested + controlCenterCompactComponent: compactControlCenterComponent + controlCenterExpandedComponent: expandedControlCenterComponent + controlCenterVisualsRequested: root.controller.controlCenterVisualsRequested + wallpaperCompactComponent: compactWallpaperComponent + wallpaperExpandedComponent: expandedWallpaperComponent + wallpaperVisualsRequested: root.controller.wallpaperVisualsRequested + weatherCompactComponent: compactWeatherComponent + weatherExpandedComponent: expandedWeatherComponent + weatherVisualsRequested: root.controller.weatherVisualsRequested + systemCompactComponent: compactSystemComponent + systemExpandedComponent: expandedSystemComponent + notificationCompactComponent: compactNotificationComponent + notificationExpandedComponent: expandedNotificationComponent + notificationCenterCompactComponent: compactNotificationCenterComponent + notificationCenterExpandedComponent: expandedNotificationCenterComponent + notificationCenterVisualsRequested: root.controller.notificationCenterVisualsRequested + } + + HoverHandler { + onHoveredChanged: { + if (hovered) + root.controller.updatePointerInside(true); + else + root.controller.updatePointerInside(false); + } + } + } + + MediaDropdownOverlay { + id: mediaDropdowns + + dropdownType: root.mediaDropdownType + anchorPos: root.mediaDropdownAnchor + isRightEdge: true + availableBounds: Qt.rect(0, 0, root.width, root.height) + activePlayer: MprisController.activePlayer + allPlayers: MprisController.availablePlayers + targetWindow: root.Window.window + onCloseRequested: root.hideMediaDropdowns() + onPanelEntered: root.stopMediaDropdownCloseTimer() + onPanelExited: root.startMediaDropdownCloseTimer() + onVolumeChanged: volume => { + const player = MprisController.activePlayer; + const identity = (player?.identity ?? "").toLowerCase(); + const chrome = identity.includes("chrome") || identity.includes("chromium"); + if (player && player.volumeSupported && !chrome) + player.volume = volume; + else if (AudioService.sink?.audio) + AudioService.sink.audio.volume = volume; + } + onDeviceSelected: device => AudioService.setSink(device) + onPlayerSelected: player => MprisController.setActivePlayer(player) + } + + Component { + id: compactHomeComponent + + HomeCompact { + controller: root.controller + mediaModel: root.mediaModel + } + } + + Component { + id: expandedHomeComponent + + HomeExpanded { + islandController: root.controller + } + } + + Component { + id: compactMediaComponent + + MediaCompact { + mediaModel: root.mediaModel + controller: root.controller + } + } + + Component { + id: expandedMediaComponent + + MediaExpanded { + islandController: root.controller + geometrySettled: !motion.running + effectiveScreen: root.effectiveScreen + alignedX: root.targetVisualX + // Surface-local on purpose: the media dropdowns it anchors live inside this surface. + alignedY: root.targetVisualY + alignedWidth: root.targetVisualWidth + alignedHeight: root.targetVisualHeight + onShowVolumeDropdown: pos => root.openMediaDropdown(1, pos) + onShowAudioDevicesDropdown: pos => root.openMediaDropdown(2, pos) + onShowPlayersDropdown: pos => root.openMediaDropdown(3, pos) + onHideDropdowns: root.hideMediaDropdowns() + onDropdownHoverEnded: root.startMediaDropdownCloseTimer() + } + } + + Component { + id: compactLauncherComponent + + LauncherCompact { + controller: root.controller + } + } + + Component { + id: expandedLauncherComponent + + LauncherExpanded { + islandController: root.controller + launcherController: root.launcherController + transientSurfaceTracker: root.launcherTransientSurfaceTracker + effectiveScreen: root.effectiveScreen + // Settled bounds; animated values rebind the tree every frame. + alignedX: root.targetVisualX + alignedY: root.targetScreenY + } + } + + Component { + id: compactControlCenterComponent + + ControlCenterCompact { + controller: root.controller + } + } + + Component { + id: expandedControlCenterComponent + + ControlCenterExpanded { + islandController: root.controller + effectiveScreen: root.effectiveScreen + colorPickerModal: root.colorPickerModal + powerMenuModalLoader: root.powerMenuModalLoader + // Settled bounds; animated values rebind the tree every frame. + alignedX: root.targetVisualX + alignedY: root.targetScreenY + alignedWidth: root.targetVisualWidth + alignedHeight: root.targetVisualHeight + onLockRequested: root.lockRequested() + } + } + + Component { + id: compactWallpaperComponent + + WallpaperCompact { + controller: root.controller + } + } + + Component { + id: expandedWallpaperComponent + + WallpaperExpanded { + islandController: root.controller + effectiveScreen: root.effectiveScreen + } + } + + Component { + id: compactWeatherComponent + + WeatherCompact { + controller: root.controller + } + } + + Component { + id: expandedWeatherComponent + + WeatherExpanded { + islandController: root.controller + } + } + + Component { + id: compactSystemComponent + + SystemLevelCompact { + systemModel: root.systemModel + dense: root.controller.compactDense + iconSize: root.controller.compactIconSize + } + } + + Component { + id: expandedSystemComponent + + SystemLevelExpanded { + systemModel: root.systemModel + } + } + + Component { + id: compactNotificationComponent + + NotificationCompact { + notificationModel: root.notificationModel + dense: root.controller.compactDense + iconSize: root.controller.compactIconSize + } + } + + Component { + id: compactNotificationCenterComponent + + NotificationCenterCompact { + controller: root.controller + } + } + + Component { + id: expandedNotificationCenterComponent + + NotificationCenterExpanded { + islandController: root.controller + effectiveScreen: root.effectiveScreen + transientSurfaceTracker: root.notificationTransientSurfaceTracker + } + } + + Component { + id: expandedNotificationComponent + + NotificationExpanded { + notificationModel: root.notificationModel + } + } +} diff --git a/quickshell/Modules/DankIsland/DmsMediaActivitySource.qml b/quickshell/Modules/DankIsland/DmsMediaActivitySource.qml new file mode 100644 index 000000000..8fcd7ed94 --- /dev/null +++ b/quickshell/Modules/DankIsland/DmsMediaActivitySource.qml @@ -0,0 +1,115 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import qs.Services + +QtObject { + id: root + + required property IslandController controller + + readonly property bool demoEnabled: Quickshell.env("DMS_DANKISLAND_MEDIA_DEMO") === "1" + readonly property var demoTracks: [ + { + "title": "DankIsland Motion Study", + "artist": "DankMaterialShell", + "album": "Physical Springs", + "icon": "applications-multimedia" + }, + { + "title": "Event Horizon", + "artist": "Quickshell Ensemble", + "album": "Signal Driven", + "icon": "audio-x-generic" + }, + { + "title": "Warm Surface", + "artist": "Scene Graph", + "album": "Sixty Frames", + "icon": "multimedia-player" + } + ] + readonly property var demoTrack: demoTracks[demoTrackIndex] + readonly property var player: MprisController.activePlayer + readonly property bool realMediaAvailable: !!player && player.playbackState !== MprisPlaybackState.Stopped + readonly property bool available: demoEnabled || realMediaAvailable + readonly property string title: demoEnabled ? demoTrack.title : (MprisController.stableTitle || player?.trackTitle || "Unknown track") + readonly property string artist: demoEnabled ? demoTrack.artist : (MprisController.stableArtist || player?.trackArtist || player?.identity || "Unknown artist") + readonly property string album: demoEnabled ? demoTrack.album : (MprisController.stableAlbum || player?.trackAlbum || "") + readonly property string artUrl: demoEnabled ? Quickshell.iconPath(demoTrack.icon, true) : TrackArtService.resolvedArtUrl + readonly property bool playing: demoEnabled ? demoPlaying : !!player?.isPlaying + readonly property bool canToggle: demoEnabled || !!player?.canTogglePlaying + readonly property bool canGoPrevious: demoEnabled || !!player?.canGoPrevious + readonly property bool canGoNext: demoEnabled || !!player?.canGoNext + readonly property bool canSeek: demoEnabled || (!!player?.canSeek && length > 0) + readonly property real position: demoEnabled ? demoPosition : Math.max(0, player?.position || 0) + readonly property real length: demoEnabled ? 243 : Math.max(0, MprisController.activePlayerStableLength) + readonly property real progress: length > 0 ? Math.max(0, Math.min(1, position / length)) : 0 + + property int demoTrackIndex: 0 + property bool demoPlaying: true + property real demoPosition: 74 + + function advanceDemoTrack(delta) { + demoTrackIndex = (demoTrackIndex + delta + demoTracks.length) % demoTracks.length; + demoPosition = 0; + } + + function syncAvailability() { + controller.updateMediaAvailability(available); + } + + function togglePlaying() { + if (demoEnabled) { + demoPlaying = !demoPlaying; + return; + } + if (player?.canTogglePlaying) + player.togglePlaying(); + } + + function previous() { + if (demoEnabled) { + advanceDemoTrack(-1); + return; + } + MprisController.previousOrRewind(); + } + + function next() { + if (demoEnabled) { + advanceDemoTrack(1); + return; + } + MprisController.next(); + } + + function seekRatio(ratio) { + if (!canSeek || length <= 0) + return; + const nextPosition = Math.max(0.1, Math.min(length - 0.1, length * Math.max(0, Math.min(1, ratio)))); + if (demoEnabled) + demoPosition = nextPosition; + else + player.position = nextPosition; + } + + Component.onCompleted: syncAvailability() + onAvailableChanged: syncAvailability() + + property Timer demoTimer: Timer { + interval: 1000 + repeat: true + running: root.demoEnabled && root.demoPlaying + onTriggered: root.demoPosition = (root.demoPosition + 1) % root.length + } + + property Timer demoTrackTimer: Timer { + interval: 4200 + repeat: true + running: root.demoEnabled + onTriggered: root.advanceDemoTrack(1) + } +} diff --git a/quickshell/Modules/DankIsland/DmsNotificationActivitySource.qml b/quickshell/Modules/DankIsland/DmsNotificationActivitySource.qml new file mode 100644 index 000000000..6588e4f51 --- /dev/null +++ b/quickshell/Modules/DankIsland/DmsNotificationActivitySource.qml @@ -0,0 +1,244 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import qs.Common +import qs.Services + +QtObject { + id: root + + required property IslandController controller + required property var targetScreen + + property var currentWrapper: null + property string appName: "" + property string summary: "" + property string body: "" + property string timeText: "" + property string imageSource: "" + property string fallbackIcon: "material:notifications" + property string fallbackText: "" + property string actionLabel: "" + property bool critical: false + property int demoIndex: 0 + + readonly property bool demoEnabled: Quickshell.env("DMS_DANKISLAND_NOTIFICATION_DEMO") === "1" + readonly property bool hasAction: actionLabel.length > 0 + // Keep the wrapper alive while the island is hovered or expanded, matching NotificationPopup. + readonly property bool wrapperTimeoutHeld: controller.notificationActive && controller.timeoutSuspended + + function isFocusedScreen() { + if (!SettingsData.notificationFocusedMonitor) + return true; + const focused = CompositorService.getFocusedScreen(); + return !!focused && !!root.targetScreen && focused.name === root.targetScreen.name; + } + + // Hold display fields through the outgoing crossfade. + function scheduleDisplayFieldClear() { + displayFieldClearTimer.restart(); + } + + function clearDisplayFields() { + if (currentWrapper) + return; + appName = ""; + summary = ""; + body = ""; + timeText = ""; + imageSource = ""; + fallbackIcon = "material:notifications"; + fallbackText = ""; + actionLabel = ""; + critical = false; + } + + function wrapperTimeout(wrapper) { + const interval = wrapper?.timer?.interval ?? -1; + return interval >= 0 ? interval : 5000; + } + + function plainText(value) { + return String(value || "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim(); + } + + function resolveImage(wrapper) { + const image = wrapper?.cleanImage || ""; + if (image) + return image; + const icon = wrapper?.appIcon || ""; + if (icon.startsWith("file://") || icon.startsWith("http://") || icon.startsWith("https://") || icon.includes("/")) + return icon; + return ""; + } + + function resolveFallbackIcon(wrapper) { + const rawImage = wrapper?.image || ""; + if (rawImage.startsWith("image://icon/")) + return rawImage.substring(13); + return wrapper?.appIcon || "material:notifications"; + } + + function applyWrapperTimeoutHold() { + const timer = currentWrapper?.timer; + if (!timer) + return; + if (wrapperTimeoutHeld) { + timer.stop(); + return; + } + if (currentWrapper.popup && timer.interval > 0) + timer.restart(); + } + + function releaseWrapperTimeoutHold() { + const timer = currentWrapper?.timer; + if (!timer || !currentWrapper.popup || timer.interval <= 0) + return; + timer.restart(); + } + + function adopt(wrapper) { + if (!wrapper || !isFocusedScreen()) + return false; + + const isCritical = wrapper.urgency === NotificationUrgency.Critical; + if (!controller.canRequestNotification(isCritical)) + return false; + + releaseWrapperTimeoutHold(); + displayFieldClearTimer.stop(); + currentWrapper = wrapper; + appName = wrapper.appName || "Notification"; + summary = plainText(wrapper.summary) || appName; + body = plainText(wrapper.body); + timeText = wrapper.timeStr || "Now"; + imageSource = resolveImage(wrapper); + fallbackIcon = resolveFallbackIcon(wrapper); + fallbackText = appName.charAt(0).toUpperCase(); + critical = isCritical; + const actions = wrapper.actions || []; + actionLabel = actions.length > 0 ? (actions[0].text || "Open") : ""; + controller.notificationTimeout = wrapperTimeout(wrapper); + const accepted = controller.requestNotification(isCritical); + applyWrapperTimeoutHold(); + return accepted; + } + + function refreshDeduplicated(wrapper) { + if (!wrapper || wrapper !== currentWrapper) + return; + controller.refreshNotification(); + applyWrapperTimeoutHold(); + } + + onWrapperTimeoutHeldChanged: applyWrapperTimeoutHold() + + function syncVisibleNotifications() { + if (demoEnabled) + return; + + const visible = NotificationService.visibleNotifications || []; + if (visible.length > 0) { + const latest = visible[visible.length - 1]; + if (latest === currentWrapper || adopt(latest)) + return; + } + + if (currentWrapper && visible.indexOf(currentWrapper) === -1) { + currentWrapper = null; + controller.completeNotification(); + scheduleDisplayFieldClear(); + } + } + + function activate() { + if (demoEnabled) { + controller.completeNotification(); + return; + } + const actions = currentWrapper?.actions || []; + if (actions.length > 0) + actions[0].invoke(); + dismiss(); + } + + function dismiss() { + const wrapper = currentWrapper; + currentWrapper = null; + controller.completeNotification(); + scheduleDisplayFieldClear(); + if (!demoEnabled && wrapper) + NotificationService.dismissNotification(wrapper); + } + + function showDemo() { + const variants = [ + { + "appName": "Messages", + "summary": "New message from Alex", + "body": "The new activity transition looks great. Are we still on for tomorrow?", + "icon": "material:mark_chat_unread", + "action": "Reply" + }, + { + "appName": "Calendar", + "summary": "Design review in 10 minutes", + "body": "DankIsland activity protocol · Studio call", + "icon": "material:event_upcoming", + "action": "Open" + } + ]; + const demo = variants[demoIndex % variants.length]; + demoIndex += 1; + if (!controller.canRequestNotification(false)) + return; + releaseWrapperTimeoutHold(); + displayFieldClearTimer.stop(); + currentWrapper = null; + appName = demo.appName; + summary = demo.summary; + body = demo.body; + timeText = "Now"; + imageSource = ""; + fallbackIcon = demo.icon; + fallbackText = demo.appName.charAt(0); + actionLabel = demo.action; + critical = false; + controller.notificationTimeout = 5000; + controller.requestNotification(false); + } + + property Connections notificationConnections: Connections { + target: NotificationService + + function onVisibleNotificationsChanged() { + root.syncVisibleNotifications(); + } + + function onNotificationDeduplicated(wrapper) { + root.refreshDeduplicated(wrapper); + } + } + + property Timer displayFieldClearTimer: Timer { + interval: 400 + onTriggered: root.clearDisplayFields() + } + + property Timer demoTimer: Timer { + interval: 7000 + repeat: true + running: root.demoEnabled + onTriggered: root.showDemo() + } + + Component.onCompleted: { + if (demoEnabled) + Qt.callLater(showDemo); + else + syncVisibleNotifications(); + } +} diff --git a/quickshell/Modules/DankIsland/DmsSystemActivitySource.qml b/quickshell/Modules/DankIsland/DmsSystemActivitySource.qml new file mode 100644 index 000000000..1eab5b2cb --- /dev/null +++ b/quickshell/Modules/DankIsland/DmsSystemActivitySource.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +QtObject { + id: root + + required property IslandController controller + + property string kind: "volume" + property real demoRatio: 0.68 + + readonly property bool demoEnabled: Quickshell.env("DMS_DANKISLAND_SYSTEM_DEMO") === "1" + + readonly property bool volumeActivity: kind === "volume" + readonly property bool available: demoEnabled || (volumeActivity ? !!AudioService.sink?.audio : DisplayService.brightnessAvailable) + readonly property bool muted: !demoEnabled && volumeActivity && (AudioService.sink?.audio?.muted ?? false) + readonly property real value: demoEnabled ? demoRatio * 100 : volumeActivity ? Math.min(AudioService.sinkMaxVolume, Math.round((AudioService.sink?.audio?.volume ?? 0) * 100)) : DisplayService.brightnessLevel + readonly property real maximum: { + if (demoEnabled) + return 100; + if (volumeActivity) + return AudioService.sinkMaxVolume; + const deviceInfo = DisplayService.getCurrentDeviceInfo(); + if (!deviceInfo) + return 100; + if (SessionData.getBrightnessExponential(deviceInfo.id)) + return 100; + return deviceInfo.displayMax || 100; + } + readonly property int minimum: { + if (volumeActivity || demoEnabled) + return 0; + const deviceInfo = DisplayService.getCurrentDeviceInfo(); + if (!deviceInfo) + return 1; + if (SessionData.getBrightnessExponential(deviceInfo.id)) + return 1; + return (deviceInfo.class === "backlight" || deviceInfo.class === "ddc") ? 1 : 0; + } + readonly property real ratio: maximum > 0 ? Math.max(0, Math.min(1, value / maximum)) : 0 + readonly property string title: volumeActivity ? "Volume" : "Brightness" + readonly property string unit: { + if (volumeActivity) + return "%"; + const deviceInfo = demoEnabled ? null : DisplayService.getCurrentDeviceInfo(); + if (!deviceInfo) + return "%"; + if (SessionData.getBrightnessExponential(deviceInfo.id)) + return "%"; + return deviceInfo.class === "ddc" ? "" : "%"; + } + readonly property string displayValue: muted ? I18n.tr("Muted") : Math.round(value) + unit + readonly property string iconName: { + if (!volumeActivity) { + const deviceInfo = demoEnabled ? null : DisplayService.getCurrentDeviceInfo(); + if (!demoEnabled && (!available || !deviceInfo)) + return "brightness_low"; + if (demoEnabled || deviceInfo.class === "backlight" || deviceInfo.class === "ddc") { + if (ratio <= 0.33) + return "brightness_low"; + return ratio <= 0.66 ? "brightness_medium" : "brightness_high"; + } + if (String(deviceInfo.name ?? "").includes("kbd")) + return "keyboard"; + return "lightbulb"; + } + if (!available || muted) + return "volume_off"; + const volume = demoEnabled ? ratio : (AudioService.sink?.audio?.volume ?? 0); + if (volume === 0) + return "volume_mute"; + return volume <= 0.33 ? "volume_down" : "volume_up"; + } + + function show(activityKind) { + if (controller.requestSystemActivity(activityKind)) + kind = activityKind; + } + + function setRatio(nextRatio) { + const clampedRatio = Math.max(0, Math.min(1, nextRatio)); + if (demoEnabled) { + demoRatio = clampedRatio; + return; + } + SessionData.suppressOSDTemporarily(); + if (volumeActivity) + AudioService.setVolume(Math.round(clampedRatio * maximum)); + else + DisplayService.setBrightness(Math.round(clampedRatio * maximum), DisplayService.lastIpcDevice, true); + } + + function toggleMute() { + if (!volumeActivity || !available) + return; + if (demoEnabled) + return; + SessionData.suppressOSDTemporarily(); + AudioService.toggleMute(); + } + + property Connections audioConnection: Connections { + target: AudioService.sink?.audio ?? null + + function onVolumeChanged() { + if (SettingsData.osdVolumeEnabled) + root.show("volume"); + } + + function onMutedChanged() { + if (SettingsData.osdVolumeEnabled) + root.show("volume"); + } + } + + property Connections brightnessConnection: Connections { + target: DisplayService + + function onBrightnessChanged(showOsd) { + if (showOsd && SettingsData.osdBrightnessEnabled) + root.show("brightness"); + } + } + + property Timer demoTimer: Timer { + interval: 3200 + repeat: true + running: root.demoEnabled + + onTriggered: { + const nextKind = root.kind === "volume" ? "brightness" : "volume"; + root.demoRatio = nextKind === "volume" ? 0.68 : 0.82; + root.show(nextKind); + } + } + + Component.onCompleted: { + if (demoEnabled) + show("volume"); + } +} diff --git a/quickshell/Modules/DankIsland/IslandContentHost.qml b/quickshell/Modules/DankIsland/IslandContentHost.qml new file mode 100644 index 000000000..80c04c331 --- /dev/null +++ b/quickshell/Modules/DankIsland/IslandContentHost.qml @@ -0,0 +1,339 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + required property real morphProgress + required property bool expanded + required property bool pointerInside + required property string activityId + required property Component homeCompactComponent + required property Component homeExpandedComponent + required property Component mediaCompactComponent + required property Component mediaExpandedComponent + required property Component launcherCompactComponent + required property Component launcherExpandedComponent + required property bool launcherVisualsRequested + required property Component controlCenterCompactComponent + required property Component controlCenterExpandedComponent + required property bool controlCenterVisualsRequested + required property Component wallpaperCompactComponent + required property Component wallpaperExpandedComponent + required property bool wallpaperVisualsRequested + required property Component weatherCompactComponent + required property Component weatherExpandedComponent + required property bool weatherVisualsRequested + required property Component systemCompactComponent + required property Component systemExpandedComponent + required property Component notificationCompactComponent + required property Component notificationExpandedComponent + required property Component notificationCenterCompactComponent + required property Component notificationCenterExpandedComponent + required property bool notificationCenterVisualsRequested + + readonly property real compactFade: root.fadeCompact(root.morphProgress) + readonly property real expandedFade: root.fadeExpanded(root.morphProgress) + // Outgoing faces freeze at the morph they were drawn at so they do not flash expanded. + readonly property real outgoingCompactFade: root.fadeCompact(root.outgoingMorph) + readonly property real outgoingExpandedFade: root.fadeExpanded(root.outgoingMorph) + readonly property bool mediaSurfaceActive: root.activityId === "media" || root.outgoingActivity === "media" + + property string renderedActivity: "home" + property string outgoingActivity: "" + property real activityFade: 1 + property real outgoingMorph: 0 + property bool homeExpandedTouched: false + + function fadeCompact(morph) { + return 1 - Math.max(0, Math.min(1, morph / 0.34)); + } + + function fadeExpanded(morph) { + return Math.max(0, Math.min(1, (morph - 0.22) / 0.42)); + } + + function surfaceActive(activity) { + return root.renderedActivity === activity || root.outgoingActivity === activity; + } + + function compactOpacity(activity) { + const incoming = root.renderedActivity === activity ? root.activityFade * root.compactFade : 0; + const outgoing = root.outgoingActivity === activity ? (1 - root.activityFade) * root.outgoingCompactFade : 0; + return Math.max(incoming, outgoing); + } + + function expandedOpacity(activity) { + const incoming = root.renderedActivity === activity ? root.activityFade * root.expandedFade : 0; + const outgoing = root.outgoingActivity === activity ? (1 - root.activityFade) * root.outgoingExpandedFade : 0; + return Math.max(incoming, outgoing); + } + + function requestActivityFocus() { + if (root.activityId === "launcher" && launcherExpandedLoader.item) { + launcherExpandedLoader.item.focusSearch(); + return true; + } + if (root.activityId === "home" && homeExpandedLoader.item) { + homeExpandedLoader.item.focusOverview(); + return true; + } + if (root.activityId === "media" && mediaExpandedLoader.item) { + mediaExpandedLoader.item.focusPlayer(); + return true; + } + if (root.activityId === "wallpaper" && wallpaperExpandedLoader.item) { + wallpaperExpandedLoader.item.focusGrid(); + return true; + } + if (root.activityId === "weather" && weatherExpandedLoader.item) { + weatherExpandedLoader.item.focusWeather(); + return true; + } + if (root.activityId === "notificationcenter" && notificationCenterExpandedLoader.item) { + notificationCenterExpandedLoader.item.focusList(); + return true; + } + return false; + } + + function latchHomeExpanded() { + if (!homeExpandedTouched && expanded && activityId === "home") + homeExpandedTouched = true; + } + + // Overview is latched on first pointer intent, not at startup. + onPointerInsideChanged: { + if (root.pointerInside) + root.homeExpandedTouched = true; + } + + onExpandedChanged: latchHomeExpanded() + + onActivityIdChanged: { + latchHomeExpanded(); + if (activityId === renderedActivity) + return; + outgoingMorph = morphProgress; + outgoingActivity = renderedActivity; + renderedActivity = activityId; + activityFade = 0; + activityTransition.restart(); + } + + SequentialAnimation { + id: activityTransition + + PauseAnimation { + duration: 45 + } + + NumberAnimation { + target: root + property: "activityFade" + to: 1 + duration: 180 + easing.type: Easing.OutCubic + } + + ScriptAction { + script: root.outgoingActivity = "" + } + } + + Loader { + anchors.fill: parent + active: true + asynchronous: false + sourceComponent: root.homeCompactComponent + opacity: root.compactOpacity("home") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: homeExpandedLoader + + anchors.fill: parent + // Latched: built on first expansion, then kept so later morphs never hitch. + active: root.homeExpandedTouched + asynchronous: true + sourceComponent: root.homeExpandedComponent + opacity: root.expandedOpacity("home") + visible: active || opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.mediaSurfaceActive + asynchronous: false + sourceComponent: root.mediaCompactComponent + opacity: root.compactOpacity("media") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: mediaExpandedLoader + + anchors.fill: parent + active: root.mediaSurfaceActive && (root.expanded || root.expandedFade > 0) + asynchronous: false + sourceComponent: root.mediaExpandedComponent + opacity: root.expandedOpacity("media") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("launcher") + asynchronous: false + sourceComponent: root.launcherCompactComponent + opacity: root.compactOpacity("launcher") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: launcherExpandedLoader + + anchors.fill: parent + active: root.launcherVisualsRequested + asynchronous: true + sourceComponent: root.launcherExpandedComponent + opacity: root.expandedOpacity("launcher") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("controlcenter") + asynchronous: false + sourceComponent: root.controlCenterCompactComponent + opacity: root.compactOpacity("controlcenter") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: controlCenterExpandedLoader + + anchors.fill: parent + active: root.controlCenterVisualsRequested + asynchronous: false + sourceComponent: root.controlCenterExpandedComponent + opacity: root.expandedOpacity("controlcenter") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("wallpaper") + asynchronous: false + sourceComponent: root.wallpaperCompactComponent + opacity: root.compactOpacity("wallpaper") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: wallpaperExpandedLoader + + anchors.fill: parent + active: root.wallpaperVisualsRequested + asynchronous: true + sourceComponent: root.wallpaperExpandedComponent + opacity: root.expandedOpacity("wallpaper") + visible: active || opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("weather") + asynchronous: false + sourceComponent: root.weatherCompactComponent + opacity: root.compactOpacity("weather") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: weatherExpandedLoader + + anchors.fill: parent + active: root.weatherVisualsRequested + asynchronous: true + sourceComponent: root.weatherExpandedComponent + opacity: root.expandedOpacity("weather") + visible: active || opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("notificationcenter") + asynchronous: false + sourceComponent: root.notificationCenterCompactComponent + opacity: root.compactOpacity("notificationcenter") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + id: notificationCenterExpandedLoader + + anchors.fill: parent + active: root.notificationCenterVisualsRequested + asynchronous: true + sourceComponent: root.notificationCenterExpandedComponent + opacity: root.expandedOpacity("notificationcenter") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("volume") || root.surfaceActive("brightness") + asynchronous: false + sourceComponent: root.systemCompactComponent + opacity: Math.max(root.compactOpacity("volume"), root.compactOpacity("brightness")) + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: (root.surfaceActive("volume") || root.surfaceActive("brightness")) && (root.expanded || root.expandedFade > 0) + asynchronous: false + sourceComponent: root.systemExpandedComponent + opacity: Math.max(root.expandedOpacity("volume"), root.expandedOpacity("brightness")) + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("notification") + asynchronous: false + sourceComponent: root.notificationCompactComponent + opacity: root.compactOpacity("notification") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } + + Loader { + anchors.fill: parent + active: root.surfaceActive("notification") && (root.expanded || root.expandedFade > 0) + asynchronous: false + sourceComponent: root.notificationExpandedComponent + opacity: root.expandedOpacity("notification") + visible: opacity > 0.001 + enabled: opacity >= 0.5 + } +} diff --git a/quickshell/Modules/DankIsland/IslandController.qml b/quickshell/Modules/DankIsland/IslandController.qml new file mode 100644 index 000000000..ff09c1dd1 --- /dev/null +++ b/quickshell/Modules/DankIsland/IslandController.qml @@ -0,0 +1,1086 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common + +QtObject { + id: root + + // Hybrid expands glance faces on hover but keeps full destinations click-driven. + property string interactionMode: "click" + property bool hoverExpanded: false + property bool inputSuspended: false + property bool expanded: false + property bool pointerInside: false + // Slots with their own click target suppress hover expansion so the click wins. + property int slotHoverCount: 0 + readonly property bool slotHovered: slotHoverCount > 0 + property bool mediaDropdownOpen: false + property bool autoDemo: false + property bool keyboardDismissRequested: false + property string activeActivity: "home" + property bool mediaAvailable: false + property bool mediaPreferred: false + property bool launcherCycleEnabled: false + property bool launcherVisualsRequested: false + property bool launcherVisualsReady: false + property bool launcherRequestPending: false + property bool launcherSessionActive: false + property bool launcherInputFocused: false + property string launcherPendingQuery: "" + property string launcherPendingMode: "" + property int launcherSessionSerial: 0 + property bool _launcherPendingExpand: true + property bool _launcherPendingKeyboardFocus: true + property bool controlCenterVisualsRequested: false + property bool controlCenterVisualsReady: false + property bool controlCenterRequestPending: false + property string controlCenterPendingSection: "" + property int controlCenterSessionSerial: 0 + property bool wallpaperVisualsRequested: false + property bool wallpaperVisualsReady: false + property bool wallpaperRequestPending: false + property int wallpaperSessionSerial: 0 + property bool weatherVisualsRequested: false + property bool weatherVisualsReady: false + property bool weatherRequestPending: false + property bool notificationCenterVisualsRequested: false + property bool notificationCenterVisualsReady: false + property bool notificationCenterRequestPending: false + property int notificationCenterSessionSerial: 0 + // Arriving notifications stay pill-sized unless the user opts into full expansion. + property bool notificationExpandAllowed: false + property bool keyboardYielded: false + property real horizontalOffset: 0 + property real outerGap: 8 + // Expanded silhouette signature: tight corners on the screen edge, open corners facing the desktop. + readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom" + readonly property real anchoredRadius: bottomEdge ? 34 : 26 + readonly property real freeRadius: bottomEdge ? 26 : 34 + property real compactHeight: 38 + property int transientTimeout: 2200 + // Adopted popup timeout; 0 is sticky until dismiss. + property int notificationTimeout: 5000 + property string transientReturnActivity: "" + property string suspendedActivity: "" + property bool suspendedExpanded: false + property bool suspendedKeyboardDismissRequested: false + property int hoverOpenDelay: 220 + property int hoverCloseDelay: 400 + property int mediaReturnDelay: 1800 + property real controlCenterContentHeight: 420 + property real controlCenterMaxHeight: 640 + readonly property real controlCenterHeight: Math.max(320, Math.min(controlCenterMaxHeight, controlCenterContentHeight)) + property real notificationCenterContentHeight: 320 + property real notificationCenterMaxHeight: 640 + readonly property real notificationCenterHeight: Math.max(320, Math.min(notificationCenterMaxHeight, notificationCenterContentHeight)) + + // Below the pill threshold every compact face drops to a single text line. + readonly property bool compactDense: compactHeight < 40 + readonly property real compactFaceHeight: compactHeight + (compactDense ? 2 : 4) + readonly property real compactIconSize: Math.max(22, Math.min(32, compactHeight - 8)) + readonly property bool homeCompactTight: SettingsData.dankIslandHomeCompactTight + readonly property real homeCompactFaceHeight: homeCompactTight ? Math.max(28, Math.min(32, compactHeight - 8)) : compactFaceHeight + + property int unreadNotificationCount: 0 + readonly property bool homeNotificationBadge: unreadNotificationCount > 0 + readonly property real homeSlotMargin: homeCompactTight ? 4 : 8 + readonly property real homeSlotSpacing: homeCompactTight ? 2 : 4 + readonly property real homeClusterGap: homeCompactTight ? 6 : 10 + property real homeContentWidth: 200 + + // Ignore sub-2px clock jitter so the spring does not restart every tick. + function setHomeContentWidth(width) { + const next = Math.ceil(width); + if (!isFinite(next) || next <= 0 || Math.abs(next - homeContentWidth) < 2) + return; + homeContentWidth = next; + } + + property real mediaContentWidth: 360 + readonly property real mediaCompactMaxWidth: 360 + + function setMediaContentWidth(width) { + const next = Math.ceil(Math.min(root.mediaCompactMaxWidth, width)); + if (!isFinite(next) || next <= 0 || Math.abs(next - mediaContentWidth) < 2) + return; + mediaContentWidth = next; + } + + property real launcherContentWidth: 160 + property real controlCenterContentWidth: 180 + property real wallpaperContentWidth: 150 + property real weatherContentWidth: 130 + property real notificationCenterContentWidth: 170 + readonly property real destinationCompactEndPad: Math.max(root.compactFaceHeight / 2, Theme.spacingM) + Theme.spacingS + + function destinationCompactWidthFromContent(contentWidth) { + return Math.ceil(Math.max(root.compactFaceHeight, contentWidth + root.destinationCompactEndPad * 2)); + } + + function setDestinationContentWidth(activityId, width) { + const next = Math.ceil(width); + if (!isFinite(next) || next <= 0) + return; + switch (activityId) { + case "launcher": + if (Math.abs(next - launcherContentWidth) < 2) + return; + launcherContentWidth = next; + return; + case "controlcenter": + if (Math.abs(next - controlCenterContentWidth) < 2) + return; + controlCenterContentWidth = next; + return; + case "wallpaper": + if (Math.abs(next - wallpaperContentWidth) < 2) + return; + wallpaperContentWidth = next; + return; + case "weather": + if (Math.abs(next - weatherContentWidth) < 2) + return; + weatherContentWidth = next; + return; + case "notificationcenter": + if (Math.abs(next - notificationCenterContentWidth) < 2) + return; + notificationCenterContentWidth = next; + return; + } + } + + function setLauncherContentWidth(width) { + root.setDestinationContentWidth("launcher", width); + } + + function resolvedHomeSlot(value, fallback) { + if (value === "left" || value === "right" || value === "hidden") + return value; + return fallback; + } + + readonly property string homeMediaSlot: resolvedHomeSlot(SettingsData.dankIslandHomeMediaSlot, "left") + readonly property string homeStatusSlot: resolvedHomeSlot(SettingsData.dankIslandHomeStatusSlot, "right") + readonly property string homeWeatherSlot: SettingsData.weatherEnabled ? resolvedHomeSlot(SettingsData.dankIslandHomeWeatherSlot, "hidden") : "hidden" + + readonly property real homeCompactWidth: Math.ceil(homeSlotMargin * 2 + Math.max(homeContentWidth, 1)) + + readonly property var homeCompactTarget: ({ + "width": root.homeCompactWidth, + "height": root.homeCompactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.homeCompactFaceHeight / 2, + "topRightRadius": root.homeCompactFaceHeight / 2, + "bottomLeftRadius": root.homeCompactFaceHeight / 2, + "bottomRightRadius": root.homeCompactFaceHeight / 2 + }) + readonly property var homeExpandedTarget: ({ + "width": SettingsData.showWeekNumber ? 736 : 700, + "height": 452, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property real mediaCompactWidth: Math.ceil(Math.max(1, mediaContentWidth)) + + readonly property var mediaCompactTarget: ({ + "width": root.mediaCompactWidth, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var mediaExpandedTarget: ({ + "width": 600, + "height": 352, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property real launcherCompactWidth: root.destinationCompactWidthFromContent(launcherContentWidth) + readonly property real controlCenterCompactWidth: root.destinationCompactWidthFromContent(controlCenterContentWidth) + readonly property real wallpaperCompactWidth: root.destinationCompactWidthFromContent(wallpaperContentWidth) + readonly property real weatherCompactWidth: root.destinationCompactWidthFromContent(weatherContentWidth) + readonly property real notificationCenterCompactWidth: root.destinationCompactWidthFromContent(notificationCenterContentWidth) + + readonly property var launcherCompactTarget: ({ + "width": root.launcherCompactWidth, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var launcherExpandedTarget: ({ + "width": 680, + "height": 560, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property var controlCenterCompactTarget: ({ + "width": root.controlCenterCompactWidth, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var controlCenterExpandedTarget: ({ + "width": 580, + "height": root.controlCenterHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property var wallpaperCompactTarget: ({ + "width": root.wallpaperCompactWidth, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var wallpaperExpandedTarget: ({ + "width": 700, + "height": 452, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property var weatherCompactTarget: ({ + "width": root.weatherCompactWidth, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var weatherExpandedTarget: ({ + "width": SettingsData.showWeekNumber ? 736 : 700, + "height": 452, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property var systemCompactTarget: ({ + "width": SettingsData.osdAlwaysShowValue ? 330 : 282, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var systemExpandedTarget: ({ + "width": 460, + "height": 176, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property var notificationCompactTarget: ({ + "width": root.compactDense ? 320 : 360, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var notificationExpandedTarget: ({ + "width": 520, + "height": 220, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property var notificationCenterCompactTarget: ({ + "width": root.notificationCenterCompactWidth, + "height": root.compactFaceHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.compactFaceHeight / 2, + "topRightRadius": root.compactFaceHeight / 2, + "bottomLeftRadius": root.compactFaceHeight / 2, + "bottomRightRadius": root.compactFaceHeight / 2 + }) + readonly property var notificationCenterExpandedTarget: ({ + "width": 480, + "height": root.notificationCenterHeight, + "offsetX": root.horizontalOffset, + "offsetY": root.outerGap, + "topLeftRadius": root.anchoredRadius, + "topRightRadius": root.anchoredRadius, + "bottomLeftRadius": root.freeRadius, + "bottomRightRadius": root.freeRadius + }) + readonly property bool systemActivityActive: activeActivity === "volume" || activeActivity === "brightness" + readonly property bool notificationActive: activeActivity === "notification" + readonly property bool transientActive: systemActivityActive || notificationActive + readonly property bool timeoutSuspended: pointerInside || (expanded && !notificationActive) + readonly property var fullDestinations: ["launcher", "controlcenter", "wallpaper", "weather", "notificationcenter"] + readonly property bool activityOwnsBlankClicks: root.fullDestinations.indexOf(root.activeActivity) !== -1 + readonly property bool hoverExpandEnabled: root.interactionMode === "hybrid" && !root.activityOwnsBlankClicks && !root.systemActivityActive + readonly property var compactTarget: { + if (root.notificationActive) + return root.notificationCompactTarget; + switch (root.activeActivity) { + case "launcher": + return root.launcherCompactTarget; + case "controlcenter": + return root.controlCenterCompactTarget; + case "wallpaper": + return root.wallpaperCompactTarget; + case "weather": + return root.weatherCompactTarget; + case "notificationcenter": + return root.notificationCenterCompactTarget; + case "media": + return root.mediaCompactTarget; + } + return root.systemActivityActive ? root.systemCompactTarget : root.homeCompactTarget; + } + readonly property var expandedTarget: { + if (root.notificationActive) + return root.notificationExpandedTarget; + switch (root.activeActivity) { + case "launcher": + return root.launcherExpandedTarget; + case "controlcenter": + return root.controlCenterExpandedTarget; + case "wallpaper": + return root.wallpaperExpandedTarget; + case "weather": + return root.weatherExpandedTarget; + case "notificationcenter": + return root.notificationCenterExpandedTarget; + case "media": + return root.mediaExpandedTarget; + } + return root.systemActivityActive ? root.systemExpandedTarget : root.homeExpandedTarget; + } + readonly property var targetDescriptor: expanded ? expandedTarget : compactTarget + + function syncNotificationTimeout(restart) { + // Hover pauses arrival expiry; opt-in expansion remains a timed glance. + if (!notificationActive || timeoutSuspended || notificationTimeout <= 0) { + notificationTimer.stop(); + return; + } + if (restart === true || !notificationTimer.running) + notificationTimer.restart(); + } + + onNotificationTimeoutChanged: syncNotificationTimeout(true) + + function syncTransientTimeout(restart) { + if (!systemActivityActive || timeoutSuspended) { + transientTimer.stop(); + return; + } + if (restart === true || !transientTimer.running) + transientTimer.restart(); + } + + function updateMediaAvailability(available) { + if (available) { + mediaReturnTimer.stop(); + mediaAvailable = true; + return true; + } + + mediaAvailable = false; + mediaPreferred = false; + if (activeActivity === "media") + mediaReturnTimer.restart(); + return true; + } + + onInputSuspendedChanged: { + // Screenshot selection owns input while the island remains mapped for the session. + if (!inputSuspended) + return; + hoverExpanded = false; + hoverOpenTimer.stop(); + hoverCloseTimer.stop(); + keyboardDismissRequested = false; + expanded = false; + keyboardYielded = false; + launcherRequestPending = false; + launcherSessionActive = false; + controlCenterRequestPending = false; + wallpaperRequestPending = false; + weatherRequestPending = false; + notificationCenterRequestPending = false; + if (fullDestinations.indexOf(activeActivity) !== -1) + activeActivity = mediaAvailable && mediaPreferred ? "media" : "home"; + } + + function requestHoverExpand() { + if (inputSuspended || !hoverExpandEnabled) + return false; + if (notificationActive) { + hoverExpanded = true; + keyboardDismissRequested = false; + expanded = true; + hoverCloseTimer.stop(); + notificationTimer.stop(); + return true; + } + if (!requestExpand(false)) + return false; + hoverExpanded = true; + return true; + } + + function requestHoverCollapse() { + if (!hoverExpanded) + return false; + if (notificationActive) { + hoverExpanded = false; + expanded = false; + keyboardDismissRequested = false; + syncNotificationTimeout(true); + return true; + } + return requestCollapse(); + } + + function requestExpand(requestKeyboardFocus) { + if (inputSuspended) + return false; + if (systemActivityActive) { + syncTransientTimeout(true); + return false; + } + if (activityOwnsBlankClicks) + return requestActivity(activeActivity, true, requestKeyboardFocus); + if (notificationActive && !notificationExpandAllowed) + return requestNotificationCenter(false); + hoverExpanded = false; + launcherRequestPending = false; + controlCenterRequestPending = false; + wallpaperRequestPending = false; + weatherRequestPending = false; + notificationCenterRequestPending = false; + hoverCloseTimer.stop(); + transientTimer.stop(); + notificationTimer.stop(); + keyboardDismissRequested = requestKeyboardFocus === true; + expanded = true; + return true; + } + + function requestCollapse() { + hoverExpanded = false; + hoverOpenTimer.stop(); + transientTimer.stop(); + notificationTimer.stop(); + keyboardDismissRequested = false; + keyboardYielded = false; + expanded = false; + launcherRequestPending = false; + launcherSessionActive = false; + controlCenterRequestPending = false; + wallpaperRequestPending = false; + weatherRequestPending = false; + notificationCenterRequestPending = false; + if (notificationActive) { + resumeAfterNotification(false); + return true; + } + if (systemActivityActive) { + finishTransient(); + return true; + } + + if (activityOwnsBlankClicks) { + activeActivity = mediaAvailable && mediaPreferred ? "media" : "home"; + return true; + } + + return true; + } + + function resolvedReturnActivity(activityId) { + if (activityId === "media" && !mediaAvailable) + return "home"; + if (activityId === "home" || activityId === "media" || fullDestinations.indexOf(activityId) !== -1) + return activityId; + return mediaAvailable && mediaPreferred ? "media" : "home"; + } + + function requestActivity(activityId, shouldExpand, requestKeyboardFocus) { + if (activityId !== "home" && activityId !== "media" && fullDestinations.indexOf(activityId) === -1) + return false; + if (inputSuspended && shouldExpand === true) + return false; + if (activityId === "media" && !mediaAvailable) + return false; + if (activityId === "weather" && !SettingsData.weatherEnabled) + return false; + + if (activityId !== "launcher") { + launcherRequestPending = false; + launcherSessionActive = false; + } + if (activityId !== "controlcenter") + controlCenterRequestPending = false; + if (activityId === "wallpaper") + wallpaperReleaseTimer.stop(); + else + wallpaperRequestPending = false; + if (activityId === "weather") + weatherReleaseTimer.stop(); + else + weatherRequestPending = false; + if (activityId === "notificationcenter") + notificationCenterReleaseTimer.stop(); + else + notificationCenterRequestPending = false; + + if (activeActivity === activityId && expanded && shouldExpand === true) { + if (requestKeyboardFocus === true) + keyboardDismissRequested = true; + return true; + } + + if (activityId === "launcher" && shouldExpand === true && !launcherVisualsReady) { + launcherRequestPending = true; + _launcherPendingExpand = true; + _launcherPendingKeyboardFocus = requestKeyboardFocus === true; + launcherVisualsRequested = true; + return true; + } + + if (activityId === "controlcenter" && shouldExpand === true && !controlCenterVisualsReady) { + controlCenterRequestPending = true; + controlCenterVisualsRequested = true; + return true; + } + + if (activityId === "wallpaper" && shouldExpand === true && !wallpaperVisualsReady) { + wallpaperRequestPending = true; + wallpaperVisualsRequested = true; + return true; + } + + if (activityId === "weather" && shouldExpand === true && !weatherVisualsReady) { + weatherRequestPending = true; + weatherVisualsRequested = true; + return true; + } + + if (activityId === "notificationcenter" && shouldExpand === true && !notificationCenterVisualsReady) { + notificationCenterRequestPending = true; + notificationCenterVisualsRequested = true; + return true; + } + + hoverExpanded = false; + hoverOpenTimer.stop(); + hoverCloseTimer.stop(); + transientTimer.stop(); + // IPC and other destinations consume the card so its timeout cannot snap back. + root.consumeTransientNotification(); + keyboardDismissRequested = shouldExpand === true && requestKeyboardFocus === true; + expanded = shouldExpand === true; + activeActivity = activityId; + if (activityId === "media") + mediaPreferred = true; + else if (activityId === "home") + mediaPreferred = false; + launcherSessionActive = activityId === "launcher" && expanded; + if (launcherSessionActive) + launcherSessionSerial++; + if (activityId === "controlcenter" && expanded) + controlCenterSessionSerial++; + if (activityId === "wallpaper" && expanded) + wallpaperSessionSerial++; + if (activityId === "notificationcenter" && expanded) + notificationCenterSessionSerial++; + return true; + } + + function requestLauncher(query, mode, shouldToggle) { + if (shouldToggle === true && activeActivity === "launcher" && expanded) + return requestCollapse(); + launcherPendingQuery = query || ""; + launcherPendingMode = mode || ""; + return requestActivity("launcher", true, true); + } + + function requestControlCenter(section, shouldToggle) { + if (shouldToggle === true && activeActivity === "controlcenter" && expanded) + return requestCollapse(); + controlCenterPendingSection = section || ""; + return requestActivity("controlcenter", true, true); + } + + function requestWallpaper(shouldToggle) { + if (shouldToggle === true && activeActivity === "wallpaper" && expanded) + return requestCollapse(); + return requestActivity("wallpaper", true, true); + } + + function requestWeather(shouldToggle) { + if (shouldToggle === true && activeActivity === "weather" && expanded) + return requestCollapse(); + return requestActivity("weather", true, true); + } + + function markLauncherVisualsReady() { + launcherVisualsReady = true; + if (!launcherRequestPending) + return; + launcherRequestPending = false; + requestActivity("launcher", _launcherPendingExpand, _launcherPendingKeyboardFocus); + } + + function releaseLauncherVisuals() { + if (expanded && activeActivity === "launcher") { + launcherReleaseTimer.stop(); + return; + } + if (!launcherVisualsRequested && !launcherVisualsReady) + return; + launcherReleaseTimer.restart(); + } + + function _dropLauncherVisuals() { + launcherReleaseTimer.stop(); + if (expanded && activeActivity === "launcher") + return; + launcherRequestPending = false; + launcherVisualsRequested = false; + launcherVisualsReady = false; + launcherSessionActive = false; + launcherInputFocused = false; + launcherPendingQuery = ""; + launcherPendingMode = ""; + } + + function markControlCenterVisualsReady() { + controlCenterVisualsReady = true; + if (!controlCenterRequestPending) + return; + controlCenterRequestPending = false; + requestActivity("controlcenter", true, true); + } + + function releaseControlCenterVisuals() { + if (expanded && activeActivity === "controlcenter") { + controlCenterReleaseTimer.stop(); + return; + } + if (!controlCenterVisualsRequested && !controlCenterVisualsReady) + return; + controlCenterReleaseTimer.restart(); + } + + function _dropControlCenterVisuals() { + controlCenterReleaseTimer.stop(); + if (expanded && activeActivity === "controlcenter") + return; + controlCenterRequestPending = false; + controlCenterVisualsRequested = false; + controlCenterVisualsReady = false; + controlCenterPendingSection = ""; + controlCenterContentHeight = 420; + } + + function requestNotificationCenter(shouldToggle) { + if (shouldToggle === true && activeActivity === "notificationcenter" && expanded) + return requestCollapse(); + root.consumeTransientNotification(); + return requestActivity("notificationcenter", true, true); + } + + function markNotificationCenterVisualsReady() { + notificationCenterVisualsReady = true; + if (!notificationCenterRequestPending) + return; + notificationCenterRequestPending = false; + requestActivity("notificationcenter", true, true); + } + + function setNotificationCenterContentHeight(height) { + const next = Math.round(height); + if (!isFinite(next) || Math.abs(next - notificationCenterContentHeight) < 1) + return; + notificationCenterContentHeight = next; + } + + function releaseNotificationCenterVisuals() { + if (expanded && activeActivity === "notificationcenter") { + notificationCenterReleaseTimer.stop(); + return; + } + if (!notificationCenterVisualsRequested && !notificationCenterVisualsReady) + return; + notificationCenterReleaseTimer.restart(); + } + + function _dropNotificationCenterVisuals() { + notificationCenterReleaseTimer.stop(); + notificationCenterRequestPending = false; + notificationCenterVisualsRequested = false; + notificationCenterVisualsReady = false; + notificationCenterContentHeight = 320; + } + + function markWallpaperVisualsReady() { + wallpaperVisualsReady = true; + if (!wallpaperRequestPending) + return; + wallpaperRequestPending = false; + requestActivity("wallpaper", true, true); + } + + function releaseWallpaperVisuals() { + if (expanded && activeActivity === "wallpaper") { + wallpaperReleaseTimer.stop(); + return; + } + if (!wallpaperVisualsRequested && !wallpaperVisualsReady) + return; + wallpaperReleaseTimer.restart(); + } + + function _dropWallpaperVisuals() { + wallpaperReleaseTimer.stop(); + wallpaperRequestPending = false; + wallpaperVisualsRequested = false; + wallpaperVisualsReady = false; + keyboardYielded = false; + } + + function markWeatherVisualsReady() { + weatherVisualsReady = true; + if (!weatherRequestPending) + return; + weatherRequestPending = false; + requestActivity("weather", true, true); + } + + function releaseWeatherVisuals() { + if (expanded && activeActivity === "weather") { + weatherReleaseTimer.stop(); + return; + } + if (!weatherVisualsRequested && !weatherVisualsReady) + return; + weatherReleaseTimer.restart(); + } + + function _dropWeatherVisuals() { + weatherReleaseTimer.stop(); + weatherRequestPending = false; + weatherVisualsRequested = false; + weatherVisualsReady = false; + } + + function setControlCenterContentHeight(height) { + const next = Math.round(height); + if (!isFinite(next) || Math.abs(next - controlCenterContentHeight) < 1) + return; + controlCenterContentHeight = next; + } + + function cycleActivity(direction, shouldExpand) { + const activities = ["home"]; + if (mediaAvailable) + activities.push("media"); + if (launcherCycleEnabled) + activities.push("launcher"); + activities.push("controlcenter"); + activities.push("notificationcenter"); + const currentIndex = Math.max(0, activities.indexOf(activeActivity)); + const step = direction < 0 ? -1 : 1; + const nextIndex = (currentIndex + step + activities.length) % activities.length; + return requestActivity(activities[nextIndex], shouldExpand, shouldExpand); + } + + function finishTransient() { + if (!systemActivityActive) + return; + + transientTimer.stop(); + const returnActivity = resolvedReturnActivity(transientReturnActivity); + transientReturnActivity = ""; + activeActivity = returnActivity; + } + + function requestSystemActivity(activityId) { + if (activityId !== "volume" && activityId !== "brightness") + return false; + + if (notificationActive) + return false; + + launcherRequestPending = false; + launcherSessionActive = false; + controlCenterRequestPending = false; + wallpaperRequestPending = false; + weatherRequestPending = false; + notificationCenterRequestPending = false; + + if (expanded) { + if (activeActivity === activityId) + transientTimer.stop(); + return activeActivity === activityId; + } + + if (!systemActivityActive) + transientReturnActivity = activeActivity; + activeActivity = activityId; + syncTransientTimeout(true); + return true; + } + + function canRequestNotification(critical) { + return notificationActive || !expanded || critical === true; + } + + function consumeTransientNotification() { + if (!notificationActive) + return; + notificationTimer.stop(); + suspendedActivity = ""; + suspendedExpanded = false; + suspendedKeyboardDismissRequested = false; + } + + function requestNotification(critical) { + if (!canRequestNotification(critical)) + return false; + + if (notificationActive) { + return refreshNotification(); + } + + launcherRequestPending = false; + controlCenterRequestPending = false; + wallpaperRequestPending = false; + weatherRequestPending = false; + notificationCenterRequestPending = false; + + transientTimer.stop(); + suspendedActivity = activeActivity; + suspendedExpanded = expanded; + suspendedKeyboardDismissRequested = keyboardDismissRequested; + launcherSessionActive = false; + keyboardDismissRequested = false; + expanded = notificationExpandAllowed; + activeActivity = "notification"; + syncNotificationTimeout(true); + return true; + } + + function refreshNotification() { + if (!notificationActive) + return false; + syncNotificationTimeout(true); + return true; + } + + function resumeAfterNotification(restoreExpanded) { + if (!notificationActive) + return; + + notificationTimer.stop(); + const nextActivity = suspendedActivity === "media" && !mediaAvailable ? "home" : suspendedActivity || (mediaAvailable && mediaPreferred ? "media" : "home"); + const nextExpanded = restoreExpanded === true && suspendedExpanded; + const nextKeyboardDismissRequested = nextExpanded && suspendedKeyboardDismissRequested; + suspendedActivity = ""; + suspendedExpanded = false; + suspendedKeyboardDismissRequested = false; + expanded = nextExpanded; + keyboardDismissRequested = nextKeyboardDismissRequested; + activeActivity = nextActivity; + launcherSessionActive = nextActivity === "launcher" && nextExpanded; + if (launcherSessionActive) + launcherSessionSerial++; + if (nextActivity === "controlcenter" && nextExpanded) + controlCenterSessionSerial++; + if (nextActivity === "wallpaper" && nextExpanded) + wallpaperSessionSerial++; + if (nextActivity === "notificationcenter" && nextExpanded) + notificationCenterSessionSerial++; + if (systemActivityActive && !expanded) + transientTimer.restart(); + } + + function completeNotification() { + if (!notificationActive) + return false; + resumeAfterNotification(true); + return true; + } + + function requestToggle(requestKeyboardFocus) { + if (notificationActive && hoverExpanded && expanded) + return requestNotificationCenter(false); + if (expanded) + return requestCollapse(); + return requestExpand(requestKeyboardFocus); + } + + function slotHoverEntered() { + slotHoverCount++; + } + + function slotHoverExited() { + slotHoverCount = Math.max(0, slotHoverCount - 1); + } + + onSlotHoveredChanged: { + if (slotHovered) { + hoverOpenTimer.stop(); + return; + } + if (pointerInside && hoverExpandEnabled && !expanded) + hoverOpenTimer.restart(); + } + + onMediaDropdownOpenChanged: { + if (mediaDropdownOpen) { + hoverOpenTimer.stop(); + hoverCloseTimer.stop(); + return; + } + if (!pointerInside && hoverExpanded && expanded && !activityOwnsBlankClicks) + hoverCloseTimer.restart(); + } + + function updatePointerInside(inside) { + pointerInside = inside === true; + if (pointerInside) { + hoverCloseTimer.stop(); + if (hoverExpandEnabled && !expanded && !slotHovered) + hoverOpenTimer.restart(); + } else { + hoverOpenTimer.stop(); + if (hoverExpanded && expanded && !activityOwnsBlankClicks && !mediaDropdownOpen) + hoverCloseTimer.restart(); + } + syncNotificationTimeout(!pointerInside); + syncTransientTimeout(!pointerInside); + return true; + } + + property Timer hoverOpenTimer: Timer { + interval: root.hoverOpenDelay + onTriggered: { + if (!root.pointerInside || !root.hoverExpandEnabled || root.inputSuspended || root.slotHovered) + return; + root.requestHoverExpand(); + } + } + + property Timer hoverCloseTimer: Timer { + interval: root.hoverCloseDelay + onTriggered: { + if (!root.pointerInside && !root.mediaDropdownOpen && root.hoverExpanded) + root.requestHoverCollapse(); + } + } + + property Timer autoDemoTimer: Timer { + interval: 1600 + repeat: true + running: root.autoDemo + onTriggered: root.requestToggle(false) + } + + property Timer mediaReturnTimer: Timer { + interval: root.mediaReturnDelay + onTriggered: { + if (root.mediaAvailable || root.activeActivity !== "media") + return; + root.keyboardDismissRequested = false; + root.expanded = false; + root.activeActivity = "home"; + } + } + + property Timer transientTimer: Timer { + interval: root.transientTimeout + onTriggered: { + if (!root.transientActive || root.expanded) + return; + root.finishTransient(); + } + } + + property Timer notificationTimer: Timer { + interval: root.notificationTimeout + onTriggered: root.completeNotification() + } + + property Timer launcherReleaseTimer: Timer { + interval: 260 + onTriggered: root._dropLauncherVisuals() + } + + property Timer controlCenterReleaseTimer: Timer { + interval: 260 + onTriggered: root._dropControlCenterVisuals() + } + + property Timer wallpaperReleaseTimer: Timer { + interval: 260 + onTriggered: root._dropWallpaperVisuals() + } + + property Timer weatherReleaseTimer: Timer { + interval: 260 + onTriggered: root._dropWeatherVisuals() + } + + property Timer notificationCenterReleaseTimer: Timer { + interval: 260 + onTriggered: root._dropNotificationCenterVisuals() + } +} diff --git a/quickshell/Modules/DankIsland/IslandSatelliteHost.qml b/quickshell/Modules/DankIsland/IslandSatelliteHost.qml new file mode 100644 index 000000000..219600a8e --- /dev/null +++ b/quickshell/Modules/DankIsland/IslandSatelliteHost.qml @@ -0,0 +1,215 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Modules.DankBar +import qs.Services + +Item { + id: root + + required property var hostWindow + required property var targetScreen + required property IslandController controller + required property var islandSurface + property real outerGap: 8 + + readonly property alias leftInputItem: leftInputEnvelope + readonly property alias rightInputItem: rightInputEnvelope + readonly property var referenceBarConfig: { + SettingsData.barConfigs; + return SettingsData.islandBarConfig ?? ({}); + } + readonly property var satelliteConfig: { + const base = SettingsData.effectiveBarConfigForRender(root.referenceBarConfig, false) ?? root.referenceBarConfig ?? ({}); + if (!root.hostWindow?.usesOverlayLayer) + return base; + const next = Object.assign({}, base); + next.useOverlayLayer = true; + return next; + } + readonly property bool scrollEnabled: satelliteConfig?.scrollEnabled ?? true + readonly property string scrollXBehavior: satelliteConfig?.scrollXBehavior ?? "column" + readonly property string scrollYBehavior: satelliteConfig?.scrollYBehavior ?? "workspace" + readonly property real screenScale: CompositorService.getScreenScale(root.targetScreen) + readonly property real innerPadding: root.satelliteConfig?.innerPadding ?? 4 + readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + root.innerPadding * 0.6), root.screenScale) + readonly property real barThickness: Theme.snap(Math.max(root.widgetThickness + root.innerPadding + 4, Theme.barHeight - 4 - (8 - root.innerPadding)), root.screenScale) + readonly property real satelliteSpacing: root.satelliteConfig?.spacing ?? 4 + readonly property bool edgeAligned: SettingsData.dankIslandSatellitePosition === "edges" + readonly property real edgeBaseMargin: Math.max(Theme.spacingXS, root.innerPadding * 0.8) + readonly property real edgeInsetRaw: SettingsData.barInsetPaddingSyncAll ? SettingsData.barInsetPaddingShared : (root.satelliteConfig?.barInsetPadding ?? -1) + readonly property real edgeInset: Theme.snap(Math.max(0, root.edgeInsetRaw < 0 ? root.edgeBaseMargin : root.edgeInsetRaw), root.screenScale) + + readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom" + readonly property real rowY: root.bottomEdge ? root.height - root.outerGap - root.barThickness : root.outerGap + readonly property bool tracksIsland: !root.edgeAligned && root.visible + readonly property bool motionRunning: root.tracksIsland && (root.islandSurface?.motionRunning ?? false) + readonly property real islandStartX: root.islandSurface?.motionStartBounds.x ?? 0 + readonly property real islandStartRight: root.islandStartX + (root.islandSurface?.motionStartBounds.width ?? 0) + readonly property real islandTargetX: root.islandSurface?.targetVisualX ?? 0 + readonly property real islandTargetRight: root.islandTargetX + (root.islandSurface?.targetVisualWidth ?? 0) + + readonly property real leftEdgeSpread: root.motionRunning ? Math.abs(root.islandStartX - root.islandTargetX) : 0 + readonly property real rightEdgeSpread: root.motionRunning ? Math.abs(root.islandStartRight - root.islandTargetRight) : 0 + + function normalizedWidgets(widgets) { + return (widgets || []).map((widget, index) => { + if (typeof widget === "string") { + return { + "widgetId": widget, + "id": widget + "_" + index, + "enabled": true + }; + } + const normalized = Object.assign({}, widget); + normalized.widgetId = widget.widgetId || widget.id; + normalized.id = normalized.widgetId + "_" + index; + normalized.enabled = widget.enabled !== false; + return normalized; + }); + } + + visible: SettingsData.dankIslandSatellitesEnabled + + function switchWorkspace(direction) { + componentProvider.switchWorkspace(direction); + } + + AxisContext { + id: satelliteAxis + + edge: SettingsData.dankIslandEdge + } + + ScriptModel { + id: emptyWidgetsModel + + values: [] + } + + ScriptModel { + id: leftWidgetsModel + + values: root.normalizedWidgets(root.referenceBarConfig?.leftWidgets) + } + + ScriptModel { + id: rightWidgetsModel + + values: root.normalizedWidgets(root.referenceBarConfig?.rightWidgets) + } + + QtObject { + id: satelliteBarWindow + + property var axis: satelliteAxis + property var screen: root.targetScreen + property string screenName: root.targetScreen?.name ?? "" + property real widgetThickness: root.widgetThickness + property real effectiveBarThickness: root.barThickness + property bool isVertical: false + property bool usesFrameBarChrome: false + property bool hasAdjacentTopBar: false + property bool hasAdjacentBottomBar: false + property bool hasAdjacentLeftBar: false + property bool hasAdjacentRightBar: false + property var hyprlandOverviewLoader: null + property var controlCenterButtonRef: null + property var clockButtonRef: null + property var systemUpdateButtonRef: null + property int notificationCount: 0 + + signal colorPickerRequested + + function registerBlurWidget(item) { + } + function unregisterBlurWidget(item) { + } + } + + DankBarContent { + id: componentProvider + + width: 0 + height: 0 + visible: false + barWindow: satelliteBarWindow + rootWindow: root.hostWindow + barConfig: root.satelliteConfig + leftWidgetsModel: emptyWidgetsModel + centerWidgetsModel: emptyWidgetsModel + rightWidgetsModel: emptyWidgetsModel + } + + Item { + id: leftInputEnvelope + + x: root.motionRunning ? Math.min(root.islandStartX, root.islandTargetX) - SettingsData.dankIslandSatelliteGap - leftInput.width : leftInput.x + y: leftInput.y + width: leftInput.width + root.leftEdgeSpread + height: leftInput.height + } + + Item { + id: rightInputEnvelope + + x: root.motionRunning ? Math.min(root.islandStartRight, root.islandTargetRight) + SettingsData.dankIslandSatelliteGap : rightInput.x + y: rightInput.y + width: rightInput.width + root.rightEdgeSpread + height: rightInput.height + } + + Item { + id: leftInput + + x: root.edgeAligned ? root.edgeInset : root.islandSurface.currentVisualX - SettingsData.dankIslandSatelliteGap - width + y: root.rowY + width: root.visible ? leftWidgetSection.implicitWidth : 0 + height: root.visible ? root.barThickness : 0 + + LeftSection { + id: leftWidgetSection + + anchors.fill: parent + objectName: "leftSection" + axis: satelliteAxis + widgetsModel: leftWidgetsModel + components: componentProvider.allComponents + noBackground: root.satelliteConfig?.noBackground ?? false + parentScreen: root.targetScreen + widgetThickness: root.widgetThickness + barThickness: root.barThickness + barSpacing: root.satelliteSpacing + barConfig: root.satelliteConfig + blurBarWindow: satelliteBarWindow + } + } + + Item { + id: rightInput + + x: root.edgeAligned ? root.width - root.edgeInset - width : root.islandSurface.currentVisualX + root.islandSurface.currentVisualWidth + SettingsData.dankIslandSatelliteGap + y: root.rowY + width: root.visible ? rightWidgetSection.implicitWidth : 0 + height: root.visible ? root.barThickness : 0 + + RightSection { + id: rightWidgetSection + + anchors.fill: parent + objectName: "rightSection" + axis: satelliteAxis + widgetsModel: rightWidgetsModel + components: componentProvider.allComponents + noBackground: root.satelliteConfig?.noBackground ?? false + parentScreen: root.targetScreen + widgetThickness: root.widgetThickness + barThickness: root.barThickness + barSpacing: root.satelliteSpacing + barConfig: root.satelliteConfig + blurBarWindow: satelliteBarWindow + } + } +} diff --git a/quickshell/Modules/DankIsland/NativeSpringMotion.qml b/quickshell/Modules/DankIsland/NativeSpringMotion.qml new file mode 100644 index 000000000..cabba82c3 --- /dev/null +++ b/quickshell/Modules/DankIsland/NativeSpringMotion.qml @@ -0,0 +1,176 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +QtObject { + id: root + + property bool enabled: true + property bool animationsEnabled: true + + property real spring: 5 + property real damping: 0.32 + property real mass: 1 + property real epsilon: 0.04 + + property real currentWidth: 176 + property real currentHeight: 42 + property real currentOffsetX: 0 + property real currentOffsetY: 8 + property real currentTopLeftRadius: 21 + property real currentTopRightRadius: 21 + property real currentBottomLeftRadius: 21 + property real currentBottomRightRadius: 21 + + property real targetWidth: currentWidth + property real targetHeight: currentHeight + property real targetOffsetX: currentOffsetX + property real targetOffsetY: currentOffsetY + property real targetTopLeftRadius: currentTopLeftRadius + property real targetTopRightRadius: currentTopRightRadius + property real targetBottomLeftRadius: currentBottomLeftRadius + property real targetBottomRightRadius: currentBottomRightRadius + + readonly property bool running: widthSpring.running || heightSpring.running || offsetXSpring.running || offsetYSpring.running || topLeftRadiusSpring.running || topRightRadiusSpring.running + || bottomLeftRadiusSpring.running || bottomRightRadiusSpring.running + + function setTarget(target) { + targetWidth = target.width; + targetHeight = target.height; + targetOffsetX = target.offsetX; + targetOffsetY = target.offsetY; + targetTopLeftRadius = target.topLeftRadius; + targetTopRightRadius = target.topRightRadius; + targetBottomLeftRadius = target.bottomLeftRadius; + targetBottomRightRadius = target.bottomRightRadius; + + currentWidth = targetWidth; + currentHeight = targetHeight; + currentOffsetX = targetOffsetX; + currentOffsetY = targetOffsetY; + currentTopLeftRadius = targetTopLeftRadius; + currentTopRightRadius = targetTopRightRadius; + currentBottomLeftRadius = targetBottomLeftRadius; + currentBottomRightRadius = targetBottomRightRadius; + } + + function snapTo(target) { + animationsEnabled = false; + + targetWidth = target.width; + targetHeight = target.height; + targetOffsetX = target.offsetX; + targetOffsetY = target.offsetY; + targetTopLeftRadius = target.topLeftRadius; + targetTopRightRadius = target.topRightRadius; + targetBottomLeftRadius = target.bottomLeftRadius; + targetBottomRightRadius = target.bottomRightRadius; + + currentWidth = target.width; + currentHeight = target.height; + currentOffsetX = target.offsetX; + currentOffsetY = target.offsetY; + currentTopLeftRadius = target.topLeftRadius; + currentTopRightRadius = target.topRightRadius; + currentBottomLeftRadius = target.bottomLeftRadius; + currentBottomRightRadius = target.bottomRightRadius; + + animationsEnabled = true; + } + + Behavior on currentWidth { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: widthSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentHeight { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: heightSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentOffsetX { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: offsetXSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentOffsetY { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: offsetYSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentTopLeftRadius { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: topLeftRadiusSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentTopRightRadius { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: topRightRadiusSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentBottomLeftRadius { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: bottomLeftRadiusSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } + + Behavior on currentBottomRightRadius { + enabled: root.enabled && root.animationsEnabled + + SpringAnimation { + id: bottomRightRadiusSpring + spring: root.spring + damping: root.damping + mass: root.mass + epsilon: root.epsilon + } + } +} diff --git a/quickshell/Modules/DankIsland/VectorSpringMotion.qml b/quickshell/Modules/DankIsland/VectorSpringMotion.qml new file mode 100644 index 000000000..86b85dade --- /dev/null +++ b/quickshell/Modules/DankIsland/VectorSpringMotion.qml @@ -0,0 +1,173 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +QtObject { + id: root + + property bool enabled: true + property bool running: false + property bool reducedMotion: false + + property real stiffness: 560 + property real damping: 37 + property real mass: 1 + property real positionEpsilon: 0.035 + property real velocityEpsilon: 0.035 + property real maximumFrameTime: 1 / 30 + property real integrationStep: 1 / 240 + + property real currentWidth: 176 + property real currentHeight: 42 + property real currentOffsetX: 0 + property real currentOffsetY: 8 + property real currentTopLeftRadius: 21 + property real currentTopRightRadius: 21 + property real currentBottomLeftRadius: 21 + property real currentBottomRightRadius: 21 + + property real targetWidth: currentWidth + property real targetHeight: currentHeight + property real targetOffsetX: currentOffsetX + property real targetOffsetY: currentOffsetY + property real targetTopLeftRadius: currentTopLeftRadius + property real targetTopRightRadius: currentTopRightRadius + property real targetBottomLeftRadius: currentBottomLeftRadius + property real targetBottomRightRadius: currentBottomRightRadius + + property real velocityWidth: 0 + property real velocityHeight: 0 + property real velocityOffsetX: 0 + property real velocityOffsetY: 0 + property real velocityTopLeftRadius: 0 + property real velocityTopRightRadius: 0 + property real velocityBottomLeftRadius: 0 + property real velocityBottomRightRadius: 0 + + function setTarget(target) { + targetWidth = target.width; + targetHeight = target.height; + targetOffsetX = target.offsetX; + targetOffsetY = target.offsetY; + targetTopLeftRadius = target.topLeftRadius; + targetTopRightRadius = target.topRightRadius; + targetBottomLeftRadius = target.bottomLeftRadius; + targetBottomRightRadius = target.bottomRightRadius; + + if (reducedMotion) { + settle(); + return; + } + + if (!isSettled()) + running = true; + } + + function snapTo(target) { + targetWidth = target.width; + targetHeight = target.height; + targetOffsetX = target.offsetX; + targetOffsetY = target.offsetY; + targetTopLeftRadius = target.topLeftRadius; + targetTopRightRadius = target.topRightRadius; + targetBottomLeftRadius = target.bottomLeftRadius; + targetBottomRightRadius = target.bottomRightRadius; + + currentWidth = target.width; + currentHeight = target.height; + currentOffsetX = target.offsetX; + currentOffsetY = target.offsetY; + currentTopLeftRadius = target.topLeftRadius; + currentTopRightRadius = target.topRightRadius; + currentBottomLeftRadius = target.bottomLeftRadius; + currentBottomRightRadius = target.bottomRightRadius; + + velocityWidth = 0; + velocityHeight = 0; + velocityOffsetX = 0; + velocityOffsetY = 0; + velocityTopLeftRadius = 0; + velocityTopRightRadius = 0; + velocityBottomLeftRadius = 0; + velocityBottomRightRadius = 0; + running = false; + } + + function isSettled() { + const positionSettled = Math.abs(targetWidth - currentWidth) <= positionEpsilon && Math.abs(targetHeight - currentHeight) <= positionEpsilon + && Math.abs(targetOffsetX - currentOffsetX) <= positionEpsilon && Math.abs(targetOffsetY - currentOffsetY) <= positionEpsilon + && Math.abs(targetTopLeftRadius - currentTopLeftRadius) <= positionEpsilon && Math.abs(targetTopRightRadius - currentTopRightRadius) <= positionEpsilon + && Math.abs(targetBottomLeftRadius - currentBottomLeftRadius) <= positionEpsilon && Math.abs(targetBottomRightRadius - currentBottomRightRadius) <= positionEpsilon; + const velocitySettled = Math.abs(velocityWidth) <= velocityEpsilon && Math.abs(velocityHeight) <= velocityEpsilon && Math.abs(velocityOffsetX) <= velocityEpsilon + && Math.abs(velocityOffsetY) <= velocityEpsilon && Math.abs(velocityTopLeftRadius) <= velocityEpsilon && Math.abs(velocityTopRightRadius) <= velocityEpsilon + && Math.abs(velocityBottomLeftRadius) <= velocityEpsilon && Math.abs(velocityBottomRightRadius) <= velocityEpsilon; + return positionSettled && velocitySettled; + } + + function settle() { + currentWidth = targetWidth; + currentHeight = targetHeight; + currentOffsetX = targetOffsetX; + currentOffsetY = targetOffsetY; + currentTopLeftRadius = targetTopLeftRadius; + currentTopRightRadius = targetTopRightRadius; + currentBottomLeftRadius = targetBottomLeftRadius; + currentBottomRightRadius = targetBottomRightRadius; + + velocityWidth = 0; + velocityHeight = 0; + velocityOffsetX = 0; + velocityOffsetY = 0; + velocityTopLeftRadius = 0; + velocityTopRightRadius = 0; + velocityBottomLeftRadius = 0; + velocityBottomRightRadius = 0; + running = false; + } + + function advance(rawFrameTime) { + if (!enabled || !running || reducedMotion) + return; + + const frameTime = Math.min(Math.max(rawFrameTime, 0), maximumFrameTime); + if (frameTime <= 0) + return; + + const steps = Math.max(1, Math.ceil(frameTime / integrationStep)); + const step = frameTime / steps; + const inverseMass = 1 / Math.max(0.001, mass); + + for (let i = 0; i < steps; i++) { + velocityWidth += (stiffness * (targetWidth - currentWidth) - damping * velocityWidth) * inverseMass * step; + velocityHeight += (stiffness * (targetHeight - currentHeight) - damping * velocityHeight) * inverseMass * step; + velocityOffsetX += (stiffness * (targetOffsetX - currentOffsetX) - damping * velocityOffsetX) * inverseMass * step; + velocityOffsetY += (stiffness * (targetOffsetY - currentOffsetY) - damping * velocityOffsetY) * inverseMass * step; + velocityTopLeftRadius += (stiffness * (targetTopLeftRadius - currentTopLeftRadius) - damping * velocityTopLeftRadius) * inverseMass * step; + velocityTopRightRadius += (stiffness * (targetTopRightRadius - currentTopRightRadius) - damping * velocityTopRightRadius) * inverseMass * step; + velocityBottomLeftRadius += (stiffness * (targetBottomLeftRadius - currentBottomLeftRadius) - damping * velocityBottomLeftRadius) * inverseMass * step; + velocityBottomRightRadius += (stiffness * (targetBottomRightRadius - currentBottomRightRadius) - damping * velocityBottomRightRadius) * inverseMass * step; + + currentWidth += velocityWidth * step; + currentHeight += velocityHeight * step; + currentOffsetX += velocityOffsetX * step; + currentOffsetY += velocityOffsetY * step; + currentTopLeftRadius += velocityTopLeftRadius * step; + currentTopRightRadius += velocityTopRightRadius * step; + currentBottomLeftRadius += velocityBottomLeftRadius * step; + currentBottomRightRadius += velocityBottomRightRadius * step; + } + + if (isSettled()) + settle(); + } + + onReducedMotionChanged: { + if (reducedMotion) + settle(); + } + + property FrameAnimation frameAnimation: FrameAnimation { + running: root.enabled && root.running && !root.reducedMotion + onTriggered: root.advance(frameTime) + } +} diff --git a/quickshell/Modules/Dock/DockBody.qml b/quickshell/Modules/Dock/DockBody.qml index 859c1c1b9..9078ff92e 100644 --- a/quickshell/Modules/Dock/DockBody.qml +++ b/quickshell/Modules/Dock/DockBody.qml @@ -53,7 +53,7 @@ Item { } readonly property real barSpacing: { - const defaultBar = SettingsData.barConfigs[0] || SettingsData.getBarConfig("default"); + const defaultBar = SettingsData.getPrimaryBarConfig(); if (!defaultBar) return 0; @@ -84,6 +84,8 @@ Item { const topBar = SettingsData.barConfigs.find(bc => { if (!bc.enabled || bc.autoHide || !(bc.visible ?? true)) return false; + if (SettingsData.isIslandBarConfig(bc)) + return false; if (bc.position !== SettingsData.Position.Top && bc.position !== 0) return false; const onThisScreen = bc.screenPreferences.length === 0 || bc.screenPreferences.includes("all") || bc.screenPreferences.includes(screenName); @@ -99,6 +101,8 @@ Item { const leftBar = SettingsData.barConfigs.find(bc => { if (!bc.enabled || bc.autoHide || !(bc.visible ?? true)) return false; + if (SettingsData.isIslandBarConfig(bc)) + return false; if (bc.position !== SettingsData.Position.Left && bc.position !== 2) return false; const onThisScreen = bc.screenPreferences.length === 0 || bc.screenPreferences.includes("all") || bc.screenPreferences.includes(screenName); diff --git a/quickshell/Modules/Frame/FrameBarHost.qml b/quickshell/Modules/Frame/FrameBarHost.qml index c621251aa..2d3d0b2ce 100644 --- a/quickshell/Modules/Frame/FrameBarHost.qml +++ b/quickshell/Modules/Frame/FrameBarHost.qml @@ -27,6 +27,10 @@ Item { continue; if (!SettingsData.barConfigCoversScreen(bc, host.targetScreen)) continue; + if (SettingsData.isIslandBarConfig(bc)) + continue; + if (SettingsData.dankIslandOwnsEdge(host.targetScreen, SettingsData.positionToSide(bc.position ?? SettingsData.Position.Top))) + continue; let edge = "top"; switch (bc.position ?? 0) { case SettingsData.Position.Bottom: diff --git a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml index 109dad55f..e28d501f9 100644 --- a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml +++ b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml @@ -13,25 +13,107 @@ DankListView { property alias listContentHeight: listView.contentHeight property real stableContentHeight: 0 property bool cardAnimateExpansion: true + property bool trackStableContentHeight: true + property bool trackSessionContentHeight: false + property bool lightweightCards: false property bool listInitialized: false property int swipingCardIndex: -1 property real swipingCardOffset: 0 property bool _stableHeightUpdatePending: false + property bool _sessionHeightUpdatePending: false + property real sessionContentHeight: 0 property var transientSurfaceTracker: null readonly property real shadowBlurPx: Theme.elevationEnabled ? ((Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined) ? Theme.elevationLevel1.blurPx : 4) : 0 readonly property real shadowHorizontalGutter: Theme.snap(Math.max(Theme.spacingS, Math.min(32, shadowBlurPx * 1.5 + 6)), 1) readonly property real shadowVerticalGutter: Theme.snap(Math.max(Theme.spacingXS, 6), 1) readonly property real delegateShadowGutter: Theme.snap(Math.max(Theme.spacingXS, 4), 1) + readonly property real estimatedCollapsedCardHeight: { + const compact = SettingsData.notificationCompactMode; + const padding = compact ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding; + const icon = compact ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal; + const body = Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compact ? 1 : 2); + const actionHeight = compact ? 20 : 24; + const contentSpacing = compact ? Theme.spacingXS : Theme.spacingS; + return padding * 2 + Math.max(icon, body) + actionHeight + contentSpacing + delegateShadowGutter; + } Component.onCompleted: { Qt.callLater(() => { if (listView) { listView.listInitialized = true; - listView.syncStableContentHeight(false); + if (listView.trackStableContentHeight) + listView.syncStableContentHeight(false); + listView.syncSessionContentHeight(); } }); } + function estimateContentHeight(groupCount) { + if (groupCount <= 0) + return 0; + return topMargin + bottomMargin + groupCount * estimatedCollapsedCardHeight + Math.max(0, groupCount - 1) * spacing; + } + + function estimateExpandedCardHeight(group) { + const compact = SettingsData.notificationCompactMode; + const padding = compact ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding; + const header = compact ? 32 : 40; + const count = Math.min(10, Math.max(1, group?.count || group?.notifications?.length || 1)); + const icon = compact ? Theme.notificationExpandedIconSizeCompact : Theme.notificationExpandedIconSizeNormal; + const itemPadding = compact ? Theme.spacingS : Theme.spacingM; + const contentSpacing = compact ? Theme.spacingXS : Theme.spacingS; + const actionHeight = compact ? 20 : 24; + const textBlock = Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * 2; + const row = itemPadding * 2 + Math.max(icon, textBlock) + actionHeight + contentSpacing * 2; + const innerSpacing = compact ? Theme.spacingS : Theme.spacingL; + const headerSpacing = compact ? Theme.spacingXS : Theme.spacingS; + return padding * 2 + header + headerSpacing + count * row + Math.max(0, count - 1) * innerSpacing + delegateShadowGutter; + } + + function estimatedHeightForGroup(group) { + if (!group) + return estimatedCollapsedCardHeight; + if (NotificationService.expandedGroups[group.key]) + return estimateExpandedCardHeight(group); + return estimatedCollapsedCardHeight; + } + + function computeSessionContentHeight() { + const groups = NotificationService.groupedNotifications; + const count = groups ? groups.length : 0; + if (count <= 0) + return 0; + + let total = topMargin + bottomMargin + Math.max(0, count - 1) * spacing; + for (let i = 0; i < count; i++) { + const item = itemAtIndex(i); + if (item && item.nonAnimHeight !== undefined && item.nonAnimHeight > 0) + total += item.nonAnimHeight; + else + total += estimatedHeightForGroup(groups[i]); + } + return Math.max(0, total); + } + + function syncSessionContentHeight() { + if (!trackSessionContentHeight) + return; + const next = computeSessionContentHeight(); + if (Math.abs(next - sessionContentHeight) <= 0.5) + return; + sessionContentHeight = next; + } + + function queueSessionContentHeightUpdate() { + if (!trackSessionContentHeight || _sessionHeightUpdatePending) + return; + _sessionHeightUpdatePending = true; + Qt.callLater(() => { + _sessionHeightUpdatePending = false; + syncSessionContentHeight(); + }); + } + function targetContentHeight() { if (count <= 0) return contentHeight; @@ -47,6 +129,8 @@ DankListView { } function syncStableContentHeight(useTarget) { + if (!trackStableContentHeight) + return; const nextHeight = useTarget ? targetContentHeight() : contentHeight; if (Math.abs(nextHeight - stableContentHeight) <= 0.5) return; @@ -54,7 +138,7 @@ DankListView { } function queueStableContentHeightUpdate(useTarget) { - if (_stableHeightUpdatePending) + if (!trackStableContentHeight || _stableHeightUpdatePending) return; _stableHeightUpdatePending = true; Qt.callLater(() => { @@ -64,11 +148,13 @@ DankListView { } onContentHeightChanged: { - if (!isAnimatingExpansion) + if (trackStableContentHeight && !isAnimatingExpansion) queueStableContentHeightUpdate(false); } onIsAnimatingExpansionChanged: { + if (!trackStableContentHeight) + return; if (isAnimatingExpansion) { syncStableContentHeight(true); } else { @@ -121,10 +207,12 @@ DankListView { anchors.horizontalCenter: parent.horizontalCenter } + onCountChanged: listView.queueSessionContentHeightUpdate() + onModelChanged: { - if (!keyboardController || !keyboardController.keyboardNavigationActive) { + listView.queueSessionContentHeightUpdate(); + if (!keyboardController || !keyboardController.keyboardNavigationActive) return; - } keyboardController.rebuildFlatNavigation(); Qt.callLater(() => { if (keyboardController && keyboardController.keyboardNavigationActive && !autoScrollDisabled) { @@ -155,7 +243,9 @@ DankListView { Qt.callLater(() => { if (delegateRoot) { delegateRoot.__delegateInitialized = true; - listView.queueStableContentHeightUpdate(listView.isAnimatingExpansion); + if (listView.trackStableContentHeight) + listView.queueStableContentHeightUpdate(listView.isAnimatingExpansion); + listView.queueSessionContentHeightUpdate(); } }); } @@ -174,6 +264,7 @@ DankListView { notificationGroup: modelData keyboardNavigationActive: listView.keyboardActive animateExpansion: listView.cardAnimateExpansion && listView.listInitialized + lightweight: listView.lightweightCards transientSurfaceTracker: listView.transientSurfaceTracker opacity: { const swipeAmount = Math.abs(delegateRoot.swipeOffset); @@ -183,6 +274,8 @@ DankListView { return Math.max(0, 1 - fadeProgress); } onIsAnimatingChanged: { + if (!listView.trackStableContentHeight) + return; if (isAnimating) { listView.isAnimatingExpansion = true; listView.syncStableContentHeight(true); @@ -204,6 +297,9 @@ DankListView { } onTargetHeightChanged: { + listView.queueSessionContentHeightUpdate(); + if (!listView.trackStableContentHeight) + return; if (isAnimating || listView.isAnimatingExpansion) listView.syncStableContentHeight(true); else @@ -289,6 +385,7 @@ DankListView { target: NotificationService function onGroupedNotificationsChanged() { + listView.queueSessionContentHeightUpdate(); if (!keyboardController) { return; } @@ -310,12 +407,14 @@ DankListView { } function onExpandedGroupsChanged() { + listView.queueSessionContentHeightUpdate(); if (!keyboardController || !keyboardController.keyboardNavigationActive) return; expansionEnsureVisibleTimer.restart(); } function onExpandedMessagesChanged() { + listView.queueSessionContentHeightUpdate(); if (!keyboardController || !keyboardController.keyboardNavigationActive) return; expansionEnsureVisibleTimer.restart(); diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index 70f8bca5f..45b709eee 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -15,6 +15,7 @@ Rectangle { property bool userInitiatedExpansion: false property bool isAnimating: false property bool animateExpansion: true + property bool lightweight: false property bool isDescriptionToggleAnimation: false property bool _retainedExpandedContent: false property bool _clipAnimatedContent: false @@ -47,8 +48,8 @@ Rectangle { height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) readonly property real targetHeight: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) radius: connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius - scale: (cardHoverHandler.hovered ? 1.004 : 1.0) * listLevelAdjacentScaleInfluence - readonly property bool shadowsAllowed: Theme.elevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + scale: lightweight ? 1 : (cardHoverHandler.hovered ? 1.004 : 1.0) * listLevelAdjacentScaleInfluence + readonly property bool shadowsAllowed: !lightweight && Theme.elevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" readonly property var shadowElevation: Theme.elevationLevel1 readonly property real baseShadowBlurPx: (shadowElevation && shadowElevation.blurPx !== undefined) ? shadowElevation.blurPx : 4 readonly property real hoverShadowBlurBoost: cardHoverHandler.hovered ? Math.min(2, baseShadowBlurPx * 0.25) : 0 @@ -89,7 +90,7 @@ Rectangle { } Behavior on scale { - enabled: listLevelScaleAnimationsEnabled + enabled: !root.lightweight && listLevelScaleAnimationsEnabled NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -97,7 +98,7 @@ Rectangle { } Behavior on shadowBlurPx { - enabled: !root.connectedFrameMode + enabled: !root.lightweight && !root.connectedFrameMode NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -105,7 +106,7 @@ Rectangle { } Behavior on shadowOffsetXPx { - enabled: !root.connectedFrameMode + enabled: !root.lightweight && !root.connectedFrameMode NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -113,7 +114,7 @@ Rectangle { } Behavior on shadowOffsetYPx { - enabled: !root.connectedFrameMode + enabled: !root.lightweight && !root.connectedFrameMode NumberAnimation { duration: Theme.shortDuration easing.type: Theme.standardEasing @@ -224,6 +225,7 @@ Rectangle { shadowOffsetY: root.shadowOffsetYPx shadowColor: root.shadowElevation ? Theme.elevationShadowColor(root.shadowElevation) : Theme.withAlpha(Theme.elevationShadowColor(root.shadowElevation), 0) shadowEnabled: root.shadowsAllowed && !root.connectedFrameMode + visible: !root.lightweight } Rectangle { @@ -268,7 +270,7 @@ Rectangle { DankCircularImage { id: iconContainer - cacheImages: false + cacheImages: root.lightweight readonly property string rawImage: notificationGroup?.latestNotification?.image || "" readonly property string iconFromImage: { if (rawImage.startsWith("image://icon/")) @@ -611,7 +613,7 @@ Rectangle { DankCircularImage { id: messageIcon - cacheImages: false + cacheImages: root.lightweight readonly property string rawImage: modelData?.image || "" readonly property string iconFromImage: { diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml b/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml new file mode 100644 index 000000000..db7476865 --- /dev/null +++ b/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml @@ -0,0 +1,156 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services + +// Host-agnostic notification center body; popout and island both host this. +Item { + id: root + + required property var host + property var externalKeyboardController: null + property real cachedHeaderHeight: 32 + + readonly property alias notificationList: notificationList + readonly property alias notificationHeader: notificationHeader + readonly property int currentTab: notificationHeader.currentTab + readonly property bool hostOwnsHeight: root.host.hostOwnsHeight ?? false + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + readonly property real maxContentHeight: { + const requested = root.host.maxContentHeight ?? 0; + if (requested > 0) + return requested; + return (root.host.screen?.height ?? 1080) * 0.8; + } + + readonly property real targetImplicitHeight: { + let baseHeight = Theme.spacingL * 2; + baseHeight += cachedHeaderHeight; + baseHeight += Theme.spacingM * 2; + + let listHeight = 200; + if (notificationHeader.currentTab === 0) { + if (NotificationService.groupedNotifications.length === 0) { + listHeight = 200; + } else if (root.hostOwnsHeight) { + listHeight = notificationList.sessionContentHeight > 0 ? notificationList.sessionContentHeight : notificationList.estimateContentHeight(NotificationService.groupedNotifications.length); + } else { + listHeight = root.host.shouldBeVisible ? notificationList.stableContentHeight : notificationList.listContentHeight; + } + } else if (NotificationService.historyList.length > 0) { + listHeight = Math.max(200, NotificationService.historyList.length * 80); + } + + if (!root.hostOwnsHeight) + listHeight = Math.min(listHeight, 600); + + baseHeight += listHeight; + return Math.max(300, Math.min(baseHeight, maxContentHeight)); + } + + implicitHeight: root.hostOwnsHeight ? height : targetImplicitHeight + + function handleKey(event) { + if (event.key === Qt.Key_Escape) { + root.host.close(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Left) { + if (notificationHeader.currentTab > 0) { + notificationHeader.currentTab = 0; + event.accepted = true; + } + return; + } + + if (event.key === Qt.Key_Right) { + if (notificationHeader.currentTab === 0 && SettingsData.notificationHistoryEnabled) { + notificationHeader.currentTab = 1; + event.accepted = true; + } + return; + } + + if (notificationHeader.currentTab === 1) { + historyList.handleKey(event); + return; + } + + if (root.externalKeyboardController) + root.externalKeyboardController.handleKey(event); + } + + FocusScope { + id: contentColumn + + anchors.fill: parent + anchors.margins: Theme.spacingL + focus: true + + Column { + id: contentColumnInner + + anchors.fill: parent + spacing: Theme.spacingM + + NotificationHeader { + id: notificationHeader + + objectName: "notificationHeader" + transientSurfaceTracker: root.host.transientSurfaceTracker ?? null + tapToClose: root.host.headerTogglesClose ?? false + onHeaderTapped: root.host.close() + onHeightChanged: root.cachedHeaderHeight = height + onSettingsRequested: { + if (typeof root.host.requestSettings === "function") + root.host.requestSettings(); + } + } + + Item { + visible: notificationHeader.currentTab === 0 + width: parent.width + height: parent.height - root.cachedHeaderHeight - contentColumnInner.spacing + + KeyboardNavigatedNotificationList { + id: notificationList + + objectName: "notificationList" + anchors.fill: parent + anchors.leftMargin: -shadowHorizontalGutter + anchors.rightMargin: -shadowHorizontalGutter + anchors.topMargin: -(shadowVerticalGutter + delegateShadowGutter / 2) + anchors.bottomMargin: -(shadowVerticalGutter + delegateShadowGutter / 2) + cardAnimateExpansion: root.host.animateCardExpansion ?? true + trackStableContentHeight: !root.hostOwnsHeight + trackSessionContentHeight: root.hostOwnsHeight + lightweightCards: root.host.lightweightNotifications ?? false + transientSurfaceTracker: root.host.transientSurfaceTracker ?? null + } + } + + HistoryNotificationList { + id: historyList + + visible: notificationHeader.currentTab === 1 + width: parent.width + height: parent.height - root.cachedHeaderHeight - contentColumnInner.spacing + } + } + } + + NotificationKeyboardHints { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingL + showHints: notificationHeader.currentTab === 0 ? (root.externalKeyboardController?.showKeyboardHints ?? false) : historyList.showKeyboardHints + z: 200 + } +} diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml index 167afef03..c7571c98a 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -181,146 +181,56 @@ DankPopout { } content: Component { - Rectangle { + Item { id: notificationContent - LayoutMirroring.enabled: I18n.isRtl - LayoutMirroring.childrenInherit: true + property alias externalKeyboardController: body.externalKeyboardController - property var externalKeyboardController: null - property real cachedHeaderHeight: 32 - implicitHeight: { - let baseHeight = Theme.spacingL * 2; - baseHeight += cachedHeaderHeight; - baseHeight += Theme.spacingM * 2; - - const currentListHeight = root.shouldBeVisible ? notificationList.stableContentHeight : notificationList.listContentHeight; - let listHeight = notificationHeader.currentTab === 0 ? currentListHeight : Math.max(200, NotificationService.historyList.length * 80); - if (notificationHeader.currentTab === 0 && NotificationService.groupedNotifications.length === 0) { - listHeight = 200; - } - if (notificationHeader.currentTab === 1 && NotificationService.historyList.length === 0) { - listHeight = 200; - } - - const maxContentArea = 600; - baseHeight += Math.min(listHeight, maxContentArea); - - const maxHeight = root.screen ? root.screen.height * 0.8 : Screen.height * 0.8; - return Math.max(300, Math.min(baseHeight, maxHeight)); - } - - color: "transparent" + implicitHeight: body.implicitHeight focus: true Component.onCompleted: { - if (root.shouldBeVisible) { + if (root.shouldBeVisible) forceActiveFocus(); - } } - Keys.onPressed: event => { - if (event.key === Qt.Key_Escape) { - notificationHistoryVisible = false; - event.accepted = true; - return; - } - - if (event.key === Qt.Key_Left) { - if (notificationHeader.currentTab > 0) { - notificationHeader.currentTab = 0; - event.accepted = true; - } - return; - } - - if (event.key === Qt.Key_Right) { - if (notificationHeader.currentTab === 0 && SettingsData.notificationHistoryEnabled) { - notificationHeader.currentTab = 1; - event.accepted = true; - } - return; - } - if (notificationHeader.currentTab === 1) { - historyList.handleKey(event); - return; - } - if (externalKeyboardController) { - externalKeyboardController.handleKey(event); - } - } + Keys.onPressed: event => body.handleKey(event) Connections { + target: root + function onShouldBeVisibleChanged() { if (root.shouldBeVisible) { - Qt.callLater(() => { - notificationContent.forceActiveFocus(); - }); - } else { - notificationContent.focus = false; + Qt.callLater(() => notificationContent.forceActiveFocus()); + return; } + notificationContent.focus = false; } - target: root } - FocusScope { - id: contentColumn + QtObject { + id: popoutHost + + readonly property bool shouldBeVisible: root.shouldBeVisible + readonly property var screen: root.screen + readonly property var transientSurfaceTracker: root.transientSurfaceTracker + readonly property real maxContentHeight: (root.screen?.height ?? 1080) * 0.8 + + function close() { + root.notificationHistoryVisible = false; + } + + function requestSettings() { + root.notificationHistoryVisible = false; + PopoutService.openSettingsWithTab("notifications", root, () => root.open()); + } + } + + NotificationCenterContent { + id: body anchors.fill: parent - anchors.margins: Theme.spacingL - focus: true - - Column { - id: contentColumnInner - anchors.fill: parent - spacing: Theme.spacingM - - NotificationHeader { - id: notificationHeader - objectName: "notificationHeader" - transientSurfaceTracker: root.transientSurfaceTracker - onHeightChanged: notificationContent.cachedHeaderHeight = height - onSettingsRequested: { - root.notificationHistoryVisible = false; - PopoutService.openSettingsWithTab("notifications", root, () => root.open()); - } - } - - Item { - visible: notificationHeader.currentTab === 0 - width: parent.width - height: parent.height - notificationContent.cachedHeaderHeight - contentColumnInner.spacing - - KeyboardNavigatedNotificationList { - id: notificationList - objectName: "notificationList" - anchors.fill: parent - anchors.leftMargin: -shadowHorizontalGutter - anchors.rightMargin: -shadowHorizontalGutter - anchors.topMargin: -(shadowVerticalGutter + delegateShadowGutter / 2) - anchors.bottomMargin: -(shadowVerticalGutter + delegateShadowGutter / 2) - cardAnimateExpansion: true - transientSurfaceTracker: root.transientSurfaceTracker - } - } - - HistoryNotificationList { - id: historyList - visible: notificationHeader.currentTab === 1 - width: parent.width - height: parent.height - notificationContent.cachedHeaderHeight - contentColumnInner.spacing - } - } - } - - NotificationKeyboardHints { - id: keyboardHints - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Theme.spacingL - showHints: notificationHeader.currentTab === 0 ? (externalKeyboardController && externalKeyboardController.showKeyboardHints) || false : historyList.showKeyboardHints - z: 200 + host: popoutHost } } } diff --git a/quickshell/Modules/Notifications/Center/NotificationHeader.qml b/quickshell/Modules/Notifications/Center/NotificationHeader.qml index 9691ab366..215d8d47a 100644 --- a/quickshell/Modules/Notifications/Center/NotificationHeader.qml +++ b/quickshell/Modules/Notifications/Center/NotificationHeader.qml @@ -10,6 +10,9 @@ Item { property int currentTab: 0 property bool showDndMenu: false property var transientSurfaceTracker: null + property bool tapToClose: false + + signal headerTapped signal settingsRequested @@ -54,6 +57,13 @@ Item { width: parent.width height: Math.max(titleRow.implicitHeight, actionsRow.implicitHeight) + MouseArea { + anchors.fill: parent + enabled: root.tapToClose + acceptedButtons: Qt.LeftButton + onClicked: root.headerTapped() + } + Row { id: titleRow anchors.left: parent.left diff --git a/quickshell/Modules/OSD/BrightnessOSD.qml b/quickshell/Modules/OSD/BrightnessOSD.qml index d4fd686ac..8e453b6b6 100644 --- a/quickshell/Modules/OSD/BrightnessOSD.qml +++ b/quickshell/Modules/OSD/BrightnessOSD.qml @@ -13,7 +13,9 @@ DankOSD { _displayBrightness = DisplayService.brightnessLevel; } - osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(260, screenWidth - Theme.spacingM * 2) + readonly property real osdValueReserve: SettingsData.osdAlwaysShowValue ? 48 : 0 + + osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(216 + osdValueReserve, screenWidth - Theme.spacingM * 2) osdHeight: useVertical ? Math.min(260, screenHeight - Theme.spacingM * 2) : (40 + Theme.spacingS * 2) autoHideInterval: 3000 enableMouseInteraction: true @@ -36,47 +38,25 @@ DankOSD { id: horizontalContent Item { - property int gap: Theme.spacingS - - anchors.centerIn: parent - width: parent.width - Theme.spacingS * 2 - height: 40 + anchors.fill: parent MouseArea { anchors.fill: parent onClicked: root.hide() } - Rectangle { - width: Theme.iconSize - height: Theme.iconSize - radius: Theme.iconSize / 2 - color: "transparent" - x: parent.gap - anchors.verticalCenter: parent.verticalCenter - - DankIcon { - anchors.centerIn: parent - name: { - const deviceInfo = DisplayService.getCurrentDeviceInfo(); - if (!deviceInfo || deviceInfo.class === "backlight" || deviceInfo.class === "ddc") - return "brightness_medium"; - if (deviceInfo.name.includes("kbd")) - return "keyboard"; - return "lightbulb"; - } - size: Theme.iconSize - color: Theme.primary + OsdLevelRow { + anchors.fill: parent + iconName: { + const deviceInfo = DisplayService.getCurrentDeviceInfo(); + if (!deviceInfo || deviceInfo.class === "backlight" || deviceInfo.class === "ddc") + return "brightness_medium"; + if (deviceInfo.name.includes("kbd")) + return "keyboard"; + return "lightbulb"; } - } - - DankSlider { - id: brightnessSlider - - width: parent.width - Theme.iconSize - parent.gap * 3 - height: 40 - x: parent.gap * 2 + Theme.iconSize - anchors.verticalCenter: parent.verticalCenter + iconColor: Theme.primary + value: root._displayBrightness minimum: { const deviceInfo = DisplayService.getCurrentDeviceInfo(); if (!deviceInfo) @@ -93,8 +73,6 @@ DankOSD { return 100; return deviceInfo.displayMax || 100; } - enabled: DisplayService.brightnessAvailable - showValue: true unit: { const deviceInfo = DisplayService.getCurrentDeviceInfo(); if (!deviceInfo) @@ -103,9 +81,8 @@ DankOSD { return "%"; return deviceInfo.class === "ddc" ? "" : "%"; } - thumbOutlineColor: Theme.surfaceContainer - alwaysShowValue: SettingsData.osdAlwaysShowValue - + sliderEnabled: DisplayService.brightnessAvailable + onHoverChanged: hovered => setChildHovered(hovered) onSliderValueChanged: newValue => { if (!DisplayService.brightnessAvailable) return; @@ -113,13 +90,6 @@ DankOSD { DisplayService.setBrightness(newValue, DisplayService.lastIpcDevice, true); resetHideTimer(); } - - onContainsMouseChanged: setChildHovered(containsMouse) - - Binding on value { - value: root._displayBrightness - when: !brightnessSlider.isDragging - } } } } @@ -162,7 +132,7 @@ DankOSD { Item { id: vertSlider width: 12 - height: parent.height - Theme.iconSize - gap * 3 - 24 + height: parent.height - Theme.iconSize - gap * 3 - (SettingsData.osdAlwaysShowValue ? 24 : 0) anchors.horizontalCenter: parent.horizontalCenter y: gap * 2 + Theme.iconSize diff --git a/quickshell/Modules/OSD/MediaVolumeOSD.qml b/quickshell/Modules/OSD/MediaVolumeOSD.qml index c515f69d3..ef415f2a8 100644 --- a/quickshell/Modules/OSD/MediaVolumeOSD.qml +++ b/quickshell/Modules/OSD/MediaVolumeOSD.qml @@ -30,7 +30,9 @@ DankOSD { onTriggered: _suppressNewPlayer = false } - osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(260, screenWidth - Theme.spacingM * 2) + readonly property real osdValueReserve: SettingsData.osdAlwaysShowValue ? 48 : 0 + + osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(216 + osdValueReserve, screenWidth - Theme.spacingM * 2) osdHeight: useVertical ? Math.min(260, screenHeight - Theme.spacingM * 2) : (40 + Theme.spacingS * 2) autoHideInterval: 3000 enableMouseInteraction: true @@ -75,72 +77,25 @@ DankOSD { id: horizontalContent Item { - property int gap: Theme.spacingS - - anchors.centerIn: parent - width: parent.width - Theme.spacingS * 2 - height: 40 + anchors.fill: parent MouseArea { anchors.fill: parent onClicked: root.hide() } - Rectangle { - width: Theme.iconSize - height: Theme.iconSize - radius: Theme.iconSize / 2 - color: "transparent" - x: parent.gap - anchors.verticalCenter: parent.verticalCenter - - DankIcon { - anchors.centerIn: parent - name: getVolumeIcon(player?.volume ?? 0) - size: Theme.iconSize - color: muteButton.containsMouse ? Theme.primary : Theme.surfaceText - } - - MouseArea { - id: muteButton - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: toggleMute() - onContainsMouseChanged: setChildHovered(containsMouse || volumeSlider.containsMouse) - } - } - - DankSlider { - id: volumeSlider - - width: parent.width - Theme.iconSize - parent.gap * 3 - height: 40 - x: parent.gap * 2 + Theme.iconSize - anchors.verticalCenter: parent.verticalCenter + OsdLevelRow { + anchors.fill: parent + iconName: getVolumeIcon(player?.volume ?? 0) + iconInteractive: true + value: root._displayVolume minimum: 0 maximum: 100 - enabled: volumeSupported - showValue: true - unit: "%" - thumbOutlineColor: Theme.surfaceContainer - valueOverride: root._displayVolume - alwaysShowValue: SettingsData.osdAlwaysShowValue - - Component.onCompleted: { - root._syncVolume(); - value = root._displayVolume; - } - + sliderEnabled: volumeSupported + onIconClicked: toggleMute() + onHoverChanged: hovered => setChildHovered(hovered) onSliderValueChanged: newValue => setVolume(newValue) - - onContainsMouseChanged: setChildHovered(containsMouse || muteButton.containsMouse) - - Binding on value { - value: root._displayVolume - when: !volumeSlider.pressed - } + Component.onCompleted: root._syncVolume() } } } @@ -186,7 +141,7 @@ DankOSD { Item { id: vertSlider width: 12 - height: parent.height - Theme.iconSize - gap * 3 - 24 + height: parent.height - Theme.iconSize - gap * 3 - (SettingsData.osdAlwaysShowValue ? 24 : 0) anchors.horizontalCenter: parent.horizontalCenter y: gap * 2 + Theme.iconSize diff --git a/quickshell/Modules/OSD/MicVolumeOSD.qml b/quickshell/Modules/OSD/MicVolumeOSD.qml index 5064b6d7a..903982817 100644 --- a/quickshell/Modules/OSD/MicVolumeOSD.qml +++ b/quickshell/Modules/OSD/MicVolumeOSD.qml @@ -15,7 +15,9 @@ DankOSD { _displayVolume = Math.round(AudioService.source.audio.volume * 100); } - osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(260, screenWidth - Theme.spacingM * 2) + readonly property real osdValueReserve: SettingsData.osdAlwaysShowValue ? 48 : 0 + + osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(216 + osdValueReserve, screenWidth - Theme.spacingM * 2) osdHeight: useVertical ? Math.min(260, screenHeight - Theme.spacingM * 2) : (40 + Theme.spacingS * 2) autoHideInterval: 3000 enableMouseInteraction: true @@ -59,64 +61,25 @@ DankOSD { id: horizontalContent Item { - property int gap: Theme.spacingS - - anchors.centerIn: parent - width: parent.width - Theme.spacingS * 2 - height: 40 + anchors.fill: parent MouseArea { anchors.fill: parent onClicked: root.hide() } - Rectangle { - width: Theme.iconSize - height: Theme.iconSize - radius: Theme.iconSize / 2 - color: "transparent" - x: parent.gap - anchors.verticalCenter: parent.verticalCenter - - DankIcon { - anchors.centerIn: parent - name: AudioService.source?.audio?.muted ? "mic_off" : "mic" - size: Theme.iconSize - color: muteButton.containsMouse ? Theme.primary : (AudioService.source?.audio?.muted ? Theme.error : Theme.surfaceText) - } - - MouseArea { - id: muteButton - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: AudioService.toggleMicMute() - onContainsMouseChanged: setChildHovered(containsMouse || volumeSlider.containsMouse) - } - } - - DankSlider { - id: volumeSlider - - width: parent.width - Theme.iconSize - parent.gap * 3 - height: 40 - x: parent.gap * 2 + Theme.iconSize - anchors.verticalCenter: parent.verticalCenter + OsdLevelRow { + anchors.fill: parent + iconName: AudioService.source?.audio?.muted ? "mic_off" : "mic" + iconColor: AudioService.source?.audio?.muted ? Theme.error : Theme.surfaceText + iconInteractive: true + value: root._displayVolume minimum: 0 maximum: 100 - enabled: AudioService.source?.audio ?? false - showValue: true - unit: "%" - thumbOutlineColor: Theme.surfaceContainer - valueOverride: root._displayVolume - alwaysShowValue: SettingsData.osdAlwaysShowValue - - Component.onCompleted: { - root._syncVolume(); - value = root._displayVolume; - } - + sliderEnabled: !!AudioService.source?.audio + displayText: AudioService.source?.audio?.muted ? I18n.tr("Muted") : "" + onIconClicked: AudioService.toggleMicMute() + onHoverChanged: hovered => setChildHovered(hovered) onSliderValueChanged: newValue => { if (!AudioService.source?.audio) return; @@ -124,13 +87,7 @@ DankOSD { AudioService.source.audio.volume = newValue / 100; resetHideTimer(); } - - onContainsMouseChanged: setChildHovered(containsMouse || muteButton.containsMouse) - - Binding on value { - value: root._displayVolume - when: !volumeSlider.pressed - } + Component.onCompleted: root._syncVolume() } } } @@ -176,7 +133,7 @@ DankOSD { Item { id: vertSlider width: 12 - height: parent.height - Theme.iconSize - gap * 3 - 24 + height: parent.height - Theme.iconSize - gap * 3 - (SettingsData.osdAlwaysShowValue ? 24 : 0) anchors.horizontalCenter: parent.horizontalCenter y: gap * 2 + Theme.iconSize diff --git a/quickshell/Modules/OSD/OsdLevelRow.qml b/quickshell/Modules/OSD/OsdLevelRow.qml new file mode 100644 index 000000000..abbb4bb8e --- /dev/null +++ b/quickshell/Modules/OSD/OsdLevelRow.qml @@ -0,0 +1,137 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: root + + property string iconName: "volume_up" + property color iconColor: Theme.surfaceText + property color iconHoverColor: Theme.primary + property real iconSize: Theme.iconSize + property bool iconInteractive: false + property int value: 0 + property int minimum: 0 + property int maximum: 100 + property string unit: "%" + property string displayText: "" + property string reserveText: "" + property bool sliderEnabled: true + property bool alwaysShowValue: SettingsData.osdAlwaysShowValue + property color thumbOutlineColor: Theme.surfaceContainer + property real horizontalPadding: -1 + + readonly property bool containsMouse: iconArea.containsMouse || levelSlider.containsMouse + readonly property bool showValueColumn: root.alwaysShowValue + readonly property alias isDragging: levelSlider.isDragging + readonly property real effectiveHorizontalPadding: root.horizontalPadding >= 0 ? root.horizontalPadding : Math.max(Theme.spacingS, Math.round((height - root.iconSize) / 2)) + + signal sliderValueChanged(int newValue) + signal iconClicked + signal hoverChanged(bool hovered) + + onContainsMouseChanged: root.hoverChanged(root.containsMouse) + + height: 40 + + Item { + id: iconSlot + + width: root.iconSize + height: root.iconSize + anchors { + left: parent.left + leftMargin: root.effectiveHorizontalPadding + verticalCenter: parent.verticalCenter + } + + DankIcon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: iconArea.containsMouse && root.iconInteractive ? root.iconHoverColor : root.iconColor + } + + MouseArea { + id: iconArea + + anchors.fill: parent + hoverEnabled: true + acceptedButtons: root.iconInteractive ? Qt.LeftButton : Qt.NoButton + cursorShape: root.iconInteractive ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (root.iconInteractive) + root.iconClicked(); + } + } + } + + DankSlider { + id: levelSlider + + anchors { + left: iconSlot.right + leftMargin: Theme.spacingS + right: valueSlot.left + rightMargin: root.showValueColumn ? Theme.spacingS : root.effectiveHorizontalPadding + verticalCenter: parent.verticalCenter + } + height: 40 + minimum: root.minimum + maximum: Math.max(root.minimum + 1, root.maximum) + enabled: root.sliderEnabled + showValue: false + unit: root.unit + thumbOutlineColor: root.thumbOutlineColor + valueOverride: root.value + onSliderValueChanged: newValue => root.sliderValueChanged(newValue) + + Binding on value { + value: root.value + when: !levelSlider.isDragging + } + } + + Item { + id: valueSlot + + anchors { + right: parent.right + rightMargin: root.showValueColumn ? root.effectiveHorizontalPadding : 0 + top: parent.top + bottom: parent.bottom + } + width: root.showValueColumn ? valueLabel.reservedWidth : 0 + visible: root.showValueColumn + + NumericText { + id: valueLabel + + anchors { + right: parent.right + top: parent.top + bottom: parent.bottom + } + text: root.displayText.length > 0 ? root.displayText : (Math.round(root.value) + root.unit) + reserveText: { + const samples = [root.minimum, root.maximum, root.value]; + let widest = Math.round(root.maximum) + root.unit; + for (let i = 0; i < samples.length; i++) { + const candidate = Math.round(samples[i]) + root.unit; + if (candidate.length > widest.length) + widest = candidate; + } + if (root.reserveText.length > widest.length) + return root.reserveText; + return widest; + } + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + } + } +} diff --git a/quickshell/Modules/OSD/VolumeOSD.qml b/quickshell/Modules/OSD/VolumeOSD.qml index 5a71917b3..1d9267849 100644 --- a/quickshell/Modules/OSD/VolumeOSD.qml +++ b/quickshell/Modules/OSD/VolumeOSD.qml @@ -15,7 +15,9 @@ DankOSD { _displayVolume = Math.min(AudioService.sinkMaxVolume, Math.round(AudioService.sink.audio.volume * 100)); } - osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(260, screenWidth - Theme.spacingM * 2) + readonly property real osdValueReserve: SettingsData.osdAlwaysShowValue ? 48 : 0 + + osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(216 + osdValueReserve, screenWidth - Theme.spacingM * 2) osdHeight: useVertical ? Math.min(260, screenHeight - Theme.spacingM * 2) : (40 + Theme.spacingS * 2) autoHideInterval: 3000 enableMouseInteraction: true @@ -54,64 +56,24 @@ DankOSD { id: horizontalContent Item { - property int gap: Theme.spacingS - - anchors.centerIn: parent - width: parent.width - Theme.spacingS * 2 - height: 40 + anchors.fill: parent MouseArea { anchors.fill: parent onClicked: root.hide() } - Rectangle { - width: Theme.iconSize - height: Theme.iconSize - radius: Theme.iconSize / 2 - color: "transparent" - x: parent.gap - anchors.verticalCenter: parent.verticalCenter - - DankIcon { - anchors.centerIn: parent - name: AudioService.sink?.audio?.muted ? "volume_off" : "volume_up" - size: Theme.iconSize - color: muteButton.containsMouse ? Theme.primary : Theme.surfaceText - } - - MouseArea { - id: muteButton - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: AudioService.toggleMute() - onContainsMouseChanged: setChildHovered(containsMouse || volumeSlider.containsMouse) - } - } - - DankSlider { - id: volumeSlider - - width: parent.width - Theme.iconSize - parent.gap * 3 - height: 40 - x: parent.gap * 2 + Theme.iconSize - anchors.verticalCenter: parent.verticalCenter + OsdLevelRow { + anchors.fill: parent + iconName: AudioService.sink?.audio?.muted ? "volume_off" : "volume_up" + iconInteractive: true + value: root._displayVolume minimum: 0 maximum: AudioService.sinkMaxVolume - enabled: AudioService.sink?.audio ?? false - showValue: true - unit: "%" - thumbOutlineColor: Theme.surfaceContainer - valueOverride: root._displayVolume - alwaysShowValue: SettingsData.osdAlwaysShowValue - - Component.onCompleted: { - root._syncVolume(); - value = root._displayVolume; - } - + sliderEnabled: !!AudioService.sink?.audio + displayText: AudioService.sink?.audio?.muted ? I18n.tr("Muted") : "" + onIconClicked: AudioService.toggleMute() + onHoverChanged: hovered => setChildHovered(hovered) onSliderValueChanged: newValue => { if (!AudioService.sink?.audio) return; @@ -119,13 +81,7 @@ DankOSD { AudioService.sink.audio.volume = newValue / 100; resetHideTimer(); } - - onContainsMouseChanged: setChildHovered(containsMouse || muteButton.containsMouse) - - Binding on value { - value: root._displayVolume - when: !volumeSlider.pressed - } + Component.onCompleted: root._syncVolume() } } } @@ -171,7 +127,7 @@ DankOSD { Item { id: vertSlider width: 12 - height: parent.height - Theme.iconSize - gap * 3 - 24 + height: parent.height - Theme.iconSize - gap * 3 - (SettingsData.osdAlwaysShowValue ? 24 : 0) anchors.horizontalCenter: parent.horizontalCenter y: gap * 2 + Theme.iconSize diff --git a/quickshell/Modules/Settings/CompositorLayoutTab.qml b/quickshell/Modules/Settings/CompositorLayoutTab.qml index 944b30897..0e67015ff 100644 --- a/quickshell/Modules/Settings/CompositorLayoutTab.qml +++ b/quickshell/Modules/Settings/CompositorLayoutTab.qml @@ -267,42 +267,6 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; } } - SettingsCard { - width: parent.width - tags: ["compositor", "toast"] - title: I18n.tr("Notifications") - settingKey: "compositorNotifications" - iconName: "notifications" - visible: CompositorService.isNiri || CompositorService.isMango - - SettingsToggleRow { - tags: ["compositor", "toast", "reload"] - settingKey: "showConfigReloadToast" - text: I18n.tr("Show \"config reloaded\" Toast") - description: I18n.tr("Show a toast when the compositor config is reloaded") - checked: SessionData.showConfigReloadToast - onToggled: checked => SessionData.showConfigReloadToast = checked - } - } - - SettingsCard { - width: parent.width - tags: ["niri", "overview", "window", "focus", "launch", "launcher", "dock", "settings"] - title: I18n.tr("Overview") - settingKey: "niriOverview" - iconName: "overview" - visible: CompositorService.isNiri - - SettingsToggleRow { - tags: ["niri", "overview", "window", "focus", "launch", "launcher", "dock", "settings"] - settingKey: "closeNiriOverviewOnWindowFocus" - text: I18n.tr("Close Niri Overview on Window Focus", "Niri setting to leave Niri overview when DMS launches or focuses a window") - description: I18n.tr("Auto-close Niri overview when DMS launches or focuses a window.", "Description of the close Niri overview on window focus setting") - checked: SettingsData.closeNiriOverviewOnWindowFocus - onToggled: checked => SettingsData.set("closeNiriOverviewOnWindowFocus", checked) - } - } - SettingsCard { width: parent.width tags: ["niri", "layout", "gaps", "radius", "window", "border"] @@ -327,7 +291,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; return; switch (index) { case 1: - SettingsData.set("niriLayoutGapsOverride", Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4))); + SettingsData.set("niriLayoutGapsOverride", Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4))); return; case 2: SettingsData.set("niriLayoutGapsOverride", -2); @@ -348,7 +312,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; minimum: 0 maximum: 50 unit: "px" - defaultValue: Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) + defaultValue: Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) onSliderValueChanged: newValue => SettingsData.set("niriLayoutGapsOverride", newValue) } @@ -432,6 +396,42 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; } } + SettingsCard { + width: parent.width + tags: ["compositor", "toast"] + title: I18n.tr("Notifications") + settingKey: "compositorNotifications" + iconName: "notifications" + visible: CompositorService.isNiri || CompositorService.isMango + + SettingsToggleRow { + tags: ["compositor", "toast", "reload"] + settingKey: "showConfigReloadToast" + text: I18n.tr("Show \"config reloaded\" Toast") + description: I18n.tr("Show a toast when the compositor config is reloaded") + checked: SessionData.showConfigReloadToast + onToggled: checked => SessionData.showConfigReloadToast = checked + } + } + + SettingsCard { + width: parent.width + tags: ["niri", "overview", "window", "focus", "launch", "launcher", "dock", "settings"] + title: I18n.tr("Overview") + settingKey: "niriOverview" + iconName: "overview" + visible: CompositorService.isNiri + + SettingsToggleRow { + tags: ["niri", "overview", "window", "focus", "launch", "launcher", "dock", "settings"] + settingKey: "closeNiriOverviewOnWindowFocus" + text: I18n.tr("Close Niri Overview on Window Focus", "Niri setting to leave Niri overview when DMS launches or focuses a window") + description: I18n.tr("Auto-close Niri overview when DMS launches or focuses a window.", "Description of the close Niri overview on window focus setting") + checked: SettingsData.closeNiriOverviewOnWindowFocus + onToggled: checked => SettingsData.set("closeNiriOverviewOnWindowFocus", checked) + } + } + SettingsCard { width: parent.width tags: ["hyprland", "layout", "gaps", "radius", "window", "border", "rounding"] @@ -456,7 +456,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; return; switch (index) { case 1: - SettingsData.set("hyprlandLayoutGapsOverride", Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4))); + SettingsData.set("hyprlandLayoutGapsOverride", Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4))); return; case 2: SettingsData.set("hyprlandLayoutGapsOverride", -2); @@ -477,7 +477,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; minimum: 0 maximum: 50 unit: "px" - defaultValue: Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) + defaultValue: Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) onSliderValueChanged: newValue => SettingsData.set("hyprlandLayoutGapsOverride", newValue) } @@ -607,7 +607,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; return; switch (index) { case 1: - SettingsData.set("mangoLayoutGapsOverride", Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4))); + SettingsData.set("mangoLayoutGapsOverride", Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4))); return; case 2: SettingsData.set("mangoLayoutGapsOverride", -2); @@ -628,7 +628,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`; minimum: 0 maximum: 50 unit: "px" - defaultValue: Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) + defaultValue: Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) onSliderValueChanged: newValue => SettingsData.set("mangoLayoutGapsOverride", newValue) } diff --git a/quickshell/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index 285a90dc6..0f155e2e3 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -52,6 +52,53 @@ Item { } readonly property bool connectedFrameModeActive: SettingsData.connectedFrameModeActive + // The selected instance is itself the island, so bar-surface settings do not apply to it. + readonly property bool selectedBarIsIsland: { + SettingsData.dankIslandBarId; + selectedBarId; + return !!selectedBarId && selectedBarId === SettingsData.dankIslandBarId; + } + readonly property bool islandShadowsSelectedBar: { + SettingsData.dankIslandBarId; + SettingsData.barConfigs; + Quickshell.screens; + selectedBarId; + if (!selectedBarConfig || dankBarTab.selectedBarIsIsland) + return false; + const edge = SettingsData.positionToSide(selectedBarConfig.position ?? SettingsData.Position.Top); + return Quickshell.screens.some(screen => SettingsData.barConfigCoversScreen(selectedBarConfig, screen) && SettingsData.dankIslandOwnsEdge(screen, edge)); + } + readonly property bool islandOwnsSelectedBarTop: dankBarTab.selectedBarIsIsland || dankBarTab.islandShadowsSelectedBar + readonly property var positionChoices: { + SettingsData.dankIslandBarId; + SettingsData.barConfigs; + Quickshell.screens; + selectedBarId; + const all = [SettingsData.Position.Top, SettingsData.Position.Bottom, SettingsData.Position.Left, SettingsData.Position.Right]; + if (dankBarTab.selectedBarIsIsland) + return [SettingsData.Position.Top, SettingsData.Position.Bottom]; + if (!selectedBarConfig) + return all; + const current = selectedBarConfig.position ?? SettingsData.Position.Top; + return all.filter(pos => { + if (pos === current) + return true; + const edge = SettingsData.positionToSide(pos); + return !Quickshell.screens.some(screen => SettingsData.barConfigCoversScreen(selectedBarConfig, screen) && SettingsData.dankIslandOwnsEdge(screen, edge)); + }); + } + function positionLabel(pos) { + switch (pos) { + case SettingsData.Position.Bottom: + return I18n.tr("Bottom", "screen edge position"); + case SettingsData.Position.Left: + return I18n.tr("Left", "screen edge position"); + case SettingsData.Position.Right: + return I18n.tr("Right", "screen edge position"); + } + return I18n.tr("Top", "screen edge position"); + } + // Bar Inset Padding: resolve the "auto" sentinel (stored < 0) to each mode's natural inset for the slider display. readonly property real insetPadAutoUI: SettingsData.connectedFrameModeActive ? SettingsData.frameThickness : (selectedBarIsVertical ? Theme.spacingXS : Math.max(Theme.spacingXS, (selectedBarConfig?.innerPadding ?? 4) * 0.8)) readonly property int insetPadDisplayValue: { @@ -64,7 +111,7 @@ Item { interval: 500 repeat: false onTriggered: { - const verticalBars = SettingsData.barConfigs.filter(cfg => { + const verticalBars = SettingsData.getBarKindConfigs().filter(cfg => { const pos = cfg.position ?? SettingsData.Position.Top; return pos === SettingsData.Position.Left || pos === SettingsData.Position.Right; }); @@ -83,7 +130,7 @@ Item { } function _isBarActive(c) { - if (!c.enabled) + if (!c.enabled || SettingsData.isIslandBarConfig(c)) return false; const prefs = c.screenPreferences || ["all"]; if (prefs.length > 0) @@ -92,7 +139,7 @@ Item { } function notifyHorizontalBarChange() { - const configs = SettingsData.barConfigs; + const configs = SettingsData.getBarKindConfigs(); if (configs.length < 2) return; @@ -189,14 +236,14 @@ Item { function deleteBar(barId) { if (barId === "default") return; - if (SettingsData.barConfigs.length <= 1) + if (SettingsData.getBarKindConfigs().length <= 1 && barId !== SettingsData.dankIslandBarId) return; SettingsData.deleteBarConfig(barId); selectedBarId = "default"; } function toggleBarEnabled(barId) { - if (barId === "default") + if (barId === "default" && barId !== SettingsData.dankIslandBarId) return; const config = SettingsData.getBarConfig(barId); if (!config) @@ -390,7 +437,7 @@ Item { SettingsData.barConfigs; const cfg = SettingsData.getBarConfig(barCard.modelData.id); const left = cfg?.leftWidgets?.length || 0; - const center = cfg?.centerWidgets?.length || 0; + const center = SettingsData.isIslandBarConfig(cfg) ? 0 : (cfg?.centerWidgets?.length || 0); const right = cfg?.rightWidgets?.length || 0; return I18n.tr("%1 widgets").replace("%1", left + center + right); } @@ -407,7 +454,7 @@ Item { visible: { SettingsData.barConfigs; const cfg = SettingsData.getBarConfig(barCard.modelData.id); - return !cfg?.enabled && barCard.modelData.id !== "default"; + return !cfg?.enabled && (barCard.modelData.id !== "default" || SettingsData.isIslandBarConfig(cfg)); } } @@ -419,9 +466,26 @@ Item { visible: { SettingsData.barConfigs; const cfg = SettingsData.getBarConfig(barCard.modelData.id); - return !cfg?.enabled && barCard.modelData.id !== "default"; + return !cfg?.enabled && (barCard.modelData.id !== "default" || SettingsData.isIslandBarConfig(cfg)); } } + + StyledText { + text: "•" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignLeft + visible: SettingsData.dankIslandBarId === barCard.modelData.id + } + + StyledText { + text: I18n.tr("Island") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.primary + horizontalAlignment: Text.AlignLeft + visible: SettingsData.dankIslandBarId === barCard.modelData.id + } } } @@ -433,7 +497,7 @@ Item { backgroundColor: Theme.withAlpha(Theme.error, 0.15) iconColor: Theme.error visible: barCard.modelData.id !== "default" - enabled: SettingsData.barConfigs.length > 1 + enabled: SettingsData.getBarKindConfigs().length > 1 || SettingsData.dankIslandBarId === barCard.modelData.id anchors.verticalCenter: parent.verticalCenter onClicked: dankBarTab.deleteBar(barCard.modelData.id) } @@ -467,11 +531,11 @@ Item { SettingsCard { settingKey: "barEnable" iconName: selectedBarConfig?.enabled ? "visibility" : "visibility_off" - title: I18n.tr("Enable Bar") - visible: !dankBarTab.appearanceOnly && selectedBarId !== "default" + title: dankBarTab.selectedBarIsIsland ? I18n.tr("Enable Island") : I18n.tr("Enable Bar") + visible: !dankBarTab.appearanceOnly && (selectedBarId !== "default" || dankBarTab.selectedBarIsIsland) SettingsToggleRow { - text: I18n.tr("Toggle visibility of this bar configuration") + text: dankBarTab.selectedBarIsIsland ? I18n.tr("Toggle the Island on this instance's displays") : I18n.tr("Toggle visibility of this bar configuration") checked: { selectedBarId; return selectedBarConfig?.enabled ?? false; @@ -486,6 +550,20 @@ Item { settingKey: "barPosition" visible: !dankBarTab.appearanceOnly && selectedBarConfig?.enabled + SettingsControlledByIsland { + visible: dankBarTab.selectedBarIsIsland + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Island placement") + reason: I18n.tr("The Island anchors to the top or bottom edge") + } + + SettingsControlledByIsland { + visible: dankBarTab.islandShadowsSelectedBar + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("%1 position").arg(dankBarTab.positionLabel(selectedBarConfig?.position ?? SettingsData.Position.Top)) + reason: I18n.tr("The Island holds this edge on a display this bar covers, so the bar stays hidden there") + } + Item { width: parent.width height: positionButtonGroup.height @@ -493,44 +571,17 @@ Item { DankButtonGroup { id: positionButtonGroup anchors.horizontalCenter: parent.horizontalCenter - model: [I18n.tr("Top", "screen edge position"), I18n.tr("Bottom", "screen edge position"), I18n.tr("Left", "screen edge position"), I18n.tr("Right", "screen edge position")] + model: dankBarTab.positionChoices.map(pos => dankBarTab.positionLabel(pos)) currentIndex: { selectedBarId; const config = SettingsData.getBarConfig(selectedBarId); - const pos = config?.position ?? 0; - switch (pos) { - case SettingsData.Position.Top: - return 0; - case SettingsData.Position.Bottom: - return 1; - case SettingsData.Position.Left: - return 2; - case SettingsData.Position.Right: - return 3; - default: - return 0; - } + return dankBarTab.positionChoices.indexOf(config?.position ?? SettingsData.Position.Top); } onSelectionChanged: (index, selected) => { - if (!selected) + if (!selected || index < 0 || index >= dankBarTab.positionChoices.length) return; - let newPos = 0; - switch (index) { - case 0: - newPos = SettingsData.Position.Top; - break; - case 1: - newPos = SettingsData.Position.Bottom; - break; - case 2: - newPos = SettingsData.Position.Left; - break; - case 3: - newPos = SettingsData.Position.Right; - break; - } SettingsData.updateBarConfig(selectedBarId, { - position: newPos + position: dankBarTab.positionChoices[index] }); notifyHorizontalBarChange(); } @@ -548,7 +599,7 @@ Item { StyledText { width: parent.width - text: I18n.tr("Configure which displays show \"%1\"").replace("%1", selectedBarConfig?.name || "this bar") + text: dankBarTab.selectedBarIsIsland ? I18n.tr("Configure which displays show Dank Island as \"%1\"").replace("%1", selectedBarConfig?.name || "this instance") : I18n.tr("Configure which displays show \"%1\"").replace("%1", selectedBarConfig?.name || "this bar") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -653,9 +704,17 @@ Item { expanded: true visible: !dankBarTab.appearanceOnly && selectedBarConfig?.enabled + SettingsControlledByIsland { + visible: dankBarTab.islandOwnsSelectedBarTop + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar visibility") + reason: I18n.tr("Additional settings are managed by the Island") + } + SettingsToggleRow { settingKey: "barAutoHide" tags: ["autohide", "auto-hide", "reveal", "intellihide"] + visible: !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Auto-hide") description: I18n.tr("Automatically hide the bar when the pointer moves away") checked: selectedBarConfig?.autoHide ?? false @@ -670,7 +729,7 @@ Item { Column { width: parent.width spacing: Theme.spacingS - visible: selectedBarConfig?.autoHide ?? false + visible: (selectedBarConfig?.autoHide ?? false) && !dankBarTab.islandOwnsSelectedBarTop leftPadding: Theme.spacingM Rectangle { @@ -740,11 +799,13 @@ Item { height: 1 color: Theme.outline opacity: 0.15 + visible: !dankBarTab.islandOwnsSelectedBarTop } SettingsToggleRow { settingKey: "barManualVisibility" tags: ["manual", "show", "hide", "ipc", "toggle"] + visible: !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Manual Show/Hide") description: I18n.tr("Toggle bar visibility manually via IPC") checked: selectedBarConfig?.visible ?? true @@ -761,11 +822,13 @@ Item { height: 1 color: Theme.outline opacity: 0.15 + visible: !dankBarTab.islandOwnsSelectedBarTop } SettingsToggleRow { settingKey: "barClickThrough" tags: ["clickthrough", "click", "through", "mouse", "input", "mask", "passthrough"] + visible: !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Click Through") description: I18n.tr("Mouse clicks pass through the bar to windows behind it") checked: selectedBarConfig?.clickThrough ?? false @@ -779,13 +842,13 @@ Item { height: 1 color: Theme.outline opacity: 0.15 - visible: CompositorService.isNiri + visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop } SettingsToggleRow { settingKey: "barOpenOnOverview" tags: ["bar", "overview", "niri", "show", "frame"] - visible: CompositorService.isNiri + visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Show on Overview") description: SettingsData.frameEnabled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active") checked: SettingsData.frameEnabled ? SettingsData.frameShowOnOverview : (selectedBarConfig?.openOnOverview ?? false) @@ -805,11 +868,13 @@ Item { height: 1 color: Theme.outline opacity: 0.15 + visible: !dankBarTab.islandOwnsSelectedBarTop } SettingsToggleRow { settingKey: "barUseOverlayLayer" tags: ["bar", "fullscreen", "overlay", "layer"] + visible: !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Use Overlay Layer", "bar layer toggle: use Wayland overlay layer") description: I18n.tr("Place the bar on the Wayland overlay layer") checked: selectedBarConfig?.useOverlayLayer ?? false @@ -831,7 +896,7 @@ Item { SettingsSliderRow { id: barTransparencySlider - visible: !SettingsData.frameEnabled + visible: !SettingsData.frameEnabled && !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Bar Opacity") value: (selectedBarConfig?.transparency ?? 1.0) * 100 minimum: 0 @@ -880,6 +945,13 @@ Item { settingLabel: I18n.tr("Bar Opacity") reason: I18n.tr("Managed by Frame") } + + SettingsControlledByIsland { + visible: !SettingsData.frameEnabled && dankBarTab.islandOwnsSelectedBarTop + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar Opacity") + reason: I18n.tr("The island draws its own surface") + } } SettingsCard { @@ -898,7 +970,7 @@ Item { SettingsSliderRow { id: edgeSpacingSlider - visible: !SettingsData.frameEnabled + visible: !SettingsData.frameEnabled && !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Edge Spacing") description: I18n.tr("Space between the bar and screen edges") value: selectedBarConfig?.spacing ?? 4 @@ -923,7 +995,7 @@ Item { id: exclusiveZoneSlider settingKey: "barExclusiveZone" tags: ["exclusive", "zone", "reserved", "offset"] - visible: !SettingsData.frameEnabled + visible: !SettingsData.frameEnabled && !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Exclusive Zone Offset") description: I18n.tr("Fine-tune the space reserved for the bar from the screen edge") value: selectedBarConfig?.bottomGap ?? 0 @@ -1034,7 +1106,7 @@ Item { SettingsSliderRow { id: barLengthPaddingSlider settingKey: "barLengthPadding" - visible: !SettingsData.frameEnabled + visible: !SettingsData.frameEnabled && !dankBarTab.islandOwnsSelectedBarTop text: I18n.tr("Bar Length Padding") description: I18n.tr("Reduce the visible bar length from both ends") tags: ["bar", "length", "padding", "size", "shorter"] @@ -1120,6 +1192,13 @@ Item { } } } + + SettingsControlledByIsland { + visible: !SettingsData.frameEnabled && dankBarTab.islandOwnsSelectedBarTop + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar Spacing") + reason: I18n.tr("Some settings are managed by the island") + } } SettingsSliderCard { @@ -1192,6 +1271,13 @@ Item { reason: I18n.tr("Managed by Frame") } + SettingsControlledByIsland { + visible: !SettingsData.frameEnabled && dankBarTab.islandOwnsSelectedBarTop + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar corners") + reason: I18n.tr("Corner style applies to the bar surface") + } + SettingsToggleRow { settingKey: "barNoBackground" tags: ["transparent", "background", "invisible"] @@ -1249,7 +1335,7 @@ Item { tags: ["rounded", "attached", "square", "corners", "edge", "screen", "flush"] text: I18n.tr("Corner Style") description: I18n.tr("Choose how the bar meets the screen edge") - visible: !SettingsData.frameEnabled + visible: !SettingsData.frameEnabled && !dankBarTab.islandOwnsSelectedBarTop model: [I18n.tr("Rounded", "bar corner style option"), I18n.tr("Flush", "bar corner style option"), I18n.tr("Square", "bar corner style option")] currentIndex: { if (selectedBarConfig?.squareCorners ?? false) @@ -1273,7 +1359,7 @@ Item { tags: ["goth", "corners", "concave", "cutout"] text: I18n.tr("Goth Corners") description: I18n.tr("Apply inverse concave corner cutouts to the bar") - visible: !SettingsData.frameEnabled + visible: !SettingsData.frameEnabled && !dankBarTab.islandOwnsSelectedBarTop checked: selectedBarConfig?.gothCornersEnabled ?? false onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { gothCornersEnabled: checked @@ -1284,7 +1370,7 @@ Item { text: I18n.tr("Corner Radius Override") description: I18n.tr("Use a custom radius for goth corner cutouts") checked: selectedBarConfig?.gothCornerRadiusOverride ?? false - visible: selectedBarConfig?.gothCornersEnabled ?? false + visible: (selectedBarConfig?.gothCornersEnabled ?? false) && !dankBarTab.islandOwnsSelectedBarTop onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { gothCornerRadiusOverride: checked }) @@ -1293,7 +1379,7 @@ Item { Column { width: parent.width spacing: Theme.spacingS - visible: (selectedBarConfig?.gothCornersEnabled ?? false) && (selectedBarConfig?.gothCornerRadiusOverride ?? false) + visible: (selectedBarConfig?.gothCornersEnabled ?? false) && (selectedBarConfig?.gothCornerRadiusOverride ?? false) && !dankBarTab.islandOwnsSelectedBarTop leftPadding: Theme.spacingM SettingsSliderRow { @@ -1484,7 +1570,7 @@ Item { settingKey: "barBorder" iconName: "border_style" title: I18n.tr("Border") - visible: dankBarTab.appearanceOnly && selectedBarConfig?.enabled && !dankBarTab.connectedFrameModeActive + visible: dankBarTab.appearanceOnly && selectedBarConfig?.enabled && !dankBarTab.connectedFrameModeActive && !dankBarTab.islandOwnsSelectedBarTop checked: selectedBarConfig?.borderEnabled ?? false onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { borderEnabled: checked @@ -1674,6 +1760,13 @@ Item { reason: I18n.tr("Managed by Frame in Connected Mode") } + SettingsControlledByIsland { + visible: dankBarTab.appearanceOnly && !dankBarTab.connectedFrameModeActive && dankBarTab.islandOwnsSelectedBarTop + parentModal: dankBarTab.parentModal + settingLabel: I18n.tr("Bar border and shadow") + reason: I18n.tr("These style the bar surface, which the Island replaces") + } + SettingsCard { id: shadowCard tab: "appearance" @@ -1682,7 +1775,7 @@ Item { settingKey: "barShadow" collapsible: true expanded: false - visible: dankBarTab.appearanceOnly && (selectedBarConfig?.enabled ?? false) && !dankBarTab.connectedFrameModeActive + visible: dankBarTab.appearanceOnly && (selectedBarConfig?.enabled ?? false) && !dankBarTab.connectedFrameModeActive && !dankBarTab.islandOwnsSelectedBarTop readonly property bool shadowActive: (selectedBarConfig?.shadowIntensity ?? 0) > 0 readonly property bool isCustomColor: (selectedBarConfig?.shadowColorMode ?? "default") === "custom" diff --git a/quickshell/Modules/Settings/DankIslandTab.qml b/quickshell/Modules/Settings/DankIslandTab.qml new file mode 100644 index 000000000..5ee1ec514 --- /dev/null +++ b/quickshell/Modules/Settings/DankIslandTab.qml @@ -0,0 +1,486 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Modules.Settings.Widgets +import qs.Services +import qs.Widgets + +Item { + id: root + + property var parentModal: null + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + readonly property var instanceChoices: { + SettingsData.barConfigs; + SettingsData.dankIslandBarId; + const rows = [({ + "id": "", + "name": I18n.tr("Off") + })]; + const configs = SettingsData.barConfigs || []; + for (let i = 0; i < configs.length; i++) + rows.push({ + "id": configs[i].id, + "name": configs[i].name || I18n.tr("Bar %1").arg(i + 1) + }); + return rows; + } + readonly property int instanceIndex: Math.max(0, root.instanceChoices.findIndex(row => row.id === SettingsData.dankIslandBarId)) + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + + topPadding: 4 + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + SettingsCard { + width: parent.width + iconName: "view_in_ar" + title: I18n.tr("Dank Island - Beta") + settingKey: "dankIslandBarId" + + SettingsButtonGroupRow { + settingKey: "dankIslandBarId" + tags: ["island", "activities", "media", "notifications", "osd", "bar"] + text: I18n.tr("Island Instance") + model: root.instanceChoices.map(row => row.name) + currentIndex: root.instanceIndex + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandBarId", root.instanceChoices[index]?.id ?? ""); + } + } + } + + SettingsCard { + width: parent.width + iconName: "open_with" + title: I18n.tr("Placement") + settingKey: "dankIslandPlacement" + enabled: SettingsData.dankIslandEnabled + + SettingsToggleRow { + settingKey: "dankIslandFloating" + tags: ["island", "placement", "float", "overlay", "exclusive", "reserve"] + text: I18n.tr("Float") + description: I18n.tr("Floats above windows and other content") + checked: SettingsData.dankIslandFloating + onToggled: checked => SettingsData.set("dankIslandFloating", checked) + } + + SettingsToggleRow { + settingKey: "dankIslandUseOverlayLayer" + tags: ["island", "fullscreen", "overlay", "layer"] + text: I18n.tr("Use Overlay Layer", "island layer toggle: use Wayland overlay layer") + description: I18n.tr("Stage the Wayland overlay layer to remain visible over fullscreen apps") + checked: SettingsData.dankIslandUseOverlayLayer + onToggled: checked => SettingsData.set("dankIslandUseOverlayLayer", checked) + } + + SettingsSliderRow { + settingKey: "dankIslandReserveHeight" + tags: ["island", "placement", "reservation", "exclusive", "height"] + text: I18n.tr("Reserved Height") + description: I18n.tr("Space kept clear for compact island and satellites") + unit: "px" + minimum: 36 + maximum: 128 + step: 1 + defaultValue: 40 + value: SettingsData.dankIslandReserveHeight + enabled: !SettingsData.dankIslandFloating + onSliderValueChanged: value => SettingsData.set("dankIslandReserveHeight", value) + } + + SettingsSliderRow { + settingKey: "dankIslandCompactHeight" + tags: ["island", "placement", "compact", "height", "size", "satellite"] + text: I18n.tr("Compact Island Height") + description: I18n.tr("Resize the resting island to align with nearby widgets") + unit: "px" + minimum: 36 + maximum: 72 + step: 1 + defaultValue: 38 + value: SettingsData.dankIslandCompactHeight + onSliderValueChanged: value => SettingsData.set("dankIslandCompactHeight", value) + } + + SettingsSliderRow { + settingKey: "dankIslandOuterGap" + tags: ["island", "placement", "gap", "top", "margin"] + text: I18n.tr("Outer Gap") + description: I18n.tr("Distance between display edge and island") + unit: "px" + minimum: 0 + maximum: 48 + step: 1 + defaultValue: 4 + value: SettingsData.dankIslandOuterGap + onSliderValueChanged: value => SettingsData.set("dankIslandOuterGap", value) + } + + SettingsSliderRow { + settingKey: "dankIslandHorizontalOffset" + tags: ["island", "placement", "horizontal", "offset", "center"] + text: I18n.tr("Horizontal Offset") + description: I18n.tr("Move island and satellites left or right from display center") + unit: "px" + minimum: -600 + maximum: 600 + step: 1 + defaultValue: 0 + value: SettingsData.dankIslandHorizontalOffset + onSliderValueChanged: value => SettingsData.set("dankIslandHorizontalOffset", value) + } + } + + SettingsCard { + id: homeCompactCard + + width: parent.width + iconName: "home" + title: I18n.tr("Home Compact") + settingKey: "dankIslandActivities" + enabled: SettingsData.dankIslandEnabled + + readonly property var _homeSlotValues: ["left", "right", "hidden"] + + function homeSlotIndex(value, fallback) { + const index = _homeSlotValues.indexOf(value); + if (index >= 0) + return index; + return Math.max(0, _homeSlotValues.indexOf(fallback)); + } + + StyledText { + width: parent.width + text: I18n.tr("Choose which shortcuts sit left or right of the clock") + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + SettingsButtonGroupRow { + settingKey: "dankIslandHomeMediaSlot" + tags: ["island", "home", "compact", "media", "launcher", "search", "cava"] + text: I18n.tr("Media / Launcher") + description: I18n.tr("Search when idle, visualizer when media is playing") + model: [I18n.tr("Left"), I18n.tr("Right"), I18n.tr("Hidden")] + currentIndex: homeCompactCard.homeSlotIndex(SettingsData.dankIslandHomeMediaSlot, "left") + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandHomeMediaSlot", homeCompactCard._homeSlotValues[index] ?? "left"); + } + } + + SettingsButtonGroupRow { + settingKey: "dankIslandHomeStatusSlot" + tags: ["island", "home", "compact", "battery", "control center", "tools"] + text: I18n.tr("Battery / Control Center") + description: BatteryService.batteryAvailable ? I18n.tr("Battery gauge opens Control Center") : I18n.tr("Tools icon opens Control Center") + model: [I18n.tr("Left"), I18n.tr("Right"), I18n.tr("Hidden")] + currentIndex: homeCompactCard.homeSlotIndex(SettingsData.dankIslandHomeStatusSlot, "right") + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandHomeStatusSlot", homeCompactCard._homeSlotValues[index] ?? "right"); + } + } + + SettingsButtonGroupRow { + settingKey: "dankIslandHomeWeatherSlot" + tags: ["island", "home", "compact", "weather", "forecast"] + text: I18n.tr("Weather") + description: SettingsData.weatherEnabled ? I18n.tr("Weather icon and temperature open the weather activity") : I18n.tr("Enable weather in Time & Weather to show this shortcut") + model: [I18n.tr("Left"), I18n.tr("Right"), I18n.tr("Hidden")] + currentIndex: homeCompactCard.homeSlotIndex(SettingsData.dankIslandHomeWeatherSlot, "hidden") + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandHomeWeatherSlot", homeCompactCard._homeSlotValues[index] ?? "hidden"); + } + } + + SettingsToggleRow { + settingKey: "dankIslandHomeCompactTight" + tags: ["island", "home", "compact", "narrow", "width", "height", "clock"] + text: I18n.tr("Compact Clock Pill") + description: I18n.tr("Compact home clock pill in both width and height") + checked: SettingsData.dankIslandHomeCompactTight + onToggled: checked => SettingsData.set("dankIslandHomeCompactTight", checked) + } + + Flow { + width: parent.width + spacing: Theme.spacingS + + DankButton { + text: I18n.tr("Launcher Settings") + iconName: "grid_view" + onClicked: { + if (!root.parentModal) + return; + SettingsSearchService.navigateToSection("launcherStyle"); + root.parentModal.showWithTabName("launcher"); + } + } + + DankButton { + text: I18n.tr("Weather Settings") + iconName: "cloud" + onClicked: { + if (!root.parentModal) + return; + SettingsSearchService.navigateToSection("weatherEnabled"); + root.parentModal.showWithTabName("time_weather"); + } + } + } + } + + SettingsCard { + width: parent.width + iconName: "palette" + title: I18n.tr("Appearance") + settingKey: "dankIslandAppearance" + enabled: SettingsData.dankIslandEnabled + + SettingsButtonGroupRow { + settingKey: "dankIslandPalette" + tags: ["island", "appearance", "palette", "surface", "bright", "dim"] + text: I18n.tr("Palette") + model: [I18n.tr("Default"), I18n.tr("Bright"), I18n.tr("Dim")] + currentIndex: SettingsData.dankIslandPalette === "bright" ? 1 : SettingsData.dankIslandPalette === "dim" ? 2 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("dankIslandPalette", index === 1 ? "bright" : index === 2 ? "dim" : "default"); + } + } + + SettingsToggleRow { + settingKey: "dankIslandHighContrast" + tags: ["island", "appearance", "contrast", "accessibility", "outline"] + text: I18n.tr("High Contrast") + description: I18n.tr("Draw an outline and use the highest-contrast surface tone") + checked: SettingsData.dankIslandHighContrast + onToggled: checked => SettingsData.set("dankIslandHighContrast", checked) + } + + SettingsToggleRow { + settingKey: "dankIslandNotificationExpand" + tags: ["island", "notifications", "expand", "arrival", "size"] + text: I18n.tr("Expand Notifications") + description: I18n.tr("Expand notifications by default instead of click or hover") + checked: SettingsData.dankIslandNotificationExpand + onToggled: checked => SettingsData.set("dankIslandNotificationExpand", checked) + } + + SettingsToggleRow { + settingKey: "dankIslandMediaClockVisible" + tags: ["island", "media", "clock", "compact", "time"] + text: I18n.tr("Keep Clock with Media") + description: I18n.tr("Show a clickable clock beside compact media details") + checked: SettingsData.dankIslandMediaClockVisible + onToggled: checked => SettingsData.set("dankIslandMediaClockVisible", checked) + } + + SettingsButtonGroupRow { + settingKey: "dankIslandBatteryStyle" + tags: ["island", "battery", "gauge", "solid", "outline", "appearance"] + text: I18n.tr("Battery Gauge") + description: I18n.tr("Solid material type or Outline") + visible: BatteryService.batteryAvailable + model: [I18n.tr("Solid"), I18n.tr("Outline")] + currentIndex: SettingsData.dankIslandBatteryStyle === "outline" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandBatteryStyle", index === 1 ? "outline" : "solid"); + } + } + } + + SettingsCard { + width: parent.width + iconName: "widgets" + title: I18n.tr("Satellite Widgets") + settingKey: "dankIslandSatellites" + collapsible: true + expanded: true + enabled: SettingsData.dankIslandEnabled + + SettingsToggleRow { + settingKey: "dankIslandSatellitesEnabled" + tags: ["island", "satellite", "widgets", "left", "right"] + text: I18n.tr("Show Satellite Widgets") + description: I18n.tr("Place independent widgets to left and right of island") + checked: SettingsData.dankIslandSatellitesEnabled + onToggled: checked => SettingsData.set("dankIslandSatellitesEnabled", checked) + } + + SettingsButtonGroupRow { + settingKey: "dankIslandSatellitePosition" + tags: ["island", "satellite", "widgets", "position", "edges", "center"] + text: I18n.tr("Placement") + description: I18n.tr("Keep widgets beside the island or align them like a standalone bar") + model: [I18n.tr("Near Island"), I18n.tr("Screen Edges")] + currentIndex: SettingsData.dankIslandSatellitePosition === "edges" ? 1 : 0 + enabled: SettingsData.dankIslandSatellitesEnabled + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandSatellitePosition", index === 1 ? "edges" : "island"); + } + } + + SettingsSliderRow { + settingKey: "dankIslandSatelliteGap" + tags: ["island", "satellite", "widgets", "gap", "spacing"] + text: I18n.tr("Island Gap") + unit: "px" + minimum: 4 + maximum: 48 + step: 1 + defaultValue: 12 + value: SettingsData.dankIslandSatelliteGap + enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatellitePosition !== "edges" + onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteGap", value) + } + + StyledText { + width: parent.width + text: I18n.tr("Satellites widget to place left or right of the island") + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + visible: SettingsData.dankIslandSatellitesEnabled + } + } + + SettingsCard { + width: parent.width + iconName: "touch_app" + title: I18n.tr("Interaction") + settingKey: "dankIslandInteraction" + enabled: SettingsData.dankIslandEnabled + + SettingsButtonGroupRow { + settingKey: "dankIslandInteractionMode" + tags: ["island", "interaction", "click", "hybrid", "expand"] + text: I18n.tr("Expansion Mode") + description: I18n.tr("Click expands only on an intentional press. Hybrid peeks glance faces on hover") + readonly property var _modeValues: ["click", "hybrid"] + model: [I18n.tr("Click"), I18n.tr("Hybrid")] + currentIndex: SettingsData.dankIslandInteractionMode === "click" ? 0 : 1 + onSelectionChanged: (index, selected) => { + if (selected) + SettingsData.set("dankIslandInteractionMode", _modeValues[index] ?? "hybrid"); + } + } + + StyledText { + width: parent.width + text: I18n.tr("Hybrid peeks at the clock, media, system faces, and notifications on hover. Control Center, the launcher, wallpapers, and weather are click actions") + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + visible: SettingsData.dankIslandInteractionMode !== "click" + } + + SettingsSliderRow { + settingKey: "dankIslandHoverOpenDelay" + tags: ["island", "interaction", "hover", "open", "delay"] + text: I18n.tr("Hover Open Delay") + unit: "ms" + minimum: 0 + maximum: 1000 + step: 10 + defaultValue: 220 + value: SettingsData.dankIslandHoverOpenDelay + enabled: SettingsData.dankIslandInteractionMode !== "click" + onSliderValueChanged: value => SettingsData.set("dankIslandHoverOpenDelay", value) + } + + SettingsSliderRow { + settingKey: "dankIslandHoverCloseDelay" + tags: ["island", "interaction", "hover", "close", "delay"] + text: I18n.tr("Hover Close Delay") + unit: "ms" + minimum: 0 + maximum: 1000 + step: 10 + defaultValue: 400 + value: SettingsData.dankIslandHoverCloseDelay + enabled: SettingsData.dankIslandInteractionMode !== "click" + onSliderValueChanged: value => SettingsData.set("dankIslandHoverCloseDelay", value) + } + } + + SettingsCard { + width: parent.width + iconName: "animation" + title: I18n.tr("Motion & Accessibility") + settingKey: "dankIslandMotion" + collapsible: true + enabled: SettingsData.dankIslandEnabled + + SettingsToggleRow { + settingKey: "dankIslandReducedMotion" + text: I18n.tr("Reduce Motion") + description: I18n.tr("Apply island geometry changes immediately without spring overshoot") + checked: SettingsData.dankIslandReducedMotion + onToggled: checked => SettingsData.set("dankIslandReducedMotion", checked) + } + + SettingsSliderRow { + settingKey: "dankIslandSpringStiffness" + text: I18n.tr("Spring Stiffness") + description: I18n.tr("Higher values pull island toward its target more strongly") + minimum: 100 + maximum: 1200 + step: 10 + value: Math.round(SettingsData.dankIslandSpringStiffness) + defaultValue: 560 + enabled: !SettingsData.dankIslandReducedMotion + onSliderValueChanged: value => SettingsData.set("dankIslandSpringStiffness", value) + } + + SettingsSliderRow { + settingKey: "dankIslandSpringDamping" + text: I18n.tr("Spring Damping") + description: I18n.tr("Higher values settle island with less bounce") + minimum: 10 + maximum: 100 + step: 1 + value: Math.round(SettingsData.dankIslandSpringDamping) + defaultValue: 37 + enabled: !SettingsData.dankIslandReducedMotion + onSliderValueChanged: value => SettingsData.set("dankIslandSpringDamping", value) + } + + SettingsSliderRow { + settingKey: "dankIslandSpringMass" + text: I18n.tr("Spring Mass") + description: I18n.tr("Higher percentages give island more inertia") + minimum: 25 + maximum: 300 + step: 5 + unit: "%" + value: Math.round(SettingsData.dankIslandSpringMass * 100) + defaultValue: 100 + enabled: !SettingsData.dankIslandReducedMotion + onSliderValueChanged: value => SettingsData.set("dankIslandSpringMass", value / 100) + } + } + } + } +} diff --git a/quickshell/Modules/Settings/DisplayWidgetsTab.qml b/quickshell/Modules/Settings/DisplayWidgetsTab.qml index 54fea04af..ad4ef8843 100644 --- a/quickshell/Modules/Settings/DisplayWidgetsTab.qml +++ b/quickshell/Modules/Settings/DisplayWidgetsTab.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import qs.Common +import qs.Modules.Settings.Widgets import qs.Services import qs.Widgets @@ -13,13 +14,16 @@ Item { function getBarComponentsFromSettings() { const bars = SettingsData.barConfigs || []; - return bars.map(bar => ({ - "id": "bar:" + bar.id, - "name": bar.name || "Bar", - "description": I18n.tr("Individual bar configuration"), - "icon": "toolbar", - "barId": bar.id - })); + return bars.map(bar => { + const isIsland = SettingsData.isIslandBarConfig(bar); + return { + "id": "bar:" + bar.id, + "name": bar.name || "Bar", + "description": isIsland ? I18n.tr("Dank Island instance") : I18n.tr("Individual bar configuration"), + "icon": isIsland ? "view_in_ar" : "toolbar", + "barId": bar.id + }; + }); } property var variantComponents: getVariantComponentsList() diff --git a/quickshell/Modules/Settings/LauncherTab.qml b/quickshell/Modules/Settings/LauncherTab.qml index b0a86db97..d99eff48a 100644 --- a/quickshell/Modules/Settings/LauncherTab.qml +++ b/quickshell/Modules/Settings/LauncherTab.qml @@ -79,7 +79,7 @@ Item { StyledText { width: parent.width visible: !SettingsData.connectedFrameModeActive - text: SettingsData.launcherStyle === "spotlight" ? I18n.tr("Default launcher shortcuts open the minimal Spotlight Bar. The dedicated Spotlight Bar shortcut below stays independent.") : I18n.tr("Default launcher shortcuts open the full launcher with mode tabs, grid view, and action panel.") + text: SettingsData.launcherStyle === "island" ? I18n.tr("Default launcher shortcuts open a native launcher activity inside Dank Island. Spotlight is used when the focused screen has no DankIsland.") : SettingsData.launcherStyle === "spotlight" ? I18n.tr("Default launcher shortcuts open the minimal Spotlight Bar. The dedicated Spotlight Bar shortcut below stays independent.") : I18n.tr("Default launcher shortcuts open the full launcher with mode tabs, grid view, and action panel.") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -88,14 +88,14 @@ Item { SettingsButtonGroupRow { visible: !SettingsData.connectedFrameModeActive settingKey: "launcherStyleSelector" - tags: ["launcher", "style", "default", "spotlight", "full", "minimal"] + tags: ["launcher", "style", "default", "spotlight", "full", "minimal", "island", "dankisland"] text: I18n.tr("Default Opens") - model: [I18n.tr("Full"), I18n.tr("Spotlight")] - currentIndex: SettingsData.launcherStyle === "spotlight" ? 1 : 0 + model: [I18n.tr("Full"), I18n.tr("Spotlight"), I18n.tr("Island")] + currentIndex: SettingsData.launcherStyle === "island" ? 2 : SettingsData.launcherStyle === "spotlight" ? 1 : 0 onSelectionChanged: (index, selected) => { if (!selected) return; - SettingsData.set("launcherStyle", index === 1 ? "spotlight" : "full"); + SettingsData.set("launcherStyle", index === 2 ? "island" : index === 1 ? "spotlight" : "full"); } } diff --git a/quickshell/Modules/Settings/Widgets/SettingsControlledByIsland.qml b/quickshell/Modules/Settings/Widgets/SettingsControlledByIsland.qml new file mode 100644 index 000000000..7e097aac2 --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/SettingsControlledByIsland.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Widgets + +StyledRect { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + property string settingLabel: "" + property string reason: "" + property var parentModal: null + + width: parent?.width ?? 0 + height: contentRow.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.primary, 0.08) + border.color: Theme.withAlpha(Theme.primary, 0.18) + border.width: 1 + + Row { + id: contentRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingM + + DankIcon { + name: "view_in_ar" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - Theme.iconSize - openButton.width - Theme.spacingM * 2 + spacing: Theme.spacingXXS + + StyledText { + text: root.settingLabel + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + wrapMode: Text.WordWrap + } + + StyledText { + text: root.reason + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.WordWrap + visible: root.reason !== "" + } + } + + DankButton { + id: openButton + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Open Island") + backgroundColor: Theme.primary + textColor: Theme.primaryText + buttonHeight: 32 + horizontalPadding: Theme.spacingM + onClicked: { + if (!root.parentModal) + return; + root.parentModal.showWithTabName("dank_island"); + } + } + } +} diff --git a/quickshell/Modules/Settings/WidgetsTab.qml b/quickshell/Modules/Settings/WidgetsTab.qml index 5e83ad516..df65da7d9 100644 --- a/quickshell/Modules/Settings/WidgetsTab.qml +++ b/quickshell/Modules/Settings/WidgetsTab.qml @@ -24,6 +24,12 @@ Item { return pos === SettingsData.Position.Left || pos === SettingsData.Position.Right; } + readonly property bool dankIslandOwnsSelectedBarCenter: { + SettingsData.dankIslandBarId; + selectedBarId; + return !!selectedBarId && selectedBarId === SettingsData.dankIslandBarId; + } + property bool hasMultipleBars: SettingsData.barConfigs.length > 1 property int pluginCatalogRevision: 0 @@ -423,6 +429,8 @@ Item { } function setWidgetsForSection(sectionId, widgets) { + if (sectionId === "center" && dankIslandOwnsSelectedBarCenter) + return; switch (sectionId) { case "left": SettingsData.updateBarConfig(selectedBarId, { @@ -522,16 +530,7 @@ Item { "id": widget, "enabled": true }; - var result = { - "id": widget.id, - "enabled": widget.enabled - }; - var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "clockDateOrder", "focusedWindowSize", "focusedWindowCompactMode", "focusedWindowShowIcon", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "keyboardLayoutNameShowIcon", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showBatteryPercent", "showBatteryPercentOnlyOnBattery", "showBatteryTime", "showBatteryTimeOnlyOnBattery", "batteryPillStyle", "batteryPillPercentSign", "showPrinterIcon", "showScreenSharingIcon", "showIdleInhibitorIcon", "showDoNotDisturbIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion", "trayPopupSingleLine", "trayAutoOverflow", "trayMaxVisibleItems", "hideWhenIdle"]; - for (var i = 0; i < keys.length; i++) { - if (widget[keys[i]] !== undefined) - result[keys[i]] = widget[keys[i]]; - } - return result; + return Object.assign({}, widget); } function handleItemEnabledChanged(sectionId, itemId, enabled) { @@ -574,6 +573,8 @@ Item { // Move a widget across sections (or within); committed as one atomic bar-config save function moveWidget(fromSection, toSection, movedId, toIndex) { + if (dankIslandOwnsSelectedBarCenter && (fromSection === "center" || toSection === "center")) + return; if (fromSection === toSection) { var arr = getWidgetsForSection(fromSection).slice(); var fi = arr.findIndex(w => (typeof w === "string" ? w : w.id) === movedId); @@ -598,7 +599,7 @@ Item { } function sectionAtY(gy) { - var sections = ["left", "center", "right"]; + var sections = dankIslandOwnsSelectedBarCenter ? ["left", "right"] : ["left", "center", "right"]; var nearest = ""; var nearestDist = Infinity; for (var i = 0; i < sections.length; i++) { @@ -724,7 +725,7 @@ Item { } function moveAcrossSections(sectionId, id, delta) { - var order = ["left", "center", "right"]; + var order = dankIslandOwnsSelectedBarCenter ? ["left", "right"] : ["left", "center", "right"]; var si = order.indexOf(sectionId); var ti = si + delta; if (si < 0 || ti < 0 || ti >= order.length) @@ -1419,9 +1420,10 @@ Item { id: centerSection anchors.fill: parent anchors.margins: Theme.spacingL - title: selectedBarIsVertical ? I18n.tr("Middle Section") : I18n.tr("Center Section") - titleIcon: "format_align_center" + title: widgetsTab.dankIslandOwnsSelectedBarCenter ? I18n.tr("Center Section - Reserved by Dank Island") : (selectedBarIsVertical ? I18n.tr("Middle Section") : I18n.tr("Center Section")) + titleIcon: widgetsTab.dankIslandOwnsSelectedBarCenter ? "lock" : "format_align_center" sectionId: "center" + readOnly: widgetsTab.dankIslandOwnsSelectedBarCenter allWidgets: widgetsTab.baseWidgetDefinitions items: widgetsTab.getItemsForSection("center") onItemEnabledChanged: (sectionId, itemId, enabled) => { diff --git a/quickshell/Modules/Settings/WidgetsTabSection.qml b/quickshell/Modules/Settings/WidgetsTabSection.qml index df653cedc..bf0f694dd 100644 --- a/quickshell/Modules/Settings/WidgetsTabSection.qml +++ b/quickshell/Modules/Settings/WidgetsTabSection.qml @@ -14,6 +14,10 @@ Column { property string title: "" property string titleIcon: "widgets" property string sectionId: "" + property bool readOnly: false + + enabled: !readOnly + opacity: readOnly ? 0.55 : 1.0 DankTooltipV2 { id: sharedTooltip diff --git a/quickshell/Modules/Toast.qml b/quickshell/Modules/Toast.qml index 7ae8001a5..a87fe0d65 100644 --- a/quickshell/Modules/Toast.qml +++ b/quickshell/Modules/Toast.qml @@ -17,7 +17,9 @@ PanelWindow { readonly property real dpr: modelData ? CompositorService.getScreenScale(modelData) : 1 readonly property real shadowBuffer: 5 - readonly property real toastY: Theme.barHeight - 4 + (SettingsData.barConfigs[0]?.spacing ?? 4) + 2 + // Toasts always drop from the top edge, so they clear whichever chrome owns it. + readonly property real islandTopOffset: SettingsData.dankIslandEdgeOffset(modelData, "top") + readonly property real toastY: islandTopOffset > 0 ? islandTopOffset + Theme.spacingS : Theme.barHeight - 4 + (SettingsData.getPrimaryBarConfig()?.spacing ?? 4) + 2 Connections { target: ToastService diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 8611272ca..803476d0c 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -80,7 +80,7 @@ Singleton { function onBarConfigsChanged() { if (!CompositorService.isHyprland) return; - const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)); + const newGaps = Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)); if (newGaps === root._lastGapValue) return; root._lastGapValue = newGaps; @@ -330,7 +330,7 @@ Singleton { layoutGenerationRunning = true; const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; - const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; + const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) : 4; const defaultBorderSize = 2; const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutRadiusOverride >= 0) ? SettingsData.hyprlandLayoutRadiusOverride : defaultRadius; @@ -344,6 +344,8 @@ Singleton { // Hyprland `xray = false` is still early-development; unset already samples real content, so only force xray=true // dms:frame only in separate mode — connected-mode frame blur overlaps windows via popouts/arcs const xrayNamespaces = ["dms:bar"]; + if (typeof SettingsData !== "undefined" && SettingsData.dankIslandEnabled) + xrayNamespaces.push("dms:dankisland"); if (frameEnabled && SettingsData.frameMode !== "connected") xrayNamespaces.push("dms:frame"); diff --git a/quickshell/Services/MangoService.qml b/quickshell/Services/MangoService.qml index 3ee505ddd..ad2d573fc 100644 --- a/quickshell/Services/MangoService.qml +++ b/quickshell/Services/MangoService.qml @@ -525,7 +525,7 @@ Singleton { function onBarConfigsChanged() { if (!CompositorService.isMango) return; - const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)); + const newGaps = Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)); if (newGaps === root._lastGapValue) return; root._lastGapValue = newGaps; @@ -631,7 +631,7 @@ Singleton { return; const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; - const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; + const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) : 4; const defaultBorderSize = 2; const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutRadiusOverride >= 0) ? SettingsData.mangoLayoutRadiusOverride : defaultRadius; diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index 1006596a8..1db4b36c4 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -121,7 +121,7 @@ Singleton { Connections { target: SettingsData function onBarConfigsChanged() { - const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)); + const newGaps = Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)); if (newGaps === root._lastGapValue) return; root._lastGapValue = newGaps; @@ -1203,7 +1203,7 @@ Singleton { log.debug("Generating layout config..."); const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; - const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; + const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.getPrimaryBarConfig()?.spacing ?? 4)) : 4; const defaultBorderSize = 2; const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutRadiusOverride >= 0) ? SettingsData.niriLayoutRadiusOverride : defaultRadius; @@ -1214,6 +1214,8 @@ Singleton { const frameEnabled = typeof SettingsData !== "undefined" && SettingsData.frameEnabled; // dms:frame only in separate mode — connected-mode frame blur overlaps windows via popouts/arcs const excludeNamespaces = ["dms:bar"]; + if (typeof SettingsData !== "undefined" && SettingsData.dankIslandEnabled) + excludeNamespaces.push("dms:dankisland"); if (frameEnabled && SettingsData.frameMode !== "connected") excludeNamespaces.push("dms:frame"); diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 0ae61756a..668ca420b 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -38,6 +38,8 @@ Singleton { readonly property int notificationDedupBurstMs: 5000 property var _recentDedupKeys: [] + signal notificationDeduplicated(var wrapper) + property var _dismissQueue: [] property int _dismissBatchSize: 8 property int _dismissTickMs: 8 @@ -657,8 +659,11 @@ Singleton { const dedupKey = _notificationDedupKey(notif); const duplicate = _findActiveDuplicate(notif); if (duplicate || _hasRecentDuplicate(dedupKey)) { - if (duplicate && duplicate.timer && duplicate.timer.running) - duplicate.timer.restart(); + if (duplicate) { + if (duplicate.timer && duplicate.timer.running) + duplicate.timer.restart(); + root.notificationDeduplicated(duplicate); + } try { notif.dismiss(); } catch (e) {} diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml index 1d42f3b49..125b6d463 100644 --- a/quickshell/Services/PluginService.qml +++ b/quickshell/Services/PluginService.qml @@ -707,7 +707,7 @@ Singleton { return id !== widgetId; } - const defaultBar = SettingsData.barConfigs[0] || SettingsData.getBarConfig("default"); + const defaultBar = SettingsData.getPrimaryBarConfig(); if (!defaultBar) return; const leftWidgets = defaultBar.leftWidgets || []; diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index 869953279..eada1ec7d 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -37,6 +37,7 @@ Singleton { property var clipboardHistoryModal: null property var dankLauncherV2Modal: null property var dankLauncherV2ModalLoader: null + property var dankIslandRouter: null property var spotlightBarModal: null property var spotlightBarModalLoader: null property var powerMenuModal: null @@ -132,7 +133,93 @@ Singleton { } } + function _islandOwnsSharedTrigger(screen) { + const target = screen ?? dankIslandRouter?.focusedIslandScreen?.() ?? null; + if (dankIslandRouter?.hasHostForScreen?.(target) !== true) + return false; + return SettingsData.dankIslandIsSoleBarForScreen(target); + } + + readonly property bool islandControlCenterOpen: dankIslandRouter?.controlCenterOpen ?? false + readonly property bool islandMediaOpen: dankIslandRouter?.mediaOpen ?? false + readonly property bool islandWallpaperOpen: dankIslandRouter?.wallpaperOpen ?? false + readonly property bool islandWeatherOpen: dankIslandRouter?.weatherOpen ?? false + readonly property bool islandHomeOpen: dankIslandRouter?.homeOpen ?? false + readonly property bool islandNotificationCenterOpen: dankIslandRouter?.notificationCenterOpen ?? false + + // Shared triggers use the island only when it owns that screen; false lets callers keep the popout path. + function routeControlCenterToIsland(screen, section, shouldToggle) { + if (!_islandOwnsSharedTrigger(screen)) + return false; + if (shouldToggle === true) + return dankIslandRouter.toggleControlCenter(section || "", screen ?? null); + return dankIslandRouter.openControlCenter(section || "", screen ?? null); + } + + // A missing media session is a deliberate fallback signal, not a handled request. + function routeMediaToIsland(screen, shouldToggle) { + if (!_islandOwnsSharedTrigger(screen)) + return false; + if (shouldToggle === true) + return dankIslandRouter.toggleMedia(screen ?? null) === true; + return dankIslandRouter.openMedia(screen ?? null) === true; + } + + function routeWallpaperToIsland(screen, shouldToggle) { + if (!_islandOwnsSharedTrigger(screen)) + return false; + if (shouldToggle === true) + return dankIslandRouter.toggleWallpaper(screen ?? null) === true; + return dankIslandRouter.openWallpaper(screen ?? null) === true; + } + + function routeWeatherToIsland(screen, shouldToggle) { + if (!_islandOwnsSharedTrigger(screen)) + return false; + if (shouldToggle === true) + return dankIslandRouter.toggleWeather(screen ?? null) === true; + return dankIslandRouter.openWeather(screen ?? null) === true; + } + + function routeOverviewToIsland(screen, shouldToggle) { + if (!_islandOwnsSharedTrigger(screen)) + return false; + if (shouldToggle === true) + return dankIslandRouter.toggleHome(screen ?? null) === true; + return dankIslandRouter.openHome(screen ?? null) === true; + } + + function routeNotificationCenterToIsland(screen, shouldToggle) { + if (!_islandOwnsSharedTrigger(screen)) + return false; + if (shouldToggle === true) + return dankIslandRouter.toggleNotifications(screen ?? null) === true; + return dankIslandRouter.openNotifications(screen ?? null) === true; + } + + function closeIslandNotificationCenter() { + return dankIslandRouter?.closeNotifications?.() === true; + } + + function closeIslandMedia() { + return dankIslandRouter?.closeMedia?.() === true; + } + + function closeIslandWallpaper() { + return dankIslandRouter?.closeWallpaper?.() === true; + } + + function closeIslandWeather() { + return dankIslandRouter?.closeWeather?.() === true; + } + + function closeIslandHome() { + return dankIslandRouter?.closeHome?.() === true; + } + function openControlCenter(x, y, width, section, screen) { + if (_islandOwnsSharedTrigger(screen) && dankIslandRouter.openControlCenter(section || "", screen ?? null)) + return; if (controlCenterPopout) { setPosition(controlCenterPopout, x, y, width, section, screen); controlCenterPopout.open(); @@ -140,6 +227,8 @@ Singleton { } function closeControlCenter() { + if (dankIslandRouter?.closeControlCenter?.() === true) + return; controlCenterPopout?.close(); } @@ -148,6 +237,8 @@ Singleton { } function toggleControlCenter(x, y, width, section, screen) { + if (_islandOwnsSharedTrigger(screen) && dankIslandRouter.toggleControlCenter(section || "", screen ?? null)) + return; if (controlCenterPopout) { setPosition(controlCenterPopout, x, y, width, section, screen); controlCenterPopout.toggle(); @@ -155,6 +246,8 @@ Singleton { } function openNotificationCenter(x, y, width, section, screen) { + if (routeNotificationCenterToIsland(screen, false)) + return; if (notificationCenterPopout) { setPosition(notificationCenterPopout, x, y, width, section, screen); notificationCenterPopout.open(); @@ -162,6 +255,8 @@ Singleton { } function closeNotificationCenter() { + if (closeIslandNotificationCenter()) + return; notificationCenterPopout?.close(); } @@ -170,6 +265,8 @@ Singleton { } function toggleNotificationCenter(x, y, width, section, screen) { + if (routeNotificationCenterToIsland(screen, true)) + return; if (notificationCenterPopout) { setPosition(notificationCenterPopout, x, y, width, section, screen); notificationCenterPopout.toggle(); diff --git a/quickshell/ShellCore.qml b/quickshell/ShellCore.qml index 94c939a4a..f692547b2 100644 --- a/quickshell/ShellCore.qml +++ b/quickshell/ShellCore.qml @@ -23,7 +23,7 @@ Item { property string _barLayoutStateJson: { if (!barSurfacesLoaded) return "[]"; - const configs = SettingsData.barConfigs; + const configs = SettingsData.getBarKindConfigs(); const mapped = configs.map(c => ({ id: c.id, position: c.position, @@ -117,7 +117,7 @@ Item { property var hyprlandOverviewLoaderRef: hyprlandOverviewLoader // Horizontal bars must claim their exclusive zones first, so vertical bars wait for every enabled horizontal bar to load - readonly property int horizontalWanted: SettingsData.barConfigs.filter(c => (c.enabled ?? false) && c.position !== SettingsData.Position.Left && c.position !== SettingsData.Position.Right).length + readonly property int horizontalWanted: SettingsData.getBarKindConfigs().filter(c => (c.enabled ?? false) && c.position !== SettingsData.Position.Left && c.position !== SettingsData.Position.Right).length property int horizontalReady: 0 function recountHorizontalReady() { diff --git a/quickshell/Widgets/BackdropBlur.qml b/quickshell/Widgets/BackdropBlur.qml index c4450a1f4..a082aeed0 100644 --- a/quickshell/Widgets/BackdropBlur.qml +++ b/quickshell/Widgets/BackdropBlur.qml @@ -45,6 +45,6 @@ Item { anchors.fill: parent radius: root.radius visible: false - layer.enabled: true + layer.enabled: root.blurActive } } diff --git a/quickshell/Widgets/DankOSD.qml b/quickshell/Widgets/DankOSD.qml index dfc37a3fc..9741667a7 100644 --- a/quickshell/Widgets/DankOSD.qml +++ b/quickshell/Widgets/DankOSD.qml @@ -130,11 +130,13 @@ PanelWindow { const configs = SettingsData.barConfigs; if (!screen || !configs) return offsets; - const defaultBar = configs[0] || SettingsData.getBarConfig("default"); + const defaultBar = SettingsData.getPrimaryBarConfig(); for (var i = 0; i < configs.length; i++) { const bc = configs[i]; if (!bc || !(bc.enabled ?? true) || !(bc.visible ?? true)) continue; + if (SettingsData.isIslandBarConfig(bc)) + continue; if (!SettingsData.barConfigCoversScreen(bc, screen)) continue; const innerPadding = bc.innerPadding ?? (defaultBar?.innerPadding ?? 4); @@ -158,6 +160,9 @@ PanelWindow { break; } } + // Legacy OSDs still instantiate on island screens, so the strip has to be reserved for them. + offsets.top = Math.max(offsets.top, SettingsData.dankIslandEdgeOffset(screen, "top")); + offsets.bottom = Math.max(offsets.bottom, SettingsData.dankIslandEdgeOffset(screen, "bottom")); return offsets; } diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 357da0799..aed35c5bd 100755 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -86,6 +86,7 @@ CATEGORY_KEYWORDS = { "Greeter": ["login", "greetd", "display manager"], "Multiplexers": ["tmux", "zellij", "terminal"], "Frame": ["window", "border", "decoration"], + "Dank Island": ["island", "activities", "dynamic island"], "Default Apps": ["browser", "terminal", "handlers", "mime"], "Users": ["accounts", "user", "profile"], "Autostart": ["startup", "launch", "boot"], @@ -96,6 +97,7 @@ TAB_INDEX_MAP = { "TimeWeatherTab.qml": 1, "KeybindsTab.qml": 2, "DankBarTab.qml": 3, + "DankIslandTab.qml": 46, "WorkspacesTab.qml": 4, "CompositorLayoutTab.qml": 37, "WindowRulesTab.qml": 38, @@ -191,6 +193,7 @@ TAB_CATEGORY_MAP = { 43: "Dank Dash", 44: "System", 45: "System", + 46: "Dank Island", } SEARCHABLE_COMPONENTS = [ diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index d553456f3..90b5e80f5 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -903,16 +903,16 @@ }, { "section": "barEnable", - "label": "Enable Bar", + "label": "Enable Island", "tabIndex": 3, "category": "Dank Bar", "keywords": [ "bar", "dank", "enable", + "island", "panel", "statusbar", - "taskbar", "topbar" ] }, @@ -2581,6 +2581,22 @@ ], "icon": "opacity" }, + { + "section": "_tab_6", + "label": "Primary Bar", + "tabIndex": 6, + "category": "Dank Bar", + "keywords": [ + "bar", + "dank", + "panel", + "primary", + "statusbar", + "taskbar", + "topbar" + ], + "icon": "toolbar" + }, { "section": "barShadow", "label": "Shadow Override", @@ -2866,9 +2882,11 @@ "app drawer", "app menu", "applications", + "dankisland", "default", "drawer", "full", + "island", "launcher", "menu", "minimal", @@ -2885,9 +2903,11 @@ "tabIndex": 9, "category": "Launcher", "keywords": [ + "dankisland", "default", "drawer", "full", + "island", "launcher", "menu", "minimal", @@ -11894,5 +11914,743 @@ ], "description": "Choose a shortcut key to cycle between keyboard layouts", "conditionKey": "isNiri" + }, + { + "section": "dankIslandAppearance", + "label": "Appearance", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "appearance", + "bright", + "contrast", + "dank", + "dim", + "draw", + "dynamic island", + "highest", + "island", + "outline", + "palette", + "surface", + "tone" + ], + "icon": "palette", + "description": "Draw an outline and use the highest-contrast surface tone" + }, + { + "section": "dankIslandHomeStatusSlot", + "label": "Battery / Control Center", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "battery", + "center", + "charge", + "charging", + "compact", + "control", + "control center", + "dank", + "dynamic island", + "gauge", + "home", + "island", + "opens", + "power", + "tools" + ], + "description": "Battery gauge opens Control Center" + }, + { + "section": "dankIslandBatteryStyle", + "label": "Battery Gauge", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "appearance", + "battery", + "charge", + "charging", + "dank", + "dynamic island", + "gauge", + "island", + "material", + "outline", + "power", + "solid", + "type" + ], + "description": "Solid material type or Outline" + }, + { + "section": "dankIslandHomeCompactTight", + "label": "Compact Clock Pill", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "both", + "clock", + "compact", + "dank", + "dynamic island", + "height", + "home", + "island", + "narrow", + "pill", + "time", + "watch", + "width" + ], + "description": "Compact home clock pill in both width and height" + }, + { + "section": "dankIslandCompactHeight", + "label": "Compact Island Height", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "align", + "compact", + "components", + "dank", + "dynamic island", + "height", + "island", + "modules", + "nearby", + "placement", + "resize", + "resting", + "satellite", + "size", + "widgets" + ], + "description": "Resize the resting island to align with nearby widgets" + }, + { + "section": "dankIslandBarId", + "label": "Dank Island - Beta", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "bar", + "beta", + "dank", + "dynamic island", + "island", + "media", + "notifications", + "osd" + ], + "icon": "view_in_ar" + }, + { + "section": "dankIslandNotificationExpand", + "label": "Expand Notifications", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "alert", + "alerts", + "arrival", + "click", + "dank", + "default", + "dynamic island", + "expand", + "hover", + "island", + "notif", + "notifications", + "notifs", + "size" + ], + "description": "Expand notifications by default instead of click or hover" + }, + { + "section": "dankIslandInteractionMode", + "label": "Expansion Mode", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "click", + "dank", + "dynamic island", + "expand", + "expands", + "expansion", + "faces", + "glance", + "hover", + "hybrid", + "intentional", + "interaction", + "island", + "mode", + "peeks", + "press" + ], + "description": "Click expands only on an intentional press. Hybrid peeks glance faces on hover" + }, + { + "section": "dankIslandFloating", + "label": "Float", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "above", + "activities", + "content", + "dank", + "dynamic island", + "exclusive", + "float", + "floats", + "island", + "overlay", + "placement", + "reserve", + "windows" + ], + "description": "Floats above windows and other content" + }, + { + "section": "dankIslandHighContrast", + "label": "High Contrast", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "accessibility", + "activities", + "appearance", + "contrast", + "dank", + "draw", + "dynamic island", + "high", + "highest", + "island", + "outline", + "surface", + "tone" + ], + "description": "Draw an outline and use the highest-contrast surface tone" + }, + { + "section": "dankIslandActivities", + "label": "Home Compact", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "afk", + "cava", + "compact", + "dank", + "dynamic island", + "home", + "idle", + "inactive", + "island", + "launcher", + "media", + "playing", + "screensaver", + "search", + "timeout", + "visualizer" + ], + "icon": "home", + "description": "Search when idle, visualizer when media is playing" + }, + { + "section": "dankIslandHorizontalOffset", + "label": "Horizontal Offset", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "center", + "dank", + "display", + "dynamic island", + "horizontal", + "island", + "left", + "monitor", + "move", + "offset", + "output", + "placement", + "right", + "satellites", + "screen" + ], + "description": "Move island and satellites left or right from display center" + }, + { + "section": "dankIslandHoverCloseDelay", + "label": "Hover Close Delay", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "close", + "dank", + "delay", + "dynamic island", + "hover", + "interaction", + "island" + ] + }, + { + "section": "dankIslandHoverOpenDelay", + "label": "Hover Open Delay", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "dank", + "delay", + "dynamic island", + "hover", + "interaction", + "island", + "open" + ] + }, + { + "section": "dankIslandInteraction", + "label": "Interaction", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "click", + "dank", + "dynamic island", + "expand", + "expands", + "faces", + "glance", + "hover", + "hybrid", + "intentional", + "interaction", + "island", + "peeks", + "press" + ], + "icon": "touch_app", + "description": "Click expands only on an intentional press. Hybrid peeks glance faces on hover" + }, + { + "section": "_tab_46", + "label": "Island", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "dank", + "dynamic island", + "island" + ], + "icon": "view_in_ar" + }, + { + "section": "dankIslandSatelliteGap", + "label": "Island Gap", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "dank", + "dynamic island", + "gap", + "island", + "satellite", + "spacing", + "widgets" + ] + }, + { + "section": "dankIslandMediaClockVisible", + "label": "Keep Clock with Media", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "beside", + "clickable", + "clock", + "compact", + "dank", + "details", + "dynamic island", + "island", + "keep", + "media", + "show", + "time", + "watch" + ], + "description": "Show a clickable clock beside compact media details" + }, + { + "section": "dankIslandHomeMediaSlot", + "label": "Media / Launcher", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "afk", + "app drawer", + "app menu", + "applications", + "cava", + "compact", + "dank", + "dynamic island", + "home", + "idle", + "inactive", + "island", + "launcher", + "media", + "playing", + "screensaver", + "search", + "start menu", + "timeout", + "visualizer" + ], + "description": "Search when idle, visualizer when media is playing" + }, + { + "section": "dankIslandMotion", + "label": "Motion & Accessibility", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "accessibility", + "activities", + "apply", + "changes", + "dank", + "dynamic island", + "geometry", + "immediately", + "island", + "motion", + "overshoot", + "spring", + "without" + ], + "icon": "animation", + "description": "Apply island geometry changes immediately without spring overshoot" + }, + { + "section": "dankIslandOuterGap", + "label": "Outer Gap", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "between", + "dank", + "display", + "distance", + "dynamic island", + "edge", + "gap", + "island", + "margin", + "monitor", + "outer", + "output", + "placement", + "screen", + "top" + ], + "description": "Distance between display edge and island" + }, + { + "section": "dankIslandPalette", + "label": "Palette", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "appearance", + "bright", + "dank", + "dim", + "dynamic island", + "island", + "palette", + "surface" + ] + }, + { + "section": "dankIslandPlacement", + "label": "Placement", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "above", + "activities", + "content", + "dank", + "dynamic island", + "exclusive", + "float", + "floats", + "island", + "overlay", + "placement", + "reserve", + "windows" + ], + "icon": "open_with", + "description": "Floats above windows and other content" + }, + { + "section": "dankIslandSatellitePosition", + "label": "Placement", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "align", + "beside", + "center", + "components", + "dank", + "dynamic island", + "edges", + "island", + "keep", + "modules", + "panel", + "placement", + "position", + "satellite", + "standalone", + "statusbar", + "taskbar", + "topbar", + "widgets" + ], + "description": "Keep widgets beside the island or align them like a standalone bar" + }, + { + "section": "dankIslandReducedMotion", + "label": "Reduce Motion", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "apply", + "changes", + "dank", + "dynamic island", + "geometry", + "immediately", + "island", + "motion", + "overshoot", + "reduce", + "spring", + "without" + ], + "description": "Apply island geometry changes immediately without spring overshoot" + }, + { + "section": "dankIslandReserveHeight", + "label": "Reserved Height", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "clear", + "compact", + "dank", + "dynamic island", + "exclusive", + "height", + "island", + "kept", + "placement", + "reservation", + "reserved", + "satellites", + "space" + ], + "description": "Space kept clear for compact island and satellites" + }, + { + "section": "dankIslandSatellites", + "label": "Satellite Widgets", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "components", + "dank", + "dynamic island", + "independent", + "island", + "left", + "modules", + "place", + "right", + "satellite", + "widgets" + ], + "icon": "widgets", + "description": "Place independent widgets to left and right of island" + }, + { + "section": "dankIslandSatellitesEnabled", + "label": "Show Satellite Widgets", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "components", + "dank", + "dynamic island", + "independent", + "island", + "left", + "modules", + "place", + "right", + "satellite", + "show", + "widgets" + ], + "description": "Place independent widgets to left and right of island" + }, + { + "section": "dankIslandSpringDamping", + "label": "Spring Damping", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "bounce", + "damping", + "dank", + "dynamic island", + "higher", + "island", + "less", + "settle", + "spring", + "values" + ], + "description": "Higher values settle island with less bounce" + }, + { + "section": "dankIslandSpringMass", + "label": "Spring Mass", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "dank", + "dynamic island", + "give", + "higher", + "inertia", + "island", + "mass", + "more", + "percentages", + "spring" + ], + "description": "Higher percentages give island more inertia" + }, + { + "section": "dankIslandSpringStiffness", + "label": "Spring Stiffness", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "dank", + "dynamic island", + "higher", + "island", + "more", + "pull", + "spring", + "stiffness", + "strongly", + "target", + "toward", + "values" + ], + "description": "Higher values pull island toward its target more strongly" + }, + { + "section": "dankIslandUseOverlayLayer", + "label": "Use Overlay Layer", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "apps", + "dank", + "dynamic island", + "fullscreen", + "island", + "layer", + "over", + "overlay", + "remain", + "stage", + "visible", + "wayland" + ], + "description": "Stage the Wayland overlay layer to remain visible over fullscreen apps" + }, + { + "section": "dankIslandHomeWeatherSlot", + "label": "Weather", + "tabIndex": 46, + "category": "Dank Island", + "keywords": [ + "activities", + "activity", + "celsius", + "climate", + "compact", + "dank", + "dynamic island", + "fahrenheit", + "forecast", + "home", + "icon", + "island", + "open", + "temp", + "temperature", + "weather" + ], + "description": "Weather icon and temperature open the weather activity" } ]