1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-12 07:19:41 -04:00

Compare commits

...

29 Commits

Author SHA1 Message Date
purian23
62bf9c6efe Add Directional Motion options 2026-03-04 10:14:00 -05:00
purian23
61a77bd186 Initial staging for Animation & Motion effects 2026-03-03 20:02:32 -05:00
Michael Erdely
e04c919d78 Not everyone uses paru or yay on Arch: Support pacman command (#1900)
* Not everyone uses paru or yay on Arch: Support pacman command
* Handle sudo properly when using pacman
* Move pacman to bottom per Purian23
* Remote duplicate which -- thanks Purian23!
2026-03-03 17:27:31 -05:00
Triệu Kha
246b6c44b0 fix(dock): Dock flickering when having cursor floating by the side (#1897) 2026-03-03 16:11:06 -05:00
Lucas
847ddf7d38 ipc: update DankBar selection (#1894)
* ipc: update DankBar selection

* ipc: use getPreferredBar in dash open

* ipc: don't toggle dash on dash open
2026-03-02 22:07:40 -05:00
Triệu Kha
16e8199f9e fix(osd): play/pause icon flipped in MediaPlaybackOSD (#1889) 2026-03-02 22:01:08 -05:00
purian23
7d1519f546 fix(dbar): Fixes autohide + click through edge case 2026-03-01 20:54:05 -05:00
purian23
1bf66ee482 fix(notifications): Allow duplicate history entry management w/unique IDs & source tracking 2026-03-01 19:39:00 -05:00
purian23
39a43f4de5 feat: Reintroduce app filters in v2 launcher 2026-03-01 18:34:13 -05:00
purian23
971a511edb fix(notifications): Apply appIdSubs to iconFrImage fallback path
- Consistent with the
appIcon PR changes in #1880.
2026-03-01 17:37:21 -05:00
odt
0f8e0bc2b4 refactor(icons): centralize icon resolution into Paths.resolveIconPath/resolveIconUrl (#1880)
Supersedes #1878. Rather than duplicating the moddedAppId + file path
substitution pattern inline across 8 files, this introduces two
centralized functions in Paths.qml:

- resolveIconPath(iconName): for Quickshell.iconPath() callsites,
  with DesktopService.resolveIconPath() fallback
- resolveIconUrl(iconName): for image://icon/ URL callsites

All consumer files now use one-line calls. When no substitutions are
configured, moddedAppId() returns the original name unchanged (zero
cost), so this has no impact on users who don't use the feature.

Affected components:
- AppIconRenderer (8 lines → 1)
- NotificationCard, NotificationPopup, HistoryNotificationCard
- DockContextMenu, AppsDockContextMenu
- LauncherContent, LauncherTab (×3)

Co-authored-by: odtgit <odtgit@taliops.com>
2026-03-01 17:31:51 -05:00
supposede
537c44e354 Update toolbar button styles with primary color (#1879) 2026-03-01 16:51:40 -05:00
bbedward
db53a9a719 i18n: decouple time and language locale
fixes #1876
2026-03-01 15:17:34 -05:00
odt
f4a10de790 fix(icons): apply file path substitutions in launcher icon resolution (#1877)
Follow-up to #1867. The launcher's AppIconRenderer used its own
Quickshell.iconPath() call without going through appIdSubstitutions,
so PWA icons configured via regex file path rules were not resolved
in the app launcher.

Co-authored-by: odtgit <odtgit@taliops.com>
2026-03-01 15:03:28 -05:00
bbedward
8c9fe84d02 wallpaper: bump render settle timer 2026-03-01 10:26:46 -05:00
purian23
f0fcc77bdb feat: Implement M3 design elevation & shadow effects
- Added global toggles in the Themes tab
- Light color & directional user ovverides
- Independent shadow overrides per/bar
- Refactored various components to sync the updated designs
2026-03-01 00:54:31 -05:00
purian23
cf4c4b7d69 clipboard: Fix thumbnail load & modal bottom margin 2026-03-01 00:45:38 -05:00
bbedward
7bb8499353 time: add system default option to first day of week dropdown 2026-02-28 20:40:32 -05:00
Jonas Bloch
ee1a2bc7de feat: add setting for first day of the week (#1854)
* feat: add setting for first day of the week

* fix: extract settings indices

* fix: formatting mistake

* fix(ui): add outline rectangle between settings and reorder settings

* fix: don't set firstDayOfWeek automatically to system's locale
2026-02-28 20:37:16 -05:00
Giorgio De Trane
20d383d4ab feat(cups): add manual printer addition by IP/hostname (#1868)
Add a new "Add by Address" flow in the printer settings that allows
users to manually add printers by IP address or hostname, enabling
printing to devices not visible via mDNS/Avahi discovery (e.g.,
printers behind Tailscale subnet routers, VPNs, or across network
boundaries).

Go backend:
- New cups.testConnection IPC method that probes remote printers via
  IPP Get-Printer-Attributes with /ipp/print then / fallback
- Input validation with host sanitization and protocol allowlist
- Auth-aware probing (HTTP 401/403 reported as reachable)
- lpadmin CLI fallback for CreatePrinter/DeletePrinter when
  cups-pk-helper polkit authorization fails

QML frontend:
- "Add by Address" toggle alongside existing device discovery
- Manual entry form with host, port, protocol fields
- Test Connection button with loading state and result display
- Smart PPD auto-selection by probed makeModel with driverless fallback
- All strings use I18n.tr() with translator context

Includes 20+ unit tests covering validation, probe delegation, TLS
flag propagation, auth error detection, and handler routing.
2026-02-28 20:36:16 -05:00
odt
9cb0d8baf2 feat(icons): support file path substitutions in getAppIcon (#1867)
Allow appIdSubstitutions to return absolute file paths (/, ~, file://)
that bypass Quickshell.iconPath theme lookup. This enables users to map
app IDs directly to icon files on disk via the existing substitution UI.

Fixes PWA icon resolution for Chrome, Chromium and Edge PWAs where
Qt's icon theme lookup fails to find icons installed to
~/.local/share/icons/hicolor/ by the browser.

Example substitutions (Settings → Running Apps → App ID Substitutions):

  ^msedge-_(.+)$ → ~/.local/share/icons/hicolor/128x128/apps/msedge-$1.png
  ^(chrome|msedge|chromium)-(.+)$ → ~/.local/share/icons/hicolor/128x128/apps/$1-$2.png

Tested with Chrome PWAs (YouTube, Twitch, ai-ta) and Edge PWAs
(Microsoft Teams, Outlook) on niri/Wayland.

Co-authored-by: odtgit <odtgit@taliops.com>
2026-02-28 15:41:28 -05:00
bbedward
362ded3bc9 blurred wallpaper: defer update disabling much longer 2026-02-28 15:39:57 -05:00
bbedward
654f2ec7ad wallpaper: defer updatesEnabled binding 2026-02-28 01:10:04 -05:00
bbedward
3600e034b8 weather: fix geoclue IP fallback 2026-02-28 00:07:04 -05:00
İlkecan Bozdoğan
d7c501e175 nix: add package option for dms-shell (#1864)
... to make it configurable.
2026-02-27 23:07:01 -05:00
bbedward
b9e9da579f weather: fix fallback temporarily 2026-02-27 22:37:10 -05:00
Sunner
7bea6b4a62 Add GeoClue2 integration as alternative to IP location (#1856)
* feat: switch auto location in weather widget to use GeoClue2 instead of simple IP check

* nix: enable GeoClue2 service by default

* lint: fix line endings

* fix: fall back to IP location if GeoClue is not available
2026-02-27 22:29:08 -05:00
bbedward
ab211266a6 loginctl: add fallbacks for session discovery 2026-02-27 10:00:41 -05:00
Iris
4da22a4345 Change IsPluggedIn logic (#1859)
Co-authored-by: Iris <iris@raidev.eu>
2026-02-27 09:45:52 -05:00
105 changed files with 6308 additions and 1354 deletions

View File

@@ -28,6 +28,12 @@ packages:
outpkg: mocks_brightness outpkg: mocks_brightness
interfaces: interfaces:
DBusConn: DBusConn:
github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation:
config:
dir: "internal/mocks/geolocation"
outpkg: mocks_geolocation
interfaces:
Client:
github.com/AvengeMedia/DankMaterialShell/core/internal/server/network: github.com/AvengeMedia/DankMaterialShell/core/internal/server/network:
config: config:
dir: "internal/mocks/network" dir: "internal/mocks/network"

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/golangci/golangci-lint - repo: https://github.com/golangci/golangci-lint
rev: v2.9.0 rev: v2.10.1
hooks: hooks:
- id: golangci-lint-fmt - id: golangci-lint-fmt
require_serial: true require_serial: true

View File

@@ -0,0 +1,42 @@
package geolocation
import "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
func NewClient() Client {
geoclueClient, err := newGeoClueClient()
if err != nil {
log.Warnf("GeoClue2 unavailable: %v", err)
return newSeededIpClient()
}
loc, _ := geoclueClient.GetLocation()
if loc.Latitude != 0 || loc.Longitude != 0 {
log.Info("Using GeoClue2 location")
return geoclueClient
}
log.Info("GeoClue2 has no fix yet, seeding with IP location")
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location seed failed: %v", err)
return geoclueClient
}
log.Info("Seeded GeoClue2 with IP location")
geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude})
return geoclueClient
}
func newSeededIpClient() *IpClient {
client := newIpClient()
ipLoc, err := fetchIPLocation()
if err != nil {
log.Warnf("IP location also failed: %v", err)
return client
}
log.Info("Using IP location")
client.currLocation.Latitude = ipLoc.Latitude
client.currLocation.Longitude = ipLoc.Longitude
return client
}

View File

@@ -0,0 +1,243 @@
package geolocation
import (
"fmt"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5"
)
const (
dbusGeoClueService = "org.freedesktop.GeoClue2"
dbusGeoCluePath = "/org/freedesktop/GeoClue2"
dbusGeoClueInterface = dbusGeoClueService
dbusGeoClueManagerPath = dbusGeoCluePath + "/Manager"
dbusGeoClueManagerInterface = dbusGeoClueInterface + ".Manager"
dbusGeoClueManagerGetClient = dbusGeoClueManagerInterface + ".GetClient"
dbusGeoClueClientInterface = dbusGeoClueInterface + ".Client"
dbusGeoClueClientDesktopId = dbusGeoClueClientInterface + ".DesktopId"
dbusGeoClueClientTimeThreshold = dbusGeoClueClientInterface + ".TimeThreshold"
dbusGeoClueClientTimeStart = dbusGeoClueClientInterface + ".Start"
dbusGeoClueClientTimeStop = dbusGeoClueClientInterface + ".Stop"
dbusGeoClueClientLocationUpdated = dbusGeoClueClientInterface + ".LocationUpdated"
dbusGeoClueLocationInterface = dbusGeoClueInterface + ".Location"
dbusGeoClueLocationLatitude = dbusGeoClueLocationInterface + ".Latitude"
dbusGeoClueLocationLongitude = dbusGeoClueLocationInterface + ".Longitude"
)
type GeoClueClient struct {
currLocation *Location
locationMutex sync.RWMutex
dbusConn *dbus.Conn
clientPath dbus.ObjectPath
signals chan *dbus.Signal
stopChan chan struct{}
sigWG sync.WaitGroup
subscribers syncmap.Map[string, chan Location]
}
func newGeoClueClient() (*GeoClueClient, error) {
dbusConn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, fmt.Errorf("system bus connection failed: %w", err)
}
c := &GeoClueClient{
dbusConn: dbusConn,
stopChan: make(chan struct{}),
signals: make(chan *dbus.Signal, 256),
currLocation: &Location{
Latitude: 0.0,
Longitude: 0.0,
},
}
if err := c.setupClient(); err != nil {
dbusConn.Close()
return nil, err
}
if err := c.startSignalPump(); err != nil {
return nil, err
}
return c, nil
}
func (c *GeoClueClient) Close() {
close(c.stopChan)
c.sigWG.Wait()
if c.signals != nil {
c.dbusConn.RemoveSignal(c.signals)
close(c.signals)
}
c.subscribers.Range(func(key string, ch chan Location) bool {
close(ch)
c.subscribers.Delete(key)
return true
})
if c.dbusConn != nil {
c.dbusConn.Close()
}
}
func (c *GeoClueClient) Subscribe(id string) chan Location {
ch := make(chan Location, 64)
c.subscribers.Store(id, ch)
return ch
}
func (c *GeoClueClient) Unsubscribe(id string) {
if ch, ok := c.subscribers.LoadAndDelete(id); ok {
close(ch)
}
}
func (c *GeoClueClient) setupClient() error {
managerObj := c.dbusConn.Object(dbusGeoClueService, dbusGeoClueManagerPath)
if err := managerObj.Call(dbusGeoClueManagerGetClient, 0).Store(&c.clientPath); err != nil {
return fmt.Errorf("failed to create GeoClue2 client: %w", err)
}
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
if err := clientObj.SetProperty(dbusGeoClueClientDesktopId, "dms"); err != nil {
return fmt.Errorf("failed to set desktop ID: %w", err)
}
if err := clientObj.SetProperty(dbusGeoClueClientTimeThreshold, uint(10)); err != nil {
return fmt.Errorf("failed to set time threshold: %w", err)
}
return nil
}
func (c *GeoClueClient) startSignalPump() error {
c.dbusConn.Signal(c.signals)
if err := c.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(c.clientPath),
dbus.WithMatchInterface(dbusGeoClueClientInterface),
dbus.WithMatchSender(dbusGeoClueClientLocationUpdated),
); err != nil {
return err
}
c.sigWG.Add(1)
go func() {
defer c.sigWG.Done()
clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath)
clientObj.Call(dbusGeoClueClientTimeStart, 0)
defer clientObj.Call(dbusGeoClueClientTimeStop, 0)
for {
select {
case <-c.stopChan:
return
case sig, ok := <-c.signals:
if !ok {
return
}
if sig == nil {
continue
}
c.handleSignal(sig)
}
}
}()
return nil
}
func (c *GeoClueClient) handleSignal(sig *dbus.Signal) {
switch sig.Name {
case dbusGeoClueClientLocationUpdated:
if len(sig.Body) != 2 {
return
}
newLocationPath, ok := sig.Body[1].(dbus.ObjectPath)
if !ok {
return
}
if err := c.handleLocationUpdated(newLocationPath); err != nil {
log.Warn("GeoClue: Failed to handle location update: %v", err)
return
}
}
}
func (c *GeoClueClient) handleLocationUpdated(path dbus.ObjectPath) error {
locationObj := c.dbusConn.Object(dbusGeoClueService, path)
lat, err := locationObj.GetProperty(dbusGeoClueLocationLatitude)
if err != nil {
return err
}
long, err := locationObj.GetProperty(dbusGeoClueLocationLongitude)
if err != nil {
return err
}
c.locationMutex.Lock()
c.currLocation.Latitude = dbusutil.AsOr(lat, 0.0)
c.currLocation.Longitude = dbusutil.AsOr(long, 0.0)
c.locationMutex.Unlock()
c.notifySubscribers()
return nil
}
func (c *GeoClueClient) notifySubscribers() {
currentLocation, err := c.GetLocation()
if err != nil {
return
}
c.subscribers.Range(func(key string, ch chan Location) bool {
select {
case ch <- currentLocation:
default:
log.Warn("GeoClue: subscriber channel full, dropping update")
}
return true
})
}
func (c *GeoClueClient) SeedLocation(loc Location) {
c.locationMutex.Lock()
defer c.locationMutex.Unlock()
c.currLocation.Latitude = loc.Latitude
c.currLocation.Longitude = loc.Longitude
}
func (c *GeoClueClient) GetLocation() (Location, error) {
c.locationMutex.RLock()
defer c.locationMutex.RUnlock()
if c.currLocation == nil {
return Location{
Latitude: 0.0,
Longitude: 0.0,
}, nil
}
stateCopy := *c.currLocation
return stateCopy, nil
}

View File

@@ -0,0 +1,91 @@
package geolocation
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type IpClient struct {
currLocation *Location
}
type ipLocationResult struct {
Location
City string
}
type ipAPIResponse struct {
Status string `json:"status"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
City string `json:"city"`
}
func newIpClient() *IpClient {
return &IpClient{
currLocation: &Location{},
}
}
func (c *IpClient) Subscribe(id string) chan Location {
ch := make(chan Location, 1)
if location, err := c.GetLocation(); err == nil {
ch <- location
}
return ch
}
func (c *IpClient) Unsubscribe(id string) {}
func (c *IpClient) Close() {}
func (c *IpClient) GetLocation() (Location, error) {
if c.currLocation.Latitude != 0 || c.currLocation.Longitude != 0 {
return *c.currLocation, nil
}
result, err := fetchIPLocation()
if err != nil {
return Location{}, err
}
c.currLocation.Latitude = result.Latitude
c.currLocation.Longitude = result.Longitude
return *c.currLocation, nil
}
func fetchIPLocation() (ipLocationResult, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("http://ip-api.com/json/")
if err != nil {
return ipLocationResult{}, fmt.Errorf("failed to fetch IP location: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ipLocationResult{}, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return ipLocationResult{}, fmt.Errorf("failed to read response: %w", err)
}
var data ipAPIResponse
if err := json.Unmarshal(body, &data); err != nil {
return ipLocationResult{}, fmt.Errorf("failed to parse response: %w", err)
}
if data.Status == "fail" || (data.Lat == 0 && data.Lon == 0) {
return ipLocationResult{}, fmt.Errorf("ip-api.com returned no location data")
}
return ipLocationResult{
Location: Location{Latitude: data.Lat, Longitude: data.Lon},
City: data.City,
}, nil
}

View File

@@ -0,0 +1,15 @@
package geolocation
type Location struct {
Latitude float64
Longitude float64
}
type Client interface {
GetLocation() (Location, error)
Subscribe(id string) chan Location
Unsubscribe(id string)
Close()
}

View File

@@ -0,0 +1,203 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_geolocation
import (
geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
mock "github.com/stretchr/testify/mock"
)
// MockClient is an autogenerated mock type for the Client type
type MockClient struct {
mock.Mock
}
type MockClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// Close provides a mock function with no fields
func (_m *MockClient) Close() {
_m.Called()
}
// MockClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
type MockClient_Close_Call struct {
*mock.Call
}
// Close is a helper method to define mock.On call
func (_e *MockClient_Expecter) Close() *MockClient_Close_Call {
return &MockClient_Close_Call{Call: _e.mock.On("Close")}
}
func (_c *MockClient_Close_Call) Run(run func()) *MockClient_Close_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClient_Close_Call) Return() *MockClient_Close_Call {
_c.Call.Return()
return _c
}
func (_c *MockClient_Close_Call) RunAndReturn(run func()) *MockClient_Close_Call {
_c.Run(run)
return _c
}
// GetLocation provides a mock function with no fields
func (_m *MockClient) GetLocation() (geolocation.Location, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetLocation")
}
var r0 geolocation.Location
var r1 error
if rf, ok := ret.Get(0).(func() (geolocation.Location, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() geolocation.Location); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(geolocation.Location)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLocation'
type MockClient_GetLocation_Call struct {
*mock.Call
}
// GetLocation is a helper method to define mock.On call
func (_e *MockClient_Expecter) GetLocation() *MockClient_GetLocation_Call {
return &MockClient_GetLocation_Call{Call: _e.mock.On("GetLocation")}
}
func (_c *MockClient_GetLocation_Call) Run(run func()) *MockClient_GetLocation_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClient_GetLocation_Call) Return(_a0 geolocation.Location, _a1 error) *MockClient_GetLocation_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_GetLocation_Call) RunAndReturn(run func() (geolocation.Location, error)) *MockClient_GetLocation_Call {
_c.Call.Return(run)
return _c
}
// Subscribe provides a mock function with given fields: id
func (_m *MockClient) Subscribe(id string) chan geolocation.Location {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for Subscribe")
}
var r0 chan geolocation.Location
if rf, ok := ret.Get(0).(func(string) chan geolocation.Location); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(chan geolocation.Location)
}
}
return r0
}
// MockClient_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
type MockClient_Subscribe_Call struct {
*mock.Call
}
// Subscribe is a helper method to define mock.On call
// - id string
func (_e *MockClient_Expecter) Subscribe(id interface{}) *MockClient_Subscribe_Call {
return &MockClient_Subscribe_Call{Call: _e.mock.On("Subscribe", id)}
}
func (_c *MockClient_Subscribe_Call) Run(run func(id string)) *MockClient_Subscribe_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockClient_Subscribe_Call) Return(_a0 chan geolocation.Location) *MockClient_Subscribe_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockClient_Subscribe_Call) RunAndReturn(run func(string) chan geolocation.Location) *MockClient_Subscribe_Call {
_c.Call.Return(run)
return _c
}
// Unsubscribe provides a mock function with given fields: id
func (_m *MockClient) Unsubscribe(id string) {
_m.Called(id)
}
// MockClient_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe'
type MockClient_Unsubscribe_Call struct {
*mock.Call
}
// Unsubscribe is a helper method to define mock.On call
// - id string
func (_e *MockClient_Expecter) Unsubscribe(id interface{}) *MockClient_Unsubscribe_Call {
return &MockClient_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", id)}
}
func (_c *MockClient_Unsubscribe_Call) Run(run func(id string)) *MockClient_Unsubscribe_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockClient_Unsubscribe_Call) Return() *MockClient_Unsubscribe_Call {
_c.Call.Return()
return _c
}
func (_c *MockClient_Unsubscribe_Call) RunAndReturn(run func(string)) *MockClient_Unsubscribe_Call {
_c.Run(run)
return _c
}
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockClient {
mock := &MockClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -2,8 +2,10 @@ package cups
import ( import (
"errors" "errors"
"fmt"
"net" "net"
"net/url" "net/url"
"os/exec"
"strings" "strings"
"time" "time"
@@ -275,13 +277,42 @@ func (m *Manager) GetClasses() ([]PrinterClass, error) {
return classes, nil return classes, nil
} }
func createPrinterViaLpadmin(name, deviceURI, ppd, information, location string) error {
args := []string{"-p", name, "-E", "-v", deviceURI, "-m", ppd}
if information != "" {
args = append(args, "-D", information)
}
if location != "" {
args = append(args, "-L", location)
}
out, err := exec.Command("lpadmin", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func deletePrinterViaLpadmin(name string) error {
out, err := exec.Command("lpadmin", "-x", name).CombinedOutput()
if err != nil {
return fmt.Errorf("lpadmin failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error { func (m *Manager) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy, information, location string) error {
usedPkHelper := false usedPkHelper := false
err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location) err := m.client.CreatePrinter(name, deviceURI, ppd, shared, errorPolicy, information, location)
if isAuthError(err) && m.pkHelper != nil { if isAuthError(err) && m.pkHelper != nil {
if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil { if err = m.pkHelper.PrinterAdd(name, deviceURI, ppd, information, location); err != nil {
return err // pkHelper failed (e.g., no polkit agent), try lpadmin as last resort.
// lpadmin -E enables the printer, so no further setup needed.
if lpadminErr := createPrinterViaLpadmin(name, deviceURI, ppd, information, location); lpadminErr != nil {
return err
}
m.RefreshState()
return nil
} }
usedPkHelper = true usedPkHelper = true
} else if err != nil { } else if err != nil {
@@ -308,6 +339,12 @@ func (m *Manager) DeletePrinter(printerName string) error {
err := m.client.DeletePrinter(printerName) err := m.client.DeletePrinter(printerName)
if isAuthError(err) && m.pkHelper != nil { if isAuthError(err) && m.pkHelper != nil {
err = m.pkHelper.PrinterDelete(printerName) err = m.pkHelper.PrinterDelete(printerName)
if err != nil {
// pkHelper failed, try lpadmin as last resort
if lpadminErr := deletePrinterViaLpadmin(printerName); lpadminErr == nil {
err = nil
}
}
} }
if err == nil { if err == nil {
m.RefreshState() m.RefreshState()

View File

@@ -70,6 +70,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleRestartJob(conn, req, manager) handleRestartJob(conn, req, manager)
case "cups.holdJob": case "cups.holdJob":
handleHoldJob(conn, req, manager) handleHoldJob(conn, req, manager)
case "cups.testConnection":
handleTestConnection(conn, req, manager)
default: default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
} }
@@ -464,3 +466,22 @@ func handleHoldJob(conn net.Conn, req models.Request, manager *Manager) {
} }
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "job held"})
} }
func handleTestConnection(conn net.Conn, req models.Request, manager *Manager) {
host, err := params.StringNonEmpty(req.Params, "host")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
port := params.IntOpt(req.Params, "port", 631)
protocol := params.StringOpt(req.Params, "protocol", "ipp")
result, err := manager.TestRemotePrinter(host, port, protocol)
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, result)
}

View File

@@ -0,0 +1,176 @@
package cups
import (
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
)
var validProtocols = map[string]bool{
"ipp": true,
"ipps": true,
"lpd": true,
"socket": true,
}
func validateTestConnectionParams(host string, port int, protocol string) error {
if host == "" {
return errors.New("host is required")
}
if strings.ContainsAny(host, " \t\n\r/\\") {
return errors.New("host contains invalid characters")
}
if port < 1 || port > 65535 {
return errors.New("port must be between 1 and 65535")
}
if protocol != "" && !validProtocols[protocol] {
return errors.New("protocol must be one of: ipp, ipps, lpd, socket")
}
return nil
}
const probeTimeout = 10 * time.Second
func probeRemotePrinter(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
// Fast fail: TCP reachability check
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
if err != nil {
return &RemotePrinterInfo{
Reachable: false,
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
}, nil
}
conn.Close()
// Create a temporary IPP client pointing at the remote host.
// The TCP dial above provides fast-fail for unreachable hosts.
// The IPP adapter's ResponseHeaderTimeout (90s) bounds stalling servers.
client := ipp.NewIPPClient(host, port, "", "", useTLS)
// Try /ipp/print first (modern driverless printers), then / (legacy)
info, err := probeIPPEndpoint(client, host, port, useTLS, "/ipp/print")
if err != nil {
// If we got an auth error, the printer exists but requires credentials.
// Report it as reachable with the URI that triggered the auth challenge.
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
Info: "authentication required",
}, nil
}
info, err = probeIPPEndpoint(client, host, port, useTLS, "/")
}
if err != nil {
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/", proto, host, port),
Info: "authentication required",
}, nil
}
// TCP reachable but not an IPP printer
return &RemotePrinterInfo{
Reachable: true,
Error: fmt.Sprintf("host is reachable but does not appear to be an IPP printer: %s", err.Error()),
}, nil
}
return info, nil
}
func probeIPPEndpoint(client *ipp.IPPClient, host string, port int, useTLS bool, resourcePath string) (*RemotePrinterInfo, error) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
printerURI := fmt.Sprintf("%s://%s:%d%s", proto, host, port, resourcePath)
httpProto := "http"
if useTLS {
httpProto = "https"
}
httpURL := fmt.Sprintf("%s://%s:%d%s", httpProto, host, port, resourcePath)
req := ipp.NewRequest(ipp.OperationGetPrinterAttributes, 1)
req.OperationAttributes[ipp.AttributePrinterURI] = printerURI
req.OperationAttributes[ipp.AttributeRequestedAttributes] = []string{
ipp.AttributePrinterName,
ipp.AttributePrinterMakeAndModel,
ipp.AttributePrinterState,
ipp.AttributePrinterInfo,
ipp.AttributePrinterUriSupported,
}
resp, err := client.SendRequest(httpURL, req, nil)
if err != nil {
return nil, err
}
if len(resp.PrinterAttributes) == 0 {
return nil, errors.New("no printer attributes returned")
}
attrs := resp.PrinterAttributes[0]
return &RemotePrinterInfo{
Reachable: true,
MakeModel: getStringAttr(attrs, ipp.AttributePrinterMakeAndModel),
Name: getStringAttr(attrs, ipp.AttributePrinterName),
Info: getStringAttr(attrs, ipp.AttributePrinterInfo),
State: parsePrinterState(attrs),
URI: printerURI,
}, nil
}
// TestRemotePrinter validates inputs and probes a remote printer via IPP.
// For lpd/socket protocols, only TCP reachability is tested.
func (m *Manager) TestRemotePrinter(host string, port int, protocol string) (*RemotePrinterInfo, error) {
if protocol == "" {
protocol = "ipp"
}
if err := validateTestConnectionParams(host, port, protocol); err != nil {
return nil, err
}
// For non-IPP protocols, only check TCP reachability
if protocol == "lpd" || protocol == "socket" {
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, probeTimeout)
if err != nil {
return &RemotePrinterInfo{
Reachable: false,
Error: fmt.Sprintf("cannot reach %s: %s", addr, err.Error()),
}, nil
}
conn.Close()
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d", protocol, host, port),
}, nil
}
useTLS := protocol == "ipps"
probeFn := m.probeRemoteFn
if probeFn == nil {
probeFn = probeRemotePrinter
}
return probeFn(host, port, useTLS)
}

View File

@@ -0,0 +1,397 @@
package cups
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp"
"github.com/stretchr/testify/assert"
)
func TestValidateTestConnectionParams(t *testing.T) {
tests := []struct {
name string
host string
port int
protocol string
wantErr string
}{
{
name: "valid ipp",
host: "192.168.0.5",
port: 631,
protocol: "ipp",
wantErr: "",
},
{
name: "valid ipps",
host: "printer.local",
port: 443,
protocol: "ipps",
wantErr: "",
},
{
name: "valid lpd",
host: "10.0.0.1",
port: 515,
protocol: "lpd",
wantErr: "",
},
{
name: "valid socket",
host: "10.0.0.1",
port: 9100,
protocol: "socket",
wantErr: "",
},
{
name: "empty host",
host: "",
port: 631,
protocol: "ipp",
wantErr: "host is required",
},
{
name: "port too low",
host: "192.168.0.5",
port: 0,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "port too high",
host: "192.168.0.5",
port: 70000,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "invalid protocol",
host: "192.168.0.5",
port: 631,
protocol: "ftp",
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
},
{
name: "empty protocol treated as ipp",
host: "192.168.0.5",
port: 631,
protocol: "",
wantErr: "",
},
{
name: "host with slash",
host: "192.168.0.5/admin",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
{
name: "host with space",
host: "192.168.0.5 ",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
{
name: "host with newline",
host: "192.168.0.5\n",
port: 631,
protocol: "ipp",
wantErr: "host contains invalid characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateTestConnectionParams(tt.host, tt.port, tt.protocol)
if tt.wantErr == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErr)
}
})
}
}
func TestManager_TestRemotePrinter_Validation(t *testing.T) {
m := NewTestManager(nil, nil)
tests := []struct {
name string
host string
port int
protocol string
wantErr string
}{
{
name: "empty host returns error",
host: "",
port: 631,
protocol: "ipp",
wantErr: "host is required",
},
{
name: "invalid port returns error",
host: "192.168.0.5",
port: 0,
protocol: "ipp",
wantErr: "port must be between 1 and 65535",
},
{
name: "invalid protocol returns error",
host: "192.168.0.5",
port: 631,
protocol: "ftp",
wantErr: "protocol must be one of: ipp, ipps, lpd, socket",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := m.TestRemotePrinter(tt.host, tt.port, tt.protocol)
assert.EqualError(t, err, tt.wantErr)
})
}
}
func TestManager_TestRemotePrinter_IPP(t *testing.T) {
tests := []struct {
name string
protocol string
probeRet *RemotePrinterInfo
probeErr error
wantTLS bool
wantReach bool
wantModel string
}{
{
name: "successful ipp probe",
protocol: "ipp",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
Name: "OfficeJet",
State: "idle",
URI: "ipp://192.168.0.5:631/ipp/print",
},
wantTLS: false,
wantReach: true,
wantModel: "HP OfficeJet 8010",
},
{
name: "successful ipps probe",
protocol: "ipps",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
URI: "ipps://192.168.0.5:631/ipp/print",
},
wantTLS: true,
wantReach: true,
wantModel: "HP OfficeJet 8010",
},
{
name: "unreachable host",
protocol: "ipp",
probeRet: &RemotePrinterInfo{
Reachable: false,
Error: "cannot reach 192.168.0.5:631: connection refused",
},
wantReach: false,
},
{
name: "empty protocol defaults to ipp",
protocol: "",
probeRet: &RemotePrinterInfo{
Reachable: true,
MakeModel: "Test Printer",
},
wantTLS: false,
wantReach: true,
wantModel: "Test Printer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedTLS bool
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
capturedTLS = useTLS
return tt.probeRet, tt.probeErr
}
result, err := m.TestRemotePrinter("192.168.0.5", 631, tt.protocol)
if tt.probeErr != nil {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantReach, result.Reachable)
assert.Equal(t, tt.wantModel, result.MakeModel)
assert.Equal(t, tt.wantTLS, capturedTLS)
})
}
}
func TestManager_TestRemotePrinter_AuthRequired(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
// Simulate what happens when the printer returns HTTP 401
return probeRemotePrinterWithAuthError(host, port, useTLS)
}
result, err := m.TestRemotePrinter("192.168.0.107", 631, "ipp")
assert.NoError(t, err)
assert.True(t, result.Reachable)
assert.Equal(t, "authentication required", result.Info)
assert.Contains(t, result.URI, "ipp://192.168.0.107:631")
}
// probeRemotePrinterWithAuthError simulates a probe where the printer
// returns HTTP 401 on both endpoints.
func probeRemotePrinterWithAuthError(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
// This simulates what probeRemotePrinter does when both endpoints
// return auth errors. We test the auth detection logic directly.
err := ipp.HTTPError{Code: 401}
if isAuthError(err) {
proto := "ipp"
if useTLS {
proto = "ipps"
}
return &RemotePrinterInfo{
Reachable: true,
URI: fmt.Sprintf("%s://%s:%d/ipp/print", proto, host, port),
Info: "authentication required",
}, nil
}
return nil, err
}
func TestManager_TestRemotePrinter_NonIPPProtocol(t *testing.T) {
m := NewTestManager(nil, nil)
probeCalled := false
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
probeCalled = true
return nil, nil
}
// These will fail at TCP dial (no real server), but the important
// thing is that probeRemoteFn is NOT called for lpd/socket.
m.TestRemotePrinter("192.168.0.5", 9100, "socket")
assert.False(t, probeCalled, "probe function should not be called for socket protocol")
m.TestRemotePrinter("192.168.0.5", 515, "lpd")
assert.False(t, probeCalled, "probe function should not be called for lpd protocol")
}
func TestHandleTestConnection_Success(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
return &RemotePrinterInfo{
Reachable: true,
MakeModel: "HP OfficeJet 8010",
Name: "OfficeJet",
State: "idle",
URI: "ipp://192.168.0.5:631/ipp/print",
}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{
"host": "192.168.0.5",
},
}
handleTestConnection(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
assert.Equal(t, "HP OfficeJet 8010", resp.Result.MakeModel)
}
func TestHandleTestConnection_MissingHost(t *testing.T) {
m := NewTestManager(nil, nil)
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{},
}
handleTestConnection(conn, req, m)
var resp models.Response[any]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.Nil(t, resp.Result)
assert.NotNil(t, resp.Error)
}
func TestHandleTestConnection_CustomPortAndProtocol(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
assert.Equal(t, 9631, port)
assert.True(t, useTLS)
return &RemotePrinterInfo{Reachable: true, URI: "ipps://192.168.0.5:9631/ipp/print"}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{
"host": "192.168.0.5",
"port": float64(9631),
"protocol": "ipps",
},
}
handleTestConnection(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
}
func TestHandleRequest_TestConnection(t *testing.T) {
m := NewTestManager(nil, nil)
m.probeRemoteFn = func(host string, port int, useTLS bool) (*RemotePrinterInfo, error) {
return &RemotePrinterInfo{Reachable: true}, nil
}
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{
ID: 1,
Method: "cups.testConnection",
Params: map[string]any{"host": "192.168.0.5"},
}
HandleRequest(conn, req, m)
var resp models.Response[RemotePrinterInfo]
err := json.NewDecoder(buf).Decode(&resp)
assert.NoError(t, err)
assert.NotNil(t, resp.Result)
assert.True(t, resp.Result.Reachable)
}

View File

@@ -55,6 +55,16 @@ type PPD struct {
Type string `json:"type"` Type string `json:"type"`
} }
type RemotePrinterInfo struct {
Reachable bool `json:"reachable"`
MakeModel string `json:"makeModel"`
Name string `json:"name"`
Info string `json:"info"`
State string `json:"state"`
URI string `json:"uri"`
Error string `json:"error,omitempty"`
}
type PrinterClass struct { type PrinterClass struct {
Name string `json:"name"` Name string `json:"name"`
URI string `json:"uri"` URI string `json:"uri"`
@@ -77,6 +87,7 @@ type Manager struct {
notifierWg sync.WaitGroup notifierWg sync.WaitGroup
lastNotifiedState *CUPSState lastNotifiedState *CUPSState
baseURL string baseURL string
probeRemoteFn func(host string, port int, useTLS bool) (*RemotePrinterInfo, error)
} }
type SubscriptionManagerInterface interface { type SubscriptionManagerInterface interface {

View File

@@ -0,0 +1,61 @@
package location
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type LocationEvent struct {
Type string `json:"type"`
Data State `json:"data"`
}
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
switch req.Method {
case "location.getState":
handleGetState(conn, req, manager)
case "location.subscribe":
handleSubscribe(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
}
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
models.Respond(conn, req.ID, manager.GetState())
}
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
clientID := fmt.Sprintf("client-%p", conn)
stateChan := manager.Subscribe(clientID)
defer manager.Unsubscribe(clientID)
initialState := manager.GetState()
event := LocationEvent{
Type: "state_changed",
Data: initialState,
}
if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
ID: req.ID,
Result: &event,
}); err != nil {
return
}
for state := range stateChan {
event := LocationEvent{
Type: "state_changed",
Data: state,
}
if err := json.NewEncoder(conn).Encode(models.Response[LocationEvent]{
Result: &event,
}); err != nil {
return
}
}
}

View File

@@ -0,0 +1,175 @@
package location
import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func NewManager(client geolocation.Client) (*Manager, error) {
currLocation, err := client.GetLocation()
if err != nil {
log.Warnf("Failed to get initial location: %v", err)
}
m := &Manager{
client: client,
dirty: make(chan struct{}),
stopChan: make(chan struct{}),
state: &State{
Latitude: currLocation.Latitude,
Longitude: currLocation.Longitude,
},
}
if err := m.startSignalPump(); err != nil {
return nil, err
}
m.notifierWg.Add(1)
go m.notifier()
return m, nil
}
func (m *Manager) Close() {
close(m.stopChan)
m.notifierWg.Wait()
m.sigWG.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 64)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if ch, ok := m.subscribers.LoadAndDelete(id); ok {
close(ch)
}
}
func (m *Manager) startSignalPump() error {
m.sigWG.Add(1)
go func() {
defer m.sigWG.Done()
subscription := m.client.Subscribe("locationManager")
defer m.client.Unsubscribe("locationManager")
for {
select {
case <-m.stopChan:
return
case location, ok := <-subscription:
if !ok {
return
}
m.handleLocationChange(location)
}
}
}()
return nil
}
func (m *Manager) handleLocationChange(location geolocation.Location) {
m.stateMutex.Lock()
defer m.stateMutex.Unlock()
m.state.Latitude = location.Latitude
m.state.Longitude = location.Longitude
m.notifySubscribers()
}
func (m *Manager) notifySubscribers() {
select {
case m.dirty <- struct{}{}:
default:
}
}
func (m *Manager) GetState() State {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return State{
Latitude: 0.0,
Longitude: 0.0,
}
}
stateCopy := *m.state
return stateCopy
}
func (m *Manager) notifier() {
defer m.notifierWg.Done()
const minGap = 200 * time.Millisecond
timer := time.NewTimer(minGap)
timer.Stop()
var pending bool
for {
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.dirty:
if pending {
continue
}
pending = true
timer.Reset(minGap)
case <-timer.C:
if !pending {
continue
}
currentState := m.GetState()
if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
pending = false
continue
}
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- currentState:
default:
log.Warn("Location: subscriber channel full, dropping update")
}
return true
})
stateCopy := currentState
m.lastNotified = &stateCopy
pending = false
}
}
}
func stateChanged(old, new *State) bool {
if old == nil || new == nil {
return true
}
if old.Latitude != new.Latitude {
return true
}
if old.Longitude != new.Longitude {
return true
}
return false
}

View File

@@ -0,0 +1,28 @@
package location
import (
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type State struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
type Manager struct {
state *State
stateMutex sync.RWMutex
client geolocation.Client
stopChan chan struct{}
sigWG sync.WaitGroup
subscribers syncmap.Map[string, chan State]
dirty chan struct{}
notifierWg sync.WaitGroup
lastNotified *State
}

View File

@@ -5,5 +5,6 @@ const (
dbusPath = "/org/freedesktop/login1" dbusPath = "/org/freedesktop/login1"
dbusManagerInterface = "org.freedesktop.login1.Manager" dbusManagerInterface = "org.freedesktop.login1.Manager"
dbusSessionInterface = "org.freedesktop.login1.Session" dbusSessionInterface = "org.freedesktop.login1.Session"
dbusUserInterface = "org.freedesktop.login1.User"
dbusPropsInterface = "org.freedesktop.DBus.Properties" dbusPropsInterface = "org.freedesktop.DBus.Properties"
) )

View File

@@ -17,15 +17,8 @@ func NewManager() (*Manager, error) {
return nil, fmt.Errorf("failed to connect to system bus: %w", err) return nil, fmt.Errorf("failed to connect to system bus: %w", err)
} }
sessionID := os.Getenv("XDG_SESSION_ID")
if sessionID == "" {
sessionID = "self"
}
m := &Manager{ m := &Manager{
state: &SessionState{ state: &SessionState{},
SessionID: sessionID,
},
stateMutex: sync.RWMutex{}, stateMutex: sync.RWMutex{},
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
@@ -60,12 +53,13 @@ func (m *Manager) initialize() error {
m.initializeFallbackDelay() m.initializeFallbackDelay()
sessionPath, err := m.getSession(m.state.SessionID) sessionID, sessionPath, err := m.discoverSession()
if err != nil { if err != nil {
return fmt.Errorf("failed to get session path: %w", err) return fmt.Errorf("failed to get session path: %w", err)
} }
m.stateMutex.Lock() m.stateMutex.Lock()
m.state.SessionID = sessionID
m.state.SessionPath = string(sessionPath) m.state.SessionPath = string(sessionPath)
m.sessionPath = sessionPath m.sessionPath = sessionPath
m.stateMutex.Unlock() m.stateMutex.Unlock()
@@ -79,6 +73,41 @@ func (m *Manager) initialize() error {
return nil return nil
} }
func (m *Manager) discoverSession() (string, dbus.ObjectPath, error) {
// 1. Explicit XDG_SESSION_ID
if id := os.Getenv("XDG_SESSION_ID"); id != "" {
if path, err := m.getSession(id); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: using XDG_SESSION_ID=%s\n", id)
return id, path, nil
}
}
// 2. PID-based lookup (works when caller is inside a session cgroup)
if id, path, err := m.getSessionByPID(uint32(os.Getpid())); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: found session %s via PID\n", id)
return id, path, nil
}
// 3. User's primary display session (handles UWSM and similar)
if id, path, err := m.getUserDisplaySession(); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: found session %s via User.Display\n", id)
return id, path, nil
}
// 4. Score all sessions for current UID
if id, path, err := m.findBestSession(); err == nil {
fmt.Fprintf(os.Stderr, "loginctl: found session %s via ListSessions scoring\n", id)
return id, path, nil
}
// 5. Last resort: "self"
path, err := m.getSession("self")
if err != nil {
return "", "", fmt.Errorf("%w", err)
}
return "self", path, nil
}
func (m *Manager) getSession(id string) (dbus.ObjectPath, error) { func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
var out dbus.ObjectPath var out dbus.ObjectPath
err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out) err := m.managerObj.Call(dbusManagerInterface+".GetSession", 0, id).Store(&out)
@@ -88,6 +117,166 @@ func (m *Manager) getSession(id string) (dbus.ObjectPath, error) {
return out, nil return out, nil
} }
func (m *Manager) getSessionByPID(pid uint32) (string, dbus.ObjectPath, error) {
var path dbus.ObjectPath
if err := m.managerObj.Call(dbusManagerInterface+".GetSessionByPID", 0, pid).Store(&path); err != nil {
return "", "", err
}
sessionObj := m.conn.Object(dbusDest, path)
var id dbus.Variant
if err := sessionObj.Call(dbusPropsInterface+".Get", 0, dbusSessionInterface, "Id").Store(&id); err != nil {
return "", "", err
}
return id.Value().(string), path, nil
}
func (m *Manager) getUserDisplaySession() (string, dbus.ObjectPath, error) {
uid := uint32(os.Getuid())
var userPath dbus.ObjectPath
if err := m.managerObj.Call(dbusManagerInterface+".GetUser", 0, uid).Store(&userPath); err != nil {
return "", "", err
}
userObj := m.conn.Object(dbusDest, userPath)
var display dbus.Variant
if err := userObj.Call(dbusPropsInterface+".Get", 0, dbusUserInterface, "Display").Store(&display); err != nil {
return "", "", err
}
pair, ok := display.Value().([]any)
if !ok || len(pair) < 2 {
return "", "", fmt.Errorf("unexpected Display format")
}
sessionID, _ := pair[0].(string)
sessionPath, _ := pair[1].(dbus.ObjectPath)
if sessionID == "" || sessionPath == "" {
return "", "", fmt.Errorf("empty Display session")
}
return sessionID, sessionPath, nil
}
type sessionCandidate struct {
id string
path dbus.ObjectPath
}
func (m *Manager) findBestSession() (string, dbus.ObjectPath, error) {
// ListSessions returns a(susso): [][]any where each entry is [id, uid, name, seat, path]
var raw [][]any
if err := m.managerObj.Call(dbusManagerInterface+".ListSessions", 0).Store(&raw); err != nil {
return "", "", err
}
uid := uint32(os.Getuid())
var candidates []sessionCandidate
for _, entry := range raw {
if len(entry) < 5 {
continue
}
entryUID, _ := entry[1].(uint32)
if entryUID != uid {
continue
}
id, _ := entry[0].(string)
path, _ := entry[4].(dbus.ObjectPath)
if id != "" && path != "" {
candidates = append(candidates, sessionCandidate{id: id, path: path})
}
}
if len(candidates) == 0 {
return "", "", fmt.Errorf("no sessions for uid %d", uid)
}
bestScore := -1
var best sessionCandidate
for _, c := range candidates {
score := m.scoreSession(c.path)
if score > bestScore {
bestScore = score
best = c
}
}
if bestScore < 0 {
return "", "", fmt.Errorf("no viable session found")
}
return best.id, best.path, nil
}
func (m *Manager) scoreSession(path dbus.ObjectPath) int {
obj := m.conn.Object(dbusDest, path)
var props map[string]dbus.Variant
if err := obj.Call(dbusPropsInterface+".GetAll", 0, dbusSessionInterface).Store(&props); err != nil {
return -1
}
getStr := func(key string) string {
if v, ok := props[key]; ok {
if s, ok := v.Value().(string); ok {
return s
}
}
return ""
}
getBool := func(key string) bool {
if v, ok := props[key]; ok {
if b, ok := v.Value().(bool); ok {
return b
}
}
return false
}
getUint32 := func(key string) uint32 {
if v, ok := props[key]; ok {
if u, ok := v.Value().(uint32); ok {
return u
}
}
return 0
}
class := getStr("Class")
if class != "user" {
return -1
}
if getBool("Remote") {
return -1
}
score := 0
if getBool("Active") {
score += 100
}
switch getStr("Type") {
case "wayland", "x11":
score += 80
case "tty":
score += 10
}
if v, ok := props["Seat"]; ok {
if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 {
if seat, ok := seatArr[0].(string); ok && seat != "" {
score += 40
if seat == "seat0" {
score += 10
}
}
}
}
if getUint32("VTNr") > 0 {
score += 20
}
return score
}
func (m *Manager) refreshSessionBinding() error { func (m *Manager) refreshSessionBinding() error {
if m.managerObj == nil || m.conn == nil { if m.managerObj == nil || m.conn == nil {
return fmt.Errorf("manager not fully initialized") return fmt.Errorf("manager not fully initialized")

View File

@@ -15,6 +15,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
@@ -192,6 +193,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "location.") {
if locationManager == nil {
models.RespondError(conn, req.ID, "location manager not initialized")
return
}
location.HandleRequest(conn, req, locationManager)
return
}
switch req.Method { switch req.Method {
case "ping": case "ping":
models.Respond(conn, req.ID, "pong") models.Respond(conn, req.ID, "pong")

View File

@@ -14,6 +14,7 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
@@ -25,6 +26,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
@@ -70,6 +72,7 @@ var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager var themeModeManager *thememode.Manager
var locationManager *location.Manager
const dbusClientID = "dms-dbus-client" const dbusClientID = "dms-dbus-client"
@@ -188,7 +191,7 @@ func InitializeFreedeskManager() error {
return nil return nil
} }
func InitializeWaylandManager() error { func InitializeWaylandManager(geoClient geolocation.Client) error {
log.Info("Attempting to initialize Wayland gamma control...") log.Info("Attempting to initialize Wayland gamma control...")
if wlContext == nil { if wlContext == nil {
@@ -201,7 +204,7 @@ func InitializeWaylandManager() error {
} }
config := wayland.DefaultConfig() config := wayland.DefaultConfig()
manager, err := wayland.NewManager(wlContext.Display(), config) manager, err := wayland.NewManager(wlContext.Display(), geoClient, config)
if err != nil { if err != nil {
log.Errorf("Failed to initialize wayland manager: %v", err) log.Errorf("Failed to initialize wayland manager: %v", err)
return err return err
@@ -382,14 +385,27 @@ func InitializeDbusManager() error {
return nil return nil
} }
func InitializeThemeModeManager() error { func InitializeThemeModeManager(geoClient geolocation.Client) error {
manager := thememode.NewManager() manager := thememode.NewManager(geoClient)
themeModeManager = manager themeModeManager = manager
log.Info("Theme mode automation manager initialized") log.Info("Theme mode automation manager initialized")
return nil return nil
} }
func InitializeLocationManager(geoClient geolocation.Client) error {
manager, err := location.NewManager(geoClient)
if err != nil {
log.Warnf("Failed to initialize location manager: %v", err)
return err
}
locationManager = manager
log.Info("Location manager initialized")
return nil
}
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -537,6 +553,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "theme.auto") caps = append(caps, "theme.auto")
} }
if locationManager != nil {
caps = append(caps, "location")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
@@ -1307,6 +1327,9 @@ func cleanupManagers() {
if wlContext != nil { if wlContext != nil {
wlContext.Close() wlContext.Close()
} }
if locationManager != nil {
locationManager.Close()
}
} }
func Start(printDocs bool) error { func Start(printDocs bool) error {
@@ -1488,6 +1511,9 @@ func Start(printDocs bool) error {
log.Info(" clipboard.getConfig - Get clipboard configuration") log.Info(" clipboard.getConfig - Get clipboard configuration")
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)") log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)") log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
log.Info("Location:")
log.Info(" location.getState - Get current location state")
log.Info(" location.subscribe - Subscribe to location changes (streaming)")
log.Info("") log.Info("")
} }
log.Info("Initializing managers...") log.Info("Initializing managers...")
@@ -1519,6 +1545,9 @@ func Start(printDocs bool) error {
loginctlReady := make(chan struct{}) loginctlReady := make(chan struct{})
freedesktopReady := make(chan struct{}) freedesktopReady := make(chan struct{})
geoClient := geolocation.NewClient()
defer geoClient.Close()
go func() { go func() {
defer close(loginctlReady) defer close(loginctlReady)
if err := InitializeLoginctlManager(); err != nil { if err := InitializeLoginctlManager(); err != nil {
@@ -1563,7 +1592,7 @@ func Start(printDocs bool) error {
} }
}() }()
if err := InitializeWaylandManager(); err != nil { if err := InitializeWaylandManager(geoClient); err != nil {
log.Warnf("Wayland manager unavailable: %v", err) log.Warnf("Wayland manager unavailable: %v", err)
} }
@@ -1595,7 +1624,7 @@ func Start(printDocs bool) error {
log.Debugf("WlrOutput manager unavailable: %v", err) log.Debugf("WlrOutput manager unavailable: %v", err)
} }
if err := InitializeThemeModeManager(); err != nil { if err := InitializeThemeModeManager(geoClient); err != nil {
log.Warnf("Theme mode manager unavailable: %v", err) log.Warnf("Theme mode manager unavailable: %v", err)
} else { } else {
notifyCapabilityChange() notifyCapabilityChange()
@@ -1608,6 +1637,12 @@ func Start(printDocs bool) error {
}() }()
} }
if err := InitializeLocationManager(geoClient); err != nil {
log.Warnf("Location manager unavailable: %v", err)
} else {
notifyCapabilityChange()
}
fatalErrChan := make(chan error, 1) fatalErrChan := make(chan error, 1)
if wlrOutputManager != nil { if wlrOutputManager != nil {
go func() { go func() {

View File

@@ -5,6 +5,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
@@ -32,12 +33,14 @@ type Manager struct {
cachedIPLat *float64 cachedIPLat *float64
cachedIPLon *float64 cachedIPLon *float64
geoClient geolocation.Client
stopChan chan struct{} stopChan chan struct{}
updateTrigger chan struct{} updateTrigger chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
} }
func NewManager() *Manager { func NewManager(geoClient geolocation.Client) *Manager {
m := &Manager{ m := &Manager{
config: Config{ config: Config{
Enabled: false, Enabled: false,
@@ -51,6 +54,7 @@ func NewManager() *Manager {
}, },
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
updateTrigger: make(chan struct{}, 1), updateTrigger: make(chan struct{}, 1),
geoClient: geoClient,
} }
m.updateState(time.Now()) m.updateState(time.Now())
@@ -327,17 +331,17 @@ func (m *Manager) getLocation(config Config) (*float64, *float64) {
} }
m.locationMutex.RUnlock() m.locationMutex.RUnlock()
lat, lon, err := wayland.FetchIPLocation() location, err := m.geoClient.GetLocation()
if err != nil { if err != nil {
return nil, nil return nil, nil
} }
m.locationMutex.Lock() m.locationMutex.Lock()
m.cachedIPLat = lat m.cachedIPLat = &location.Latitude
m.cachedIPLon = lon m.cachedIPLon = &location.Longitude
m.locationMutex.Unlock() m.locationMutex.Unlock()
return lat, lon return m.cachedIPLat, m.cachedIPLon
} }
func statesEqual(a, b *State) bool { func statesEqual(a, b *State) bool {

View File

@@ -13,13 +13,14 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
) )
const animKelvinStep = 25 const animKelvinStep = 25
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) { func NewManager(display wlclient.WaylandDisplay, geoClient geolocation.Client, config Config) (*Manager, error) {
if err := config.Validate(); err != nil { if err := config.Validate(); err != nil {
return nil, err return nil, err
} }
@@ -40,6 +41,7 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
updateTrigger: make(chan struct{}, 1), updateTrigger: make(chan struct{}, 1),
dirty: make(chan struct{}, 1), dirty: make(chan struct{}, 1),
dbusSignal: make(chan *dbus.Signal, 16), dbusSignal: make(chan *dbus.Signal, 16),
geoClient: geoClient,
} }
if err := m.setupRegistry(); err != nil { if err := m.setupRegistry(); err != nil {
@@ -437,15 +439,16 @@ func (m *Manager) getLocation() (*float64, *float64) {
} }
m.locationMutex.RUnlock() m.locationMutex.RUnlock()
lat, lon, err := FetchIPLocation() location, err := m.geoClient.GetLocation()
if err != nil { if err != nil {
return nil, nil return nil, nil
} }
m.locationMutex.Lock() m.locationMutex.Lock()
m.cachedIPLat = lat m.cachedIPLat = &location.Latitude
m.cachedIPLon = lon m.cachedIPLon = &location.Longitude
m.locationMutex.Unlock() m.locationMutex.Unlock()
return lat, lon return m.cachedIPLat, m.cachedIPLon
} }
return nil, nil return nil, nil
} }

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
mocks_geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/geolocation"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
) )
@@ -390,18 +391,20 @@ func TestNotifySubscribers_NonBlocking(t *testing.T) {
func TestNewManager_GetRegistryError(t *testing.T) { func TestNewManager_GetRegistryError(t *testing.T) {
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
mockGeoclient := mocks_geolocation.NewMockClient(t)
mockDisplay.EXPECT().Context().Return(nil) mockDisplay.EXPECT().Context().Return(nil)
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
config := DefaultConfig() config := DefaultConfig()
_, err := NewManager(mockDisplay, config) _, err := NewManager(mockDisplay, mockGeoclient, config)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "get registry") assert.Contains(t, err.Error(), "get registry")
} }
func TestNewManager_InvalidConfig(t *testing.T) { func TestNewManager_InvalidConfig(t *testing.T) {
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
mockGeoclient := mocks_geolocation.NewMockClient(t)
config := Config{ config := Config{
LowTemp: 500, LowTemp: 500,
@@ -409,6 +412,6 @@ func TestNewManager_InvalidConfig(t *testing.T) {
Gamma: 1.0, Gamma: 1.0,
} }
_, err := NewManager(mockDisplay, config) _, err := NewManager(mockDisplay, mockGeoclient, config)
assert.Error(t, err) assert.Error(t, err)
} }

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
@@ -97,6 +98,8 @@ type Manager struct {
dbusConn *dbus.Conn dbusConn *dbus.Conn
dbusSignal chan *dbus.Signal dbusSignal chan *dbus.Signal
geoClient geolocation.Client
lastAppliedTemp int lastAppliedTemp int
lastAppliedGamma float64 lastAppliedGamma float64
} }

View File

@@ -2,7 +2,6 @@
config, config,
lib, lib,
pkgs, pkgs,
dmsPkgs,
... ...
}: }:
let let
@@ -10,7 +9,7 @@ let
in in
{ {
packages = [ packages = [
dmsPkgs.dms-shell cfg.package
] ]
++ lib.optional cfg.enableSystemMonitoring cfg.dgop.package ++ lib.optional cfg.enableSystemMonitoring cfg.dgop.package
++ lib.optionals cfg.enableVPN [ ++ lib.optionals cfg.enableVPN [

View File

@@ -8,6 +8,7 @@
let let
inherit (lib) types; inherit (lib) types;
cfg = config.programs.dank-material-shell.greeter; cfg = config.programs.dank-material-shell.greeter;
cfgDms = config.programs.dank-material-shell;
inherit (config.services.greetd.settings.default_session) user; inherit (config.services.greetd.settings.default_session) user;
@@ -29,13 +30,13 @@ let
lib.escapeShellArgs ( lib.escapeShellArgs (
[ [
"sh" "sh"
"${../../quickshell/Modules/Greetd/assets/dms-greeter}" "${cfg.package}/share/quickshell/dms/Modules/Greetd/assets/dms-greeter"
"--cache-dir" "--cache-dir"
cacheDir cacheDir
"--command" "--command"
cfg.compositor.name cfg.compositor.name
"-p" "-p"
"${dmsPkgs.dms-shell}/share/quickshell/dms" "${cfg.package}/share/quickshell/dms"
] ]
++ lib.optionals (cfg.compositor.customConfig != "") [ ++ lib.optionals (cfg.compositor.customConfig != "") [
"-C" "-C"
@@ -65,6 +66,21 @@ in
options.programs.dank-material-shell.greeter = { options.programs.dank-material-shell.greeter = {
enable = lib.mkEnableOption "DankMaterialShell greeter"; enable = lib.mkEnableOption "DankMaterialShell greeter";
package = lib.mkOption {
type = types.package;
default = if cfgDms.enable or false then cfgDms.package else dmsPkgs.dms-shell;
defaultText = lib.literalExpression ''
if config.programs.dank-material-shell.enable
then config.programs.dank-material-shell.package
else built from source;
'';
description = ''
The DankMaterialShell package to use for the greeter.
Defaults to the package from `programs.dank-material-shell` if it is enabled,
otherwise defaults to building from source.
'';
};
compositor.name = lib.mkOption { compositor.name = lib.mkOption {
type = types.enum [ type = types.enum [
"niri" "niri"

View File

@@ -2,7 +2,6 @@
config, config,
pkgs, pkgs,
lib, lib,
dmsPkgs,
... ...
}@args: }@args:
let let
@@ -13,7 +12,6 @@ let
config config
pkgs pkgs
lib lib
dmsPkgs
; ;
}; };
hasPluginSettings = lib.any (plugin: plugin.settings != { }) ( hasPluginSettings = lib.any (plugin: plugin.settings != { }) (
@@ -96,7 +94,7 @@ in
}; };
Service = { Service = {
ExecStart = lib.getExe dmsPkgs.dms-shell + " run --session"; ExecStart = lib.getExe cfg.package + " run --session";
Restart = "on-failure"; Restart = "on-failure";
}; };

View File

@@ -2,7 +2,6 @@
config, config,
pkgs, pkgs,
lib, lib,
dmsPkgs,
... ...
}@args: }@args:
let let
@@ -12,7 +11,6 @@ let
config config
pkgs pkgs
lib lib
dmsPkgs
; ;
}; };
in in
@@ -36,7 +34,7 @@ in
restartIfChanged = cfg.systemd.restartIfChanged; restartIfChanged = cfg.systemd.restartIfChanged;
serviceConfig = { serviceConfig = {
ExecStart = lib.getExe dmsPkgs.dms-shell + " run --session"; ExecStart = lib.getExe cfg.package + " run --session";
Restart = "on-failure"; Restart = "on-failure";
}; };
}; };
@@ -50,6 +48,7 @@ in
services.power-profiles-daemon.enable = lib.mkDefault true; services.power-profiles-daemon.enable = lib.mkDefault true;
services.accounts-daemon.enable = lib.mkDefault true; services.accounts-daemon.enable = lib.mkDefault true;
services.geoclue2.enable = lib.mkDefault true;
security.polkit.enable = lib.mkDefault true; security.polkit.enable = lib.mkDefault true;
}; };
} }

View File

@@ -26,6 +26,9 @@ in
options.programs.dank-material-shell = { options.programs.dank-material-shell = {
enable = lib.mkEnableOption "DankMaterialShell"; enable = lib.mkEnableOption "DankMaterialShell";
package = lib.mkPackageOption dmsPkgs "dms-shell" {
extraDescription = "The DankMaterialShell package to use (defaults to be built from source)";
};
systemd = { systemd = {
enable = lib.mkEnableOption "DankMaterialShell systemd startup"; enable = lib.mkEnableOption "DankMaterialShell systemd startup";

View File

@@ -0,0 +1,174 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
// AnimVariants — Central tuning for animation and Motion Effects variants
// (Material/Fluent/Dynamic) (Standard/Directional/Depth)
Singleton {
id: root
readonly property list<real> variantEnterCurve: {
if (typeof SettingsData === "undefined")
return Anims.expressiveDefaultSpatial;
switch (SettingsData.animationVariant) {
case 1:
return Anims.standardDecel;
case 2:
return Anims.expressiveFastSpatial;
default:
return Anims.expressiveDefaultSpatial;
}
}
readonly property list<real> variantExitCurve: {
if (typeof SettingsData === "undefined")
return Anims.emphasized;
switch (SettingsData.animationVariant) {
case 1:
return Anims.standard;
case 2:
return Anims.emphasized;
default:
return Anims.emphasized;
}
}
// Modal-specific entry curve
readonly property list<real> variantModalEnterCurve: {
if (typeof SettingsData === "undefined")
return Anims.expressiveDefaultSpatial;
if (isDirectionalEffect) {
if (SettingsData.animationVariant === 1)
return Anims.standardDecel;
if (SettingsData.animationVariant === 2)
return Anims.expressiveFastSpatial;
}
return variantEnterCurve;
}
readonly property list<real> variantModalExitCurve: {
if (typeof SettingsData === "undefined")
return Anims.emphasized;
if (isDirectionalEffect) {
if (SettingsData.animationVariant === 1)
return Anims.emphasizedAccel;
if (SettingsData.animationVariant === 2)
return Anims.emphasizedAccel;
}
return variantExitCurve;
}
// Popout-specific entry curve
readonly property list<real> variantPopoutEnterCurve: {
if (typeof SettingsData === "undefined")
return Anims.expressiveDefaultSpatial;
if (isDirectionalEffect) {
if (SettingsData.animationVariant === 1)
return Anims.standardDecel;
if (SettingsData.animationVariant === 2)
return Anims.expressiveFastSpatial;
return Anims.standardDecel;
}
return variantEnterCurve;
}
readonly property list<real> variantPopoutExitCurve: {
if (typeof SettingsData === "undefined")
return Anims.emphasized;
if (isDirectionalEffect) {
if (SettingsData.animationVariant === 1)
return Anims.emphasizedAccel;
if (SettingsData.animationVariant === 2)
return Anims.emphasizedAccel;
}
return variantExitCurve;
}
readonly property real variantEnterDurationFactor: {
if (typeof SettingsData === "undefined")
return 1.0;
switch (SettingsData.animationVariant) {
case 1:
return 0.9;
case 2:
return 1.08;
default:
return 1.0;
}
}
readonly property real variantExitDurationFactor: {
if (typeof SettingsData === "undefined")
return 1.0;
switch (SettingsData.animationVariant) {
case 1:
return 0.85;
case 2:
return 0.92;
default:
return 1.0;
}
}
// Fluent: opacity at ~55% of duration; Material/Dynamic: 1:1 with position
readonly property real variantOpacityDurationScale: {
if (typeof SettingsData === "undefined")
return 1.0;
return SettingsData.animationVariant === 1 ? 0.55 : 1.0;
}
function variantDuration(baseDuration, entering) {
const factor = entering ? variantEnterDurationFactor : variantExitDurationFactor;
return Math.max(0, Math.round(baseDuration * factor));
}
function variantExitCleanupPadding() {
if (typeof SettingsData === "undefined")
return 50;
switch (SettingsData.motionEffect) {
case 1:
return 8;
case 2:
return 24;
default:
return 50;
}
}
function variantCloseInterval(baseDuration) {
return variantDuration(baseDuration, false) + variantExitCleanupPadding();
}
readonly property bool isDirectionalEffect: typeof SettingsData !== "undefined" && SettingsData.motionEffect === 1
readonly property bool isDepthEffect: typeof SettingsData !== "undefined" && SettingsData.motionEffect === 2
readonly property real effectScaleCollapsed: {
if (typeof SettingsData === "undefined")
return 0.96;
switch (SettingsData.motionEffect) {
case 1:
return 1.0;
case 2:
return 0.88;
default:
return 0.96;
}
}
readonly property real effectAnimOffset: {
if (typeof SettingsData === "undefined")
return 16;
switch (SettingsData.motionEffect) {
case 1:
return 144;
case 2:
return 56;
default:
return 16;
}
}
}

View File

@@ -22,4 +22,9 @@ Singleton {
readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00] readonly property var standard: [0.20, 0.00, 0.00, 1.00, 1.00, 1.00]
readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00] readonly property var standardDecel: [0.00, 0.00, 0.00, 1.00, 1.00, 1.00]
readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00] readonly property var standardAccel: [0.30, 0.00, 1.00, 1.00, 1.00, 1.00]
// Used by AnimVariants for variant/effect logic
readonly property var expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1]
readonly property var expressiveFastSpatial: [0.34, 1.5, 0.2, 1.0, 1.0, 1.0]
readonly property var expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1]
} }

View File

@@ -0,0 +1,54 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import qs.Common
Item {
id: root
property var level: Theme.elevationLevel2
property string direction: Theme.elevationLightDirection
property real fallbackOffset: 4
property color targetColor: "white"
property real targetRadius: Theme.cornerRadius
property color borderColor: "transparent"
property real borderWidth: 0
property bool shadowEnabled: Theme.elevationEnabled
property real shadowBlurPx: level && level.blurPx !== undefined ? level.blurPx : 0
property real shadowSpreadPx: level && level.spreadPx !== undefined ? level.spreadPx : 0
property real shadowOffsetX: Theme.elevationOffsetXFor(level, direction, fallbackOffset)
property real shadowOffsetY: Theme.elevationOffsetYFor(level, direction, fallbackOffset)
property color shadowColor: Theme.elevationShadowColor(level)
property real shadowOpacity: 1
property real blurMax: Theme.elevationBlurMax
property alias sourceRect: sourceRect
layer.enabled: shadowEnabled
layer.effect: MultiEffect {
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, root.shadowBlurPx / Math.max(1, root.blurMax)))
shadowScale: 1 + (2 * root.shadowSpreadPx) / Math.max(1, Math.min(root.width, root.height))
shadowHorizontalOffset: root.shadowOffsetX
shadowVerticalOffset: root.shadowOffsetY
blurMax: root.blurMax
shadowColor: root.shadowColor
shadowOpacity: root.shadowOpacity
}
Rectangle {
id: sourceRect
anchors.fill: parent
radius: root.targetRadius
color: root.targetColor
border.color: root.borderColor
border.width: root.borderWidth
}
}

View File

@@ -1,5 +1,6 @@
pragma Singleton pragma Singleton
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Qt.labs.folderlistmodel import Qt.labs.folderlistmodel
import Quickshell import Quickshell
@@ -24,7 +25,9 @@ Singleton {
readonly property url translationsFolder: Qt.resolvedUrl("../translations/poexports") readonly property url translationsFolder: Qt.resolvedUrl("../translations/poexports")
readonly property alias folder: dir.folder readonly property alias folder: dir.folder
property var presentLocales: ({ "en": Qt.locale("en") }) property var presentLocales: ({
"en": Qt.locale("en")
})
property var translations: ({}) property var translations: ({})
property bool translationsLoaded: false property bool translationsLoaded: false
@@ -65,7 +68,9 @@ Singleton {
} }
function locale() { function locale() {
return presentLocales[_resolvedLocale] ?? presentLocales["en"]; if (SessionData.timeLocale)
return Qt.locale(SessionData.timeLocale);
return Qt.locale();
} }
function _loadPresentLocales() { function _loadPresentLocales() {
@@ -84,7 +89,8 @@ Singleton {
function _pickTranslation() { function _pickTranslation() {
for (let i = 0; i < _candidates.length; i++) { for (let i = 0; i < _candidates.length; i++) {
const cand = _candidates[i]; const cand = _candidates[i];
if (presentLocales[cand] === undefined) continue; if (presentLocales[cand] === undefined)
continue;
_resolvedLocale = cand; _resolvedLocale = cand;
useLocale(cand, cand.startsWith("en") ? "" : translationsFolder + "/" + cand + ".json"); useLocale(cand, cand.startsWith("en") ? "" : translationsFolder + "/" + cand + ".json");
return; return;

View File

@@ -71,15 +71,40 @@ Singleton {
return appId; return appId;
} }
function resolveIconPath(iconName: string): string {
if (!iconName) return "";
const moddedId = moddedAppId(iconName);
if (moddedId !== iconName) {
if (moddedId.startsWith("~") || moddedId.startsWith("/"))
return toFileUrl(expandTilde(moddedId));
if (moddedId.startsWith("file://"))
return moddedId;
return Quickshell.iconPath(moddedId, true);
}
return Quickshell.iconPath(iconName, true) || DesktopService.resolveIconPath(iconName);
}
function resolveIconUrl(iconName: string): string {
if (!iconName) return "";
const moddedId = moddedAppId(iconName);
if (moddedId !== iconName) {
if (moddedId.startsWith("~") || moddedId.startsWith("/"))
return toFileUrl(expandTilde(moddedId));
if (moddedId.startsWith("file://"))
return moddedId;
return "image://icon/" + moddedId;
}
return "image://icon/" + iconName;
}
function getAppIcon(appId: string, desktopEntry: var): string { function getAppIcon(appId: string, desktopEntry: var): string {
if (appId === "org.quickshell") { if (appId === "org.quickshell") {
return Qt.resolvedUrl("../assets/danklogo.svg"); return Qt.resolvedUrl("../assets/danklogo.svg");
} }
const moddedId = moddedAppId(appId); const moddedId = moddedAppId(appId);
if (moddedId !== appId) { if (moddedId !== appId)
return Quickshell.iconPath(moddedId, true); return resolveIconPath(appId);
}
if (desktopEntry && desktopEntry.icon) { if (desktopEntry && desktopEntry.icon) {
return Quickshell.iconPath(desktopEntry.icon, true); return Quickshell.iconPath(desktopEntry.icon, true);

View File

@@ -129,6 +129,7 @@ Singleton {
property var hiddenInputDeviceNames: [] property var hiddenInputDeviceNames: []
property string locale: "" property string locale: ""
property string timeLocale: ""
property string launcherLastMode: "all" property string launcherLastMode: "all"
property string appDrawerLastMode: "apps" property string appDrawerLastMode: "apps"

View File

@@ -14,7 +14,7 @@ import "settings/SettingsStore.js" as Store
Singleton { Singleton {
id: root id: root
readonly property int settingsConfigVersion: 5 readonly property int settingsConfigVersion: 6
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -37,6 +37,18 @@ Singleton {
Custom Custom
} }
enum AnimationVariant {
Material,
Fluent,
Dynamic
}
enum AnimationEffect {
Standard, // 0 — M3: scale-in, rises from below
Directional, // 1 — pure large slide, no scale
Depth // 2 — medium slide with deep depth scale pop
}
enum SuspendBehavior { enum SuspendBehavior {
Suspend, Suspend,
Hibernate, Hibernate,
@@ -149,6 +161,7 @@ Singleton {
property int mangoLayoutRadiusOverride: -1 property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1 property int mangoLayoutBorderSize: -1
property int firstDayOfWeek: -1
property bool use24HourClock: true property bool use24HourClock: true
property bool showSeconds: false property bool showSeconds: false
property bool padHours12Hour: false property bool padHours12Hour: false
@@ -165,6 +178,30 @@ Singleton {
property int modalCustomAnimationDuration: 150 property int modalCustomAnimationDuration: 150
property bool enableRippleEffects: true property bool enableRippleEffects: true
onEnableRippleEffectsChanged: saveSettings() onEnableRippleEffectsChanged: saveSettings()
property int animationVariant: SettingsData.AnimationVariant.Material
onAnimationVariantChanged: saveSettings()
property int motionEffect: SettingsData.AnimationEffect.Standard
onMotionEffectChanged: saveSettings()
property int directionalAnimationMode: 0
onDirectionalAnimationModeChanged: saveSettings()
property bool m3ElevationEnabled: true
onM3ElevationEnabledChanged: saveSettings()
property int m3ElevationIntensity: 12
onM3ElevationIntensityChanged: saveSettings()
property int m3ElevationOpacity: 30
onM3ElevationOpacityChanged: saveSettings()
property string m3ElevationColorMode: "default"
onM3ElevationColorModeChanged: saveSettings()
property string m3ElevationLightDirection: "top"
onM3ElevationLightDirectionChanged: saveSettings()
property string m3ElevationCustomColor: "#000000"
onM3ElevationCustomColorChanged: saveSettings()
property bool modalElevationEnabled: true
onModalElevationEnabledChanged: saveSettings()
property bool popoutElevationEnabled: true
onPopoutElevationEnabledChanged: saveSettings()
property bool barElevationEnabled: true
onBarElevationEnabledChanged: saveSettings()
property string wallpaperFillMode: "Fill" property string wallpaperFillMode: "Fill"
property bool blurredWallpaperLayer: false property bool blurredWallpaperLayer: false
property bool blurWallpaperOnOverview: false property bool blurWallpaperOnOverview: false
@@ -608,7 +645,7 @@ Singleton {
"scrollYBehavior": "workspace", "scrollYBehavior": "workspace",
"shadowIntensity": 0, "shadowIntensity": 0,
"shadowOpacity": 60, "shadowOpacity": 60,
"shadowColorMode": "text", "shadowColorMode": "default",
"shadowCustomColor": "#000000", "shadowCustomColor": "#000000",
"clickThrough": false "clickThrough": false
} }

View File

@@ -673,6 +673,232 @@ Singleton {
property color shadowMedium: Qt.rgba(0, 0, 0, 0.08) property color shadowMedium: Qt.rgba(0, 0, 0, 0.08)
property color shadowStrong: Qt.rgba(0, 0, 0, 0.3) property color shadowStrong: Qt.rgba(0, 0, 0, 0.3)
readonly property bool elevationEnabled: typeof SettingsData !== "undefined" && (SettingsData.m3ElevationEnabled ?? true)
readonly property real elevationBlurMax: typeof SettingsData !== "undefined" && SettingsData.m3ElevationIntensity !== undefined ? Math.min(128, Math.max(32, SettingsData.m3ElevationIntensity * 2)) : 64
readonly property real _elevMult: typeof SettingsData !== "undefined" && SettingsData.m3ElevationIntensity !== undefined ? SettingsData.m3ElevationIntensity / 12 : 1
readonly property real _opMult: typeof SettingsData !== "undefined" && SettingsData.m3ElevationOpacity !== undefined ? SettingsData.m3ElevationOpacity / 60 : 1
function normalizeElevationDirection(direction) {
switch (direction) {
case "top":
case "topLeft":
case "topRight":
case "bottom":
case "bottomLeft":
case "bottomRight":
case "left":
case "right":
case "autoBar":
return direction;
default:
return "top";
}
}
readonly property string elevationLightDirection: {
if (typeof SettingsData === "undefined" || !SettingsData.m3ElevationLightDirection)
return "top";
switch (SettingsData.m3ElevationLightDirection) {
case "autoBar":
case "top":
case "topLeft":
case "topRight":
case "bottom":
return SettingsData.m3ElevationLightDirection;
default:
return "top";
}
}
readonly property real _elevDiagRatio: 0.55
readonly property string _globalElevationDirForTokens: {
const normalized = normalizeElevationDirection(elevationLightDirection);
return normalized === "autoBar" ? "top" : normalized;
}
readonly property real _elevDirX: {
switch (_globalElevationDirForTokens) {
case "topLeft":
case "bottomLeft":
case "left":
return 1;
case "topRight":
case "bottomRight":
case "right":
return -1;
default:
return 0;
}
}
readonly property real _elevDirY: {
switch (_globalElevationDirForTokens) {
case "bottom":
case "bottomLeft":
case "bottomRight":
return -1;
case "left":
case "right":
return 0;
default:
return 1;
}
}
readonly property real _elevDirXScale: (_globalElevationDirForTokens === "left" || _globalElevationDirForTokens === "right") ? 1 : _elevDiagRatio
readonly property var elevationLevel1: ({
blurPx: 4 * _elevMult,
offsetX: 1 * _elevMult * _elevDirXScale * _elevDirX,
offsetY: 1 * _elevMult * _elevDirY,
spreadPx: 0,
alpha: 0.2 * _opMult
})
readonly property var elevationLevel2: ({
blurPx: 8 * _elevMult,
offsetX: 4 * _elevMult * _elevDirXScale * _elevDirX,
offsetY: 4 * _elevMult * _elevDirY,
spreadPx: 0,
alpha: 0.25 * _opMult
})
readonly property var elevationLevel3: ({
blurPx: 12 * _elevMult,
offsetX: 6 * _elevMult * _elevDirXScale * _elevDirX,
offsetY: 6 * _elevMult * _elevDirY,
spreadPx: 0,
alpha: 0.3 * _opMult
})
readonly property var elevationLevel4: ({
blurPx: 16 * _elevMult,
offsetX: 8 * _elevMult * _elevDirXScale * _elevDirX,
offsetY: 8 * _elevMult * _elevDirY,
spreadPx: 0,
alpha: 0.3 * _opMult
})
readonly property var elevationLevel5: ({
blurPx: 20 * _elevMult,
offsetX: 10 * _elevMult * _elevDirXScale * _elevDirX,
offsetY: 10 * _elevMult * _elevDirY,
spreadPx: 0,
alpha: 0.3 * _opMult
})
function elevationOffsetMagnitude(level, fallback, direction) {
if (!level) {
return fallback !== undefined ? Math.abs(fallback) : 0;
}
const yMag = Math.abs(level.offsetY !== undefined ? level.offsetY : 0);
if (yMag > 0)
return yMag;
const xMag = Math.abs(level.offsetX !== undefined ? level.offsetX : 0);
if (xMag > 0) {
if (direction === "left" || direction === "right")
return xMag;
return xMag / _elevDiagRatio;
}
return fallback !== undefined ? Math.abs(fallback) : 0;
}
function elevationOffsetXFor(level, direction, fallback) {
const dir = normalizeElevationDirection(direction || elevationLightDirection);
const mag = elevationOffsetMagnitude(level, fallback, dir);
switch (dir) {
case "topLeft":
case "bottomLeft":
return mag * _elevDiagRatio;
case "topRight":
case "bottomRight":
return -mag * _elevDiagRatio;
case "left":
return mag;
case "right":
return -mag;
default:
return 0;
}
}
function elevationOffsetYFor(level, direction, fallback) {
const dir = normalizeElevationDirection(direction || elevationLightDirection);
const mag = elevationOffsetMagnitude(level, fallback, dir);
switch (dir) {
case "bottom":
case "bottomLeft":
case "bottomRight":
return -mag;
case "left":
case "right":
return 0;
default:
return mag;
}
}
function elevationOffsetX(level, fallback) {
return elevationOffsetXFor(level, elevationLightDirection, fallback);
}
function elevationOffsetY(level, fallback) {
return elevationOffsetYFor(level, elevationLightDirection, fallback);
}
function elevationRenderPadding(level, direction, fallbackOffset, extraPadding, minPadding) {
const dir = direction !== undefined ? direction : elevationLightDirection;
const blur = (level && level.blurPx !== undefined) ? Math.max(0, level.blurPx) : 0;
const spread = (level && level.spreadPx !== undefined) ? Math.max(0, level.spreadPx) : 0;
const fallback = fallbackOffset !== undefined ? fallbackOffset : 0;
const extra = extraPadding !== undefined ? extraPadding : 8;
const minPad = minPadding !== undefined ? minPadding : 16;
const offsetX = Math.abs(elevationOffsetXFor(level, dir, fallback));
const offsetY = Math.abs(elevationOffsetYFor(level, dir, fallback));
return Math.max(minPad, blur + spread + Math.max(offsetX, offsetY) + extra);
}
function elevationShadowColor(level) {
const alpha = (level && level.alpha !== undefined) ? level.alpha : 0.3;
let r = 0;
let g = 0;
let b = 0;
if (typeof SettingsData !== "undefined") {
const mode = SettingsData.m3ElevationColorMode || "default";
if (mode === "default") {
r = 0;
g = 0;
b = 0;
} else if (mode === "text") {
r = surfaceText.r;
g = surfaceText.g;
b = surfaceText.b;
} else if (mode === "primary") {
r = primary.r;
g = primary.g;
b = primary.b;
} else if (mode === "surfaceVariant") {
r = surfaceVariant.r;
g = surfaceVariant.g;
b = surfaceVariant.b;
} else if (mode === "custom" && SettingsData.m3ElevationCustomColor) {
const c = Qt.color(SettingsData.m3ElevationCustomColor);
r = c.r;
g = c.g;
b = c.b;
}
}
return Qt.rgba(r, g, b, alpha);
}
function elevationTintOpacity(level) {
if (!level)
return 0;
if (level === elevationLevel1)
return 0.05;
if (level === elevationLevel2)
return 0.08;
if (level === elevationLevel3)
return 0.11;
if (level === elevationLevel4)
return 0.12;
if (level === elevationLevel5)
return 0.14;
return 0.08;
}
readonly property var animationDurations: [ readonly property var animationDurations: [
{ {
"shorter": 0, "shorter": 0,
@@ -734,6 +960,24 @@ Singleton {
"expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1] "expressiveEffects": [0.34, 0.8, 0.34, 1, 1, 1]
} }
// Delegates to AnimVariants.qml for curves, timing, scale, and offsets.
readonly property list<real> variantEnterCurve: AnimVariants.variantEnterCurve
readonly property list<real> variantExitCurve: AnimVariants.variantExitCurve
readonly property list<real> variantModalEnterCurve: AnimVariants.variantModalEnterCurve
readonly property list<real> variantModalExitCurve: AnimVariants.variantModalExitCurve
readonly property list<real> variantPopoutEnterCurve: AnimVariants.variantPopoutEnterCurve
readonly property list<real> variantPopoutExitCurve: AnimVariants.variantPopoutExitCurve
readonly property real variantEnterDurationFactor: AnimVariants.variantEnterDurationFactor
readonly property real variantExitDurationFactor: AnimVariants.variantExitDurationFactor
readonly property real variantOpacityDurationScale: AnimVariants.variantOpacityDurationScale
readonly property bool isDirectionalEffect: AnimVariants.isDirectionalEffect
readonly property bool isDepthEffect: AnimVariants.isDepthEffect
readonly property real effectScaleCollapsed: AnimVariants.effectScaleCollapsed
readonly property real effectAnimOffset: AnimVariants.effectAnimOffset
function variantDuration(baseDuration, entering) { return AnimVariants.variantDuration(baseDuration, entering); }
function variantExitCleanupPadding() { return AnimVariants.variantExitCleanupPadding(); }
function variantCloseInterval(baseDuration) { return AnimVariants.variantCloseInterval(baseDuration); }
readonly property var animationPresetDurations: { readonly property var animationPresetDurations: {
"none": 0, "none": 0,
"short": 250, "short": 250,

View File

@@ -80,6 +80,7 @@ var SPEC = {
hiddenInputDeviceNames: { def: [] }, hiddenInputDeviceNames: { def: [] },
locale: { def: "", onChange: "updateLocale" }, locale: { def: "", onChange: "updateLocale" },
timeLocale: { def: "" },
launcherLastMode: { def: "all" }, launcherLastMode: { def: "all" },
appDrawerLastMode: { def: "apps" }, appDrawerLastMode: { def: "apps" },

View File

@@ -21,7 +21,7 @@ var SPEC = {
widgetColorMode: { def: "default" }, widgetColorMode: { def: "default" },
controlCenterTileColorMode: { def: "primary" }, controlCenterTileColorMode: { def: "primary" },
buttonColorMode: { def: "primary" }, buttonColorMode: { def: "primary" },
cornerRadius: { def: 12, onChange: "updateCompositorLayout" }, cornerRadius: { def: 16, onChange: "updateCompositorLayout" },
niriLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
@@ -32,6 +32,7 @@ var SPEC = {
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" }, mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
firstDayOfWeek: { def: -1 },
use24HourClock: { def: true }, use24HourClock: { def: true },
showSeconds: { def: false }, showSeconds: { def: false },
padHours12Hour: { def: false }, padHours12Hour: { def: false },
@@ -46,6 +47,18 @@ var SPEC = {
modalAnimationSpeed: { def: 1 }, modalAnimationSpeed: { def: 1 },
modalCustomAnimationDuration: { def: 150 }, modalCustomAnimationDuration: { def: 150 },
enableRippleEffects: { def: true }, enableRippleEffects: { def: true },
animationVariant: { def: 0 },
motionEffect: { def: 0 },
directionalAnimationMode: { def: 0 },
m3ElevationEnabled: { def: true },
m3ElevationIntensity: { def: 12 },
m3ElevationOpacity: { def: 30 },
m3ElevationColorMode: { def: "default" },
m3ElevationLightDirection: { def: "top" },
m3ElevationCustomColor: { def: "#000000" },
modalElevationEnabled: { def: true },
popoutElevationEnabled: { def: true },
barElevationEnabled: { def: true },
wallpaperFillMode: { def: "Fill" }, wallpaperFillMode: { def: "Fill" },
blurredWallpaperLayer: { def: false }, blurredWallpaperLayer: { def: false },
blurWallpaperOnOverview: { def: false }, blurWallpaperOnOverview: { def: false },
@@ -431,7 +444,7 @@ var SPEC = {
scrollYBehavior: "workspace", scrollYBehavior: "workspace",
shadowIntensity: 0, shadowIntensity: 0,
shadowOpacity: 60, shadowOpacity: 60,
shadowColorMode: "text", shadowColorMode: "default",
shadowCustomColor: "#000000", shadowCustomColor: "#000000",
clickThrough: false clickThrough: false
}], onChange: "updateBarConfigs" }], onChange: "updateBarConfigs"

View File

@@ -229,6 +229,25 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 5; settings.configVersion = 5;
} }
if (currentVersion < 6) {
console.info("Migrating settings from version", currentVersion, "to version 6");
if (settings.barElevationEnabled === undefined) {
var legacyBars = Array.isArray(settings.barConfigs) ? settings.barConfigs : [];
var hadLegacyBarShadowEnabled = false;
for (var j = 0; j < legacyBars.length; j++) {
var legacyIntensity = Number(legacyBars[j] && legacyBars[j].shadowIntensity);
if (!isNaN(legacyIntensity) && legacyIntensity > 0) {
hadLegacyBarShadowEnabled = true;
break;
}
}
settings.barElevationEnabled = hadLegacyBarShadowEnabled;
}
settings.configVersion = 6;
}
return settings; return settings;
} }

View File

@@ -21,11 +21,37 @@ Item {
required property var workspaceRenameModalLoader required property var workspaceRenameModalLoader
required property var windowRuleModalLoader required property var windowRuleModalLoader
function getFirstBar() { function getPreferredBar(refPropertyName) {
if (!root.dankBarRepeater || root.dankBarRepeater.count === 0) if (!root.dankBarRepeater || root.dankBarRepeater.count === 0)
return null; return null;
const firstLoader = root.dankBarRepeater.itemAt(0);
return firstLoader ? firstLoader.item : null; const focusedScreenName = BarWidgetService.getFocusedScreenName();
const loaders = Array.from({
length: root.dankBarRepeater.count
}, (_, i) => root.dankBarRepeater.itemAt(i));
let currentBar = null;
for (const loader of loaders) {
const instances = loader?.item?.barVariants?.instances || [];
for (const bar of instances) {
if (!bar)
continue;
const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName;
const hasRef = !refPropertyName || !!bar[refPropertyName];
if (hasRef) {
currentBar = bar;
if (onFocusedScreen)
break;
}
}
}
return currentBar;
} }
IpcHandler { IpcHandler {
@@ -97,9 +123,9 @@ Item {
IpcHandler { IpcHandler {
function open(): string { function open(): string {
const bar = root.getFirstBar(); const bar = root.getPreferredBar("controlCenterButtonRef");
if (bar) { if (bar) {
bar.triggerControlCenterOnFocusedScreen(); bar.triggerControlCenter();
return "CONTROL_CENTER_OPEN_SUCCESS"; return "CONTROL_CENTER_OPEN_SUCCESS";
} }
return "CONTROL_CENTER_OPEN_FAILED"; return "CONTROL_CENTER_OPEN_FAILED";
@@ -114,9 +140,14 @@ Item {
} }
function toggle(): string { function toggle(): string {
const bar = root.getFirstBar(); if (root.controlCenterLoader.item?.shouldBeVisible) {
root.controlCenterLoader.item.close();
return "CONTROL_CENTER_TOGGLE_SUCCESS";
}
const bar = root.getPreferredBar("controlCenterButtonRef");
if (bar) { if (bar) {
bar.triggerControlCenterOnFocusedScreen(); bar.triggerControlCenter();
return "CONTROL_CENTER_TOGGLE_SUCCESS"; return "CONTROL_CENTER_TOGGLE_SUCCESS";
} }
return "CONTROL_CENTER_TOGGLE_FAILED"; return "CONTROL_CENTER_TOGGLE_FAILED";
@@ -131,27 +162,37 @@ Item {
IpcHandler { IpcHandler {
function open(tab: string): string { function open(tab: string): string {
root.dankDashPopoutLoader.active = true; const bar = root.getPreferredBar("clockButtonRef");
if (root.dankDashPopoutLoader.item) { if (!bar)
switch (tab.toLowerCase()) { return "DASH_OPEN_FAILED";
case "media":
root.dankDashPopoutLoader.item.currentTabIndex = 1; const dash = root.dankDashPopoutLoader.item;
break; const onSameScreen = dash && dash.shouldBeVisible && dash.triggerScreen?.name === bar.screen?.name;
case "wallpaper":
root.dankDashPopoutLoader.item.currentTabIndex = 2; if (!onSameScreen) {
break; bar.triggerWallpaperBrowser();
case "weather":
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
break;
default:
root.dankDashPopoutLoader.item.currentTabIndex = 0;
break;
}
root.dankDashPopoutLoader.item.setTriggerPosition(Screen.width / 2, Theme.barHeight + Theme.spacingS, 100, "center", Screen);
root.dankDashPopoutLoader.item.dashVisible = true;
return "DASH_OPEN_SUCCESS";
} }
return "DASH_OPEN_FAILED";
if (!root.dankDashPopoutLoader.item)
return "DASH_OPEN_FAILED";
switch (tab.toLowerCase()) {
case "media":
root.dankDashPopoutLoader.item.currentTabIndex = 1;
break;
case "wallpaper":
root.dankDashPopoutLoader.item.currentTabIndex = 2;
break;
case "weather":
root.dankDashPopoutLoader.item.currentTabIndex = SettingsData.weatherEnabled ? 3 : 0;
break;
default:
root.dankDashPopoutLoader.item.currentTabIndex = 0;
break;
}
root.dankDashPopoutLoader.item.dashVisible = true;
return "DASH_OPEN_SUCCESS";
} }
function close(): string { function close(): string {
@@ -163,8 +204,14 @@ Item {
} }
function toggle(tab: string): string { function toggle(tab: string): string {
const bar = root.getFirstBar(); if (root.dankDashPopoutLoader.item?.dashVisible) {
if (bar && bar.triggerWallpaperBrowserOnFocusedScreen()) { root.dankDashPopoutLoader.item.dashVisible = false;
return "DASH_TOGGLE_SUCCESS";
}
const bar = root.getPreferredBar("clockButtonRef");
if (bar) {
bar.triggerWallpaperBrowser();
if (root.dankDashPopoutLoader.item) { if (root.dankDashPopoutLoader.item) {
switch (tab.toLowerCase()) { switch (tab.toLowerCase()) {
case "media": case "media":
@@ -521,8 +568,9 @@ Item {
IpcHandler { IpcHandler {
function wallpaper(): string { function wallpaper(): string {
const bar = root.getFirstBar(); const bar = root.getPreferredBar("clockButtonRef");
if (bar && bar.triggerWallpaperBrowserOnFocusedScreen()) { if (bar) {
bar.triggerWallpaperBrowser();
return "SUCCESS: Toggled wallpaper browser"; return "SUCCESS: Toggled wallpaper browser";
} }
return "ERROR: Failed to toggle wallpaper browser"; return "ERROR: Failed to toggle wallpaper browser";

View File

@@ -86,7 +86,7 @@ Item {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: modal.showKeyboardHints ? (ClipboardConstants.keyboardHintsHeight + Theme.spacingM * 2) : 0 anchors.bottomMargin: (modal.showKeyboardHints ? (ClipboardConstants.keyboardHintsHeight + Theme.spacingM * 2) : 0) + Theme.spacingXS
clip: true clip: true
DankListView { DankListView {
@@ -112,14 +112,7 @@ Item {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return; return;
} }
const itemHeight = ClipboardConstants.itemHeight + spacing; positionViewAtIndex(index, ListView.Contain);
const itemY = index * itemHeight;
const itemBottom = itemY + itemHeight;
if (itemY < contentY) {
contentY = itemY;
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height;
}
} }
onCurrentIndexChanged: { onCurrentIndexChanged: {
@@ -178,14 +171,7 @@ Item {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return; return;
} }
const itemHeight = ClipboardConstants.itemHeight + spacing; positionViewAtIndex(index, ListView.Contain);
const itemY = index * itemHeight;
const itemBottom = itemY + itemHeight;
if (itemY < contentY) {
contentY = itemY;
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height;
}
} }
onCurrentIndexChanged: { onCurrentIndexChanged: {

View File

@@ -31,13 +31,13 @@ Item {
sourceSize.height: 128 sourceSize.height: 128
function tryLoadImage() { function tryLoadImage() {
if (loadQueued || entryType !== "image" || cachedImageData) { if (thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData) {
return; return;
} }
loadQueued = true; thumbnailImage.loadQueued = true;
if (modal.activeImageLoads < modal.maxConcurrentLoads) { if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++; modal.activeImageLoads++;
loadImage(); thumbnailImage.loadImage();
} else { } else {
retryTimer.restart(); retryTimer.restart();
} }
@@ -47,7 +47,7 @@ Item {
DMSService.sendRequest("clipboard.getEntry", { DMSService.sendRequest("clipboard.getEntry", {
"id": entry.id "id": entry.id
}, function (response) { }, function (response) {
loadQueued = false; thumbnailImage.loadQueued = false;
if (modal.activeImageLoads > 0) { if (modal.activeImageLoads > 0) {
modal.activeImageLoads--; modal.activeImageLoads--;
} }
@@ -57,7 +57,7 @@ Item {
} }
const data = response.result?.data; const data = response.result?.data;
if (data) { if (data) {
cachedImageData = data; thumbnailImage.cachedImageData = data;
} }
}); });
} }

View File

@@ -26,15 +26,15 @@ Item {
property bool closeOnBackgroundClick: true property bool closeOnBackgroundClick: true
property string animationType: "scale" property string animationType: "scale"
property int animationDuration: Theme.modalAnimationDuration property int animationDuration: Theme.modalAnimationDuration
property real animationScaleCollapsed: 0.96 property real animationScaleCollapsed: Theme.effectScaleCollapsed
property real animationOffset: Theme.spacingL property real animationOffset: Theme.effectAnimOffset
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial property list<real> animationEnterCurve: Theme.variantModalEnterCurve
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized property list<real> animationExitCurve: Theme.variantModalExitCurve
property color backgroundColor: Theme.surfaceContainer property color backgroundColor: Theme.surfaceContainer
property color borderColor: Theme.outlineMedium property color borderColor: Theme.outlineMedium
property real borderWidth: 1 property real borderWidth: 0
property real cornerRadius: Theme.cornerRadius property real cornerRadius: Theme.cornerRadius
property bool enableShadow: false property bool enableShadow: true
property alias modalFocusScope: focusScope property alias modalFocusScope: focusScope
property bool shouldBeVisible: false property bool shouldBeVisible: false
property bool shouldHaveFocus: shouldBeVisible property bool shouldHaveFocus: shouldBeVisible
@@ -44,11 +44,13 @@ Item {
property bool keepPopoutsOpen: false property bool keepPopoutsOpen: false
property var customKeyboardFocus: null property var customKeyboardFocus: null
property bool useOverlayLayer: false property bool useOverlayLayer: false
property real frozenMotionOffsetX: 0
property real frozenMotionOffsetY: 0
readonly property alias contentWindow: contentWindow readonly property alias contentWindow: contentWindow
readonly property alias clickCatcher: clickCatcher readonly property alias clickCatcher: clickCatcher
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground
readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground readonly property bool useSingleWindow: CompositorService.isHyprland
signal opened signal opened
signal dialogClosed signal dialogClosed
@@ -58,19 +60,34 @@ Item {
function open() { function open() {
closeTimer.stop(); closeTimer.stop();
animationsEnabled = false;
frozenMotionOffsetX = modalContainer ? modalContainer.offsetX : 0;
frozenMotionOffsetY = modalContainer ? modalContainer.offsetY : animationOffset;
const focusedScreen = CompositorService.getFocusedScreen(); const focusedScreen = CompositorService.getFocusedScreen();
if (focusedScreen) { if (focusedScreen) {
contentWindow.screen = focusedScreen; contentWindow.screen = focusedScreen;
if (!useSingleWindow) if (!useSingleWindow)
clickCatcher.screen = focusedScreen; clickCatcher.screen = focusedScreen;
} }
if (Theme.isDirectionalEffect || root.useBackground) {
if (!useSingleWindow)
clickCatcher.visible = true;
contentWindow.visible = true;
}
ModalManager.openModal(root); ModalManager.openModal(root);
shouldBeVisible = true;
if (!useSingleWindow) Qt.callLater(() => {
clickCatcher.visible = true; animationsEnabled = true;
contentWindow.visible = true; shouldBeVisible = true;
shouldHaveFocus = false; if (!useSingleWindow && !clickCatcher.visible)
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible)); clickCatcher.visible = true;
if (!contentWindow.visible)
contentWindow.visible = true;
shouldHaveFocus = false;
Qt.callLater(() => shouldHaveFocus = Qt.binding(() => shouldBeVisible));
});
} }
function close() { function close() {
@@ -131,7 +148,7 @@ Item {
Timer { Timer {
id: closeTimer id: closeTimer
interval: animationDuration + 50 interval: Theme.variantCloseInterval(animationDuration)
onTriggered: { onTriggered: {
if (shouldBeVisible) if (shouldBeVisible)
return; return;
@@ -142,7 +159,21 @@ Item {
} }
} }
readonly property real shadowBuffer: 5 readonly property var shadowLevel: Theme.elevationLevel3
readonly property real shadowFallbackOffset: 6
readonly property real shadowRenderPadding: (root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowMotionPadding: {
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 && Theme.isDirectionalEffect)
return 0; // Wayland native overlap mask
if (animationType === "slide")
return 30;
if (Theme.isDirectionalEffect)
return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.9);
if (Theme.isDepthEffect)
return Math.max(Math.max(0, animationOffset), Math.max(alignedWidth, alignedHeight) * 0.35);
return Math.max(0, animationOffset);
}
readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr)
readonly property real alignedWidth: Theme.px(modalWidth, dpr) readonly property real alignedWidth: Theme.px(modalWidth, dpr)
readonly property real alignedHeight: Theme.px(modalHeight, dpr) readonly property real alignedHeight: Theme.px(modalHeight, dpr)
@@ -201,9 +232,26 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: root.closeOnBackgroundClick && root.shouldBeVisible enabled: !root.useSingleWindow && root.closeOnBackgroundClick && root.shouldBeVisible
onClicked: root.backgroundClicked() onClicked: root.backgroundClicked()
} }
Rectangle {
anchors.fill: parent
z: -1
color: "black"
opacity: (!root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
visible: opacity > 0
Behavior on opacity {
enabled: root.animationsEnabled && !Theme.isDirectionalEffect
NumberAnimation {
duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
}
} }
PanelWindow { PanelWindow {
@@ -246,9 +294,12 @@ Item {
bottom: root.useSingleWindow bottom: root.useSingleWindow
} }
readonly property real actualMarginLeft: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr))
readonly property real actualMarginTop: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr))
WlrLayershell.margins { WlrLayershell.margins {
left: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedX - shadowBuffer, dpr)) left: actualMarginLeft
top: root.useSingleWindow ? 0 : Math.max(0, Theme.snap(root.alignedY - shadowBuffer, dpr)) top: actualMarginTop
right: 0 right: 0
bottom: 0 bottom: 0
} }
@@ -278,13 +329,14 @@ Item {
anchors.fill: parent anchors.fill: parent
z: -1 z: -1
color: "black" color: "black"
opacity: root.useBackground ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0 opacity: (root.useSingleWindow && root.useBackground) ? (root.shouldBeVisible ? root.backgroundOpacity : 0) : 0
visible: root.useBackground visible: opacity > 0
Behavior on opacity { Behavior on opacity {
enabled: root.animationsEnabled enabled: root.animationsEnabled && !Theme.isDirectionalEffect
DankAnim { NumberAnimation {
duration: root.animationDuration duration: Math.round(Theme.variantDuration(root.animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
@@ -292,8 +344,8 @@ Item {
Item { Item {
id: modalContainer id: modalContainer
x: root.useSingleWindow ? root.alignedX : shadowBuffer x: (root.useSingleWindow ? root.alignedX : (root.alignedX - contentWindow.actualMarginLeft)) + Theme.snap(animX, root.dpr)
y: root.useSingleWindow ? root.alignedY : shadowBuffer y: (root.useSingleWindow ? root.alignedY : (root.alignedY - contentWindow.actualMarginTop)) + Theme.snap(animY, root.dpr)
width: root.alignedWidth width: root.alignedWidth
height: root.alignedHeight height: root.alignedHeight
@@ -309,45 +361,117 @@ Item {
} }
readonly property bool slide: root.animationType === "slide" readonly property bool slide: root.animationType === "slide"
readonly property real offsetX: slide ? 15 : 0 readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property real offsetY: slide ? -30 : root.animationOffset readonly property bool depthEffect: Theme.isDepthEffect
readonly property real directionalTravel: Math.max(root.animationOffset, Math.max(root.alignedWidth, root.alignedHeight) * 0.8)
property real animX: 0 readonly property real depthTravel: Math.max(root.animationOffset * 0.8, 36)
property real animY: 0 readonly property real customAnchorX: root.alignedX + root.alignedWidth * 0.5
property real scaleValue: root.animationScaleCollapsed readonly property real customAnchorY: root.alignedY + root.alignedHeight * 0.5
readonly property real customDistLeft: customAnchorX
onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr) readonly property real customDistRight: root.screenWidth - customAnchorX
onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr) readonly property real customDistTop: customAnchorY
readonly property real customDistBottom: root.screenHeight - customAnchorY
Connections { readonly property real offsetX: {
target: root if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect)
function onShouldBeVisibleChanged() { return 0;
modalContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetX, root.dpr); if (slide && !directionalEffect && !depthEffect)
modalContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : modalContainer.offsetY, root.dpr); return 15;
modalContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; if (directionalEffect) {
switch (root.positioning) {
case "top-right":
return 0;
case "custom":
if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom)
return -directionalTravel;
if (customDistRight <= customDistTop && customDistRight <= customDistBottom)
return directionalTravel;
return 0;
default:
return 0;
}
} }
if (depthEffect) {
switch (root.positioning) {
case "top-right":
return 0;
case "custom":
if (customDistLeft <= customDistRight && customDistLeft <= customDistTop && customDistLeft <= customDistBottom)
return -depthTravel;
if (customDistRight <= customDistTop && customDistRight <= customDistBottom)
return depthTravel;
return 0;
default:
return 0;
}
}
return 0;
} }
readonly property real offsetY: {
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect)
return 0;
if (slide && !directionalEffect && !depthEffect)
return -30;
if (directionalEffect) {
switch (root.positioning) {
case "top-right":
return -Math.max(directionalTravel * 0.65, 96);
case "custom":
if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight)
return -directionalTravel;
if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight)
return directionalTravel;
return 0;
default:
// Default to sliding down from top when centered
return -Math.max(directionalTravel, root.screenHeight * 0.24);
}
}
if (depthEffect) {
switch (root.positioning) {
case "top-right":
return -depthTravel * 0.75;
case "custom":
if (customDistTop <= customDistBottom && customDistTop <= customDistLeft && customDistTop <= customDistRight)
return -depthTravel;
if (customDistBottom <= customDistLeft && customDistBottom <= customDistRight)
return depthTravel;
return depthTravel * 0.45;
default:
return -depthTravel;
}
}
return root.animationOffset;
}
property real animX: root.shouldBeVisible ? 0 : root.frozenMotionOffsetX
property real animY: root.shouldBeVisible ? 0 : root.frozenMotionOffsetY
readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed
property real scaleValue: root.shouldBeVisible ? 1.0 : computedScaleCollapsed
Behavior on animX { Behavior on animX {
enabled: root.animationsEnabled enabled: root.animationsEnabled
DankAnim { NumberAnimation {
duration: root.animationDuration duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
Behavior on animY { Behavior on animY {
enabled: root.animationsEnabled enabled: root.animationsEnabled
DankAnim { NumberAnimation {
duration: root.animationDuration duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
Behavior on scaleValue { Behavior on scaleValue {
enabled: root.animationsEnabled enabled: root.animationsEnabled
DankAnim { NumberAnimation {
duration: root.animationDuration duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
@@ -363,26 +487,29 @@ Item {
id: animatedContent id: animatedContent
anchors.fill: parent anchors.fill: parent
clip: false clip: false
opacity: root.shouldBeVisible ? 1 : 0 opacity: Theme.isDirectionalEffect ? 1 : (root.shouldBeVisible ? 1 : 0)
scale: modalContainer.scaleValue scale: modalContainer.scaleValue
x: Theme.snap(modalContainer.animX, root.dpr) + (parent.width - width) * (1 - modalContainer.scaleValue) * 0.5 transformOrigin: Item.Center
y: Theme.snap(modalContainer.animY, root.dpr) + (parent.height - height) * (1 - modalContainer.scaleValue) * 0.5
Behavior on opacity { Behavior on opacity {
enabled: root.animationsEnabled enabled: root.animationsEnabled && !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: animationDuration duration: Math.round(Theme.variantDuration(animationDuration, root.shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
Rectangle { ElevationShadow {
id: modalShadowLayer
anchors.fill: parent anchors.fill: parent
color: root.backgroundColor level: root.shadowLevel
border.color: root.borderColor fallbackOffset: root.shadowFallbackOffset
border.width: root.borderWidth targetRadius: root.cornerRadius
radius: root.cornerRadius targetColor: root.backgroundColor
borderColor: root.borderColor
borderWidth: root.borderWidth
shadowEnabled: root.enableShadow && Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
} }
FocusScope { FocusScope {

View File

@@ -51,6 +51,15 @@ Item {
} }
} }
onSearchModeChanged: {
if (searchMode === "apps") {
_loadAppCategories();
} else {
appCategory = "";
appCategories = [];
}
}
Connections { Connections {
target: SettingsData target: SettingsData
function onSortAppsAlphabeticallyChanged() { function onSortAppsAlphabeticallyChanged() {
@@ -65,8 +74,12 @@ Item {
if (!active) if (!active)
return; return;
_clearModeCache(); _clearModeCache();
if (!searchQuery && searchMode === "all") if (searchMode === "apps") {
_loadAppCategories();
performSearch(); performSearch();
} else if (!searchQuery && searchMode === "all") {
performSearch();
}
} }
} }
@@ -171,6 +184,8 @@ Item {
property string activePluginName: "" property string activePluginName: ""
property var activePluginCategories: [] property var activePluginCategories: []
property string activePluginCategory: "" property string activePluginCategory: ""
property string appCategory: ""
property var appCategories: []
function getSectionViewMode(sectionId) { function getSectionViewMode(sectionId) {
if (sectionId === "browse_plugins") if (sectionId === "browse_plugins")
@@ -364,6 +379,8 @@ Item {
activePluginName = ""; activePluginName = "";
activePluginCategories = []; activePluginCategories = [];
activePluginCategory = ""; activePluginCategory = "";
appCategory = "";
appCategories = [];
pluginFilter = ""; pluginFilter = "";
collapsedSections = {}; collapsedSections = {};
_clearModeCache(); _clearModeCache();
@@ -408,6 +425,19 @@ Item {
performSearch(); performSearch();
} }
function setAppCategory(category) {
if (appCategory === category)
return;
appCategory = category;
_queryDrivenSearch = true;
_clearModeCache();
performSearch();
}
function _loadAppCategories() {
appCategories = AppSearchService.getAllCategories();
}
function setFileSearchType(type) { function setFileSearchType(type) {
if (fileSearchType === type) if (fileSearchType === type)
return; return;
@@ -592,8 +622,9 @@ Item {
} }
if (searchMode === "apps") { if (searchMode === "apps") {
var isCategoryFiltered = appCategory && appCategory !== I18n.tr("All");
var cachedSections = AppSearchService.getCachedDefaultSections(); var cachedSections = AppSearchService.getCachedDefaultSections();
if (cachedSections && !searchQuery) { if (cachedSections && !searchQuery && !isCategoryFiltered) {
var modeCache = _getCachedModeData("apps"); var modeCache = _getCachedModeData("apps");
if (modeCache) { if (modeCache) {
_applyHighlights(modeCache.sections, ""); _applyHighlights(modeCache.sections, "");
@@ -623,9 +654,23 @@ Item {
return; return;
} }
var apps = searchApps(searchQuery); if (isCategoryFiltered) {
for (var i = 0; i < apps.length; i++) { var rawApps = AppSearchService.getAppsInCategory(appCategory);
allItems.push(apps[i]); for (var i = 0; i < rawApps.length; i++) {
allItems.push(getOrTransformApp(rawApps[i]));
}
// Also include core apps (DMS Settings etc.) that match this category
var allCoreApps = AppSearchService.getCoreApps("");
for (var i = 0; i < allCoreApps.length; i++) {
var coreAppCats = AppSearchService.getCategoriesForApp(allCoreApps[i]);
if (coreAppCats.indexOf(appCategory) !== -1)
allItems.push(transformCoreApp(allCoreApps[i]));
}
} else {
var apps = searchApps(searchQuery);
for (var i = 0; i < apps.length; i++) {
allItems.push(apps[i]);
}
} }
var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem); var scoredItems = Scorer.scoreItems(allItems, searchQuery, getFrecencyForItem);

View File

@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Hyprland import Quickshell.Hyprland
@@ -13,6 +14,7 @@ Item {
property bool spotlightOpen: false property bool spotlightOpen: false
property bool keyboardActive: false property bool keyboardActive: false
property bool contentVisible: false property bool contentVisible: false
readonly property bool launcherMotionVisible: Theme.isDirectionalEffect ? spotlightOpen : _motionActive
property var spotlightContent: launcherContentLoader.item property var spotlightContent: launcherContentLoader.item
property bool openedFromOverview: false property bool openedFromOverview: false
property bool isClosing: false property bool isClosing: false
@@ -22,8 +24,14 @@ Item {
property string _pendingMode: "" property string _pendingMode: ""
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
// Animation state — matches DankPopout/DankModal pattern
property bool animationsEnabled: true
property bool _motionActive: false
property real _frozenMotionX: 0
property real _frozenMotionY: 0
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property var effectiveScreen: launcherWindow.screen readonly property var effectiveScreen: contentWindow.screen
readonly property real screenWidth: effectiveScreen?.width ?? 1920 readonly property real screenWidth: effectiveScreen?.width ?? 1920
readonly property real screenHeight: effectiveScreen?.height ?? 1080 readonly property real screenHeight: effectiveScreen?.height ?? 1080
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
@@ -75,7 +83,35 @@ Item {
return Theme.primary; return Theme.primary;
} }
} }
readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 1 readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0
// Shadow padding for the content window (render padding only, no motion padding)
readonly property var shadowLevel: Theme.elevationLevel3
readonly property real shadowFallbackOffset: 6
readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr)
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
readonly property real alignedX: Theme.snap(modalX, dpr)
readonly property real alignedY: Theme.snap(modalY, dpr)
// For directional/depth: window extends from screen top (content slides within)
// For standard: small window tightly around the modal + shadow padding
readonly property bool _needsExtendedWindow: Theme.isDirectionalEffect || Theme.isDepthEffect
// Content window geometry
readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr)
readonly property real _cwMarginTop: _needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr)
readonly property real _cwWidth: alignedWidth + shadowPad * 2
readonly property real _cwHeight: {
if (Theme.isDirectionalEffect)
return screenHeight + shadowPad;
if (Theme.isDepthEffect)
return alignedY + alignedHeight + shadowPad;
return alignedHeight + shadowPad * 2;
}
// Where the content container sits inside the content window
readonly property real _ccX: shadowPad
readonly property real _ccY: _needsExtendedWindow ? alignedY : shadowPad
signal dialogClosed signal dialogClosed
@@ -96,7 +132,8 @@ Item {
if (!spotlightContent) if (!spotlightContent)
return; return;
contentVisible = true; contentVisible = true;
spotlightContent.searchField.forceActiveFocus(); // NOTE: forceActiveFocus() is deliberately NOT called here.
// It is deferred to after animation starts to avoid compositor IPC stalls.
if (spotlightContent.searchField) { if (spotlightContent.searchField) {
spotlightContent.searchField.text = query; spotlightContent.searchField.text = query;
@@ -129,40 +166,59 @@ Item {
} }
} }
function show() { function _openCommon(query, mode) {
closeCleanupTimer.stop(); closeCleanupTimer.stop();
isClosing = false; isClosing = false;
openedFromOverview = false; openedFromOverview = false;
var focusedScreen = CompositorService.getFocusedScreen(); // Disable animations so the snap is instant
if (focusedScreen) animationsEnabled = false;
launcherWindow.screen = focusedScreen;
spotlightOpen = true; // Freeze the collapsed offsets (they depend on height which could change)
keyboardActive = true; _frozenMotionX = contentContainer ? contentContainer.collapsedMotionX : 0;
_frozenMotionY = contentContainer ? contentContainer.collapsedMotionY : (Theme.isDirectionalEffect ? Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1) : -Theme.effectAnimOffset);
var focusedScreen = CompositorService.getFocusedScreen();
if (focusedScreen) {
backgroundWindow.screen = focusedScreen;
contentWindow.screen = focusedScreen;
}
// _motionActive = false ensures motionX/Y snap to frozen collapsed position
_motionActive = false;
// Make windows visible but do NOT request keyboard focus yet
ModalManager.openModal(root); ModalManager.openModal(root);
spotlightOpen = true;
backgroundWindow.visible = true;
contentWindow.visible = true;
if (useHyprlandFocusGrab) if (useHyprlandFocusGrab)
focusGrab.active = true; focusGrab.active = true;
_ensureContentLoadedAndInitialize("", ""); // Load content and initialize (but no forceActiveFocus — that's deferred)
_ensureContentLoadedAndInitialize(query || "", mode || "");
// Frame 1: enable animations and trigger enter motion
Qt.callLater(() => {
root.animationsEnabled = true;
root._motionActive = true;
// Frame 2: request keyboard focus + activate search field
// Double-deferred to avoid compositor IPC competing with animation frames
Qt.callLater(() => {
root.keyboardActive = true;
if (root.spotlightContent && root.spotlightContent.searchField)
root.spotlightContent.searchField.forceActiveFocus();
});
});
}
function show() {
_openCommon("", "");
} }
function showWithQuery(query) { function showWithQuery(query) {
closeCleanupTimer.stop(); _openCommon(query, "");
isClosing = false;
openedFromOverview = false;
var focusedScreen = CompositorService.getFocusedScreen();
if (focusedScreen)
launcherWindow.screen = focusedScreen;
spotlightOpen = true;
keyboardActive = true;
ModalManager.openModal(root);
if (useHyprlandFocusGrab)
focusGrab.active = true;
_ensureContentLoadedAndInitialize(query, "");
} }
function hide() { function hide() {
@@ -170,13 +226,17 @@ Item {
return; return;
openedFromOverview = false; openedFromOverview = false;
isClosing = true; isClosing = true;
contentVisible = false; // For directional effects, defer contentVisible=false so content stays rendered during exit slide
if (!Theme.isDirectionalEffect)
contentVisible = false;
// Trigger exit animation — Behaviors will animate motionX/Y to frozen collapsed position
_motionActive = false;
keyboardActive = false; keyboardActive = false;
spotlightOpen = false; spotlightOpen = false;
focusGrab.active = false; focusGrab.active = false;
ModalManager.closeModal(root); ModalManager.closeModal(root);
closeCleanupTimer.start(); closeCleanupTimer.start();
} }
@@ -185,21 +245,7 @@ Item {
} }
function showWithMode(mode) { function showWithMode(mode) {
closeCleanupTimer.stop(); _openCommon("", mode);
isClosing = false;
openedFromOverview = false;
var focusedScreen = CompositorService.getFocusedScreen();
if (focusedScreen)
launcherWindow.screen = focusedScreen;
spotlightOpen = true;
keyboardActive = true;
ModalManager.openModal(root);
if (useHyprlandFocusGrab)
focusGrab.active = true;
_ensureContentLoadedAndInitialize("", mode);
} }
function toggleWithMode(mode) { function toggleWithMode(mode) {
@@ -220,10 +266,13 @@ Item {
Timer { Timer {
id: closeCleanupTimer id: closeCleanupTimer
interval: Theme.modalAnimationDuration + 50 interval: Theme.variantCloseInterval(Theme.modalAnimationDuration)
repeat: false repeat: false
onTriggered: { onTriggered: {
isClosing = false; isClosing = false;
contentVisible = false;
contentWindow.visible = false;
backgroundWindow.visible = false;
if (root.unloadContentOnClose) if (root.unloadContentOnClose)
launcherContentLoader.active = false; launcherContentLoader.active = false;
dialogClosed(); dialogClosed();
@@ -241,7 +290,7 @@ Item {
HyprlandFocusGrab { HyprlandFocusGrab {
id: focusGrab id: focusGrab
windows: [launcherWindow] windows: [contentWindow]
active: false active: false
onCleared: { onCleared: {
@@ -266,7 +315,7 @@ Item {
if (Quickshell.screens.length === 0) if (Quickshell.screens.length === 0)
return; return;
const screen = launcherWindow.screen; const screen = contentWindow.screen;
const screenName = screen?.name; const screenName = screen?.name;
let needsReset = !screen || !screenName; let needsReset = !screen || !screenName;
@@ -288,35 +337,31 @@ Item {
return; return;
root._windowEnabled = false; root._windowEnabled = false;
launcherWindow.screen = newScreen; backgroundWindow.screen = newScreen;
contentWindow.screen = newScreen;
Qt.callLater(() => { Qt.callLater(() => {
root._windowEnabled = true; root._windowEnabled = true;
}); });
} }
} }
// ── Background window: fullscreen, handles darkening + click-to-dismiss ──
PanelWindow { PanelWindow {
id: launcherWindow id: backgroundWindow
visible: root._windowEnabled && (spotlightOpen || isClosing) visible: false
color: "transparent" color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "dms:spotlight" WlrLayershell.namespace: "dms:spotlight:bg"
WlrLayershell.layer: { WlrLayershell.layer: WlrLayershell.Top
switch (Quickshell.env("DMS_MODAL_LAYER")) { WlrLayershell.exclusiveZone: -1
case "bottom": WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
console.error("DankModal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top; WlrLayershell.margins {
case "background": top: contentContainer.dockTop ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 0 ? Theme.px(42, root.dpr) : 0)
console.error("DankModal: 'background' layer is not valid for modals. Defaulting to 'top' layer."); bottom: contentContainer.dockBottom ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 1 ? Theme.px(42, root.dpr) : 0)
return WlrLayershell.Top; left: contentContainer.dockLeft ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 2 ? Theme.px(42, root.dpr) : 0)
case "overlay": right: contentContainer.dockRight ? contentContainer.dockThickness : (typeof SettingsData !== "undefined" && SettingsData.barPosition === 3 ? Theme.px(42, root.dpr) : 0)
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
} }
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors { anchors {
top: true top: true
@@ -326,11 +371,11 @@ Item {
} }
mask: Region { mask: Region {
item: spotlightOpen ? fullScreenMask : null item: (spotlightOpen || isClosing) ? bgFullScreenMask : null
} }
Item { Item {
id: fullScreenMask id: bgFullScreenMask
anchors.fill: parent anchors.fill: parent
} }
@@ -338,13 +383,14 @@ Item {
id: backgroundDarken id: backgroundDarken
anchors.fill: parent anchors.fill: parent
color: "black" color: "black"
opacity: contentVisible && SettingsData.modalDarkenBackground ? 0.5 : 0 opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0
visible: contentVisible || opacity > 0 visible: launcherMotionVisible || opacity > 0
Behavior on opacity { Behavior on opacity {
enabled: root.animationsEnabled && !Theme.isDirectionalEffect
DankAnim { DankAnim {
duration: Theme.modalAnimationDuration duration: Math.round(Theme.variantDuration(Theme.modalAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized easing.bezierCurve: launcherMotionVisible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }
} }
@@ -352,84 +398,240 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
enabled: spotlightOpen enabled: spotlightOpen
onClicked: mouse => { onClicked: root.hide()
var contentX = modalContainer.x; }
var contentY = modalContainer.y; }
var contentW = modalContainer.width;
var contentH = modalContainer.height;
if (mouse.x < contentX || mouse.x > contentX + contentW || mouse.y < contentY || mouse.y > contentY + contentH) { // ── Content window: SMALL, positioned with margins — only renders the modal area ──
root.hide(); PanelWindow {
} id: contentWindow
visible: false
color: "transparent"
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "bottom":
console.error("DankLauncherV2Modal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "background":
console.error("DankLauncherV2Modal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
return WlrLayershell.Top;
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
} }
} }
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
left: true
top: true
}
WlrLayershell.margins {
left: root._cwMarginLeft
top: root._cwMarginTop
}
implicitWidth: root._cwWidth
implicitHeight: root._cwHeight
mask: Region {
item: contentInputMask
}
Item { Item {
id: modalContainer id: contentInputMask
x: root.modalX visible: false
y: root.modalY x: contentContainer.x + contentWrapper.x
width: root.modalWidth y: contentContainer.y + contentWrapper.y
height: root.modalHeight width: root.alignedWidth
visible: contentVisible || opacity > 0 height: root.alignedHeight
opacity: contentVisible ? 1 : 0
scale: contentVisible ? 1 : 0.96
transformOrigin: Item.Center
Behavior on opacity {
DankAnim {
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Behavior on scale {
DankAnim {
duration: Theme.modalAnimationDuration
easing.bezierCurve: contentVisible ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized
}
}
Rectangle {
anchors.fill: parent
color: root.backgroundColor
border.color: root.borderColor
border.width: root.borderWidth
radius: root.cornerRadius
}
MouseArea {
anchors.fill: parent
onPressed: mouse => mouse.accepted = true
}
FocusScope {
anchors.fill: parent
focus: keyboardActive
Loader {
id: launcherContentLoader
anchors.fill: parent
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
asynchronous: false
sourceComponent: LauncherContent {
focus: true
parentModal: root
}
onLoaded: {
if (root._pendingInitialize) {
root._initializeAndShow(root._pendingQuery, root._pendingMode);
root._pendingInitialize = false;
}
}
}
Keys.onEscapePressed: event => {
root.hide();
event.accepted = true;
}
}
} }
}
Item {
id: contentContainer
// For directional/depth: contentContainer is at alignedY from window top (window starts at screen top)
// For standard: contentContainer is at shadowPad from window top (window starts near modal)
x: root._ccX
y: root._ccY
width: root.alignedWidth
height: root.alignedHeight
readonly property int dockEdge: typeof SettingsData !== "undefined" ? SettingsData.dockPosition : 1
readonly property bool dockTop: dockEdge === 0
readonly property bool dockBottom: dockEdge === 1
readonly property bool dockLeft: dockEdge === 2
readonly property bool dockRight: dockEdge === 3
readonly property real dockThickness: typeof SettingsData !== "undefined" && SettingsData.showDock ? Theme.px(SettingsData.dockIconSize + (SettingsData.dockMargin * 2) + SettingsData.dockSpacing + 8, root.dpr) : Theme.px(60, root.dpr)
readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property bool depthEffect: Theme.isDepthEffect
readonly property real collapsedMotionX: {
if (directionalEffect) {
if (dockLeft)
return -(root._ccX + root.alignedWidth + Theme.effectAnimOffset);
if (dockRight)
return root.screenWidth - root._ccX + Theme.effectAnimOffset;
}
if (depthEffect)
return Theme.effectAnimOffset * 0.25;
return 0;
}
readonly property real collapsedMotionY: {
if (directionalEffect) {
if (dockTop)
return -(root._ccY + root.alignedHeight + Theme.effectAnimOffset);
if (dockBottom)
return root.screenHeight - root._ccY + root.shadowPad + Theme.effectAnimOffset;
return 0;
}
if (depthEffect)
return -Math.max(Theme.effectAnimOffset * 0.85, 34);
return -Math.max((root.shadowPad || 0) + Theme.effectAnimOffset, 40);
}
// animX/animY are Behavior-animated — DankPopout pattern
property real animX: 0
property real animY: 0
property real scaleValue: Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed)
Component.onCompleted: {
animX = Theme.snap(root._motionActive ? 0 : collapsedMotionX, root.dpr);
animY = Theme.snap(root._motionActive ? 0 : collapsedMotionY, root.dpr);
scaleValue = root._motionActive ? 1.0 : (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed));
}
Connections {
target: root
function on_MotionActiveChanged() {
contentContainer.animX = Theme.snap(root._motionActive ? 0 : root._frozenMotionX, root.dpr);
contentContainer.animY = Theme.snap(root._motionActive ? 0 : root._frozenMotionY, root.dpr);
contentContainer.scaleValue = root._motionActive ? 1.0 : (Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 ? Theme.effectScaleCollapsed : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed));
}
}
Behavior on animX {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
Behavior on animY {
enabled: root.animationsEnabled
DankAnim {
duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
Behavior on scaleValue {
enabled: root.animationsEnabled && (!Theme.isDirectionalEffect || (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2))
DankAnim {
duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive)
easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
Item {
id: directionalClipMask
readonly property bool shouldClip: Theme.isDirectionalEffect && typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0
readonly property real clipOversize: 2000
clip: shouldClip
x: shouldClip ? (contentContainer.dockRight ? -clipOversize : (contentContainer.dockLeft ? contentContainer.dockThickness - root._ccX : -clipOversize)) : 0
y: shouldClip ? (contentContainer.dockBottom ? -clipOversize : (contentContainer.dockTop ? contentContainer.dockThickness - root._ccY : -clipOversize)) : 0
width: shouldClip ? parent.width + clipOversize + (contentContainer.dockRight ? (root.screenWidth - contentContainer.dockThickness - root._ccX - parent.width) : (contentContainer.dockLeft ? clipOversize : clipOversize)) : parent.width
height: shouldClip ? parent.height + clipOversize + (contentContainer.dockBottom ? (root.screenHeight - contentContainer.dockThickness - root._ccY - parent.height) : (contentContainer.dockTop ? clipOversize : clipOversize)) : parent.height
Item {
id: aligner
x: directionalClipMask.x !== 0 ? -directionalClipMask.x : 0
y: directionalClipMask.y !== 0 ? -directionalClipMask.y : 0
width: contentContainer.width
height: contentContainer.height
// Shadow mirrors contentWrapper position/scale/opacity
ElevationShadow {
id: launcherShadowLayer
width: parent.width
height: parent.height
opacity: contentWrapper.opacity
scale: contentWrapper.scale
x: contentWrapper.x
y: contentWrapper.y
level: root.shadowLevel
fallbackOffset: root.shadowFallbackOffset
targetColor: root.backgroundColor
borderColor: root.borderColor
borderWidth: root.borderWidth
targetRadius: root.cornerRadius
shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
}
// contentWrapper moves inside static contentContainer — DankPopout pattern
Item {
id: contentWrapper
width: parent.width
height: parent.height
opacity: Theme.isDirectionalEffect ? 1 : (launcherMotionVisible ? 1 : 0)
visible: opacity > 0
scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
Behavior on opacity {
enabled: root.animationsEnabled && !Theme.isDirectionalEffect
DankAnim {
duration: Math.round(Theme.variantDuration(Theme.modalAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
easing.bezierCurve: launcherMotionVisible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
MouseArea {
anchors.fill: parent
onPressed: mouse => mouse.accepted = true
}
FocusScope {
anchors.fill: parent
focus: keyboardActive
Loader {
id: launcherContentLoader
anchors.fill: parent
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
asynchronous: false
sourceComponent: LauncherContent {
focus: true
parentModal: root
}
onLoaded: {
if (root._pendingInitialize) {
root._initializeAndShow(root._pendingQuery, root._pendingMode);
root._pendingInitialize = false;
}
}
}
Keys.onEscapePressed: event => {
root.hide();
event.accepted = true;
}
}
} // contentWrapper
} // aligner
} // directionalClipMask
} // contentContainer
} // PanelWindow
} }

View File

@@ -86,7 +86,7 @@ FocusScope {
Controller { Controller {
id: controller id: controller
active: root.parentModal?.spotlightOpen ?? true active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
viewModeContext: root.viewModeContext viewModeContext: root.viewModeContext
onItemExecuted: { onItemExecuted: {
@@ -462,7 +462,7 @@ FocusScope {
showClearButton: true showClearButton: true
textColor: Theme.surfaceText textColor: Theme.surfaceText
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
enabled: root.parentModal ? root.parentModal.spotlightOpen : true enabled: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
placeholderText: "" placeholderText: ""
ignoreUpDownKeys: true ignoreUpDownKeys: true
ignoreTabKeys: true ignoreTabKeys: true
@@ -496,8 +496,9 @@ FocusScope {
Row { Row {
id: categoryRow id: categoryRow
width: parent.width width: parent.width
height: controller.activePluginCategories.length > 0 ? 36 : 0 readonly property bool showPluginCategories: controller.activePluginCategories.length > 0
visible: controller.activePluginCategories.length > 0 height: showPluginCategories ? 36 : 0
visible: showPluginCategories
spacing: Theme.spacingS spacing: Theme.spacingS
clip: true clip: true
@@ -511,6 +512,7 @@ FocusScope {
DankDropdown { DankDropdown {
id: categoryDropdown id: categoryDropdown
visible: categoryRow.showPluginCategories
width: Math.min(200, parent.width) width: Math.min(200, parent.width)
compactMode: true compactMode: true
dropdownWidth: 200 dropdownWidth: 200
@@ -694,7 +696,13 @@ FocusScope {
Item { Item {
width: parent.width width: parent.width
height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2) height: parent.height - searchField.height - categoryRow.height - fileFilterRow.height - actionPanel.height - Theme.spacingXS * ((categoryRow.visible ? 1 : 0) + (fileFilterRow.visible ? 1 : 0) + 2)
opacity: root.parentModal?.isClosing ? 0 : 1 opacity: {
if (!root.parentModal)
return 1;
if (Theme.isDirectionalEffect && root.parentModal.isClosing)
return 1;
return root.parentModal.isClosing ? 0 : 1;
}
ResultsList { ResultsList {
id: resultsList id: resultsList
@@ -789,7 +797,7 @@ FocusScope {
Image { Image {
width: 40 width: 40
height: 40 height: 40
source: editingApp?.icon ? "image://icon/" + editingApp.icon : "image://icon/application-x-executable" source: Paths.resolveIconUrl(editingApp?.icon || "application-x-executable")
sourceSize.width: 40 sourceSize.width: 40
sourceSize.height: 40 sourceSize.height: 40
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit

View File

@@ -1,7 +1,9 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls
import qs.Common import qs.Common
import qs.Services
import qs.Widgets import qs.Widgets
Rectangle { Rectangle {
@@ -35,21 +37,190 @@ Rectangle {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS spacing: Theme.spacingS
// Whether the apps category picker should replace the plain title
readonly property bool hasAppCategories: root.section?.id === "apps" && (root.controller?.appCategories?.length ?? 0) > 0
DankIcon { DankIcon {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
// Hide section icon when the category chip already shows one
visible: !leftContent.hasAppCategories
name: root.section?.icon ?? "folder" name: root.section?.icon ?? "folder"
size: 16 size: 16
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
// Plain title — hidden when the category chip is shown
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: !leftContent.hasAppCategories
text: root.section?.title ?? "" text: root.section?.title ?? ""
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium font.weight: Font.Medium
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
} }
// Compact inline category chip — only visible on the apps section
Item {
id: categoryChip
visible: leftContent.hasAppCategories
anchors.verticalCenter: parent.verticalCenter
// Size to content with a fixed-min width so it doesn't jump around
width: chipRow.implicitWidth + Theme.spacingM * 2
height: 24
readonly property string currentCategory: root.controller?.appCategory || (root.controller?.appCategories?.length > 0 ? root.controller.appCategories[0] : "")
readonly property var iconMap: {
const cats = root.controller?.appCategories ?? [];
const m = {};
cats.forEach(c => { m[c] = AppSearchService.getCategoryIcon(c); });
return m;
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: chipArea.containsMouse || categoryPopup.visible ? Theme.surfaceContainerHigh : "transparent"
border.color: categoryPopup.visible ? Theme.primary : Theme.outlineMedium
border.width: categoryPopup.visible ? 2 : 1
}
Row {
id: chipRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: categoryChip.iconMap[categoryChip.currentCategory] ?? "apps"
size: 14
color: Theme.surfaceText
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: categoryChip.currentCategory
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
}
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: categoryPopup.visible ? "expand_less" : "expand_more"
size: 14
color: Theme.surfaceVariantText
}
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (categoryPopup.visible) {
categoryPopup.close();
} else {
const pos = categoryChip.mapToItem(Overlay.overlay, 0, 0);
categoryPopup.x = pos.x;
categoryPopup.y = pos.y + categoryChip.height + 4;
categoryPopup.open();
}
}
}
Popup {
id: categoryPopup
parent: Overlay.overlay
width: Math.max(categoryChip.width, 180)
padding: 0
modal: true
dim: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle { color: "transparent" }
contentItem: Rectangle {
radius: Theme.cornerRadius
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 1)
border.color: Theme.primary
border.width: 2
ElevationShadow {
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
fallbackOffset: 4
targetRadius: parent.radius
targetColor: parent.color
borderColor: parent.border.color
borderWidth: parent.border.width
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled
}
ListView {
id: categoryList
anchors.fill: parent
anchors.margins: Theme.spacingS
model: root.controller?.appCategories ?? []
spacing: 2
clip: true
interactive: contentHeight > height
implicitHeight: contentHeight
delegate: Rectangle {
id: catDelegate
required property string modelData
required property int index
width: categoryList.width
height: 32
radius: Theme.cornerRadius
readonly property bool isCurrent: categoryChip.currentCategory === modelData
color: isCurrent ? Theme.primaryHover : catArea.containsMouse ? Theme.primaryHoverLight : "transparent"
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
anchors.verticalCenter: parent.verticalCenter
name: categoryChip.iconMap[catDelegate.modelData] ?? "apps"
size: 16
color: catDelegate.isCurrent ? Theme.primary : Theme.surfaceText
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: catDelegate.modelData
font.pixelSize: Theme.fontSizeMedium
color: catDelegate.isCurrent ? Theme.primary : Theme.surfaceText
font.weight: catDelegate.isCurrent ? Font.Medium : Font.Normal
}
}
MouseArea {
id: catArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.controller)
root.controller.setAppCategory(catDelegate.modelData);
categoryPopup.close();
}
}
}
}
}
// Size to list content, cap at 10 visible items
height: Math.min((root.controller?.appCategories?.length ?? 0) * 34, 10 * 34) + Theme.spacingS * 2 + 4
}
}
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.section?.items?.length ?? 0 text: root.section?.items?.length ?? 0

View File

@@ -106,7 +106,7 @@ DankPopout {
QtObject { QtObject {
id: modalAdapter id: modalAdapter
property bool spotlightOpen: appDrawerPopout.shouldBeVisible property bool spotlightOpen: appDrawerPopout.shouldBeVisible
property bool isClosing: false property bool isClosing: appDrawerPopout.isClosing
function hide() { function hide() {
appDrawerPopout.close(); appDrawerPopout.close();

View File

@@ -87,10 +87,6 @@ Variants {
Component.onCompleted: { Component.onCompleted: {
if (typeof blurWallpaperWindow.updatesEnabled !== "undefined") if (typeof blurWallpaperWindow.updatesEnabled !== "undefined")
blurWallpaperWindow.updatesEnabled = Qt.binding(() => root.effectActive || root._renderSettling || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading); blurWallpaperWindow.updatesEnabled = Qt.binding(() => root.effectActive || root._renderSettling || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading);
if (!source) {
root._renderSettling = false;
}
isInitialized = true; isInitialized = true;
} }
@@ -113,7 +109,7 @@ Variants {
Timer { Timer {
id: renderSettleTimer id: renderSettleTimer
interval: 100 interval: 1000
onTriggered: root._renderSettling = false onTriggered: root._renderSettling = false
} }

View File

@@ -12,7 +12,7 @@ DankPopout {
id: root id: root
layerNamespace: "dms:control-center" layerNamespace: "dms:control-center"
fullHeightSurface: true fullHeightSurface: false
property string expandedSection: "" property string expandedSection: ""
property var triggerScreen: null property var triggerScreen: null
@@ -126,9 +126,11 @@ DankPopout {
z: 5000 z: 5000
Behavior on opacity { Behavior on opacity {
enabled: !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: 200 duration: Theme.shortDuration
easing.type: Easing.OutCubic easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
} }

View File

@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Effects
import QtQuick.Shapes import QtQuick.Shapes
import qs.Common import qs.Common
import qs.Services import qs.Services
@@ -53,15 +52,43 @@ Item {
} }
} }
readonly property real shadowIntensity: barConfig?.shadowIntensity ?? 0 // M3 elevation shadow — Level 2 baseline (navigation bar), with per-bar override support
readonly property bool shadowEnabled: shadowIntensity > 0 readonly property bool hasPerBarOverride: (barConfig?.shadowIntensity ?? 0) > 0
readonly property int blurMax: 64 readonly property var elevLevel: Theme.elevationLevel2
readonly property real shadowBlurPx: shadowIntensity * 0.2 readonly property bool shadowEnabled: (Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)) || hasPerBarOverride
readonly property real shadowBlur: Math.max(0, Math.min(1, shadowBlurPx / blurMax)) readonly property string autoBarShadowDirection: isTop ? "top" : (isBottom ? "bottom" : (isLeft ? "left" : (isRight ? "right" : "top")))
readonly property real shadowOpacity: (barConfig?.shadowOpacity ?? 60) / 100 readonly property string globalShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection
readonly property string shadowColorMode: barConfig?.shadowColorMode ?? "text" readonly property string perBarShadowDirectionMode: barConfig?.shadowDirectionMode ?? "inherit"
readonly property color shadowBaseColor: { readonly property string perBarManualShadowDirection: {
switch (shadowColorMode) { switch (barConfig?.shadowDirection) {
case "top":
case "topLeft":
case "topRight":
case "bottom":
return barConfig.shadowDirection;
default:
return "top";
}
}
readonly property string effectiveShadowDirection: {
if (!hasPerBarOverride)
return globalShadowDirection;
switch (perBarShadowDirectionMode) {
case "autoBar":
return autoBarShadowDirection;
case "manual":
return perBarManualShadowDirection === "autoBar" ? autoBarShadowDirection : perBarManualShadowDirection;
default:
return globalShadowDirection;
}
}
// Per-bar override values (when barConfig.shadowIntensity > 0)
readonly property real overrideBlurPx: (barConfig?.shadowIntensity ?? 0) * 0.2
readonly property real overrideOpacity: (barConfig?.shadowOpacity ?? 60) / 100
readonly property string overrideColorMode: barConfig?.shadowColorMode ?? "default"
readonly property color overrideBaseColor: {
switch (overrideColorMode) {
case "surface": case "surface":
return Theme.surface; return Theme.surface;
case "primary": case "primary":
@@ -71,10 +98,16 @@ Item {
case "custom": case "custom":
return barConfig?.shadowCustomColor ?? "#000000"; return barConfig?.shadowCustomColor ?? "#000000";
default: default:
return Theme.surfaceText; return "#000000";
} }
} }
readonly property color shadowColor: Theme.withAlpha(shadowBaseColor, shadowOpacity * barWindow._backgroundAlpha)
// Resolved values — per-bar override wins if set, otherwise use global M3 elevation
readonly property real shadowBlurPx: hasPerBarOverride ? overrideBlurPx : (elevLevel.blurPx ?? 8)
readonly property color shadowColor: hasPerBarOverride ? Theme.withAlpha(overrideBaseColor, overrideOpacity) : Theme.elevationShadowColor(elevLevel)
readonly property real shadowOffsetMagnitude: hasPerBarOverride ? (overrideBlurPx * 0.5) : Theme.elevationOffsetMagnitude(elevLevel, 4, effectiveShadowDirection)
readonly property real shadowOffsetX: Theme.elevationOffsetXFor(hasPerBarOverride ? null : elevLevel, effectiveShadowDirection, shadowOffsetMagnitude)
readonly property real shadowOffsetY: Theme.elevationOffsetYFor(hasPerBarOverride ? null : elevLevel, effectiveShadowDirection, shadowOffsetMagnitude)
readonly property string mainPath: generatePathForPosition(width, height) readonly property string mainPath: generatePathForPosition(width, height)
readonly property string borderFullPath: generateBorderFullPath(width, height) readonly property string borderFullPath: generateBorderFullPath(width, height)
@@ -118,42 +151,28 @@ Item {
} }
} }
Loader { ElevationShadow {
id: shadowLoader id: barShadow
anchors.fill: parent visible: root.shadowEnabled && root.width > 0 && root.height > 0
active: root.shadowEnabled && mainPathCorrectShape
asynchronous: false
sourceComponent: Item {
anchors.fill: parent
layer.enabled: true // Size to the bar's rectangular body, excluding gothic wing extensions
layer.smooth: true x: root.isRight ? root.wing : 0
layer.samples: 8 y: root.isBottom ? root.wing : 0
layer.textureSize: Qt.size(Math.round(width * barWindow._dpr * 2), Math.round(height * barWindow._dpr * 2)) width: axis.isVertical ? (parent.width - root.wing) : parent.width
layer.effect: MultiEffect { height: axis.isVertical ? parent.height : (parent.height - root.wing)
shadowEnabled: true
shadowBlur: root.shadowBlur
shadowColor: root.shadowColor
shadowVerticalOffset: root.isTop ? root.shadowBlurPx * 0.5 : (root.isBottom ? -root.shadowBlurPx * 0.5 : 0)
shadowHorizontalOffset: root.isLeft ? root.shadowBlurPx * 0.5 : (root.isRight ? -root.shadowBlurPx * 0.5 : 0)
autoPaddingEnabled: true
}
Shape { shadowEnabled: root.shadowEnabled
anchors.fill: parent level: root.hasPerBarOverride ? null : root.elevLevel
preferredRendererType: Shape.CurveRenderer direction: root.effectiveShadowDirection
fallbackOffset: 4
targetRadius: root.rt
targetColor: barWindow._bgColor
ShapePath { shadowBlurPx: root.shadowBlurPx
fillColor: barWindow._bgColor shadowOffsetX: root.shadowOffsetX
strokeColor: "transparent" shadowOffsetY: root.shadowOffsetY
strokeWidth: 0 shadowColor: root.shadowColor
blurMax: Theme.elevationBlurMax
PathSvg {
path: root.mainPath
}
}
}
}
} }
Loader { Loader {

View File

@@ -1117,6 +1117,7 @@ Item {
if (!notificationCenterLoader.item) { if (!notificationCenterLoader.item) {
return; return;
} }
notificationCenterLoader.item.triggerScreen = barWindow.screen;
const effectiveBarConfig = topBarContent.barConfig; const effectiveBarConfig = topBarContent.barConfig;
const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1)); const barPosition = barWindow.axis?.edge === "left" ? 2 : (barWindow.axis?.edge === "right" ? 3 : (barWindow.axis?.edge === "top" ? 0 : 1));
if (notificationCenterLoader.item.setBarContext) { if (notificationCenterLoader.item.setBarContext) {

View File

@@ -140,6 +140,20 @@ PanelWindow {
} }
readonly property real _dpr: CompositorService.getScreenScale(barWindow.screen) readonly property real _dpr: CompositorService.getScreenScale(barWindow.screen)
// Shadow buffer: extra window space for shadow to render beyond bar bounds
readonly property bool _shadowActive: (Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)) || (barConfig?.shadowIntensity ?? 0) > 0
readonly property real _shadowBuffer: {
if (!_shadowActive)
return 0;
const hasOverride = (barConfig?.shadowIntensity ?? 0) > 0;
if (hasOverride) {
const blur = (barConfig.shadowIntensity ?? 0) * 0.2;
const offset = blur * 0.5;
return Theme.snap(Math.max(16, blur + offset + 8), _dpr);
}
return Theme.snap(Theme.elevationRenderPadding(Theme.elevationLevel2, "top", 4, 8, 16), _dpr);
}
property string screenName: modelData.name property string screenName: modelData.name
property bool hasMaximizedToplevel: false property bool hasMaximizedToplevel: false
@@ -354,8 +368,8 @@ PanelWindow {
} }
screen: modelData screen: modelData
implicitHeight: !isVertical ? Theme.px(effectiveBarThickness + effectiveSpacing + ((barConfig?.gothCornersEnabled ?? false) && !hasMaximizedToplevel ? _wingR : 0), _dpr) : 0 implicitHeight: !isVertical ? Theme.px(effectiveBarThickness + effectiveSpacing + ((barConfig?.gothCornersEnabled ?? false) && !hasMaximizedToplevel ? _wingR : 0), _dpr) + _shadowBuffer : 0
implicitWidth: isVertical ? Theme.px(effectiveBarThickness + effectiveSpacing + ((barConfig?.gothCornersEnabled ?? false) && !hasMaximizedToplevel ? _wingR : 0), _dpr) : 0 implicitWidth: isVertical ? Theme.px(effectiveBarThickness + effectiveSpacing + ((barConfig?.gothCornersEnabled ?? false) && !hasMaximizedToplevel ? _wingR : 0), _dpr) + _shadowBuffer : 0
color: "transparent" color: "transparent"
property var nativeInhibitor: null property var nativeInhibitor: null
@@ -552,8 +566,9 @@ PanelWindow {
readonly property var _leftSection: topBarContent ? (barWindow.isVertical ? topBarContent.vLeftSection : topBarContent.hLeftSection) : null readonly property var _leftSection: topBarContent ? (barWindow.isVertical ? topBarContent.vLeftSection : topBarContent.hLeftSection) : null
readonly property var _centerSection: topBarContent ? (barWindow.isVertical ? topBarContent.vCenterSection : topBarContent.hCenterSection) : null readonly property var _centerSection: topBarContent ? (barWindow.isVertical ? topBarContent.vCenterSection : topBarContent.hCenterSection) : null
readonly property var _rightSection: topBarContent ? (barWindow.isVertical ? topBarContent.vRightSection : topBarContent.hRightSection) : null readonly property var _rightSection: topBarContent ? (barWindow.isVertical ? topBarContent.vRightSection : topBarContent.hRightSection) : null
readonly property real _revealProgress: topBarSlide.x + topBarSlide.y
function sectionRect(section, isCenter) { function sectionRect(section, isCenter, _dep) {
if (!section) if (!section)
return { return {
"x": 0, "x": 0,
@@ -582,7 +597,7 @@ PanelWindow {
item: clickThroughEnabled ? null : inputMask item: clickThroughEnabled ? null : inputMask
Region { Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._leftSection, false) : { readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._leftSection, false, barWindow._revealProgress) : {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -595,7 +610,7 @@ PanelWindow {
} }
Region { Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._centerSection, true) : { readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._centerSection, true, barWindow._revealProgress) : {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -608,7 +623,7 @@ PanelWindow {
} }
Region { Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._rightSection, false) : { readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._rightSection, false, barWindow._revealProgress) : {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -619,6 +634,14 @@ PanelWindow {
width: r.w width: r.w
height: r.h height: r.h
} }
Region {
readonly property bool active: barWindow.clickThroughEnabled && !inputMask.showing
x: active ? inputMask.x : 0
y: active ? inputMask.y : 0
width: active ? inputMask.width : 0
height: active ? inputMask.height : 0
}
} }
Item { Item {
@@ -631,7 +654,7 @@ PanelWindow {
Timer { Timer {
id: revealHold id: revealHold
interval: barConfig?.autoHideDelay ?? 250 interval: barWindow.clickThroughEnabled ? Math.max((barConfig?.autoHideDelay ?? 250) * 6, 1500) : (barConfig?.autoHideDelay ?? 250)
repeat: false repeat: false
onTriggered: { onTriggered: {
if (!topBarMouseArea.containsMouse && !topBarCore.hasActivePopout) { if (!topBarMouseArea.containsMouse && !topBarCore.hasActivePopout) {
@@ -689,7 +712,6 @@ PanelWindow {
Connections { Connections {
function onBarConfigChanged() { function onBarConfigChanged() {
topBarCore.autoHide = barConfig?.autoHide ?? false; topBarCore.autoHide = barConfig?.autoHide ?? false;
revealHold.interval = barConfig?.autoHideDelay ?? 250;
} }
target: rootWindow target: rootWindow

View File

@@ -273,7 +273,7 @@ PanelWindow {
IconImage { IconImage {
anchors.fill: parent anchors.fill: parent
source: modelData.icon ? Quickshell.iconPath(modelData.icon, true) : "" source: modelData.icon ? Paths.resolveIconPath(modelData.icon) : ""
smooth: true smooth: true
asynchronous: true asynchronous: true
visible: status === Image.Ready visible: status === Image.Ready

View File

@@ -178,8 +178,9 @@ BasePill {
if (root.popoutTarget && root.popoutTarget.setTriggerPosition) { if (root.popoutTarget && root.popoutTarget.setTriggerPosition) {
const globalPos = parent.mapToItem(null, 0, 0); const globalPos = parent.mapToItem(null, 0, 0);
const currentScreen = root.parentScreen || Screen; const currentScreen = root.parentScreen || Screen;
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, root.barThickness, parent.width); const barPosition = root.axis?.edge === "left" ? 2 : (root.axis?.edge === "right" ? 3 : (root.axis?.edge === "top" ? 0 : 1));
root.popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, root.section, currentScreen); const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, root.barThickness, parent.width, root.barSpacing, barPosition, root.barConfig);
root.popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, root.section, currentScreen, barPosition, root.barThickness, root.barSpacing, root.barConfig);
} }
root.clicked(); root.clicked();
} }
@@ -334,8 +335,9 @@ BasePill {
if (root.popoutTarget && root.popoutTarget.setTriggerPosition) { if (root.popoutTarget && root.popoutTarget.setTriggerPosition) {
const globalPos = mapToItem(null, 0, 0); const globalPos = mapToItem(null, 0, 0);
const currentScreen = root.parentScreen || Screen; const currentScreen = root.parentScreen || Screen;
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, root.barThickness, root.width); const barPosition = root.axis?.edge === "left" ? 2 : (root.axis?.edge === "right" ? 3 : (root.axis?.edge === "top" ? 0 : 1));
root.popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, root.section, currentScreen); const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, root.barThickness, root.width, root.barSpacing, barPosition, root.barConfig);
root.popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, root.section, currentScreen, barPosition, root.barThickness, root.barSpacing, root.barConfig);
} }
root.clicked(); root.clicked();
} }

View File

@@ -940,9 +940,10 @@ BasePill {
} }
})(), overflowMenu.dpr) })(), overflowMenu.dpr)
property real shadowBlurPx: 10 readonly property var elev: Theme.elevationLevel2
property real shadowSpreadPx: 0 property real shadowBlurPx: elev && elev.blurPx !== undefined ? elev.blurPx : 8
property real shadowBaseAlpha: 0.60 property real shadowSpreadPx: elev && elev.spreadPx !== undefined ? elev.spreadPx : 0
property real shadowBaseAlpha: elev && elev.alpha !== undefined ? elev.alpha : 0.25
readonly property real popupSurfaceAlpha: Theme.popupTransparency readonly property real popupSurfaceAlpha: Theme.popupTransparency
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha)) readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha))
@@ -963,37 +964,26 @@ BasePill {
} }
} }
Item { ElevationShadow {
id: bgShadowLayer id: bgShadowLayer
anchors.fill: parent anchors.fill: parent
layer.enabled: true level: menuContainer.elev
fallbackOffset: 4
shadowBlurPx: menuContainer.shadowBlurPx
shadowSpreadPx: menuContainer.shadowSpreadPx
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, menuContainer.effectiveShadowAlpha);
}
targetColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
targetRadius: Theme.cornerRadius
sourceRect.antialiasing: true
sourceRect.smooth: true
shadowEnabled: Theme.elevationEnabled
layer.smooth: true layer.smooth: true
layer.textureSize: Qt.size(Math.round(width * overflowMenu.dpr * 2), Math.round(height * overflowMenu.dpr * 2)) layer.textureSize: Qt.size(Math.round(width * overflowMenu.dpr * 2), Math.round(height * overflowMenu.dpr * 2))
layer.textureMirroring: ShaderEffectSource.MirrorVertically layer.textureMirroring: ShaderEffectSource.MirrorVertically
layer.samples: 4 layer.samples: 4
readonly property int blurMax: 64
layer.effect: MultiEffect {
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, menuContainer.shadowBlurPx / bgShadowLayer.blurMax))
shadowScale: 1 + (2 * menuContainer.shadowSpreadPx) / Math.max(1, Math.min(bgShadowLayer.width, bgShadowLayer.height))
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, menuContainer.effectiveShadowAlpha);
}
}
Rectangle {
anchors.fill: parent
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
antialiasing: true
smooth: true
}
} }
Grid { Grid {
@@ -1412,9 +1402,10 @@ BasePill {
} }
})(), menuWindow.dpr) })(), menuWindow.dpr)
property real shadowBlurPx: 10 readonly property var elev: Theme.elevationLevel2
property real shadowSpreadPx: 0 property real shadowBlurPx: elev && elev.blurPx !== undefined ? elev.blurPx : 8
property real shadowBaseAlpha: 0.60 property real shadowSpreadPx: elev && elev.spreadPx !== undefined ? elev.spreadPx : 0
property real shadowBaseAlpha: elev && elev.alpha !== undefined ? elev.alpha : 0.25
readonly property real popupSurfaceAlpha: Theme.popupTransparency readonly property real popupSurfaceAlpha: Theme.popupTransparency
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha)) readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha))
@@ -1435,35 +1426,24 @@ BasePill {
} }
} }
Item { ElevationShadow {
id: menuBgShadowLayer id: menuBgShadowLayer
anchors.fill: parent anchors.fill: parent
layer.enabled: true level: menuContainer.elev
fallbackOffset: 4
shadowBlurPx: menuContainer.shadowBlurPx
shadowSpreadPx: menuContainer.shadowSpreadPx
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, menuContainer.effectiveShadowAlpha);
}
targetColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
targetRadius: Theme.cornerRadius
sourceRect.antialiasing: true
shadowEnabled: Theme.elevationEnabled
layer.smooth: true layer.smooth: true
layer.textureSize: Qt.size(Math.round(width * menuWindow.dpr), Math.round(height * menuWindow.dpr)) layer.textureSize: Qt.size(Math.round(width * menuWindow.dpr), Math.round(height * menuWindow.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically layer.textureMirroring: ShaderEffectSource.MirrorVertically
readonly property int blurMax: 64
layer.effect: MultiEffect {
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, menuContainer.shadowBlurPx / menuBgShadowLayer.blurMax))
shadowScale: 1 + (2 * menuContainer.shadowSpreadPx) / Math.max(1, Math.min(menuBgShadowLayer.width, menuBgShadowLayer.height))
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, menuContainer.effectiveShadowAlpha);
}
}
Rectangle {
anchors.fill: parent
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
antialiasing: true
}
} }
QsMenuAnchor { QsMenuAnchor {

View File

@@ -177,8 +177,9 @@ BasePill {
if (popoutTarget && popoutTarget.setTriggerPosition) { if (popoutTarget && popoutTarget.setTriggerPosition) {
const globalPos = root.visualContent.mapToItem(null, 0, 0); const globalPos = root.visualContent.mapToItem(null, 0, 0);
const currentScreen = parentScreen || Screen; const currentScreen = parentScreen || Screen;
const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barThickness, root.visualWidth); const barPosition = root.axis?.edge === "left" ? 2 : (root.axis?.edge === "right" ? 3 : (root.axis?.edge === "top" ? 0 : 1));
popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen); const pos = SettingsData.getPopupTriggerPosition(globalPos, currentScreen, barThickness, root.visualWidth, root.barSpacing, barPosition, root.barConfig);
popoutTarget.setTriggerPosition(pos.x, pos.y, pos.width, section, currentScreen, barPosition, barThickness, root.barSpacing, root.barConfig);
} }
root.clicked(); root.clicked();
} }

View File

@@ -16,7 +16,6 @@ DankPopout {
popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 500 popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 500
triggerWidth: 80 triggerWidth: 80
screen: triggerScreen screen: triggerScreen
shouldBeVisible: dashVisible
property bool __focusArmed: false property bool __focusArmed: false
property bool __contentReady: false property bool __contentReady: false

View File

@@ -44,6 +44,43 @@ Item {
property int __volumeHoverCount: 0 property int __volumeHoverCount: 0
readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property bool depthEffect: Theme.isDepthEffect
function panelMotionX(panelWidth, active) {
if (active)
return 0;
if (directionalEffect) {
const travel = Math.max(Theme.effectAnimOffset, panelWidth * 0.85);
return isRightEdge ? -travel : travel;
}
if (depthEffect) {
const travel = Math.max(Theme.effectAnimOffset * 0.7, panelWidth * 0.32);
return isRightEdge ? -travel : travel;
}
return 0;
}
function panelMotionY(panelType, panelHeight, active) {
if (active)
return 0;
if (directionalEffect) {
if (panelType === 2)
return panelHeight * 0.08;
if (panelType === 3)
return -panelHeight * 0.08;
return 0;
}
if (depthEffect) {
if (panelType === 2)
return panelHeight * 0.04;
if (panelType === 3)
return -panelHeight * 0.04;
return 0;
}
return 0;
}
function volumeAreaEntered() { function volumeAreaEntered() {
__volumeHoverCount++; __volumeHoverCount++;
panelEntered(); panelEntered();
@@ -62,41 +99,62 @@ Item {
visible: dropdownType === 1 && volumeAvailable visible: dropdownType === 1 && volumeAvailable
width: 60 width: 60
height: 180 height: 180
x: isRightEdge ? anchorPos.x : anchorPos.x - width x: (isRightEdge ? anchorPos.x : anchorPos.x - width) + panelMotionX(width, dropdownType === 1)
y: anchorPos.y - height / 2 y: anchorPos.y - height / 2 + panelMotionY(1, height, dropdownType === 1)
radius: Theme.cornerRadius * 2 radius: Theme.cornerRadius * 2
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95)
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
border.width: 1 border.width: 1
opacity: dropdownType === 1 ? 1 : 0 opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 1 ? 1 : 0)
scale: dropdownType === 1 ? 1 : 0.96 scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 1 ? 1 : Theme.effectScaleCollapsed)
transformOrigin: isRightEdge ? Item.Left : Item.Right transformOrigin: isRightEdge ? Item.Left : Item.Right
Behavior on opacity { Behavior on opacity {
NumberAnimation { enabled: !Theme.isDirectionalEffect
duration: Theme.expressiveDurations.expressiveDefaultSpatial DankAnim {
easing.type: Easing.BezierSpline duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1) * Theme.variantOpacityDurationScale)
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
Behavior on scale { Behavior on scale {
enabled: !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
layer.enabled: true Behavior on x {
layer.effect: MultiEffect { NumberAnimation {
shadowEnabled: true duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1)
shadowHorizontalOffset: 0 easing.type: Easing.BezierSpline
shadowVerticalOffset: 8 easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
shadowBlur: 1.0 }
shadowColor: Qt.rgba(0, 0, 0, 0.4) }
shadowOpacity: 0.7
Behavior on y {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 1)
easing.type: Easing.BezierSpline
easing.bezierCurve: dropdownType === 1 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
}
}
ElevationShadow {
id: volumeShadowLayer
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
fallbackOffset: 4
targetRadius: volumePanel.radius
targetColor: volumePanel.color
borderColor: volumePanel.border.color
borderWidth: volumePanel.border.width
shadowOpacity: Theme.elevationLevel2 && Theme.elevationLevel2.alpha !== undefined ? Theme.elevationLevel2.alpha : 0.25
shadowEnabled: Theme.elevationEnabled
} }
MouseArea { MouseArea {
@@ -193,44 +251,65 @@ Item {
Rectangle { Rectangle {
id: audioDevicesPanel id: audioDevicesPanel
visible: dropdownType === 2 visible: dropdownType === 2 && activePlayer !== null
width: 280 width: 280
height: Math.max(200, Math.min(280, availableDevices.length * 50 + 100)) height: Math.max(200, Math.min(280, availableDevices.length * 50 + 100))
x: isRightEdge ? anchorPos.x : anchorPos.x - width x: (isRightEdge ? anchorPos.x : anchorPos.x - width) + panelMotionX(width, dropdownType === 2)
y: anchorPos.y - height / 2 y: anchorPos.y - height / 2 + panelMotionY(2, height, dropdownType === 2)
radius: Theme.cornerRadius * 2 radius: Theme.cornerRadius * 2
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98) color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98)
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6)
border.width: 2 border.width: 2
opacity: dropdownType === 2 ? 1 : 0 opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 2 ? 1 : 0)
scale: dropdownType === 2 ? 1 : 0.96 scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 2 ? 1 : Theme.effectScaleCollapsed)
transformOrigin: isRightEdge ? Item.Left : Item.Right transformOrigin: isRightEdge ? Item.Left : Item.Right
Behavior on opacity { Behavior on opacity {
NumberAnimation { enabled: !Theme.isDirectionalEffect
duration: Theme.expressiveDurations.expressiveDefaultSpatial DankAnim {
easing.type: Easing.BezierSpline duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2) * Theme.variantOpacityDurationScale)
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
Behavior on scale { Behavior on scale {
enabled: !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
layer.enabled: true Behavior on x {
layer.effect: MultiEffect { NumberAnimation {
shadowEnabled: true duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2)
shadowHorizontalOffset: 0 easing.type: Easing.BezierSpline
shadowVerticalOffset: 8 easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
shadowBlur: 1.0 }
shadowColor: Qt.rgba(0, 0, 0, 0.4) }
shadowOpacity: 0.7
Behavior on y {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 2)
easing.type: Easing.BezierSpline
easing.bezierCurve: dropdownType === 2 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
}
}
ElevationShadow {
id: audioDevicesShadowLayer
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
fallbackOffset: 4
targetRadius: audioDevicesPanel.radius
targetColor: audioDevicesPanel.color
borderColor: audioDevicesPanel.border.color
borderWidth: audioDevicesPanel.border.width
shadowOpacity: Theme.elevationLevel2 && Theme.elevationLevel2.alpha !== undefined ? Theme.elevationLevel2.alpha : 0.25
shadowEnabled: Theme.elevationEnabled
} }
Column { Column {
@@ -346,41 +425,62 @@ Item {
visible: dropdownType === 3 visible: dropdownType === 3
width: 240 width: 240
height: Math.max(180, Math.min(240, (allPlayers?.length || 0) * 50 + 80)) height: Math.max(180, Math.min(240, (allPlayers?.length || 0) * 50 + 80))
x: isRightEdge ? anchorPos.x : anchorPos.x - width x: (isRightEdge ? anchorPos.x : anchorPos.x - width) + panelMotionX(width, dropdownType === 3)
y: anchorPos.y - height / 2 y: anchorPos.y - height / 2 + panelMotionY(3, height, dropdownType === 3)
radius: Theme.cornerRadius * 2 radius: Theme.cornerRadius * 2
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98) color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.98)
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6) border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.6)
border.width: 2 border.width: 2
opacity: dropdownType === 3 ? 1 : 0 opacity: Theme.isDirectionalEffect ? 1 : (dropdownType === 3 ? 1 : 0)
scale: dropdownType === 3 ? 1 : 0.96 scale: Theme.isDirectionalEffect ? 1 : (dropdownType === 3 ? 1 : Theme.effectScaleCollapsed)
transformOrigin: isRightEdge ? Item.Left : Item.Right transformOrigin: isRightEdge ? Item.Left : Item.Right
Behavior on opacity { Behavior on opacity {
NumberAnimation { enabled: !Theme.isDirectionalEffect
duration: Theme.expressiveDurations.expressiveDefaultSpatial DankAnim {
easing.type: Easing.BezierSpline duration: Math.round(Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3) * Theme.variantOpacityDurationScale)
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
Behavior on scale { Behavior on scale {
enabled: !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
layer.enabled: true Behavior on x {
layer.effect: MultiEffect { NumberAnimation {
shadowEnabled: true duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3)
shadowHorizontalOffset: 0 easing.type: Easing.BezierSpline
shadowVerticalOffset: 8 easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
shadowBlur: 1.0 }
shadowColor: Qt.rgba(0, 0, 0, 0.4) }
shadowOpacity: 0.7
Behavior on y {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, dropdownType === 3)
easing.type: Easing.BezierSpline
easing.bezierCurve: dropdownType === 3 ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
}
}
ElevationShadow {
id: playersShadowLayer
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
fallbackOffset: 4
targetRadius: playersPanel.radius
targetColor: playersPanel.color
borderColor: playersPanel.border.color
borderWidth: playersPanel.border.width
shadowOpacity: Theme.elevationLevel2 && Theme.elevationLevel2.alpha !== undefined ? Theme.elevationLevel2.alpha : 0.25
shadowEnabled: Theme.elevationEnabled
} }
Column { Column {

View File

@@ -529,14 +529,15 @@ Item {
onClicked: activePlayer && activePlayer.togglePlaying() onClicked: activePlayer && activePlayer.togglePlaying()
} }
layer.enabled: true ElevationShadow {
layer.effect: MultiEffect { anchors.fill: parent
shadowEnabled: true z: -1
shadowHorizontalOffset: 0 level: Theme.elevationLevel1
shadowVerticalOffset: 0 fallbackOffset: 1
shadowBlur: 1.0 targetRadius: parent.radius
shadowColor: Qt.rgba(0, 0, 0, 0.3) targetColor: parent.color
shadowOpacity: 0.3 shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2
shadowEnabled: Theme.elevationEnabled
} }
} }
} }

View File

@@ -14,8 +14,15 @@ Rectangle {
signal closeDash signal closeDash
function weekStartQt() {
if (SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0) {
return Qt.locale().firstDayOfWeek;
}
return SettingsData.firstDayOfWeek;
}
function weekStartJs() { function weekStartJs() {
return Qt.locale().firstDayOfWeek % 7; return weekStartQt() % 7;
} }
function startOfWeek(dateObj) { function startOfWeek(dateObj) {
@@ -223,7 +230,7 @@ Rectangle {
Repeater { Repeater {
model: { model: {
const days = []; const days = [];
const qtFirst = Qt.locale().firstDayOfWeek; const qtFirst = weekStartQt();
for (let i = 0; i < 7; ++i) { for (let i = 0; i < 7; ++i) {
const qtDay = ((qtFirst - 1 + i) % 7) + 1; const qtDay = ((qtFirst - 1 + i) % 7) + 1;
days.push(I18n.locale().dayName(qtDay, Locale.ShortFormat)); days.push(I18n.locale().dayName(qtDay, Locale.ShortFormat));

View File

@@ -241,14 +241,15 @@ Item {
color: Theme.primary color: Theme.primary
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
layer.enabled: true layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect { layer.effect: MultiEffect {
shadowEnabled: true shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: 0 shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel1)
shadowVerticalOffset: 4 shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel1, 1)
shadowBlur: 0.8 shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined ? Theme.elevationLevel1.blurPx : 4) / Theme.elevationBlurMax)) : 0
shadowColor: Qt.rgba(0, 0, 0, 0.2) blurMax: Theme.elevationBlurMax
shadowOpacity: 0.2 shadowColor: Theme.elevationShadowColor(Theme.elevationLevel1)
shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2
} }
} }
@@ -812,14 +813,14 @@ Item {
x: (pos?.h ?? 0) * skyBox.effectiveWidth - (moonPhase.width / 2) + skyBox.hMargin x: (pos?.h ?? 0) * skyBox.effectiveWidth - (moonPhase.width / 2) + skyBox.hMargin
y: (pos?.v ?? 0) * -(skyBox.effectiveHeight / 2) + skyBox.effectiveHeight / 2 - (moonPhase.height / 2) + skyBox.vMargin y: (pos?.v ?? 0) * -(skyBox.effectiveHeight / 2) + skyBox.effectiveHeight / 2 - (moonPhase.height / 2) + skyBox.vMargin
layer.enabled: true layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect { layer.effect: MultiEffect {
shadowEnabled: true shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: 0 shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel2)
shadowVerticalOffset: 4 shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel2, 4)
shadowBlur: 0.8 shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel2 && Theme.elevationLevel2.blurPx !== undefined ? Theme.elevationLevel2.blurPx : 8) / Theme.elevationBlurMax)) : 0
shadowColor: Qt.rgba(0, 0, 0, 0.2) blurMax: Theme.elevationBlurMax
shadowOpacity: 0.2 shadowColor: Theme.elevationShadowColor(Theme.elevationLevel2)
} }
} }
@@ -834,14 +835,14 @@ Item {
x: (pos?.h ?? 0) * skyBox.effectiveWidth - (sun.width / 2) + skyBox.hMargin x: (pos?.h ?? 0) * skyBox.effectiveWidth - (sun.width / 2) + skyBox.hMargin
y: (pos?.v ?? 0) * -(skyBox.effectiveHeight / 2) + skyBox.effectiveHeight / 2 - (sun.height / 2) + skyBox.vMargin y: (pos?.v ?? 0) * -(skyBox.effectiveHeight / 2) + skyBox.effectiveHeight / 2 - (sun.height / 2) + skyBox.vMargin
layer.enabled: true layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect { layer.effect: MultiEffect {
shadowEnabled: true shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: 0 shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel2)
shadowVerticalOffset: 4 shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel2, 4)
shadowBlur: 0.8 shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel2 && Theme.elevationLevel2.blurPx !== undefined ? Theme.elevationLevel2.blurPx : 8) / Theme.elevationBlurMax)) : 0
shadowColor: Qt.rgba(0, 0, 0, 0.2) blurMax: Theme.elevationBlurMax
shadowOpacity: 0.2 shadowColor: Theme.elevationShadowColor(Theme.elevationLevel2)
} }
} }
} }

View File

@@ -447,9 +447,8 @@ Variants {
height: { height: {
if (dock.isVertical) { if (dock.isVertical) {
if (!dock.reveal) // Keep the taller hit area regardless of the reveal state to prevent shrinking loop
return Math.min(Math.max(dockBackground.height + 64, 200), screenHeight * 0.5); return Math.min(Math.max(dockBackground.height + 64, 200), screenHeight * 0.5);
return Math.min(dockBackground.height + 8 + dock.borderThickness, maxDockHeight);
} }
return dock.reveal ? px(dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin) : 1; return dock.reveal ? px(dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin) : 1;
} }
@@ -457,8 +456,7 @@ Variants {
if (dock.isVertical) { if (dock.isVertical) {
return dock.reveal ? px(dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin) : 1; return dock.reveal ? px(dock.effectiveBarHeight + SettingsData.dockSpacing + SettingsData.dockBottomGap + SettingsData.dockMargin) : 1;
} }
if (!dock.reveal) // Keep the wider hit area regardless of the reveal state to prevent shrinking loop
return Math.min(Math.max(dockBackground.width + 64, 200), screenWidth * 0.5);
return Math.min(dockBackground.width + 8 + dock.borderThickness, maxDockWidth); return Math.min(dockBackground.width + 8 + dock.borderThickness, maxDockWidth);
} }
anchors { anchors {

View File

@@ -329,7 +329,7 @@ PanelWindow {
IconImage { IconImage {
anchors.fill: parent anchors.fill: parent
source: modelData.icon ? Quickshell.iconPath(modelData.icon, true) : "" source: modelData.icon ? Paths.resolveIconPath(modelData.icon) : ""
smooth: true smooth: true
asynchronous: true asynchronous: true
visible: status === Image.Ready visible: status === Image.Ready

View File

@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Services import qs.Services
@@ -30,7 +31,21 @@ Rectangle {
width: parent ? parent.width : 400 width: parent ? parent.width : 400
height: baseCardHeight + contentItem.extraHeight height: baseCardHeight + contentItem.extraHeight
radius: Theme.cornerRadius radius: Theme.cornerRadius
clip: true clip: false
readonly property bool shadowsAllowed: Theme.elevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
ElevationShadow {
id: shadowLayer
anchors.fill: parent
z: -1
level: Theme.elevationLevel1
fallbackOffset: 1
targetRadius: root.radius
targetColor: root.color
borderColor: root.border.color
borderWidth: root.border.width
shadowEnabled: root.shadowsAllowed
}
color: { color: {
if (isSelected && keyboardNavigationActive) if (isSelected && keyboardNavigationActive)
@@ -49,7 +64,7 @@ Rectangle {
return 1.5; return 1.5;
if (historyItem.urgency === 2) if (historyItem.urgency === 2)
return 2; return 2;
return 1; return 0;
} }
Behavior on border.color { Behavior on border.color {
@@ -122,12 +137,12 @@ Rectangle {
return ""; return "";
const appIcon = historyItem.appIcon; const appIcon = historyItem.appIcon;
if (!appIcon) if (!appIcon)
return iconFromImage ? "image://icon/" + iconFromImage : ""; return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/")) if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
return appIcon; return appIcon;
if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:")) if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
return ""; return "";
return Quickshell.iconPath(appIcon, true); return Paths.resolveIconPath(appIcon);
} }
hasImage: hasNotificationImage hasImage: hasNotificationImage

View File

@@ -232,6 +232,11 @@ Item {
height: parent.height - filterChips.height - Theme.spacingS height: parent.height - filterChips.height - Theme.spacingS
clip: true clip: true
spacing: Theme.spacingS spacing: Theme.spacingS
readonly property real horizontalShadowGutter: Theme.snap(Math.max(Theme.spacingXS, 4), 1)
readonly property real verticalShadowGutter: Theme.snap(Math.max(Theme.spacingS, 8), 1)
readonly property real delegateShadowGutter: Theme.snap(Math.max(Theme.spacingXS, 4), 1)
topMargin: verticalShadowGutter
bottomMargin: verticalShadowGutter
model: ScriptModel { model: ScriptModel {
id: historyModel id: historyModel
@@ -263,13 +268,14 @@ Item {
} }
width: ListView.view.width width: ListView.view.width
height: historyCard.height height: historyCard.height + historyListView.delegateShadowGutter
clip: true clip: false
HistoryNotificationCard { HistoryNotificationCard {
id: historyCard id: historyCard
width: parent.width width: Math.max(0, parent.width - (historyListView.horizontalShadowGutter * 2))
x: delegateRoot.swipeOffset y: historyListView.delegateShadowGutter / 2
x: historyListView.horizontalShadowGutter + delegateRoot.swipeOffset
historyItem: modelData historyItem: modelData
isSelected: root.keyboardActive && root.selectedIndex === index isSelected: root.keyboardActive && root.selectedIndex === index
keyboardNavigationActive: root.keyboardActive keyboardNavigationActive: root.keyboardActive

View File

@@ -18,6 +18,10 @@ DankListView {
property real swipingCardOffset: 0 property real swipingCardOffset: 0
property real __pendingStableHeight: 0 property real __pendingStableHeight: 0
property real __heightUpdateThreshold: 20 property real __heightUpdateThreshold: 20
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)
Component.onCompleted: { Component.onCompleted: {
Qt.callLater(() => { Qt.callLater(() => {
@@ -56,21 +60,26 @@ DankListView {
let delta = 0; let delta = 0;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const item = itemAtIndex(i); const item = itemAtIndex(i);
if (item && item.children[0] && item.children[0].isAnimating) if (item && item.children[0] && item.children[0].isAnimating) {
delta += item.children[0].targetHeight - item.height; const targetDelegateHeight = item.children[0].targetHeight + listView.delegateShadowGutter;
delta += targetDelegateHeight - item.height;
}
} }
const targetHeight = contentHeight + delta; const targetHeight = contentHeight + delta;
// During expansion, always update immediately without threshold check // During expansion, always update immediately without threshold check
stableContentHeight = targetHeight; stableContentHeight = targetHeight;
} else { } else {
__pendingStableHeight = contentHeight; __pendingStableHeight = contentHeight;
heightUpdateDebounce.restart(); heightUpdateDebounce.stop();
stableContentHeight = __pendingStableHeight;
} }
} }
clip: true clip: true
model: NotificationService.groupedNotifications model: NotificationService.groupedNotifications
spacing: Theme.spacingL spacing: Theme.spacingL
topMargin: shadowVerticalGutter
bottomMargin: shadowVerticalGutter
onIsUserScrollingChanged: { onIsUserScrollingChanged: {
if (isUserScrolling && keyboardController && keyboardController.keyboardNavigationActive) { if (isUserScrolling && keyboardController && keyboardController.keyboardNavigationActive) {
@@ -134,8 +143,7 @@ DankListView {
readonly property real dismissThreshold: width * 0.35 readonly property real dismissThreshold: width * 0.35
property bool __delegateInitialized: false property bool __delegateInitialized: false
readonly property bool isAdjacentToSwipe: listView.count >= 2 && listView.swipingCardIndex !== -1 && readonly property bool isAdjacentToSwipe: listView.count >= 2 && listView.swipingCardIndex !== -1 && (index === listView.swipingCardIndex - 1 || index === listView.swipingCardIndex + 1)
(index === listView.swipingCardIndex - 1 || index === listView.swipingCardIndex + 1)
readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? listView.swipingCardOffset * 0.10 : 0 readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? listView.swipingCardOffset * 0.10 : 0
readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(listView.swipingCardOffset) / width * 0.02 : 1.0 readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(listView.swipingCardOffset) / width * 0.02 : 1.0
readonly property real swipeFadeStartOffset: width * 0.75 readonly property real swipeFadeStartOffset: width * 0.75
@@ -149,13 +157,14 @@ DankListView {
} }
width: ListView.view.width width: ListView.view.width
height: notificationCard.height height: notificationCard.height + listView.delegateShadowGutter
clip: notificationCard.isAnimating clip: false
NotificationCard { NotificationCard {
id: notificationCard id: notificationCard
width: parent.width width: Math.max(0, parent.width - (listView.shadowHorizontalGutter * 2))
x: delegateRoot.swipeOffset + delegateRoot.adjacentSwipeInfluence y: listView.delegateShadowGutter / 2
x: listView.shadowHorizontalGutter + delegateRoot.swipeOffset + delegateRoot.adjacentSwipeInfluence
listLevelAdjacentScaleInfluence: delegateRoot.adjacentScaleInfluence listLevelAdjacentScaleInfluence: delegateRoot.adjacentScaleInfluence
listLevelScaleAnimationsEnabled: listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe listLevelScaleAnimationsEnabled: listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe
notificationGroup: modelData notificationGroup: modelData

View File

@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import qs.Common import qs.Common
@@ -38,7 +39,14 @@ Rectangle {
height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight)
readonly property real targetHeight: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight) readonly property real targetHeight: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight)
radius: Theme.cornerRadius radius: Theme.cornerRadius
scale: (cardHoverHandler.hovered ? 1.01 : 1.0) * listLevelAdjacentScaleInfluence 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"
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
property real shadowBlurPx: shadowsAllowed ? (baseShadowBlurPx + hoverShadowBlurBoost) : 0
property real shadowOffsetXPx: shadowsAllowed ? Theme.elevationOffsetX(shadowElevation) : 0
property real shadowOffsetYPx: shadowsAllowed ? (Theme.elevationOffsetY(shadowElevation, 1) + (cardHoverHandler.hovered ? 0.35 : 0)) : 0
property bool __initialized: false property bool __initialized: false
Component.onCompleted: { Component.onCompleted: {
@@ -56,6 +64,27 @@ Rectangle {
} }
} }
Behavior on shadowBlurPx {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on shadowOffsetXPx {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on shadowOffsetYPx {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color { Behavior on border.color {
enabled: root.__initialized enabled: root.__initialized
ColorAnimation { ColorAnimation {
@@ -95,14 +124,31 @@ Rectangle {
if (notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical) { if (notificationGroup?.latestNotification?.urgency === NotificationUrgency.Critical) {
return 2; return 2;
} }
return 1; return 0;
} }
clip: true clip: false
HoverHandler { HoverHandler {
id: cardHoverHandler id: cardHoverHandler
} }
ElevationShadow {
id: shadowLayer
anchors.fill: parent
z: -1
level: root.shadowElevation
targetRadius: root.radius
targetColor: root.color
borderColor: root.border.color
borderWidth: root.border.width
shadowBlurPx: root.shadowBlurPx
shadowSpreadPx: 0
shadowOffsetX: root.shadowOffsetXPx
shadowOffsetY: root.shadowOffsetYPx
shadowColor: root.shadowElevation ? Theme.elevationShadowColor(root.shadowElevation) : "transparent"
shadowEnabled: root.shadowsAllowed
}
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
radius: parent.radius radius: parent.radius
@@ -169,12 +215,12 @@ Rectangle {
return ""; return "";
const appIcon = notificationGroup?.latestNotification?.appIcon; const appIcon = notificationGroup?.latestNotification?.appIcon;
if (!appIcon) if (!appIcon)
return iconFromImage ? "image://icon/" + iconFromImage : ""; return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/")) if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
return appIcon; return appIcon;
if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:")) if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
return ""; return "";
return Quickshell.iconPath(appIcon, true); return Paths.resolveIconPath(appIcon);
} }
hasImage: hasNotificationImage hasImage: hasNotificationImage
@@ -304,8 +350,13 @@ Rectangle {
onClicked: mouse => { onClicked: mouse => {
if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) { if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) {
root.userInitiatedExpansion = true;
const messageId = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : ""; const messageId = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : "";
NotificationService.toggleMessageExpansion(messageId); NotificationService.toggleMessageExpansion(messageId);
Qt.callLater(() => {
if (root && !root.isAnimating)
root.userInitiatedExpansion = false;
});
} }
} }
@@ -419,9 +470,7 @@ Rectangle {
id: delegateRect id: delegateRect
width: parent.width width: parent.width
readonly property bool isAdjacentToSwipe: root.swipingNotificationIndex !== -1 && readonly property bool isAdjacentToSwipe: root.swipingNotificationIndex !== -1 && (expandedDelegateWrapper.index === root.swipingNotificationIndex - 1 || expandedDelegateWrapper.index === root.swipingNotificationIndex + 1)
(expandedDelegateWrapper.index === root.swipingNotificationIndex - 1 ||
expandedDelegateWrapper.index === root.swipingNotificationIndex + 1)
readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? root.swipingNotificationOffset * 0.10 : 0 readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? root.swipingNotificationOffset * 0.10 : 0
readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(root.swipingNotificationOffset) / width * 0.02 : 1.0 readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(root.swipingNotificationOffset) / width * 0.02 : 1.0
@@ -503,12 +552,12 @@ Rectangle {
return ""; return "";
const appIcon = modelData?.appIcon; const appIcon = modelData?.appIcon;
if (!appIcon) if (!appIcon)
return iconFromImage ? "image://icon/" + iconFromImage : ""; return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/")) if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
return appIcon; return appIcon;
if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:")) if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
return ""; return "";
return Quickshell.iconPath(appIcon, true); return Paths.resolveIconPath(appIcon);
} }
fallbackIcon: { fallbackIcon: {
@@ -605,7 +654,12 @@ Rectangle {
onClicked: mouse => { onClicked: mouse => {
if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) { if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) {
root.userInitiatedExpansion = true;
NotificationService.toggleMessageExpansion(modelData?.notification?.id || ""); NotificationService.toggleMessageExpansion(modelData?.notification?.id || "");
Qt.callLater(() => {
if (root && !root.isAnimating)
root.userInitiatedExpansion = false;
});
} }
} }

View File

@@ -7,15 +7,22 @@ DankPopout {
id: root id: root
layerNamespace: "dms:notification-center-popout" layerNamespace: "dms:notification-center-popout"
fullHeightSurface: true fullHeightSurface: false
property bool notificationHistoryVisible: false property bool notificationHistoryVisible: false
property var triggerScreen: null property var triggerScreen: null
property real stablePopupHeight: 400 property real stablePopupHeight: 400
property real _lastAlignedContentHeight: -1 property real _lastAlignedContentHeight: -1
property bool _pendingSizedOpen: false
function updateStablePopupHeight() { function updateStablePopupHeight() {
const item = contentLoader.item; const item = contentLoader.item;
if (item && !root.shouldBeVisible) {
const notificationList = findChild(item, "notificationList");
if (notificationList && typeof notificationList.forceLayout === "function") {
notificationList.forceLayout();
}
}
const target = item ? Theme.px(item.implicitHeight, dpr) : 400; const target = item ? Theme.px(item.implicitHeight, dpr) : 400;
if (Math.abs(target - _lastAlignedContentHeight) < 0.5) if (Math.abs(target - _lastAlignedContentHeight) < 0.5)
return; return;
@@ -26,34 +33,54 @@ DankPopout {
NotificationKeyboardController { NotificationKeyboardController {
id: keyboardController id: keyboardController
listView: null listView: null
isOpen: notificationHistoryVisible isOpen: root.shouldBeVisible
onClose: () => { onClose: () => {
notificationHistoryVisible = false; notificationHistoryVisible = false;
} }
} }
popupWidth: triggerScreen ? Math.min(500, Math.max(380, triggerScreen.width - 48)) : 400 popupWidth: 400
popupHeight: stablePopupHeight popupHeight: stablePopupHeight
positioning: "" positioning: ""
animationScaleCollapsed: 0.94
animationOffset: 0
suspendShadowWhileResizing: false suspendShadowWhileResizing: false
screen: triggerScreen screen: triggerScreen
shouldBeVisible: notificationHistoryVisible
function toggle() { function toggle() {
notificationHistoryVisible = !notificationHistoryVisible; notificationHistoryVisible = !notificationHistoryVisible;
} }
function openSized() {
if (!notificationHistoryVisible)
return;
primeContent();
if (contentLoader.item) {
updateStablePopupHeight();
_pendingSizedOpen = false;
Qt.callLater(() => {
if (!notificationHistoryVisible)
return;
updateStablePopupHeight();
open();
clearPrimedContent();
});
return;
}
_pendingSizedOpen = true;
}
onBackgroundClicked: { onBackgroundClicked: {
notificationHistoryVisible = false; notificationHistoryVisible = false;
} }
onNotificationHistoryVisibleChanged: { onNotificationHistoryVisibleChanged: {
if (notificationHistoryVisible) { if (notificationHistoryVisible) {
open(); openSized();
} else { } else {
_pendingSizedOpen = false;
clearPrimedContent();
close(); close();
} }
} }
@@ -82,6 +109,17 @@ DankPopout {
target: contentLoader target: contentLoader
function onLoaded() { function onLoaded() {
root.updateStablePopupHeight(); root.updateStablePopupHeight();
if (root._pendingSizedOpen && root.notificationHistoryVisible) {
Qt.callLater(() => {
if (!root._pendingSizedOpen || !root.notificationHistoryVisible)
return;
root.updateStablePopupHeight();
root._pendingSizedOpen = false;
root.open();
root.clearPrimedContent();
});
return;
}
if (root.shouldBeVisible) if (root.shouldBeVisible)
Qt.callLater(root.setupKeyboardNavigation); Qt.callLater(root.setupKeyboardNavigation);
} }
@@ -139,7 +177,8 @@ DankPopout {
baseHeight += Theme.spacingM * 2; baseHeight += Theme.spacingM * 2;
const settingsHeight = notificationSettings.expanded ? notificationSettings.contentHeight : 0; const settingsHeight = notificationSettings.expanded ? notificationSettings.contentHeight : 0;
let listHeight = notificationHeader.currentTab === 0 ? notificationList.stableContentHeight : Math.max(200, NotificationService.historyList.length * 80); 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) { if (notificationHeader.currentTab === 0 && NotificationService.groupedNotifications.length === 0) {
listHeight = 200; listHeight = 200;
} }
@@ -233,13 +272,21 @@ DankPopout {
expanded: notificationHeader.showSettings expanded: notificationHeader.showSettings
} }
KeyboardNavigatedNotificationList { Item {
id: notificationList
objectName: "notificationList"
visible: notificationHeader.currentTab === 0 visible: notificationHeader.currentTab === 0
width: parent.width width: parent.width
height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2 height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2
cardAnimateExpansion: true
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
}
} }
HistoryNotificationList { HistoryNotificationList {

View File

@@ -24,6 +24,29 @@ PanelWindow {
property real _lastReportedAlignedHeight: -1 property real _lastReportedAlignedHeight: -1
property real _storedTopMargin: 0 property real _storedTopMargin: 0
property real _storedBottomMargin: 0 property real _storedBottomMargin: 0
readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property bool depthEffect: Theme.isDepthEffect
readonly property real entryTravel: {
const base = Math.abs(Theme.effectAnimOffset);
if (directionalEffect) {
if (isCenterPosition)
return Math.max(base, Math.round(content.height * 1.1));
return Math.max(base, Math.round(content.width * 0.95));
}
if (depthEffect)
return Math.max(base, 44);
return base;
}
readonly property real exitTravel: {
if (directionalEffect) {
if (isCenterPosition)
return content.height + entryTravel;
return content.width + entryTravel;
}
if (depthEffect)
return Math.round(entryTravel * 1.35);
return Anims.slidePx;
}
readonly property string clearText: I18n.tr("Dismiss") readonly property string clearText: I18n.tr("Dismiss")
property bool descriptionExpanded: false property bool descriptionExpanded: false
readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0 readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0
@@ -118,8 +141,8 @@ PanelWindow {
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent" color: "transparent"
implicitWidth: screen ? Math.min(400, Math.max(320, screen.width * 0.23)) : 380 readonly property real contentImplicitWidth: screen ? Math.min(400, Math.max(320, screen.width * 0.23)) : 380
implicitHeight: { readonly property real contentImplicitHeight: {
if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded)
return basePopupHeightPrivacy; return basePopupHeightPrivacy;
if (!descriptionExpanded) if (!descriptionExpanded)
@@ -130,14 +153,16 @@ PanelWindow {
return basePopupHeight + bodyTextHeight - collapsedBodyHeight; return basePopupHeight + bodyTextHeight - collapsedBodyHeight;
return basePopupHeight; return basePopupHeight;
} }
implicitWidth: contentImplicitWidth + (windowShadowPad * 2)
implicitHeight: contentImplicitHeight + (windowShadowPad * 2)
Behavior on implicitHeight { Behavior on implicitHeight {
enabled: !exiting && !_isDestroying enabled: !exiting && !_isDestroying
NumberAnimation { NumberAnimation {
id: implicitHeightAnim id: implicitHeightAnim
duration: descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration duration: Theme.variantDuration(descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration, descriptionExpanded)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasized easing.bezierCurve: descriptionExpanded ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
} }
} }
@@ -182,11 +207,15 @@ PanelWindow {
property bool isTopCenter: SettingsData.notificationPopupPosition === -1 property bool isTopCenter: SettingsData.notificationPopupPosition === -1
property bool isBottomCenter: SettingsData.notificationPopupPosition === SettingsData.Position.BottomCenter property bool isBottomCenter: SettingsData.notificationPopupPosition === SettingsData.Position.BottomCenter
property bool isCenterPosition: isTopCenter || isBottomCenter property bool isCenterPosition: isTopCenter || isBottomCenter
readonly property real maxPopupShadowBlurPx: Math.max((Theme.elevationLevel3 && Theme.elevationLevel3.blurPx !== undefined) ? Theme.elevationLevel3.blurPx : 12, (Theme.elevationLevel4 && Theme.elevationLevel4.blurPx !== undefined) ? Theme.elevationLevel4.blurPx : 16)
readonly property real maxPopupShadowOffsetXPx: Math.max(Math.abs(Theme.elevationOffsetX(Theme.elevationLevel3)), Math.abs(Theme.elevationOffsetX(Theme.elevationLevel4)))
readonly property real maxPopupShadowOffsetYPx: Math.max(Math.abs(Theme.elevationOffsetY(Theme.elevationLevel3, 6)), Math.abs(Theme.elevationOffsetY(Theme.elevationLevel4, 8)))
readonly property real windowShadowPad: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0
anchors.top: true anchors.top: true
anchors.bottom: true anchors.left: true
anchors.left: SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom anchors.bottom: false
anchors.right: SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Right anchors.right: false
mask: contentInputMask mask: contentInputMask
@@ -205,10 +234,10 @@ PanelWindow {
} }
margins { margins {
top: _storedTopMargin top: getWindowTopMargin()
bottom: _storedBottomMargin bottom: 0
left: getLeftMargin() left: getWindowLeftMargin()
right: getRightMargin() right: 0
} }
function getBarInfo() { function getBarInfo() {
@@ -250,7 +279,7 @@ PanelWindow {
function getLeftMargin() { function getLeftMargin() {
if (isCenterPosition) if (isCenterPosition)
return screen ? (screen.width - implicitWidth) / 2 : 0; return screen ? (screen.width - alignedWidth) / 2 : 0;
const popupPos = SettingsData.notificationPopupPosition; const popupPos = SettingsData.notificationPopupPosition;
const isLeft = popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom; const isLeft = popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom;
@@ -274,23 +303,56 @@ PanelWindow {
return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance; return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance;
} }
function getContentX() {
if (!screen)
return 0;
const popupPos = SettingsData.notificationPopupPosition;
const barLeft = getLeftMargin();
const barRight = getRightMargin();
if (isCenterPosition)
return Theme.snap((screen.width - alignedWidth) / 2, dpr);
if (popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom)
return Theme.snap(barLeft, dpr);
return Theme.snap(screen.width - alignedWidth - barRight, dpr);
}
function getContentY() {
if (!screen)
return 0;
const popupPos = SettingsData.notificationPopupPosition;
const barTop = getTopMargin();
const barBottom = getBottomMargin();
const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left;
if (isTop)
return Theme.snap(barTop, dpr);
return Theme.snap(screen.height - alignedHeight - barBottom, dpr);
}
function getWindowLeftMargin() {
if (!screen)
return 0;
return Theme.snap(getContentX() - windowShadowPad, dpr);
}
function getWindowTopMargin() {
if (!screen)
return 0;
return Theme.snap(getContentY() - windowShadowPad, dpr);
}
readonly property bool screenValid: win.screen && !_isDestroying readonly property bool screenValid: win.screen && !_isDestroying
readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1 readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1
readonly property real alignedWidth: Theme.px(implicitWidth, dpr) readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2)), dpr)
readonly property real alignedHeight: Theme.px(implicitHeight, dpr) readonly property real alignedHeight: Theme.px(Math.max(0, implicitHeight - (windowShadowPad * 2)), dpr)
Item { Item {
id: content id: content
x: Theme.snap((win.width - alignedWidth) / 2, dpr) x: Theme.snap(windowShadowPad, dpr)
y: { y: Theme.snap(windowShadowPad, dpr)
const isTop = isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left;
if (isTop) {
return Theme.snap(screenY, dpr);
} else {
return Theme.snap(win.height - alignedHeight - screenY, dpr);
}
}
width: alignedWidth width: alignedWidth
height: alignedHeight height: alignedHeight
visible: !win._finalized visible: !win._finalized
@@ -313,12 +375,13 @@ PanelWindow {
readonly property bool swipeActive: swipeDragHandler.active readonly property bool swipeActive: swipeDragHandler.active
property bool swipeDismissing: false property bool swipeDismissing: false
readonly property real radiusForShadow: Theme.cornerRadius readonly property bool shadowsAllowed: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled
property real shadowBlurPx: SettingsData.notificationPopupShadowEnabled ? ((2 + radiusForShadow * 0.2) * (cardHoverHandler.hovered ? 1.2 : 1)) : 0 readonly property var elevLevel: cardHoverHandler.hovered ? Theme.elevationLevel4 : Theme.elevationLevel3
property real shadowSpreadPx: SettingsData.notificationPopupShadowEnabled ? (radiusForShadow * (cardHoverHandler.hovered ? 0.06 : 0)) : 0 readonly property real cardInset: Theme.snap(4, win.dpr)
property real shadowBaseAlpha: 0.35 readonly property real shadowRenderPadding: shadowsAllowed ? Theme.snap(Math.max(16, shadowBlurPx + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)) + 8), win.dpr) : 0
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency property real shadowBlurPx: shadowsAllowed ? (elevLevel && elevLevel.blurPx !== undefined ? elevLevel.blurPx : 12) : 0
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha)) property real shadowOffsetX: shadowsAllowed ? Theme.elevationOffsetX(elevLevel) : 0
property real shadowOffsetY: shadowsAllowed ? Theme.elevationOffsetY(elevLevel, 6) : 0
Behavior on shadowBlurPx { Behavior on shadowBlurPx {
NumberAnimation { NumberAnimation {
@@ -327,50 +390,50 @@ PanelWindow {
} }
} }
Behavior on shadowSpreadPx { Behavior on shadowOffsetX {
NumberAnimation { NumberAnimation {
duration: Theme.shortDuration duration: Theme.shortDuration
easing.type: Theme.standardEasing easing.type: Theme.standardEasing
} }
} }
Item { Behavior on shadowOffsetY {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
ElevationShadow {
id: bgShadowLayer id: bgShadowLayer
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.snap(4, win.dpr) anchors.margins: -content.shadowRenderPadding
layer.enabled: !win._isDestroying && win.screenValid level: content.elevLevel
layer.smooth: false fallbackOffset: 6
shadowBlurPx: content.shadowBlurPx
shadowOffsetX: content.shadowOffsetX
shadowOffsetY: content.shadowOffsetY
shadowColor: content.shadowsAllowed && content.elevLevel ? Theme.elevationShadowColor(content.elevLevel) : "transparent"
shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed
layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr)) layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically layer.textureMirroring: ShaderEffectSource.MirrorVertically
readonly property int blurMax: 64 sourceRect.anchors.fill: undefined
sourceRect.x: content.shadowRenderPadding + content.cardInset
layer.effect: MultiEffect { sourceRect.y: content.shadowRenderPadding + content.cardInset
id: shadowFx sourceRect.width: Math.max(0, content.width - (content.cardInset * 2))
autoPaddingEnabled: true sourceRect.height: Math.max(0, content.height - (content.cardInset * 2))
shadowEnabled: SettingsData.notificationPopupShadowEnabled sourceRect.radius: Theme.cornerRadius
blurEnabled: false sourceRect.color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
maskEnabled: false sourceRect.border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08)
shadowBlur: Math.max(0, Math.min(1, content.shadowBlurPx / bgShadowLayer.blurMax)) sourceRect.border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
shadowScale: 1 + (2 * content.shadowSpreadPx) / Math.max(1, Math.min(bgShadowLayer.width, bgShadowLayer.height))
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, content.effectiveShadowAlpha);
}
}
Rectangle { Rectangle {
id: shadowShapeSource x: bgShadowLayer.sourceRect.x
anchors.fill: parent y: bgShadowLayer.sourceRect.y
radius: Theme.cornerRadius width: bgShadowLayer.sourceRect.width
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) height: bgShadowLayer.sourceRect.height
border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08) radius: bgShadowLayer.sourceRect.radius
border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
}
Rectangle {
anchors.fill: parent
radius: shadowShapeSource.radius
visible: notificationData && notificationData.urgency === NotificationUrgency.Critical visible: notificationData && notificationData.urgency === NotificationUrgency.Critical
opacity: 1 opacity: 1
clip: true clip: true
@@ -399,7 +462,7 @@ PanelWindow {
Item { Item {
id: backgroundContainer id: backgroundContainer
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.snap(4, win.dpr) anchors.margins: content.cardInset
clip: true clip: true
HoverHandler { HoverHandler {
@@ -479,12 +542,12 @@ PanelWindow {
return ""; return "";
const appIcon = notificationData.appIcon; const appIcon = notificationData.appIcon;
if (!appIcon) if (!appIcon)
return iconFromImage ? "image://icon/" + iconFromImage : ""; return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : "";
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/")) if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
return appIcon; return appIcon;
if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:")) if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
return ""; return "";
return Quickshell.iconPath(appIcon, true); return Paths.resolveIconPath(appIcon);
} }
hasImage: hasNotificationImage hasImage: hasNotificationImage
@@ -871,9 +934,9 @@ PanelWindow {
if (isCenterPosition) if (isCenterPosition)
return 0; return 0;
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
return isLeft ? -Anims.slidePx : Anims.slidePx; return isLeft ? -entryTravel : entryTravel;
} }
y: isTopCenter ? -Anims.slidePx : isBottomCenter ? Anims.slidePx : 0 y: isTopCenter ? -entryTravel : isBottomCenter ? entryTravel : 0
} }
] ]
} }
@@ -885,16 +948,16 @@ PanelWindow {
property: isCenterPosition ? "y" : "x" property: isCenterPosition ? "y" : "x"
from: { from: {
if (isTopCenter) if (isTopCenter)
return -Anims.slidePx; return -entryTravel;
if (isBottomCenter) if (isBottomCenter)
return Anims.slidePx; return entryTravel;
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
return isLeft ? -Anims.slidePx : Anims.slidePx; return isLeft ? -entryTravel : entryTravel;
} }
to: 0 to: 0
duration: Theme.notificationEnterDuration duration: Theme.variantDuration(Theme.notificationEnterDuration, true)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: isCenterPosition ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantPopoutEnterCurve
onStopped: { onStopped: {
if (!win.exiting && !win._isDestroying) { if (!win.exiting && !win._isDestroying) {
if (isCenterPosition) { if (isCenterPosition) {
@@ -919,35 +982,35 @@ PanelWindow {
from: 0 from: 0
to: { to: {
if (isTopCenter) if (isTopCenter)
return -Anims.slidePx; return -exitTravel;
if (isBottomCenter) if (isBottomCenter)
return Anims.slidePx; return exitTravel;
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
return isLeft ? -Anims.slidePx : Anims.slidePx; return isLeft ? -exitTravel : exitTravel;
} }
duration: Theme.notificationExitDuration duration: Theme.variantDuration(Theme.notificationExitDuration, false)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel easing.bezierCurve: Theme.variantPopoutExitCurve
} }
NumberAnimation { NumberAnimation {
target: content target: content
property: "opacity" property: "opacity"
from: 1 from: 1
to: 0 to: Theme.isDirectionalEffect ? 1 : 0
duration: Theme.notificationExitDuration duration: Theme.variantDuration(Theme.notificationExitDuration, false)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standardAccel easing.bezierCurve: Theme.variantPopoutExitCurve
} }
NumberAnimation { NumberAnimation {
target: content target: content
property: "scale" property: "scale"
from: 1 from: 1
to: 0.98 to: Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed
duration: Theme.notificationExitDuration duration: Theme.variantDuration(Theme.notificationExitDuration, false)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel easing.bezierCurve: Theme.variantPopoutExitCurve
} }
} }

View File

@@ -27,11 +27,11 @@ DankOSD {
let icon = "music_note"; let icon = "music_note";
switch (player.playbackState) { switch (player.playbackState) {
case MprisPlaybackState.Playing: case MprisPlaybackState.Playing:
icon = "play_arrow"; icon = "pause";
break; break;
case MprisPlaybackState.Paused: case MprisPlaybackState.Paused:
case MprisPlaybackState.Stopped: case MprisPlaybackState.Stopped:
icon = "pause"; icon = "play_arrow";
break; break;
} }
if (icon === _displayIcon) if (icon === _displayIcon)

View File

@@ -878,12 +878,17 @@ Item {
x: hoveredButton ? hoveredButton.mapToItem(aboutTab, hoveredButton.width / 2, 0).x - width / 2 : 0 x: hoveredButton ? hoveredButton.mapToItem(aboutTab, hoveredButton.width / 2, 0).x - width / 2 : 0
y: hoveredButton ? communityIcons.mapToItem(aboutTab, 0, 0).y - height - 8 : 0 y: hoveredButton ? communityIcons.mapToItem(aboutTab, 0, 0).y - height - 8 : 0
layer.enabled: true ElevationShadow {
layer.effect: MultiEffect { anchors.fill: parent
shadowEnabled: true z: -1
shadowOpacity: 0.15 level: Theme.elevationLevel1
shadowVerticalOffset: 2 fallbackOffset: 1
shadowBlur: 0.5 targetRadius: communityTooltip.radius
targetColor: communityTooltip.color
borderColor: communityTooltip.border.color
borderWidth: communityTooltip.border.width
shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2
shadowEnabled: Theme.elevationEnabled
} }
StyledText { StyledText {

View File

@@ -52,9 +52,11 @@ Item {
} }
function _isBarActive(c) { function _isBarActive(c) {
if (!c.enabled) return false; if (!c.enabled)
return false;
const prefs = c.screenPreferences || ["all"]; const prefs = c.screenPreferences || ["all"];
if (prefs.length > 0) return true; if (prefs.length > 0)
return true;
return (c.showOnLastDisplay ?? true) && Quickshell.screens.length === 1; return (c.showOnLastDisplay ?? true) && Quickshell.screens.length === 1;
} }
@@ -64,7 +66,8 @@ Item {
return; return;
const hasHorizontal = configs.some(c => { const hasHorizontal = configs.some(c => {
if (!_isBarActive(c)) return false; if (!_isBarActive(c))
return false;
const p = c.position ?? SettingsData.Position.Top; const p = c.position ?? SettingsData.Position.Top;
return p === SettingsData.Position.Top || p === SettingsData.Position.Bottom; return p === SettingsData.Position.Top || p === SettingsData.Position.Bottom;
}); });
@@ -72,7 +75,8 @@ Item {
return; return;
const hasVertical = configs.some(c => { const hasVertical = configs.some(c => {
if (!_isBarActive(c)) return false; if (!_isBarActive(c))
return false;
const p = c.position ?? SettingsData.Position.Top; const p = c.position ?? SettingsData.Position.Top;
return p === SettingsData.Position.Left || p === SettingsData.Position.Right; return p === SettingsData.Position.Left || p === SettingsData.Position.Right;
}); });
@@ -136,7 +140,9 @@ Item {
scrollYBehavior: defaultBar.scrollYBehavior ?? "workspace", scrollYBehavior: defaultBar.scrollYBehavior ?? "workspace",
shadowIntensity: defaultBar.shadowIntensity ?? 0, shadowIntensity: defaultBar.shadowIntensity ?? 0,
shadowOpacity: defaultBar.shadowOpacity ?? 60, shadowOpacity: defaultBar.shadowOpacity ?? 60,
shadowColorMode: defaultBar.shadowColorMode ?? "text", shadowDirectionMode: defaultBar.shadowDirectionMode ?? "inherit",
shadowDirection: defaultBar.shadowDirection ?? "top",
shadowColorMode: defaultBar.shadowColorMode ?? "default",
shadowCustomColor: defaultBar.shadowCustomColor ?? "#000000" shadowCustomColor: defaultBar.shadowCustomColor ?? "#000000"
}; };
SettingsData.addBarConfig(newBar); SettingsData.addBarConfig(newBar);
@@ -1040,6 +1046,237 @@ Item {
} }
} }
SettingsCard {
id: shadowCard
iconName: "layers"
title: I18n.tr("Shadow Override", "bar shadow settings card")
settingKey: "barShadow"
collapsible: true
expanded: true
visible: selectedBarConfig?.enabled
readonly property bool shadowActive: (selectedBarConfig?.shadowIntensity ?? 0) > 0
readonly property bool isCustomColor: (selectedBarConfig?.shadowColorMode ?? "default") === "custom"
readonly property string directionSource: selectedBarConfig?.shadowDirectionMode ?? "inherit"
StyledText {
width: parent.width
text: I18n.tr("Enable a custom override below to set per-bar shadow intensity, opacity, and color.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignLeft
}
SettingsToggleRow {
text: I18n.tr("Custom Shadow Override")
description: I18n.tr("Override the global shadow with per-bar settings")
checked: shadowCard.shadowActive
onToggled: checked => {
if (checked) {
SettingsData.updateBarConfig(selectedBarId, {
shadowIntensity: 12,
shadowOpacity: 60
});
} else {
SettingsData.updateBarConfig(selectedBarId, {
shadowIntensity: 0
});
}
}
}
SettingsSliderRow {
visible: shadowCard.shadowActive
text: I18n.tr("Intensity", "shadow intensity slider")
minimum: 0
maximum: 100
unit: "px"
defaultValue: 12
value: selectedBarConfig?.shadowIntensity ?? 0
onSliderValueChanged: newValue => SettingsData.updateBarConfig(selectedBarId, {
shadowIntensity: newValue
})
}
SettingsSliderRow {
visible: shadowCard.shadowActive
text: I18n.tr("Opacity")
minimum: 10
maximum: 100
unit: "%"
defaultValue: 60
value: selectedBarConfig?.shadowOpacity ?? 60
onSliderValueChanged: newValue => SettingsData.updateBarConfig(selectedBarId, {
shadowOpacity: newValue
})
}
SettingsDropdownRow {
visible: shadowCard.shadowActive
text: I18n.tr("Direction Source", "bar shadow direction source")
description: I18n.tr("Choose how this bar resolves shadow direction")
settingKey: "barShadowDirectionSource"
options: [I18n.tr("Inherit Global (Default)", "bar shadow direction source option"), I18n.tr("Auto (Bar-aware)", "bar shadow direction source option"), I18n.tr("Manual", "bar shadow direction source option")]
currentValue: {
switch (shadowCard.directionSource) {
case "autoBar":
return I18n.tr("Auto (Bar-aware)", "bar shadow direction source option");
case "manual":
return I18n.tr("Manual", "bar shadow direction source option");
default:
return I18n.tr("Inherit Global (Default)", "bar shadow direction source option");
}
}
onValueChanged: value => {
if (value === I18n.tr("Auto (Bar-aware)", "bar shadow direction source option")) {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirectionMode: "autoBar"
});
} else if (value === I18n.tr("Manual", "bar shadow direction source option")) {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirectionMode: "manual"
});
} else {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirectionMode: "inherit"
});
}
}
}
SettingsDropdownRow {
visible: shadowCard.shadowActive && shadowCard.directionSource === "manual"
text: I18n.tr("Manual Direction", "bar manual shadow direction")
description: I18n.tr("Use a fixed shadow direction for this bar")
settingKey: "barShadowDirectionManual"
options: [I18n.tr("Top", "shadow direction option"), I18n.tr("Top Left", "shadow direction option"), I18n.tr("Top Right", "shadow direction option"), I18n.tr("Bottom", "shadow direction option")]
currentValue: {
switch (selectedBarConfig?.shadowDirection) {
case "topLeft":
return I18n.tr("Top Left", "shadow direction option");
case "topRight":
return I18n.tr("Top Right", "shadow direction option");
case "bottom":
return I18n.tr("Bottom", "shadow direction option");
default:
return I18n.tr("Top", "shadow direction option");
}
}
onValueChanged: value => {
if (value === I18n.tr("Top Left", "shadow direction option")) {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirection: "topLeft"
});
} else if (value === I18n.tr("Top Right", "shadow direction option")) {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirection: "topRight"
});
} else if (value === I18n.tr("Bottom", "shadow direction option")) {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirection: "bottom"
});
} else {
SettingsData.updateBarConfig(selectedBarId, {
shadowDirection: "top"
});
}
}
}
Column {
visible: shadowCard.shadowActive
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Color")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
horizontalAlignment: Text.AlignLeft
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
}
Item {
width: parent.width
height: shadowColorGroup.implicitHeight
DankButtonGroup {
id: shadowColorGroup
anchors.horizontalCenter: parent.horizontalCenter
buttonPadding: parent.width < 420 ? Theme.spacingXS : Theme.spacingS
minButtonWidth: parent.width < 420 ? 36 : 56
textSize: parent.width < 420 ? Theme.fontSizeSmall : Theme.fontSizeMedium
model: [I18n.tr("Default (Black)"), I18n.tr("Surface", "shadow color option"), I18n.tr("Primary"), I18n.tr("Secondary"), I18n.tr("Custom")]
selectionMode: "single"
currentIndex: {
switch (selectedBarConfig?.shadowColorMode || "default") {
case "surface":
return 1;
case "primary":
return 2;
case "secondary":
return 3;
case "custom":
return 4;
default:
return 0;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
let mode = "default";
switch (index) {
case 1:
mode = "surface";
break;
case 2:
mode = "primary";
break;
case 3:
mode = "secondary";
break;
case 4:
mode = "custom";
break;
}
SettingsData.updateBarConfig(selectedBarId, {
shadowColorMode: mode
});
}
}
}
Rectangle {
visible: selectedBarConfig?.shadowColorMode === "custom"
width: 32
height: 32
radius: 16
color: selectedBarConfig?.shadowCustomColor ?? "#000000"
border.color: Theme.outline
border.width: 1
anchors.horizontalCenter: parent.horizontalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
PopoutService.colorPickerModal.selectedColor = selectedBarConfig?.shadowCustomColor ?? "#000000";
PopoutService.colorPickerModal.pickerTitle = I18n.tr("Color");
PopoutService.colorPickerModal.onColorSelectedCallback = function (color) {
SettingsData.updateBarConfig(selectedBarId, {
shadowCustomColor: color.toString()
});
};
PopoutService.colorPickerModal.show();
}
}
}
}
}
SettingsCard { SettingsCard {
iconName: "rounded_corner" iconName: "rounded_corner"
title: I18n.tr("Corners & Background") title: I18n.tr("Corners & Background")
@@ -1142,134 +1379,6 @@ Item {
} }
} }
SettingsCard {
id: shadowCard
iconName: "layers"
title: I18n.tr("Shadow", "bar shadow settings card")
settingKey: "barShadow"
collapsible: true
expanded: false
visible: selectedBarConfig?.enabled
readonly property bool shadowActive: (selectedBarConfig?.shadowIntensity ?? 0) > 0
readonly property bool isCustomColor: (selectedBarConfig?.shadowColorMode ?? "text") === "custom"
SettingsSliderRow {
text: I18n.tr("Intensity", "shadow intensity slider")
minimum: 0
maximum: 100
unit: "%"
value: selectedBarConfig?.shadowIntensity ?? 0
onSliderValueChanged: newValue => SettingsData.updateBarConfig(selectedBarId, {
shadowIntensity: newValue
})
}
SettingsSliderRow {
visible: shadowCard.shadowActive
text: I18n.tr("Opacity")
minimum: 10
maximum: 100
unit: "%"
value: selectedBarConfig?.shadowOpacity ?? 60
onSliderValueChanged: newValue => SettingsData.updateBarConfig(selectedBarId, {
shadowOpacity: newValue
})
}
Column {
visible: shadowCard.shadowActive
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Color")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
horizontalAlignment: Text.AlignLeft
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
}
Item {
width: parent.width
height: shadowColorGroup.implicitHeight
DankButtonGroup {
id: shadowColorGroup
anchors.horizontalCenter: parent.horizontalCenter
buttonPadding: parent.width < 420 ? Theme.spacingXS : Theme.spacingS
minButtonWidth: parent.width < 420 ? 36 : 56
textSize: parent.width < 420 ? Theme.fontSizeSmall : Theme.fontSizeMedium
model: [I18n.tr("Text", "shadow color option"), I18n.tr("Surface", "shadow color option"), I18n.tr("Primary"), I18n.tr("Secondary"), I18n.tr("Custom")]
selectionMode: "single"
currentIndex: {
switch (selectedBarConfig?.shadowColorMode || "text") {
case "surface":
return 1;
case "primary":
return 2;
case "secondary":
return 3;
case "custom":
return 4;
default:
return 0;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
let mode = "text";
switch (index) {
case 1:
mode = "surface";
break;
case 2:
mode = "primary";
break;
case 3:
mode = "secondary";
break;
case 4:
mode = "custom";
break;
}
SettingsData.updateBarConfig(selectedBarId, {
shadowColorMode: mode
});
}
}
}
Rectangle {
visible: selectedBarConfig?.shadowColorMode === "custom"
width: 32
height: 32
radius: 16
color: selectedBarConfig?.shadowCustomColor ?? "#000000"
border.color: Theme.outline
border.width: 1
anchors.horizontalCenter: parent.horizontalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
PopoutService.colorPickerModal.selectedColor = selectedBarConfig?.shadowCustomColor ?? "#000000";
PopoutService.colorPickerModal.pickerTitle = I18n.tr("Color");
PopoutService.colorPickerModal.onColorSelectedCallback = function (color) {
SettingsData.updateBarConfig(selectedBarId, {
shadowCustomColor: color.toString()
});
};
PopoutService.colorPickerModal.show();
}
}
}
}
}
SettingsToggleCard { SettingsToggleCard {
iconName: "border_style" iconName: "border_style"
title: I18n.tr("Border") title: I18n.tr("Border")

View File

@@ -897,7 +897,7 @@ Item {
Image { Image {
width: 24 width: 24
height: 24 height: 24
source: modelData.icon ? "image://icon/" + modelData.icon : "image://icon/application-x-executable" source: Paths.resolveIconUrl(modelData.icon || "application-x-executable")
sourceSize.width: 24 sourceSize.width: 24
sourceSize.height: 24 sourceSize.height: 24
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
@@ -1008,7 +1008,7 @@ Item {
Image { Image {
width: 24 width: 24
height: 24 height: 24
source: modelData.icon ? "image://icon/" + modelData.icon : "image://icon/application-x-executable" source: Paths.resolveIconUrl(modelData.icon || "application-x-executable")
sourceSize.width: 24 sourceSize.width: 24
sourceSize.height: 24 sourceSize.height: 24
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
@@ -1154,7 +1154,7 @@ Item {
Image { Image {
width: 24 width: 24
height: 24 height: 24
source: modelData.icon ? "image://icon/" + modelData.icon : "image://icon/application-x-executable" source: Paths.resolveIconUrl(modelData.icon || "application-x-executable")
sourceSize.width: 24 sourceSize.width: 24
sourceSize.height: 24 sourceSize.height: 24
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit

View File

@@ -1,6 +1,5 @@
import QtQuick import QtQuick
import qs.Common import qs.Common
import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modules.Settings.Widgets import qs.Modules.Settings.Widgets
@@ -9,17 +8,25 @@ Item {
readonly property string _systemDefaultLabel: I18n.tr("System Default") readonly property string _systemDefaultLabel: I18n.tr("System Default")
function capitalizeNativeLanguageName(localeCode) { function _localeDisplayName(localeCode) {
if (I18n.presentLocales[localeCode] == undefined) { if (!I18n.presentLocales[localeCode])
return; return;
}
const nativeName = I18n.presentLocales[localeCode].nativeLanguageName; const nativeName = I18n.presentLocales[localeCode].nativeLanguageName;
return nativeName[0].toUpperCase() + nativeName.slice(1); return nativeName[0].toUpperCase() + nativeName.slice(1);
} }
function _displayValue() { function _allLocaleOptions() {
if (!SessionData.locale) return _systemDefaultLabel; return [_systemDefaultLabel].concat(Object.keys(I18n.presentLocales).map(_localeDisplayName));
return capitalizeNativeLanguageName(SessionData.locale); }
function _codeForDisplayName(displayName) {
if (displayName === _systemDefaultLabel)
return "";
for (const code of Object.keys(I18n.presentLocales)) {
if (_localeDisplayName(code) === displayName)
return code;
}
return "";
} }
DankFlickable { DankFlickable {
@@ -48,24 +55,34 @@ Item {
settingKey: "locale" settingKey: "locale"
text: I18n.tr("Current Locale") text: I18n.tr("Current Locale")
description: I18n.tr("Change the locale used by the DMS interface.") description: I18n.tr("Change the locale used by the DMS interface.")
options: [localeTab._systemDefaultLabel].concat(Object.keys(I18n.presentLocales).map(localeTab.capitalizeNativeLanguageName)) options: localeTab._allLocaleOptions()
enableFuzzySearch: true enableFuzzySearch: true
Component.onCompleted: { Component.onCompleted: {
currentValue = localeTab._displayValue(); currentValue = SessionData.locale ? localeTab._localeDisplayName(SessionData.locale) : localeTab._systemDefaultLabel;
} }
onValueChanged: value => { onValueChanged: value => {
if (value === localeTab._systemDefaultLabel) { SessionData.set("locale", localeTab._codeForDisplayName(value));
SessionData.set("locale", ""); }
return; }
}
for (let code of Object.keys(I18n.presentLocales)) { SettingsDropdownRow {
if (localeTab.capitalizeNativeLanguageName(code) === value) { id: timeLocaleDropdown
SessionData.set("locale", code); tab: "locale"
return; tags: ["locale", "time", "date", "format", "region"]
} settingKey: "timeLocale"
} text: I18n.tr("Time & Date Locale")
description: I18n.tr("Change the locale used for date and time formatting, independent of the interface language.")
options: localeTab._allLocaleOptions()
enableFuzzySearch: true
Component.onCompleted: {
currentValue = SessionData.timeLocale ? localeTab._localeDisplayName(SessionData.timeLocale) : localeTab._systemDefaultLabel;
}
onValueChanged: value => {
SessionData.set("timeLocale", localeTab._codeForDisplayName(value));
} }
} }
} }

View File

@@ -274,7 +274,7 @@ Item {
settingKey: "notificationPopupShadowEnabled" settingKey: "notificationPopupShadowEnabled"
tags: ["notification", "popup", "shadow", "radius", "rounded"] tags: ["notification", "popup", "shadow", "radius", "rounded"]
text: I18n.tr("Popup Shadow") text: I18n.tr("Popup Shadow")
description: I18n.tr("Show drop shadow on notification popups") description: I18n.tr("Show drop shadow on notification popups. Requires M3 Elevation to be enabled in Theme & Colors.")
checked: SettingsData.notificationPopupShadowEnabled checked: SettingsData.notificationPopupShadowEnabled
onToggled: checked => SettingsData.set("notificationPopupShadowEnabled", checked) onToggled: checked => SettingsData.set("notificationPopupShadowEnabled", checked)
} }

View File

@@ -14,6 +14,12 @@ Item {
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
property bool showAddPrinter: false property bool showAddPrinter: false
property bool manualEntryMode: false
property string manualHost: ""
property string manualPort: "631"
property string manualProtocol: "ipp"
property bool testingConnection: false
property var testConnectionResult: null
property string newPrinterName: "" property string newPrinterName: ""
property string selectedDeviceUri: "" property string selectedDeviceUri: ""
property var selectedDevice: null property var selectedDevice: null
@@ -23,6 +29,12 @@ Item {
property var suggestedPPDs: [] property var suggestedPPDs: []
function resetAddPrinterForm() { function resetAddPrinterForm() {
manualEntryMode = false;
manualHost = "";
manualPort = "631";
manualProtocol = "ipp";
testingConnection = false;
testConnectionResult = null;
newPrinterName = ""; newPrinterName = "";
selectedDeviceUri = ""; selectedDeviceUri = "";
selectedDevice = null; selectedDevice = null;
@@ -32,6 +44,45 @@ Item {
suggestedPPDs = []; suggestedPPDs = [];
} }
Connections {
target: CupsService
function onPpdsChanged() {
if (printerTab.manualEntryMode && printerTab.testConnectionResult?.success)
printerTab.selectDriverlessPPD();
}
}
function selectDriverlessPPD() {
if (printerTab.selectedPpd || CupsService.ppds.length === 0)
return;
const probeModel = printerTab.testConnectionResult?.data?.makeModel || "";
let suggested = [];
// Try to find a model-specific PPD match
if (probeModel) {
const normalizedModel = probeModel.toLowerCase().replace(/[^a-z0-9]/g, "");
const modelMatches = CupsService.ppds.filter(p => {
const normalizedPPD = (p.makeModel || "").toLowerCase().replace(/[^a-z0-9]/g, "");
return normalizedPPD.includes(normalizedModel) || normalizedModel.includes(normalizedPPD);
});
if (modelMatches.length > 0)
suggested = suggested.concat(modelMatches);
}
// Always include driverless as an option
const driverless = CupsService.ppds.filter(p => p.name === "driverless" || p.name === "everywhere");
for (const d of driverless) {
if (!suggested.find(s => s.name === d.name))
suggested.push(d);
}
if (suggested.length > 0) {
printerTab.selectedPpd = suggested[0].name;
printerTab.suggestedPPDs = suggested;
}
}
function selectDevice(device) { function selectDevice(device) {
if (!device) if (!device)
return; return;
@@ -276,9 +327,93 @@ Item {
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
} }
Row {
width: parent.width
spacing: Theme.spacingS
Rectangle {
width: discoverRow.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius
color: !printerTab.manualEntryMode ? Theme.primary : (discoverArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: discoverRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "search"
size: 16
color: !printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
}
StyledText {
text: I18n.tr("Discover Devices", "Toggle button to scan for printers via mDNS/Avahi")
font.pixelSize: Theme.fontSizeSmall
color: !printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: discoverArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.manualEntryMode = false;
printerTab.testConnectionResult = null;
printerTab.testingConnection = false;
}
}
}
Rectangle {
width: manualRow.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius
color: printerTab.manualEntryMode ? Theme.primary : (manualArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: manualRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: "edit"
size: 16
color: printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
}
StyledText {
text: I18n.tr("Add by Address", "Toggle button to manually add a printer by IP or hostname")
font.pixelSize: Theme.fontSizeSmall
color: printerTab.manualEntryMode ? Theme.onPrimary : Theme.surfaceText
font.weight: Font.Medium
}
}
MouseArea {
id: manualArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
printerTab.manualEntryMode = true;
printerTab.selectedDevice = null;
printerTab.selectedDeviceUri = "";
if (CupsService.ppds.length === 0)
CupsService.getPPDs();
}
}
}
}
Column { Column {
width: parent.width width: parent.width
spacing: Theme.spacingS spacing: Theme.spacingS
visible: !printerTab.manualEntryMode
Row { Row {
width: parent.width width: parent.width
@@ -351,6 +486,202 @@ Item {
elide: Text.ElideRight elide: Text.ElideRight
} }
} }
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: printerTab.manualEntryMode
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Host", "Label for printer IP address or hostname input field")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: parent.width - 80 - Theme.spacingS
placeholderText: I18n.tr("IP address or hostname", "Placeholder text for manual printer address input")
text: printerTab.manualHost
onTextEdited: {
printerTab.manualHost = text;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Port", "Label for printer port number input field")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankTextField {
width: 80
placeholderText: "631"
text: printerTab.manualPort
onTextEdited: {
printerTab.manualPort = text;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Protocol", "Label for printer protocol selector, e.g. ipp, ipps, lpd, socket")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: 80
anchors.verticalCenter: parent.verticalCenter
}
DankDropdown {
id: protocolDropdown
dropdownWidth: 120
popupWidth: 120
currentValue: printerTab.manualProtocol
options: ["ipp", "ipps", "lpd", "socket"]
onValueChanged: value => {
printerTab.manualProtocol = value;
printerTab.testConnectionResult = null;
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
Item {
width: 80
height: 1
}
DankButton {
text: printerTab.testingConnection ? I18n.tr("Testing...", "Button state while testing printer connection") : I18n.tr("Test Connection", "Button to test connection to a printer by IP address")
iconName: printerTab.testingConnection ? "sync" : "lan"
buttonHeight: 36
enabled: printerTab.manualHost.length > 0 && !printerTab.testingConnection
onClicked: {
printerTab.testingConnection = true;
printerTab.testConnectionResult = null;
const port = parseInt(printerTab.manualPort) || 631;
CupsService.testConnection(printerTab.manualHost, port, printerTab.manualProtocol, response => {
printerTab.testingConnection = false;
if (response.error) {
printerTab.testConnectionResult = {
"success": false,
"error": response.error
};
} else if (response.result) {
printerTab.testConnectionResult = {
"success": response.result.reachable === true,
"data": response.result
};
if (response.result.reachable) {
if (response.result.uri)
printerTab.selectedDeviceUri = response.result.uri;
if (response.result.name && !printerTab.newPrinterName)
printerTab.newPrinterName = response.result.name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").substring(0, 32) || "Printer";
// Load PPDs if not loaded yet, then select driverless
if (CupsService.ppds.length === 0) {
CupsService.getPPDs();
}
selectDriverlessPPD();
}
}
});
}
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
visible: printerTab.testConnectionResult !== null
Row {
spacing: Theme.spacingS
Item {
width: 80
height: 1
}
Rectangle {
width: 8
height: 8
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: printerTab.testConnectionResult?.success ? Theme.success : Theme.error
}
StyledText {
text: printerTab.testConnectionResult?.success ? I18n.tr("Printer reachable", "Status message when test connection to printer succeeds") : I18n.tr("Connection failed", "Status message when test connection to printer fails")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: printerTab.testConnectionResult?.success ? Theme.success : Theme.error
}
}
Row {
spacing: Theme.spacingS
visible: printerTab.testConnectionResult?.success && (printerTab.testConnectionResult?.data?.makeModel || printerTab.testConnectionResult?.data?.info)
Item {
width: 80
height: 1
}
StyledText {
text: printerTab.testConnectionResult?.data?.makeModel || printerTab.testConnectionResult?.data?.info || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
Row {
spacing: Theme.spacingS
visible: !printerTab.testConnectionResult?.success && printerTab.testConnectionResult?.data?.error
Item {
width: 80
height: 1
}
StyledText {
text: printerTab.testConnectionResult?.data?.error || printerTab.testConnectionResult?.error || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.parent.width - 80 - Theme.spacingS
wrapMode: Text.WordWrap
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
Row { Row {
width: parent.width width: parent.width

View File

@@ -126,6 +126,15 @@ Item {
return Theme.warning; return Theme.warning;
} }
function openM3ShadowColorPicker() {
PopoutService.colorPickerModal.selectedColor = SettingsData.m3ElevationCustomColor ?? "#000000";
PopoutService.colorPickerModal.pickerTitle = I18n.tr("Shadow Color");
PopoutService.colorPickerModal.onColorSelectedCallback = function (color) {
SettingsData.set("m3ElevationCustomColor", color.toString());
};
PopoutService.colorPickerModal.show();
}
function formatThemeAutoTime(isoString) { function formatThemeAutoTime(isoString) {
if (!isoString) if (!isoString)
return ""; return "";
@@ -1592,6 +1601,189 @@ Item {
defaultValue: 12 defaultValue: 12
onSliderValueChanged: newValue => SettingsData.setCornerRadius(newValue) onSliderValueChanged: newValue => SettingsData.setCornerRadius(newValue)
} }
SettingsToggleRow {
tab: "theme"
tags: ["elevation", "shadow", "lift", "m3", "material"]
settingKey: "m3ElevationEnabled"
text: I18n.tr("Shadows")
description: I18n.tr("Material inspired shadows and elevation on modals, popouts, and dialogs")
checked: SettingsData.m3ElevationEnabled ?? true
onToggled: checked => SettingsData.set("m3ElevationEnabled", checked)
}
SettingsSliderRow {
tab: "theme"
tags: ["elevation", "shadow", "intensity", "blur", "m3"]
settingKey: "m3ElevationIntensity"
text: I18n.tr("Shadow Intensity")
description: I18n.tr("Controls the base blur radius and offset of shadows")
value: SettingsData.m3ElevationIntensity ?? 12
minimum: 0
maximum: 100
unit: "px"
defaultValue: 12
visible: SettingsData.m3ElevationEnabled ?? true
onSliderValueChanged: newValue => SettingsData.set("m3ElevationIntensity", newValue)
}
SettingsSliderRow {
tab: "theme"
tags: ["elevation", "shadow", "opacity", "transparency", "m3"]
settingKey: "m3ElevationOpacity"
text: I18n.tr("Shadow Opacity")
description: I18n.tr("Controls the transparency of the shadow")
value: SettingsData.m3ElevationOpacity ?? 30
minimum: 0
maximum: 100
unit: "%"
defaultValue: 30
visible: SettingsData.m3ElevationEnabled ?? true
onSliderValueChanged: newValue => SettingsData.set("m3ElevationOpacity", newValue)
}
SettingsDropdownRow {
tab: "theme"
tags: ["elevation", "shadow", "color", "m3"]
settingKey: "m3ElevationColorMode"
text: I18n.tr("Shadow Color")
description: I18n.tr("Base color for shadows (opacity is applied automatically)")
options: [I18n.tr("Default (Black)", "shadow color option"), I18n.tr("Text Color", "shadow color option"), I18n.tr("Primary", "shadow color option"), I18n.tr("Surface Variant", "shadow color option"), I18n.tr("Custom", "shadow color option")]
currentValue: {
switch (SettingsData.m3ElevationColorMode) {
case "text":
return I18n.tr("Text Color", "shadow color option");
case "primary":
return I18n.tr("Primary", "shadow color option");
case "surfaceVariant":
return I18n.tr("Surface Variant", "shadow color option");
case "custom":
return I18n.tr("Custom", "shadow color option");
default:
return I18n.tr("Default (Black)", "shadow color option");
}
}
visible: SettingsData.m3ElevationEnabled ?? true
onValueChanged: value => {
if (value === I18n.tr("Primary", "shadow color option")) {
SettingsData.set("m3ElevationColorMode", "primary");
} else if (value === I18n.tr("Surface Variant", "shadow color option")) {
SettingsData.set("m3ElevationColorMode", "surfaceVariant");
} else if (value === I18n.tr("Custom", "shadow color option")) {
SettingsData.set("m3ElevationColorMode", "custom");
openM3ShadowColorPicker();
} else if (value === I18n.tr("Text Color", "shadow color option")) {
SettingsData.set("m3ElevationColorMode", "text");
} else {
SettingsData.set("m3ElevationColorMode", "default");
}
}
}
SettingsDropdownRow {
tab: "theme"
tags: ["elevation", "shadow", "direction", "light", "advanced", "m3"]
settingKey: "m3ElevationLightDirection"
text: I18n.tr("Light Direction")
description: I18n.tr("Controls shadow cast direction for elevation layers")
options: [I18n.tr("Auto (Bar-aware)", "shadow direction option"), I18n.tr("Top (Default)", "shadow direction option"), I18n.tr("Top Left", "shadow direction option"), I18n.tr("Top Right", "shadow direction option"), I18n.tr("Bottom", "shadow direction option")]
currentValue: {
switch (SettingsData.m3ElevationLightDirection) {
case "autoBar":
return I18n.tr("Auto (Bar-aware)", "shadow direction option");
case "topLeft":
return I18n.tr("Top Left", "shadow direction option");
case "topRight":
return I18n.tr("Top Right", "shadow direction option");
case "bottom":
return I18n.tr("Bottom", "shadow direction option");
default:
return I18n.tr("Top (Default)", "shadow direction option");
}
}
visible: SettingsData.m3ElevationEnabled ?? true
onValueChanged: value => {
if (value === I18n.tr("Auto (Bar-aware)", "shadow direction option")) {
SettingsData.set("m3ElevationLightDirection", "autoBar");
} else if (value === I18n.tr("Top Left", "shadow direction option")) {
SettingsData.set("m3ElevationLightDirection", "topLeft");
} else if (value === I18n.tr("Top Right", "shadow direction option")) {
SettingsData.set("m3ElevationLightDirection", "topRight");
} else if (value === I18n.tr("Bottom", "shadow direction option")) {
SettingsData.set("m3ElevationLightDirection", "bottom");
} else {
SettingsData.set("m3ElevationLightDirection", "top");
}
}
}
Item {
visible: (SettingsData.m3ElevationEnabled ?? true) && SettingsData.m3ElevationColorMode === "custom"
width: parent.width
implicitHeight: 36
height: implicitHeight
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
StyledText {
text: I18n.tr("Custom Shadow Color")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
verticalAlignment: Text.AlignVCenter
}
Rectangle {
width: 26
height: 26
radius: 13
color: SettingsData.m3ElevationCustomColor ?? "#000000"
border.color: Theme.outline
border.width: 1
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: openM3ShadowColorPicker()
}
}
}
}
SettingsToggleRow {
tab: "theme"
tags: ["elevation", "shadow", "modal", "dialog", "m3"]
settingKey: "modalElevationEnabled"
text: I18n.tr("Modal Shadows")
description: I18n.tr("Shadow elevation on modals and dialogs")
checked: SettingsData.modalElevationEnabled ?? true
visible: SettingsData.m3ElevationEnabled ?? true
onToggled: checked => SettingsData.set("modalElevationEnabled", checked)
}
SettingsToggleRow {
tab: "theme"
tags: ["elevation", "shadow", "popout", "popup", "osd", "dropdown", "m3"]
settingKey: "popoutElevationEnabled"
text: I18n.tr("Popout Shadows")
description: I18n.tr("Shadow elevation on popouts, OSDs, and dropdowns")
checked: SettingsData.popoutElevationEnabled ?? true
visible: SettingsData.m3ElevationEnabled ?? true
onToggled: checked => SettingsData.set("popoutElevationEnabled", checked)
}
SettingsToggleRow {
tab: "theme"
tags: ["elevation", "shadow", "bar", "panel", "navigation", "m3"]
settingKey: "barElevationEnabled"
text: I18n.tr("Bar Shadows")
description: I18n.tr("Shadow elevation on bars and panels")
checked: SettingsData.barElevationEnabled ?? true
visible: SettingsData.m3ElevationEnabled ?? true
onToggled: checked => SettingsData.set("barElevationEnabled", checked)
}
} }
SettingsCard { SettingsCard {
@@ -2138,12 +2330,41 @@ Item {
} }
} }
SettingsCard {
tab: "theme"
tags: ["icon", "theme", "system"]
title: I18n.tr("Icon Theme")
settingKey: "iconTheme"
iconName: "interests"
SettingsDropdownRow {
tab: "theme"
tags: ["icon", "theme", "system"]
settingKey: "iconTheme"
text: I18n.tr("Icon Theme")
description: I18n.tr("DankShell & System Icons (requires restart)")
currentValue: SettingsData.iconTheme
enableFuzzySearch: true
popupWidthOffset: 100
maxPopupHeight: 236
options: cachedIconThemes
onValueChanged: value => {
SettingsData.setIconTheme(value);
if (Quickshell.env("QT_QPA_PLATFORMTHEME") != "gtk3" && Quickshell.env("QT_QPA_PLATFORMTHEME") != "qt6ct" && Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") != "qt6ct") {
ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set either:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct\nas environment variables, and then restart the shell.\n\nqt6ct requires qt6ct-kde to be installed.", "qt theme env error body"));
}
}
}
}
SettingsCard { SettingsCard {
tab: "theme" tab: "theme"
tags: ["matugen", "templates", "theming"] tags: ["matugen", "templates", "theming"]
title: I18n.tr("Matugen Templates") title: I18n.tr("Matugen Templates")
settingKey: "matugenTemplates" settingKey: "matugenTemplates"
iconName: "auto_awesome" iconName: "auto_awesome"
collapsible: true
expanded: false
visible: Theme.matugenAvailable visible: Theme.matugenAvailable
SettingsToggleRow { SettingsToggleRow {
@@ -2448,33 +2669,6 @@ Item {
} }
} }
SettingsCard {
tab: "theme"
tags: ["icon", "theme", "system"]
title: I18n.tr("Icon Theme")
settingKey: "iconTheme"
iconName: "interests"
SettingsDropdownRow {
tab: "theme"
tags: ["icon", "theme", "system"]
settingKey: "iconTheme"
text: I18n.tr("Icon Theme")
description: I18n.tr("DankShell & System Icons (requires restart)")
currentValue: SettingsData.iconTheme
enableFuzzySearch: true
popupWidthOffset: 100
maxPopupHeight: 236
options: cachedIconThemes
onValueChanged: value => {
SettingsData.setIconTheme(value);
if (Quickshell.env("QT_QPA_PLATFORMTHEME") != "gtk3" && Quickshell.env("QT_QPA_PLATFORMTHEME") != "qt6ct" && Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") != "qt6ct") {
ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set either:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct\nas environment variables, and then restart the shell.\n\nqt6ct requires qt6ct-kde to be installed.", "qt theme env error body"));
}
}
}
}
SettingsCard { SettingsCard {
tab: "theme" tab: "theme"
tags: ["system", "app", "theming", "gtk", "qt"] tags: ["system", "app", "theming", "gtk", "qt"]

View File

@@ -9,6 +9,22 @@ import qs.Modules.Settings.Widgets
Item { Item {
id: root id: root
readonly property string _systemDefaultLabel: I18n.tr("System Default")
function weekStartQt() {
if (SettingsData.firstDayOfWeek < 0 || SettingsData.firstDayOfWeek >= 7)
return Qt.locale().firstDayOfWeek;
return SettingsData.firstDayOfWeek;
}
function weekStartJs() {
return weekStartQt() % 7;
}
function _dayNames() {
return Array(7).fill(0).map((_, i) => new Date(Date.UTC(2026, 2, 1 + i, 0, 0, 0)).toLocaleDateString(I18n.locale(), "dddd")).map(d => d[0].toUpperCase() + d.slice(1));
}
DankFlickable { DankFlickable {
anchors.fill: parent anchors.fill: parent
clip: true clip: true
@@ -69,6 +85,33 @@ Item {
settingKey: "dateFormat" settingKey: "dateFormat"
iconName: "calendar_today" iconName: "calendar_today"
SettingsDropdownRow {
tab: "time"
tags: ["first", "day", "week"]
settingKey: "firstDayOfWeek"
text: I18n.tr("First Day of Week")
options: [root._systemDefaultLabel].concat(root._dayNames())
currentValue: {
if (SettingsData.firstDayOfWeek < 0 || SettingsData.firstDayOfWeek >= 7)
return root._systemDefaultLabel;
return root._dayNames()[root.weekStartJs()];
}
onValueChanged: value => {
if (value === root._systemDefaultLabel) {
SettingsData.set("firstDayOfWeek", -1);
return;
}
SettingsData.set("firstDayOfWeek", root._dayNames().indexOf(value));
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
}
SettingsDropdownRow { SettingsDropdownRow {
tab: "time" tab: "time"
tags: ["date", "format", "topbar"] tags: ["date", "format", "topbar"]
@@ -663,14 +706,15 @@ Item {
anchors.left: parent.left anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
layer.enabled: true layer.enabled: Theme.elevationEnabled
layer.effect: MultiEffect { layer.effect: MultiEffect {
shadowEnabled: true shadowEnabled: Theme.elevationEnabled
shadowHorizontalOffset: 0 shadowHorizontalOffset: Theme.elevationOffsetX(Theme.elevationLevel1)
shadowVerticalOffset: 4 shadowVerticalOffset: Theme.elevationOffsetY(Theme.elevationLevel1, 1)
shadowBlur: 0.8 shadowBlur: Theme.elevationEnabled ? Math.max(0, Math.min(1, (Theme.elevationLevel1 && Theme.elevationLevel1.blurPx !== undefined ? Theme.elevationLevel1.blurPx : 4) / Theme.elevationBlurMax)) : 0
shadowColor: Qt.rgba(0, 0, 0, 0.2) blurMax: Theme.elevationBlurMax
shadowOpacity: 0.2 shadowColor: Theme.elevationShadowColor(Theme.elevationLevel1)
shadowOpacity: Theme.elevationLevel1 && Theme.elevationLevel1.alpha !== undefined ? Theme.elevationLevel1.alpha : 0.2
} }
} }

View File

@@ -55,6 +55,180 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL spacing: Theme.spacingXL
SettingsCard {
tab: "typography"
tags: ["animation", "variant", "style", "slide", "fluent", "dynamic", "motion"]
title: I18n.tr("Animation Style")
settingKey: "animationVariant"
iconName: "auto_awesome_motion"
Item {
width: parent.width
height: animVariantGroup.implicitHeight
clip: true
DankButtonGroup {
id: animVariantGroup
anchors.horizontalCenter: parent.horizontalCenter
buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL
minButtonWidth: parent.width < 480 ? 64 : 96
textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium
model: [I18n.tr("Material"), I18n.tr("Fluent"), I18n.tr("Dynamic")]
selectionMode: "single"
currentIndex: SettingsData.animationVariant
onSelectionChanged: (index, selected) => {
if (!selected)
return;
SettingsData.set("animationVariant", index);
}
Connections {
target: SettingsData
function onAnimationVariantChanged() {
animVariantGroup.currentIndex = SettingsData.animationVariant;
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
}
Item {
width: parent.width
height: variantDescription.implicitHeight + Theme.spacingS * 2
StyledText {
id: variantDescription
x: Theme.spacingM
y: Theme.spacingS
width: parent.width - Theme.spacingM * 2
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
text: {
switch (SettingsData.animationVariant) {
case 1:
return I18n.tr("Fluent: Smooth cubic deceleration in, quick snap out — clean, elegant curves.");
case 2:
return I18n.tr("Dynamic: Spring bezier with overshoot — entry briefly exceeds its target then settles. Expressive and alive.");
default:
return I18n.tr("Material: Material Design 3 Expressive bezier curves. The DMS default feel.");
}
}
}
}
}
SettingsCard {
tab: "typography"
tags: ["animation", "motion", "effect", "slide", "directional", "depth", "spring", "physics"]
title: I18n.tr("Motion Effects")
settingKey: "motionEffect"
iconName: "motion_photos_on"
Item {
width: parent.width
height: motionEffectGroup.implicitHeight
clip: true
DankButtonGroup {
id: motionEffectGroup
anchors.horizontalCenter: parent.horizontalCenter
buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingL
minButtonWidth: parent.width < 480 ? 64 : 96
textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium
model: [I18n.tr("Standard"), I18n.tr("Directional"), I18n.tr("Depth")]
selectionMode: "single"
currentIndex: SettingsData.motionEffect
onSelectionChanged: (index, selected) => {
if (!selected)
return;
SettingsData.set("motionEffect", index);
}
Connections {
target: SettingsData
function onMotionEffectChanged() {
motionEffectGroup.currentIndex = SettingsData.motionEffect;
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
}
Item {
width: parent.width
height: motionEffectDescription.implicitHeight + Theme.spacingS * 2
StyledText {
id: motionEffectDescription
x: Theme.spacingM
y: Theme.spacingS
width: parent.width - Theme.spacingM * 2
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
text: {
switch (SettingsData.motionEffect) {
case 1:
return I18n.tr("Directional: Panels glide in from a larger distance at full size — no scale change, pure clean motion.");
case 2:
return I18n.tr("Depth: Panels scale up from small as they slide in — a dramatic pop-forward depth effect.");
default:
return I18n.tr("Standard: Classic Material Design 3 — panels rise from below with a subtle scale. The DMS default.");
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
visible: SettingsData.motionEffect === 1
}
SettingsDropdownRow {
visible: SettingsData.motionEffect === 1
tab: "typography"
tags: ["animation", "directional", "behavior", "overlap", "sticky", "roll"]
settingKey: "directionalAnimationMode"
text: I18n.tr("Directional Behavior")
description: I18n.tr("How the popout emerges from the DankBar")
options: [I18n.tr("Overlap"), I18n.tr("Slide"), I18n.tr("Roll")]
currentValue: {
switch (SettingsData.directionalAnimationMode) {
case 1:
return I18n.tr("Slide");
case 2:
return I18n.tr("Roll");
default:
return I18n.tr("Overlap");
}
}
onValueChanged: value => {
if (value === I18n.tr("Slide"))
SettingsData.set("directionalAnimationMode", 1);
else if (value === I18n.tr("Roll"))
SettingsData.set("directionalAnimationMode", 2);
else
SettingsData.set("directionalAnimationMode", 0);
}
}
}
SettingsCard { SettingsCard {
tab: "typography" tab: "typography"
tags: ["font", "family", "text", "typography"] tags: ["font", "family", "text", "typography"]

View File

@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
@@ -96,7 +95,6 @@ PanelWindow {
} }
} }
radius: Theme.cornerRadius radius: Theme.cornerRadius
layer.enabled: true
opacity: shouldBeVisible ? 1 : 0 opacity: shouldBeVisible ? 1 : 0
Column { Column {
@@ -406,13 +404,15 @@ PanelWindow {
onClicked: ToastService.hideToast() onClicked: ToastService.hideToast()
} }
layer.effect: MultiEffect { ElevationShadow {
shadowEnabled: true anchors.fill: parent
shadowHorizontalOffset: 0 z: -1
shadowVerticalOffset: 4 level: Theme.elevationLevel3
shadowBlur: 0.8 fallbackOffset: 6
shadowColor: Qt.rgba(0, 0, 0, 0.3) targetRadius: toast.radius
shadowOpacity: 0.3 targetColor: toast.color
shadowOpacity: Theme.elevationLevel3 && Theme.elevationLevel3.alpha !== undefined ? Theme.elevationLevel3.alpha : 0.3
shadowEnabled: Theme.elevationEnabled
} }
Behavior on opacity { Behavior on opacity {

View File

@@ -135,7 +135,7 @@ Variants {
Timer { Timer {
id: renderSettleTimer id: renderSettleTimer
interval: 100 interval: 1000
onTriggered: root._renderSettling = false onTriggered: root._renderSettling = false
} }

View File

@@ -121,9 +121,9 @@ Scope {
Behavior on opacity { Behavior on opacity {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }
@@ -154,45 +154,69 @@ Scope {
id: scaleTransform id: scaleTransform
origin.x: contentContainer.width / 2 origin.x: contentContainer.width / 2
origin.y: contentContainer.height / 2 origin.y: contentContainer.height / 2
xScale: overviewScope.overviewOpen ? 1 : 0.96 xScale: overviewScope.overviewOpen ? 1 : Theme.effectScaleCollapsed
yScale: overviewScope.overviewOpen ? 1 : 0.96 yScale: overviewScope.overviewOpen ? 1 : Theme.effectScaleCollapsed
Behavior on xScale { Behavior on xScale {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }
Behavior on yScale { Behavior on yScale {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }
} }
Translate { Translate {
id: motionTransform id: motionTransform
x: 0 x: {
y: overviewScope.overviewOpen ? 0 : Theme.spacingL if (overviewScope.overviewOpen)
return 0;
if (Theme.isDirectionalEffect)
return 0;
if (Theme.isDepthEffect)
return Theme.effectAnimOffset * 0.25;
return 0;
}
y: {
if (overviewScope.overviewOpen)
return 0;
if (Theme.isDirectionalEffect)
return -Math.max(contentContainer.height * 0.8, Theme.effectAnimOffset * 1.1);
if (Theme.isDepthEffect)
return Math.max(Theme.effectAnimOffset * 0.85, 28);
return Theme.effectAnimOffset;
}
Behavior on x {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen)
easing.type: Easing.BezierSpline
easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
Behavior on y { Behavior on y {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }
} }
Behavior on opacity { Behavior on opacity {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewScope.overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: overviewScope.overviewOpen ? Theme.expressiveCurves.expressiveDefaultSpatial : Theme.expressiveCurves.emphasized easing.bezierCurve: overviewScope.overviewOpen ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }

View File

@@ -202,8 +202,18 @@ Scope {
Item { Item {
id: spotlightContainer id: spotlightContainer
x: Theme.snap((parent.width - width) / 2, overlayWindow.dpr) readonly property bool directionalEffect: Theme.isDirectionalEffect
y: Theme.snap((parent.height - height) / 2, overlayWindow.dpr) readonly property bool depthEffect: Theme.isDepthEffect
readonly property real collapsedMotionX: depthEffect ? Theme.effectAnimOffset * 0.25 : 0
readonly property real collapsedMotionY: {
if (directionalEffect)
return Math.max(height * 0.85, Theme.effectAnimOffset * 1.1);
if (depthEffect)
return Math.max(Theme.effectAnimOffset * 0.8, 30);
return 0;
}
x: Theme.snap((parent.width - width) / 2 + (overlayWindow.shouldShowSpotlight ? 0 : collapsedMotionX), overlayWindow.dpr)
y: Theme.snap((parent.height - height) / 2 + (overlayWindow.shouldShowSpotlight ? 0 : collapsedMotionY), overlayWindow.dpr)
readonly property int baseWidth: { readonly property int baseWidth: {
switch (SettingsData.dankLauncherV2Size) { switch (SettingsData.dankLauncherV2Size) {
@@ -234,8 +244,8 @@ Scope {
readonly property bool animatingOut: niriOverviewScope.isClosing && overlayWindow.isSpotlightScreen readonly property bool animatingOut: niriOverviewScope.isClosing && overlayWindow.isSpotlightScreen
scale: overlayWindow.shouldShowSpotlight ? 1.0 : 0.96 scale: Theme.isDirectionalEffect ? 1 : (overlayWindow.shouldShowSpotlight ? 1.0 : Theme.effectScaleCollapsed)
opacity: overlayWindow.shouldShowSpotlight ? 1 : 0 opacity: Theme.isDirectionalEffect ? 1 : (overlayWindow.shouldShowSpotlight ? 1 : 0)
visible: overlayWindow.shouldShowSpotlight || animatingOut visible: overlayWindow.shouldShowSpotlight || animatingOut
enabled: overlayWindow.shouldShowSpotlight enabled: overlayWindow.shouldShowSpotlight
@@ -245,10 +255,11 @@ Scope {
Behavior on scale { Behavior on scale {
id: scaleAnimation id: scaleAnimation
enabled: !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.fast duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
onRunningChanged: { onRunningChanged: {
if (running || !spotlightContainer.animatingOut) if (running || !spotlightContainer.animatingOut)
return; return;
@@ -258,10 +269,27 @@ Scope {
} }
Behavior on opacity { Behavior on opacity {
enabled: !Theme.isDirectionalEffect
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.fast duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: spotlightContainer.visible ? Theme.expressiveCurves.expressiveFastSpatial : Theme.expressiveCurves.standardAccel easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
Behavior on x {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight)
easing.type: Easing.BezierSpline
easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
}
}
Behavior on y {
NumberAnimation {
duration: Theme.variantDuration(Theme.expressiveDurations.fast, overlayWindow.shouldShowSpotlight)
easing.type: Easing.BezierSpline
easing.bezierCurve: spotlightContainer.visible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
} }
} }

View File

@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Effects
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import Quickshell.Hyprland import Quickshell.Hyprland
@@ -17,59 +16,61 @@ Item {
readonly property var allWorkspaces: Hyprland.workspaces?.values || [] readonly property var allWorkspaces: Hyprland.workspaces?.values || []
readonly property var allWorkspaceIds: { readonly property var allWorkspaceIds: {
const workspaces = allWorkspaces const workspaces = allWorkspaces;
if (!workspaces || workspaces.length === 0) return [] if (!workspaces || workspaces.length === 0)
return [];
try { try {
const ids = workspaces.map(ws => ws?.id).filter(id => id !== null && id !== undefined) const ids = workspaces.map(ws => ws?.id).filter(id => id !== null && id !== undefined);
return ids.sort((a, b) => a - b) return ids.sort((a, b) => a - b);
} catch (e) { } catch (e) {
return [] return [];
} }
} }
readonly property var thisMonitorWorkspaceIds: { readonly property var thisMonitorWorkspaceIds: {
const workspaces = allWorkspaces const workspaces = allWorkspaces;
const mon = monitor const mon = monitor;
if (!workspaces || workspaces.length === 0 || !mon) return [] if (!workspaces || workspaces.length === 0 || !mon)
return [];
try { try {
const filtered = workspaces.filter(ws => ws?.monitor?.name === mon.name) const filtered = workspaces.filter(ws => ws?.monitor?.name === mon.name);
return filtered.map(ws => ws?.id).filter(id => id !== null && id !== undefined).sort((a, b) => a - b) return filtered.map(ws => ws?.id).filter(id => id !== null && id !== undefined).sort((a, b) => a - b);
} catch (e) { } catch (e) {
return [] return [];
} }
} }
readonly property var displayedWorkspaceIds: { readonly property var displayedWorkspaceIds: {
if (!allWorkspaceIds || allWorkspaceIds.length === 0) { if (!allWorkspaceIds || allWorkspaceIds.length === 0) {
const result = [] const result = [];
for (let i = 1; i <= workspacesShown; i++) { for (let i = 1; i <= workspacesShown; i++) {
result.push(i) result.push(i);
} }
return result return result;
} }
try { try {
const maxExisting = Math.max(...allWorkspaceIds) const maxExisting = Math.max(...allWorkspaceIds);
const totalNeeded = Math.max(workspacesShown, allWorkspaceIds.length) const totalNeeded = Math.max(workspacesShown, allWorkspaceIds.length);
const result = [] const result = [];
for (let i = 1; i <= maxExisting; i++) { for (let i = 1; i <= maxExisting; i++) {
result.push(i) result.push(i);
} }
let nextId = maxExisting + 1 let nextId = maxExisting + 1;
while (result.length < totalNeeded) { while (result.length < totalNeeded) {
result.push(nextId) result.push(nextId);
nextId++ nextId++;
} }
return result return result;
} catch (e) { } catch (e) {
const result = [] const result = [];
for (let i = 1; i <= workspacesShown; i++) { for (let i = 1; i <= workspacesShown; i++) {
result.push(i) result.push(i);
} }
return result return result;
} }
} }
@@ -81,24 +82,27 @@ Item {
readonly property int effectiveRows: Math.max(SettingsData.overviewRows, Math.ceil(displayWorkspaceCount / effectiveColumns)) readonly property int effectiveRows: Math.max(SettingsData.overviewRows, Math.ceil(displayWorkspaceCount / effectiveColumns))
function getWorkspaceMonitorName(workspaceId) { function getWorkspaceMonitorName(workspaceId) {
if (!allWorkspaces || !workspaceId) return "" if (!allWorkspaces || !workspaceId)
return "";
try { try {
const ws = allWorkspaces.find(w => w?.id === workspaceId) const ws = allWorkspaces.find(w => w?.id === workspaceId);
return ws?.monitor?.name ?? "" return ws?.monitor?.name ?? "";
} catch (e) { } catch (e) {
return "" return "";
} }
} }
function workspaceHasWindows(workspaceId) { function workspaceHasWindows(workspaceId) {
if (!workspaceId) return false if (!workspaceId)
return false;
try { try {
const workspace = allWorkspaces.find(ws => ws?.id === workspaceId) const workspace = allWorkspaces.find(ws => ws?.id === workspaceId);
if (!workspace) return false if (!workspace)
const toplevels = workspace?.toplevels?.values || [] return false;
return toplevels.length > 0 const toplevels = workspace?.toplevels?.values || [];
return toplevels.length > 0;
} catch (e) { } catch (e) {
return false return false;
} }
} }
@@ -124,16 +128,16 @@ Item {
implicitHeight: overviewBackground.implicitHeight + Theme.spacingL * 2 implicitHeight: overviewBackground.implicitHeight + Theme.spacingL * 2
Component.onCompleted: { Component.onCompleted: {
Hyprland.refreshToplevels() Hyprland.refreshToplevels();
Hyprland.refreshWorkspaces() Hyprland.refreshWorkspaces();
Hyprland.refreshMonitors() Hyprland.refreshMonitors();
} }
onOverviewOpenChanged: { onOverviewOpenChanged: {
if (overviewOpen) { if (overviewOpen) {
Hyprland.refreshToplevels() Hyprland.refreshToplevels();
Hyprland.refreshWorkspaces() Hyprland.refreshWorkspaces();
Hyprland.refreshMonitors() Hyprland.refreshMonitors();
} }
} }
@@ -148,15 +152,15 @@ Item {
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: Theme.surfaceContainer color: Theme.surfaceContainer
layer.enabled: true ElevationShadow {
layer.effect: MultiEffect { anchors.fill: parent
shadowEnabled: true z: -1
shadowBlur: 0.5 level: Theme.elevationLevel2
shadowHorizontalOffset: 0 fallbackOffset: 4
shadowVerticalOffset: 4 targetRadius: Theme.cornerRadius
shadowColor: Theme.shadowStrong targetColor: Theme.surfaceContainer
shadowOpacity: 1 shadowOpacity: Theme.elevationLevel2 && Theme.elevationLevel2.alpha !== undefined ? Theme.elevationLevel2.alpha : 0.25
blurMax: 32 shadowEnabled: Theme.elevationEnabled
} }
ColumnLayout { ColumnLayout {
@@ -217,8 +221,8 @@ Item {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onClicked: { onClicked: {
if (root.draggingTargetWorkspace === -1) { if (root.draggingTargetWorkspace === -1) {
root.overviewOpen = false root.overviewOpen = false;
Hyprland.dispatch(`workspace ${workspaceValue}`) Hyprland.dispatch(`workspace ${workspaceValue}`);
} }
} }
} }
@@ -226,13 +230,15 @@ Item {
DropArea { DropArea {
anchors.fill: parent anchors.fill: parent
onEntered: { onEntered: {
root.draggingTargetWorkspace = workspaceValue root.draggingTargetWorkspace = workspaceValue;
if (root.draggingFromWorkspace == root.draggingTargetWorkspace) return if (root.draggingFromWorkspace == root.draggingTargetWorkspace)
hoveredWhileDragging = true return;
hoveredWhileDragging = true;
} }
onExited: { onExited: {
hoveredWhileDragging = false hoveredWhileDragging = false;
if (root.draggingTargetWorkspace == workspaceValue) root.draggingTargetWorkspace = -1 if (root.draggingTargetWorkspace == workspaceValue)
root.draggingTargetWorkspace = -1;
} }
} }
} }
@@ -250,27 +256,28 @@ Item {
Repeater { Repeater {
model: ScriptModel { model: ScriptModel {
values: { values: {
const workspaces = root.allWorkspaces const workspaces = root.allWorkspaces;
const minId = root.minWorkspaceId const minId = root.minWorkspaceId;
const maxId = root.maxWorkspaceId const maxId = root.maxWorkspaceId;
if (!workspaces || workspaces.length === 0) return [] if (!workspaces || workspaces.length === 0)
return [];
try { try {
const result = [] const result = [];
for (const workspace of workspaces) { for (const workspace of workspaces) {
const wsId = workspace?.id ?? -1 const wsId = workspace?.id ?? -1;
if (wsId >= minId && wsId <= maxId) { if (wsId >= minId && wsId <= maxId) {
const toplevels = workspace?.toplevels?.values || [] const toplevels = workspace?.toplevels?.values || [];
for (const toplevel of toplevels) { for (const toplevel of toplevels) {
result.push(toplevel) result.push(toplevel);
} }
} }
} }
return result return result;
} catch (e) { } catch (e) {
console.error("OverviewWidget filter error:", e) console.error("OverviewWidget filter error:", e);
return [] return [];
} }
} }
} }
@@ -282,17 +289,19 @@ Item {
readonly property int windowWorkspaceId: modelData?.workspace?.id ?? -1 readonly property int windowWorkspaceId: modelData?.workspace?.id ?? -1
function getWorkspaceIndex() { function getWorkspaceIndex() {
if (!root.displayedWorkspaceIds || root.displayedWorkspaceIds.length === 0) return 0 if (!root.displayedWorkspaceIds || root.displayedWorkspaceIds.length === 0)
if (!windowWorkspaceId || windowWorkspaceId < 0) return 0 return 0;
if (!windowWorkspaceId || windowWorkspaceId < 0)
return 0;
try { try {
for (let i = 0; i < root.displayedWorkspaceIds.length; i++) { for (let i = 0; i < root.displayedWorkspaceIds.length; i++) {
if (root.displayedWorkspaceIds[i] === windowWorkspaceId) { if (root.displayedWorkspaceIds[i] === windowWorkspaceId) {
return i return i;
} }
} }
return 0 return 0;
} catch (e) { } catch (e) {
return 0 return 0;
} }
} }
@@ -325,48 +334,48 @@ Item {
acceptedButtons: Qt.LeftButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.MiddleButton
drag.target: parent drag.target: parent
onPressed: (mouse) => { onPressed: mouse => {
root.draggingFromWorkspace = windowData?.workspace.id root.draggingFromWorkspace = windowData?.workspace.id;
window.pressed = true window.pressed = true;
window.Drag.active = true window.Drag.active = true;
window.Drag.source = window window.Drag.source = window;
window.Drag.hotSpot.x = mouse.x window.Drag.hotSpot.x = mouse.x;
window.Drag.hotSpot.y = mouse.y window.Drag.hotSpot.y = mouse.y;
} }
onReleased: { onReleased: {
const targetWorkspace = root.draggingTargetWorkspace const targetWorkspace = root.draggingTargetWorkspace;
window.pressed = false window.pressed = false;
window.Drag.active = false window.Drag.active = false;
root.draggingFromWorkspace = -1 root.draggingFromWorkspace = -1;
root.draggingTargetWorkspace = -1 root.draggingTargetWorkspace = -1;
if (targetWorkspace !== -1 && targetWorkspace !== windowData?.workspace.id) { if (targetWorkspace !== -1 && targetWorkspace !== windowData?.workspace.id) {
Hyprland.dispatch(`movetoworkspacesilent ${targetWorkspace},address:${windowData?.address}`) Hyprland.dispatch(`movetoworkspacesilent ${targetWorkspace},address:${windowData?.address}`);
Qt.callLater(() => { Qt.callLater(() => {
Hyprland.refreshToplevels() Hyprland.refreshToplevels();
Hyprland.refreshWorkspaces() Hyprland.refreshWorkspaces();
Qt.callLater(() => { Qt.callLater(() => {
window.x = window.initX window.x = window.initX;
window.y = window.initY window.y = window.initY;
}) });
}) });
} else { } else {
window.x = window.initX window.x = window.initX;
window.y = window.initY window.y = window.initY;
} }
} }
onClicked: (event) => { onClicked: event => {
if (!windowData || !windowData.address) return if (!windowData || !windowData.address)
return;
if (event.button === Qt.LeftButton) { if (event.button === Qt.LeftButton) {
root.overviewOpen = false root.overviewOpen = false;
Hyprland.dispatch(`focuswindow address:${windowData.address}`) Hyprland.dispatch(`focuswindow address:${windowData.address}`);
event.accepted = true event.accepted = true;
} else if (event.button === Qt.MiddleButton) { } else if (event.button === Qt.MiddleButton) {
Hyprland.dispatch(`closewindow address:${windowData.address}`) Hyprland.dispatch(`closewindow address:${windowData.address}`);
event.accepted = true event.accepted = true;
} }
} }
} }

View File

@@ -62,30 +62,30 @@ Item {
Behavior on x { Behavior on x {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantModalEnterCurve
} }
} }
Behavior on y { Behavior on y {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantModalEnterCurve
} }
} }
Behavior on width { Behavior on width {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantModalEnterCurve
} }
} }
Behavior on height { Behavior on height {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantModalEnterCurve
} }
} }
@@ -124,16 +124,16 @@ Item {
Behavior on width { Behavior on width {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantModalEnterCurve
} }
} }
Behavior on height { Behavior on height {
NumberAnimation { NumberAnimation {
duration: Theme.expressiveDurations.expressiveDefaultSpatial duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedDecel easing.bezierCurve: Theme.variantModalEnterCurve
} }
} }
} }

View File

@@ -687,6 +687,12 @@ Singleton {
appCategories.forEach(cat => categories.add(cat)); appCategories.forEach(cat => categories.add(cat));
} }
// Include categories from core apps (e.g. DMS Settings)
for (const app of coreApps) {
const appCategories = getCategoriesForApp(app);
appCategories.forEach(cat => categories.add(cat));
}
const pluginCategories = getPluginCategories(); const pluginCategories = getPluginCategories();
pluginCategories.forEach(cat => categories.add(cat)); pluginCategories.forEach(cat => categories.add(cat));

View File

@@ -56,8 +56,8 @@ Singleton {
} }
readonly property bool isCharging: batteryAvailable && batteries.some(b => b.state === UPowerDeviceState.Charging) readonly property bool isCharging: batteryAvailable && batteries.some(b => b.state === UPowerDeviceState.Charging)
// Is the system plugged in (none of the batteries are discharging or empty) // Is the system plugged in (Is not running on battery)
readonly property bool isPluggedIn: batteryAvailable && batteries.every(b => b.state !== UPowerDeviceState.Discharging) readonly property bool isPluggedIn: !UPower.onBattery
readonly property bool isLowBattery: batteryAvailable && batteryLevel <= 20 readonly property bool isLowBattery: batteryAvailable && batteryLevel <= 20
onIsPluggedInChanged: { onIsPluggedInChanged: {

View File

@@ -479,6 +479,21 @@ Singleton {
}); });
} }
function testConnection(host, port, protocol, callback) {
if (!cupsAvailable)
return;
const params = {
"host": host,
"port": port,
"protocol": protocol
};
DMSService.sendRequest("cups.testConnection", params, response => {
if (callback)
callback(response);
});
}
function createPrinter(name, deviceURI, ppd, options) { function createPrinter(name, deviceURI, ppd, options) {
if (!cupsAvailable) if (!cupsAvailable)
return; return;

View File

@@ -61,12 +61,13 @@ Singleton {
signal appPickerRequested(var data) signal appPickerRequested(var data)
signal screensaverStateUpdate(var data) signal screensaverStateUpdate(var data)
signal clipboardStateUpdate(var data) signal clipboardStateUpdate(var data)
signal locationStateUpdate(var data)
property bool capsLockState: false property bool capsLockState: false
property bool screensaverInhibited: false property bool screensaverInhibited: false
property var screensaverInhibitors: [] property var screensaverInhibitors: []
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard"] property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location"]
Component.onCompleted: { Component.onCompleted: {
if (socketPath && socketPath.length > 0) { if (socketPath && socketPath.length > 0) {
@@ -284,7 +285,7 @@ Singleton {
function removeSubscription(service) { function removeSubscription(service) {
if (activeSubscriptions.includes("all")) { if (activeSubscriptions.includes("all")) {
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser"]; const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser", "location"];
const filtered = allServices.filter(s => s !== service); const filtered = allServices.filter(s => s !== service);
subscribe(filtered); subscribe(filtered);
} else { } else {
@@ -306,7 +307,7 @@ Singleton {
excludeServices = [excludeServices]; excludeServices = [excludeServices];
} }
const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus"]; const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus", "location"];
const filtered = allServices.filter(s => !excludeServices.includes(s)); const filtered = allServices.filter(s => !excludeServices.includes(s));
subscribe(filtered); subscribe(filtered);
} }
@@ -395,6 +396,8 @@ Singleton {
dbusSignalReceived(data.subscriptionId || "", data); dbusSignalReceived(data.subscriptionId || "", data);
} else if (service === "clipboard") { } else if (service === "clipboard") {
clipboardStateUpdate(data); clipboardStateUpdate(data);
} else if (service === "location") {
locationStateUpdate(data);
} }
} }

View File

@@ -0,0 +1,55 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
readonly property bool locationAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("location"))
readonly property bool valid: latitude !== 0 || longitude !== 0
property var latitude: 0.0
property var longitude: 0.0
signal locationChanged(var data)
onLocationAvailableChanged: {
if (locationAvailable && !valid)
getState();
}
Connections {
target: DMSService
function onLocationStateUpdate(data) {
if (!locationAvailable)
return;
handleStateUpdate(data);
}
}
function handleStateUpdate(data) {
const lat = data.latitude;
const lon = data.longitude;
if (lat === 0 && lon === 0)
return;
root.latitude = lat;
root.longitude = lon;
root.locationChanged(data);
}
function getState() {
if (!locationAvailable)
return;
DMSService.sendRequest("location.getState", null, response => {
if (response.result)
handleStateUpdate(response.result);
});
}
}

View File

@@ -19,6 +19,7 @@ Singleton {
readonly property string historyFile: Paths.strip(Paths.cache) + "/notification_history.json" readonly property string historyFile: Paths.strip(Paths.cache) + "/notification_history.json"
readonly property string imageCacheDir: Paths.strip(Paths.cache) + "/notification_images" readonly property string imageCacheDir: Paths.strip(Paths.cache) + "/notification_images"
property bool historyLoaded: false property bool historyLoaded: false
property int historyEntryCounter: 0
property list<NotifWrapper> notificationQueue: [] property list<NotifWrapper> notificationQueue: []
property list<NotifWrapper> visibleNotifications: [] property list<NotifWrapper> visibleNotifications: []
@@ -73,6 +74,12 @@ Singleton {
onTriggered: root.performSaveHistory() onTriggered: root.performSaveHistory()
} }
function _makeHistoryEntryId(sourceId, timestamp) {
historyEntryCounter += 1;
const safeSource = sourceId && sourceId !== "" ? sourceId : "notification";
return safeSource + "_" + (timestamp || Date.now()) + "_" + historyEntryCounter;
}
function getImageCachePath(wrapper) { function getImageCachePath(wrapper) {
const ts = wrapper.time ? wrapper.time.getTime() : Date.now(); const ts = wrapper.time ? wrapper.time.getTime() : Date.now();
const id = wrapper.notification?.id?.toString() || "0"; const id = wrapper.notification?.id?.toString() || "0";
@@ -80,12 +87,13 @@ Singleton {
} }
function updateHistoryImage(wrapperId, imagePath) { function updateHistoryImage(wrapperId, imagePath) {
const idx = historyList.findIndex(n => n.id === wrapperId); const idx = historyList.findIndex(n => n.sourceNotificationId === wrapperId || n.id === wrapperId);
if (idx < 0) if (idx < 0)
return; return;
const item = historyList[idx]; const item = historyList[idx];
const updated = { const updated = {
id: item.id, id: item.id,
sourceNotificationId: item.sourceNotificationId || item.id,
summary: item.summary, summary: item.summary,
body: item.body, body: item.body,
htmlBody: item.htmlBody, htmlBody: item.htmlBody,
@@ -113,8 +121,11 @@ Singleton {
} else if (imageUrl && !imageUrl.startsWith("image://qsimage/")) { } else if (imageUrl && !imageUrl.startsWith("image://qsimage/")) {
persistableImage = imageUrl; persistableImage = imageUrl;
} }
const sourceNotificationId = wrapper.notification?.id?.toString() || "";
const timestamp = wrapper.time.getTime();
const data = { const data = {
id: wrapper.notification?.id?.toString() || Date.now().toString(), id: _makeHistoryEntryId(sourceNotificationId, timestamp),
sourceNotificationId: sourceNotificationId,
summary: wrapper.summary || "", summary: wrapper.summary || "",
body: wrapper.body || "", body: wrapper.body || "",
htmlBody: wrapper.htmlBody || wrapper.body || "", htmlBody: wrapper.htmlBody || wrapper.body || "",
@@ -122,7 +133,7 @@ Singleton {
appIcon: wrapper.appIcon || "", appIcon: wrapper.appIcon || "",
image: persistableImage, image: persistableImage,
urgency: urg, urgency: urg,
timestamp: wrapper.time.getTime(), timestamp: timestamp,
desktopEntry: wrapper.desktopEntry || "" desktopEntry: wrapper.desktopEntry || ""
}; };
let newList = [data, ...historyList]; let newList = [data, ...historyList];
@@ -152,6 +163,8 @@ Singleton {
const now = Date.now(); const now = Date.now();
const maxAgeMs = maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : 0; const maxAgeMs = maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : 0;
const loaded = []; const loaded = [];
const seenIds = {};
let needsRewrite = false;
for (const item of historyAdapter.notifications || []) { for (const item of historyAdapter.notifications || []) {
if (maxAgeMs > 0 && (now - item.timestamp) > maxAgeMs) if (maxAgeMs > 0 && (now - item.timestamp) > maxAgeMs)
@@ -162,8 +175,18 @@ Singleton {
if (htmlBody) { if (htmlBody) {
htmlBody = htmlBody.replace(/<img\b[^>]*>/gi, ""); htmlBody = htmlBody.replace(/<img\b[^>]*>/gi, "");
} }
const sourceNotificationId = (item.sourceNotificationId || item.id || "").toString();
let historyId = (item.id || "").toString();
if (!historyId || seenIds[historyId]) {
historyId = _makeHistoryEntryId(sourceNotificationId, item.timestamp || now);
needsRewrite = true;
}
if (!item.sourceNotificationId)
needsRewrite = true;
seenIds[historyId] = true;
loaded.push({ loaded.push({
id: item.id || "", id: historyId,
sourceNotificationId: sourceNotificationId,
summary: item.summary || "", summary: item.summary || "",
body: body, body: body,
htmlBody: htmlBody, htmlBody: htmlBody,
@@ -177,7 +200,7 @@ Singleton {
} }
historyList = loaded; historyList = loaded;
historyLoaded = true; historyLoaded = true;
if (maxAgeMs > 0 && loaded.length !== (historyAdapter.notifications || []).length) if ((maxAgeMs > 0 && loaded.length !== (historyAdapter.notifications || []).length) || needsRewrite)
saveHistory(); saveHistory();
} catch (e) { } catch (e) {
console.warn("NotificationService: load history failed:", e); console.warn("NotificationService: load history failed:", e);

View File

@@ -44,24 +44,26 @@ Singleton {
} }
} }
readonly property var archBasedPMSettings: { readonly property var archBasedPMSettings: function(requiresSudo) {
"listUpdatesSettings": { return {
"params": ["-Qu"], "listUpdatesSettings": {
"correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates "params": ["-Qu"],
}, "correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
"upgradeSettings": { },
"params": ["-Syu"], "upgradeSettings": {
"requiresSudo": false "params": ["-Syu"],
}, "requiresSudo": requiresSudo
"parserSettings": { },
"lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/, "parserSettings": {
"entryProducer": function (match) { "lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
return { "entryProducer": function (match) {
"name": match[1], return {
"currentVersion": match[2], "name": match[1],
"newVersion": match[3], "currentVersion": match[2],
"description": `${match[1]} ${match[2]} ${match[3]}` "newVersion": match[3],
}; "description": `${match[1]} ${match[2]} ${match[3]}`
};
}
} }
} }
} }
@@ -92,8 +94,9 @@ Singleton {
"checkupdates": archBasedUCSettings "checkupdates": archBasedUCSettings
} }
readonly property var packageManagerParams: { readonly property var packageManagerParams: {
"yay": archBasedPMSettings, "yay": archBasedPMSettings(false),
"paru": archBasedPMSettings, "paru": archBasedPMSettings(false),
"pacman": archBasedPMSettings(true),
"dnf": fedoraBasedPMSettings "dnf": fedoraBasedPMSettings
} }
readonly property list<string> supportedDistributions: ["arch", "artix", "cachyos", "manjaro", "endeavouros", "fedora"] readonly property list<string> supportedDistributions: ["arch", "artix", "cachyos", "manjaro", "endeavouros", "fedora"]
@@ -182,7 +185,7 @@ Singleton {
Process { Process {
id: pkgManagerDetection id: pkgManagerDetection
command: ["sh", "-c", "which paru || which yay || which dnf"] command: ["sh", "-c", "which paru || which yay || which pacman || which dnf"]
onExited: exitCode => { onExited: exitCode => {
if (exitCode === 0) { if (exitCode === 0) {

View File

@@ -480,7 +480,7 @@ Singleton {
const cityName = SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation; const cityName = SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation;
if (useAuto) { if (useAuto) {
getLocationFromIP(); getLocationFromService();
return; return;
} }
@@ -511,8 +511,10 @@ Singleton {
cityGeocodeFetcher.running = true; cityGeocodeFetcher.running = true;
} }
function getLocationFromIP() { function getLocationFromService() {
ipLocationFetcher.running = true; if (!LocationService.valid)
return;
getLocationFromCoords(LocationService.latitude, LocationService.longitude);
} }
function fetchWeather() { function fetchWeather() {
@@ -583,53 +585,6 @@ Singleton {
} }
} }
Process {
id: ipLocationFetcher
command: lowPriorityCmd.concat(curlBaseCmd).concat(["http://ip-api.com/json/"])
running: false
stdout: StdioCollector {
onStreamFinished: {
const raw = text.trim();
if (!raw || raw[0] !== "{") {
root.handleWeatherFailure();
return;
}
try {
const data = JSON.parse(raw);
if (data.status === "fail") {
throw new Error("IP location lookup failed");
}
const lat = parseFloat(data.lat);
const lon = parseFloat(data.lon);
const city = data.city;
if (!city || isNaN(lat) || isNaN(lon)) {
throw new Error("Missing or invalid location data");
}
root.location = {
city: city,
latitude: lat,
longitude: lon
};
fetchWeather();
} catch (e) {
root.handleWeatherFailure();
}
}
}
onExited: exitCode => {
if (exitCode !== 0) {
root.handleWeatherFailure();
}
}
}
Process { Process {
id: reverseGeocodeFetcher id: reverseGeocodeFetcher
running: false running: false
@@ -872,6 +827,18 @@ Singleton {
} }
} }
Connections {
target: LocationService
function onLocationChanged(data) {
if (!SettingsData.useAutoLocation)
return;
if (data.latitude === 0 && data.longitude === 0)
return;
root.getLocationFromCoords(data.latitude, data.longitude);
}
}
Component.onCompleted: { Component.onCompleted: {
SettingsData.weatherCoordinatesChanged.connect(() => { SettingsData.weatherCoordinatesChanged.connect(() => {
root.location = null; root.location = null;

View File

@@ -49,7 +49,7 @@ Item {
readonly property string iconPath: { readonly property string iconPath: {
if (hasSpecialPrefix || !iconValue) if (hasSpecialPrefix || !iconValue)
return ""; return "";
return Quickshell.iconPath(iconValue, true) || DesktopService.resolveIconPath(iconValue); return Paths.resolveIconPath(iconValue);
} }
visible: iconValue !== undefined && iconValue !== "" visible: iconValue !== undefined && iconValue !== ""
@@ -98,7 +98,7 @@ Item {
sourceComponent: IconImage { sourceComponent: IconImage {
anchors.fill: parent anchors.fill: parent
source: root.iconPath source: root.iconPath
backer.sourceSize: Qt.size(root.iconSize, root.iconSize) backer.sourceSize: Qt.size(root.iconSize * 2, root.iconSize * 2)
mipmap: true mipmap: true
asynchronous: true asynchronous: true
visible: status === Image.Ready visible: status === Image.Ready

View File

@@ -81,6 +81,8 @@ Rectangle {
mipmap: true mipmap: true
cache: true cache: true
visible: false visible: false
sourceSize.width: Math.max(width * 2, 128)
sourceSize.height: Math.max(height * 2, 128)
source: !root.shouldProbe ? root.imageSource : "" source: !root.shouldProbe ? root.imageSource : ""
} }

View File

@@ -1,7 +1,6 @@
import "../Common/fzf.js" as Fzf import "../Common/fzf.js" as Fzf
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Effects
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
@@ -254,6 +253,8 @@ Item {
} }
contentItem: Rectangle { contentItem: Rectangle {
id: contentSurface
LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true LayoutMirroring.childrenInherit: true
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 1) color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 1)
@@ -261,12 +262,17 @@ Item {
border.width: 2 border.width: 2
radius: Theme.cornerRadius radius: Theme.cornerRadius
layer.enabled: true ElevationShadow {
layer.effect: MultiEffect { id: shadowLayer
shadowEnabled: true anchors.fill: parent
shadowBlur: 0.4 z: -1
shadowColor: Theme.shadowStrong level: Theme.elevationLevel2
shadowVerticalOffset: 4 fallbackOffset: 4
targetRadius: contentSurface.radius
targetColor: contentSurface.color
borderColor: contentSurface.border.color
borderWidth: contentSurface.border.width
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled
} }
Column { Column {

View File

@@ -1,6 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Effects
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
@@ -132,16 +131,20 @@ Rectangle {
} }
contentItem: Rectangle { contentItem: Rectangle {
id: contentSurface
color: Theme.surface color: Theme.surface
radius: Theme.cornerRadius radius: Theme.cornerRadius
layer.enabled: true ElevationShadow {
layer.effect: MultiEffect { id: shadowLayer
shadowEnabled: true anchors.fill: parent
shadowColor: Theme.shadowStrong z: -1
shadowBlur: 0.8 level: Theme.elevationLevel2
shadowHorizontalOffset: 0 fallbackOffset: 4
shadowVerticalOffset: 4 targetRadius: contentSurface.radius
targetColor: contentSurface.color
shadowOpacity: Theme.elevationLevel2 && Theme.elevationLevel2.alpha !== undefined ? Theme.elevationLevel2.alpha : 0.25
shadowEnabled: Theme.elevationEnabled
} }
Rectangle { Rectangle {

View File

@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
@@ -257,11 +256,7 @@ PanelWindow {
scale: shouldBeVisible ? 1 : 0.9 scale: shouldBeVisible ? 1 : 0.9
property bool childHovered: false property bool childHovered: false
property real shadowBlurPx: 10
property real shadowSpreadPx: 0
property real shadowBaseAlpha: 0.60
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency readonly property real popupSurfaceAlpha: SettingsData.popupTransparency
readonly property real effectiveShadowAlpha: shouldBeVisible ? Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha)) : 0
Rectangle { Rectangle {
id: background id: background
@@ -273,38 +268,20 @@ PanelWindow {
z: -1 z: -1
} }
Item { ElevationShadow {
id: bgShadowLayer id: bgShadowLayer
anchors.fill: parent anchors.fill: parent
visible: osdContainer.popupSurfaceAlpha >= 0.95 visible: osdContainer.popupSurfaceAlpha >= 0.95
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" z: -1
layer.smooth: false level: Theme.elevationLevel3
fallbackOffset: 6
targetRadius: Theme.cornerRadius
targetColor: Theme.surfaceContainer
borderColor: Theme.outlineMedium
borderWidth: 1
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
layer.textureSize: Qt.size(Math.round(width * root.dpr), Math.round(height * root.dpr)) layer.textureSize: Qt.size(Math.round(width * root.dpr), Math.round(height * root.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically layer.textureMirroring: ShaderEffectSource.MirrorVertically
readonly property int blurMax: 64
layer.effect: MultiEffect {
id: shadowFx
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, osdContainer.shadowBlurPx / bgShadowLayer.blurMax))
shadowScale: 1 + (2 * osdContainer.shadowSpreadPx) / Math.max(1, Math.min(bgShadowLayer.width, bgShadowLayer.height))
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, osdContainer.effectiveShadowAlpha);
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Theme.outlineMedium
border.width: 1
}
} }
MouseArea { MouseArea {

View File

@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
@@ -21,16 +20,17 @@ Item {
property string triggerSection: "" property string triggerSection: ""
property string positioning: "center" property string positioning: "center"
property int animationDuration: Theme.popoutAnimationDuration property int animationDuration: Theme.popoutAnimationDuration
property real animationScaleCollapsed: 0.96 property real animationScaleCollapsed: Theme.effectScaleCollapsed
property real animationOffset: Theme.spacingL property real animationOffset: Theme.effectAnimOffset
property list<real> animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial property list<real> animationEnterCurve: Theme.variantPopoutEnterCurve
property list<real> animationExitCurve: Theme.expressiveCurves.emphasized property list<real> animationExitCurve: Theme.variantPopoutExitCurve
property bool suspendShadowWhileResizing: false property bool suspendShadowWhileResizing: false
property bool shouldBeVisible: false property bool shouldBeVisible: false
property var customKeyboardFocus: null property var customKeyboardFocus: null
property bool backgroundInteractive: true property bool backgroundInteractive: true
property bool contentHandlesKeys: false property bool contentHandlesKeys: false
property bool fullHeightSurface: false property bool fullHeightSurface: false
property bool _primeContent: false
property bool _resizeActive: false property bool _resizeActive: false
property real _surfaceMarginLeft: 0 property real _surfaceMarginLeft: 0
property real _surfaceW: 0 property real _surfaceW: 0
@@ -74,9 +74,42 @@ Item {
signal backgroundClicked signal backgroundClicked
property var _lastOpenedScreen: null property var _lastOpenedScreen: null
property bool isClosing: false
property int effectiveBarPosition: 0 property int effectiveBarPosition: 0
property real effectiveBarBottomGap: 0 property real effectiveBarBottomGap: 0
readonly property string autoBarShadowDirection: {
const section = triggerSection || "center";
switch (effectiveBarPosition) {
case SettingsData.Position.Top:
if (section === "left")
return "topLeft";
if (section === "right")
return "topRight";
return "top";
case SettingsData.Position.Bottom:
if (section === "left")
return "bottomLeft";
if (section === "right")
return "bottomRight";
return "bottom";
case SettingsData.Position.Left:
if (section === "left")
return "topLeft";
if (section === "right")
return "bottomLeft";
return "left";
case SettingsData.Position.Right:
if (section === "left")
return "topRight";
if (section === "right")
return "bottomRight";
return "right";
default:
return "top";
}
}
readonly property string effectiveShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection
// Snapshot mask geometry to prevent background damage on bar updates // Snapshot mask geometry to prevent background damage on bar updates
property real _frozenMaskX: 0 property real _frozenMaskX: 0
@@ -89,6 +122,14 @@ Item {
effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0; effectiveBarBottomGap = bottomGap !== undefined ? bottomGap : 0;
} }
function primeContent() {
_primeContent = true;
}
function clearPrimedContent() {
_primeContent = false;
}
function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) { function setTriggerPosition(x, y, width, section, targetScreen, barPosition, barThickness, barSpacing, barConfig) {
triggerX = x; triggerX = x;
triggerY = y; triggerY = y;
@@ -116,10 +157,14 @@ Item {
} }
} }
property bool animationsEnabled: true
function open() { function open() {
if (!screen) if (!screen)
return; return;
closeTimer.stop(); closeTimer.stop();
isClosing = false;
animationsEnabled = false;
// Snapshot mask geometry // Snapshot mask geometry
_frozenMaskX = maskX; _frozenMaskX = maskX;
@@ -134,12 +179,22 @@ Item {
} }
_lastOpenedScreen = screen; _lastOpenedScreen = screen;
shouldBeVisible = true; if (contentContainer) {
contentContainer.animX = Theme.snap(contentContainer.offsetX, root.dpr);
contentContainer.animY = Theme.snap(contentContainer.offsetY, root.dpr);
contentContainer.scaleValue = root.animationScaleCollapsed;
}
if (useBackgroundWindow) { if (useBackgroundWindow) {
_surfaceMarginLeft = alignedX - shadowBuffer; _surfaceMarginLeft = alignedX - shadowBuffer;
_surfaceW = alignedWidth + shadowBuffer * 2; _surfaceW = alignedWidth + shadowBuffer * 2;
backgroundWindow.visible = true;
} }
contentWindow.visible = true;
Qt.callLater(() => { Qt.callLater(() => {
animationsEnabled = true;
shouldBeVisible = true;
if (shouldBeVisible && screen) { if (shouldBeVisible && screen) {
if (useBackgroundWindow) if (useBackgroundWindow)
backgroundWindow.visible = true; backgroundWindow.visible = true;
@@ -151,7 +206,9 @@ Item {
} }
function close() { function close() {
isClosing = true;
shouldBeVisible = false; shouldBeVisible = false;
_primeContent = false;
PopoutManager.popoutChanged(); PopoutManager.popoutChanged();
closeTimer.restart(); closeTimer.restart();
} }
@@ -181,9 +238,10 @@ Item {
Timer { Timer {
id: closeTimer id: closeTimer
interval: animationDuration interval: Theme.variantCloseInterval(animationDuration)
onTriggered: { onTriggered: {
if (!shouldBeVisible) { if (!shouldBeVisible) {
isClosing = false;
contentWindow.visible = false; contentWindow.visible = false;
if (useBackgroundWindow) if (useBackgroundWindow)
backgroundWindow.visible = false; backgroundWindow.visible = false;
@@ -197,7 +255,20 @@ Item {
readonly property real screenHeight: screen ? screen.height : 0 readonly property real screenHeight: screen ? screen.height : 0
readonly property real dpr: screen ? screen.devicePixelRatio : 1 readonly property real dpr: screen ? screen.devicePixelRatio : 1
readonly property real shadowBuffer: 5 readonly property var shadowLevel: Theme.elevationLevel3
readonly property real shadowFallbackOffset: 6
readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.popoutElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, effectiveShadowDirection, shadowFallbackOffset, 8, 16) : 0
readonly property real shadowMotionPadding: {
if (Theme.isDirectionalEffect) {
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode !== 0)
return 16; // Slide Behind and Roll Out do not add animationOffset, enabling strict Wayland clipping.
return Math.max(0, animationOffset) + 16;
}
if (Theme.isDepthEffect)
return Math.max(0, animationOffset) + 8;
return Math.max(0, animationOffset);
}
readonly property real shadowBuffer: Theme.snap(shadowRenderPadding + shadowMotionPadding, dpr)
readonly property real alignedWidth: Theme.px(popupWidth, dpr) readonly property real alignedWidth: Theme.px(popupWidth, dpr)
readonly property real alignedHeight: Theme.px(popupHeight, dpr) readonly property real alignedHeight: Theme.px(popupHeight, dpr)
@@ -257,29 +328,30 @@ Item {
} }
})(), dpr) })(), dpr)
readonly property real triggeringBarLeftExclusion: (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? Math.max(0, barX + barWidth) : 0
readonly property real triggeringBarTopExclusion: (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? Math.max(0, barY + barHeight) : 0
readonly property real triggeringBarRightExclusion: (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? Math.max(0, screenWidth - barX) : 0
readonly property real triggeringBarBottomExclusion: (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? Math.max(0, screenHeight - barY) : 0
readonly property real maskX: { readonly property real maskX: {
const triggeringBarX = (effectiveBarPosition === SettingsData.Position.Left && barWidth > 0) ? barWidth : 0;
const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0; const adjacentLeftBar = adjacentBarInfo?.leftBar ?? 0;
return Math.max(triggeringBarX, adjacentLeftBar); return Math.max(triggeringBarLeftExclusion, adjacentLeftBar);
} }
readonly property real maskY: { readonly property real maskY: {
const triggeringBarY = (effectiveBarPosition === SettingsData.Position.Top && barHeight > 0) ? barHeight : 0;
const adjacentTopBar = adjacentBarInfo?.topBar ?? 0; const adjacentTopBar = adjacentBarInfo?.topBar ?? 0;
return Math.max(triggeringBarY, adjacentTopBar); return Math.max(triggeringBarTopExclusion, adjacentTopBar);
} }
readonly property real maskWidth: { readonly property real maskWidth: {
const triggeringBarRight = (effectiveBarPosition === SettingsData.Position.Right && barWidth > 0) ? barWidth : 0;
const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0; const adjacentRightBar = adjacentBarInfo?.rightBar ?? 0;
const rightExclusion = Math.max(triggeringBarRight, adjacentRightBar); const rightExclusion = Math.max(triggeringBarRightExclusion, adjacentRightBar);
return Math.max(100, screenWidth - maskX - rightExclusion); return Math.max(100, screenWidth - maskX - rightExclusion);
} }
readonly property real maskHeight: { readonly property real maskHeight: {
const triggeringBarBottom = (effectiveBarPosition === SettingsData.Position.Bottom && barHeight > 0) ? barHeight : 0;
const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0; const adjacentBottomBar = adjacentBarInfo?.bottomBar ?? 0;
const bottomExclusion = Math.max(triggeringBarBottom, adjacentBottomBar); const bottomExclusion = Math.max(triggeringBarBottomExclusion, adjacentBottomBar);
return Math.max(100, screenHeight - maskY - bottomExclusion); return Math.max(100, screenHeight - maskY - bottomExclusion);
} }
@@ -307,6 +379,10 @@ Item {
mask: Region { mask: Region {
item: maskRect item: maskRect
Region {
item: contentExclusionRect
intersection: Intersection.Subtract
}
} }
Rectangle { Rectangle {
@@ -315,26 +391,70 @@ Item {
color: "transparent" color: "transparent"
x: root._frozenMaskX x: root._frozenMaskX
y: root._frozenMaskY y: root._frozenMaskY
width: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskWidth : 0 width: (backgroundWindow.visible && backgroundInteractive) ? root._frozenMaskWidth : 0
height: (shouldBeVisible && backgroundInteractive) ? root._frozenMaskHeight : 0 height: (backgroundWindow.visible && backgroundInteractive) ? root._frozenMaskHeight : 0
} }
MouseArea { Item {
id: contentExclusionRect
visible: false
x: root.alignedX
y: root.alignedY
width: root.alignedWidth
height: root.alignedHeight
}
Item {
id: outsideClickCatcher
x: root._frozenMaskX x: root._frozenMaskX
y: root._frozenMaskY y: root._frozenMaskY
width: root._frozenMaskWidth width: root._frozenMaskWidth
height: root._frozenMaskHeight height: root._frozenMaskHeight
hoverEnabled: false enabled: root.shouldBeVisible && root.backgroundInteractive
enabled: shouldBeVisible && backgroundInteractive
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
const clickX = mouse.x + root._frozenMaskX;
const clickY = mouse.y + root._frozenMaskY;
const outsideContent = clickX < root.alignedX || clickX > root.alignedX + root.alignedWidth || clickY < root.alignedY || clickY > root.alignedY + root.alignedHeight;
if (!outsideContent) readonly property real contentLeft: Math.max(0, root.alignedX - x)
return; readonly property real contentTop: Math.max(0, root.alignedY - y)
backgroundClicked(); readonly property real contentRight: Math.min(width, contentLeft + root.alignedWidth)
readonly property real contentBottom: Math.min(height, contentTop + root.alignedHeight)
MouseArea {
x: 0
y: 0
width: outsideClickCatcher.width
height: Math.max(0, outsideClickCatcher.contentTop)
enabled: parent.enabled
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: root.backgroundClicked()
}
MouseArea {
x: 0
y: outsideClickCatcher.contentBottom
width: outsideClickCatcher.width
height: Math.max(0, outsideClickCatcher.height - outsideClickCatcher.contentBottom)
enabled: parent.enabled
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: root.backgroundClicked()
}
MouseArea {
x: 0
y: outsideClickCatcher.contentTop
width: Math.max(0, outsideClickCatcher.contentLeft)
height: Math.max(0, outsideClickCatcher.contentBottom - outsideClickCatcher.contentTop)
enabled: parent.enabled
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: root.backgroundClicked()
}
MouseArea {
x: outsideClickCatcher.contentRight
y: outsideClickCatcher.contentTop
width: Math.max(0, outsideClickCatcher.width - outsideClickCatcher.contentRight)
height: Math.max(0, outsideClickCatcher.contentBottom - outsideClickCatcher.contentTop)
enabled: parent.enabled
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: root.backgroundClicked()
} }
} }
@@ -379,7 +499,6 @@ Item {
} }
readonly property bool _fullHeight: useBackgroundWindow && root.fullHeightSurface readonly property bool _fullHeight: useBackgroundWindow && root.fullHeightSurface
anchors { anchors {
left: true left: true
top: true top: true
@@ -395,7 +514,7 @@ Item {
implicitWidth: useBackgroundWindow ? root._surfaceW : 0 implicitWidth: useBackgroundWindow ? root._surfaceW : 0
implicitHeight: (useBackgroundWindow && !_fullHeight) ? (root.alignedHeight + shadowBuffer * 2) : 0 implicitHeight: (useBackgroundWindow && !_fullHeight) ? (root.alignedHeight + shadowBuffer * 2) : 0
mask: (useBackgroundWindow && _fullHeight) ? contentInputMask : null mask: useBackgroundWindow ? contentInputMask : null
Region { Region {
id: contentInputMask id: contentInputMask
@@ -405,10 +524,10 @@ Item {
Item { Item {
id: contentMaskRect id: contentMaskRect
visible: false visible: false
x: contentContainer.x - root.shadowBuffer x: contentContainer.x
y: contentContainer.y - root.shadowBuffer y: contentContainer.y
width: root.alignedWidth + root.shadowBuffer * 2 width: root.alignedWidth
height: root.alignedHeight + root.shadowBuffer * 2 height: root.alignedHeight
} }
MouseArea { MouseArea {
@@ -437,12 +556,70 @@ Item {
readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom readonly property bool barBottom: effectiveBarPosition === SettingsData.Position.Bottom
readonly property bool barLeft: effectiveBarPosition === SettingsData.Position.Left readonly property bool barLeft: effectiveBarPosition === SettingsData.Position.Left
readonly property bool barRight: effectiveBarPosition === SettingsData.Position.Right readonly property bool barRight: effectiveBarPosition === SettingsData.Position.Right
readonly property real offsetX: barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0) readonly property bool directionalEffect: Theme.isDirectionalEffect
readonly property real offsetY: barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0) readonly property bool depthEffect: Theme.isDepthEffect
readonly property real directionalTravelX: Math.max(root.animationOffset, root.alignedWidth + Theme.spacingL)
readonly property real directionalTravelY: Math.max(root.animationOffset, root.alignedHeight + Theme.spacingL)
readonly property real depthTravel: Math.max(root.animationOffset * 0.7, 28)
readonly property real sectionTilt: (triggerSection === "left" ? -1 : (triggerSection === "right" ? 1 : 0))
readonly property real offsetX: {
if (directionalEffect) {
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2)
return 0;
if (barLeft)
return -directionalTravelX;
if (barRight)
return directionalTravelX;
if (barTop || barBottom)
return 0;
return sectionTilt * directionalTravelX * 0.2;
}
if (depthEffect) {
if (barLeft)
return -depthTravel;
if (barRight)
return depthTravel;
if (barTop || barBottom)
return 0;
return sectionTilt * depthTravel * 0.2;
}
return barLeft ? root.animationOffset : (barRight ? -root.animationOffset : 0);
}
readonly property real offsetY: {
if (directionalEffect) {
if (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2)
return 0;
if (barBottom)
return directionalTravelY;
if (barTop)
return -directionalTravelY;
if (barLeft || barRight)
return 0;
return directionalTravelY;
}
if (depthEffect) {
if (barBottom)
return depthTravel;
if (barTop)
return -depthTravel;
if (barLeft || barRight)
return 0;
return depthTravel;
}
return barBottom ? -root.animationOffset : (barTop ? root.animationOffset : 0);
}
property real animX: 0 property real animX: 0
property real animY: 0 property real animY: 0
property real scaleValue: root.animationScaleCollapsed
readonly property real computedScaleCollapsed: (typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect) ? 0.0 : root.animationScaleCollapsed
property real scaleValue: computedScaleCollapsed
Component.onCompleted: {
animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr);
animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr);
scaleValue = root.shouldBeVisible ? 1.0 : computedScaleCollapsed;
}
onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr) onOffsetXChanged: animX = Theme.snap(root.shouldBeVisible ? 0 : offsetX, root.dpr)
onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr) onOffsetYChanged: animY = Theme.snap(root.shouldBeVisible ? 0 : offsetY, root.dpr)
@@ -452,111 +629,131 @@ Item {
function onShouldBeVisibleChanged() { function onShouldBeVisibleChanged() {
contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr); contentContainer.animX = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetX, root.dpr);
contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr); contentContainer.animY = Theme.snap(root.shouldBeVisible ? 0 : contentContainer.offsetY, root.dpr);
contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : root.animationScaleCollapsed; contentContainer.scaleValue = root.shouldBeVisible ? 1.0 : contentContainer.computedScaleCollapsed;
} }
} }
Behavior on animX { Behavior on animX {
enabled: root.animationsEnabled
NumberAnimation { NumberAnimation {
duration: root.animationDuration duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
Behavior on animY { Behavior on animY {
enabled: root.animationsEnabled
NumberAnimation { NumberAnimation {
duration: root.animationDuration duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
Behavior on scaleValue { Behavior on scaleValue {
enabled: root.animationsEnabled
NumberAnimation { NumberAnimation {
duration: root.animationDuration duration: Theme.variantDuration(root.animationDuration, root.shouldBeVisible)
easing.type: Easing.BezierSpline easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
} }
} }
Rectangle {
id: shadowSource
anchors.centerIn: parent
width: parent.width
height: parent.height
radius: Theme.cornerRadius
color: "black"
visible: false
opacity: contentWrapper.opacity
scale: contentWrapper.scale
x: contentWrapper.x
y: contentWrapper.y
property real shadowBlurPx: 10
property real shadowSpreadPx: 0
property real shadowBaseAlpha: 0.60
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha))
readonly property int blurMax: 64
layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive)
layer.smooth: false
layer.effect: MultiEffect {
id: shadowFx
autoPaddingEnabled: true
shadowEnabled: true
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, shadowSource.shadowBlurPx / shadowSource.blurMax))
shadowScale: 1 + (2 * shadowSource.shadowSpreadPx) / Math.max(1, Math.min(shadowSource.width, shadowSource.height))
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, shadowSource.effectiveShadowAlpha);
}
}
}
Item { Item {
id: contentWrapper id: directionalClipMask
anchors.centerIn: parent
width: parent.width
height: parent.height
opacity: shouldBeVisible ? 1 : 0
visible: opacity > 0
scale: contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
layer.enabled: contentWrapper.opacity < 1 readonly property bool shouldClip: typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode > 0 && Theme.isDirectionalEffect
layer.smooth: false readonly property real clipOversize: 1000
layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0)
Behavior on opacity { clip: shouldClip
NumberAnimation {
duration: animationDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Rectangle { // Bound the clipping strictly to the bar side, allowing massive overflow on the other 3 sides for shadows
anchors.fill: parent x: shouldClip ? (contentContainer.barRight ? -clipOversize : (contentContainer.barLeft ? 0 : -clipOversize)) : 0
radius: Theme.cornerRadius y: shouldClip ? (contentContainer.barBottom ? -clipOversize : (contentContainer.barTop ? 0 : -clipOversize)) : 0
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
border.color: Theme.outlineMedium
border.width: 1
}
Loader { width: shouldClip ? parent.width + clipOversize + (contentContainer.barLeft || contentContainer.barRight ? 0 : clipOversize) : parent.width
id: contentLoader height: shouldClip ? parent.height + clipOversize + (contentContainer.barTop || contentContainer.barBottom ? 0 : clipOversize) : parent.height
anchors.fill: parent
active: shouldBeVisible || contentWindow.visible Item {
asynchronous: false id: aligner
} readonly property real baseWidth: contentContainer.width
} readonly property real baseHeight: contentContainer.height
} readonly property bool isRollOut: typeof SettingsData !== "undefined" && SettingsData.directionalAnimationMode === 2 && Theme.isDirectionalEffect
x: (directionalClipMask.x !== 0 ? -directionalClipMask.x : 0) + (isRollOut && contentContainer.barRight ? baseWidth * (1 - contentContainer.scaleValue) : 0)
y: (directionalClipMask.y !== 0 ? -directionalClipMask.y : 0) + (isRollOut && contentContainer.barBottom ? baseHeight * (1 - contentContainer.scaleValue) : 0)
width: isRollOut && (contentContainer.barLeft || contentContainer.barRight) ? Math.max(0, baseWidth * contentContainer.scaleValue) : baseWidth
height: isRollOut && (contentContainer.barTop || contentContainer.barBottom) ? Math.max(0, baseHeight * contentContainer.scaleValue) : baseHeight
clip: isRollOut
Item {
id: unrollCounteract
x: aligner.isRollOut && contentContainer.barRight ? -(aligner.baseWidth * (1 - contentContainer.scaleValue)) : 0
y: aligner.isRollOut && contentContainer.barBottom ? -(aligner.baseHeight * (1 - contentContainer.scaleValue)) : 0
width: aligner.baseWidth
height: aligner.baseHeight
ElevationShadow {
id: shadowSource
width: parent.width
height: parent.height
opacity: contentWrapper.opacity
scale: contentWrapper.scale
x: contentWrapper.x
y: contentWrapper.y
level: root.shadowLevel
direction: root.effectiveShadowDirection
fallbackOffset: root.shadowFallbackOffset
targetRadius: Theme.cornerRadius
targetColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive)
}
Item {
id: contentWrapper
width: parent.width
height: parent.height
opacity: Theme.isDirectionalEffect ? 1 : (shouldBeVisible ? 1 : 0)
visible: opacity > 0
scale: aligner.isRollOut ? 1.0 : contentContainer.scaleValue
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - scale) * 0.5, root.dpr)
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - scale) * 0.5, root.dpr)
layer.enabled: contentWrapper.opacity < 1
layer.smooth: false
layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0)
Behavior on opacity {
enabled: !Theme.isDirectionalEffect
NumberAnimation {
duration: Math.round(Theme.variantDuration(animationDuration, shouldBeVisible) * Theme.variantOpacityDurationScale)
easing.type: Easing.BezierSpline
easing.bezierCurve: root.shouldBeVisible ? root.animationEnterCurve : root.animationExitCurve
}
}
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
border.color: Theme.outlineMedium
border.width: 0
}
Loader {
id: contentLoader
anchors.fill: parent
active: root._primeContent || shouldBeVisible || contentWindow.visible
asynchronous: false
}
} // closes contentWrapper
} // closes unrollCounteract
} // closes aligner
} // closes directionalClipMask
} // closes contentContainer
Item { Item {
id: focusHelper id: focusHelper

Some files were not shown because too many files have changed in this diff Show More