Compare commits

...
Author SHA1 Message Date
bbedward 84bb4d4753 add aqueous logo 2026-09-07 11:14:51 -04:00
bbedward f5acb716b9 aqueous: consolidate more aqueous specific stuff 2026-09-07 10:32:58 -04:00
bbedward b138ec1b0f fix staticcheck 2026-09-07 09:53:02 -04:00
bbedward 5a72cd9616 aqueous: move IPC integration to QML, delete test fixtures (since we
dont have a QML test structure even setup), delete in-repo docs
2026-09-07 09:08:46 -04:00
Zoey ce1ecfc6bc swapped to ipc 2026-09-06 23:07:29 -04:00
Zoey 80472c4db8 clean up per claud review 2026-09-06 16:12:34 -04:00
Zoey 49513047b2 remove unneeded blur 2026-09-06 15:23:30 -04:00
Zoey 045c5c3a89 added keybinding update 2026-09-06 02:33:50 -04:00
Zoey 398b360c48 added blur to dms shell for aqueous 2026-09-05 23:18:19 -04:00
Zoey a39196e992 updated to support workspace options better in aqueous 2026-09-05 20:55:09 -04:00
Zoey 2ac1fe670c aqueous support work 2026-09-05 20:39:19 -04:00
Zoey f39529d58c further updates 2026-09-05 17:03:57 -04:00
Zoey 8874a804b6 updated dank to support aqueous 2026-09-05 17:03:36 -04:00
bbedward 5baef07048 lock: keep the password cursor inside the field and follow it through overflow and wrapped reveals
fixes #3319
port 1.6
2026-09-05 12:26:02 -04:00
bbedward f402731ef2 network: reject weak hotspot passwords before handing them to NetworkManager
fixes #3318
port 1.6
2026-09-05 12:12:08 -04:00
bbedward 59f6f1fa71 dash, notifications, island: fix calendar banner overflow, expand chevron, and player row clipping
fixes #3300
fixes #3301
fixes #3317
port 1.6
2026-09-05 12:04:27 -04:00
bbedward c0f775ae49 network: keep saved wifi profiles when NM fails with no-secrets
fixes #3313
port 1.6
2026-09-05 11:55:46 -04:00
bbedward 3d6e45f430 bar: focus workspace icons on any workspace and wire island overview loader
fixes #3309
fixes #3310

port 1.6
2026-09-05 11:39:43 -04:00
bbedward 85bc7604c2 display config: judge profile outputs by live heads, not stored make/model
fixes #3306
port 1.6
2026-09-05 11:29:46 -04:00
bbedward 3674d0f480 screenshot: resolve region and picker keys through the seat keymap
fixes #3311
port 1.6
2026-09-05 11:18:59 -04:00
Huỳnh Thiện Lộc 5f124de8d4 fix(screenshot): scale HUD and selection label for HiDPI displays (#3308)
port 1.6
2026-09-05 11:10:35 -04:00
bbedward 59a03f450d theme: read the dynamic error color per mode
fixes #3298
port 1.6
2026-09-05 10:46:41 -04:00
bbedward 3797422832 systemd: keep the inherited hard file limit in dms.service
fixes #3312
port 1.6
2026-09-05 10:22:33 -04:00
Adolfo Ochagavía e4793327b8 Fix repeated day in calendar (#3302)
Root cause: we were creating a `Date` that did not exist in my timezone
(Santiago, Chile) because of DST. The JS engine decided to pick the
previous day, so I ended up with two times 5 September.

Fix: introduce a `DateOnly` object that internally uses UTC (to prevent
DST issues). Other parts of the system still use `Date`, which
probably should be refactored to use `DateOnly` as well (maybe in an
upcoming PR?). In the meantime, we provide a function to export a
`DateOnly` as a `Date`, using noon as the time instead of midnight
(because there are no DST changes at noon AFAIK).

We tested this manually (see the attached screenshots), but didn't find
a suitable place for automated tests.

port 1.6
2026-09-04 19:41:59 -04:00
purian23 e18931353b feat(island): vertical orientation layout management support
Port 1.6
2026-09-04 17:57:20 -04:00
bbedward cf3c76a79f bar: keep the widget font size the user asked for
ref #3295
port 1.6
2026-09-04 13:10:37 -04:00
bbedward 7e18a68b82 screenshot: add instancelock on runtimedir/wayland-display to prevent
multiple instances from running

fixes #3293
port 1.6
2026-09-04 13:03:45 -04:00
bbedward 96a21fbe08 launcher: revalidate the cached launcher sections on load
fixes #3292
port 1.6
2026-09-04 12:00:19 -04:00
bbedward fbffa410af clock: align the horizontal time block to whole pixels
fixes #3291
port 1.6
2026-09-04 11:50:32 -04:00
bbedward 936b42b4e7 audio: resolve the sound theme before the login sound plays
fixes #3290
port 1.6
2026-09-04 11:29:17 -04:00
bbedward eb53d8983e matugen: stop embedding the adw-gtk3 body in the gtk light template
fixes #3282
port 1.6
2026-09-04 10:37:31 -04:00
bbedward 4e2afb53cc bar: make the global shadow toggles authoritative 2026-09-04 10:37:31 -04:00
bbedward a665ba8ca5 theme: write desktop settings through dconf instead of gsettings
fixes #3280
port 1.6
2026-09-04 10:37:31 -04:00
bbedward d57e32c5cb matugen: give dry runs their own config file
fixes #3279
port 1.6
2026-09-04 10:37:31 -04:00
Huỳnh Thiện Lộc 07d8e28668 fix(dash): dim today button when on current month (#3294)
* fix(dash): dim today button when on current month and hide tooltip on click

* fix(dash): use Theme.shortDuration for calendar today button opacity animation

* refactor(dash): use standard DankActionButton tooltipText for calendar today button

* chore(dash): remove unused todayButton id

* fix(dash): allow returning to today when another day in current month is selected
2026-09-04 10:28:37 -04:00
usoaraway0 ec22e9e594 brightness: retry initial ddc read (#3287)
port 1.6
2026-09-04 09:51:37 -04:00
枫莹 433b13cb0e fix(plugins): use plugin ids for management actions (#3284) 2026-09-04 09:51:05 -04:00
usoaraway0 cbe572eea6 niri: create screenshot directory on demand (#3286)
port 1.6
2026-09-04 09:46:16 -04:00
purian23 c1f1da1d26 feat(island): allow hiding clock or date options in compact pill
Port 1.6
2026-09-04 00:47:19 -04:00
bbedward 2008c39af3 build: remove install-shell from install in Makefile 2026-09-03 18:27:45 -04:00
bbedward 1f22ad4b33 changelog: disable 2026-09-03 15:12:29 -04:00
bbedward 8a871aafac bump to v1.7-beta 2026-09-03 13:54:26 -04:00
173 changed files with 7158 additions and 10151 deletions
+4 -13
View File
@@ -18,7 +18,7 @@ SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications
.PHONY: all build dev run clean lint-qml install install-bin install-shell install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
.PHONY: all build dev run clean lint-qml install install-bin install-completions install-systemd install-icon install-desktop uninstall uninstall-bin uninstall-shell uninstall-completions uninstall-systemd uninstall-icon uninstall-desktop help
all: build
@@ -53,14 +53,6 @@ install-bin:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed"
install-shell:
@echo "Installing shell files to $(SHELL_INSTALL_DIR)..."
@test -e $(SHELL_DIR)/DankCommon/Widgets/DankIcon.qml || { echo "DankCommon missing: run git submodule update --init"; exit 1; }
@mkdir -p $(SHELL_INSTALL_DIR)
@cp -rL $(SHELL_DIR)/* $(SHELL_INSTALL_DIR)/
@rm -rf $(SHELL_INSTALL_DIR)/.git* $(SHELL_INSTALL_DIR)/.github
@echo "Shell files installed"
install-completions:
@echo "Installing shell completions..."
@mkdir -p $(DATA_DIR)/bash-completion/completions
@@ -98,7 +90,7 @@ install-desktop:
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed"
install: install-bin install-shell install-completions install-systemd install-icon install-desktop
install: install-bin install-completions install-systemd install-icon install-desktop
@echo ""
@echo "Installation complete!"
@echo ""
@@ -111,7 +103,7 @@ uninstall-bin:
@echo "Binary removed"
uninstall-shell:
@echo "Removing shell files from $(SHELL_INSTALL_DIR)..."
@echo "Removing pre-1.6 shell files from $(SHELL_INSTALL_DIR)..."
@rm -rf $(SHELL_INSTALL_DIR)
@echo "Shell files removed"
@@ -159,7 +151,6 @@ help:
@echo "Install:"
@echo " install - Build and install everything (requires sudo)"
@echo " install-bin - Install only the binary"
@echo " install-shell - Install only shell files"
@echo " install-completions - Install only shell completions"
@echo " install-systemd - Install only systemd service"
@echo " install-icon - Install only icon"
@@ -168,7 +159,7 @@ help:
@echo "Uninstall:"
@echo " uninstall - Remove everything (requires sudo)"
@echo " uninstall-bin - Remove only the binary"
@echo " uninstall-shell - Remove only shell files"
@echo " uninstall-shell - Remove shell files left by pre-1.6 installs"
@echo " uninstall-completions - Remove only shell completions"
@echo " uninstall-systemd - Remove only systemd service"
@echo " uninstall-icon - Remove only icon"
+1 -1
View File
@@ -9,7 +9,7 @@ Type=dbus
BusName=org.freedesktop.Notifications
ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384
LimitNOFILE=16384:infinity
Restart=on-failure
RestartForceExitStatus=TEMPFAIL
SuccessExitStatus=TEMPFAIL
+14 -10
View File
@@ -11,10 +11,11 @@ import (
)
var (
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
colorOutputFmt string
colorAutocopy bool
colorNotify bool
colorLowercase bool
colorAllowMulti bool
)
var colorCmd = &cobra.Command{
@@ -54,7 +55,8 @@ Examples:
dms color pick --rgb # Output as RGB
dms color pick --json # Output all formats as JSON
dms color pick --hex -l # Output hex in lowercase
dms color pick -a # Auto-copy result to clipboard`,
dms color pick -a # Auto-copy result to clipboard
dms color pick --allow-multiple # Skip the one-picker-at-a-time guard`,
Run: runColorPick,
}
@@ -69,6 +71,7 @@ func init() {
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template")
colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase")
colorPickCmd.Flags().BoolVar(&colorAllowMulti, "allow-multiple", false, "Open a picker even when another selection overlay is already open")
colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
@@ -90,11 +93,12 @@ func runColorPick(cmd *cobra.Command, args []string) {
}
config := colorpicker.Config{
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
Format: format,
CustomFormat: colorOutputFmt,
Lowercase: colorLowercase,
Autocopy: colorAutocopy,
Notify: colorNotify,
AllowMultiple: colorAllowMulti,
}
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs.
+94 -79
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -46,7 +47,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3),
Run: runKeybindsSet,
Run: runKeybindsEdit,
}
var keybindsRemoveCmd = &cobra.Command{
@@ -54,7 +55,7 @@ var keybindsRemoveCmd = &cobra.Command{
Short: "Remove a keybind",
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2),
Run: runKeybindsRemove,
Run: runKeybindsEdit,
}
var keybindsResetCmd = &cobra.Command{
@@ -62,10 +63,14 @@ var keybindsResetCmd = &cobra.Command{
Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2),
Run: runKeybindsReset,
Run: runKeybindsEdit,
}
func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
}
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -93,6 +98,9 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
@@ -206,85 +214,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider)
}
func getWritableProvider(name string) keybinds.WritableProvider {
provider, err := keybinds.GetDefaultRegistry().Get(name)
func keybindEditFailure(err error) map[string]any {
code := "command_failed"
var helperError *providers.AqueousError
if errors.As(err, &helperError) {
code = helperError.Code
}
return map[string]any{"success": false, "code": code, "message": err.Error()}
}
func runKeybindsEdit(cmd *cobra.Command, args []string) {
result, err := editKeybind(cmd, args)
if err == nil {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
return
}
structured, _ := cmd.Flags().GetBool("json")
if !structured {
log.Fatalf("Failed to save keybind: %v", err)
return
}
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
os.Exit(1)
}
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
if err != nil {
log.Fatalf("Error: %v", err)
return nil, err
}
key := args[1]
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
if !edit.Remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := aqueous.Edit(cmd.Context(), edit)
if err != nil {
return nil, err
}
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
}
writable, ok := provider.(keybinds.WritableProvider)
if !ok {
log.Fatalf("Provider %s does not support writing keybinds", name)
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
}
return writable
}
func runKeybindsSet(cmd *cobra.Command, args []string) {
providerName, key, action := args[0], args[1], args[2]
writable := getWritableProvider(providerName)
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil {
log.Fatalf("Error setting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"action": action,
"path": writable.GetOverridePath(),
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.RemoveBind(key); err != nil {
log.Fatalf("Error removing keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"removed": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
result := map[string]any{"success": true, "key": key}
switch cmd.Name() {
case "set":
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
desc, _ := cmd.Flags().GetString("desc")
err = writable.SetBind(key, args[2], desc, options)
result["action"] = args[2]
result["path"] = writable.GetOverridePath()
case "remove":
err = writable.RemoveBind(key)
result["removed"] = true
case "reset":
err = writable.ResetBind(key)
result["reset"] = true
default:
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
}
return result, err
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"errors"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
for _, tc := range []struct {
err error
code string
}{
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
{errors.New("transport failed"), "command_failed"},
} {
result := keybindEditFailure(tc.err)
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
t.Fatalf("unexpected failure result: %#v", result)
}
}
}
+10 -3
View File
@@ -20,6 +20,7 @@ import (
var (
ssOutputName string
ssSeat string
ssCursor string
ssFormat string
ssQuality int
@@ -33,6 +34,7 @@ var (
ssStdout bool
ssJSON bool
ssGeometry bool
ssAllowMulti bool
)
type screenshotMetadata struct {
@@ -57,7 +59,7 @@ Modes:
full - Capture the focused output
all - Capture all outputs combined
output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango/niri)
window - Capture the focused window (Hyprland/Mango/niri/Aqueous)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
@@ -71,7 +73,7 @@ Examples:
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window (Hyprland)
dms screenshot window # Focused window
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
@@ -80,6 +82,7 @@ Examples:
dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON
dms screenshot --allow-multiple # Skip the one-selector-at-a-time guard
dms screenshot -g # Print selected region geometry (X,Y WxH) to stdout
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`,
@@ -120,7 +123,7 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{
Use: "window",
Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
Run: runScreenshotWindow,
}
@@ -163,6 +166,7 @@ var notifyActionCmd = &cobra.Command{
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -176,6 +180,7 @@ func init() {
screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)")
screenshotCmd.PersistentFlags().BoolVar(&ssJSON, "json", false, "Print capture metadata as JSON")
screenshotCmd.PersistentFlags().BoolVarP(&ssGeometry, "geometry", "g", false, "Print selected region geometry (X,Y WxH) to stdout without capturing an image")
screenshotCmd.PersistentFlags().BoolVar(&ssAllowMulti, "allow-multiple", false, "Open a selector even when another one is already open")
ssScrollCmd.Flags().IntVar(&ssScrollInterval, "interval", 45, "Capture interval in milliseconds (30-1000)")
@@ -195,6 +200,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn
}
@@ -205,6 +211,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Reset = ssReset
config.Stdout = ssStdout
config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
if ssGeometry {
config.Clipboard = false
+2 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
// maxIPCMessageSize allows room for a 50 MB clipboard entry plus JSON/base64
@@ -96,10 +97,7 @@ func tryServerRequest(req models.Request) (*models.Response[any], bool) {
}
func getServerSocketPath() string {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = os.TempDir()
}
runtimeDir := utils.RuntimeDir()
if sessionSock, ok := shellApp.SessionSocketPath(); ok {
return sessionSock
+12 -106
View File
@@ -2,28 +2,19 @@ package clipboard
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0
keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0
ctrlModMask = 1 << 2
// evdev fallbacks for a standard pc105 map
fallbackCtrlKey = 29 // KEY_LEFTCTRL
fallbackShiftKey = 42 // KEY_LEFTSHIFT
fallbackVKey = 47 // KEY_V
)
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
@@ -50,25 +41,25 @@ func SendPasteKeystroke(withShift bool) (err error) {
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
var seatKeymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
if seatKeymap == nil {
seatKeymap = &e
}
})
s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
if seatKeymap == nil || seatKeymap.Format != keymap.FormatXkbV1 {
return fmt.Errorf("no xkb keymap from seat")
}
defer unix.Close(keymap.Fd)
defer unix.Close(seatKeymap.Fd)
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
keymapText, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
if err != nil {
return fmt.Errorf("read keymap: %w", err)
}
keys := resolveKeycodes(keymapText)
keys := keymap.Parse(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil {
@@ -76,15 +67,15 @@ func SendPasteKeystroke(withShift bool) (err error) {
}
defer vk.Destroy()
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
if err := vk.Keymap(keymap.FormatXkbV1, seatKeymap.Fd, seatKeymap.Size); err != nil {
return fmt.Errorf("set keymap: %w", err)
}
mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl}
held := []uint32{keys.Keycode("Control_L")}
if withShift {
mods |= shiftModMask
held = append(held, keys.shift)
held = append(held, keys.Keycode("Shift_L"))
}
t := uint32(0)
@@ -121,93 +112,8 @@ func SendPasteKeystroke(withShift bool) (err error) {
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err)
}
if err := press(keys.v); err != nil {
if err := press(keys.Keycode("v")); err != nil {
return fmt.Errorf("key press: %w", err)
}
return nil
}
func readKeymap(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
type pasteKeycodes struct {
ctrl uint32
shift uint32
v uint32
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// xkbcommon may serialize keysyms as hex escapes instead of names
// (e.g. "0x76" for v, "0xffe3" for Control_L).
var keysymNames = map[uint32]string{
0x76: "v",
0xffe3: "Control_L",
0xffe1: "Shift_L",
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
// the seat keymap's first group, falling back to pc105 positions.
func resolveKeycodes(keymap string) pasteKeycodes {
codes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
codes[m[1]] = uint32(code)
}
}
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
want := map[string]*uint32{
"Control_L": &keys.ctrl,
"Shift_L": &keys.shift,
"v": &keys.v,
}
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := codes[m[1]]
if !ok || xkbCode < 8 {
continue
}
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
target, wanted := want[level1]
if !wanted {
continue
}
*target = xkbCode - 8
delete(want, level1)
if len(want) == 0 {
break
}
}
return keys
}
+10 -12
View File
@@ -4,6 +4,7 @@ import (
"os"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
@@ -32,20 +33,20 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
}
defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent
var seatKeymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil {
keymap = &e
if seatKeymap == nil {
seatKeymap = &e
}
})
s.display.Roundtrip()
if keymap == nil {
if seatKeymap == nil {
t.Fatal("no keymap event")
}
defer unix.Close(keymap.Fd)
defer unix.Close(seatKeymap.Fd)
text, err := readKeymap(keymap.Fd, keymap.Size)
text, err := keymap.Read(seatKeymap.Fd, seatKeymap.Size)
if err != nil {
t.Fatalf("read keymap: %v", err)
}
@@ -56,10 +57,7 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
}
}
keys := resolveKeycodes(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
keys := keymap.Parse(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", seatKeymap.Size,
keys.Keycode("Control_L"), keys.Keycode("Shift_L"), keys.Keycode("v"))
}
-80
View File
@@ -1,80 +0,0 @@
package clipboard
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
func TestResolveKeycodes(t *testing.T) {
tests := []struct {
name string
keymap string
want pasteKeycodes
}{
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveKeycodes(tt.keymap)
if got != tt.want {
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
}
})
}
}
+23 -6
View File
@@ -10,16 +10,19 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
)
type Config struct {
Format OutputFormat
CustomFormat string
Lowercase bool
Autocopy bool
Notify bool
Format OutputFormat
CustomFormat string
Lowercase bool
Autocopy bool
Notify bool
AllowMultiple bool
}
type Output struct {
@@ -62,6 +65,7 @@ type Picker struct {
seat *client.Seat
pointer *client.Pointer
keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
@@ -91,6 +95,14 @@ func New(config Config) *Picker {
}
func (p *Picker) Run() (*Color, error) {
if !p.config.AllowMultiple {
lock, err := utils.LockSelectionOverlay()
if err != nil {
return nil, err
}
defer lock.Release()
}
if err := p.connect(); err != nil {
return nil, fmt.Errorf("wayland connect: %w", err)
}
@@ -728,9 +740,14 @@ func (p *Picker) setupPointerHandlers() {
}
func (p *Picker) setupKeyboardHandlers() {
p.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
p.keymap = keymap.FromEvent(e)
})
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
sym := p.keymap.Keysym(e.Key)
for _, ls := range p.surfaces {
ls.state.OnKey(e.Key, e.State)
ls.state.OnKey(sym, e.State)
}
})
}
+4 -4
View File
@@ -266,7 +266,7 @@ func (s *SurfaceState) OnPointerButton(button, state uint32) {
}
}
func (s *SurfaceState) OnKey(key, state uint32) {
func (s *SurfaceState) OnKey(sym string, state uint32) {
if state != 1 {
return
}
@@ -274,10 +274,10 @@ func (s *SurfaceState) OnKey(key, state uint32) {
s.mu.Lock()
defer s.mu.Unlock()
switch key {
case 1: // KEY_ESC
switch sym {
case "Escape":
s.cancelled = true
case 28: // KEY_ENTER
case "Return", "KP_Enter":
if s.readyForDisplay && s.screenBuf != nil {
s.colorPicked = true
}
+1 -1
View File
@@ -104,7 +104,7 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
for range goroutines / 3 {
wg.Go(func() {
for range iterations {
s.OnKey(1, 1)
s.OnKey("Escape", 1)
}
})
}
+178
View File
@@ -0,0 +1,178 @@
package providers
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
)
type AqueousProvider struct{}
func NewAqueousProvider() *AqueousProvider { return &AqueousProvider{} }
func (*AqueousProvider) Name() string { return "aqueous" }
func aqueousObjects(snapshot aqueousConfig, key string) []aqueousConfig {
values, _ := snapshot[key].([]any)
result := make([]aqueousConfig, 0, len(values))
for _, value := range values {
if object, ok := value.(map[string]any); ok {
result = append(result, aqueousConfig(object))
}
}
return result
}
func AqueousCheatSheet(snapshot aqueousConfig) (*keybinds.CheatSheet, error) {
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
sheet := &keybinds.CheatSheet{Title: "Aqueous", Provider: "aqueous", Generation: snapshot.String("generation"), ModKey: "Super", DMSBindsIncluded: true, Binds: map[string][]keybinds.Keybind{"Compositor": {}, "Custom": {}}}
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
if len(values) == 0 {
values = []any{""}
}
for _, value := range values {
key, ok := value.(string)
if !ok {
return nil, errors.New("invalid keybinding list")
}
sheet.Binds["Compositor"] = append(sheet.Binds["Compositor"], keybinds.Keybind{Key: key, Action: field.String("id"), Description: field.String("label"), Source: "dms"})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
sheet.Binds["Custom"] = append(sheet.Binds["Custom"], keybinds.Keybind{Key: binding.String("chord"), Action: "spawn " + binding.String("command"), Description: binding.String("command"), Source: "dms"})
}
seen := map[string]keybinds.Keybind{}
for _, category := range []string{"Compositor", "Custom"} {
for i, bind := range sheet.Binds[category] {
if bind.Key == "" {
continue
}
if previous, exists := seen[bind.Key]; exists {
sheet.Binds[category][i].Conflict = &previous
}
seen[bind.Key] = bind
}
}
return sheet, nil
}
func (*AqueousProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
snapshot, err := aqueousHelper(context.Background(), "snapshot", nil)
if err != nil {
return nil, err
}
return AqueousCheatSheet(snapshot)
}
type AqueousBindEdit struct {
Generation string
OriginalKey string
Key string
Action string
Remove bool
}
func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousConfig, error) {
if edit.Generation == "" || edit.Generation != snapshot.String("generation") {
return nil, &AqueousError{Code: "external_change", Message: "reload and reconcile the retained keybind draft"}
}
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
if edit.Key == "" {
return nil, errors.New("missing key")
}
original := edit.OriginalKey
if original == "" {
original = edit.Key
}
originalCount := 0
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
for _, value := range values {
if value == original {
originalCount++
}
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
originalCount++
}
}
if originalCount > 1 {
return nil, &AqueousError{Code: "ambiguous_target", Message: "multiple bindings use the original shortcut"}
}
if originalCount == 0 && (edit.Remove || edit.OriginalKey != "") {
return nil, &AqueousError{Code: "target_removed", Message: "the original shortcut no longer exists"}
}
changes := []any{}
customChanges := []any{}
foundAction := false
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
old, _ := field["value"].([]any)
values := make([]any, 0, len(old)+1)
for _, value := range old {
if value == original {
continue
}
if !edit.Remove && value == edit.Key && field.String("id") != edit.Action {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
values = append(values, value)
}
if field.String("id") == edit.Action && !edit.Remove {
foundAction = true
if !slices.Contains(values, any(edit.Key)) {
values = append(values, edit.Key)
}
}
if !slices.Equal(old, values) {
changes = append(changes, map[string]any{"id": field.String("id"), "value": values})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
customChanges = append(customChanges, map[string]any{"op": "delete", "id": binding.String("id")})
continue
}
if !edit.Remove && binding.String("chord") == edit.Key {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
}
if !edit.Remove && !foundAction {
command, ok := strings.CutPrefix(edit.Action, "spawn ")
if !ok || strings.TrimSpace(command) == "" {
return nil, errors.New("unknown Aqueous action; custom commands use spawn")
}
customChanges = append(customChanges, map[string]any{"op": "add", "chord": edit.Key, "command": command})
}
return aqueousConfig{"expected_generation": edit.Generation, "changes": changes, "custom_keybind_changes": customChanges, "create_user_override": true}, nil
}
func (*AqueousProvider) Edit(ctx context.Context, edit AqueousBindEdit) (aqueousConfig, error) {
snapshot, err := aqueousHelper(ctx, "snapshot", nil)
if err != nil {
return nil, err
}
request, err := AqueousBindRequest(snapshot, edit)
if err != nil {
return nil, err
}
return aqueousHelper(ctx, "apply", request)
}
@@ -0,0 +1,115 @@
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func helperResult(ctx context.Context, verb string, input []byte) (aqueousConfig, error) {
var result aqueousConfig
args := []string{verb, "--shell", "dms"}
if input != nil {
args = append(args, "--request", "-")
}
err := utils.RunJSON(ctx, "aqueous-config", args, input, &result)
if err != nil || !result.Bool("ok") {
if result.String("code") != "" {
return result, &AqueousError{Code: result.String("code"), Message: result.String("message")}
}
if err != nil {
if verb == "apply" {
return result, &AqueousError{Code: "uncertain", Message: err.Error()}
}
return result, err
}
return result, errors.New("aqueous-config rejected the request")
}
return result, nil
}
type AqueousError struct {
Code string
Message string
}
func (e *AqueousError) Error() string { return e.Code + ": " + e.Message }
func requireAqueousCapabilities(result aqueousConfig, required ...string) error {
if result.Number("protocol") != 1 {
return errors.New("unsupported aqueous-config protocol")
}
values, ok := result["capabilities"].([]any)
if !ok {
return errors.New("unsupported aqueous-config: capability discovery required")
}
for _, capability := range required {
if !slices.Contains(values, any(capability)) {
return fmt.Errorf("unsupported aqueous-config capability: %s", capability)
}
}
return nil
}
func aqueousHelper(ctx context.Context, verb string, request aqueousConfig) (aqueousConfig, error) {
if verb == "snapshot" {
result, err := helperResult(ctx, verb, nil)
if err != nil {
return result, err
}
if err := requireAqueousCapabilities(result, "schema_fields", "shell_dms"); err != nil {
return result, err
}
if result.String("generation") == "" {
return result, errors.New("missing configuration generation")
}
return result, nil
}
if verb != "validate" && verb != "apply" {
return nil, errors.New("unsupported helper operation")
}
if request.String("expected_generation") == "" {
return nil, errors.New("missing expected_generation; reload configuration")
}
version, err := helperResult(ctx, "version", nil)
if err != nil {
return nil, err
}
required := []string{"keybinds", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"}
if err := requireAqueousCapabilities(version, required...); err != nil {
return nil, err
}
request["protocol"] = 1
if _, exists := request["backup_dir"]; !exists {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
request["backup_dir"] = filepath.Join(configDir, "DankMaterialShell", "aqueous-backups")
}
input, err := json.Marshal(request)
if err != nil {
return nil, err
}
validated, err := helperResult(ctx, "validate", input)
if err != nil || verb == "validate" {
return validated, err
}
return helperResult(ctx, "apply", input)
}
type aqueousConfig map[string]any
func (e aqueousConfig) String(key string) string { value, _ := e[key].(string); return value }
func (e aqueousConfig) Bool(key string) bool { value, _ := e[key].(bool); return value }
func (e aqueousConfig) Number(key string) float64 {
value, _ := e[key].(json.Number)
number, _ := value.Float64()
return number
}
@@ -0,0 +1,133 @@
package providers
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func aqueousFixture(t *testing.T) aqueousConfig {
t.Helper()
var snapshot aqueousConfig
err := utils.DecodeJSON([]byte(`{"ok":true,"protocol":1,"generation":"abc","capabilities":["keybinds"],"fields":[{"id":"spawn_terminal","category":"keybinds","type":"string_list","label":"Terminal","value":["Super+Return","Super+T"]},{"id":"close","category":"keybinds","type":"string_list","label":"Close","value":[]}],"custom_keybinds":[{"id":"custom:12","chord":"Super+R","command":"printf 'hello; $world'"}]}`), &snapshot)
if err != nil {
t.Fatal(err)
}
return snapshot
}
func TestAqueousCheatsheetIncludesUnboundAndCustom(t *testing.T) {
sheet, err := AqueousCheatSheet(aqueousFixture(t))
if err != nil {
t.Fatal(err)
}
if sheet.Generation != "abc" || len(sheet.Binds["Compositor"]) != 3 {
t.Fatal("lost generation or multiple bindings")
}
if sheet.Binds["Compositor"][2].Key != "" || sheet.Binds["Compositor"][2].Action != "close" {
t.Fatal("unbound action lost")
}
if sheet.Binds["Custom"][0].Action != "spawn printf 'hello; $world'" {
t.Fatal("custom command modified")
}
}
func TestAqueousBindingReplacementIsOneRequest(t *testing.T) {
snapshot := aqueousFixture(t)
request, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+Enter", Action: "spawn_terminal"})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["changes"])
if string(data) != `[{"id":"spawn_terminal","value":["Super+T","Super+Enter"]}]` {
t.Fatalf("wrong atomic replacement: %s", data)
}
if request["expected_generation"] != "abc" {
t.Fatal("generation replaced")
}
if !reflect.DeepEqual(snapshot, aqueousFixture(t)) {
t.Fatal("draft mutated snapshot")
}
}
func TestAqueousBindingConflictAndRemove(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "stale", Key: "Super+T", Remove: true},
{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+R", Action: "close"},
{Generation: "abc", Key: "Super+Q", Action: "invented_action"},
} {
if _, err := AqueousBindRequest(aqueousFixture(t), edit); err == nil {
t.Fatal("invalid edit accepted")
}
}
request, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: "abc", Key: "Super+R", Remove: true})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["custom_keybind_changes"])
if string(data) != `[{"id":"custom:12","op":"delete"}]` {
t.Fatalf("wrong removal: %s", data)
}
}
func TestAqueousGenerationConflictCode(t *testing.T) {
for _, generation := range []string{"", "stale"} {
_, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: generation, Key: "Super+R", Remove: true})
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "external_change" {
t.Fatalf("expected structured generation conflict, got %v", err)
}
}
}
func TestAqueousBindingRequiresUniqueOriginal(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "abc", Key: "Super+Missing", Remove: true},
{Generation: "abc", OriginalKey: "Super+Missing", Key: "Super+X", Action: "spawn_terminal"},
} {
_, err := AqueousBindRequest(aqueousFixture(t), edit)
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "target_removed" {
t.Fatalf("expected missing original, got %v", err)
}
}
snapshot := aqueousFixture(t)
snapshot["custom_keybinds"] = append(snapshot["custom_keybinds"].([]any), map[string]any{"id": "duplicate", "chord": "Super+T", "command": "echo duplicate"})
_, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", Key: "Super+T", Remove: true})
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "ambiguous_target" {
t.Fatalf("expected ambiguous original, got %v", err)
}
}
func TestAqueousHelperFailureCodes(t *testing.T) {
for _, tc := range []struct {
name string
script string
code string
}{
{"conflict", `printf '%s' '{"ok":false,"code":"external_change","message":"changed"}'; exit 1`, "external_change"},
{"missing_ack", "exit 0", "uncertain"},
{"failed_process", "exit 1", "uncertain"},
{"malformed_ack", "printf '{'", "uncertain"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "aqueous-config"), []byte("#!/bin/sh\n"+tc.script+"\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
_, err := helperResult(context.Background(), "apply", nil)
var failure *AqueousError
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("expected %s, got %v", tc.code, err)
}
})
}
}
+1
View File
@@ -30,6 +30,7 @@ type DMSBindsStatus struct {
}
type CheatSheet struct {
Generation string `json:"generation,omitempty"`
Title string `json:"title"`
Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
+42 -15
View File
@@ -500,23 +500,25 @@ func buildImportData(dank16JSON, image string) string {
return fmt.Sprintf(`{"dank16": %s, "image": %s}`, dank16JSON, path)
}
func userConfigSection(opts *Options) string {
if !opts.RunUserTemplates || opts.ConfigDir == "" {
return "[config]\n\n"
}
data, err := os.ReadFile(filepath.Join(opts.ConfigDir, "matugen", "config.toml"))
if err != nil {
return "[config]\n\n"
}
section := extractTOMLSection(string(data), "[config]", "[templates]")
if section == "" {
return "[config]\n\n"
}
return section + "\n"
}
func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
wroteConfig := false
if opts.RunUserTemplates {
if data, err := os.ReadFile(userConfigPath); err == nil {
configSection := extractTOMLSection(string(data), "[config]", "[templates]")
if configSection != "" {
cfgFile.WriteString(configSection)
cfgFile.WriteString("\n")
wroteConfig = true
}
}
}
if !wroteConfig {
cfgFile.WriteString("[config]\n\n")
}
cfgFile.WriteString(userConfigSection(opts))
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
if data, err := os.ReadFile(baseConfigPath); err == nil {
@@ -983,7 +985,32 @@ func runMatugenDryRun(opts *Options) (string, error) {
return execDryRun(opts, newFlags)
}
// matugen aborts on a config file it cannot deserialize, and without -c it
// reads the user's own ~/.config/matugen/config.toml, so a dry run gets a
// config of its own instead of inheriting whatever is there.
func writeDryRunConfig(opts *Options) (string, error) {
cfgFile, err := os.CreateTemp("", "matugen-dryrun-*.toml")
if err != nil {
return "", fmt.Errorf("failed to create dry-run config: %w", err)
}
defer cfgFile.Close()
section := userConfigSection(opts)
if idx := strings.Index(section, "\n[templates"); idx != -1 {
section = section[:idx+1]
}
cfgFile.WriteString(section)
cfgFile.WriteString("[templates]\n")
return cfgFile.Name(), nil
}
func execDryRun(opts *Options, flags matugenFlags) (string, error) {
cfgPath, err := writeDryRunConfig(opts)
if err != nil {
return "", err
}
defer os.Remove(cfgPath)
var baseArgs []string
switch opts.Kind {
case "hex":
@@ -991,7 +1018,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
default:
baseArgs = []string{opts.Kind, opts.Value}
}
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "--json", "hex", "--dry-run")
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgPath, "--json", "hex", "--dry-run")
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
if flags.isV4 {
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...)
+73
View File
@@ -1327,3 +1327,76 @@ func TestResolveSmartMode(t *testing.T) {
})
}
}
func writeUserMatugenConfig(t *testing.T, configDir, content string) {
t.Helper()
dir := filepath.Join(configDir, "matugen")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("failed to create matugen config dir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(content), 0o644); err != nil {
t.Fatalf("failed to write user config: %v", err)
}
}
func TestWriteDryRunConfigAlwaysDeclaresConfigAndTemplates(t *testing.T) {
tests := []struct {
name string
userConfig string
runUserTemplates bool
wantContains []string
wantNotContains []string
}{
{
name: "comment only user config",
userConfig: "# nothing here\n",
runUserTemplates: true,
wantNotContains: []string{"# nothing here"},
},
{
name: "user config section is carried over",
userConfig: "[config]\nfallback_color = \"#ff0000\"\n\n[templates.mine]\ninput_path = 'in'\noutput_path = 'out'\n",
runUserTemplates: true,
wantContains: []string{"fallback_color = \"#ff0000\""},
wantNotContains: []string{"[templates.mine]"},
},
{
name: "user templates disabled",
userConfig: "[config]\nfallback_color = \"#ff0000\"\n",
wantNotContains: []string{"fallback_color"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configDir := filepath.Join(t.TempDir(), "config")
writeUserMatugenConfig(t, configDir, tc.userConfig)
opts := &Options{ConfigDir: configDir, RunUserTemplates: tc.runUserTemplates}
cfgPath, err := writeDryRunConfig(opts)
if err != nil {
t.Fatalf("writeDryRunConfig failed: %v", err)
}
defer os.Remove(cfgPath)
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("failed to read dry-run config: %v", err)
}
content := string(data)
assert.Contains(t, content, "[config]")
assert.Contains(t, content, "[templates]")
for _, want := range tc.wantContains {
assert.Contains(t, content, want)
}
for _, unwanted := range tc.wantNotContains {
assert.NotContains(t, content, unwanted)
}
})
}
}
func TestUserConfigSectionWithoutConfigDir(t *testing.T) {
assert.Equal(t, "[config]\n\n", userConfigSection(&Options{RunUserTemplates: true}))
}
+3 -6
View File
@@ -8,10 +8,11 @@ import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"time"
"unicode/utf16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -148,9 +149,5 @@ func readQString(r io.Reader) (string, error) {
// SocketPathForPID returns the instance IPC socket used by qs --pid.
func SocketPathForPID(pid int) string {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
}
return filepath.Join(runtimeDir, "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
return filepath.Join(utils.RuntimeDir(), "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
}
+95
View File
@@ -0,0 +1,95 @@
package screenshot
import (
"context"
"errors"
"image"
"math"
)
func aqueousWindowGeometry(model aqueousSnapshotModel, seatName string) (*WindowGeometry, error) {
seat, err := model.Seat(seatName)
if err != nil {
return nil, err
}
if seat.FocusKind != "window" {
return nil, errors.New("no active window: seat focus is not a window")
}
window := model.Entities["window:"+seat.Window]
if window == nil || !window.Visible || window.Minimized || !window.CanActivate {
return nil, errors.New("no eligible active window")
}
output := model.Entities["output:"+window.Output]
if output == nil || !output.Enabled || !output.Powered {
return nil, errors.New("active window output is unavailable")
}
box, bounds := window.OuterGeometry, output.Bounds
if box == nil || bounds == nil || !box.valid() || !bounds.valid() || box.Width <= 0 || box.Height <= 0 || output.Scale <= 0 || math.IsInf(output.Scale, 0) || math.IsNaN(output.Scale) {
return nil, errors.New("invalid active window geometry")
}
return &WindowGeometry{X: int32(box.X), Y: int32(box.Y), Width: int32(box.Width), Height: int32(box.Height), Output: output.Name, Scale: output.Scale, OutputX: int32(bounds.X), OutputY: int32(bounds.Y)}, nil
}
func aqueousCropRect(geom *WindowGeometry, pixels image.Rectangle) image.Rectangle {
left := math.Floor((float64(geom.X) - float64(geom.OutputX)) * geom.Scale)
top := math.Floor((float64(geom.Y) - float64(geom.OutputY)) * geom.Scale)
right := math.Ceil((float64(geom.X) + float64(geom.Width) - float64(geom.OutputX)) * geom.Scale)
bottom := math.Ceil((float64(geom.Y) + float64(geom.Height) - float64(geom.OutputY)) * geom.Scale)
return image.Rect(int(left), int(top), int(right), int(bottom)).Intersect(pixels)
}
func aqueousFocusedOutput(seatName string) (string, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return "", err
}
seat, err := model.Seat(seatName)
if err != nil {
return "", err
}
output := model.Entities["output:"+seat.Output]
if output == nil || !output.Enabled || !output.Powered {
return "", errors.New("selected output is unavailable")
}
return output.Name, nil
}
func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
geom, err := aqueousWindowGeometry(model, s.config.Seat)
if err != nil {
return nil, err
}
region := Region{X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, Output: geom.Output}
if s.config.Geometry {
return geometryResult(region), nil
}
output := s.findOutputByName(geom.Output)
if output == nil {
return nil, errors.New("active window output disappeared")
}
result, err := s.captureWholeOutput(output)
if err != nil {
return nil, err
}
defer result.Buffer.Close()
rect := aqueousCropRect(geom, image.Rect(0, 0, result.Buffer.Width, result.Buffer.Height))
if rect.Empty() {
return nil, errors.New("active window is outside the output")
}
bpp := PixelFormat(result.Format).BytesPerPixel()
cropped, err := CreateShmBuffer(rect.Dx(), rect.Dy(), rect.Dx()*bpp)
if err != nil {
return nil, err
}
src, dst := result.Buffer.Data(), cropped.Data()
for y := 0; y < rect.Dy(); y++ {
start := (rect.Min.Y+y)*result.Buffer.Stride + rect.Min.X*bpp
copy(dst[y*cropped.Stride:y*cropped.Stride+rect.Dx()*bpp], src[start:start+rect.Dx()*bpp])
}
cropped.Format = PixelFormat(result.Format)
return &CaptureResult{Buffer: cropped, Region: region, Format: result.Format, Scale: geom.Scale, CICP: result.CICP}, nil
}
@@ -0,0 +1,119 @@
package screenshot
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"regexp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) {
executable, err := os.Executable()
if err != nil {
return aqueousSnapshotModel{}, err
}
var data json.RawMessage
if err := utils.RunJSON(ctx, executable, []string{"ipc", "call", "aqueous", "snapshot", os.Getenv("AQUEOUS_SOCKET")}, nil, &data); err != nil {
return aqueousSnapshotModel{}, fmt.Errorf("get Aqueous state from DMS; a shell must be running in this session: %w", err)
}
return parseAqueousSnapshot(data)
}
type aqueousBox struct {
X, Y, Width, Height int64
}
func (b aqueousBox) valid() bool {
for _, value := range []int64{b.X, b.Y, b.Width, b.Height} {
if value < math.MinInt32 || value > math.MaxInt32 {
return false
}
}
return true
}
type aqueousCaptureEntity struct {
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
Output string `json:"output"`
Window string `json:"window"`
FocusKind string `json:"focus_kind"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
CanActivate bool `json:"can_activate"`
Enabled bool `json:"enabled"`
Powered bool `json:"powered"`
Scale float64 `json:"scale"`
Bounds *aqueousBox `json:"bounds"`
OuterGeometry *aqueousBox `json:"outer_geometry"`
}
type aqueousSnapshotModel struct {
Entities map[string]*aqueousCaptureEntity
}
func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) {
if name != "" {
if seat := m.Entities["seat:"+name]; seat != nil {
return seat, nil
}
return nil, errors.New("not_found: seat")
}
var seat *aqueousCaptureEntity
for _, entity := range m.Entities {
if entity.Kind != "seat" {
continue
}
if seat != nil {
return nil, errors.New("ambiguous_seat")
}
seat = entity
}
if seat == nil {
return nil, errors.New("unavailable: no seat")
}
return seat, nil
}
func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) {
var batch struct {
Error string `json:"error"`
Schema int `json:"schema"`
Session string `json:"session"`
Sequence string `json:"sequence"`
BaseSequence json.RawMessage `json:"base_sequence"`
Type string `json:"type"`
Upsert []aqueousCaptureEntity `json:"upsert"`
Removed []string `json:"removed"`
}
var model aqueousSnapshotModel
if err := utils.DecodeJSON(data, &batch); err != nil {
return model, err
}
if batch.Error != "" {
return model, errors.New("aqueous state is unavailable in the running DMS shell")
}
if batch.Schema != 1 || batch.Type != "snapshot" || !bytes.Equal(batch.BaseSequence, []byte("null")) || batch.Upsert == nil || batch.Removed == nil || len(batch.Removed) != 0 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(batch.Session) || !regexp.MustCompile(`^[0-9]+$`).MatchString(batch.Sequence) {
return model, errors.New("invalid Aqueous shell snapshot")
}
model.Entities = make(map[string]*aqueousCaptureEntity, len(batch.Upsert))
for i := range batch.Upsert {
entity := &batch.Upsert[i]
key := entity.Kind + ":" + entity.ID
if entity.ID == "" || entity.Kind == "" || model.Entities[key] != nil {
return aqueousSnapshotModel{}, errors.New("invalid or duplicate shell entity")
}
model.Entities[key] = entity
}
if model.Entities["session:session"] == nil {
return aqueousSnapshotModel{}, errors.New("missing session entity")
}
return model, nil
}
+79
View File
@@ -0,0 +1,79 @@
package screenshot
import (
"bytes"
"image"
"os"
"testing"
)
func TestAqueousCropRoundsOutwardAndClips(t *testing.T) {
for _, test := range []struct {
geometry WindowGeometry
pixels image.Rectangle
want image.Rectangle
}{
{WindowGeometry{X: -1919, Y: 1, Width: 101, Height: 51, OutputX: -1920, Scale: 1.25}, image.Rect(0, 0, 2400, 1350), image.Rect(1, 1, 128, 65)},
{WindowGeometry{X: -20, Y: -10, Width: 100, Height: 60, Scale: 1.5}, image.Rect(0, 0, 800, 600), image.Rect(0, 0, 120, 75)},
{WindowGeometry{X: 90, Y: 80, Width: 100, Height: 100, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rect(90, 80, 100, 100)},
{WindowGeometry{X: 200, Y: 200, Width: 20, Height: 20, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rectangle{}},
} {
if got := aqueousCropRect(&test.geometry, test.pixels); got != test.want {
t.Fatalf("got %v want %v", got, test.want)
}
}
}
func TestAqueousGeometryUsesSeatAndOuterBounds(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
model, err := parseAqueousSnapshot(data)
if err != nil {
t.Fatal(err)
}
geometry, err := aqueousWindowGeometry(model, "")
if err != nil {
t.Fatal(err)
}
seat, _ := model.Seat("")
window := model.Entities["window:"+seat.Window]
if geometry.Width != int32(window.OuterGeometry.Width) || geometry.X != int32(window.OuterGeometry.X) {
t.Fatal("wrong bounds")
}
seat.FocusKind = "layer_surface"
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("layer focus captured")
}
seat.FocusKind = "window"
model.Entities["seat:second"] = &aqueousCaptureEntity{Kind: "seat", ID: "second"}
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("ambiguous seat captured")
}
if _, err := aqueousWindowGeometry(model, seat.ID); err != nil {
t.Fatal(err)
}
delete(model.Entities, "window:"+seat.Window)
if _, err := aqueousWindowGeometry(model, seat.ID); err == nil {
t.Fatal("removed window captured")
}
}
func TestAqueousSnapshotRejectsInvalidInput(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
for _, input := range [][]byte{
[]byte(`{}`),
bytes.Replace(data, []byte(`"schema": 1`), []byte(`"schema": 2`), 1),
bytes.Replace(data, []byte(`"type": "snapshot"`), []byte(`"type": "delta"`), 1),
append(data, []byte(`{}`)...),
{0xff},
} {
if _, err := parseAqueousSnapshot(input); err == nil {
t.Fatal("invalid snapshot accepted")
}
}
}
+13 -1
View File
@@ -1,6 +1,7 @@
package screenshot
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -21,6 +22,7 @@ const (
CompositorScroll
CompositorMiracle
CompositorMango
CompositorAqueous
)
var detectedCompositor Compositor = -1
@@ -35,6 +37,7 @@ func DetectCompositor() Compositor {
needsStat bool
compositor Compositor
}{
{os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous},
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
@@ -78,8 +81,14 @@ func GetActiveWindow() (*WindowGeometry, error) {
return getHyprlandActiveWindow()
case CompositorMango:
return getMangoActiveWindow()
case CompositorAqueous:
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
return aqueousWindowGeometry(model, "")
default:
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
return nil, fmt.Errorf("window capture requires Hyprland, Mango, niri, or Aqueous")
}
}
@@ -355,6 +364,9 @@ func getNiriFocusedMonitor() string {
func GetFocusedMonitor() string {
switch DetectCompositor() {
case CompositorAqueous:
name, _ := aqueousFocusedOutput("")
return name
case CompositorHyprland:
return getHyprlandFocusedMonitor()
case CompositorSway:
+3 -5
View File
@@ -10,6 +10,8 @@ import (
"os"
"path/filepath"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const niriScreenshotTimeout = 5 * time.Second
@@ -24,11 +26,7 @@ func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
return nil, fmt.Errorf("NIRI_SOCKET not set")
}
dir := os.Getenv("XDG_RUNTIME_DIR")
if dir == "" {
dir = os.TempDir()
}
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
path := filepath.Join(utils.RuntimeDir(), fmt.Sprintf("dms-window-%d.png", os.Getpid()))
events, err := subscribeNiriEvents(socket)
if err != nil {
+7 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_cursor_shape"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
)
@@ -108,6 +109,7 @@ type RegionSelector struct {
seat *client.Seat
pointer *client.Pointer
keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter
@@ -967,7 +969,11 @@ func (r *RegionSelector) renderSurface(os *OutputSurface) {
case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
slot.shm.CopyFrom(srcBuf)
r.dimBackground(slot.shm)
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat)
scale := 1
if os.output != nil {
scale = scaleFactor(os.output.effectiveScale())
}
r.drawHUD(slot.shm.Data(), slot.shm.Stride, slot.shm.Width, slot.shm.Height, os.screenFormat, scale)
slot.backgroundInitialized = true
slot.backgroundSource = srcBuf
slot.backgroundDragging = r.selection.dragging
+47 -38
View File
@@ -3,6 +3,7 @@ package screenshot
import (
"math"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client"
)
@@ -432,52 +433,60 @@ func (r *RegionSelector) setupKeyboardHandlers() {
}
})
r.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
r.keymap = keymap.FromEvent(e)
})
r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
switch e.Key {
case 29, 97: // Ctrl left/right
ctrl := e.State != 0
if ctrl != r.ctrlHeld {
r.ctrlHeld = ctrl
r.refreshCursor()
if r.selection.hasSelection {
for _, os := range r.surfaces {
r.redrawSurface(os)
}
r.handleKey(r.keymap.Keysym(e.Key), e.State)
})
}
func (r *RegionSelector) handleKey(sym string, state uint32) {
held := state != 0
switch sym {
case "Control_L", "Control_R":
if held != r.ctrlHeld {
r.ctrlHeld = held
r.refreshCursor()
if r.selection.hasSelection {
for _, os := range r.surfaces {
r.redrawSurface(os)
}
}
case 56, 100: // Alt left/right
r.altHeld = e.State != 0
}
if e.State != 1 {
return
}
case "Alt_L", "Alt_R":
r.altHeld = held
}
if state != 1 {
return
}
if r.phase == phaseScroll {
switch e.Key {
case 1:
r.cancelled = true
r.running = false
case 28, 96:
r.finishScroll()
}
return
}
switch e.Key {
case 1:
if r.phase == phaseScroll {
switch sym {
case "Escape":
r.cancelled = true
r.running = false
case 25:
r.showCapturedCursor = !r.showCapturedCursor
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case 28, 57, 96:
if r.selection.hasSelection {
r.finishSelection()
}
case "Return", "KP_Enter":
r.finishScroll()
}
})
return
}
switch sym {
case "Escape":
r.cancelled = true
r.running = false
case "p":
r.showCapturedCursor = !r.showCapturedCursor
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case "Return", "space", "KP_Enter":
if r.selection.hasSelection {
r.finishSelection()
}
}
}
func (r *RegionSelector) selectionDeviceRect() (*OutputSurface, int, int, int, int) {
@@ -0,0 +1,48 @@
package screenshot
import "testing"
func TestHandleKeyEscapeCancels(t *testing.T) {
for _, phase := range []selectorPhase{phaseSelect, phaseScroll} {
r := &RegionSelector{running: true, phase: phase}
r.handleKey("Escape", 1)
if !r.cancelled || r.running {
t.Errorf("phase %v: cancelled=%v running=%v after Escape", phase, r.cancelled, r.running)
}
}
}
func TestHandleKeyIgnoresReleaseAndCapsLock(t *testing.T) {
r := &RegionSelector{running: true}
r.handleKey("Escape", 0)
r.handleKey("Caps_Lock", 1)
if r.cancelled || !r.running {
t.Errorf("cancelled=%v running=%v, want untouched", r.cancelled, r.running)
}
}
func TestHandleKeyTogglesCapturedCursor(t *testing.T) {
r := &RegionSelector{running: true, showCapturedCursor: true}
r.handleKey("p", 1)
if r.showCapturedCursor {
t.Error("p did not toggle captured cursor off")
}
r.handleKey("p", 1)
if !r.showCapturedCursor {
t.Error("p did not toggle captured cursor back on")
}
}
func TestHandleKeyTracksModifiers(t *testing.T) {
r := &RegionSelector{running: true}
r.handleKey("Control_R", 1)
r.handleKey("Alt_L", 1)
if !r.ctrlHeld || !r.altHeld {
t.Errorf("ctrl=%v alt=%v after press, want both held", r.ctrlHeld, r.altHeld)
}
r.handleKey("Control_R", 0)
r.handleKey("Alt_L", 0)
if r.ctrlHeld || r.altHeld {
t.Errorf("ctrl=%v alt=%v after release, want both released", r.ctrlHeld, r.altHeld)
}
}
+90 -57
View File
@@ -6,7 +6,21 @@ import (
"slices"
)
var fontGlyphs = map[rune][12]uint8{
const (
fontCharW = 8
fontCharH = 12
fontStep = fontCharW + 1
)
func scaleFactor(scale float64) int {
s := int(math.Round(scale))
if s < 1 {
return 1
}
return s
}
var fontGlyphs = map[rune][fontCharH]uint8{
'0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00},
'1': {0x18, 0x38, 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, 0x00},
'2': {0x3C, 0x66, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x66, 0x7E, 0x00, 0x00},
@@ -593,12 +607,11 @@ func (r *RegionSelector) drawScrollPreview(data []byte, stride, bufW, bufH int,
func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
s := r.scroll
style := LoadOverlayStyle()
const charH = 12
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format)
labelY := s.doneY + (s.btnH-charH)/2
labelY := s.doneY + (s.btnH-fontCharH)/2
r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
style.AccentR, style.AccentG, style.AccentB, 255, format)
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, format)
@@ -617,9 +630,15 @@ func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, form
style.TextR, style.TextG, style.TextB, format)
}
func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH int) {
const charW, charH, padding, itemSpacing = 8, 12, 12, 24
type hudItem struct {
key, desc string
}
func (h hudItem) width(step int) int {
return (len(h.key) + 1 + len(h.desc)) * step
}
func (r *RegionSelector) hudItems() []hudItem {
cursorLabel := "hide"
if !r.showCapturedCursor {
cursorLabel = "show"
@@ -629,16 +648,25 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
captureKey = "Drag+Release"
}
items := []struct{ key, desc string }{
return []hudItem{
{captureKey, "capture"},
{"Ctrl", "resize/move"},
{"P", cursorLabel + " cursor"},
{"Esc", "cancel"},
}
}
func (r *RegionSelector) hudDimensions(bufW, bufH, scale int) (hudX, hudY, hudW, hudH int) {
scale = scaleFactor(float64(scale))
charH := fontCharH * scale
padding, itemSpacing := 12*scale, 24*scale
margin := 20 * scale
items := r.hudItems()
totalW := 0
step := fontStep * scale
for i, item := range items {
totalW += len(item.key)*(charW+1) + (1+len(item.desc))*(charW+1)
totalW += item.width(step)
if i < len(items)-1 {
totalW += itemSpacing
}
@@ -647,46 +675,32 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
hudW = totalW + padding*2
hudH = charH + padding*2
hudX = (bufW - hudW) / 2
hudY = bufH - hudH - 20
hudY = bufH - hudH - margin
return hudX, hudY, hudW, hudH
}
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) {
func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32, scale int) {
if r.selection.dragging {
return
}
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
scale = scaleFactor(float64(scale))
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
style := LoadOverlayStyle()
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
const charW, padding, itemSpacing = 8, 12, 24
cursorLabel := "hide"
if !r.showCapturedCursor {
cursorLabel = "show"
}
captureKey := "Space/Enter"
if r.screenshoter != nil && r.screenshoter.config.NoConfirm {
captureKey = "Drag+Release"
}
items := []struct{ key, desc string }{
{captureKey, "capture"},
{"Ctrl", "resize/move"},
{"P", cursorLabel + " cursor"},
{"Esc", "cancel"},
}
padding, itemSpacing := 12*scale, 24*scale
step := fontStep * scale
items := r.hudItems()
tx, ty := hudX+padding, hudY+padding
for i, item := range items {
r.drawText(data, stride, bufW, bufH, tx, ty, item.key,
style.AccentR, style.AccentG, style.AccentB, format)
tx += len(item.key) * (charW + 1)
r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc,
style.TextR, style.TextG, style.TextB, format)
tx += (1 + len(item.desc)) * (charW + 1)
r.drawTextScaled(data, stride, bufW, bufH, tx, ty, item.key,
style.AccentR, style.AccentG, style.AccentB, format, scale)
r.drawTextScaled(data, stride, bufW, bufH, tx+len(item.key)*step, ty, " "+item.desc,
style.TextR, style.TextG, style.TextB, format, scale)
tx += item.width(step)
if i < len(items)-1 {
tx += itemSpacing
@@ -741,24 +755,29 @@ func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length
}
func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
scale := scaleFactor(b.scaleX)
text := b.labelText
const charW, charH = 8, 12
textW := len(text) * (charW + 1)
charH := fontCharH * scale
step := fontStep * scale
textW := len(text) * step
padX := 4 * scale
padY := 2 * scale
offsetY := 8 * scale
tx := b.x + (b.w-textW)/2
ty := b.y + b.h + 8
ty := b.y + b.h + offsetY
if ty+charH > bufH {
ty = b.y - charH - 8
ty = b.y - charH - offsetY
}
tx = clamp(tx, 0, bufW-textW)
return dirtyRect{x1: tx - 4, y1: ty - 2, x2: tx + textW + 4, y2: ty + charH + 2}, text
return dirtyRect{x1: tx - padX, y1: ty - padY, x2: tx + textW + padX, y2: ty + charH + padY}, text
}
func (r *RegionSelector) drawLabel(data []byte, stride, bufW, bufH int, o *overlay, format uint32) {
scale := scaleFactor(o.scaleX)
l := o.label
r.fillRect(data, stride, bufW, bufH, l.x1, l.y1, l.x2-l.x1, l.y2-l.y1, 0, 0, 0, 200, format)
r.drawText(data, stride, bufW, bufH, l.x1+4, l.y1+2, o.text, 255, 255, 255, format)
r.drawTextScaled(data, stride, bufW, bufH, l.x1+4*scale, l.y1+2*scale, o.text, 255, 255, 255, format, scale)
}
func formatIsBGR(format uint32) bool {
@@ -795,41 +814,55 @@ func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h in
}
func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) {
r.drawTextScaled(data, stride, bufW, bufH, x, y, text, cr, cg, cb, format, 1)
}
func (r *RegionSelector) drawTextScaled(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32, scale int) {
scale = scaleFactor(float64(scale))
step := fontStep * scale
for i, ch := range text {
r.drawChar(data, stride, bufW, bufH, x+i*9, y, ch, cr, cg, cb, format)
r.drawChar(data, stride, bufW, bufH, x+i*step, y, ch, cr, cg, cb, format, scale)
}
}
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32) {
func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32, scale int) {
glyph, ok := fontGlyphs[ch]
if !ok {
return
}
scale = scaleFactor(float64(scale))
c0, c2 := cb, cr
if formatIsBGR(format) {
c0, c2 = cr, cb
}
for row := range 12 {
py := y + row
if py < 0 || py >= bufH {
continue
}
for row := range fontCharH {
bits := glyph[row]
for col := range 8 {
if (bits & (1 << (7 - col))) == 0 {
for dy := range scale {
py := y + row*scale + dy
if py < 0 || py >= bufH {
continue
}
px := x + col
if px < 0 || px >= bufW {
continue
rowOff := py * stride
for col := range fontCharW {
if (bits & (1 << (7 - col))) == 0 {
continue
}
baseX := x + col*scale
for dx := range scale {
px := baseX + dx
if px < 0 || px >= bufW {
continue
}
off := rowOff + px*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
}
}
off := py*stride + px*4
if off+3 >= len(data) {
continue
}
data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255
}
}
}
+13 -11
View File
@@ -238,18 +238,20 @@ func TestHUDDimensionsContainText(t *testing.T) {
},
}
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH)
data := make([]byte, bufH*stride)
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888))
for _, scale := range []int{1, 2} {
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
data := make([]byte, bufH*stride)
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888), scale)
// Assert no lit pixel falls outside the pill boundaries
for y := range bufH {
for x := range bufW {
off := y*stride + x*4
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 {
if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH {
t.Fatalf("noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
// Assert no lit pixel falls outside the pill boundaries
for y := range bufH {
for x := range bufW {
off := y*stride + x*4
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 {
if x < hudX || x >= hudX+hudW || y < hudY || y >= hudY+hudH {
t.Fatalf("scale=%d noConfirm=%v showCursor=%v: pixel (%d, %d) is non-zero outside HUD pill [%d..%d, %d..%d]",
scale, noConfirm, showCursor, x, y, hudX, hudX+hudW, hudY, hudY+hudH)
}
}
}
}
+19
View File
@@ -8,6 +8,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/dankgo/wayland/client"
)
@@ -177,6 +178,14 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
if !s.config.AllowMultiple {
lock, err := utils.LockSelectionOverlay()
if err != nil {
return nil, err
}
defer lock.Release()
}
if s.config.Reset {
if err := SaveLastRegion(Region{}); err != nil {
log.Debug("failed to reset last region", "err", err)
@@ -211,6 +220,9 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
return s.captureAqueousWindow()
}
if DetectCompositor() == CompositorNiri {
if s.config.Geometry {
return nil, fmt.Errorf("window geometry mode is not supported on niri")
@@ -353,6 +365,13 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
}
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
name, err := aqueousFocusedOutput(s.config.Seat)
if err != nil {
return nil, err
}
return s.captureOutput(name)
}
output := s.findFocusedOutput()
if output == nil {
s.outputsMu.Lock()
+1
View File
@@ -0,0 +1 @@
aqueous-snapshot.json contains focused-window state captured from Aqueous 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated session on 2026-09-05. The session token is sanitized.
@@ -0,0 +1 @@
{"schema": 1, "session": "0123456789abcdef0123456789abcdef", "sequence": "5", "base_sequence": null, "type": "snapshot", "upsert": [{"kind": "workspace", "id": "12", "output": "2", "name": "same \"name\"", "number": 3, "active": true, "urgent": false}, {"kind": "workspace", "id": "2", "output": "1", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "session", "id": "session", "locked": false, "default_seat": "default", "overview_output": null, "overview_window": null}, {"kind": "workspace", "id": "11", "output": "2", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "output", "id": "1", "name": "HEADLESS-2", "enabled": true, "powered": true, "bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "1"}, {"kind": "workspace", "id": "18", "output": "2", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "16", "output": "2", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "15", "output": "2", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "10", "output": "2", "name": "1", "number": 1, "active": false, "urgent": false}, {"kind": "workspace", "id": "5", "output": "1", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "9", "output": "1", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "4", "output": "1", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "seat", "id": "default", "output": "1", "window": "ca89b2eb6850b81d12b92763969d82ca", "focus_kind": "window", "keyboard": "1"}, {"kind": "workspace", "id": "3", "output": "1", "name": "3", "number": 3, "active": false, "urgent": false}, {"kind": "workspace", "id": "14", "output": "2", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "7", "output": "1", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "1", "output": "1", "name": "1", "number": 1, "active": true, "urgent": false}, {"kind": "workspace", "id": "6", "output": "1", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "8", "output": "1", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "workspace", "id": "13", "output": "2", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "workspace", "id": "17", "output": "2", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "output", "id": "2", "name": "HEADLESS-1", "enabled": true, "powered": true, "bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "12"}, {"kind": "window", "id": "ca89b2eb6850b81d12b92763969d82ca", "backend": "xdg", "app_id": "aq-shell-test", "class": null, "title": "same title", "workspace": "1", "output": "1", "geometry": {"x": 10, "y": 10, "width": 628, "height": 700}, "outer_geometry": {"x": 8, "y": 8, "width": 632, "height": 704}, "focused": true, "visible": true, "floating": false, "minimized": false, "maximized": false, "fullscreen": false, "skip_taskbar": false, "skip_switcher": false, "always_above": false, "always_below": false, "snapped": false, "fixed_position": false, "layout": "tiled", "can_minimize": false, "can_maximize": false, "can_activate": true}, {"kind": "keyboard", "id": "1", "seat": "default", "layouts": ["English (US)", "German"], "index": 0}, {"kind": "keyboard_device", "id": "1", "name": "wlr_virtual_keyboard_v1", "seat": "default", "group": "1", "virtual": true}], "removed": []}
+17 -15
View File
@@ -56,21 +56,23 @@ type Output struct {
}
type Config struct {
Mode Mode
OutputName string
Cursor CursorMode
NoConfirm bool
Reset bool
Format Format
Quality int
OutputDir string
Filename string
Clipboard bool
SaveFile bool
Notify bool
Stdout bool
Geometry bool
IntervalMs int
Seat string
Mode Mode
OutputName string
Cursor CursorMode
NoConfirm bool
Reset bool
Format Format
Quality int
OutputDir string
Filename string
Clipboard bool
SaveFile bool
Notify bool
Stdout bool
Geometry bool
AllowMultiple bool
IntervalMs int
// SelectorHook runs as the interactive selector starts (true) and ends (false).
SelectorHook func(begin bool)
}
+12 -7
View File
@@ -160,15 +160,20 @@ func (b *DDCBackend) getDDCName(bus int) string {
}
func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err != nil {
for attempt := 0; attempt < 3; attempt++ {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS)
if err == nil {
dev.max = cap.max
dev.lastBrightness = cap.current
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
return
}
if attempt < 2 {
time.Sleep(100 * time.Millisecond)
continue
}
log.Debugf("failed to read initial brightness for %s: %v", dev.name, err)
return
}
dev.max = cap.max
dev.lastBrightness = cap.current
log.Debugf("initialized %s with brightness %d/%d", dev.name, cap.current, cap.max)
}
func (b *DDCBackend) GetDevices() ([]Device, error) {
@@ -1,6 +1,7 @@
package network
import (
"encoding/hex"
"fmt"
"sort"
@@ -21,15 +22,13 @@ const (
var _ HotspotBackend = (*NetworkManagerBackend)(nil)
// ConfigureHotspot validates only DMS-owned constraints: SSID presence, band
// names, device capability, and the not-active guard. SSID byte-length and
// WPA-PSK password policy are deliberately left to NetworkManager, whose
// AddConnection/Update errors are returned to the caller; duplicating its
// evolving rules here would only let them drift.
func (b *NetworkManagerBackend) ConfigureHotspot(req HotspotRequest) error {
if req.SSID == "" {
return fmt.Errorf("hotspot SSID cannot be empty")
}
if err := validateHotspotPassword(req.Password); err != nil {
return err
}
if err := validateHotspotBandName(req.Band); err != nil {
return err
}
@@ -495,6 +494,22 @@ func isAPCapableWiFiDevice(devInfo *wifiDeviceInfo) (bool, error) {
return caps&nmWiFiDeviceCapAP != 0, nil
}
// Mirrors NetworkManager's nm_utils_wpa_psk_valid: byte length, not rune count.
func validateHotspotPassword(password string) error {
if password == "" {
return nil
}
if len(password) == 64 {
if _, err := hex.DecodeString(password); err == nil {
return nil
}
}
if len(password) < 8 || len(password) > 63 {
return fmt.Errorf("hotspot password must be 8 to 63 characters, or a 64-digit hex key")
}
return nil
}
func validateHotspotBandName(band string) error {
if band != "" && band != "bg" && band != "a" {
return fmt.Errorf("unsupported hotspot band: %s", band)
@@ -1,6 +1,7 @@
package network
import (
"strings"
"testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
@@ -916,3 +917,48 @@ func TestGetHotspotSecrets(t *testing.T) {
assert.Error(t, err)
})
}
func TestValidateHotspotPassword(t *testing.T) {
hex64 := strings.Repeat("0123456789abcdef", 4)
cases := []struct {
name string
password string
valid bool
}{
{"open network", "", true},
{"three digits", "123", false},
{"seven chars", "testing", false},
{"eight chars", "testing1", true},
{"sixty three chars", strings.Repeat("a", 63), true},
{"sixty four hex", hex64, true},
{"sixty four hex uppercase", strings.ToUpper(hex64), true},
{"sixty four non-hex", strings.Repeat("g", 64), false},
{"sixty five chars", strings.Repeat("a", 65), false},
{"multibyte counts bytes", "ääää", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateHotspotPassword(tc.password)
if tc.valid {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), "8 to 63 characters")
})
}
}
func TestConfigureHotspotRejectsShortPasswordBeforeTouchingNetworkManager(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
backend, err := NewNetworkManagerBackend(mockNM)
require.NoError(t, err)
backend.settings = mockSettings
err = backend.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot", Password: "123", Device: "wlan0"})
require.Error(t, err)
assert.Contains(t, err.Error(), "8 to 63 characters")
}
@@ -183,6 +183,12 @@ func (b *NetworkManagerBackend) classifyNMStateReason(reason uint32) string {
}
}
// NM raises NO_SECRETS once auth retries run out on a saved profile too, not only
// on a dismissed prompt (nm-device-wifi.c handle_8021x_or_psk_auth_fail, #3313).
func forgetOnConnectFailure(reasonCode string, preExisting bool) bool {
return reasonCode == errdefs.ErrUserCanceled && !preExisting
}
// With several adapters, state must follow the associated device, not whichever
// enumerated first (#2460).
func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) {
@@ -321,7 +327,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
b.state.ConnectingSSID = ""
b.state.LastError = reasonCode
if reasonCode == errdefs.ErrUserCanceled {
if forgetOnConnectFailure(reasonCode, b.state.ConnectingPreExisting) {
forgetSSID = connectingSSID
}
@@ -146,3 +146,23 @@ func TestNetworkManagerBackend_UpdateWiFiState_PicksConnectedDevice(t *testing.T
assert.True(t, backend.state.WiFiConnected)
assert.Equal(t, "wlan1", backend.state.WiFiDevice)
}
func TestForgetOnConnectFailure(t *testing.T) {
tests := []struct {
name string
reasonCode string
preExisting bool
want bool
}{
{"cancelled prompt on freshly created profile", errdefs.ErrUserCanceled, false, true},
{"no-secrets failure on saved profile", errdefs.ErrUserCanceled, true, false},
{"bad credentials on freshly created profile", errdefs.ErrBadCredentials, false, false},
{"generic failure on saved profile", errdefs.ErrConnectionFailed, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, forgetOnConnectFailure(tt.reasonCode, tt.preExisting))
})
}
}
@@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -26,15 +28,8 @@ type wpaCtrlConn struct {
seq int
}
func wpaCtrlLocalSocketBase() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return dir
}
return os.TempDir()
}
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
localDir, err := os.MkdirTemp(wpaCtrlLocalSocketBase(), "dms-wpa-")
localDir, err := os.MkdirTemp(utils.RuntimeDir(), "dms-wpa-")
if err != nil {
return nil, fmt.Errorf("create wpa_ctrl socket dir: %w", err)
}
+2 -2
View File
@@ -33,8 +33,8 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/dankgo/ipc"
"github.com/AvengeMedia/dankgo/paths"
"github.com/AvengeMedia/dankgo/syncmap"
)
@@ -86,7 +86,7 @@ var cupsMu sync.Mutex
var cupsSubscriberCount int
var cupsEverAvailable bool
var appPaths = paths.New("danklinux")
var appPaths = utils.App()
func GetSocketPath() string {
return appPaths.SocketPath()
+69
View File
@@ -1,11 +1,19 @@
package utils
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"unicode/utf8"
)
type AppChecker interface {
@@ -115,3 +123,64 @@ func userBinDirs() []string {
dirs = append(dirs, "/usr/local/bin")
return dirs
}
const maxJSONProcessBytes = 4 * 1024 * 1024
type boundedWriter struct {
bytes.Buffer
limit int
}
func (w *boundedWriter) Write(p []byte) (int, error) {
if len(p) > w.limit-w.Len() {
return 0, errors.New("process output exceeds size bound")
}
return w.Buffer.Write(p)
}
func RunJSON(ctx context.Context, executable string, args []string, input []byte, result any) error {
if len(input) > maxJSONProcessBytes {
return errors.New("request exceeds size bound")
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, executable, args...)
cmd.WaitDelay = time.Second
stdout := &boundedWriter{limit: maxJSONProcessBytes + 1}
stderr := &boundedWriter{limit: 16 * 1024}
cmd.Stdout, cmd.Stderr = stdout, stderr
if input != nil {
cmd.Stdin = bytes.NewReader(input)
}
err := cmd.Run()
if ctx.Err() != nil {
return fmt.Errorf("unavailable: command completion uncertain: %w", ctx.Err())
}
if stdout.Len() != 0 {
if decodeErr := DecodeJSON(bytes.TrimSuffix(stdout.Bytes(), []byte{'\n'}), result); decodeErr != nil {
return errors.Join(err, fmt.Errorf("invalid process JSON: %w", decodeErr))
}
}
if err != nil {
return fmt.Errorf("%s: %w", executable, err)
}
if stdout.Len() == 0 {
return errors.New("missing command acknowledgement")
}
return nil
}
func DecodeJSON(data []byte, target any) error {
if len(data) > maxJSONProcessBytes || !utf8.Valid(data) {
return errors.New("invalid UTF-8 or oversized JSON record")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(new(any)); err != io.EOF {
return errors.New("trailing JSON data")
}
return nil
}
+58
View File
@@ -1,10 +1,13 @@
package utils
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -67,3 +70,58 @@ func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
assert.Contains(t, parts, "/usr/local/bin")
assert.Contains(t, env, "OTHER=value")
}
func TestJSONProcessFixture(t *testing.T) {
mode := os.Getenv("DMS_JSON_PROCESS_FIXTURE")
if mode == "" {
return
}
switch mode {
case "accepted":
fmt.Print(`{"ok":true,"status":"accepted","sequence":"9007199254740993"}`)
case "failure":
fmt.Print(`{"ok":false,"status":"locked","sequence":"1"}`)
os.Exit(1)
case "malformed":
fmt.Print("invalid JSON")
case "timeout":
time.Sleep(time.Minute)
case "oversize":
fmt.Print(strings.Repeat("x", maxJSONProcessBytes+2))
}
os.Exit(0)
}
func TestRunJSONResultsAndTimeout(t *testing.T) {
for _, mode := range []string{"accepted", "failure", "malformed", "oversize", "timeout"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("DMS_JSON_PROCESS_FIXTURE", mode)
limit := 4 * time.Second
if mode == "timeout" {
limit = 100 * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()
var result jsonProcessResult
err := RunJSON(ctx, os.Args[0], []string{"-test.run=^TestJSONProcessFixture$"}, nil, &result)
if mode == "accepted" {
if err != nil || !result.OK || result.Status != "accepted" || result.Sequence != "9007199254740993" {
t.Fatalf("lost acknowledgement: %v %#v", err, result)
}
return
}
if err == nil {
t.Fatal("failure accepted")
}
if mode == "failure" && result.Status != "locked" {
t.Fatal("operation failure lost")
}
})
}
}
type jsonProcessResult struct {
OK bool `json:"ok"`
Status string `json:"status"`
Sequence string `json:"sequence"`
}
+23 -9
View File
@@ -2,6 +2,7 @@ package utils
import (
"fmt"
"os"
"os/exec"
"strings"
)
@@ -10,22 +11,35 @@ func dconfPath(schema, key string) string {
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key
}
// GsettingsGet reads a gsettings value, falling back to dconf read.
// gsettings reaches whichever backend its own GLib links, not necessarily the
// dconf store GTK apps read, and it exits 0 either way.
func dconfSelected() bool {
backend := os.Getenv("GSETTINGS_BACKEND")
return backend == "" || backend == "dconf"
}
// GsettingsGet reads a desktop setting from dconf, falling back to gsettings.
func GsettingsGet(schema, key string) (string, error) {
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil {
return strings.TrimSpace(string(out)), nil
if dconfSelected() {
if out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output(); err == nil {
if value := strings.TrimSpace(string(out)); value != "" {
return value, nil
}
}
}
out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output()
out, err := exec.Command("gsettings", "get", schema, key).Output()
if err != nil {
return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err)
return "", fmt.Errorf("dconf/gsettings get failed for %s %s: %w", schema, key, err)
}
return strings.TrimSpace(string(out)), nil
}
// GsettingsSet writes a gsettings value, falling back to dconf write.
// GsettingsSet writes a desktop setting to dconf, falling back to gsettings.
func GsettingsSet(schema, key, value string) error {
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil {
return nil
if dconfSelected() {
if err := exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run(); err == nil {
return nil
}
}
return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run()
return exec.Command("gsettings", "set", schema, key, value).Run()
}
+117
View File
@@ -0,0 +1,117 @@
package utils
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeTool struct {
name string
stdout string
exit int
}
func fakeTools(t *testing.T, tools ...fakeTool) string {
t.Helper()
binDir := t.TempDir()
logPath := filepath.Join(binDir, "calls.log")
for _, tool := range tools {
script := fmt.Sprintf("#!/bin/sh\necho \"%s $*\" >> %q\nprintf '%%s' %q\nexit %d\n", tool.name, logPath, tool.stdout, tool.exit)
require.NoError(t, os.WriteFile(filepath.Join(binDir, tool.name), []byte(script), 0o755))
}
t.Setenv("PATH", binDir)
return logPath
}
func toolCalls(t *testing.T, logPath string) string {
t.Helper()
data, err := os.ReadFile(logPath)
if os.IsNotExist(err) {
return ""
}
require.NoError(t, err)
return string(data)
}
func TestGsettingsSetWritesDconf(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
logPath := fakeTools(t,
fakeTool{name: "dconf"},
fakeTool{name: "gsettings"},
)
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
calls := toolCalls(t, logPath)
assert.Contains(t, calls, "dconf write /org/gnome/desktop/interface/gtk-theme 'adw-gtk3'")
assert.NotContains(t, calls, "gsettings")
}
func TestGsettingsSetFallsBackWhenDconfFails(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
logPath := fakeTools(t,
fakeTool{name: "dconf", exit: 1},
fakeTool{name: "gsettings"},
)
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
assert.Contains(t, toolCalls(t, logPath), "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3")
}
func TestGsettingsSetHonorsBackendOverride(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "keyfile")
logPath := fakeTools(t,
fakeTool{name: "dconf"},
fakeTool{name: "gsettings"},
)
require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3"))
calls := toolCalls(t, logPath)
assert.Contains(t, calls, "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3")
assert.NotContains(t, calls, "dconf write")
}
func TestGsettingsGetReadsDconf(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
fakeTools(t,
fakeTool{name: "dconf", stdout: "'adw-gtk3-dark'"},
fakeTool{name: "gsettings", stdout: "'Adwaita'"},
)
value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
require.NoError(t, err)
assert.Equal(t, "'adw-gtk3-dark'", value)
}
func TestGsettingsGetFallsBackWhenDconfUnset(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
fakeTools(t,
fakeTool{name: "dconf"},
fakeTool{name: "gsettings", stdout: "'Adwaita'"},
)
value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
require.NoError(t, err)
assert.Equal(t, "'Adwaita'", value)
}
func TestGsettingsGetFailsWhenNoToolWorks(t *testing.T) {
t.Setenv("GSETTINGS_BACKEND", "")
fakeTools(t,
fakeTool{name: "dconf", exit: 1},
fakeTool{name: "gsettings", exit: 1},
)
_, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme")
require.ErrorContains(t, err, "gtk-theme")
}
+105
View File
@@ -0,0 +1,105 @@
package utils
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
const (
// Shared by the screenshot region selector and the color picker: one grabbing overlay at a time.
SelectionOverlayLock = "dms-selection-overlay"
// Matches what the wayland client dials when WAYLAND_DISPLAY is unset.
defaultWaylandDisplay = "wayland-0"
)
var (
ErrLockHeld = errors.New("lock is held by another process")
ErrOverlayActive = errors.New("another dms selection overlay is already open")
)
type FileLock struct {
file *os.File
}
func displayScope() string {
name := filepath.Base(os.Getenv("WAYLAND_DISPLAY"))
switch name {
case ".", "..", string(filepath.Separator):
return defaultWaylandDisplay
}
return name
}
// One user's concurrent sessions share XDG_RUNTIME_DIR, so the lock is per wayland display.
func lockPath(name string) string {
return filepath.Join(RuntimeDir(), fmt.Sprintf("%s-%d-%s.lock", name, os.Getuid(), displayScope()))
}
func lockHolderPID(name string) int {
data, err := os.ReadFile(lockPath(name))
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
return pid
}
func TryLock(name string) (*FileLock, error) {
f, err := os.OpenFile(lockPath(name), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, ErrLockHeld
}
return nil, err
}
if err := f.Truncate(0); err == nil {
_, _ = f.WriteString(strconv.Itoa(os.Getpid()))
}
return &FileLock{file: f}, nil
}
// LockSelectionOverlay only refuses on contention: a lock file that cannot be created or
// flocked yields a nil lock and no error, so the overlay still runs unguarded.
func LockSelectionOverlay() (*FileLock, error) {
lock, err := TryLock(SelectionOverlayLock)
if err == nil {
return lock, nil
}
if !errors.Is(err, ErrLockHeld) {
log.Debug("selection overlay lock unavailable", "err", err)
return nil, nil
}
if pid := lockHolderPID(SelectionOverlayLock); pid > 0 {
return nil, fmt.Errorf("%w (pid %d)", ErrOverlayActive, pid)
}
return nil, ErrOverlayActive
}
func (l *FileLock) Release() {
if l == nil || l.file == nil {
return
}
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
l.file.Close()
l.file = nil
}
+130
View File
@@ -0,0 +1,130 @@
package utils
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTryLockRejectsSecondHolder(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
held, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
_, err = TryLock(SelectionOverlayLock)
assert.ErrorIs(t, err, ErrLockHeld)
held.Release()
reacquired, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
reacquired.Release()
}
func TestTryLockSeparatesNames(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
first, err := TryLock("dms-test-a")
require.NoError(t, err)
defer first.Release()
second, err := TryLock("dms-test-b")
require.NoError(t, err)
second.Release()
}
func TestTryLockSeparatesWaylandDisplays(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
t.Setenv("WAYLAND_DISPLAY", "wayland-1")
first, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
defer first.Release()
t.Setenv("WAYLAND_DISPLAY", "wayland-2")
second, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
second.Release()
}
func TestTryLockWithoutWaylandDisplay(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
t.Setenv("WAYLAND_DISPLAY", "")
os.Unsetenv("WAYLAND_DISPLAY")
unset, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%s-%d-wayland-0.lock", SelectionOverlayLock, os.Getuid()), filepath.Base(lockPath(SelectionOverlayLock)))
t.Setenv("WAYLAND_DISPLAY", "wayland-1")
other, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
other.Release()
os.Unsetenv("WAYLAND_DISPLAY")
_, err = TryLock(SelectionOverlayLock)
assert.ErrorIs(t, err, ErrLockHeld)
unset.Release()
}
func TestDisplayScopeStaysASingleName(t *testing.T) {
cases := []struct{ display, want string }{
{"", "wayland-0"},
{"/", "wayland-0"},
{"..", "wayland-0"},
{"wayland-1", "wayland-1"},
{"/run/user/1000/wayland-2", "wayland-2"},
{"/run/user/1000/wayland-3/", "wayland-3"},
}
for _, tc := range cases {
t.Setenv("WAYLAND_DISPLAY", tc.display)
assert.Equal(t, tc.want, displayScope(), tc.display)
}
}
func TestTryLockExcludesOtherProcesses(t *testing.T) {
if _, err := exec.LookPath("flock"); err != nil {
t.Skip("flock not available")
}
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
held, err := TryLock(SelectionOverlayLock)
require.NoError(t, err)
path := lockPath(SelectionOverlayLock)
assert.Error(t, exec.Command("flock", "-n", "-x", path, "-c", "true").Run())
held.Release()
assert.NoError(t, exec.Command("flock", "-n", "-x", path, "-c", "true").Run())
}
func TestLockSelectionOverlayReportsHolder(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
held, err := LockSelectionOverlay()
require.NoError(t, err)
require.NotNil(t, held)
defer held.Release()
lock, err := LockSelectionOverlay()
assert.Nil(t, lock)
assert.ErrorIs(t, err, ErrOverlayActive)
assert.Contains(t, err.Error(), fmt.Sprintf("pid %d", os.Getpid()))
}
func TestLockSelectionOverlayFallsBackWhenUnavailable(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "missing"))
lock, err := LockSelectionOverlay()
assert.NoError(t, err)
assert.Nil(t, lock)
lock.Release()
}
+6
View File
@@ -8,6 +8,12 @@ import (
"github.com/AvengeMedia/dankgo/paths"
)
var app = paths.New("danklinux")
func App() paths.App { return app }
func RuntimeDir() string { return app.SocketDir() }
func XDGStateHome() string { return paths.XDGStateHome() }
func XDGDataHome() string { return paths.XDGDataHome() }
+155
View File
@@ -0,0 +1,155 @@
package keymap
import (
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix"
)
const FormatXkbV1 = 1
// wl_keyboard.key carries evdev scancodes; xkb keycodes sit 8 above them.
const evdevOffset = 8
var pc105Codes = map[string]uint32{
"Escape": 1,
"p": 25,
"Return": 28,
"Control_L": 29,
"Shift_L": 42,
"v": 47,
"Alt_L": 56,
"space": 57,
"KP_Enter": 96,
"Control_R": 97,
"Alt_R": 100,
}
var pc105Syms = invert(pc105Codes)
// xkbcommon may serialize keysyms as hex instead of names.
var keysymNames = map[uint32]string{
0x20: "space",
0x70: "p",
0x76: "v",
0xff0d: "Return",
0xff1b: "Escape",
0xff8d: "KP_Enter",
0xffe1: "Shift_L",
0xffe3: "Control_L",
0xffe4: "Control_R",
0xffe9: "Alt_L",
0xffea: "Alt_R",
}
var (
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
)
// Keymap maps evdev scancodes to their first-group, first-level keysym names.
// A nil Keymap answers with pc105 positions.
type Keymap struct {
symByCode map[uint32]string
codeBySym map[string]uint32
}
// FromEvent owns and closes the keymap fd; nil means no usable xkb keymap.
func FromEvent(e client.KeyboardKeymapEvent) *Keymap {
defer unix.Close(e.Fd)
if e.Format != FormatXkbV1 {
return nil
}
text, err := Read(e.Fd, e.Size)
if err != nil {
return nil
}
return Parse(text)
}
func Read(fd int, size uint32) (string, error) {
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
if err != nil {
return "", err
}
text := strings.TrimRight(string(data), "\x00")
return text, unix.Munmap(data)
}
func Parse(text string) *Keymap {
xkbCodes := map[string]uint32{}
for _, m := range keycodeDefRe.FindAllStringSubmatch(text, -1) {
if code, err := strconv.Atoi(m[2]); err == nil {
xkbCodes[m[1]] = uint32(code)
}
}
k := &Keymap{symByCode: map[uint32]string{}, codeBySym: map[string]uint32{}}
for _, m := range keySymbolsRe.FindAllStringSubmatch(text, -1) {
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
if group == nil {
continue
}
xkbCode, ok := xkbCodes[m[1]]
if !ok || xkbCode < evdevOffset {
continue
}
sym := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
if sym == "" {
continue
}
code := xkbCode - evdevOffset
k.symByCode[code] = sym
if _, seen := k.codeBySym[sym]; !seen {
k.codeBySym[sym] = code
}
}
return k
}
func (k *Keymap) Keysym(code uint32) string {
if k == nil {
return pc105Syms[code]
}
if sym, ok := k.symByCode[code]; ok {
return sym
}
return pc105Syms[code]
}
func (k *Keymap) Keycode(sym string) uint32 {
if k == nil {
return pc105Codes[sym]
}
if code, ok := k.codeBySym[sym]; ok {
return code
}
return pc105Codes[sym]
}
func canonicalKeysym(sym string) string {
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
return sym
}
value, err := strconv.ParseUint(sym[2:], 16, 32)
if err != nil {
return sym
}
if name, ok := keysymNames[uint32(value)]; ok {
return name
}
return sym
}
func invert(m map[string]uint32) map[uint32]string {
out := make(map[uint32]string, len(m))
for sym, code := range m {
out[code] = sym
}
return out
}
+140
View File
@@ -0,0 +1,140 @@
package keymap
import "testing"
const qwertyKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
minimum = 8;
maximum = 708;
<ESC> = 9;
<AB04> = 55;
<LCTL> = 37;
<LFSH> = 50;
alias <AL01> = <AC01>;
indicator 1 = "Caps Lock";
};
xkb_types "(unnamed)" {
type "ALPHABETIC" {
modifiers = Shift+Lock;
map[Shift] = Level2;
level_name[Level1] = "Base";
};
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Escape ] };
key <AB04> { type= "ALPHABETIC", [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const hexKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB04> = 56;
<LCTL> = 38;
<LFSH> = 51;
};
xkb_symbols "(unnamed)" {
key <LCTL> { [ 0xffe3 ] };
key <LFSH> {
type= "PC_ALT_LEVEL2",
symbols[1]= [ 0xffe1, 0xfe08 ]
};
key <AB04> { [ 0x76, 0x56 ] };
};
};`
const dvorakKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<AB09> = 60;
<LCTL> = 37;
<LFSH> = 50;
};
xkb_symbols "(unnamed)" {
key <AB09> { [ v, V ] };
key <LCTL> { [ Control_L ] };
key <LFSH> { [ Shift_L ] };
};
};`
const swapEscapeKeymap = `xkb_keymap {
xkb_keycodes "(unnamed)" {
<ESC> = 9;
<RTRN> = 36;
<CAPS> = 66;
<KPEN> = 104;
};
xkb_symbols "(unnamed)" {
key <ESC> { [ Caps_Lock ] };
key <RTRN> { [ Return ] };
key <CAPS> {
type= "ONE_LEVEL",
symbols[1]= [ Escape ]
};
key <KPEN> { [ KP_Enter ] };
};
};`
func TestKeycode(t *testing.T) {
tests := []struct {
name string
keymap string
sym string
want uint32
}{
{"qwerty v", qwertyKeymap, "v", 47},
{"qwerty ctrl", qwertyKeymap, "Control_L", 29},
{"qwerty shift", qwertyKeymap, "Shift_L", 42},
{"dvorak v", dvorakKeymap, "v", 52},
{"hex v", hexKeymap, "v", 48},
{"hex ctrl", hexKeymap, "Control_L", 30},
{"hex shift", hexKeymap, "Shift_L", 43},
{"swapescape escape", swapEscapeKeymap, "Escape", 58},
{"missing falls back to pc105", dvorakKeymap, "Escape", 1},
{"empty falls back to pc105", "", "v", 47},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Parse(tt.keymap).Keycode(tt.sym); got != tt.want {
t.Errorf("Keycode(%q) = %d, want %d", tt.sym, got, tt.want)
}
})
}
}
func TestKeysym(t *testing.T) {
tests := []struct {
name string
keymap string
code uint32
want string
}{
{"swapescape caps yields Escape", swapEscapeKeymap, 58, "Escape"},
{"swapescape esc yields Caps_Lock", swapEscapeKeymap, 1, "Caps_Lock"},
{"swapescape return", swapEscapeKeymap, 28, "Return"},
{"swapescape keypad enter", swapEscapeKeymap, 96, "KP_Enter"},
{"qwerty escape", qwertyKeymap, 1, "Escape"},
{"hex ctrl", hexKeymap, 30, "Control_L"},
{"unmapped code falls back to pc105", qwertyKeymap, 28, "Return"},
{"unknown code is empty", qwertyKeymap, 200, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Parse(tt.keymap).Keysym(tt.code); got != tt.want {
t.Errorf("Keysym(%d) = %q, want %q", tt.code, got, tt.want)
}
})
}
}
func TestNilKeymapUsesPc105(t *testing.T) {
var k *Keymap
if got := k.Keysym(1); got != "Escape" {
t.Errorf("Keysym(1) = %q, want Escape", got)
}
if got := k.Keycode("Control_L"); got != 29 {
t.Errorf("Keycode(Control_L) = %d, want 29", got)
}
}
+1 -1
View File
@@ -1 +1 @@
Marble Tabby
Heisenberg
+107
View File
@@ -0,0 +1,107 @@
function socketPath(path, runtime) {
if (!runtime.startsWith("/") || runtime.endsWith("/") || !path.startsWith(runtime + "/aqueous/"))
return false;
if (/[\r\n\u0000]/.test(path) || path.includes("//") || path.split("/").some(part => part === "." || part === ".."))
return false;
return /^\/aqueous\/[^/]+\/ipc\.sock$/.test(path.slice(runtime.length));
}
function byteLength(text) {
return encodeURIComponent(text).replace(/%[0-9A-F]{2}/g, "x").length;
}
function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function decimal(value) {
return typeof value === "string" && /^[0-9]{1,20}$/.test(value);
}
function nextId(value) {
const digits = value.split("");
for (let i = digits.length - 1; i >= 0; i--) {
if (digits[i] !== "9") {
digits[i] = String(Number(digits[i]) + 1);
return digits.join("");
}
digits[i] = "0";
}
if (digits.length === 20)
throw new Error("IPC request IDs exhausted");
return "1" + digits.join("");
}
function hello(value) {
if (!object(value) || value.schema !== 1 || typeof value.session !== "string" || !/^[0-9a-f]{32}$/.test(value.session) || value.max_pending_requests !== 1)
throw new Error("unsupported: Aqueous IPC hello");
const limits = {max_request_bytes: 65536, max_frame_bytes: 4259840, max_batch_bytes: 4194304};
for (const key of Object.keys(limits)) {
if (!Number.isInteger(value[key]) || value[key] <= 0 || value[key] > limits[key])
throw new Error("unsupported: IPC " + key);
}
if (!object(value.capabilities))
throw new Error("unsupported: IPC capabilities");
for (const name of ["state", "commands", "keyboard", "overview", "shortcut_inhibition"]) {
if (typeof value.capabilities[name] !== "boolean")
throw new Error("unsupported: missing capability " + name);
}
if (!value.capabilities.state)
throw new Error("unsupported: shell state");
return value;
}
function envelope(line, limit) {
if (byteLength(line) > limit)
throw new Error("IPC frame exceeds size bound");
let depth = 0, quoted = false, escaped = false;
for (const c of line) {
if (quoted) {
if (escaped) {
escaped = false;
continue;
}
switch (c) {
case "\\":
escaped = true;
break;
case '"':
quoted = false;
break;
}
continue;
}
switch (c) {
case '"':
quoted = true;
break;
case "{":
case "[":
depth++;
break;
case "}":
case "]":
depth--;
break;
}
if (depth > 32)
throw new Error("IPC nesting exceeds limit");
}
const value = JSON.parse(line);
if (!object(value) || value.ipc !== 1)
throw new Error("unsupported: IPC envelope");
return value;
}
function response(value, id) {
if (value.id !== id || !decimal(value.id) || typeof value.ok !== "boolean" || value.event !== undefined)
throw new Error("invalid IPC response correlation");
if (value.ok) {
if (!object(value.result) || value.error !== undefined)
throw new Error("invalid IPC result");
return "";
}
if (!object(value.error) || typeof value.error.code !== "string" || !value.error.code || value.error.code.length > 128 || typeof value.error.message !== "string" || byteLength(value.error.message) > 4096 || value.result !== undefined)
throw new Error("malformed IPC error");
return value.error.code + ": " + value.error.message;
}
+68
View File
@@ -0,0 +1,68 @@
.pragma library
// A calendar date with no time and no timezone.
//
// Arithmetic runs through Date.UTC, which has no DST gaps, so day and month
// rollover is exact. Instances are immutable: every operation returns a new one.
// month is 0-based, matching Javascripts' Date behavior.
class DateOnly {
constructor(year, month, day) {
this.year = year;
this.month = month;
this.day = day;
}
addDays(days) {
return _fromUtc(new Date(Date.UTC(this.year, this.month, this.day + days)));
}
addMonths(months) {
const target = new Date(Date.UTC(this.year, this.month + months, 1));
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
return new DateOnly(target.getUTCFullYear(), target.getUTCMonth(), Math.min(this.day, lastDay));
}
// 0 = Sunday, matching Date.getDay().
dayOfWeek() {
return new Date(Date.UTC(this.year, this.month, this.day)).getUTCDay();
}
daysUntil(other) {
const from = Date.UTC(this.year, this.month, this.day);
const to = Date.UTC(other.year, other.month, other.day);
return Math.round((to - from) / 86400000);
}
equals(other) {
if (!other)
return false;
return this.year === other.year && this.month === other.month && this.day === other.day;
}
// Local noon, so consumers that read local calendar fields land on this date
// even on days where local midnight does not exist.
toDate() {
return new Date(this.year, this.month, this.day, 12);
}
}
function _fromUtc(date) {
return new DateOnly(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
}
function of(year, month, day) {
return new DateOnly(year, month, day);
}
function fromDate(date) {
return new DateOnly(date.getFullYear(), date.getMonth(), date.getDate());
}
function firstOfMonth(year, month) {
return new DateOnly(year, month, 1);
}
function lastOfMonth(year, month) {
return _fromUtc(new Date(Date.UTC(year, month + 1, 0)));
}
+19
View File
@@ -0,0 +1,19 @@
.pragma library
function path(schema, key) {
return "/" + schema.replace(/\./g, "/") + "/" + key;
}
// gsettings reaches whichever backend its own GLib links, not necessarily the
// dconf store GTK apps read, and it exits 0 either way.
function backendIsDconf() {
return "[ \"${GSETTINGS_BACKEND:-dconf}\" = dconf ]";
}
function getCmd(schema, key) {
return "{ { " + backendIsDconf() + " && dconf read " + path(schema, key) + " 2>/dev/null | grep .; } || gsettings get " + schema + " " + key + " 2>/dev/null; } | tr -d \"'\"";
}
function setCmd(schema, key, value) {
return "{ " + backendIsDconf() + " && dconf write " + path(schema, key) + " \"'" + value + "'\" 2>/dev/null; } || gsettings set " + schema + " " + key + " '" + value + "'";
}
+147 -115
View File
@@ -8,6 +8,7 @@ import Quickshell.Io
import qs.Common
import qs.Common.settings
import qs.Services
import "GSettings.js" as GSettings
import "settings/SettingsSpec.js" as Spec
import "settings/SettingsStore.js" as Store
@@ -15,7 +16,7 @@ Singleton {
id: root
readonly property var log: Log.scoped("SettingsData")
readonly property int settingsConfigVersion: 17
readonly property int settingsConfigVersion: 18
readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true"
@@ -987,37 +988,78 @@ Singleton {
property var notificationRules: []
property bool notificationDndAllowCritical: true
property bool notificationFocusedMonitor: false
// Names the bar config rendered as Dank Island instead of a DankBar. Empty = no island.
property string dankIslandBarId: ""
readonly property var islandBarConfig: dankIslandBarId ? (barConfigs.find(cfg => cfg.id === dankIslandBarId) || null) : null
readonly property bool dankIslandEnabled: !!islandBarConfig && (islandBarConfig.enabled ?? false)
readonly property int dankIslandPosition: islandBarConfig?.position ?? SettingsData.Position.Top
readonly property string dankIslandEdge: dankIslandPosition === SettingsData.Position.Bottom ? "bottom" : "top"
readonly property int dankIslandReservedStripHeight: {
const reserve = Math.max(24, Math.min(128, dankIslandReserveHeight));
const compact = Math.max(24, Math.min(72, dankIslandCompactHeight));
const gap = Math.max(0, Math.min(48, dankIslandOuterGap));
// Island is a per-instance render mode: any bar config with island:true draws as an island
// instead of a DankBar, and carries its own island* look settings.
readonly property var islandBarConfigs: {
barConfigs;
return (barConfigs || []).filter(cfg => cfg && cfg.island === true);
}
readonly property bool dankIslandEnabled: islandBarConfigs.some(cfg => cfg.enabled ?? false)
readonly property var islandDefaults: ({
"islandFloating": false,
"islandUseOverlayLayer": false,
"islandReserveThickness": 40,
"islandCompactThickness": 38,
"islandOuterGap": 4,
"islandAlongOffset": 0,
"islandInteractionMode": "hybrid",
"islandHoverOpenDelay": 150,
"islandHoverCloseDelay": 150,
"islandPalette": "default",
"islandTransparency": 1,
"islandCornerRadius": 34,
"islandHighContrast": false,
"islandMediaClockVisible": true,
"islandNotificationBadgeClearOnOpen": false,
"islandNotificationExpand": false,
"islandHomeCompactTight": false,
"islandHomeClockDisplay": "both",
"islandHomeVolumeDisplay": "both",
"islandHomeBrightnessDisplay": "both",
"islandBatteryStyle": "solid",
"islandSatellitesEnabled": true,
"islandSatellitePosition": "edges",
"islandSatelliteGap": 12,
"islandSatelliteBackground": false,
"islandSatelliteGothCorners": true,
"islandSatelliteTransparency": 1,
"islandSatelliteSwoopRadius": 24,
"islandReducedMotion": false,
"islandSpringStiffness": 560,
"islandSpringDamping": 37,
"islandSpringMass": 1
})
function islandSetting(bc, key) {
const value = bc?.[key];
return value === undefined || value === null ? islandDefaults[key] : value;
}
function islandLevelDisplay(bc, key) {
const value = islandSetting(bc, key);
return value === "icon" || value === "percentage" || value === "both" ? value : "both";
}
function islandClockDisplay(bc) {
const value = islandSetting(bc, "islandHomeClockDisplay");
return value === "time" || value === "date" || value === "both" ? value : "both";
}
function islandEdge(bc) {
return positionToSide(bc?.position ?? SettingsData.Position.Top) || "top";
}
function islandVertical(bc) {
const edge = islandEdge(bc);
return edge === "left" || edge === "right";
}
function islandStripThickness(bc) {
const reserve = Math.max(24, Math.min(128, islandSetting(bc, "islandReserveThickness")));
const compact = Math.max(24, Math.min(72, islandSetting(bc, "islandCompactThickness")));
const gap = Math.max(0, Math.min(48, islandSetting(bc, "islandOuterGap")));
return Math.max(reserve, gap + compact);
}
property bool dankIslandFloating: false
property bool dankIslandUseOverlayLayer: false
property int dankIslandReserveHeight: 40
property int dankIslandCompactHeight: 38
property int dankIslandOuterGap: 4
property int dankIslandHorizontalOffset: 0
property string dankIslandInteractionMode: "hybrid"
property int dankIslandHoverOpenDelay: 150
property int dankIslandHoverCloseDelay: 150
property string dankIslandPalette: "default"
property real dankIslandTransparency: 1
property int dankIslandCornerRadius: 34
property bool dankIslandHighContrast: false
property bool dankIslandMediaClockVisible: true
property bool dankIslandNotificationBadgeClearOnOpen: false
property bool dankIslandNotificationExpand: false
property bool dankIslandHomeCompactTight: false
property string dankIslandHomeVolumeDisplay: "both"
property string dankIslandHomeBrightnessDisplay: "both"
readonly property var _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications"]
readonly property var _islandHomeLayoutDefault: [
{
@@ -1049,10 +1091,8 @@ Singleton {
"enabled": true
}
]
property var dankIslandHomeLayout: _islandHomeLayoutDefault
function getIslandHomeLayout() {
const stored = Array.isArray(dankIslandHomeLayout) ? dankIslandHomeLayout : [];
function getIslandHomeLayout(bc) {
const stored = Array.isArray(bc?.islandHomeLayout) ? bc.islandHomeLayout : [];
const result = [];
const seen = {};
for (const entry of stored) {
@@ -1075,41 +1115,33 @@ Singleton {
return result;
}
function islandHomeGroupEnabled(id) {
const entry = getIslandHomeLayout().find(g => g.id === id);
function islandHomeGroupEnabled(bc, id) {
const entry = getIslandHomeLayout(bc).find(g => g.id === id);
return entry ? entry.enabled : false;
}
function setIslandHomeLayoutOrder(ids) {
const current = getIslandHomeLayout();
function setIslandHomeLayoutOrder(barId, ids) {
const current = getIslandHomeLayout(getBarConfig(barId));
const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g);
for (const entry of current) {
if (ids.indexOf(entry.id) < 0)
ordered.push(entry);
}
set("dankIslandHomeLayout", ordered);
updateBarConfig(barId, {
islandHomeLayout: ordered
});
}
function setIslandHomeGroupEnabled(id, on) {
function setIslandHomeGroupEnabled(barId, id, on) {
if (id === "clock")
return;
set("dankIslandHomeLayout", getIslandHomeLayout().map(g => g.id === id ? {
"id": g.id,
"enabled": on
} : g));
updateBarConfig(barId, {
islandHomeLayout: getIslandHomeLayout(getBarConfig(barId)).map(g => g.id === id ? {
"id": g.id,
"enabled": on
} : g)
});
}
property string dankIslandBatteryStyle: "solid"
property bool dankIslandSatellitesEnabled: true
property string dankIslandSatellitePosition: "edges"
property int dankIslandSatelliteGap: 12
property bool dankIslandSatelliteBackground: false
property bool dankIslandSatelliteGothCorners: true
property real dankIslandSatelliteTransparency: 1
property int dankIslandSatelliteSwoopRadius: 24
property bool dankIslandReducedMotion: false
property real dankIslandSpringStiffness: 560
property real dankIslandSpringDamping: 37
property real dankIslandSpringMass: 1
property bool osdAlwaysShowValue: false
property int osdPosition: SettingsData.Position.BottomCenter
@@ -1523,11 +1555,7 @@ Singleton {
return;
if (!SessionData.lastAppliedIconTheme)
return;
const script = `if command -v gsettings >/dev/null 2>&1; then
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
elif command -v dconf >/dev/null 2>&1; then
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
fi`;
const script = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => {
const platform = (output || "").trim();
@@ -1565,11 +1593,7 @@ Singleton {
const resolved = resolveIconTheme();
let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved;
if (!cosmicThemeName || cosmicThemeName === "System Default") {
const detectScript = `if command -v gsettings >/dev/null 2>&1; then
gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g"
elif command -v dconf >/dev/null 2>&1; then
dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g"
fi`;
const detectScript = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
if (exitCode !== 0)
@@ -1631,11 +1655,7 @@ Singleton {
fi
done
if command -v gsettings >/dev/null 2>&1; then
gsettings set org.gnome.desktop.interface icon-theme '${gtkThemeName}' 2>/dev/null || true
elif command -v dconf >/dev/null 2>&1; then
dconf write /org/gnome/desktop/interface/icon-theme "'${gtkThemeName}'" 2>/dev/null || true
fi
${GSettings.setCmd("org.gnome.desktop.interface", "icon-theme", gtkThemeName)} || true
pkill -HUP -f 'gtk' 2>/dev/null || true`;
@@ -1738,13 +1758,21 @@ Singleton {
})
function set(key, value) {
if (key === "dankIslandInteractionMode" && value !== "click" && value !== "hybrid")
value = "hybrid";
Spec.set(root, key, value, saveSettings, _hooks);
if (key === "dankIslandBarId" && value && frameEnabled)
set("frameEnabled", false);
if (key === "frameEnabled" && value && dankIslandBarId)
set("dankIslandBarId", "");
if (key === "frameEnabled" && value)
clearIslandBars();
}
// Frame mode hosts bars inside the frame surface, which has nowhere to put an island.
function clearIslandBars() {
const islands = islandBarConfigs;
if (islands.length === 0)
return;
const configs = JSON.parse(JSON.stringify(barConfigs));
for (const cfg of configs)
delete cfg.island;
barConfigs = configs;
updateBarConfigs();
}
function loadSettings() {
@@ -1799,8 +1827,8 @@ Singleton {
Store.parse(root, obj);
// set() enforces this pair, but a hand-edited settings.json bypasses set() entirely.
if (frameEnabled && dankIslandBarId)
dankIslandBarId = "";
if (frameEnabled && islandBarConfigs.length > 0)
clearIslandBars();
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
frameMode = "connected";
@@ -2131,7 +2159,7 @@ Singleton {
const pathsArg = iconPaths.join(" ");
const script = `
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" || echo '')"
echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "icon-theme")})"
for dir in ${pathsArg}; do
[ -d "$dir" ] || continue
for theme in "$dir"/*/; do
@@ -2170,7 +2198,7 @@ Singleton {
const pathsArg = cursorPaths.join(" ");
const script = `
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface cursor-theme 2>/dev/null | sed "s/'//g" || echo '')"
echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "cursor-theme")})"
for dir in ${pathsArg}; do
[ -d "$dir" ] || continue
for theme in "$dir"/*/; do
@@ -2329,12 +2357,10 @@ Singleton {
// The island is not a bar, but it is chrome: popouts still have to clear its strip.
if (!isIslandBarConfig(barConfig)) {
const islandTop = dankIslandEdgeOffset(screen, "top");
const islandBottom = dankIslandEdgeOffset(screen, "bottom");
if (islandTop > 0)
topBar = Math.max(topBar, islandTop);
if (islandBottom > 0)
bottomBar = Math.max(bottomBar, islandBottom);
topBar = Math.max(topBar, dankIslandEdgeOffset(screen, "top"));
bottomBar = Math.max(bottomBar, dankIslandEdgeOffset(screen, "bottom"));
leftBar = Math.max(leftBar, dankIslandEdgeOffset(screen, "left"));
rightBar = Math.max(rightBar, dankIslandEdgeOffset(screen, "right"));
}
return {
@@ -2470,21 +2496,20 @@ Singleton {
return barConfigs.find(cfg => cfg.id === barId) || null;
}
function setIslandBarId(barId) {
function setBarIsland(barId, on) {
const config = getBarConfig(barId);
if (!config)
if (!config || (config.island === true) === (on === true))
return;
set("dankIslandBarId", barId);
const updates = {};
if (!config.enabled)
updates.enabled = true;
if ((config.screenPreferences ?? []).length === 0)
updates.screenPreferences = ["all"];
const position = config.position ?? SettingsData.Position.Top;
if (position !== SettingsData.Position.Top && position !== SettingsData.Position.Bottom)
updates.position = SettingsData.Position.Top;
if (Object.keys(updates).length > 0)
updateBarConfig(barId, updates);
const updates = {
island: on === true
};
if (on === true) {
if (!config.enabled)
updates.enabled = true;
if ((config.screenPreferences ?? []).length === 0)
updates.screenPreferences = ["all"];
}
updateBarConfig(barId, updates);
}
function isBarIpcRevealed(barId) {
@@ -2494,7 +2519,7 @@ Singleton {
}
function setBarIpcReveal(barId, revealed) {
if (!barId || barId === dankIslandBarId)
if (!barId || isIslandBarConfig(getBarConfig(barId)))
return;
const nextRevealed = !!revealed;
if (!!barIpcRevealStates[barId] === nextRevealed)
@@ -2528,8 +2553,6 @@ Singleton {
const index = configs.findIndex(cfg => cfg.id === barId);
if (index === -1)
return;
if (isIslandBarConfig(configs[index]) && updates.position !== undefined && updates.position !== SettingsData.Position.Top && updates.position !== SettingsData.Position.Bottom)
delete updates.position;
const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false);
@@ -2548,8 +2571,6 @@ Singleton {
return;
const configs = barConfigs.filter(cfg => cfg.id !== barId);
barConfigs = configs;
if (dankIslandBarId === barId)
dankIslandBarId = "";
if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
delete nextBackups[barId];
@@ -2559,7 +2580,7 @@ Singleton {
updateBarConfigs();
}
// Bar-kind instances only. The island reserves its own edge via dankIslandOwnsEdge.
// Bar-kind instances only. Islands reserve their own edges via dankIslandOwnsEdge.
function getEnabledBarConfigs() {
return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg));
}
@@ -2728,34 +2749,45 @@ Singleton {
}
function isIslandBarConfig(bc) {
return !!bc && !!dankIslandBarId && bc.id === dankIslandBarId;
return !!bc && bc.island === true;
}
function getIslandScreens() {
const cfg = islandBarConfig;
if (!cfg || !(cfg.enabled ?? false))
function activeIslandConfigsForScreen(screen) {
if (!screen)
return [];
return Quickshell.screens.filter(screen => barConfigCoversScreen(cfg, screen));
return islandBarConfigs.filter(cfg => (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen));
}
// Only the first island claiming an edge renders there; the rest would stack on top of it.
function islandConfigForEdge(screen, edge) {
return activeIslandConfigsForScreen(screen).find(cfg => islandEdge(cfg) === edge) ?? null;
}
function dankIslandCoversScreen(screen) {
const cfg = islandBarConfig;
return !!screen && !!cfg && (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen);
return activeIslandConfigsForScreen(screen).length > 0;
}
function dankIslandOwnsEdge(screen, edge) {
return dankIslandCoversScreen(screen) && dankIslandEdge === edge;
return islandConfigForEdge(screen, edge) !== null;
}
// Painted strip thickness on `edge`, including when floating drops the exclusive zone.
function dankIslandEdgeOffset(screen, edge) {
return dankIslandOwnsEdge(screen, edge) ? dankIslandReservedStripHeight : 0;
const cfg = islandConfigForEdge(screen, edge);
return cfg ? islandStripThickness(cfg) : 0;
}
function dankIslandIsSoleBarForScreen(screen) {
return dankIslandCoversScreen(screen) && getActiveBarEdgesForScreen(screen).length === 0;
}
// Edges an island already holds on every screen this config covers, so a second island
// instance cannot be dropped on top of it.
function islandEdgeTakenFor(bc, screen, edge) {
const owner = islandConfigForEdge(screen, edge);
return !!owner && owner.id !== bc?.id;
}
function getActiveBarEdgesForScreen(screen) {
return ["top", "bottom", "left", "right"].filter(edge => getActiveBarConfigsForEdge(screen, edge).length > 0);
}
+11 -70
View File
@@ -10,6 +10,7 @@ import qs.DankCommon.Common as DankCommon
import qs.Services
import qs.Modules.Greetd
import "StockThemes.js" as StockThemes
import "GSettings.js" as GSettings
Singleton {
id: root
@@ -144,10 +145,6 @@ Singleton {
Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", stateDir]);
// shellDir may be an embedded-UI extraction, which is read-only and
// unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444)
if (typeof SessionData === "undefined" || !SessionData.isGreeterMode)
Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]);
Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
@@ -297,32 +294,7 @@ Singleton {
if (currentTheme === "custom") {
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
} else if (currentTheme === dynamic) {
return {
"primary": getMatugenColor("primary", "#42a5f5"),
"primaryText": getMatugenColor("on_primary", "#ffffff"),
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
"secondary": getMatugenColor("secondary", "#8ab4f8"),
"secondaryContainer": getMatugenColor("secondary_container", getMatugenColor("surface_container_high", "#292b2f")),
"tertiary": getMatugenColor("tertiary", "#efb8c8"),
"tertiaryContainer": getMatugenColor("tertiary_container", getMatugenColor("surface_container_high", "#292b2f")),
"surface": getMatugenColor("surface", "#1a1c1e"),
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
"surfaceVariantText": getMatugenColor("on_surface_variant", "#c4c7c5"),
"surfaceTint": getMatugenColor("surface_tint", "#8ab4f8"),
"background": getMatugenColor("background", "#1a1c1e"),
"backgroundText": getMatugenColor("on_background", "#e3e8ef"),
"outline": getMatugenColor("outline", "#8e918f"),
"surfaceContainerLowest": getMatugenColor("surface_container_lowest", "#0e1013"),
"surfaceContainerLow": getMatugenColor("surface_container_low", "#181a1d"),
"surfaceContainer": getMatugenColor("surface_container", "#1e2023"),
"surfaceContainerHigh": getMatugenColor("surface_container_high", "#292b2f"),
"surfaceContainerHighest": getMatugenColor("surface_container_highest", "#343740"),
"error": "#F2B8B5",
"warning": "#FF9800",
"info": "#2196F3",
"success": "#4CAF50"
};
return buildExtractedDynamicMode(isLightMode ? "light" : "dark", "Dynamic");
} else {
return StockThemes.getThemeByName(currentTheme, isLightMode);
}
@@ -1525,30 +1497,10 @@ Singleton {
const dankBarScale = fontScale !== undefined ? fontScale : 1.0;
const maxScale = (maximizeText ?? false) ? 1.5 : 1.0;
if (scale <= 0.75)
return evenLineBoxSize(Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale));
return Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale);
if (scale >= 1.25)
return evenLineBoxSize(Math.round(fontSizeMedium * dankBarScale * maxScale));
return evenLineBoxSize(Math.round(fontSizeSmall * dankBarScale * maxScale));
}
FontMetrics {
id: lineBoxProbe
}
// Text.implicitHeight is ceil(FontMetrics.height); probe.font is written whole and read via boundingRect() so bindings capture no dependency on the probe
function lineBoxHeight(pixelSize) {
lineBoxProbe.font = Qt.font({
family: fontFamily,
weight: fontWeight,
pixelSize: pixelSize
});
return Math.ceil(lineBoxProbe.boundingRect("0").height);
}
function evenLineBoxSize(pixelSize) {
if (lineBoxHeight(pixelSize) % 2 === 0)
return pixelSize;
return lineBoxHeight(pixelSize + 1) % 2 === 0 ? pixelSize + 1 : pixelSize;
return Math.round(fontSizeMedium * dankBarScale * maxScale);
return Math.round(fontSizeSmall * dankBarScale * maxScale);
}
function getBatteryIcon(level, isCharging, batteryAvailable) {
@@ -1918,30 +1870,19 @@ Singleton {
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
const schema = "org.gnome.desktop.interface";
const key = "gtk-theme";
const reset = GSettings.setCmd(schema, key, "");
const apply = GSettings.setCmd(schema, key, theme);
const makeCmd = (tool, schema, val) => {
if (tool === "gsettings") {
return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`;
} else {
const dconfPath = `/${schema.replace(/\./g, "/")}`;
return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`;
}
};
Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => {
Proc.runCommand("gtkRefresher", ["sh", "-c", `${reset}; ${apply}`], (output, exitCode) => {
if (exitCode !== 0) {
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => {
if (exitCode !== 0) {
log.warn("Failed to refresh gtk-theme");
}
});
log.warn("Failed to refresh gtk-theme");
}
});
}
function patchGtk3colors() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode);
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight, shellDir], (output, exitCode) => {
Proc.runCommand("gtk3Patcher", ["bash", shellDir + "/scripts/gtk.sh", configDir, "patch", isLight], (output, exitCode) => {
switch (exitCode) {
case 0:
refreshGtkTheme();
@@ -1963,7 +1904,7 @@ Singleton {
}
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false";
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight, shellDir], (output, exitCode) => {
Proc.runCommand("gtkApplier", ["bash", shellDir + "/scripts/gtk.sh", configDir, "apply", isLight], (output, exitCode) => {
if (exitCode === 0) {
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
@@ -534,39 +534,6 @@ var SPEC = {
notificationDndAllowCritical: { def: true },
notificationFocusedMonitor: { def: false },
dankIslandBarId: { def: "", onChange: "updateBarConfigs" },
dankIslandFloating: { def: false },
dankIslandUseOverlayLayer: { def: false },
dankIslandReserveHeight: { def: 40 },
dankIslandCompactHeight: { def: 38 },
dankIslandOuterGap: { def: 4 },
dankIslandHorizontalOffset: { def: 0 },
dankIslandInteractionMode: { def: "hybrid" },
dankIslandHoverOpenDelay: { def: 150 },
dankIslandHoverCloseDelay: { def: 150 },
dankIslandPalette: { def: "default" },
dankIslandTransparency: { def: 1 },
dankIslandCornerRadius: { def: 34 },
dankIslandHighContrast: { def: false },
dankIslandMediaClockVisible: { def: true },
dankIslandNotificationBadgeClearOnOpen: { def: false },
dankIslandNotificationExpand: { def: false },
dankIslandHomeCompactTight: { def: false },
dankIslandHomeVolumeDisplay: { def: "both" },
dankIslandHomeBrightnessDisplay: { def: "both" },
dankIslandHomeLayout: { def: [{ id: "media", enabled: true }, { id: "clock", enabled: true }, { id: "weather", enabled: false }, { id: "status", enabled: false }, { id: "volume", enabled: false }, { id: "brightness", enabled: false }, { id: "notifications", enabled: true }] },
dankIslandBatteryStyle: { def: "solid" },
dankIslandSatellitesEnabled: { def: true },
dankIslandSatellitePosition: { def: "edges" },
dankIslandSatelliteGap: { def: 12 },
dankIslandSatelliteBackground: { def: false },
dankIslandSatelliteGothCorners: { def: true },
dankIslandSatelliteTransparency: { def: 1 },
dankIslandSatelliteSwoopRadius: { def: 24 },
dankIslandReducedMotion: { def: false },
dankIslandSpringStiffness: { def: 560 },
dankIslandSpringDamping: { def: 37 },
dankIslandSpringMass: { def: 1 },
osdAlwaysShowValue: { def: false },
osdPosition: { def: 5 },
@@ -14,6 +14,43 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo
var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"];
// v18: the shell-wide island settings became per-bar-config island* keys
var ISLAND_KEY_MOVES = {
dankIslandFloating: "islandFloating",
dankIslandUseOverlayLayer: "islandUseOverlayLayer",
dankIslandReserveHeight: "islandReserveThickness",
dankIslandCompactHeight: "islandCompactThickness",
dankIslandOuterGap: "islandOuterGap",
dankIslandHorizontalOffset: "islandAlongOffset",
dankIslandInteractionMode: "islandInteractionMode",
dankIslandHoverOpenDelay: "islandHoverOpenDelay",
dankIslandHoverCloseDelay: "islandHoverCloseDelay",
dankIslandPalette: "islandPalette",
dankIslandTransparency: "islandTransparency",
dankIslandCornerRadius: "islandCornerRadius",
dankIslandHighContrast: "islandHighContrast",
dankIslandMediaClockVisible: "islandMediaClockVisible",
dankIslandNotificationBadgeClearOnOpen: "islandNotificationBadgeClearOnOpen",
dankIslandNotificationExpand: "islandNotificationExpand",
dankIslandHomeCompactTight: "islandHomeCompactTight",
dankIslandHomeClockDisplay: "islandHomeClockDisplay",
dankIslandHomeVolumeDisplay: "islandHomeVolumeDisplay",
dankIslandHomeBrightnessDisplay: "islandHomeBrightnessDisplay",
dankIslandHomeLayout: "islandHomeLayout",
dankIslandBatteryStyle: "islandBatteryStyle",
dankIslandSatellitesEnabled: "islandSatellitesEnabled",
dankIslandSatellitePosition: "islandSatellitePosition",
dankIslandSatelliteGap: "islandSatelliteGap",
dankIslandSatelliteBackground: "islandSatelliteBackground",
dankIslandSatelliteGothCorners: "islandSatelliteGothCorners",
dankIslandSatelliteTransparency: "islandSatelliteTransparency",
dankIslandSatelliteSwoopRadius: "islandSatelliteSwoopRadius",
dankIslandReducedMotion: "islandReducedMotion",
dankIslandSpringStiffness: "islandSpringStiffness",
dankIslandSpringDamping: "islandSpringDamping",
dankIslandSpringMass: "islandSpringMass"
};
function migrateBatteryPillStyle(target) {
if (!target || typeof target !== "object" || target.batteryPillStyle === undefined)
return;
@@ -433,5 +470,28 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 17;
}
if (currentVersion < 18) {
console.info("Migrating settings from version", currentVersion, "to version 18");
console.info("Moving the shell-wide Dank Island onto its bar config as a per-instance mode");
var islandId = settings.dankIslandBarId;
var islandBars = Array.isArray(settings.barConfigs) ? settings.barConfigs : [];
for (var ib = 0; ib < islandBars.length; ib++) {
if (!islandBars[ib] || islandBars[ib].id !== islandId)
continue;
islandBars[ib].island = true;
for (var oldKey in ISLAND_KEY_MOVES) {
if (!(oldKey in settings))
continue;
islandBars[ib][ISLAND_KEY_MOVES[oldKey]] = settings[oldKey];
}
}
for (var dropKey in ISLAND_KEY_MOVES)
delete settings[dropKey];
delete settings.dankIslandBarId;
settings.configVersion = 18;
}
return settings;
}
+1 -1
View File
@@ -37,7 +37,7 @@ Item {
property bool osdSurfacesLoaded: false
property int pendingOsdResumeReloads: 0
readonly property var dankIslandScreens: SettingsData.getIslandScreens()
readonly property var dankIslandScreens: Quickshell.screens.filter(screen => SettingsData.dankIslandCoversScreen(screen))
readonly property var notificationPopupScreens: {
const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications");
return root.withoutDankIslandScreens(screens);
+4 -3
View File
@@ -964,9 +964,6 @@ Item {
const posValue = positionMap[position.toLowerCase()];
if (posValue === undefined)
return "BAR_INVALID_POSITION";
// The island silhouette only anchors top or bottom.
if (SettingsData.isIslandBarConfig(barConfig) && posValue !== SettingsData.Position.Top && posValue !== SettingsData.Position.Bottom)
return "BAR_IS_ISLAND";
SettingsData.updateBarConfig(barConfig.id, {
position: posValue
});
@@ -1866,6 +1863,8 @@ Item {
function open(): string {
root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) {
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
@@ -1888,6 +1887,8 @@ Item {
root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED";
}
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
+58 -13
View File
@@ -1688,18 +1688,60 @@ Item {
return result;
}
function _diskCacheSectionDef(sectionId) {
for (var i = 0; i < sectionDefinitions.length; i++) {
if (sectionDefinitions[i].id === sectionId)
return sectionDefinitions[i];
}
return null;
}
function _diskCacheSection(def, items) {
return {
id: def.id || "",
title: def.title || "",
icon: def.icon || "",
priority: def.priority || 0,
items: items,
collapsed: false,
flatStartIndex: 0
};
}
function _loadDiskCache() {
var cached = CacheData.loadLauncherCache();
if (!cached || !Array.isArray(cached) || cached.length === 0)
return null;
var browseItems = getPluginBrowseItems();
var liveCoreItems = {};
var liveCoreApps = AppSearchService.getCoreApps("");
for (var c = 0; c < liveCoreApps.length; c++) {
var coreItem = transformCoreApp(liveCoreApps[c]);
liveCoreItems[coreItem.id] = coreItem;
}
var sectionsData = [];
var browseSectionSeen = false;
for (var i = 0; i < cached.length; i++) {
var s = cached[i];
if (s.id === "browse_plugins") {
browseSectionSeen = true;
if (browseItems.length > 0)
sectionsData.push(_diskCacheSection(_diskCacheSectionDef(s.id) || s, browseItems));
continue;
}
var items = [];
var srcItems = s.items || [];
for (var j = 0; j < srcItems.length; j++) {
var it = srcItems[j];
if (it.isCore) {
if (liveCoreItems[it.id])
items.push(liveCoreItems[it.id]);
continue;
}
if (it.type === "plugin_browse" || it.isBuiltInLauncher || it.pluginId)
continue;
items.push({
id: it.id || "",
type: it.type || "app",
@@ -1709,15 +1751,12 @@ Item {
iconType: it.iconType || "image",
iconFull: it.iconFull || "",
section: it.section || "",
isCore: it.isCore || false,
isBuiltInLauncher: it.isBuiltInLauncher || false,
pluginId: it.pluginId || "",
source: it.source || "",
data: {
id: it.id
},
actions: _actionsFromDesktopEntry(it.id),
primaryAction: it.type === "app" && !it.isCore ? {
primaryAction: it.type === "app" ? {
name: I18n.tr("Launch"),
icon: "open_in_new",
action: "launch"
@@ -1729,16 +1768,22 @@ Item {
_preScored: undefined
});
}
sectionsData.push({
id: s.id || "",
title: s.title || "",
icon: s.icon || "",
priority: s.priority || 0,
items: items,
collapsed: false,
flatStartIndex: 0
});
if (items.length > 0)
sectionsData.push(_diskCacheSection(s, items));
}
if (!browseSectionSeen && browseItems.length > 0) {
var browseDef = _diskCacheSectionDef("browse_plugins");
if (browseDef)
sectionsData.push(_diskCacheSection(browseDef, browseItems));
}
if (sectionsData.length === 0)
return null;
sectionsData.sort(function (a, b) {
return a.priority - b.priority;
});
return sectionsData;
}
@@ -193,7 +193,7 @@ Item {
if (modelData.repo)
Qt.openUrlExternally(modelData.repo);
}
onInstallRequested: PluginService.installFromRegistry(modelData.name, modelData.type === "desktop")
onInstallRequested: PluginService.installFromRegistry(modelData.id, modelData.name, modelData.type === "desktop")
}
}
}
+5
View File
@@ -51,9 +51,14 @@ DankModal {
const topChrome = Math.max(bar && barPosition === SettingsData.Position.Top ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "top"));
const bottomChrome = Math.max(bar && barPosition === SettingsData.Position.Bottom ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "bottom"));
const leftChrome = Math.max(bar && barPosition === SettingsData.Position.Left ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "left"));
const rightChrome = Math.max(bar && barPosition === SettingsData.Position.Right ? barExclusionZone : 0, SettingsData.dankIslandEdgeOffset(parentScreen, "right"));
const minY = topChrome + margin;
const maxY = screenH - modalHeight - bottomChrome - margin;
const minX = leftChrome + margin;
const maxX = screenW - modalWidth - rightChrome - margin;
targetX = Math.max(minX, Math.min(maxX, targetX));
targetY = Math.max(minY, Math.min(maxY, targetY));
return Qt.point(targetX, targetY);
@@ -92,8 +92,8 @@ Rectangle {
root.leaveHiddenTab(33);
}
function onDankIslandBarIdChanged() {
if (!SettingsData.dankIslandBarId)
function onIslandBarConfigsChanged() {
if (SettingsData.islandBarConfigs.length === 0)
root.leaveHiddenTab(46);
}
}
@@ -125,7 +125,7 @@ Rectangle {
return false;
if (item.frameOnly && !SettingsData.frameEnabled)
return false;
if (item.islandOnly && !SettingsData.dankIslandBarId)
if (item.islandOnly && SettingsData.islandBarConfigs.length === 0)
return false;
if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0)
return false;
+39 -1
View File
@@ -9,6 +9,8 @@ DankFloatingWindow {
readonly property var log: Log.scoped("WorkspaceRenameModal")
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property var aqueousWorkspace: null
property bool renaming: false
objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace")
@@ -19,9 +21,24 @@ DankFloatingWindow {
onClosed: hide()
function show(name) {
if (CompositorService.isAqueous) {
if (renaming)
return false;
const workspace = AqueousService.workspaces.find(ws => ws.output === AqueousService.seat?.output && ws.active);
if (!AqueousService.available || !AqueousService.seat || !workspace || !AqueousService.capabilities.commands || AqueousService.locked) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Unavailable"));
return false;
}
aqueousWorkspace = {
id: workspace.id,
session: workspace.aqueousSession
};
name = workspace.name;
}
nameInput.text = name;
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
return true;
}
function hide() {
@@ -29,6 +46,24 @@ DankFloatingWindow {
}
function submitAndClose() {
if (aqueousWorkspace || CompositorService.isAqueous) {
if (!aqueousWorkspace || renaming)
return;
const target = aqueousWorkspace;
renaming = true;
AqueousService.command("workspace.rename", {
id: target.id,
session: target.session,
name: nameInput.text
}, success => {
if (root.aqueousWorkspace !== target)
return;
root.renaming = false;
if (success)
root.hide();
});
return;
}
renameWorkspace(nameInput.text);
hide();
}
@@ -49,6 +84,8 @@ DankFloatingWindow {
return;
}
nameInput.text = "";
aqueousWorkspace = null;
renaming = false;
}
FocusScope {
@@ -133,7 +170,7 @@ DankFloatingWindow {
textColor: Theme.surfaceText
placeholderText: I18n.tr("Workspace name")
backgroundColor: "transparent"
enabled: root.visible
enabled: root.visible && !root.renaming
onAccepted: submitAndClose()
}
}
@@ -190,6 +227,7 @@ DankFloatingWindow {
MouseArea {
id: renameArea
enabled: !root.renaming
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
+1 -1
View File
@@ -62,7 +62,7 @@ Item {
// M3 elevation shadow — Level 2 baseline (navigation bar), with per-bar override support
readonly property bool hasPerBarOverride: (barConfig?.shadowIntensity ?? 0) > 0
readonly property var elevLevel: Theme.elevationLevel2
readonly property bool shadowEnabled: (Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)) || hasPerBarOverride
readonly property bool shadowEnabled: Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)
readonly property string autoBarShadowDirection: isTop ? "top" : (isBottom ? "bottom" : (isLeft ? "left" : (isRight ? "right" : "top")))
readonly property string globalShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection
readonly property string perBarShadowDirectionMode: barConfig?.shadowDirectionMode ?? "inherit"
+7 -7
View File
@@ -454,7 +454,7 @@ Item {
readonly property real _wingR: Math.max(0, wingtipsRadius)
// 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)) || (renderBarConfig?.shadowIntensity ?? 0) > 0
readonly property bool _shadowActive: Theme.elevationEnabled && (typeof SettingsData !== "undefined" ? (SettingsData.barElevationEnabled ?? true) : false)
readonly property real _shadowBuffer: {
if (!_shadowActive)
return 0;
@@ -496,7 +496,7 @@ Item {
hasMaximizedToplevel = false;
return;
}
if (!CompositorService.isHyprland && !CompositorService.isNiri) {
if (!CompositorService.isHyprland && !CompositorService.isNiri && !CompositorService.isAqueous) {
hasMaximizedToplevel = false;
return;
}
@@ -520,7 +520,7 @@ Item {
shouldHideForWindows = false;
return;
}
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) {
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous) {
shouldHideForWindows = false;
return;
}
@@ -795,7 +795,7 @@ Item {
readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr)
readonly property int lengthPaddingPx: Theme.px(barWindow.effectiveBarLengthPadding, barWindow._dpr)
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool inOverviewWithShow: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow)
@@ -986,7 +986,7 @@ Item {
}
property bool reveal: {
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
const inOverviewWithShow = CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview;
if (inOverviewWithShow)
return true;
@@ -997,7 +997,7 @@ Item {
return true;
}
if (CompositorService.isNiri && NiriService.inOverview)
if (CompositorService.overviewActiveOnScreen(barWindow.screenName))
return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal;
return (barConfig?.visible ?? true) && (!autoHide || hoverReveal || popoutPinsReveal || revealSticky || ipcReveal);
@@ -1062,7 +1062,7 @@ Item {
top: barWindow.isVertical ? parent.top : undefined
bottom: barWindow.isVertical ? parent.bottom : undefined
}
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool inOverview: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.popoutPinsReveal
acceptedButtons: barWindow.clickThroughEnabled ? Qt.NoButton : Qt.RightButton
enabled: !inOverview && ((barConfig?.autoHide ?? false) || !barWindow.clickThroughEnabled)
+13 -2
View File
@@ -156,6 +156,8 @@ Item {
function getRealWorkspaces() {
const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.workspacesForOutput(SettingsData.workspaceFollowFocus ? AqueousService.focusedOutput : screenName);
if (CompositorService.isNiri) {
const fallbackWorkspaces = [
{
@@ -238,6 +240,8 @@ Item {
function getCurrentWorkspace() {
const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return getRealWorkspaces().find(ws => ws.active)?.id || "";
if (CompositorService.isNiri) {
if (!screenName || SettingsData.workspaceFollowFocus) {
return NiriService.getCurrentWorkspaceNumber();
@@ -279,7 +283,14 @@ Item {
return;
}
if (CompositorService.isNiri) {
if (CompositorService.isAqueous && AqueousService.available) {
const index = realWorkspaces.findIndex(ws => ws.id === getCurrentWorkspace());
if (index < 0)
return;
const next = Math.max(0, Math.min(realWorkspaces.length - 1, index + (direction > 0 ? 1 : -1)));
if (next !== index)
AqueousService.activateWorkspace(realWorkspaces[next]);
} else if (CompositorService.isNiri) {
const currentWs = getCurrentWorkspace();
const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs);
const validIndex = currentIndex === -1 ? 0 : currentIndex;
@@ -323,7 +334,7 @@ Item {
}
function switchApp(deltaY) {
const windows = sortedToplevels;
const windows = sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2) {
return;
}
+1 -1
View File
@@ -67,7 +67,7 @@ PanelWindow {
implicitWidth: body.surfaceImplicitWidth
exclusiveZone: body.surfaceExclusiveZone
BackgroundEffect.blurRegion: body.blurRegion
BackgroundEffect.blurRegion: BlurService.enabled ? body.blurRegion : null
Component.onCompleted: {
KeyboardFocus.registerBarWindow(barWindow);
@@ -160,7 +160,7 @@ PanelWindow {
return [];
const toplevels = [];
const allToplevels = ToplevelManager.toplevels.values;
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i];
if (toplevel.appId === root.appData.appId) {
+11 -1
View File
@@ -204,7 +204,7 @@ BasePill {
spacing: Theme.spacingS
property real fontSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText)
property real digitWidth: fontSize * 0.6
property real digitWidth: Math.round(fontSize * 0.6)
property string hoursStr: {
const hours = systemClock?.date?.getHours() ?? 0;
@@ -234,6 +234,8 @@ BasePill {
text: "•"
font.pixelSize: Theme.fontSizeSmall
color: Theme.outlineButton
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
visible: !compact
}
@@ -272,6 +274,8 @@ BasePill {
text: ":"
font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
}
StyledText {
@@ -295,6 +299,8 @@ BasePill {
text: ":"
font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
}
StyledText {
@@ -320,6 +326,8 @@ BasePill {
text: clockRow.ampmStr
font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
}
}
}
@@ -337,6 +345,8 @@ BasePill {
}
font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
visible: !compact
}
@@ -31,7 +31,7 @@ BasePill {
property int availableWidth: maxWidth
readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth))
readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2)
property Toplevel activeWindow: null
property var activeWindow: null
property var activeDesktopEntry: null
property bool isHovered: mouseArea.containsMouse
property bool isAutoHideBar: false
@@ -99,6 +99,11 @@ BasePill {
}
function updateActiveWindow() {
if (CompositorService.isAqueous && AqueousService.available) {
const focused = AqueousService.focusedWindow;
activeWindow = focused && (!parentScreen || focused.screens.some(s => s.name === parentScreen.name)) ? focused : null;
return;
}
let active = ToplevelManager.activeToplevel;
if (!active && CompositorService.isNiri) {
@@ -16,6 +16,8 @@ BasePill {
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"]
property string currentLayout: {
if (CompositorService.isAqueous)
return AqueousService.keyboardLayout;
if (CompositorService.isNiri) {
return NiriService.getCurrentKeyboardLayoutName();
} else if (CompositorService.isMango) {
@@ -25,7 +27,7 @@ BasePill {
}
property string hyprlandKeyboard: ""
property var hyprlandLayoutLabels: []
readonly property var _allLayoutLabels: CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
readonly property var _allLayoutLabels: CompositorService.isAqueous ? AqueousService.keyboardLayouts.map(n => displayLabel(n)) : CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
readonly property string reserveLabel: widestLabel(_allLayoutLabels)
readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n)))
@@ -131,6 +133,8 @@ BasePill {
onClicked: {
if (CompositorService.isNiri) {
NiriService.cycleKeyboardLayout();
} else if (CompositorService.isAqueous) {
AqueousService.cycleKeyboardLayout();
} else if (CompositorService.isHyprland) {
Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]);
} else if (CompositorService.isMango) {
@@ -163,7 +163,7 @@ BasePill {
const deltaY = wheelEvent.angleDelta.y;
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
const windows = root.sortedToplevels;
const windows = root.sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2)
return;
@@ -236,7 +236,7 @@ BasePill {
id: windowRepeater
model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address"
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
}
delegate: Item {
@@ -494,7 +494,7 @@ BasePill {
id: windowRepeater
model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address"
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
}
delegate: Item {
@@ -24,6 +24,7 @@ Item {
property real crossEdgeExtension: 0
readonly property bool isMango: CompositorService.isMango
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
readonly property real _leftMargin: {
if (isVertical)
@@ -82,8 +83,10 @@ Item {
}
readonly property bool useUnfocusedAppearance: !isFocusedMonitor && SettingsData.workspaceUnfocusedMonitorSeparateAppearance && BarWidgetService.focusedScreenDetectionSupported
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(parentScreen) : null
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(CompositorService.isAqueous ? Quickshell.screens.find(s => s.name === effectiveScreenName) || parentScreen : parentScreen) : null
readonly property bool useExtWorkspace: {
if (useAqueous)
return false;
if (Quickshell.env("DMS_FORCE_EXTWS") === "1")
return (WindowManager.windowsets?.length ?? 0) > 0;
if (!CompositorService.compositorDetected)
@@ -117,6 +120,8 @@ Item {
}
property var currentWorkspace: {
if (useAqueous)
return AqueousService.workspacesForOutput(effectiveScreenName).find(w => w.active)?.id || "";
if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace();
@@ -143,6 +148,10 @@ Item {
return [];
}
property var workspaceList: {
if (useAqueous) {
const baseList = AqueousService.workspacesForOutput(effectiveScreenName).filter(ws => !SettingsData.showOccupiedWorkspacesOnly || ws.active || AqueousService.toplevels.some(w => w.aqueousWorkspaceId === ws.id));
return SettingsData.showWorkspacePadding && baseList.length ? padWorkspaces(baseList) : baseList;
}
if (useExtWorkspace) {
const baseList = getExtWorkspaceWorkspaces();
return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList;
@@ -322,12 +331,14 @@ Item {
function getWorkspaceIcons(ws) {
_desktopEntriesUpdateTrigger;
if (!SettingsData.showWorkspaceApps || !ws) {
if (!SettingsData.showWorkspaceApps || !ws || useExtWorkspace || (useAqueous && ws._placeholder)) {
return [];
}
let targetWorkspaceId;
if (CompositorService.isNiri) {
if (useAqueous) {
targetWorkspaceId = ws.id;
} else if (CompositorService.isNiri) {
if (!ws || typeof ws !== "object") {
const wsNumber = typeof ws === "number" ? ws : -1;
if (wsNumber <= 0) {
@@ -387,7 +398,9 @@ Item {
return;
} else {
let winWs = null;
if (CompositorService.isNiri) {
if (useAqueous) {
winWs = w.aqueousWorkspaceId;
} else if (CompositorService.isNiri) {
winWs = w.workspace_id;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
winWs = w.workspace?.num;
@@ -405,7 +418,7 @@ Item {
const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown");
const moddedId = Paths.moddedAppId(keyBase);
const groupThisWs = SettingsData.groupWorkspaceApps && (!isActiveWs || SettingsData.groupActiveWorkspaceApps);
const key = groupThisWs ? moddedId : `${moddedId}_${i}`;
const key = groupThisWs ? moddedId : (w.aqueousKey || `${moddedId}_${i}`);
if (!byApp[key]) {
const isQuickshell = keyBase === "org.quickshell" || keyBase === "com.danklinux.dms";
@@ -421,6 +434,7 @@ Item {
"active": !!((w.activated || w.is_focused) || (CompositorService.isNiri && w.is_focused)),
"count": 1,
"windowId": w.address || w.id,
"windowSession": useAqueous ? w.aqueousSession : "",
"fallbackText": appName || ""
};
} else {
@@ -435,7 +449,7 @@ Item {
}
function _makePlaceholder() {
if (useExtWorkspace)
if (useAqueous || useExtWorkspace)
return {
"id": "",
"name": "",
@@ -648,7 +662,7 @@ Item {
if (!extProjection)
return "";
const activeWs = extProjection.windowsets.find(ws => ws.active);
return activeWs ? (activeWs.id || activeWs.name || "") : "";
return activeWs || null;
}
readonly property real dpr: parentScreen ? CompositorService.getScreenScale(parentScreen) : 1
@@ -659,7 +673,7 @@ Item {
function getRealWorkspaces() {
return root.workspaceList.filter(ws => {
if (useExtWorkspace)
if (useAqueous || useExtWorkspace)
return ws && !ws._placeholder;
if (CompositorService.isNiri)
return ws && ws.idx !== -1;
@@ -674,9 +688,14 @@ Item {
}
function switchToWorkspaceByModelData(data) {
if (!data)
if (!data || data._placeholder)
return;
if (useAqueous) {
AqueousService.activateWorkspace(data);
return;
}
if (root.useExtWorkspace) {
if (typeof data.activate === "function")
data.activate();
@@ -727,13 +746,21 @@ Item {
}
function switchWorkspace(direction) {
if (useAqueous) {
const workspaces = getRealWorkspaces();
const index = workspaces.findIndex(w => w.id === currentWorkspace);
const next = Math.max(0, Math.min(workspaces.length - 1, index + (direction > 0 ? 1 : -1)));
if (next !== index)
AqueousService.activateWorkspace(workspaces[next]);
return;
}
if (useExtWorkspace) {
const realWorkspaces = getRealWorkspaces();
if (realWorkspaces.length < 2) {
return;
}
const currentIndex = realWorkspaces.findIndex(ws => (ws.id || ws.name) === root.currentWorkspace);
const currentIndex = realWorkspaces.findIndex(ws => ws === root.currentWorkspace);
const validIndex = currentIndex === -1 ? 0 : currentIndex;
const nextIndex = direction > 0 ? Math.min(validIndex + 1, realWorkspaces.length - 1) : Math.max(validIndex - 1, 0);
@@ -812,6 +839,8 @@ Item {
}
function getWorkspaceIndexFallback(modelData, index) {
if (useAqueous)
return modelData?.number ?? "";
if (root.useExtWorkspace)
return index + 1;
if (CompositorService.isNiri)
@@ -827,8 +856,8 @@ Item {
function getWorkspaceIndex(modelData, index) {
let isPlaceholder;
if (root.useExtWorkspace) {
isPlaceholder = modelData?.hidden === true;
if (root.useAqueous || root.useExtWorkspace) {
isPlaceholder = modelData?._placeholder === true;
} else if (CompositorService.isNiri) {
isPlaceholder = modelData?.idx === -1;
} else if (CompositorService.isHyprland) {
@@ -868,7 +897,7 @@ Item {
return getWorkspaceIndexFallback(modelData, index);
}
readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool hasNativeWorkspaceSupport: useAqueous || CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool hasWorkspaces: getRealWorkspaces().length > 0
readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces)
@@ -962,7 +991,9 @@ Item {
const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y);
switch (mouse.button) {
case Qt.RightButton:
if (CompositorService.isNiri) {
if (CompositorService.isAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1144,8 +1175,10 @@ Item {
}
property bool isActive: {
if (root.useAqueous)
return modelData?.active === true;
if (root.useExtWorkspace)
return (modelData?.id || modelData?.name) === root.currentWorkspace;
return modelData === root.currentWorkspace;
if (CompositorService.isNiri)
return !!(modelData && modelData.idx === root.currentWorkspace);
if (CompositorService.isHyprland)
@@ -1157,6 +1190,8 @@ Item {
return modelData === root.currentWorkspace;
}
property bool isOccupied: {
if (root.useAqueous)
return AqueousService.toplevels.some(w => w.aqueousWorkspaceId === modelData?.id);
if (CompositorService.isHyprland)
return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id);
if (root.isMango)
@@ -1166,7 +1201,7 @@ Item {
return false;
}
property bool isPlaceholder: {
if (root.useExtWorkspace)
if (root.useAqueous || root.useExtWorkspace)
return !!(modelData && modelData._placeholder);
if (CompositorService.isNiri)
return !!(modelData && modelData.idx === -1);
@@ -1183,6 +1218,8 @@ Item {
property var loadedWorkspaceData: null
property bool loadedIsUrgent: false
property bool isUrgent: {
if (root.useAqueous)
return modelData?.urgent === true;
if (root.useExtWorkspace)
return modelData?.urgent ?? false;
if (CompositorService.isHyprland)
@@ -1210,6 +1247,9 @@ Item {
if (!SettingsData.showWorkspaceApps || isPlaceholder)
return 0;
if (root.useAqueous)
return loadedIcons.length;
let targetWorkspaceId;
if (root.useExtWorkspace) {
targetWorkspaceId = modelData?.id || modelData?.name;
@@ -1267,7 +1307,7 @@ Item {
readonly property real baseWidth: root.isVertical ? (SettingsData.showWorkspaceApps ? Math.max(widgetHeight * 0.7, root.appIconSize + Theme.spacingXS * 2) : widgetHeight * 0.5) : (isActive ? Math.max(root.widgetHeight * 1.05, root.appIconSize * 1.6) : Math.max(root.widgetHeight * 0.7, root.appIconSize * 1.2))
readonly property real baseHeight: root.isVertical ? (isActive ? Math.max(root.widgetHeight * 1.05, root.appIconSize * 1.6) : Math.max(root.widgetHeight * 0.7, root.appIconSize * 1.2)) : (SettingsData.showWorkspaceApps ? Math.max(widgetHeight * 0.7, root.appIconSize + Theme.spacingXS * 2) : widgetHeight * 0.5)
readonly property bool hasWorkspaceName: SettingsData.showWorkspaceName && modelData?.name && modelData.name !== ""
readonly property bool workspaceNamesEnabled: SettingsData.showWorkspaceName && (CompositorService.isNiri || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
readonly property bool workspaceNamesEnabled: SettingsData.showWorkspaceName && (root.useAqueous || CompositorService.isNiri || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
readonly property real contentImplicitWidth: appIconsLoader.item?.contentWidth ?? 0
readonly property real contentImplicitHeight: appIconsLoader.item?.contentHeight ?? 0
@@ -1479,7 +1519,11 @@ Item {
return;
if (mouse.button === Qt.LeftButton) {
if (root.useExtWorkspace) {
if (delegateRoot.focusWindowAt(mouse.x, mouse.y))
return;
if (root.useAqueous) {
root.switchToWorkspaceByModelData(modelData);
} else if (root.useExtWorkspace) {
if (typeof modelData?.activate === "function")
modelData.activate();
} else if (CompositorService.isNiri) {
@@ -1494,7 +1538,9 @@ Item {
CompositorService.dispatchSwayWorkspace(modelData);
}
} else if (mouse.button === Qt.RightButton) {
if (CompositorService.isNiri) {
if (CompositorService.isAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1517,7 +1563,7 @@ Item {
}
var wsData = null;
if (root.useExtWorkspace) {
if (root.useAqueous || root.useExtWorkspace) {
wsData = modelData;
} else if (CompositorService.isNiri) {
wsData = modelData || null;
@@ -1554,6 +1600,36 @@ Item {
dataUpdateTimer.restart();
}
function windowIdAt(x, y) {
const layout = appIconsLoader.item?.iconsLayout;
if (!layout)
return null;
const point = layout.mapFromItem(mouseArea, x, y);
const icon = layout.childAt(point.x, point.y);
if (root.useAqueous)
return icon?.windowId ? {id: icon.windowId, session: icon.windowSession} : null;
return icon?.windowId ?? null;
}
function focusWindowAt(x, y) {
const winId = delegateRoot.windowIdAt(x, y);
if (!winId)
return false;
if (root.useAqueous) {
AqueousService.command("window.activate", winId);
return true;
}
if (CompositorService.isHyprland) {
HyprlandService.focusWindow(winId);
return true;
}
if (CompositorService.isNiri) {
NiriService.focusWindow(winId);
return true;
}
return false;
}
width: root.isVertical ? root.widgetHeight : visualWidth
height: root.isVertical ? visualHeight : root.widgetHeight
@@ -1678,6 +1754,7 @@ Item {
id: contentRoot
readonly property real contentWidth: contentRow.item?.implicitWidth ?? 0
readonly property real contentHeight: contentRow.item?.implicitHeight ?? 0
property alias iconsLayout: contentRow.item
Loader {
id: contentRow
@@ -1743,16 +1820,18 @@ Item {
delegate: Item {
width: root.appIconSize
height: root.appIconSize
readonly property var windowId: modelData.windowId
readonly property string windowSession: modelData.windowSession || ""
readonly property bool appHighlightActive: SettingsData.workspaceActiveAppHighlightEnabled && modelData.active
readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected
readonly property color appGlyphColor: appHighlightActive ? focusedBorderColor : Theme.primary
readonly property real appOpacity: modelData.active ? 1.0 : rowAppMouseArea.containsMouse ? 0.8 : 0.6
readonly property real appOpacity: modelData.active ? 1.0 : rowAppHover.hovered ? 0.8 : 0.6
IconImage {
id: rowAppIcon
anchors.fill: parent
source: modelData.icon || ""
opacity: modelData.active ? 1.0 : rowAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : rowAppHover.hovered ? 0.8 : 0.6
visible: !modelData.isQuickshell && !modelData.isSteamApp && status === Image.Ready
}
@@ -1794,7 +1873,7 @@ Item {
IconImage {
anchors.fill: parent
source: modelData.icon
opacity: modelData.active ? 1.0 : rowAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : rowAppHover.hovered ? 0.8 : 0.6
visible: modelData.isQuickshell
layer.enabled: true
layer.effect: MultiEffect {
@@ -1808,7 +1887,7 @@ Item {
id: rowSteamIcon
anchors.fill: parent
source: modelData.icon
opacity: modelData.active ? 1.0 : rowAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : rowAppHover.hovered ? 0.8 : 0.6
visible: modelData.isSteamApp && modelData.icon
}
@@ -1817,7 +1896,7 @@ Item {
size: root.appIconSize
name: "sports_esports"
color: appHighlightActive ? focusedBorderColor : Theme.widgetTextColor
opacity: modelData.active ? 1.0 : rowAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : rowAppHover.hovered ? 0.8 : 0.6
visible: modelData.isSteamApp && !modelData.icon
}
@@ -1831,21 +1910,8 @@ Item {
z: 1
}
MouseArea {
id: rowAppMouseArea
anchors.fill: parent
enabled: isActive
cursorShape: Qt.PointingHandCursor
onClicked: {
const winId = modelData.windowId;
if (!winId)
return;
if (CompositorService.isHyprland) {
HyprlandService.focusWindow(winId);
} else if (CompositorService.isNiri) {
NiriService.focusWindow(winId);
}
}
HoverHandler {
id: rowAppHover
}
Rectangle {
@@ -1912,16 +1978,18 @@ Item {
delegate: Item {
width: root.appIconSize
height: root.appIconSize
readonly property var windowId: modelData.windowId
readonly property string windowSession: modelData.windowSession || ""
readonly property bool appHighlightActive: SettingsData.workspaceActiveAppHighlightEnabled && modelData.active
readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected
readonly property color appGlyphColor: appHighlightActive ? focusedBorderColor : Theme.primary
readonly property real appOpacity: modelData.active ? 1.0 : colAppMouseArea.containsMouse ? 0.8 : 0.6
readonly property real appOpacity: modelData.active ? 1.0 : colAppHover.hovered ? 0.8 : 0.6
IconImage {
id: colAppIcon
anchors.fill: parent
source: modelData.icon || ""
opacity: modelData.active ? 1.0 : colAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : colAppHover.hovered ? 0.8 : 0.6
visible: !modelData.isQuickshell && !modelData.isSteamApp && status === Image.Ready
}
@@ -1963,7 +2031,7 @@ Item {
IconImage {
anchors.fill: parent
source: modelData.icon
opacity: modelData.active ? 1.0 : colAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : colAppHover.hovered ? 0.8 : 0.6
visible: modelData.isQuickshell
layer.enabled: true
layer.effect: MultiEffect {
@@ -1977,7 +2045,7 @@ Item {
id: colSteamIcon
anchors.fill: parent
source: modelData.icon
opacity: modelData.active ? 1.0 : colAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : colAppHover.hovered ? 0.8 : 0.6
visible: modelData.isSteamApp && modelData.icon
}
@@ -1986,7 +2054,7 @@ Item {
size: root.appIconSize
name: "sports_esports"
color: appHighlightActive ? focusedBorderColor : Theme.widgetTextColor
opacity: modelData.active ? 1.0 : colAppMouseArea.containsMouse ? 0.8 : 0.6
opacity: modelData.active ? 1.0 : colAppHover.hovered ? 0.8 : 0.6
visible: modelData.isSteamApp && !modelData.icon
}
@@ -2000,21 +2068,8 @@ Item {
z: 1
}
MouseArea {
id: colAppMouseArea
anchors.fill: parent
enabled: isActive
cursorShape: Qt.PointingHandCursor
onClicked: {
const winId = modelData.windowId;
if (!winId)
return;
if (CompositorService.isHyprland) {
HyprlandService.focusWindow(winId);
} else if (CompositorService.isNiri) {
NiriService.focusWindow(winId);
}
}
HoverHandler {
id: colAppHover
}
Rectangle {
@@ -2121,6 +2176,13 @@ Item {
delegateRoot.updateAllData();
}
}
Connections {
target: AqueousService
function onStateChanged() {
if (root.useAqueous)
delegateRoot.updateAllData();
}
}
property var _extWindowsetsTrigger: root.useExtWorkspace ? WindowManager.windowsets : null
on_ExtWindowsetsTriggerChanged: {
if (root.useExtWorkspace)
@@ -543,6 +543,7 @@ Item {
font.pixelSize: Theme.fontSizeMedium
font.weight: row.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
}
StyledText {
@@ -551,6 +552,7 @@ Item {
color: Theme.surfaceTextSecondary
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
}
@@ -3,6 +3,7 @@ import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import "../../../Common/DateOnly.js" as DateOnly
Rectangle {
id: root
@@ -35,53 +36,39 @@ Rectangle {
return weekStartQt() % 7;
}
function startOfWeek(dateObj) {
const d = new Date(dateObj);
const jsDow = d.getDay();
const diff = (jsDow - weekStartJs() + 7) % 7;
d.setDate(d.getDate() - diff);
return d;
function startOfWeek(dateOnly) {
const diff = (dateOnly.dayOfWeek() - weekStartJs() + 7) % 7;
return dateOnly.addDays(-diff);
}
function endOfWeek(dateObj) {
const d = new Date(dateObj);
const jsDow = d.getDay();
const add = (weekStartJs() + 6 - jsDow + 7) % 7;
d.setDate(d.getDate() + add);
return d;
function endOfWeek(dateOnly) {
const add = (weekStartJs() + 6 - dateOnly.dayOfWeek() + 7) % 7;
return dateOnly.addDays(add);
}
function getWeekNumber(dateObj) {
// Set time to noon to avoid potential Daylight Saving Time related bugs
const weekStartDay = startOfWeek(dateObj);
weekStartDay.setHours(12, 0, 0, 0);
function getWeekNumber(dateOnly) {
const weekStartDay = startOfWeek(dateOnly);
let week1Start;
if (weekStartJs() === 1) {
// ISO 8601 Standard, week start on Monday
// A week belongs to the year its Thursday falls in
// So we have to get the yearTarget from weekStartDay instead of dateObj
let yearTarget = weekStartDay;
yearTarget.setDate(yearTarget.getDate() + 3); // Monday + 3 = Thursday
// So we have to get the yearTarget from weekStartDay instead of dateOnly
const yearTarget = weekStartDay.addDays(3); // Monday + 3 = Thursday
// Week 1 is the week containing Jan 4th
const jan4 = new Date(yearTarget.getFullYear(), 0, 4);
week1Start = startOfWeek(jan4);
week1Start = startOfWeek(DateOnly.of(yearTarget.year, 0, 4));
} else {
// Traditional / US Standard, week start on Sunday
// A week belongs to the year its Sunday falls in
let yearTarget = weekStartDay;
yearTarget.setDate(yearTarget.getDate() + 6); // Monday + 6 = Sunday
const yearTarget = weekStartDay.addDays(6); // Monday + 6 = Sunday
// Week 1 is the week containing Jan 1st
const jan1 = new Date(yearTarget.getFullYear(), 0, 1);
week1Start = startOfWeek(jan1);
week1Start = startOfWeek(DateOnly.of(yearTarget.year, 0, 1));
}
week1Start.setHours(12, 0, 0, 0);
const diffDays = Math.round((weekStartDay.getTime() - week1Start.getTime()) / 86400000); // Number of miliseconds in a day
const diffDays = week1Start.daysUntil(weekStartDay);
return Math.floor(diffDays / 7) + 1;
}
@@ -97,16 +84,16 @@ Rectangle {
return;
}
const firstOfMonth = new Date(calendarGrid.displayDate.getFullYear(), calendarGrid.displayDate.getMonth(), 1);
const lastOfMonth = new Date(calendarGrid.displayDate.getFullYear(), calendarGrid.displayDate.getMonth() + 1, 0);
const year = calendarGrid.displayDate.getFullYear();
const month = calendarGrid.displayDate.getMonth();
const startDate = startOfWeek(firstOfMonth);
startDate.setDate(startDate.getDate() - 7);
const firstOfMonth = DateOnly.firstOfMonth(year, month);
const lastOfMonth = DateOnly.lastOfMonth(year, month);
const endDate = endOfWeek(lastOfMonth);
endDate.setDate(endDate.getDate() + 7);
const startDate = startOfWeek(firstOfMonth).addDays(-7);
const endDate = endOfWeek(lastOfMonth).addDays(7);
CalendarService.loadEvents(startDate, endDate);
CalendarService.loadEvents(startDate.toDate(), endDate.toDate());
}
function goToToday() {
@@ -118,11 +105,11 @@ Rectangle {
}
function moveSelection(days) {
let d = new Date(calendarGrid.selectedDate);
d.setDate(d.getDate() + days);
const moved = DateOnly.fromDate(calendarGrid.selectedDate).addDays(days);
const d = moved.toDate();
calendarGrid.selectedDate = d;
root.selectedDate = d;
if (d.getMonth() !== calendarGrid.displayDate.getMonth() || d.getFullYear() !== calendarGrid.displayDate.getFullYear()) {
if (moved.month !== calendarGrid.displayDate.getMonth() || moved.year !== calendarGrid.displayDate.getFullYear()) {
calendarGrid.displayDate = d;
loadEventsForMonth();
}
@@ -216,10 +203,13 @@ Rectangle {
border.width: 1
Column {
id: cardColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
readonly property real warningInset: dankWarning.visible ? dankWarning.height + spacing : 0
Rectangle {
id: dankWarning
width: parent.width
@@ -349,10 +339,27 @@ Rectangle {
}
DankActionButton {
readonly property bool isToday: {
const now = systemClock.date;
const disp = calendarGrid.displayDate;
const sel = calendarGrid.selectedDate;
return disp.getFullYear() === now.getFullYear() && disp.getMonth() === now.getMonth() && sel.getFullYear() === now.getFullYear() && sel.getMonth() === now.getMonth() && sel.getDate() === now.getDate();
}
buttonSize: 28
iconSize: 14
iconName: "today"
iconColor: Theme.primary
iconColor: enabled ? Theme.primary : Theme.surfaceTextMedium
enabled: !isToday
opacity: enabled ? 1 : 0.38
tooltipText: I18n.tr("Today")
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
}
}
onClicked: root.goToToday()
}
@@ -367,7 +374,7 @@ Rectangle {
Row {
width: parent.width
height: parent.height - 28 - Theme.spacingS
height: parent.height - 28 - Theme.spacingS - cardColumn.warningInset
visible: !showEventDetails
spacing: SettingsData.showWeekNumber ? Theme.spacingS : 0
@@ -399,8 +406,7 @@ Rectangle {
StyledText {
anchors.centerIn: parent
text: {
const rowDate = new Date(calendarGrid.firstDay);
rowDate.setDate(rowDate.getDate() + index * 7);
const rowDate = calendarGrid.firstDay.addDays(index * 7);
return root.getWeekNumber(rowDate);
}
font.pixelSize: Theme.fontSizeSmall
@@ -458,8 +464,8 @@ Rectangle {
property date displayDate: systemClock.date
property date selectedDate: systemClock.date
readonly property date firstDay: {
const firstOfMonth = new Date(displayDate.getFullYear(), displayDate.getMonth(), 1);
readonly property var firstDay: {
const firstOfMonth = DateOnly.firstOfMonth(displayDate.getFullYear(), displayDate.getMonth());
return startOfWeek(firstOfMonth);
}
@@ -467,14 +473,10 @@ Rectangle {
model: 42
Rectangle {
readonly property date dayDate: {
const date = new Date(parent.firstDay);
date.setDate(date.getDate() + index);
return date;
}
readonly property bool isCurrentMonth: dayDate.getMonth() === calendarGrid.displayDate.getMonth()
readonly property bool isToday: dayDate.toDateString() === new Date().toDateString()
readonly property bool isSelected: dayDate.toDateString() === calendarGrid.selectedDate.toDateString()
readonly property var dayDate: calendarGrid.firstDay.addDays(index)
readonly property bool isCurrentMonth: dayDate.month === calendarGrid.displayDate.getMonth()
readonly property bool isToday: dayDate.equals(DateOnly.fromDate(systemClock.date))
readonly property bool isSelected: dayDate.equals(DateOnly.fromDate(calendarGrid.selectedDate))
width: parent.width / 7
height: parent.height / 6
@@ -491,7 +493,7 @@ Rectangle {
StyledText {
anchors.centerIn: parent
text: dayDate.getDate()
text: dayDate.day
font.pixelSize: Theme.fontSizeSmall
color: isToday ? Theme.primary : isCurrentMonth ? Theme.surfaceText : Theme.surfaceVariantText
font.weight: isToday ? Font.Medium : Font.Normal
@@ -502,11 +504,11 @@ Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: 3
spacing: Theme.spacingXXS
visible: CalendarService && CalendarService.calendarAvailable && CalendarService.hasEventsForDate(dayDate)
visible: CalendarService && CalendarService.calendarAvailable && CalendarService.hasEventsForDate(dayDate.toDate())
Repeater {
model: {
const evs = CalendarService.getEventsForDate(dayDate);
const evs = CalendarService.getEventsForDate(dayDate.toDate());
const seen = [];
for (let i = 0; i < evs.length && seen.length < 3; i++) {
const c = (evs[i].color && evs[i].color.length) ? evs[i].color : "primary";
@@ -533,8 +535,8 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
calendarGrid.selectedDate = dayDate;
root.selectedDate = dayDate;
calendarGrid.selectedDate = dayDate.toDate();
root.selectedDate = dayDate.toDate();
root.showEventDetails = true;
}
}
@@ -547,7 +549,7 @@ Rectangle {
Flickable {
id: flickableArea
width: parent.width - Theme.spacingS * 2
height: parent.height - (showEventDetails ? 40 + 42 : 28 + 18) - Theme.spacingS
height: parent.height - (showEventDetails ? 40 + 42 : 28 + 18) - Theme.spacingS - cardColumn.warningInset
anchors.horizontalCenter: parent.horizontalCenter
visible: showEventDetails
clip: true
@@ -14,23 +14,27 @@ Item {
property color iconColor: Theme.primary
property Component leading: null
function pushMeasuredWidth() {
root.controller.setDestinationContentWidth(root.activityId, compactRow.implicitWidth);
readonly property bool isVertical: root.controller.isVertical
function pushMeasuredLength() {
root.controller.setDestinationContentLength(root.activityId, root.isVertical ? compactColumn.implicitHeight : compactRow.implicitWidth);
}
Component.onCompleted: root.pushMeasuredWidth()
Component.onCompleted: root.pushMeasuredLength()
onIsVerticalChanged: root.pushMeasuredLength()
Row {
id: compactRow
anchors.centerIn: parent
visible: !root.isVertical
spacing: Theme.spacingS
onImplicitWidthChanged: root.pushMeasuredWidth()
onImplicitWidthChanged: root.pushMeasuredLength()
Loader {
anchors.verticalCenter: parent.verticalCenter
active: root.leading !== null
active: !root.isVertical && root.leading !== null
visible: active
sourceComponent: root.leading
}
@@ -51,4 +55,30 @@ Item {
font.weight: Font.DemiBold
}
}
// The pill is only as wide as the strip on a side edge, so the label is dropped.
Column {
id: compactColumn
anchors.centerIn: parent
visible: root.isVertical
spacing: Theme.spacingXS
onImplicitHeightChanged: root.pushMeasuredLength()
Loader {
anchors.horizontalCenter: parent.horizontalCenter
active: root.isVertical && root.leading !== null
visible: active
sourceComponent: root.leading
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.iconName !== ""
name: root.iconName
size: Theme.iconSizeSmall
color: root.iconColor
}
}
}
@@ -14,6 +14,15 @@ Item {
required property var systemModel
readonly property string timeText: systemClock.date.toLocaleTimeString(I18n.locale(), SettingsData.getEffectiveTimeFormat())
readonly property string hourText: {
const hours = systemClock.date.getHours();
if (SettingsData.use24HourClock)
return String(hours).padStart(2, "0");
return String(hours === 0 ? 12 : (hours > 12 ? hours - 12 : hours)).padStart(2, "0");
}
readonly property string minuteText: String(systemClock.date.getMinutes()).padStart(2, "0")
readonly property string monthText: systemClock.date.toLocaleDateString(I18n.locale(), "MMM")
readonly property string dayText: String(systemClock.date.getDate())
readonly property string dateText: systemClock.date.toLocaleDateString(I18n.locale(), SettingsData.getEffectiveDateFormat("ddd MMM d"))
readonly property bool tight: root.controller.homeCompactTight
readonly property real slotSize: root.tight ? Theme.iconSize : Theme.iconSizeLarge
@@ -21,7 +30,9 @@ Item {
readonly property real iconSize: root.textSize + Theme.spacingXS
readonly property real statusIconSize: root.textSize + Theme.spacingXXS
readonly property real groupSpacing: root.controller.homeSlotMargin
readonly property real edgePad: Math.max(root.groupSpacing, (root.width - compactRow.width) / 2)
readonly property bool isVertical: root.controller.isVertical
readonly property string clockDisplay: root.controller.homeClockDisplay
readonly property real edgePad: Math.max(root.groupSpacing, ((root.isVertical ? root.height - compactColumn.height : root.width - compactRow.width)) / 2)
readonly property bool weatherSlotEnabled: root.controller.homeWeatherEnabled
readonly property var brightnessDevice: DisplayService.getCurrentDeviceInfo()
readonly property real brightnessMaximum: DisplayService.brightnessMaximum(root.brightnessDevice)
@@ -63,6 +74,29 @@ Item {
return direction;
}
function activateGroup(groupId) {
switch (groupId) {
case "media":
if (root.controller.mediaAvailable) {
root.controller.requestActivity("media", false, false);
return;
}
root.controller.requestLauncher("", "", false);
return;
case "weather":
root.controller.requestWeather(false);
return;
case "notifications":
root.controller.requestNotificationCenter(false);
return;
case "volume":
case "brightness":
root.systemModel.open(groupId);
return;
}
root.controller.requestControlCenter("", false);
}
function adjustSystemLevel(activityId, delta) {
const direction = root.wheelDirection(delta);
if (direction === 0)
@@ -104,10 +138,15 @@ Item {
WeatherService.removeRef();
}
function pushMeasuredLength() {
root.controller.setHomeContentLength(root.isVertical ? compactColumn.implicitHeight : compactRow.implicitWidth);
}
onWeatherSlotEnabledChanged: root.syncWeatherRef(root.weatherSlotEnabled)
onIsVerticalChanged: root.pushMeasuredLength()
Component.onCompleted: {
root.syncWeatherRef(root.weatherSlotEnabled);
root.controller.setHomeContentWidth(compactRow.implicitWidth);
root.pushMeasuredLength();
}
Component.onDestruction: root.syncWeatherRef(false)
@@ -115,7 +154,15 @@ Item {
target: compactRow
function onImplicitWidthChanged() {
root.controller.setHomeContentWidth(compactRow.implicitWidth);
root.pushMeasuredLength();
}
}
Connections {
target: compactColumn
function onImplicitHeightChanged() {
root.pushMeasuredLength();
}
}
@@ -192,12 +239,15 @@ Item {
Row {
id: clockRow
readonly property string displayMode: root.controller.homeClockDisplay
anchors.verticalCenter: parent.verticalCenter
visible: item.isClock
spacing: Theme.spacingS
NumericText {
anchors.verticalCenter: parent.verticalCenter
visible: clockRow.displayMode !== "date"
isMonospace: false
text: root.timeText
reserveText: root.timeText.replace(/\d/g, "0")
@@ -208,6 +258,7 @@ Item {
Rectangle {
anchors.verticalCenter: parent.verticalCenter
visible: clockRow.displayMode === "both"
width: Theme.spacingXS
height: Theme.spacingXS
radius: height / 2
@@ -216,6 +267,7 @@ Item {
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: clockRow.displayMode !== "time"
text: root.dateText
color: Theme.surfaceTextMedium
font.pixelSize: root.textSize
@@ -301,8 +353,8 @@ Item {
thickness: root.statusIconSize
fontSize: root.textSize
hovered: groupArea.containsMouse
meterStyle: SettingsData.dankIslandBatteryStyle
levelColors: (SettingsData.islandBarConfig?.batteryColorMode ?? "theme") === "level"
meterStyle: root.controller.batteryStyle
levelColors: (root.controller.barConfig?.batteryColorMode ?? "theme") === "level"
}
DankIcon {
@@ -322,29 +374,201 @@ Item {
height: root.height
enabled: !item.isClock
controller: root.controller
onClicked: {
if (item.isMedia && root.controller.mediaAvailable) {
root.controller.requestActivity("media", false, false);
onClicked: root.activateGroup(item.groupId)
onWheel: wheel => {
if (!item.isVolume && !item.isBrightness)
return;
}
if (item.isMedia) {
root.controller.requestLauncher("", "", false);
return;
}
if (item.isWeather) {
root.controller.requestWeather(false);
return;
}
if (item.isNotifications) {
root.controller.requestNotificationCenter(false);
return;
}
if (item.isVolume || item.isBrightness) {
root.systemModel.open(item.groupId);
return;
}
root.controller.requestControlCenter("", false);
root.adjustSystemLevel(item.groupId, wheel.angleDelta.y || wheel.angleDelta.x);
wheel.accepted = true;
}
}
}
// Side edges only have the strip's width to work with, so every slot stacks its own content.
component VerticalGroupItem: Item {
id: item
required property string groupId
required property int slotIndex
readonly property real leadPad: item.slotIndex === 0 ? root.edgePad : root.groupSpacing / 2
readonly property real trailPad: item.slotIndex === root.groupIds.length - 1 ? root.edgePad : root.groupSpacing / 2
readonly property bool isClock: item.groupId === "clock"
readonly property bool isMedia: item.groupId === "media"
readonly property bool isWeather: item.groupId === "weather"
readonly property bool isStatus: item.groupId === "status"
readonly property bool isNotifications: item.groupId === "notifications"
readonly property bool isVolume: item.groupId === "volume"
readonly property bool isBrightness: item.groupId === "brightness"
readonly property bool usesBattery: item.isStatus && BatteryService.batteryAvailable
width: root.width
height: stack.implicitHeight
Rectangle {
anchors.centerIn: parent
visible: !item.isClock
width: root.slotSize
height: parent.height + Theme.spacingXS
radius: Theme.cornerRadius
color: groupArea.containsMouse ? Theme.surfaceTextHover : "transparent"
}
Column {
id: stack
anchors.centerIn: parent
spacing: 0
NumericText {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isClock && root.clockDisplay !== "date"
isMonospace: false
text: root.hourText
reserveText: "00"
color: Theme.surfaceText
font.pixelSize: root.textSize
}
NumericText {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isClock && root.clockDisplay !== "date"
isMonospace: false
text: root.minuteText
reserveText: "00"
color: Theme.surfaceTextMedium
font.pixelSize: root.textSize
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isClock && root.clockDisplay === "both"
width: Theme.spacingXS
height: root.textSize
Rectangle {
anchors.centerIn: parent
width: Theme.spacingXS
height: Theme.spacingXS
radius: height / 2
color: Theme.primary
}
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isClock && root.clockDisplay !== "time"
text: root.monthText
color: Theme.surfaceTextMedium
font.pixelSize: root.textSize
}
NumericText {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isClock && root.clockDisplay !== "time"
isMonospace: false
text: root.dayText
reserveText: "00"
color: Theme.surfaceTextMedium
font.pixelSize: root.textSize
}
AudioVisualization {
anchors.horizontalCenter: parent.horizontalCenter
width: root.iconSize + Theme.spacingXS
height: width
maxBarHeight: Math.max(3, height - 2)
idleIconName: "graphic_eq"
visible: item.isMedia && root.controller.mediaAvailable
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isMedia && !root.controller.mediaAvailable
name: "search"
size: root.iconSize
color: Theme.surfaceTextMedium
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isWeather
name: WeatherService.getWeatherIcon(WeatherService.weather.wCode)
size: root.statusIconSize
color: Theme.surfaceTextSecondary
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isWeather
text: WeatherService.currentTempText()
color: Theme.surfaceTextSecondary
font.pixelSize: root.textSize
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isNotifications
name: "notifications"
size: root.statusIconSize
color: Theme.secondary
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isNotifications
text: root.controller.unreadNotificationCount
color: Theme.surfaceTextSecondary
font.pixelSize: root.textSize
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isVolume || item.isBrightness
name: item.isVolume ? AudioService.sinkVolumeIconName : DisplayService.brightnessIconName(root.brightnessDevice, DisplayService.brightnessLevel)
size: root.statusIconSize
color: Theme.surfaceTextSecondary
}
NumericText {
anchors.horizontalCenter: parent.horizontalCenter
visible: (item.isVolume || item.isBrightness) && (item.isVolume ? root.controller.homeVolumeDisplay : root.controller.homeBrightnessDisplay) !== "icon"
text: item.isVolume ? root.volumePercent : root.brightnessPercent
reserveText: item.isVolume ? String(AudioService.sinkMaxVolume) : "100"
color: Theme.surfaceTextSecondary
font.pixelSize: root.textSize
}
BatteryMeter {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.usesBattery
vertical: true
thickness: root.statusIconSize
fontSize: root.textSize
hovered: groupArea.containsMouse
meterStyle: root.controller.batteryStyle
levelColors: (root.controller.barConfig?.batteryColorMode ?? "theme") === "level"
}
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
visible: item.isStatus && !item.usesBattery
name: "tune"
size: root.iconSize
color: Theme.surfaceText
}
}
IslandSlotHoverArea {
id: groupArea
anchors.horizontalCenter: parent.horizontalCenter
y: -item.leadPad
width: root.width
height: parent.height + item.leadPad + item.trailPad
enabled: !item.isClock
controller: root.controller
onClicked: root.activateGroup(item.groupId)
onWheel: wheel => {
if (!item.isVolume && !item.isBrightness)
return;
@@ -358,11 +582,12 @@ Item {
id: compactRow
anchors.centerIn: parent
visible: !root.isVertical
height: root.slotSize
spacing: root.groupSpacing
Repeater {
model: root.groupIds
model: root.isVertical ? [] : root.groupIds
GroupItem {
required property var modelData
@@ -372,4 +597,24 @@ Item {
}
}
}
Column {
id: compactColumn
anchors.centerIn: parent
visible: root.isVertical
width: root.width
spacing: root.groupSpacing
Repeater {
model: root.isVertical ? root.groupIds : []
VerticalGroupItem {
required property var modelData
required property int index
groupId: String(modelData)
slotIndex: index
}
}
}
}
@@ -14,6 +14,7 @@ Item {
required property var controller
readonly property bool dense: root.controller.compactDense
readonly property bool isVertical: root.controller.isVertical
readonly property real artworkSize: root.controller.compactIconSize
readonly property real textGutter: Theme.spacingXS
readonly property real cavaWidth: 20
@@ -21,8 +22,18 @@ Item {
readonly property real clockLeadPad: Theme.spacingXS / 2
readonly property real clockTrailPad: Theme.spacingS
readonly property real clockPillHeight: root.dense ? Math.max(22, root.artworkSize - Theme.spacingXS) : 32
// A fully rounded side strip crowds square content, so the vertical face insets on both axes.
readonly property real verticalPad: Theme.spacingS
readonly property real verticalArtworkSize: Math.max(12, Math.min(root.artworkSize, root.width - root.verticalPad * 2))
readonly property string timeText: systemClock.date.toLocaleTimeString(I18n.locale(), SettingsData.getEffectiveTimeFormat())
readonly property bool clockVisible: SettingsData.dankIslandMediaClockVisible
readonly property string hourText: {
const hours = systemClock.date.getHours();
if (SettingsData.use24HourClock)
return String(hours).padStart(2, "0");
return String(hours === 0 ? 12 : (hours > 12 ? hours - 12 : hours)).padStart(2, "0");
}
readonly property string minuteText: String(systemClock.date.getMinutes()).padStart(2, "0")
readonly property bool clockVisible: root.controller.mediaClockVisible
readonly property real naturalTextWidth: Math.max(root.minTextWidth, titleMetrics.width, root.dense ? 0 : artistMetrics.width) + root.textGutter * 2
readonly property real measuredWidth: {
let width = Theme.spacingS + root.artworkSize + Theme.spacingXS + root.cavaWidth + Theme.spacingXS + root.naturalTextWidth + Theme.spacingXS;
@@ -31,12 +42,13 @@ Item {
return width;
}
function pushMeasuredWidth() {
root.controller.setMediaContentWidth(root.measuredWidth);
function pushMeasuredLength() {
root.controller.setMediaContentLength(root.isVertical ? verticalFace.implicitHeight + root.verticalPad * 2 : root.measuredWidth);
}
onMeasuredWidthChanged: root.pushMeasuredWidth()
Component.onCompleted: root.pushMeasuredWidth()
onMeasuredWidthChanged: root.pushMeasuredLength()
onIsVerticalChanged: root.pushMeasuredLength()
Component.onCompleted: root.pushMeasuredLength()
StyledTextMetrics {
id: titleMetrics
@@ -67,12 +79,14 @@ Item {
leftMargin: Theme.spacingS
verticalCenter: parent.verticalCenter
}
visible: !root.isVertical
width: root.artworkSize
height: width
artUrl: root.mediaModel.artUrl
}
Row {
visible: !root.isVertical
anchors {
left: artwork.right
leftMargin: Theme.spacingXS
@@ -133,7 +147,7 @@ Item {
}
width: root.clockVisible ? (clockText.width + Theme.spacingXS * 2) : 0
height: root.clockPillHeight
visible: root.clockVisible
visible: root.clockVisible && !root.isVertical
Rectangle {
anchors.fill: parent
@@ -166,4 +180,74 @@ Item {
}
}
}
// A side strip has no room for track text, so the vertical face is artwork over the meter.
Column {
id: verticalFace
anchors.centerIn: parent
visible: root.isVertical
spacing: Theme.spacingXS
onImplicitHeightChanged: root.pushMeasuredLength()
MediaArtwork {
anchors.horizontalCenter: parent.horizontalCenter
width: root.verticalArtworkSize
height: width
artUrl: root.mediaModel.artUrl
}
AudioVisualization {
anchors.horizontalCenter: parent.horizontalCenter
width: root.verticalArtworkSize
height: root.cavaWidth
maxBarHeight: Math.max(3, height - 2)
idleIconName: "graphic_eq"
}
Item {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.clockVisible
width: verticalClock.width
height: verticalClock.height
// A side strip cannot fit "10:42 PM" on one line, so the hour sits over the minute.
Column {
id: verticalClock
spacing: 0
NumericText {
anchors.horizontalCenter: parent.horizontalCenter
isMonospace: false
text: root.hourText
reserveText: "00"
horizontalAlignment: Text.AlignHCenter
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
NumericText {
anchors.horizontalCenter: parent.horizontalCenter
isMonospace: false
text: root.minuteText
reserveText: "00"
horizontalAlignment: Text.AlignHCenter
color: Theme.surfaceTextMedium
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
IslandSlotHoverArea {
anchors.centerIn: parent
width: root.width
height: parent.height + Theme.spacingXS
controller: root.controller
onClicked: root.controller.requestActivity("home", false, false)
}
}
}
}
@@ -12,18 +12,33 @@ Item {
property bool dense: false
property real iconSize: 36
readonly property bool isVertical: root.controller.isVertical
readonly property string headerText: root.notificationModel.appName + (root.notificationModel.timeText ? " · " + root.notificationModel.timeText : "")
readonly property string summaryText: root.dense && root.notificationModel.appName ? root.notificationModel.appName + " · " + root.notificationModel.summary : root.notificationModel.summary
readonly property real measuredWidth: Theme.spacingS * 3 + root.iconSize + Theme.spacingXS + Math.max(root.dense ? 0 : headerLabel.implicitWidth, summaryLabel.implicitWidth)
function pushMeasuredWidth() {
root.controller.setNotificationContentWidth(root.measuredWidth);
function pushMeasuredLength() {
root.controller.setNotificationContentLength(root.isVertical ? root.iconSize + Theme.spacingS * 2 : root.measuredWidth);
}
onMeasuredWidthChanged: root.pushMeasuredWidth()
Component.onCompleted: root.pushMeasuredWidth()
onMeasuredWidthChanged: root.pushMeasuredLength()
onIsVerticalChanged: root.pushMeasuredLength()
Component.onCompleted: root.pushMeasuredLength()
// A side strip only fits the sender's icon; the text lives in the expanded face.
DankCircularImage {
anchors.centerIn: parent
visible: root.isVertical
width: root.iconSize
height: width
imageSource: root.notificationModel.imageSource
fallbackIcon: root.notificationModel.fallbackIcon
fallbackText: root.notificationModel.fallbackText
cacheImages: false
}
Row {
visible: !root.isVertical
anchors {
fill: parent
leftMargin: Theme.spacingS
@@ -3,27 +3,149 @@ pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Modules.OSD
import qs.Widgets
Item {
id: root
required property var systemModel
property bool isVertical: false
property real iconSize: 36
readonly property int maximum: Math.max(root.systemModel.minimum + 1, Math.round(root.systemModel.maximum))
readonly property real fillRatio: Math.max(0, Math.min(1, (root.systemModel.value - root.systemModel.minimum) / Math.max(1, root.maximum - root.systemModel.minimum)))
// DankSlider's own metrics, transposed, so the two orientations read as the same control.
readonly property real trackThickness: 12
readonly property real handleThickness: 4
readonly property real handleBreadth: 20
readonly property real fillHandleGap: 3
function trackFraction(localY) {
if (track.travel <= 0)
return 0;
return 1 - (localY - root.handleThickness / 2) / track.travel;
}
function applyFraction(fraction) {
const clamped = Math.max(0, Math.min(1, fraction));
const level = root.systemModel.minimum + clamped * (root.maximum - root.systemModel.minimum);
root.systemModel.setRatio(level / Math.max(1, root.maximum));
}
OsdLevelRow {
anchors.fill: parent
visible: !root.isVertical
iconName: root.systemModel.iconName
iconSize: Math.min(Theme.iconSize, root.iconSize)
iconColor: root.systemModel.volumeActivity ? Theme.surfaceText : Theme.primary
iconInteractive: root.systemModel.volumeActivity
value: Math.round(root.systemModel.value)
minimum: root.systemModel.minimum
maximum: Math.max(root.systemModel.minimum + 1, Math.round(root.systemModel.maximum))
maximum: root.maximum
unit: root.systemModel.unit
displayText: root.systemModel.displayValue
sliderEnabled: root.systemModel.available
thumbOutlineColor: Theme.surfaceContainerHigh
onIconClicked: root.systemModel.toggleMute()
onSliderValueChanged: newValue => root.systemModel.setRatio(newValue / Math.max(1, Math.round(root.systemModel.maximum)))
onSliderValueChanged: newValue => root.systemModel.setRatio(newValue / Math.max(1, root.maximum))
}
// DankSlider is horizontal only, so a side strip gets its own stacked track.
Item {
id: verticalFace
anchors.fill: parent
anchors.topMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
visible: root.isVertical
DankIcon {
id: levelIcon
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
name: root.systemModel.iconName
size: Math.min(Theme.iconSize, root.iconSize)
color: root.systemModel.volumeActivity ? Theme.surfaceText : Theme.primary
MouseArea {
anchors.fill: parent
enabled: root.systemModel.volumeActivity
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.systemModel.toggleMute()
}
}
StyledText {
id: levelLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
text: root.systemModel.displayValue.length > 0 ? root.systemModel.displayValue : (Math.round(root.systemModel.value) + root.systemModel.unit)
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
Item {
id: track
readonly property real travel: Math.max(0, height - root.handleThickness)
readonly property real handleOffset: track.travel * root.fillRatio
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: levelIcon.bottom
anchors.topMargin: Theme.spacingS
anchors.bottom: levelLabel.top
anchors.bottomMargin: Theme.spacingS
width: root.handleBreadth
Rectangle {
id: groove
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.trackThickness
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.outline, Theme.popupTransparency)
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.max(0, Math.min(parent.height, track.handleOffset - root.fillHandleGap))
radius: Theme.cornerRadius
topLeftRadius: 0
topRightRadius: 0
color: root.systemModel.available ? Theme.primary : Theme.withAlpha(Theme.surfaceText, 0.12)
}
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: track.travel - track.handleOffset
width: root.handleBreadth
height: root.handleThickness
radius: Theme.cornerRadius
color: root.systemModel.available ? Theme.primary : Theme.withAlpha(Theme.surfaceText, 0.12)
}
MouseArea {
id: trackArea
readonly property real grabPad: Theme.spacingS
anchors.fill: parent
anchors.margins: -grabPad
enabled: root.systemModel.available
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: mouse => root.applyFraction(root.trackFraction(mouse.y - grabPad))
onPositionChanged: mouse => {
if (pressed)
root.applyFraction(root.trackFraction(mouse.y - grabPad));
}
}
}
}
}
+28 -4
View File
@@ -3,12 +3,34 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
Item {
id: root
property var screenModel: Quickshell.screens
property var hyprlandOverviewLoader: null
// One host per (island instance, screen), skipping a screen edge another island already holds.
readonly property var hostSlots: {
SettingsData.barConfigs;
Quickshell.screens;
const slots = [];
for (const screen of Quickshell.screens) {
const claimed = {};
for (const config of SettingsData.activeIslandConfigsForScreen(screen)) {
const edge = SettingsData.islandEdge(config);
if (claimed[edge])
continue;
claimed[edge] = true;
slots.push({
"barId": config.id,
"screen": screen
});
}
}
return slots;
}
readonly property bool launcherOpen: root.activityOpen("launcher")
readonly property bool controlCenterOpen: root.activityOpen("controlcenter")
@@ -217,7 +239,7 @@ Item {
"notificationCenterAvailable": true,
"launcherInputFocused": host.islandController.launcherInputFocused,
"launcherResultCount": host.launcherResultCount,
"compactHeight": host.islandController.compactHeight
"compactHeight": host.islandController.compactThickness
});
}
@@ -230,12 +252,14 @@ Item {
Variants {
id: islandVariants
model: root.screenModel
model: root.hostSlots
delegate: DankIslandHostWindow {
required property var modelData
screen: modelData
screen: modelData.screen
barId: modelData.barId
hyprlandOverviewLoader: root.hyprlandOverviewLoader
}
}
@@ -12,6 +12,8 @@ import qs.Widgets
PanelWindow {
id: root
property var hyprlandOverviewLoader: null
function requestKeyboardFocus() {
if (!controller.keyboardDismissRequested) {
keyboardActivationTimer.stop();
@@ -26,7 +28,7 @@ PanelWindow {
function containsGlobalPoint(gx, gy, padding) {
const pad = padding !== undefined ? padding : 16;
const items = [surface.inputMaskItem, surface.fittsStripItem, satelliteHost.leftInputItem, satelliteHost.rightInputItem];
const items = [surface.inputMaskItem, surface.fittsStripItem, satelliteHost.leadingInputItem, satelliteHost.trailingInputItem];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!item || item.width <= 0 || item.height <= 0)
@@ -35,40 +37,55 @@ PanelWindow {
if (!topLeft)
continue;
// mapToItem(null) is window-local; callers pass screen-space cursor coords.
const left = topLeft.x + root.hostOriginX;
const top = topLeft.y + root.hostOriginY;
if (gx >= topLeft.x - pad && gx < topLeft.x + item.width + pad && gy >= top - pad && gy < top + item.height + pad)
if (gx >= left - pad && gx < left + item.width + pad && gy >= top - pad && gy < top + item.height + pad)
return true;
}
return false;
}
readonly property int reserveHeight: Math.max(24, Math.min(128, SettingsData.dankIslandReserveHeight))
readonly property int compactHeight: Math.max(24, Math.min(72, SettingsData.dankIslandCompactHeight))
readonly property bool floating: SettingsData.dankIslandFloating
readonly property bool usesOverlayLayer: LayerShell.envUsesOverlay("DMS_DANKISLAND_LAYER", SettingsData.dankIslandUseOverlayLayer)
required property string barId
readonly property var barConfig: {
SettingsData.barConfigs;
return SettingsData.getBarConfig(root.barId);
}
function setting(key) {
return SettingsData.islandSetting(root.barConfig, key);
}
readonly property int reserveThickness: Math.max(24, Math.min(128, root.setting("islandReserveThickness")))
readonly property int compactThickness: Math.max(24, Math.min(72, root.setting("islandCompactThickness")))
readonly property bool floating: root.setting("islandFloating")
readonly property bool usesOverlayLayer: LayerShell.envUsesOverlay("DMS_DANKISLAND_LAYER", root.setting("islandUseOverlayLayer"))
readonly property var islandLayer: LayerShell.fromEnv("DMS_DANKISLAND_LAYER", usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top)
readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom"
readonly property int reservedStripHeight: Math.max(reserveHeight, outerGap + compactHeight)
readonly property int stripY: root.bottomEdge ? root.height - root.reservedStripHeight : 0
readonly property int hostOriginY: root.bottomEdge ? Math.max(0, (root.screen?.height ?? 0) - root.height) : 0
readonly property int outerGap: Math.max(0, Math.min(48, SettingsData.dankIslandOuterGap))
readonly property string edge: SettingsData.islandEdge(root.barConfig)
readonly property bool isVertical: SettingsData.islandVertical(root.barConfig)
readonly property bool farEdge: root.edge === "bottom" || root.edge === "right"
readonly property int reservedStripThickness: Math.max(reserveThickness, outerGap + compactThickness)
readonly property int stripPos: root.farEdge ? (root.isVertical ? root.width : root.height) - root.reservedStripThickness : 0
readonly property int hostOriginX: root.isVertical && root.farEdge ? Math.max(0, (root.screen?.width ?? 0) - root.width) : 0
readonly property int hostOriginY: !root.isVertical && root.farEdge ? Math.max(0, (root.screen?.height ?? 0) - root.height) : 0
readonly property int outerGap: Math.max(0, Math.min(48, root.setting("islandOuterGap")))
readonly property int maxActivityHeight: Math.max(560, Math.min(680, (screen?.height ?? 1080) - 200))
readonly property int hostHeight: outerGap + maxActivityHeight + 8
readonly property int maxActivityWidth: 736
readonly property real maximumHorizontalOffset: Math.max(0, (width - maxActivityWidth) / 2 - 8)
readonly property int maxActivityWidth: Math.min(736, Math.max(320, (screen?.width ?? 1920) - 200))
readonly property int hostThickness: outerGap + (root.isVertical ? maxActivityWidth : maxActivityHeight) + 8
readonly property real maximumAlongOffset: root.isVertical ? Math.max(0, (height - maxActivityHeight) / 2 - 8) : Math.max(0, (width - maxActivityWidth) / 2 - 8)
property bool keyboardFocusArmed: true
color: "transparent"
implicitHeight: hostHeight
exclusiveZone: floating ? 0 : reservedStripHeight
implicitHeight: hostThickness
implicitWidth: hostThickness
exclusiveZone: floating ? 0 : reservedStripThickness
readonly property alias islandController: controller
readonly property int launcherResultCount: launcherController.flatModel?.length ?? 0
anchors {
top: !root.bottomEdge
bottom: root.bottomEdge
left: true
right: true
top: root.isVertical || root.edge === "top"
bottom: root.isVertical || root.edge === "bottom"
left: !root.isVertical || root.edge === "left"
right: !root.isVertical || root.edge === "right"
}
WlrLayershell.namespace: "dms:dankisland"
@@ -131,11 +148,11 @@ PanelWindow {
}
Region {
item: controller.inputSuspended ? null : satelliteHost.leftInputItem
item: controller.inputSuspended ? null : satelliteHost.leadingInputItem
}
Region {
item: controller.inputSuspended ? null : satelliteHost.rightInputItem
item: controller.inputSuspended ? null : satelliteHost.trailingInputItem
}
Region {
@@ -150,18 +167,27 @@ PanelWindow {
IslandController {
id: controller
interactionMode: SettingsData.dankIslandInteractionMode === "click" ? "click" : "hybrid"
barConfig: root.barConfig
edge: root.edge
interactionMode: root.setting("islandInteractionMode") === "click" ? "click" : "hybrid"
inputSuspended: PopoutManager.screenshotActive
horizontalOffset: Math.max(-root.maximumHorizontalOffset, Math.min(root.maximumHorizontalOffset, SettingsData.dankIslandHorizontalOffset))
alongOffset: Math.max(-root.maximumAlongOffset, Math.min(root.maximumAlongOffset, root.setting("islandAlongOffset")))
outerGap: root.outerGap
compactHeight: root.compactHeight
compactThickness: root.compactThickness
cornerRadius: Math.max(0, Math.min(64, root.setting("islandCornerRadius")))
homeCompactTight: root.setting("islandHomeCompactTight")
homeClockDisplay: SettingsData.islandClockDisplay(root.barConfig)
homeVolumeDisplay: SettingsData.islandLevelDisplay(root.barConfig, "islandHomeVolumeDisplay")
homeBrightnessDisplay: SettingsData.islandLevelDisplay(root.barConfig, "islandHomeBrightnessDisplay")
batteryStyle: root.setting("islandBatteryStyle")
mediaClockVisible: root.setting("islandMediaClockVisible")
launcherCycleEnabled: SettingsData.launcherStyle === "island"
controlCenterMaxHeight: root.maxActivityHeight
notificationCenterMaxHeight: root.maxActivityHeight
notificationExpandAllowed: SettingsData.dankIslandNotificationExpand
unreadNotificationCount: SettingsData.dankIslandNotificationBadgeClearOnOpen ? NotificationService.unreadCount : NotificationService.notifications.length
hoverOpenDelay: Math.max(0, Math.min(1000, SettingsData.dankIslandHoverOpenDelay))
hoverCloseDelay: Math.max(0, Math.min(1000, SettingsData.dankIslandHoverCloseDelay))
notificationExpandAllowed: root.setting("islandNotificationExpand")
unreadNotificationCount: root.setting("islandNotificationBadgeClearOnOpen") ? NotificationService.unreadCount : NotificationService.notifications.length
hoverOpenDelay: Math.max(0, Math.min(1000, root.setting("islandHoverOpenDelay")))
hoverCloseDelay: Math.max(0, Math.min(1000, root.setting("islandHoverCloseDelay")))
}
DankLauncher.Controller {
@@ -221,11 +247,11 @@ PanelWindow {
MouseArea {
id: satelliteDismissStrip
x: 0
y: root.stripY
x: root.isVertical ? root.stripPos : 0
y: root.isVertical ? 0 : root.stripPos
z: -2
width: parent.width
height: root.reservedStripHeight
width: root.isVertical ? root.reservedStripThickness : parent.width
height: root.isVertical ? parent.height : root.reservedStripThickness
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: false
enabled: root.satelliteSurfacesOpen && !controller.inputSuspended
@@ -235,11 +261,11 @@ PanelWindow {
BarScrollArea {
id: scrollStrip
x: 0
y: root.stripY
x: root.isVertical ? root.stripPos : 0
y: root.isVertical ? 0 : root.stripPos
z: -1
width: parent.width
height: root.reservedStripHeight
width: root.isVertical ? root.reservedStripThickness : parent.width
height: root.isVertical ? parent.height : root.reservedStripThickness
hoverEnabled: false
enabled: satelliteHost.scrollEnabled && !controller.inputSuspended
scrollEnabled: satelliteHost.scrollEnabled
@@ -261,13 +287,15 @@ PanelWindow {
launcherTransientSurfaceTracker: launcherTransientSurfaces
notificationTransientSurfaceTracker: notificationTransientSurfaces
effectiveScreen: root.screen
hostOriginX: root.hostOriginX
hostOriginY: root.hostOriginY
reducedMotion: SettingsData.dankIslandReducedMotion || SettingsData.reduceMotion || SettingsData.animationSpeed === SettingsData.AnimationSpeed.None
springStiffness: Math.max(100, Math.min(1200, SettingsData.dankIslandSpringStiffness))
springDamping: Math.max(10, Math.min(100, SettingsData.dankIslandSpringDamping))
springMass: Math.max(0.25, Math.min(3, SettingsData.dankIslandSpringMass))
palette: SettingsData.dankIslandPalette
highContrast: SettingsData.dankIslandHighContrast
reducedMotion: root.setting("islandReducedMotion") || SettingsData.reduceMotion || SettingsData.animationSpeed === SettingsData.AnimationSpeed.None
springStiffness: Math.max(100, Math.min(1200, root.setting("islandSpringStiffness")))
springDamping: Math.max(10, Math.min(100, root.setting("islandSpringDamping")))
springMass: Math.max(0.25, Math.min(3, root.setting("islandSpringMass")))
palette: root.setting("islandPalette")
highContrast: root.setting("islandHighContrast")
transparency: root.setting("islandTransparency")
onScrollWheel: wheel => scrollStrip.processWheel(wheel)
}
@@ -277,9 +305,11 @@ PanelWindow {
anchors.fill: parent
hostWindow: root
targetScreen: root.screen
barConfig: root.barConfig
controller: controller
islandSurface: surface
outerGap: root.outerGap
hyprlandOverviewLoader: root.hyprlandOverviewLoader
}
FocusScope {
@@ -325,12 +355,12 @@ PanelWindow {
}
Region {
item: leftSatelliteHole
item: leadingSatelliteHole
intersection: Intersection.Subtract
}
Region {
item: rightSatelliteHole
item: trailingSatelliteHole
intersection: Intersection.Subtract
}
}
@@ -346,7 +376,7 @@ PanelWindow {
Item {
id: islandHole
x: surface.inputMaskItem.x
x: surface.inputMaskItem.x + root.hostOriginX
y: surface.inputMaskItem.y + root.hostOriginY
width: surface.inputMaskItem.width
height: surface.inputMaskItem.height
@@ -355,28 +385,28 @@ PanelWindow {
Item {
id: fittsStripHole
x: surface.fittsStripItem.x
x: surface.fittsStripItem.x + root.hostOriginX
y: surface.fittsStripItem.y + root.hostOriginY
width: surface.fittsStripItem.visible ? surface.fittsStripItem.width : 0
height: surface.fittsStripItem.visible ? surface.fittsStripItem.height : 0
}
Item {
id: leftSatelliteHole
id: leadingSatelliteHole
x: satelliteHost.leftInputItem.x
y: satelliteHost.leftInputItem.y + root.hostOriginY
width: satelliteHost.leftInputItem.width
height: satelliteHost.leftInputItem.height
x: satelliteHost.leadingInputItem.x + root.hostOriginX
y: satelliteHost.leadingInputItem.y + root.hostOriginY
width: satelliteHost.leadingInputItem.width
height: satelliteHost.leadingInputItem.height
}
Item {
id: rightSatelliteHole
id: trailingSatelliteHole
x: satelliteHost.rightInputItem.x
y: satelliteHost.rightInputItem.y + root.hostOriginY
width: satelliteHost.rightInputItem.width
height: satelliteHost.rightInputItem.height
x: satelliteHost.trailingInputItem.x + root.hostOriginX
y: satelliteHost.trailingInputItem.y + root.hostOriginY
width: satelliteHost.trailingInputItem.width
height: satelliteHost.trailingInputItem.height
}
MouseArea {
@@ -22,9 +22,11 @@ Item {
property real springStiffness: 560
property real springDamping: 37
property real springMass: 1
property real hostOriginX: 0
property real hostOriginY: 0
property string palette: "default"
property bool highContrast: false
property real transparency: 1
readonly property color surfaceColor: {
if (root.highContrast)
@@ -38,7 +40,7 @@ Item {
return Theme.surfaceContainerHigh;
}
readonly property bool popupStyled: root.controller.expanded
readonly property real islandOpacity: Math.max(0, Math.min(1, SettingsData.dankIslandTransparency))
readonly property real islandOpacity: Math.max(0, Math.min(1, root.transparency))
readonly property color effectiveSurfaceColor: root.highContrast ? Theme.surfaceContainerHighest : Theme.withAlpha(root.surfaceColor, root.islandOpacity)
readonly property real surfaceOpacity: root.effectiveSurfaceColor.a
readonly property real currentSurfaceRadius: Math.max(0, motion.currentTopLeftRadius, motion.currentBottomLeftRadius)
@@ -58,32 +60,51 @@ Item {
readonly property alias fittsStripItem: fittsStrip
readonly property bool motionRunning: motion.running
readonly property real springTimeConstantMs: motion.timeConstantMs
property real trackedHeight: 0
property real fadeCompactHeight: 48
property real fadeExpandedHeight: 352
property real trackedCrossExtent: 0
property real fadeCompactCross: 48
property real fadeExpandedCross: 352
property rect motionStartBounds: Qt.rect(0, 0, 0, 0)
readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom"
readonly property bool isVertical: root.controller.isVertical
readonly property bool farEdge: root.controller.edge === "bottom" || root.controller.edge === "right"
readonly property real alongExtent: root.isVertical ? root.height : root.width
readonly property real crossExtent: root.isVertical ? root.width : root.height
readonly property real currentVisualWidth: motion.currentWidth
readonly property real currentVisualHeight: motion.currentHeight
readonly property real currentVisualX: (width - currentVisualWidth) / 2 + motion.currentOffsetX
readonly property real currentVisualY: bottomEdge ? height - motion.currentOffsetY - currentVisualHeight : motion.currentOffsetY
readonly property real targetVisualX: Math.round((width - motion.targetWidth) / 2 + motion.targetOffsetX)
readonly property real targetVisualY: bottomEdge ? height - Math.round(motion.targetOffsetY) - motion.targetHeight : Math.round(motion.targetOffsetY)
readonly property real currentVisualCross: isVertical ? motion.currentWidth : motion.currentHeight
readonly property real currentAlongPos: (alongExtent - (isVertical ? currentVisualHeight : currentVisualWidth)) / 2 + motion.currentOffsetAlong
readonly property real currentCrossPos: farEdge ? crossExtent - motion.currentOffsetCross - currentVisualCross : motion.currentOffsetCross
readonly property real currentVisualX: isVertical ? currentCrossPos : currentAlongPos
readonly property real currentVisualY: isVertical ? currentAlongPos : currentCrossPos
readonly property real targetAlongPos: Math.round((alongExtent - (isVertical ? motion.targetHeight : motion.targetWidth)) / 2 + motion.targetOffsetAlong)
readonly property real targetCrossPos: farEdge ? crossExtent - Math.round(motion.targetOffsetCross) - (isVertical ? motion.targetWidth : motion.targetHeight) : Math.round(motion.targetOffsetCross)
readonly property real targetVisualX: isVertical ? targetCrossPos : targetAlongPos
readonly property real targetVisualY: isVertical ? targetAlongPos : targetCrossPos
readonly property real targetScreenX: targetVisualX + root.hostOriginX
readonly property real targetScreenY: targetVisualY + root.hostOriginY
readonly property real targetVisualWidth: motion.targetWidth
readonly property real targetVisualHeight: motion.targetHeight
readonly property real currentVisualAlong: isVertical ? motion.currentHeight : motion.currentWidth
readonly property real targetVisualAlong: isVertical ? motion.targetHeight : motion.targetWidth
readonly property real morphProgress: {
const span = fadeExpandedHeight - fadeCompactHeight;
const span = fadeExpandedCross - fadeCompactCross;
if (Math.abs(span) < 1)
return controller.expanded ? 1 : 0;
return Math.max(0, Math.min(1, (motion.currentHeight - fadeCompactHeight) / span));
return Math.max(0, Math.min(1, (currentVisualCross - fadeCompactCross) / span));
}
function descriptorCross(target) {
return root.isVertical ? target.width : target.height;
}
function descriptorAlong(target) {
return root.isVertical ? target.height : target.width;
}
function applyTarget() {
if (controller.expanded)
fadeExpandedHeight = controller.expandedTarget.height;
fadeExpandedCross = root.descriptorCross(controller.expandedTarget);
else
fadeCompactHeight = controller.compactTarget.height;
fadeCompactCross = root.descriptorCross(controller.compactTarget);
if (motion.running)
root.unionMotionStartBounds();
motion.setTarget(controller.targetDescriptor);
@@ -105,19 +126,21 @@ Item {
}
Component.onCompleted: {
trackedHeight = height;
fadeCompactHeight = controller.compactTarget.height;
fadeExpandedHeight = controller.expandedTarget.height;
trackedCrossExtent = crossExtent;
fadeCompactCross = root.descriptorCross(controller.compactTarget);
fadeExpandedCross = root.descriptorCross(controller.expandedTarget);
motion.snapTo(controller.targetDescriptor);
}
onHeightChanged: {
const delta = height - trackedHeight;
trackedHeight = height;
if (!bottomEdge || !motion.running || delta === 0)
// On a far edge the cross coordinate is measured from the far side, so a host resize
// shifts everything already in flight by the same delta.
onCrossExtentChanged: {
const delta = crossExtent - trackedCrossExtent;
trackedCrossExtent = crossExtent;
if (!farEdge || !motion.running || delta === 0)
return;
const b = motionStartBounds;
motionStartBounds = Qt.rect(b.x, b.y + delta, b.width, b.height);
motionStartBounds = isVertical ? Qt.rect(b.x + delta, b.y, b.width, b.height) : Qt.rect(b.x, b.y + delta, b.width, b.height);
}
Connections {
@@ -222,7 +245,9 @@ Item {
controller: root.controller
islandX: root.currentVisualX
islandY: root.currentVisualY
hostWidth: root.width
hostHeight: root.height
springTimeConstantMs: root.springTimeConstantMs
morphProgress: root.morphProgress
expanded: root.controller.expanded
@@ -265,14 +290,17 @@ Item {
Item {
id: fittsStrip
readonly property real spanWidth: Math.max(root.controller.compactTarget.width, motion.targetWidth)
readonly property real edgeGap: root.bottomEdge ? Math.max(0, root.height - (root.targetVisualY + motion.targetHeight)) : Math.max(0, root.targetVisualY)
readonly property real targetCross: root.isVertical ? motion.targetWidth : motion.targetHeight
readonly property real span: Math.max(root.descriptorAlong(root.controller.compactTarget), root.isVertical ? motion.targetHeight : motion.targetWidth)
readonly property real edgeGap: root.farEdge ? Math.max(0, root.crossExtent - (root.targetCrossPos + targetCross)) : Math.max(0, root.targetCrossPos)
readonly property real alongPos: Math.round((root.alongExtent - span) / 2 + motion.targetOffsetAlong)
readonly property real crossPos: root.farEdge ? root.targetCrossPos + targetCross : 0
x: Math.round((root.width - spanWidth) / 2 + motion.targetOffsetX)
y: root.bottomEdge ? root.targetVisualY + motion.targetHeight : 0
width: spanWidth
height: edgeGap
visible: edgeGap > 0 && spanWidth > 0
x: root.isVertical ? crossPos : alongPos
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? edgeGap : span
height: root.isVertical ? span : edgeGap
visible: edgeGap > 0 && span > 0
HoverHandler {
id: stripHover
@@ -358,7 +386,7 @@ Item {
launcherController: root.launcherController
transientSurfaceTracker: root.launcherTransientSurfaceTracker
effectiveScreen: root.effectiveScreen
alignedX: root.targetVisualX
alignedX: root.targetScreenX
alignedY: root.targetScreenY
}
}
@@ -380,7 +408,7 @@ Item {
ControlCenterExpanded {
controller: root.controller
effectiveScreen: root.effectiveScreen
alignedX: root.targetVisualX
alignedX: root.targetScreenX
alignedY: root.targetScreenY
alignedWidth: root.targetVisualWidth
alignedHeight: root.targetVisualHeight
@@ -431,6 +459,7 @@ Item {
SystemLevelCompact {
systemModel: root.systemModel
isVertical: root.isVertical
iconSize: root.controller.compactIconSize
}
}
@@ -14,7 +14,9 @@ Item {
required property var controller
required property real islandX
required property real islandY
required property real hostWidth
required property real hostHeight
required property real springTimeConstantMs
required property real morphProgress
required property bool expanded
@@ -137,12 +139,16 @@ Item {
}
}
// Pinned to the compact target's own screen slot so the face holds still while the island morphs.
component CompactFace: Loader {
required property string activity
readonly property var target: root.controller.compactTargetFor(activity)
readonly property bool isVertical: root.controller.isVertical
readonly property real alongPos: isVertical ? Math.round((root.hostHeight - target.height) / 2 + target.offsetAlong) - Math.round(root.islandY) : Math.round((root.hostWidth - target.width) / 2 + target.offsetAlong) - Math.round(root.islandX)
readonly property real crossPos: isVertical ? Math.round((parent.width - width) / 2) : Math.round((parent.height - height) / 2)
x: Math.round((root.hostWidth - target.width) / 2 + target.offsetX) - Math.round(root.islandX)
y: Math.round((parent.height - height) / 2)
x: isVertical ? crossPos : alongPos
y: isVertical ? alongPos : crossPos
width: target.width
height: target.height
asynchronous: false
@@ -29,14 +29,16 @@ QtObject {
property string controlCenterPendingSection: ""
property bool notificationExpandAllowed: false
property bool keyboardYielded: false
property real horizontalOffset: 0
property real alongOffset: 0
property real outerGap: 8
readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom"
readonly property real cornerRadius: Math.max(0, Math.min(64, SettingsData.dankIslandCornerRadius))
property var barConfig: null
property string edge: "top"
readonly property bool isVertical: edge === "left" || edge === "right"
property real cornerRadius: 34
readonly property real edgeCornerRadius: Math.round(cornerRadius * 0.75)
readonly property real topCornerRadius: bottomEdge ? cornerRadius : edgeCornerRadius
readonly property real bottomCornerRadius: bottomEdge ? edgeCornerRadius : cornerRadius
property real compactHeight: 38
property real compactThickness: 38
property string batteryStyle: "solid"
property bool mediaClockVisible: true
property int transientTimeout: 2200
property int notificationTimeout: 5000
property string transientReturnActivity: ""
@@ -51,38 +53,38 @@ QtObject {
readonly property real controlCenterHeight: Math.max(320, Math.min(controlCenterMaxHeight, destinationContentHeight("controlcenter")))
readonly property real notificationCenterHeight: Math.max(320, Math.min(notificationCenterMaxHeight, destinationContentHeight("notificationcenter")))
readonly property bool compactDense: compactHeight < 40
readonly property real compactFaceHeight: compactHeight + (compactDense ? 2 : 4)
readonly property real compactIconSize: Math.max(14, Math.min(32, compactHeight - 8))
readonly property bool homeCompactTight: SettingsData.dankIslandHomeCompactTight
readonly property real homeCompactFaceHeight: homeCompactTight ? Math.max(16, Math.min(32, compactHeight - 8)) : compactFaceHeight
readonly property bool compactDense: compactThickness < 40
readonly property real compactFaceThickness: compactThickness + (compactDense ? 2 : 4)
readonly property real compactIconSize: Math.max(14, Math.min(32, compactThickness - 8))
property bool homeCompactTight: false
readonly property real homeCompactFaceThickness: homeCompactTight ? Math.max(16, Math.min(32, compactThickness - 8)) : compactFaceThickness
property int unreadNotificationCount: 0
readonly property bool homeNotificationBadge: SettingsData.islandHomeGroupEnabled("notifications") && unreadNotificationCount > 0
readonly property bool homeWeatherEnabled: SettingsData.weatherEnabled && SettingsData.islandHomeGroupEnabled("weather")
readonly property bool homeNotificationBadge: SettingsData.islandHomeGroupEnabled(root.barConfig, "notifications") && unreadNotificationCount > 0
readonly property bool homeWeatherEnabled: SettingsData.weatherEnabled && SettingsData.islandHomeGroupEnabled(root.barConfig, "weather")
readonly property real homeSlotMargin: homeCompactTight ? Theme.spacingS : Theme.spacingM
property real homeContentWidth: 200
property real mediaContentWidth: 360
property real homeContentLength: 200
property real mediaContentLength: 360
property real mediaExpandedHeight: 324
readonly property real mediaCompactMaxWidth: 360
property real notificationContentWidth: 0
readonly property real notificationCompactMinWidth: compactDense ? 200 : 240
readonly property real notificationCompactMaxWidth: mediaCompactMaxWidth
readonly property real mediaCompactMaxLength: 360
property real notificationContentLength: 0
readonly property real notificationCompactMinLength: isVertical ? compactFaceThickness : (compactDense ? 200 : 240)
readonly property real notificationCompactMaxLength: mediaCompactMaxLength
signal sessionStarted(string activityId)
function setHomeContentWidth(width) {
const next = Math.ceil(width);
if (!isFinite(next) || next <= 0 || Math.abs(next - homeContentWidth) < 2)
function setHomeContentLength(length) {
const next = Math.ceil(length);
if (!isFinite(next) || next <= 0 || Math.abs(next - homeContentLength) < 2)
return;
homeContentWidth = next;
homeContentLength = next;
}
function setNotificationContentWidth(width) {
const next = Math.ceil(width);
if (!isFinite(next) || next <= 0 || Math.abs(next - notificationContentWidth) < 2)
function setNotificationContentLength(length) {
const next = Math.ceil(length);
if (!isFinite(next) || next <= 0 || Math.abs(next - notificationContentLength) < 2)
return;
notificationContentWidth = next;
notificationContentLength = next;
}
function setMediaExpandedHeight(height) {
@@ -92,34 +94,34 @@ QtObject {
mediaExpandedHeight = next;
}
function setMediaContentWidth(width) {
const next = Math.ceil(Math.min(root.mediaCompactMaxWidth, width));
if (!isFinite(next) || next <= 0 || Math.abs(next - mediaContentWidth) < 2)
function setMediaContentLength(length) {
const next = Math.ceil(Math.min(root.mediaCompactMaxLength, length));
if (!isFinite(next) || next <= 0 || Math.abs(next - mediaContentLength) < 2)
return;
mediaContentWidth = next;
mediaContentLength = next;
}
readonly property var destinations: ["launcher", "controlcenter", "wallpaper", "weather", "notificationcenter"]
readonly property var blankClickOwners: ["launcher", "controlcenter", "wallpaper", "notificationcenter"]
readonly property var destinationDefaults: ({
"launcher": {
"contentWidth": 160,
"contentLength": 160,
"contentHeight": 0
},
"controlcenter": {
"contentWidth": 180,
"contentLength": 180,
"contentHeight": 420
},
"wallpaper": {
"contentWidth": 150,
"contentLength": 150,
"contentHeight": 0
},
"weather": {
"contentWidth": 130,
"contentLength": 130,
"contentHeight": 0
},
"notificationcenter": {
"contentWidth": 170,
"contentLength": 170,
"contentHeight": 320
}
})
@@ -134,7 +136,7 @@ QtObject {
"ready": false,
"pending": false,
"pendingKeyboardFocus": false,
"contentWidth": destinationDefaults[id].contentWidth,
"contentLength": destinationDefaults[id].contentLength,
"contentHeight": destinationDefaults[id].contentHeight
};
}
@@ -158,20 +160,20 @@ QtObject {
return destinationEntry(activityId)?.ready ?? false;
}
function destinationContentWidth(activityId) {
return destinationEntry(activityId)?.contentWidth ?? 0;
function destinationContentLength(activityId) {
return destinationEntry(activityId)?.contentLength ?? 0;
}
function destinationContentHeight(activityId) {
return destinationEntry(activityId)?.contentHeight ?? 0;
}
function setDestinationContentWidth(activityId, width) {
function setDestinationContentLength(activityId, length) {
const entry = destinationState[activityId];
const next = Math.ceil(width);
if (!entry || !isFinite(next) || next <= 0 || Math.abs(next - entry.contentWidth) < 2)
const next = Math.ceil(length);
if (!entry || !isFinite(next) || next <= 0 || Math.abs(next - entry.contentLength) < 2)
return;
entry.contentWidth = next;
entry.contentLength = next;
destinationRevision++;
}
@@ -264,40 +266,50 @@ QtObject {
}
}
readonly property real destinationCompactEndPad: Math.max(root.compactFaceHeight / 2, Theme.spacingM) + Theme.spacingS
readonly property real destinationCompactEndPad: root.isVertical ? Theme.spacingS : Math.max(root.compactFaceThickness / 2, Theme.spacingM) + Theme.spacingS
function destinationCompactWidth(activityId) {
return Math.ceil(Math.max(root.compactFaceHeight, destinationContentWidth(activityId) + root.destinationCompactEndPad * 2));
function destinationCompactLength(activityId) {
return Math.ceil(Math.max(root.compactFaceThickness, destinationContentLength(activityId) + root.destinationCompactEndPad * 2));
}
function homeGroups(side) {
const layout = SettingsData.getIslandHomeLayout();
const layout = SettingsData.getIslandHomeLayout(root.barConfig);
const clock = layout.findIndex(g => g.id === "clock");
const groups = side === "left" ? layout.slice(0, clock) : layout.slice(clock + 1);
return groups.filter(g => g.enabled).map(g => g.id);
}
function resolvedSystemLevelDisplay(value) {
if (value === "icon" || value === "percentage" || value === "both")
return value;
return "both";
}
readonly property var homeLeftGroups: homeGroups("left")
readonly property var homeRightGroups: homeGroups("right")
readonly property string homeVolumeDisplay: resolvedSystemLevelDisplay(SettingsData.dankIslandHomeVolumeDisplay)
readonly property string homeBrightnessDisplay: resolvedSystemLevelDisplay(SettingsData.dankIslandHomeBrightnessDisplay)
property string homeClockDisplay: "both"
property string homeVolumeDisplay: "both"
property string homeBrightnessDisplay: "both"
readonly property real homeCompactWidth: Math.ceil(homeSlotMargin * 2 + Math.max(homeContentWidth, 1))
readonly property real mediaCompactWidth: Math.ceil(Math.max(1, mediaContentWidth))
readonly property real homeCompactLength: Math.ceil(homeSlotMargin * 2 + Math.max(homeContentLength, 1))
readonly property real mediaCompactLength: Math.ceil(Math.max(1, mediaContentLength))
function pillTarget(width, height) {
const radius = Math.min(height / 2, root.cornerRadius);
// Corners touching the attached screen edge stay tighter than the ones facing the desktop.
function sheetRadii() {
const near = root.edgeCornerRadius;
const far = root.cornerRadius;
switch (root.edge) {
case "bottom":
return [far, far, near, near];
case "left":
return [near, far, near, far];
case "right":
return [far, near, far, near];
}
return [near, near, far, far];
}
function pillTarget(alongSize, crossSize) {
const radius = Math.min(crossSize / 2, root.cornerRadius);
return {
"width": width,
"height": height,
"offsetX": root.horizontalOffset,
"offsetY": root.outerGap,
"width": root.isVertical ? crossSize : alongSize,
"height": root.isVertical ? alongSize : crossSize,
"offsetAlong": root.alongOffset,
"offsetCross": root.outerGap,
"topLeftRadius": radius,
"topRightRadius": radius,
"bottomLeftRadius": radius,
@@ -306,27 +318,28 @@ QtObject {
}
function sheetTarget(width, height) {
const radii = root.sheetRadii();
return {
"width": width,
"height": height,
"offsetX": root.horizontalOffset,
"offsetY": root.outerGap,
"topLeftRadius": root.topCornerRadius,
"topRightRadius": root.topCornerRadius,
"bottomLeftRadius": root.bottomCornerRadius,
"bottomRightRadius": root.bottomCornerRadius
"offsetAlong": root.alongOffset,
"offsetCross": root.outerGap,
"topLeftRadius": radii[0],
"topRightRadius": radii[1],
"bottomLeftRadius": radii[2],
"bottomRightRadius": radii[3]
};
}
readonly property var homeCompactTarget: pillTarget(homeCompactWidth, homeCompactFaceHeight)
readonly property var mediaCompactTarget: pillTarget(mediaCompactWidth, compactFaceHeight)
readonly property var homeCompactTarget: pillTarget(homeCompactLength, homeCompactFaceThickness)
readonly property var mediaCompactTarget: pillTarget(mediaCompactLength, compactFaceThickness)
readonly property var dashSheetTarget: sheetTarget(SettingsData.showWeekNumber ? 736 : 700, 452)
readonly property var mediaExpandedTarget: sheetTarget(600, mediaExpandedHeight)
readonly property var launcherExpandedTarget: sheetTarget(680, 560)
readonly property var controlCenterExpandedTarget: sheetTarget(580, controlCenterHeight)
readonly property var systemCompactTarget: pillTarget(SettingsData.osdAlwaysShowValue ? 330 : 282, compactFaceHeight)
readonly property var systemCompactTarget: pillTarget(root.isVertical ? 240 : (SettingsData.osdAlwaysShowValue ? 330 : 282), compactFaceThickness)
readonly property var systemExpandedTarget: sheetTarget(460, 176)
readonly property var notificationCompactTarget: pillTarget(Math.ceil(Math.max(notificationCompactMinWidth, Math.min(notificationCompactMaxWidth, notificationContentWidth))), compactFaceHeight)
readonly property var notificationCompactTarget: pillTarget(Math.ceil(Math.max(notificationCompactMinLength, Math.min(notificationCompactMaxLength, notificationContentLength))), compactFaceThickness)
readonly property var notificationExpandedTarget: sheetTarget(520, 220)
readonly property var notificationCenterExpandedTarget: sheetTarget(480, notificationCenterHeight)
@@ -349,7 +362,7 @@ QtObject {
case "home":
return homeCompactTarget;
}
return isDestination(activityId) ? pillTarget(destinationCompactWidth(activityId), compactFaceHeight) : homeCompactTarget;
return isDestination(activityId) ? pillTarget(destinationCompactLength(activityId), compactFaceThickness) : homeCompactTarget;
}
readonly property var compactTarget: compactTargetFor(activeActivity)
@@ -7,7 +7,8 @@ Item {
id: root
property bool rightSide: false
property bool bottomEdge: false
property bool crossFar: false
property bool isVertical: false
property bool floating: false
property color fillColor: "transparent"
property bool gothEnabled: true
@@ -15,30 +16,37 @@ Item {
property real bodyRadius: Theme.cornerRadius
property var parentScreen: null
readonly property real cornerR: Math.max(0, Math.min(root.bodyRadius, root.height / 2))
readonly property real sweepR: root.gothEnabled ? Math.max(0, Math.min(root.sweep, root.height - root.cornerR, root.width - root.cornerR)) : 0
readonly property real alongSize: root.isVertical ? root.height : root.width
readonly property real crossSize: root.isVertical ? root.width : root.height
readonly property real cornerR: Math.max(0, Math.min(root.bodyRadius, root.crossSize / 2))
readonly property real sweepR: root.gothEnabled ? Math.max(0, Math.min(root.sweep, root.crossSize - root.cornerR, root.alongSize - root.cornerR)) : 0
// Paths are authored once for a top-edge horizontal strip and rotated into place, so
// "bottomEdge"/"rightSide" below are canonical, not screen, directions.
readonly property bool canonicalBottomEdge: root.isVertical ? false : root.crossFar
readonly property bool canonicalRightSide: root.isVertical ? (root.crossFar ? root.rightSide : !root.rightSide) : root.rightSide
readonly property string fillPath: {
root.width;
root.height;
root.alongSize;
root.crossSize;
root.cornerR;
root.sweepR;
root.rightSide;
root.bottomEdge;
root.canonicalRightSide;
root.canonicalBottomEdge;
root.floating;
return root.buildPath();
}
function buildPath() {
const w = root.width;
const h = root.height;
const w = root.alongSize;
const h = root.crossSize;
const cr = root.cornerR;
const s = root.sweepR;
if (root.floating)
return root.bottomEdge ? floatBottomPath(w, h, cr, s) : floatTopPath(w, h, cr, s);
if (root.bottomEdge)
return root.rightSide ? rightBottomPath(w, h, cr, s) : leftBottomPath(w, h, cr, s);
return root.rightSide ? rightTopPath(w, h, cr, s) : leftTopPath(w, h, cr, s);
return root.canonicalBottomEdge ? floatBottomPath(w, h, cr, s) : floatTopPath(w, h, cr, s);
if (root.canonicalBottomEdge)
return root.canonicalRightSide ? rightBottomPath(w, h, cr, s) : leftBottomPath(w, h, cr, s);
return root.canonicalRightSide ? rightTopPath(w, h, cr, s) : leftTopPath(w, h, cr, s);
}
function floatTopPath(w, h, cr, s) {
@@ -77,6 +85,52 @@ Item {
return `M ${w} ${h} L 0 ${h} L 0 ${cr} A ${cr} ${cr} 0 0 1 ${cr} 0 L ${w - cr} 0 A ${cr} ${cr} 0 0 1 ${w} ${cr} Z`;
}
// Canonical (along, cross) rect to item-local coordinates, matching the canvas rotation.
function localRect(cx, cy, cw, ch) {
if (!root.isVertical)
return Qt.rect(cx, cy, cw, ch);
if (root.crossFar)
return Qt.rect(root.crossSize - cy - ch, cx, ch, cw);
return Qt.rect(cy, root.alongSize - cx - cw, ch, cw);
}
readonly property real canonicalEdgeCross: root.canonicalBottomEdge ? root.crossSize - root.cornerR : 0
readonly property real canonicalSweepCross: root.canonicalBottomEdge ? 0 : root.crossSize - root.cornerR
readonly property bool sweepSquared: !root.floating && root.sweepR > 0
// The chrome paints square corners where it meets the screen edge (and above the sweep in
// edge mode); the blur regions have to square the same ones off.
readonly property rect leadCornerSquare: root.localRect(0, root.canonicalEdgeCross, root.cornerR, root.cornerR)
readonly property rect trailCornerSquare: root.localRect(root.alongSize - root.cornerR, root.canonicalEdgeCross, root.cornerR, root.cornerR)
readonly property rect sweepCornerSquare: root.sweepSquared ? root.localRect(root.canonicalRightSide ? root.alongSize - root.cornerR : 0, root.canonicalSweepCross, root.cornerR, root.cornerR) : Qt.rect(0, 0, 0, 0)
function sweepPieceOrigin(isWing) {
const s = root.sweepR;
if (root.floating)
return [isWing ? -s : root.alongSize, root.canonicalBottomEdge ? root.crossSize - s : 0];
if (isWing)
return [root.canonicalRightSide ? root.alongSize - s : 0, root.canonicalBottomEdge ? -s : root.crossSize];
return [root.canonicalRightSide ? -s : root.alongSize, root.canonicalBottomEdge ? root.crossSize - s : 0];
}
function sweepPieceRect(isWing) {
const origin = root.sweepPieceOrigin(isWing);
return root.localRect(origin[0], origin[1], root.sweepR, root.sweepR);
}
// The fillet is that square minus a quarter disc; this is the disc's bounding box.
function sweepDiscRect(isWing) {
const s = root.sweepR;
const leadsIn = root.floating ? isWing : root.canonicalRightSide;
const origin = root.sweepPieceOrigin(isWing);
return root.localRect(origin[0] - (leadsIn ? s : 0), origin[1] - (root.canonicalBottomEdge ? s : 0), s * 2, s * 2);
}
readonly property rect sweepBodyRect: root.sweepR > 0 ? root.sweepPieceRect(false) : Qt.rect(0, 0, 0, 0)
readonly property rect sweepBodyDisc: root.sweepR > 0 ? root.sweepDiscRect(false) : Qt.rect(0, 0, 0, 0)
readonly property rect sweepWingRect: root.sweepR > 0 ? root.sweepPieceRect(true) : Qt.rect(0, 0, 0, 0)
readonly property rect sweepWingDisc: root.sweepR > 0 ? root.sweepDiscRect(true) : Qt.rect(0, 0, 0, 0)
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
@@ -95,17 +149,26 @@ Item {
}
}
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
Item {
id: canvas
ShapePath {
fillColor: root.fillColor
strokeColor: "transparent"
strokeWidth: 0
anchors.centerIn: parent
width: root.alongSize
height: root.crossSize
rotation: root.isVertical ? (root.crossFar ? 90 : -90) : 0
PathSvg {
path: root.fillPath
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.fillColor
strokeColor: "transparent"
strokeWidth: 0
PathSvg {
path: root.fillPath
}
}
}
}
@@ -12,16 +12,19 @@ Item {
required property var hostWindow
required property var targetScreen
required property var barConfig
required property IslandController controller
required property var islandSurface
property real outerGap: 8
property var hyprlandOverviewLoader: null
readonly property alias leftInputItem: leftInputEnvelope
readonly property alias rightInputItem: rightInputEnvelope
readonly property var referenceBarConfig: {
SettingsData.barConfigs;
return SettingsData.islandBarConfig ?? ({});
function setting(key) {
return SettingsData.islandSetting(root.barConfig, key);
}
readonly property alias leadingInputItem: leadingInputEnvelope
readonly property alias trailingInputItem: trailingInputEnvelope
readonly property var referenceBarConfig: root.barConfig ?? ({})
readonly property var satelliteConfig: {
const base = SettingsData.effectiveBarConfigForRender(root.referenceBarConfig, false) ?? root.referenceBarConfig ?? ({});
if (!root.hostWindow?.usesOverlayLayer)
@@ -38,31 +41,30 @@ Item {
readonly property real widgetThickness: Theme.barWidgetThickness(root.innerPadding, root.screenScale)
readonly property real barThickness: Theme.barThickness(root.innerPadding, root.screenScale)
readonly property real satelliteSpacing: root.satelliteConfig?.spacing ?? 4
readonly property bool edgeAligned: SettingsData.dankIslandSatellitePosition === "edges"
readonly property bool edgeAligned: root.setting("islandSatellitePosition") === "edges"
readonly property real edgeBaseMargin: Math.max(Theme.spacingXS, root.innerPadding * 0.8)
readonly property real edgeInsetRaw: SettingsData.barInsetPaddingSyncAll ? SettingsData.barInsetPaddingShared : (root.satelliteConfig?.barInsetPadding ?? -1)
readonly property real edgeInset: Theme.snap(Math.max(0, root.edgeInsetRaw < 0 ? root.edgeBaseMargin : root.edgeInsetRaw), root.screenScale)
readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom"
readonly property real stripHeight: root.hostWindow?.reservedStripHeight ?? (root.outerGap + root.barThickness)
readonly property real rowY: {
const centered = Theme.snap((root.stripHeight - root.barThickness) / 2, root.screenScale);
return root.bottomEdge ? root.height - root.stripHeight + centered : centered;
}
readonly property bool backgroundEnabled: SettingsData.dankIslandSatelliteBackground
readonly property bool isVertical: root.controller.isVertical
readonly property bool crossFar: root.controller.edge === "bottom" || root.controller.edge === "right"
readonly property real alongExtent: root.isVertical ? root.height : root.width
readonly property real crossExtent: root.isVertical ? root.width : root.height
readonly property real stripThickness: root.hostWindow?.reservedStripThickness ?? (root.outerGap + root.barThickness)
readonly property real stripStart: root.crossFar ? root.crossExtent - root.stripThickness : 0
readonly property real rowCross: root.stripStart + Theme.snap((root.stripThickness - root.barThickness) / 2, root.screenScale)
readonly property bool backgroundEnabled: root.setting("islandSatelliteBackground")
readonly property bool spanEdges: root.edgeAligned
readonly property real chromePad: Theme.snap(root.innerPadding + Theme.spacingXS, root.screenScale)
readonly property real chromeInset: root.backgroundEnabled ? root.chromePad : 0
readonly property real chromeY: root.bottomEdge ? root.height - root.stripHeight : 0
readonly property real chromeHeight: root.stripHeight
readonly property real crossEdgeExtension: root.spanEdges ? (root.bottomEdge ? root.height - root.rowY - root.barThickness : root.rowY) : 0
readonly property bool gothCorners: SettingsData.dankIslandSatelliteGothCorners
readonly property real chromeOpacity: Math.max(0, Math.min(1, SettingsData.dankIslandSatelliteTransparency))
readonly property real sweepRadius: Math.max(4, Math.min(64, SettingsData.dankIslandSatelliteSwoopRadius))
readonly property real crossEdgeExtension: root.spanEdges ? (root.crossFar ? root.crossExtent - root.rowCross - root.barThickness : root.rowCross) : 0
readonly property bool gothCorners: root.setting("islandSatelliteGothCorners")
readonly property real chromeOpacity: Math.max(0, Math.min(1, root.setting("islandSatelliteTransparency")))
readonly property real sweepRadius: Math.max(4, Math.min(64, root.setting("islandSatelliteSwoopRadius")))
readonly property color chromeBase: {
if (SettingsData.dankIslandHighContrast)
if (root.setting("islandHighContrast"))
return Theme.surfaceContainerHighest;
switch (SettingsData.dankIslandPalette) {
switch (root.setting("islandPalette")) {
case "bright":
return Theme.surfaceBright;
case "dim":
@@ -77,13 +79,17 @@ Item {
readonly property bool islandTranslucent: root.islandOpacity > 0 && root.islandOpacity < 1
readonly property bool tracksIsland: !root.edgeAligned && root.visible
readonly property bool motionRunning: root.tracksIsland && (root.islandSurface?.motionRunning ?? false)
readonly property real islandStartX: root.islandSurface?.motionStartBounds.x ?? 0
readonly property real islandStartRight: root.islandStartX + (root.islandSurface?.motionStartBounds.width ?? 0)
readonly property real islandTargetX: root.islandSurface?.targetVisualX ?? 0
readonly property real islandTargetRight: root.islandTargetX + (root.islandSurface?.targetVisualWidth ?? 0)
readonly property rect islandStartBounds: root.islandSurface?.motionStartBounds ?? Qt.rect(0, 0, 0, 0)
readonly property real islandStartAlong: root.isVertical ? root.islandStartBounds.y : root.islandStartBounds.x
readonly property real islandStartAlongEnd: root.islandStartAlong + (root.isVertical ? root.islandStartBounds.height : root.islandStartBounds.width)
readonly property real islandTargetAlong: root.islandSurface?.targetAlongPos ?? 0
readonly property real islandTargetAlongEnd: root.islandTargetAlong + (root.islandSurface?.targetVisualAlong ?? 0)
readonly property real islandCurrentAlong: root.islandSurface?.currentAlongPos ?? 0
readonly property real islandCurrentAlongEnd: root.islandCurrentAlong + (root.islandSurface?.currentVisualAlong ?? 0)
readonly property real satelliteGap: root.setting("islandSatelliteGap")
readonly property real leftEdgeSpread: root.motionRunning ? Math.abs(root.islandStartX - root.islandTargetX) : 0
readonly property real rightEdgeSpread: root.motionRunning ? Math.abs(root.islandStartRight - root.islandTargetRight) : 0
readonly property real leadingEdgeSpread: root.motionRunning ? Math.abs(root.islandStartAlong - root.islandTargetAlong) : 0
readonly property real trailingEdgeSpread: root.motionRunning ? Math.abs(root.islandStartAlongEnd - root.islandTargetAlongEnd) : 0
function normalizedWidgets(widgets) {
return (widgets || []).map((widget, index) => {
@@ -102,7 +108,7 @@ Item {
});
}
visible: SettingsData.dankIslandSatellitesEnabled
visible: root.setting("islandSatellitesEnabled")
function switchWorkspace(direction) {
componentProvider.switchWorkspace(direction);
@@ -111,7 +117,7 @@ Item {
AxisContext {
id: satelliteAxis
edge: SettingsData.dankIslandEdge
edge: root.controller.edge
}
ScriptModel {
@@ -140,13 +146,13 @@ Item {
property string screenName: root.targetScreen?.name ?? ""
property real widgetThickness: root.widgetThickness
property real effectiveBarThickness: root.barThickness
property bool isVertical: false
property bool isVertical: root.isVertical
property bool usesFrameBarChrome: false
property bool hasAdjacentTopBar: false
property bool hasAdjacentBottomBar: false
property bool hasAdjacentLeftBar: false
property bool hasAdjacentRightBar: false
property var hyprlandOverviewLoader: null
property var hyprlandOverviewLoader: root.hyprlandOverviewLoader
property var controlCenterButtonRef: null
property var clockButtonRef: null
property var systemUpdateButtonRef: null
@@ -192,7 +198,7 @@ Item {
if (!BlurService.enabled)
return;
const widgets = root.chromeCoversWidgets ? [] : root._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0);
const chromes = [leftChrome, rightChrome].filter(c => root.chromeTranslucent && c.visible);
const chromes = [leadingChrome, trailingChrome].filter(c => root.chromeTranslucent && c.visible);
if (chromes.length === 0 && widgets.length === 0 && !root.islandTranslucent)
return;
const region = blurRegionComp.createObject(root.hostWindow);
@@ -324,36 +330,31 @@ Item {
property Item chrome
readonly property real cr: body.chrome.cornerR
readonly property real edgeY: body.chrome.bottomEdge ? body.y + body.height - body.cr : body.y
readonly property real sweepY: body.chrome.bottomEdge ? body.y : body.y + body.height - body.cr
readonly property bool sweepSquare: !body.chrome.floating && body.chrome.sweepR > 0
x: body.chrome.x
y: body.chrome.y
width: body.chrome.width
height: body.chrome.height
radius: body.cr
radius: body.chrome.cornerR
Region {
x: body.x
y: body.edgeY
width: body.cr
height: body.cr
x: body.x + body.chrome.leadCornerSquare.x
y: body.y + body.chrome.leadCornerSquare.y
width: body.chrome.leadCornerSquare.width
height: body.chrome.leadCornerSquare.height
}
Region {
x: body.x + body.width - body.cr
y: body.edgeY
width: body.cr
height: body.cr
x: body.x + body.chrome.trailCornerSquare.x
y: body.y + body.chrome.trailCornerSquare.y
width: body.chrome.trailCornerSquare.width
height: body.chrome.trailCornerSquare.height
}
Region {
x: body.chrome.rightSide ? body.x + body.width - body.cr : body.x
y: body.sweepY
width: body.sweepSquare ? body.cr : 0
height: body.sweepSquare ? body.cr : 0
x: body.x + body.chrome.sweepCornerSquare.x
y: body.y + body.chrome.sweepCornerSquare.y
width: body.chrome.sweepCornerSquare.width
height: body.chrome.sweepCornerSquare.height
}
}
}
@@ -380,32 +381,21 @@ Item {
property Item chrome
property bool isWing
readonly property real s: piece.chrome.sweepR
readonly property rect body: piece.isWing ? piece.chrome.sweepWingRect : piece.chrome.sweepBodyRect
readonly property rect disc: piece.isWing ? piece.chrome.sweepWingDisc : piece.chrome.sweepBodyDisc
readonly property bool leadsIn: piece.chrome.floating ? piece.isWing : piece.chrome.rightSide
x: {
if (piece.chrome.floating)
return piece.isWing ? piece.chrome.x - piece.s : piece.chrome.x + piece.chrome.width;
if (piece.isWing)
return piece.chrome.rightSide ? piece.chrome.x + piece.chrome.width - piece.s : piece.chrome.x;
return piece.chrome.rightSide ? piece.chrome.x - piece.s : piece.chrome.x + piece.chrome.width;
}
y: {
if (piece.isWing && !piece.chrome.floating)
return piece.chrome.bottomEdge ? piece.chrome.y - piece.s : piece.chrome.y + piece.chrome.height;
return piece.chrome.bottomEdge ? piece.chrome.y + piece.chrome.height - piece.s : piece.chrome.y;
}
width: piece.s
height: piece.s
x: piece.chrome.x + piece.body.x
y: piece.chrome.y + piece.body.y
width: piece.body.width
height: piece.body.height
Region {
intersection: Intersection.Subtract
radius: piece.s
width: piece.s * 2
height: piece.s * 2
x: piece.x - (piece.leadsIn ? piece.s : 0)
y: piece.y - (piece.chrome.bottomEdge ? piece.s : 0)
radius: piece.chrome.sweepR
x: piece.chrome.x + piece.disc.x
y: piece.chrome.y + piece.disc.y
width: piece.disc.width
height: piece.disc.height
}
}
}
@@ -425,72 +415,93 @@ Item {
}
Item {
id: leftInputEnvelope
id: leadingInputEnvelope
x: (root.spanEdges ? 0 : (root.motionRunning ? Math.min(root.islandStartX, root.islandTargetX) - SettingsData.dankIslandSatelliteGap - leftInput.width : leftInput.x)) - (root.spanEdges ? 0 : root.chromeInset)
y: root.spanEdges ? root.chromeY : leftInput.y
width: root.spanEdges ? (leftInput.width > 0 ? Math.max(0, leftInput.x + leftInput.width + root.chromeInset) : 0) : leftInput.width + root.leftEdgeSpread + root.chromeInset * 2
height: root.spanEdges ? (leftInput.width > 0 ? root.chromeHeight : 0) : leftInput.height
readonly property real alongPos: (root.spanEdges ? 0 : (root.motionRunning ? Math.min(root.islandStartAlong, root.islandTargetAlong) - root.satelliteGap - leadingInput.alongSize : leadingInput.alongPos)) - (root.spanEdges ? 0 : root.chromeInset)
readonly property real alongSize: root.spanEdges ? (leadingInput.alongSize > 0 ? Math.max(0, leadingInput.alongPos + leadingInput.alongSize + root.chromeInset) : 0) : leadingInput.alongSize + root.leadingEdgeSpread + root.chromeInset * 2
readonly property real crossPos: root.spanEdges ? root.stripStart : root.rowCross
readonly property real crossSize: root.spanEdges ? (leadingInput.alongSize > 0 ? root.stripThickness : 0) : leadingInput.crossSize
x: root.isVertical ? crossPos : alongPos
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
}
Item {
id: rightInputEnvelope
id: trailingInputEnvelope
readonly property real edgeX: rightInput.width > 0 ? rightInput.x - root.chromeInset : root.width
readonly property real edgeAlong: trailingInput.alongSize > 0 ? trailingInput.alongPos - root.chromeInset : root.alongExtent
readonly property real alongPos: (root.spanEdges ? edgeAlong : (root.motionRunning ? Math.min(root.islandStartAlongEnd, root.islandTargetAlongEnd) + root.satelliteGap : trailingInput.alongPos)) - (root.spanEdges ? 0 : root.chromeInset)
readonly property real alongSize: root.spanEdges ? Math.max(0, root.alongExtent - edgeAlong) : trailingInput.alongSize + root.trailingEdgeSpread + root.chromeInset * 2
readonly property real crossPos: root.spanEdges ? root.stripStart : root.rowCross
readonly property real crossSize: root.spanEdges ? (trailingInput.alongSize > 0 ? root.stripThickness : 0) : trailingInput.crossSize
x: (root.spanEdges ? edgeX : (root.motionRunning ? Math.min(root.islandStartRight, root.islandTargetRight) + SettingsData.dankIslandSatelliteGap : rightInput.x)) - (root.spanEdges ? 0 : root.chromeInset)
y: root.spanEdges ? root.chromeY : rightInput.y
width: root.spanEdges ? Math.max(0, root.width - edgeX) : rightInput.width + root.rightEdgeSpread + root.chromeInset * 2
height: root.spanEdges ? (rightInput.width > 0 ? root.chromeHeight : 0) : rightInput.height
x: root.isVertical ? crossPos : alongPos
y: root.isVertical ? alongPos : crossPos
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
}
IslandSatelliteChrome {
id: leftChrome
id: leadingChrome
readonly property real alongPos: root.edgeAligned ? 0 : leadingInput.alongPos - root.chromePad
readonly property real alongSize: leadingInput.alongSize > 0 ? (root.edgeAligned ? leadingInput.alongPos + leadingInput.alongSize + root.chromePad : leadingInput.alongSize + root.chromePad * 2) : 0
floating: !root.edgeAligned
x: root.edgeAligned ? 0 : leftInput.x - root.chromePad
y: root.chromeY
width: leftInput.width > 0 ? (root.edgeAligned ? leftInput.x + leftInput.width + root.chromePad : leftInput.width + root.chromePad * 2) : 0
height: root.chromeHeight
visible: root.backgroundEnabled && leftInput.width > 0
x: root.isVertical ? root.stripStart : alongPos
y: root.isVertical ? alongPos : root.stripStart
width: root.isVertical ? root.stripThickness : alongSize
height: root.isVertical ? alongSize : root.stripThickness
visible: root.backgroundEnabled && leadingInput.alongSize > 0
fillColor: root.chromeColor
gothEnabled: root.gothCorners
sweep: root.sweepRadius
bottomEdge: root.bottomEdge
isVertical: root.isVertical
crossFar: root.crossFar
parentScreen: root.targetScreen
onVisibleChanged: blurRebuildTimer.restart()
}
IslandSatelliteChrome {
id: rightChrome
id: trailingChrome
readonly property real alongPos: trailingInput.alongPos - root.chromePad
readonly property real alongSize: trailingInput.alongSize > 0 ? (root.edgeAligned ? Math.max(0, root.alongExtent - alongPos) : trailingInput.alongSize + root.chromePad * 2) : 0
rightSide: true
floating: !root.edgeAligned
x: rightInput.x - root.chromePad
y: root.chromeY
width: rightInput.width > 0 ? (root.edgeAligned ? Math.max(0, root.width - x) : rightInput.width + root.chromePad * 2) : 0
height: root.chromeHeight
visible: root.backgroundEnabled && rightInput.width > 0
x: root.isVertical ? root.stripStart : alongPos
y: root.isVertical ? alongPos : root.stripStart
width: root.isVertical ? root.stripThickness : alongSize
height: root.isVertical ? alongSize : root.stripThickness
visible: root.backgroundEnabled && trailingInput.alongSize > 0
fillColor: root.chromeColor
gothEnabled: root.gothCorners
sweep: root.sweepRadius
bottomEdge: root.bottomEdge
isVertical: root.isVertical
crossFar: root.crossFar
parentScreen: root.targetScreen
onVisibleChanged: blurRebuildTimer.restart()
}
Item {
id: leftInput
id: leadingInput
x: root.edgeAligned ? root.edgeInset : Math.round(root.islandSurface.currentVisualX - SettingsData.dankIslandSatelliteGap - width)
y: root.rowY
width: root.visible ? leftWidgetSection.implicitWidth : 0
height: root.visible ? root.barThickness : 0
onXChanged: root.kickBlur()
onWidthChanged: root.kickBlur()
readonly property real alongPos: root.edgeAligned ? root.edgeInset : Math.round(root.islandCurrentAlong - root.satelliteGap - alongSize)
readonly property real alongSize: root.visible ? (root.isVertical ? leadingWidgetSection.implicitHeight : leadingWidgetSection.implicitWidth) : 0
readonly property real crossSize: root.visible ? root.barThickness : 0
x: root.isVertical ? root.rowCross : alongPos
y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
LeftSection {
id: leftWidgetSection
id: leadingWidgetSection
anchors.fill: parent
objectName: "leftSection"
@@ -510,17 +521,21 @@ Item {
}
Item {
id: rightInput
id: trailingInput
x: root.edgeAligned ? root.width - root.edgeInset - width : Math.round(root.islandSurface.currentVisualX + root.islandSurface.currentVisualWidth + SettingsData.dankIslandSatelliteGap)
y: root.rowY
width: root.visible ? rightWidgetSection.implicitWidth : 0
height: root.visible ? root.barThickness : 0
onXChanged: root.kickBlur()
onWidthChanged: root.kickBlur()
readonly property real alongPos: root.edgeAligned ? root.alongExtent - root.edgeInset - alongSize : Math.round(root.islandCurrentAlongEnd + root.satelliteGap)
readonly property real alongSize: root.visible ? (root.isVertical ? trailingWidgetSection.implicitHeight : trailingWidgetSection.implicitWidth) : 0
readonly property real crossSize: root.visible ? root.barThickness : 0
x: root.isVertical ? root.rowCross : alongPos
y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
RightSection {
id: rightWidgetSection
id: trailingWidgetSection
anchors.fill: parent
objectName: "rightSection"
@@ -20,8 +20,8 @@ QtObject {
property real currentWidth: 176
property real currentHeight: 42
property real currentOffsetX: 0
property real currentOffsetY: 8
property real currentOffsetAlong: 0
property real currentOffsetCross: 8
property real currentTopLeftRadius: 21
property real currentTopRightRadius: 21
property real currentBottomLeftRadius: 21
@@ -29,8 +29,8 @@ QtObject {
property real targetWidth: currentWidth
property real targetHeight: currentHeight
property real targetOffsetX: currentOffsetX
property real targetOffsetY: currentOffsetY
property real targetOffsetAlong: currentOffsetAlong
property real targetOffsetCross: currentOffsetCross
property real targetTopLeftRadius: currentTopLeftRadius
property real targetTopRightRadius: currentTopRightRadius
property real targetBottomLeftRadius: currentBottomLeftRadius
@@ -38,15 +38,15 @@ QtObject {
property real velocityWidth: 0
property real velocityHeight: 0
property real velocityOffsetX: 0
property real velocityOffsetY: 0
property real velocityOffsetAlong: 0
property real velocityOffsetCross: 0
property real velocityTopLeftRadius: 0
property real velocityTopRightRadius: 0
property real velocityBottomLeftRadius: 0
property real velocityBottomRightRadius: 0
function matchesTarget(target) {
return targetWidth === target.width && targetHeight === target.height && targetOffsetX === target.offsetX && targetOffsetY === target.offsetY && targetTopLeftRadius === target.topLeftRadius && targetTopRightRadius === target.topRightRadius && targetBottomLeftRadius === target.bottomLeftRadius && targetBottomRightRadius === target.bottomRightRadius;
return targetWidth === target.width && targetHeight === target.height && targetOffsetAlong === target.offsetAlong && targetOffsetCross === target.offsetCross && targetTopLeftRadius === target.topLeftRadius && targetTopRightRadius === target.topRightRadius && targetBottomLeftRadius === target.bottomLeftRadius && targetBottomRightRadius === target.bottomRightRadius;
}
function setTarget(target) {
@@ -54,8 +54,8 @@ QtObject {
return;
targetWidth = target.width;
targetHeight = target.height;
targetOffsetX = target.offsetX;
targetOffsetY = target.offsetY;
targetOffsetAlong = target.offsetAlong;
targetOffsetCross = target.offsetCross;
targetTopLeftRadius = target.topLeftRadius;
targetTopRightRadius = target.topRightRadius;
targetBottomLeftRadius = target.bottomLeftRadius;
@@ -73,8 +73,8 @@ QtObject {
function snapTo(target) {
targetWidth = target.width;
targetHeight = target.height;
targetOffsetX = target.offsetX;
targetOffsetY = target.offsetY;
targetOffsetAlong = target.offsetAlong;
targetOffsetCross = target.offsetCross;
targetTopLeftRadius = target.topLeftRadius;
targetTopRightRadius = target.topRightRadius;
targetBottomLeftRadius = target.bottomLeftRadius;
@@ -82,8 +82,8 @@ QtObject {
currentWidth = target.width;
currentHeight = target.height;
currentOffsetX = target.offsetX;
currentOffsetY = target.offsetY;
currentOffsetAlong = target.offsetAlong;
currentOffsetCross = target.offsetCross;
currentTopLeftRadius = target.topLeftRadius;
currentTopRightRadius = target.topRightRadius;
currentBottomLeftRadius = target.bottomLeftRadius;
@@ -91,8 +91,8 @@ QtObject {
velocityWidth = 0;
velocityHeight = 0;
velocityOffsetX = 0;
velocityOffsetY = 0;
velocityOffsetAlong = 0;
velocityOffsetCross = 0;
velocityTopLeftRadius = 0;
velocityTopRightRadius = 0;
velocityBottomLeftRadius = 0;
@@ -101,16 +101,16 @@ QtObject {
}
function isSettled() {
const positionSettled = Math.abs(targetWidth - currentWidth) <= positionEpsilon && Math.abs(targetHeight - currentHeight) <= positionEpsilon && Math.abs(targetOffsetX - currentOffsetX) <= positionEpsilon && Math.abs(targetOffsetY - currentOffsetY) <= positionEpsilon && Math.abs(targetTopLeftRadius - currentTopLeftRadius) <= positionEpsilon && Math.abs(targetTopRightRadius - currentTopRightRadius) <= positionEpsilon && Math.abs(targetBottomLeftRadius - currentBottomLeftRadius) <= positionEpsilon && Math.abs(targetBottomRightRadius - currentBottomRightRadius) <= positionEpsilon;
const velocitySettled = Math.abs(velocityWidth) <= velocityEpsilon && Math.abs(velocityHeight) <= velocityEpsilon && Math.abs(velocityOffsetX) <= velocityEpsilon && Math.abs(velocityOffsetY) <= velocityEpsilon && Math.abs(velocityTopLeftRadius) <= velocityEpsilon && Math.abs(velocityTopRightRadius) <= velocityEpsilon && Math.abs(velocityBottomLeftRadius) <= velocityEpsilon && Math.abs(velocityBottomRightRadius) <= velocityEpsilon;
const positionSettled = Math.abs(targetWidth - currentWidth) <= positionEpsilon && Math.abs(targetHeight - currentHeight) <= positionEpsilon && Math.abs(targetOffsetAlong - currentOffsetAlong) <= positionEpsilon && Math.abs(targetOffsetCross - currentOffsetCross) <= positionEpsilon && Math.abs(targetTopLeftRadius - currentTopLeftRadius) <= positionEpsilon && Math.abs(targetTopRightRadius - currentTopRightRadius) <= positionEpsilon && Math.abs(targetBottomLeftRadius - currentBottomLeftRadius) <= positionEpsilon && Math.abs(targetBottomRightRadius - currentBottomRightRadius) <= positionEpsilon;
const velocitySettled = Math.abs(velocityWidth) <= velocityEpsilon && Math.abs(velocityHeight) <= velocityEpsilon && Math.abs(velocityOffsetAlong) <= velocityEpsilon && Math.abs(velocityOffsetCross) <= velocityEpsilon && Math.abs(velocityTopLeftRadius) <= velocityEpsilon && Math.abs(velocityTopRightRadius) <= velocityEpsilon && Math.abs(velocityBottomLeftRadius) <= velocityEpsilon && Math.abs(velocityBottomRightRadius) <= velocityEpsilon;
return positionSettled && velocitySettled;
}
function settle() {
currentWidth = targetWidth;
currentHeight = targetHeight;
currentOffsetX = targetOffsetX;
currentOffsetY = targetOffsetY;
currentOffsetAlong = targetOffsetAlong;
currentOffsetCross = targetOffsetCross;
currentTopLeftRadius = targetTopLeftRadius;
currentTopRightRadius = targetTopRightRadius;
currentBottomLeftRadius = targetBottomLeftRadius;
@@ -118,8 +118,8 @@ QtObject {
velocityWidth = 0;
velocityHeight = 0;
velocityOffsetX = 0;
velocityOffsetY = 0;
velocityOffsetAlong = 0;
velocityOffsetCross = 0;
velocityTopLeftRadius = 0;
velocityTopRightRadius = 0;
velocityBottomLeftRadius = 0;
@@ -142,8 +142,8 @@ QtObject {
for (let i = 0; i < steps; i++) {
velocityWidth += (stiffness * (targetWidth - currentWidth) - damping * velocityWidth) * inverseMass * step;
velocityHeight += (stiffness * (targetHeight - currentHeight) - damping * velocityHeight) * inverseMass * step;
velocityOffsetX += (stiffness * (targetOffsetX - currentOffsetX) - damping * velocityOffsetX) * inverseMass * step;
velocityOffsetY += (stiffness * (targetOffsetY - currentOffsetY) - damping * velocityOffsetY) * inverseMass * step;
velocityOffsetAlong += (stiffness * (targetOffsetAlong - currentOffsetAlong) - damping * velocityOffsetAlong) * inverseMass * step;
velocityOffsetCross += (stiffness * (targetOffsetCross - currentOffsetCross) - damping * velocityOffsetCross) * inverseMass * step;
velocityTopLeftRadius += (stiffness * (targetTopLeftRadius - currentTopLeftRadius) - damping * velocityTopLeftRadius) * inverseMass * step;
velocityTopRightRadius += (stiffness * (targetTopRightRadius - currentTopRightRadius) - damping * velocityTopRightRadius) * inverseMass * step;
velocityBottomLeftRadius += (stiffness * (targetBottomLeftRadius - currentBottomLeftRadius) - damping * velocityBottomLeftRadius) * inverseMass * step;
@@ -151,8 +151,8 @@ QtObject {
currentWidth += velocityWidth * step;
currentHeight += velocityHeight * step;
currentOffsetX += velocityOffsetX * step;
currentOffsetY += velocityOffsetY * step;
currentOffsetAlong += velocityOffsetAlong * step;
currentOffsetCross += velocityOffsetCross * step;
currentTopLeftRadius += velocityTopLeftRadius * step;
currentTopRightRadius += velocityTopRightRadius * step;
currentBottomLeftRadius += velocityBottomLeftRadius * step;
+1 -1
View File
@@ -96,7 +96,7 @@ Item {
return toplevel.activated;
} else if (appData.type === "grouped") {
// For grouped apps, check if any window is focused
const allToplevels = ToplevelManager.toplevels.values;
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i];
if (toplevel.appId === appData.appId && toplevel.activated) {
+1 -1
View File
@@ -262,7 +262,7 @@ Item {
const separatePinnedAndRunning = SettingsData.dockSeparatePinnedAndRunningApps;
sortedToplevels.forEach((toplevel, index) => {
let uniqueKey = "window_" + index;
let uniqueKey = toplevel.aqueousKey || "window_" + index;
if (CompositorService.isHyprland && Hyprland.toplevels) {
const hyprlandToplevels = Array.from(Hyprland.toplevels.values);
for (let i = 0; i < hyprlandToplevels.length; i++) {
+5 -2
View File
@@ -249,7 +249,7 @@ Item {
readonly property bool shouldHideForWindows: {
if (!SettingsData.dockSmartAutoHide)
return false;
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango)
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous)
return false;
const screenName = dock.modelData?.name ?? "";
@@ -313,6 +313,9 @@ Item {
return false;
}
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.overlapsDock(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight);
if (CompositorService.isMango) {
MangoService.windows;
MangoService.outputs;
@@ -348,7 +351,7 @@ Item {
onTriggered: dock.startupRevealDone = true
}
readonly property bool overviewReveal: CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(screenName) && SettingsData.dockOpenOnOverview
readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
onOverviewRevealChanged: {
+1 -1
View File
@@ -87,7 +87,7 @@ PanelWindow {
readonly property bool _connectedActive: CompositorService.usesConnectedFrameChromeForScreen(win.targetScreen)
readonly property bool _dockHostedHere: {
const dockVisible = SettingsData.showDock || (CompositorService.isNiri && SettingsData.dockOpenOnOverview);
const dockVisible = SettingsData.showDock || ((CompositorService.isNiri || CompositorService.isAqueous) && SettingsData.dockOpenOnOverview);
if (!win._connectedActive || !dockVisible || SettingsData.dockUseOverlayLayer)
return false;
const screens = SettingsData.getFilteredScreens("dock");
+82 -41
View File
@@ -834,6 +834,8 @@ Item {
}
Rectangle {
id: passwordBox
property bool showPassword: false
Layout.fillWidth: true
@@ -1230,61 +1232,100 @@ Item {
}
}
StyledText {
id: passwordDisplay
Item {
id: passwordViewport
property real scrollX: 0
property real scrollY: 0
function followCursor() {
const rect = passwordDisplay.cursorRectangle;
const contentWidth = passwordDisplay.contentWidth + passwordCursor.width;
const contentHeight = passwordDisplay.contentHeight;
scrollX = followAxis(scrollX, rect.x, rect.x + passwordCursor.width, contentWidth, width);
scrollY = contentHeight < height ? (height - contentHeight) / 2 : followAxis(scrollY, rect.y, rect.y + rect.height, contentHeight, height);
}
function followAxis(offset, start, end, contentSize, viewSize) {
if (contentSize <= viewSize)
return 0;
if (start + offset < 0)
return -start;
if (end + offset > viewSize)
return viewSize - end;
return Math.max(viewSize - contentSize, Math.min(0, offset));
}
anchors.left: lockIconContainer.right
anchors.leftMargin: Theme.spacingM
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
anchors.rightMargin: 2
anchors.verticalCenter: parent.verticalCenter
text: {
if (demoMode) {
return "••••••••";
}
if (parent.showPassword) {
return root.passwordBuffer;
}
return "•".repeat(root.passwordBuffer.length);
}
color: Theme.surfaceText
font.pixelSize: parent.showPassword ? Theme.fontSizeMedium : Theme.fontSizeLarge
opacity: (demoMode || root.passwordBuffer.length > 0) ? 1 : 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.topMargin: Theme.spacingXS
anchors.bottomMargin: Theme.spacingXS
clip: true
elide: Text.ElideNone
horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
onWidthChanged: followCursor()
onHeightChanged: followCursor()
TextEdit {
id: passwordDisplay
x: passwordViewport.scrollX
y: passwordViewport.scrollY
width: wrapMode === TextEdit.NoWrap ? implicitWidth : passwordViewport.width
readOnly: true
activeFocusOnPress: false
selectByMouse: false
wrapMode: passwordBox.showPassword ? TextEdit.Wrap : TextEdit.NoWrap
text: {
if (demoMode) {
return "••••••••";
}
if (passwordBox.showPassword) {
return root.passwordBuffer;
}
return "•".repeat(root.passwordBuffer.length);
}
color: Theme.surfaceText
font.family: Theme.fontFamily
font.weight: Theme.fontWeight
font.pixelSize: passwordBox.showPassword ? Theme.fontSizeMedium : Theme.fontSizeLarge
opacity: (demoMode || root.passwordBuffer.length > 0) ? 1 : 0
onTextChanged: cursorPosition = passwordField.cursorPosition
onCursorRectangleChanged: passwordViewport.followCursor()
onContentWidthChanged: passwordViewport.followCursor()
onContentHeightChanged: passwordViewport.followCursor()
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
}
}
TextMetrics {
id: passwordCursorMetrics
font: passwordDisplay.font
text: passwordDisplay.text.slice(0, passwordField.cursorPosition)
}
DankTextCursor {
id: passwordCursor
DankTextCursor {
id: passwordCursor
x: passwordDisplay.x + passwordDisplay.cursorRectangle.x
y: passwordDisplay.y + passwordDisplay.cursorRectangle.y
height: passwordDisplay.cursorRectangle.height
shown: !demoMode && passwordField.activeFocus && !pam.passwd.active && !pam.u2fPending && !root.unlocking
x: passwordDisplay.x + passwordCursorMetrics.advanceWidth + Math.min(0, passwordDisplay.width - passwordDisplay.implicitWidth)
anchors.verticalCenter: parent.verticalCenter
height: passwordDisplay.font.pixelSize + 4
shown: !demoMode && passwordField.activeFocus && !pam.passwd.active && !pam.u2fPending && !root.unlocking
Connections {
target: passwordField
Connections {
target: passwordField
function onCursorPositionChanged() {
passwordDisplay.cursorPosition = passwordField.cursorPosition;
passwordCursor.resetBlink();
}
function onCursorPositionChanged() {
passwordCursor.resetBlink();
}
function onTextChanged() {
passwordCursor.resetBlink();
function onTextChanged() {
passwordCursor.resetBlink();
}
}
}
}
@@ -1035,7 +1035,7 @@ PanelWindow {
iconSize: compactMode ? 14 : 16
buttonSize: compactMode ? 20 : 24
z: 15
visible: (SettingsData.notificationPopupPrivacyMode || win.bodyClickInvokesAction) && win.hasExpandableBody
visible: win.hasExpandableBody && (SettingsData.notificationPopupPrivacyMode || win.bodyClickInvokesAction || bodyText.hasMoreText || win.descriptionExpanded)
onClicked: {
if (win.hasExpandableBody)
+15 -2
View File
@@ -18,6 +18,7 @@ Item {
property bool isMiracle: CompositorService.isMiracle
property bool isMango: CompositorService.isMango
property bool isLabwc: CompositorService.isLabwc
property bool isAqueous: CompositorService.isAqueous
property string compositorName: {
if (isHyprland)
@@ -32,6 +33,8 @@ Item {
return "mangowc";
if (isLabwc)
return "labwc";
if (isAqueous)
return "aqueous";
return "niri";
}
@@ -48,6 +51,8 @@ Item {
return "/assets/mango.png";
if (isLabwc)
return "/assets/labwc.png";
if (isAqueous)
return "/assets/aqueous.svg";
return "/assets/niri.svg";
}
@@ -64,6 +69,8 @@ Item {
return "https://github.com/DreamMaoMao/mangowc";
if (isLabwc)
return "https://labwc.github.io/";
if (isAqueous)
return "";
return "https://github.com/niri-wm/niri";
}
@@ -80,6 +87,8 @@ Item {
return I18n.tr("mangowc GitHub");
if (isLabwc)
return I18n.tr("LabWC Website");
if (isAqueous)
return "Aqueous";
return I18n.tr("niri GitHub");
}
@@ -376,11 +385,15 @@ Item {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
cursorShape: compositorUrl === "" ? Qt.ArrowCursor : Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.hovered = true
onExited: parent.hovered = false
onClicked: Qt.openUrlExternally(compositorUrl)
onClicked: {
if (compositorUrl === "")
return;
Qt.openUrlExternally(compositorUrl);
}
}
}
+19 -19
View File
@@ -61,12 +61,11 @@ Item {
// The selected instance is itself the island, so bar-surface settings do not apply to it.
readonly property bool selectedBarIsIsland: {
SettingsData.dankIslandBarId;
SettingsData.barConfigs;
selectedBarId;
return !!selectedBarId && selectedBarId === SettingsData.dankIslandBarId;
return SettingsData.isIslandBarConfig(selectedBarConfig);
}
readonly property int islandShadowedScreenCount: {
SettingsData.dankIslandBarId;
SettingsData.barConfigs;
if (!selectedBarConfig || dankBarTab.selectedBarIsIsland)
return 0;
@@ -84,14 +83,12 @@ Item {
readonly property bool selectedBarFrameStyled: SettingsData.frameEnabled && dankBarTab.frameStyledScreenCount === dankBarTab.selectedBarScreens.length
// Connected mode sanitizes shadow/corner/border fields on every config, so those stay frame-managed shell-wide.
readonly property bool selectedBarFrameSanitized: SettingsData.connectedFrameModeActive || dankBarTab.selectedBarFrameStyled
// An edge another island already holds on every covered screen would just stack on top of it.
readonly property var positionChoices: {
SettingsData.dankIslandBarId;
SettingsData.barConfigs;
Quickshell.screens;
selectedBarId;
const all = [SettingsData.Position.Top, SettingsData.Position.Bottom, SettingsData.Position.Left, SettingsData.Position.Right];
if (dankBarTab.selectedBarIsIsland)
return [SettingsData.Position.Top, SettingsData.Position.Bottom];
if (!selectedBarConfig)
return all;
const screens = dankBarTab.selectedBarScreens;
@@ -102,6 +99,8 @@ Item {
if (pos === current)
return true;
const edge = SettingsData.positionToSide(pos);
if (dankBarTab.selectedBarIsIsland)
return !screens.some(screen => SettingsData.islandEdgeTakenFor(selectedBarConfig, screen, edge));
return !screens.every(screen => SettingsData.dankIslandOwnsEdge(screen, edge));
});
}
@@ -254,14 +253,14 @@ Item {
function deleteBar(barId) {
if (barId === "default")
return;
if (SettingsData.getBarKindConfigs().length <= 1 && barId !== SettingsData.dankIslandBarId)
if (SettingsData.getBarKindConfigs().length <= 1 && !SettingsData.isIslandBarConfig(SettingsData.getBarConfig(barId)))
return;
SettingsData.deleteBarConfig(barId);
selectedBarId = "default";
}
function toggleBarEnabled(barId) {
if (barId === "default" && barId !== SettingsData.dankIslandBarId)
if (barId === "default" && !SettingsData.isIslandBarConfig(SettingsData.getBarConfig(barId)))
return;
const config = SettingsData.getBarConfig(barId);
if (!config)
@@ -502,7 +501,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignLeft
visible: SettingsData.dankIslandBarId === barCard.modelData.id
visible: SettingsData.isIslandBarConfig(barCard.modelData)
}
StyledText {
@@ -511,7 +510,7 @@ Item {
font.weight: Font.Medium
color: Theme.primary
horizontalAlignment: Text.AlignLeft
visible: SettingsData.dankIslandBarId === barCard.modelData.id
visible: SettingsData.isIslandBarConfig(barCard.modelData)
}
}
}
@@ -524,7 +523,7 @@ Item {
backgroundColor: Theme.withAlpha(Theme.error, 0.15)
iconColor: Theme.error
visible: barCard.modelData.id !== "default"
enabled: SettingsData.getBarKindConfigs().length > 1 || SettingsData.dankIslandBarId === barCard.modelData.id
enabled: SettingsData.getBarKindConfigs().length > 1 || SettingsData.isIslandBarConfig(barCard.modelData)
anchors.verticalCenter: parent.verticalCenter
onClicked: dankBarTab.deleteBar(barCard.modelData.id)
}
@@ -581,16 +580,16 @@ Item {
target: "island"
visible: dankBarTab.selectedBarIsIsland
parentModal: dankBarTab.parentModal
section: "dankIslandPlacement"
section: "islandPlacement"
settingLabel: I18n.tr("Island placement")
reason: I18n.tr("The Island anchors to the top or bottom edge")
reason: I18n.tr("The Island anchors to one screen edge")
}
SettingsControlledBy {
target: "island"
visible: dankBarTab.islandShadowedScreenCount > 0
parentModal: dankBarTab.parentModal
section: "dankIslandPlacement"
section: "islandPlacement"
settingLabel: I18n.tr("%1 position").arg(dankBarTab.positionLabel(selectedBarConfig?.position ?? SettingsData.Position.Top))
reason: I18n.tr("The Island holds this edge on a display this bar covers, so the bar stays hidden there")
}
@@ -739,7 +738,7 @@ Item {
target: "island"
visible: dankBarTab.islandOwnsSelectedBarTop
parentModal: dankBarTab.parentModal
section: "dankIslandPlacement"
section: "islandPlacement"
settingLabel: I18n.tr("Bar visibility")
reason: I18n.tr("Additional settings are managed by the Island")
}
@@ -875,15 +874,15 @@ Item {
height: 1
color: Theme.outline
opacity: 0.15
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
}
SettingsToggleRow {
settingKey: "barOpenOnOverview"
tags: ["bar", "overview", "niri", "show", "frame"]
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
text: I18n.tr("Show on Overview")
description: dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
checked: dankBarTab.selectedBarFrameStyled ? SettingsData.frameShowOnOverview : (selectedBarConfig?.openOnOverview ?? false)
onToggled: toggled => {
if (dankBarTab.selectedBarFrameStyled) {
@@ -1813,12 +1812,13 @@ Item {
visible: dankBarTab.appearanceOnly && (selectedBarConfig?.enabled ?? false) && !dankBarTab.selectedBarFrameSanitized && !dankBarTab.islandOwnsSelectedBarTop
readonly property bool shadowActive: (selectedBarConfig?.shadowIntensity ?? 0) > 0
readonly property bool barShadowsEnabled: (SettingsData.m3ElevationEnabled ?? true) && (SettingsData.barElevationEnabled ?? true)
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.")
text: shadowCard.barShadowsEnabled ? I18n.tr("Enable a custom override below to set per-bar shadow intensity, opacity, and color.") : I18n.tr("Requires Bar Shadows to be enabled in Theme & Colors.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
+195 -153
View File
@@ -15,9 +15,8 @@ Item {
readonly property var instanceChoices: {
SettingsData.barConfigs;
SettingsData.dankIslandBarId;
const rows = [];
const configs = SettingsData.barConfigs || [];
const configs = SettingsData.islandBarConfigs || [];
for (let i = 0; i < configs.length; i++)
rows.push({
"id": configs[i].id,
@@ -25,7 +24,36 @@ Item {
});
return rows;
}
readonly property int instanceIndex: Math.max(0, root.instanceChoices.findIndex(row => row.id === SettingsData.dankIslandBarId))
// Follows the Bars tab selection when that instance is an island, so the two stay in step.
readonly property string selectedIslandId: {
SettingsUiState.selectedBarId;
const choices = root.instanceChoices;
if (choices.length === 0)
return "";
if (choices.some(row => row.id === SettingsUiState.selectedBarId))
return SettingsUiState.selectedBarId;
return choices[0].id;
}
readonly property var config: {
SettingsData.barConfigs;
return SettingsData.getBarConfig(root.selectedIslandId);
}
readonly property bool islandEnabled: root.config?.enabled ?? false
readonly property bool isVertical: SettingsData.islandVertical(root.config)
readonly property int instanceIndex: Math.max(0, root.instanceChoices.findIndex(row => row.id === root.selectedIslandId))
function setting(key) {
return SettingsData.islandSetting(root.config, key);
}
function apply(key, value) {
if (!root.selectedIslandId)
return;
const updates = {};
updates[key] = value;
SettingsData.updateBarConfig(root.selectedIslandId, updates);
}
readonly property var clockDisplayValues: ["time", "date", "both"]
readonly property var systemLevelDisplayValues: ["icon", "percentage", "both"]
readonly property var paletteValues: ["default", "bright", "dim"]
readonly property var batteryStyleValues: ["solid", "outline", "ring"]
@@ -55,32 +83,32 @@ Item {
width: parent.width
iconName: "view_in_ar"
title: I18n.tr("Dank Island", "island settings: page title")
settingKey: "dankIslandInstance"
settingKey: "islandInstance"
tags: ["island", "layout", "standard", "frame", "mode", "bar"]
SettingsLayoutPicker {}
SettingsButtonGroupRow {
settingKey: "dankIslandBarId"
settingKey: "islandInstanceSelect"
tags: ["island", "activities", "media", "notifications", "osd", "bar"]
visible: !!SettingsData.dankIslandBarId && SettingsData.barConfigs.length > 1
text: I18n.tr("Island Instance", "island settings: which bar config the island replaces")
visible: root.instanceChoices.length > 1
text: I18n.tr("Island Instance", "island settings: which island instance these settings edit")
model: root.instanceChoices.map(row => row.name)
currentIndex: root.instanceIndex
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.setIslandBarId(root.instanceChoices[index]?.id ?? "");
SettingsUiState.selectedBarId = root.instanceChoices[index]?.id ?? "";
}
}
SettingsToggleRow {
settingKey: "dankIslandEnable"
settingKey: "islandEnable"
tags: ["island", "enable", "show", "hide"]
visible: !!SettingsData.dankIslandBarId
visible: !!root.selectedIslandId
text: I18n.tr("Enable Island")
description: I18n.tr("Toggle the Island on this instance's displays")
checked: SettingsData.dankIslandEnabled
onToggled: checked => SettingsData.updateBarConfig(SettingsData.dankIslandBarId, {
checked: root.islandEnabled
onToggled: checked => SettingsData.updateBarConfig(root.selectedIslandId, {
enabled: checked
})
}
@@ -90,15 +118,15 @@ Item {
width: parent.width
iconName: "monitor"
title: I18n.tr("Display Assignment")
settingKey: "dankIslandDisplays"
settingKey: "islandDisplays"
collapsible: true
expanded: false
visible: SettingsData.dankIslandEnabled
visible: root.islandEnabled
SettingsDisplayPicker {
emptyMeansAll: false
displayPreferences: SettingsData.islandBarConfig?.screenPreferences ?? ["all"]
onPreferencesChanged: prefs => SettingsData.updateBarConfig(SettingsData.dankIslandBarId, {
displayPreferences: root.config?.screenPreferences ?? ["all"]
onPreferencesChanged: prefs => SettingsData.updateBarConfig(root.selectedIslandId, {
screenPreferences: prefs
})
}
@@ -108,58 +136,58 @@ Item {
width: parent.width
iconName: "open_with"
title: I18n.tr("Position", "island settings: position card title")
settingKey: "dankIslandPlacement"
visible: SettingsData.dankIslandEnabled
settingKey: "islandPlacement"
visible: root.islandEnabled
SettingsToggleRow {
settingKey: "dankIslandFloating"
settingKey: "islandFloating"
tags: ["island", "placement", "float", "overlay", "exclusive", "reserve"]
text: I18n.tr("Float", "island settings: float toggle")
description: I18n.tr("Floats above windows and other content", "island settings: float toggle description")
checked: SettingsData.dankIslandFloating
onToggled: checked => SettingsData.set("dankIslandFloating", checked)
checked: root.setting("islandFloating")
onToggled: checked => root.apply("islandFloating", checked)
}
SettingsToggleRow {
settingKey: "dankIslandUseOverlayLayer"
settingKey: "islandUseOverlayLayer"
tags: ["island", "fullscreen", "overlay", "layer"]
text: I18n.tr("Use Overlay Layer", "island layer toggle: use Wayland overlay layer")
description: I18n.tr("Stage the Wayland overlay layer to remain visible over fullscreen apps", "island settings: overlay layer description")
checked: SettingsData.dankIslandUseOverlayLayer
onToggled: checked => SettingsData.set("dankIslandUseOverlayLayer", checked)
checked: root.setting("islandUseOverlayLayer")
onToggled: checked => root.apply("islandUseOverlayLayer", checked)
}
SettingsSliderRow {
settingKey: "dankIslandReserveHeight"
tags: ["island", "placement", "reservation", "exclusive", "height"]
text: I18n.tr("Reserved Height", "island settings: reserved strip height slider")
settingKey: "islandReserveThickness"
tags: ["island", "placement", "reservation", "exclusive", "height", "width", "thickness"]
text: !root.isVertical ? I18n.tr("Reserved Height", "island settings: reserved strip height slider") : I18n.tr("Reserved Width", "island settings: reserved strip width slider")
description: I18n.tr("Space kept clear for compact island and satellites", "island settings: reserved height description")
unit: "px"
minimum: 24
maximum: 128
step: 1
defaultValue: 40
value: SettingsData.dankIslandReserveHeight
enabled: !SettingsData.dankIslandFloating
onSliderValueChanged: value => SettingsData.set("dankIslandReserveHeight", value)
value: root.setting("islandReserveThickness")
enabled: !root.setting("islandFloating")
onSliderValueChanged: value => root.apply("islandReserveThickness", value)
}
SettingsSliderRow {
settingKey: "dankIslandCompactHeight"
tags: ["island", "placement", "compact", "height", "size", "satellite"]
text: I18n.tr("Compact Island Height", "island settings: compact pill height slider")
settingKey: "islandCompactThickness"
tags: ["island", "placement", "compact", "height", "width", "thickness", "size", "satellite"]
text: !root.isVertical ? I18n.tr("Compact Island Height", "island settings: compact pill height slider") : I18n.tr("Compact Island Width", "island settings: compact pill width slider")
description: I18n.tr("Resize the resting island to align with nearby widgets", "island settings: compact height description")
unit: "px"
minimum: 24
maximum: 72
step: 1
defaultValue: 38
value: SettingsData.dankIslandCompactHeight
onSliderValueChanged: value => SettingsData.set("dankIslandCompactHeight", value)
value: root.setting("islandCompactThickness")
onSliderValueChanged: value => root.apply("islandCompactThickness", value)
}
SettingsSliderRow {
settingKey: "dankIslandOuterGap"
settingKey: "islandOuterGap"
tags: ["island", "placement", "gap", "top", "margin"]
text: I18n.tr("Outer Gaps", "island settings: gap between screen edge and island")
description: I18n.tr("Distance between display edge and island", "island settings: outer gap description")
@@ -168,22 +196,22 @@ Item {
maximum: 48
step: 1
defaultValue: 4
value: SettingsData.dankIslandOuterGap
onSliderValueChanged: value => SettingsData.set("dankIslandOuterGap", value)
value: root.setting("islandOuterGap")
onSliderValueChanged: value => root.apply("islandOuterGap", value)
}
SettingsSliderRow {
settingKey: "dankIslandHorizontalOffset"
tags: ["island", "placement", "horizontal", "offset", "center"]
text: I18n.tr("Horizontal Offset", "island settings: horizontal offset slider")
description: I18n.tr("Move island and satellites left or right from display center", "island settings: horizontal offset description")
settingKey: "islandAlongOffset"
tags: ["island", "placement", "horizontal", "vertical", "offset", "center"]
text: !root.isVertical ? I18n.tr("Horizontal Offset", "island settings: horizontal offset slider") : I18n.tr("Vertical Offset", "island settings: vertical offset slider")
description: !root.isVertical ? I18n.tr("Move island and satellites left or right from display center", "island settings: horizontal offset description") : I18n.tr("Move island and satellites up or down from display center", "island settings: vertical offset description")
unit: "px"
minimum: -600
maximum: 600
step: 1
defaultValue: 0
value: SettingsData.dankIslandHorizontalOffset
onSliderValueChanged: value => SettingsData.set("dankIslandHorizontalOffset", value)
value: root.setting("islandAlongOffset")
onSliderValueChanged: value => root.apply("islandAlongOffset", value)
}
}
@@ -191,8 +219,8 @@ Item {
width: parent.width
iconName: "home"
title: I18n.tr("Home Compact", "island settings: home face card title")
settingKey: "dankIslandActivities"
visible: SettingsData.dankIslandEnabled
settingKey: "islandActivities"
visible: root.islandEnabled
StyledText {
width: parent.width
@@ -204,41 +232,55 @@ Item {
IslandHomeLayoutEditor {
width: parent.width
barId: root.selectedIslandId
}
SettingsButtonGroupRow {
settingKey: "dankIslandHomeVolumeDisplay"
tags: ["island", "home", "compact", "volume", "icon", "percentage"]
text: I18n.tr("Volume Style", "island settings: volume display mode row")
visible: SettingsData.islandHomeGroupEnabled("volume")
model: [I18n.tr("Icon", "island settings: level shown as icon only"), I18n.tr("Percentage", "island settings: level shown as percentage only"), I18n.tr("Both", "island settings: level shown as icon and percentage")]
currentIndex: root.valueIndex(root.systemLevelDisplayValues, SettingsData.dankIslandHomeVolumeDisplay, "both")
settingKey: "islandHomeClockDisplay"
tags: ["island", "home", "compact", "clock", "time", "date"]
text: I18n.tr("Clock Style", "island settings: clock display mode row")
description: I18n.tr("Current time and date pill", "island settings: clock display mode description")
model: [I18n.tr("Time", "island settings: clock shows time only"), I18n.tr("Date", "island settings: clock shows date only"), I18n.tr("Both", "island settings: clock shows time and date")]
currentIndex: root.valueIndex(root.clockDisplayValues, root.setting("islandHomeClockDisplay"), "both")
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.set("dankIslandHomeVolumeDisplay", root.systemLevelDisplayValues[index] ?? "both");
root.apply("islandHomeClockDisplay", root.clockDisplayValues[index] ?? "both");
}
}
SettingsButtonGroupRow {
settingKey: "dankIslandHomeBrightnessDisplay"
tags: ["island", "home", "compact", "brightness", "icon", "percentage"]
text: I18n.tr("Brightness Style", "island settings: brightness display mode row")
visible: SettingsData.islandHomeGroupEnabled("brightness")
settingKey: "islandHomeVolumeDisplay"
tags: ["island", "home", "compact", "volume", "icon", "percentage"]
text: I18n.tr("Volume Style", "island settings: volume display mode row")
visible: SettingsData.islandHomeGroupEnabled(root.config, "volume")
model: [I18n.tr("Icon", "island settings: level shown as icon only"), I18n.tr("Percentage", "island settings: level shown as percentage only"), I18n.tr("Both", "island settings: level shown as icon and percentage")]
currentIndex: root.valueIndex(root.systemLevelDisplayValues, SettingsData.dankIslandHomeBrightnessDisplay, "both")
currentIndex: root.valueIndex(root.systemLevelDisplayValues, root.setting("islandHomeVolumeDisplay"), "both")
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.set("dankIslandHomeBrightnessDisplay", root.systemLevelDisplayValues[index] ?? "both");
root.apply("islandHomeVolumeDisplay", root.systemLevelDisplayValues[index] ?? "both");
}
}
SettingsButtonGroupRow {
settingKey: "islandHomeBrightnessDisplay"
tags: ["island", "home", "compact", "brightness", "icon", "percentage"]
text: I18n.tr("Brightness Style", "island settings: brightness display mode row")
visible: SettingsData.islandHomeGroupEnabled(root.config, "brightness")
model: [I18n.tr("Icon", "island settings: level shown as icon only"), I18n.tr("Percentage", "island settings: level shown as percentage only"), I18n.tr("Both", "island settings: level shown as icon and percentage")]
currentIndex: root.valueIndex(root.systemLevelDisplayValues, root.setting("islandHomeBrightnessDisplay"), "both")
onSelectionChanged: (index, selected) => {
if (selected)
root.apply("islandHomeBrightnessDisplay", root.systemLevelDisplayValues[index] ?? "both");
}
}
SettingsToggleRow {
settingKey: "dankIslandHomeCompactTight"
settingKey: "islandHomeCompactTight"
tags: ["island", "home", "compact", "narrow", "width", "height", "clock"]
text: I18n.tr("Compact Clock Pill", "island settings: tighter home pill toggle")
text: I18n.tr("Compact Pill", "island settings: tighter home pill toggle")
description: I18n.tr("Compact home clock pill in both width and height", "island settings: tight pill description")
checked: SettingsData.dankIslandHomeCompactTight
onToggled: checked => SettingsData.set("dankIslandHomeCompactTight", checked)
checked: root.setting("islandHomeCompactTight")
onToggled: checked => root.apply("islandHomeCompactTight", checked)
}
Flow {
@@ -273,23 +315,23 @@ Item {
width: parent.width
iconName: "palette"
title: I18n.tr("Appearance", "island settings: appearance card title")
settingKey: "dankIslandAppearance"
visible: SettingsData.dankIslandEnabled
settingKey: "islandAppearance"
visible: root.islandEnabled
SettingsButtonGroupRow {
settingKey: "dankIslandPalette"
settingKey: "islandPalette"
tags: ["island", "appearance", "palette", "surface", "bright", "dim"]
text: I18n.tr("Palette", "island settings: surface tone choice")
model: [I18n.tr("Default", "island settings: default surface tone"), I18n.tr("Bright", "island settings: bright surface tone"), I18n.tr("Dim", "island settings: dim surface tone")]
currentIndex: root.valueIndex(root.paletteValues, SettingsData.dankIslandPalette, "default")
currentIndex: root.valueIndex(root.paletteValues, root.setting("islandPalette"), "default")
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.set("dankIslandPalette", root.paletteValues[index] ?? "default");
root.apply("islandPalette", root.paletteValues[index] ?? "default");
}
}
SettingsSliderRow {
settingKey: "dankIslandTransparency"
settingKey: "islandTransparency"
tags: ["island", "appearance", "surface", "opacity", "transparency", "blur"]
text: I18n.tr("Opacity", "island settings: island surface opacity slider")
unit: "%"
@@ -297,12 +339,12 @@ Item {
maximum: 100
step: 1
defaultValue: 100
value: Math.round(SettingsData.dankIslandTransparency * 100)
onSliderValueChanged: value => SettingsData.set("dankIslandTransparency", value / 100)
value: Math.round(root.setting("islandTransparency") * 100)
onSliderValueChanged: value => root.apply("islandTransparency", value / 100)
}
SettingsSliderRow {
settingKey: "dankIslandCornerRadius"
settingKey: "islandCornerRadius"
tags: ["island", "appearance", "corner", "radius", "rounding", "pill", "expanded"]
text: I18n.tr("Corner Radius", "island settings: island corner radius slider")
unit: "px"
@@ -310,39 +352,39 @@ Item {
maximum: 64
step: 1
defaultValue: 34
value: SettingsData.dankIslandCornerRadius
onSliderValueChanged: value => SettingsData.set("dankIslandCornerRadius", value)
value: root.setting("islandCornerRadius")
onSliderValueChanged: value => root.apply("islandCornerRadius", value)
}
SettingsToggleRow {
settingKey: "dankIslandHighContrast"
settingKey: "islandHighContrast"
tags: ["island", "appearance", "contrast", "accessibility", "outline"]
text: I18n.tr("High Contrast", "island settings: high contrast toggle")
description: I18n.tr("Draw an outline and use the highest-contrast surface tone", "island settings: high contrast description")
checked: SettingsData.dankIslandHighContrast
onToggled: checked => SettingsData.set("dankIslandHighContrast", checked)
checked: root.setting("islandHighContrast")
onToggled: checked => root.apply("islandHighContrast", checked)
}
SettingsToggleRow {
settingKey: "dankIslandMediaClockVisible"
settingKey: "islandMediaClockVisible"
tags: ["island", "media", "clock", "compact", "time"]
text: I18n.tr("Keep Clock with Media", "island settings: clock in media face toggle")
description: I18n.tr("Show a clickable clock beside compact media details", "island settings: media clock description")
checked: SettingsData.dankIslandMediaClockVisible
onToggled: checked => SettingsData.set("dankIslandMediaClockVisible", checked)
checked: root.setting("islandMediaClockVisible")
onToggled: checked => root.apply("islandMediaClockVisible", checked)
}
SettingsButtonGroupRow {
settingKey: "dankIslandBatteryStyle"
settingKey: "islandBatteryStyle"
tags: ["island", "battery", "gauge", "solid", "outline", "ring", "circle", "appearance"]
text: I18n.tr("Battery Style", "island settings: battery meter style row")
description: I18n.tr("Solid or outlined material meter, or a circular gauge", "island settings: battery style description")
visible: BatteryService.batteryAvailable
model: [I18n.tr("Solid", "island settings: filled battery meter style"), I18n.tr("Outline", "island settings: outlined battery meter style"), I18n.tr("Circle", "island settings: circular battery meter style")]
currentIndex: root.valueIndex(root.batteryStyleValues, SettingsData.dankIslandBatteryStyle, "solid")
currentIndex: root.valueIndex(root.batteryStyleValues, root.setting("islandBatteryStyle"), "solid")
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.set("dankIslandBatteryStyle", root.batteryStyleValues[index] ?? "solid");
root.apply("islandBatteryStyle", root.batteryStyleValues[index] ?? "solid");
}
}
}
@@ -351,26 +393,26 @@ Item {
width: parent.width
iconName: "notifications"
title: I18n.tr("Notifications", "island settings: notifications card title")
settingKey: "dankIslandNotifications"
visible: SettingsData.dankIslandEnabled
settingKey: "islandNotifications"
visible: root.islandEnabled
SettingsToggleRow {
settingKey: "dankIslandNotificationExpand"
settingKey: "islandNotificationExpand"
tags: ["island", "notifications", "expand", "arrival", "size"]
text: I18n.tr("Expand Notifications", "island settings: expanded notification toggle")
description: I18n.tr("Expand notifications by default instead of click or hover", "island settings: expanded notification description")
checked: SettingsData.dankIslandNotificationExpand
onToggled: checked => SettingsData.set("dankIslandNotificationExpand", checked)
checked: root.setting("islandNotificationExpand")
onToggled: checked => root.apply("islandNotificationExpand", checked)
}
SettingsToggleRow {
settingKey: "dankIslandNotificationBadgeClearOnOpen"
settingKey: "islandNotificationBadgeClearOnOpen"
tags: ["island", "home", "notifications", "badge", "unread", "clear", "dismiss", "open"]
text: I18n.tr("Clear Badge on Open", "island settings: clear the notification badge when the center opens")
description: I18n.tr("Clears the badge on open but keeps notifications active", "island settings: clear badge on open description")
checked: SettingsData.dankIslandNotificationBadgeClearOnOpen
enabled: SettingsData.islandHomeGroupEnabled("notifications")
onToggled: checked => SettingsData.set("dankIslandNotificationBadgeClearOnOpen", checked)
checked: root.setting("islandNotificationBadgeClearOnOpen")
enabled: SettingsData.islandHomeGroupEnabled(root.config, "notifications")
onToggled: checked => root.apply("islandNotificationBadgeClearOnOpen", checked)
}
}
@@ -378,42 +420,42 @@ Item {
width: parent.width
iconName: "widgets"
title: I18n.tr("Satellite Widgets", "island settings: satellite widgets card title")
settingKey: "dankIslandSatellites"
settingKey: "islandSatellites"
collapsible: true
expanded: true
visible: SettingsData.dankIslandEnabled
visible: root.islandEnabled
SettingsToggleRow {
settingKey: "dankIslandSatellitesEnabled"
settingKey: "islandSatellitesEnabled"
tags: ["island", "satellite", "widgets", "left", "right"]
text: I18n.tr("Show Satellite Widgets", "island settings: satellite widgets toggle")
description: I18n.tr("Place independent widgets to left and right of island", "island settings: satellite widgets description")
checked: SettingsData.dankIslandSatellitesEnabled
onToggled: checked => SettingsData.set("dankIslandSatellitesEnabled", checked)
checked: root.setting("islandSatellitesEnabled")
onToggled: checked => root.apply("islandSatellitesEnabled", checked)
}
SettingsToggleRow {
settingKey: "dankIslandSatelliteBackground"
settingKey: "islandSatelliteBackground"
tags: ["island", "satellite", "widgets", "background", "chrome"]
text: I18n.tr("Background", "island settings: satellite background toggle")
description: I18n.tr("Draw an island-styled background behind satellite widgets", "island settings: satellite background description")
checked: SettingsData.dankIslandSatelliteBackground
enabled: SettingsData.dankIslandSatellitesEnabled
onToggled: checked => SettingsData.set("dankIslandSatelliteBackground", checked)
checked: root.setting("islandSatelliteBackground")
enabled: root.setting("islandSatellitesEnabled")
onToggled: checked => root.apply("islandSatelliteBackground", checked)
}
SettingsToggleRow {
settingKey: "dankIslandSatelliteGothCorners"
settingKey: "islandSatelliteGothCorners"
tags: ["island", "satellite", "goth", "corners", "wing", "sweep"]
text: I18n.tr("Goth Corners", "island settings: satellite goth corners toggle")
description: I18n.tr("Sweep the background into the screen edges", "island settings: satellite goth corners description")
checked: SettingsData.dankIslandSatelliteGothCorners
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatelliteBackground
onToggled: checked => SettingsData.set("dankIslandSatelliteGothCorners", checked)
checked: root.setting("islandSatelliteGothCorners")
enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatelliteBackground")
onToggled: checked => root.apply("islandSatelliteGothCorners", checked)
}
SettingsSliderRow {
settingKey: "dankIslandSatelliteSwoopRadius"
settingKey: "islandSatelliteSwoopRadius"
tags: ["island", "satellite", "goth", "corners", "radius", "sweep", "size"]
text: I18n.tr("Goth Corner Radius", "island settings: satellite goth corner radius slider")
unit: "px"
@@ -421,13 +463,13 @@ Item {
maximum: 64
step: 1
defaultValue: 24
value: SettingsData.dankIslandSatelliteSwoopRadius
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatelliteBackground && SettingsData.dankIslandSatelliteGothCorners
onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteSwoopRadius", value)
value: root.setting("islandSatelliteSwoopRadius")
enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatelliteBackground") && root.setting("islandSatelliteGothCorners")
onSliderValueChanged: value => root.apply("islandSatelliteSwoopRadius", value)
}
SettingsSliderRow {
settingKey: "dankIslandSatelliteTransparency"
settingKey: "islandSatelliteTransparency"
tags: ["island", "satellite", "background", "opacity", "transparency", "blur"]
text: I18n.tr("Opacity", "island settings: satellite background opacity slider")
unit: "%"
@@ -435,27 +477,27 @@ Item {
maximum: 100
step: 1
defaultValue: 100
value: Math.round(SettingsData.dankIslandSatelliteTransparency * 100)
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatelliteBackground
onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteTransparency", value / 100)
value: Math.round(root.setting("islandSatelliteTransparency") * 100)
enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatelliteBackground")
onSliderValueChanged: value => root.apply("islandSatelliteTransparency", value / 100)
}
SettingsButtonGroupRow {
settingKey: "dankIslandSatellitePosition"
settingKey: "islandSatellitePosition"
tags: ["island", "satellite", "widgets", "position", "edges", "center"]
text: I18n.tr("Position", "island settings: position card title")
description: I18n.tr("Keep widgets beside the island or align them like a standalone bar", "island settings: satellite position description")
model: [I18n.tr("Near Island", "island settings: satellites hug the island"), I18n.tr("Screen Edges", "island settings: satellites sit at screen edges")]
currentIndex: root.valueIndex(root.satellitePositionValues, SettingsData.dankIslandSatellitePosition, "island")
enabled: SettingsData.dankIslandSatellitesEnabled
currentIndex: root.valueIndex(root.satellitePositionValues, root.setting("islandSatellitePosition"), "island")
enabled: root.setting("islandSatellitesEnabled")
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.set("dankIslandSatellitePosition", root.satellitePositionValues[index] ?? "island");
root.apply("islandSatellitePosition", root.satellitePositionValues[index] ?? "island");
}
}
SettingsSliderRow {
settingKey: "dankIslandSatelliteGap"
settingKey: "islandSatelliteGap"
tags: ["island", "satellite", "widgets", "gap", "spacing"]
text: I18n.tr("Island Gap", "island settings: satellite to island gap slider")
unit: "px"
@@ -463,9 +505,9 @@ Item {
maximum: 48
step: 1
defaultValue: 12
value: SettingsData.dankIslandSatelliteGap
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatellitePosition !== "edges"
onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteGap", value)
value: root.setting("islandSatelliteGap")
enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatellitePosition") !== "edges"
onSliderValueChanged: value => root.apply("islandSatelliteGap", value)
}
StyledText {
@@ -474,7 +516,7 @@ Item {
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
visible: SettingsData.dankIslandSatellitesEnabled
visible: root.setting("islandSatellitesEnabled")
}
}
@@ -482,19 +524,19 @@ Item {
width: parent.width
iconName: "touch_app"
title: I18n.tr("Behavior", "island settings: behavior card title")
settingKey: "dankIslandInteraction"
visible: SettingsData.dankIslandEnabled
settingKey: "islandInteraction"
visible: root.islandEnabled
SettingsButtonGroupRow {
settingKey: "dankIslandInteractionMode"
settingKey: "islandInteractionMode"
tags: ["island", "interaction", "click", "hybrid", "expand"]
text: I18n.tr("Expansion Mode", "island settings: click or hover expansion row")
description: I18n.tr("Click expands only on an intentional press. Hybrid peeks the current compact face on hover", "island settings: expansion mode description")
model: [I18n.tr("Click", "island settings: click expansion mode"), I18n.tr("Hybrid", "island settings: hover plus click expansion mode")]
currentIndex: root.valueIndex(root.interactionModeValues, SettingsData.dankIslandInteractionMode, "hybrid")
currentIndex: root.valueIndex(root.interactionModeValues, root.setting("islandInteractionMode"), "hybrid")
onSelectionChanged: (index, selected) => {
if (selected)
SettingsData.set("dankIslandInteractionMode", root.interactionModeValues[index] ?? "hybrid");
root.apply("islandInteractionMode", root.interactionModeValues[index] ?? "hybrid");
}
}
@@ -504,11 +546,11 @@ Item {
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
visible: SettingsData.dankIslandInteractionMode !== "click"
visible: root.setting("islandInteractionMode") !== "click"
}
SettingsSliderRow {
settingKey: "dankIslandHoverOpenDelay"
settingKey: "islandHoverOpenDelay"
tags: ["island", "interaction", "hover", "open", "delay"]
text: I18n.tr("Open Delay", "island settings: hover open delay slider")
unit: "ms"
@@ -516,13 +558,13 @@ Item {
maximum: 1000
step: 10
defaultValue: 150
value: SettingsData.dankIslandHoverOpenDelay
enabled: SettingsData.dankIslandInteractionMode !== "click"
onSliderValueChanged: value => SettingsData.set("dankIslandHoverOpenDelay", value)
value: root.setting("islandHoverOpenDelay")
enabled: root.setting("islandInteractionMode") !== "click"
onSliderValueChanged: value => root.apply("islandHoverOpenDelay", value)
}
SettingsSliderRow {
settingKey: "dankIslandHoverCloseDelay"
settingKey: "islandHoverCloseDelay"
tags: ["island", "interaction", "hover", "close", "delay"]
text: I18n.tr("Hide Delay", "island settings: hover hide delay slider")
unit: "ms"
@@ -530,9 +572,9 @@ Item {
maximum: 1000
step: 10
defaultValue: 150
value: SettingsData.dankIslandHoverCloseDelay
enabled: SettingsData.dankIslandInteractionMode !== "click"
onSliderValueChanged: value => SettingsData.set("dankIslandHoverCloseDelay", value)
value: root.setting("islandHoverCloseDelay")
enabled: root.setting("islandInteractionMode") !== "click"
onSliderValueChanged: value => root.apply("islandHoverCloseDelay", value)
}
}
@@ -540,49 +582,49 @@ Item {
width: parent.width
iconName: "animation"
title: I18n.tr("Motion & Accessibility", "island settings: motion card title")
settingKey: "dankIslandMotion"
settingKey: "islandMotion"
collapsible: true
visible: SettingsData.dankIslandEnabled
visible: root.islandEnabled
SettingsToggleRow {
settingKey: "dankIslandReducedMotion"
settingKey: "islandReducedMotion"
tags: ["island", "motion", "animation", "reduce", "accessibility", "spring"]
text: I18n.tr("Reduce Motion", "island settings: reduce motion toggle")
description: I18n.tr("Apply island geometry changes immediately without spring overshoot", "island settings: reduce motion description")
checked: SettingsData.dankIslandReducedMotion
onToggled: checked => SettingsData.set("dankIslandReducedMotion", checked)
checked: root.setting("islandReducedMotion")
onToggled: checked => root.apply("islandReducedMotion", checked)
}
SettingsSliderRow {
settingKey: "dankIslandSpringStiffness"
settingKey: "islandSpringStiffness"
tags: ["island", "motion", "spring", "stiffness", "animation"]
text: I18n.tr("Spring Stiffness", "island settings: spring stiffness slider")
description: I18n.tr("Higher values pull island toward its target more strongly", "island settings: stiffness description")
minimum: 100
maximum: 1200
step: 10
value: Math.round(SettingsData.dankIslandSpringStiffness)
value: Math.round(root.setting("islandSpringStiffness"))
defaultValue: 560
enabled: !SettingsData.dankIslandReducedMotion
onSliderValueChanged: value => SettingsData.set("dankIslandSpringStiffness", value)
enabled: !root.setting("islandReducedMotion")
onSliderValueChanged: value => root.apply("islandSpringStiffness", value)
}
SettingsSliderRow {
settingKey: "dankIslandSpringDamping"
settingKey: "islandSpringDamping"
tags: ["island", "motion", "spring", "damping", "bounce", "animation"]
text: I18n.tr("Spring Damping", "island settings: spring damping slider")
description: I18n.tr("Higher values settle island with less bounce", "island settings: damping description")
minimum: 10
maximum: 100
step: 1
value: Math.round(SettingsData.dankIslandSpringDamping)
value: Math.round(root.setting("islandSpringDamping"))
defaultValue: 37
enabled: !SettingsData.dankIslandReducedMotion
onSliderValueChanged: value => SettingsData.set("dankIslandSpringDamping", value)
enabled: !root.setting("islandReducedMotion")
onSliderValueChanged: value => root.apply("islandSpringDamping", value)
}
SettingsSliderRow {
settingKey: "dankIslandSpringMass"
settingKey: "islandSpringMass"
tags: ["island", "motion", "spring", "mass", "inertia", "animation"]
text: I18n.tr("Spring Mass", "island settings: spring mass slider")
description: I18n.tr("Higher percentages give island more inertia", "island settings: mass description")
@@ -590,10 +632,10 @@ Item {
maximum: 300
step: 5
unit: "%"
value: Math.round(SettingsData.dankIslandSpringMass * 100)
value: Math.round(root.setting("islandSpringMass") * 100)
defaultValue: 100
enabled: !SettingsData.dankIslandReducedMotion
onSliderValueChanged: value => SettingsData.set("dankIslandSpringMass", value / 100)
enabled: !root.setting("islandReducedMotion")
onSliderValueChanged: value => root.apply("islandSpringMass", value / 100)
}
}
}
@@ -41,6 +41,197 @@ Singleton {
property bool hasPendingChanges: Object.keys(pendingChanges).length > 0 || Object.keys(pendingNiriChanges).length > 0 || Object.keys(pendingHyprlandChanges).length > 0 || formatChanged
property bool validatingConfig: false
property var _cancelOutputWrite: null
property var aqueousPreview: null
function outputFingerprint(outputs) {
return JSON.stringify(outputs.map(o => ({
name: o.name,
id: o.id,
enabled: o.enabled,
x: o.x,
y: o.y,
scale: o.scale,
transform: o.transform,
mode: o.currentMode ? [o.currentMode.width, o.currentMode.height, o.currentMode.refresh] : null,
adaptiveSync: o.adaptiveSync
})).sort((a, b) => a.name.localeCompare(b.name)));
}
function outputHeads(outputs) {
return outputs.map(o => ({
name: o.name,
enabled: o.enabled,
modeId: o.currentMode?.id,
position: {
x: o.x,
y: o.y
},
scale: o.scale,
transform: o.transform,
adaptiveSync: o.adaptiveSync
}));
}
function outputHeadsMatch(candidate, actual, original) {
if (candidate.length !== actual.length || original.length !== actual.length || original.some(o => !actual.some(a => a.id === o.id && a.name === o.name)))
return false;
return candidate.every(h => {
const output = actual.find(o => o.name === h.name);
if (!output || output.enabled !== h.enabled)
return false;
if (!h.enabled)
return true;
const mode = h.customMode || original.find(o => o.name === h.name)?.modes?.find(m => m.id === h.modeId);
return output.x === h.position.x && output.y === h.position.y && Math.abs(output.scale - h.scale) < 0.0001 && output.transform === h.transform && (h.adaptiveSync === undefined || output.adaptiveSync === h.adaptiveSync) && !!mode && output.currentMode?.width === mode.width && output.currentMode?.height === mode.height && output.currentMode?.refresh === mode.refresh;
});
}
function freshAqueousOutputs(callback) {
DMSService.sendRequest("wlroutput.getState", null, response => {
callback(response.result?.outputs || null, response.error || "");
}, 5000);
}
function aqueousDisplayError(message) {
validatingConfig = false;
validationError = AqueousService.errorMessage(message);
ToastService.showError(I18n.tr("Error"), validationError, message);
}
function discardAqueousPreview() {
if (validatingConfig)
return;
aqueousPreview = null;
restoreDisplayNameMode();
validationError = "";
clearPendingChanges();
WlrOutputService.requestState();
}
function previewAqueousOutputs(descriptions) {
if (validatingConfig)
return;
if (!AqueousService.available) {
aqueousDisplayError("unavailable: compositor state");
return;
}
if (aqueousPreview) {
observeAqueousPreview(aqueousPreview, descriptions, "");
return;
}
const session = AqueousService.session;
validatingConfig = true;
AqueousConfigService.load((snapshot, error) => {
if (!snapshot) {
aqueousDisplayError(error);
return;
}
freshAqueousOutputs((original, error) => {
if (AqueousService.session !== session) {
aqueousDisplayError("conflict: compositor session changed");
return;
}
if (!original) {
aqueousDisplayError(error);
return;
}
const candidate = WlrOutputService.outputsConfigHeads(buildOutputsWithPendingChanges(), outputs);
WlrOutputService.testConfiguration(candidate, (success, message) => {
if (AqueousService.session !== session) {
aqueousDisplayError("conflict: compositor session changed");
return;
}
if (!success) {
aqueousDisplayError(message);
return;
}
const preview = {
snapshot: snapshot,
original: original,
heads: candidate,
fingerprint: null,
session: session
};
aqueousPreview = preview;
WlrOutputService.applyConfiguration(candidate, (success, message) => {
observeAqueousPreview(preview, descriptions, success ? "" : message);
});
});
});
});
}
function observeAqueousPreview(preview, descriptions, applyError) {
validatingConfig = true;
freshAqueousOutputs((actual, error) => {
if (aqueousPreview !== preview)
return;
if (!actual || AqueousService.session !== preview.session || !outputHeadsMatch(preview.heads, actual, preview.original)) {
if (actual && outputFingerprint(actual) === outputFingerprint(preview.original))
aqueousPreview = null;
aqueousDisplayError(error || applyError || "conflict: display preview changed externally");
return;
}
preview.fingerprint = outputFingerprint(actual);
validatingConfig = false;
validationError = applyError ? AqueousService.errorMessage(applyError) : "";
changesApplied(descriptions);
});
}
function finishAqueousPreview(keep) {
if (validatingConfig || !aqueousPreview)
return;
const preview = aqueousPreview;
validatingConfig = true;
freshAqueousOutputs((actual, error) => {
if (!actual || AqueousService.session !== preview.session || outputFingerprint(actual) !== preview.fingerprint) {
aqueousDisplayError(error || "conflict: output configuration changed after preview");
return;
}
if (keep) {
let draft;
try {
draft = AqueousConfigService.buildOutputsConfig(preview.snapshot, actual, preview.original);
} catch (e) {
aqueousDisplayError(String(e));
return;
}
AqueousConfigService.apply(draft, (snapshot, message) => {
if (!snapshot) {
aqueousDisplayError(message);
return;
}
aqueousPreview = null;
if (formatChanged)
SettingsData.saveSettings();
clearPendingChanges();
freshAqueousOutputs((live, error) => {
validatingConfig = false;
if (!live || AqueousService.session !== preview.session || outputFingerprint(live) !== preview.fingerprint) {
aqueousDisplayError("conflict: configuration saved but live display state changed");
return;
}
changesConfirmed();
});
});
return;
}
WlrOutputService.applyConfiguration(outputHeads(preview.original), (success, message) => {
if (!success) {
aqueousDisplayError(message);
return;
}
validatingConfig = false;
aqueousPreview = null;
restoreDisplayNameMode();
clearPendingChanges();
WlrOutputService.requestState();
changesReverted();
});
});
}
property string validationError: ""
property var currentOutputSet: []
@@ -530,20 +721,23 @@ Singleton {
const outputKeys = Object.keys(configEntry.outputs || {});
if (outputKeys.length === 0)
return false;
const hasEnabledReal = outputKeys.some(k => {
const o = configEntry.outputs[k];
return !o.disabled && o.make && o.model;
});
const hasEnabledReal = outputKeys.some(k => !configEntry.outputs[k].disabled && profileOutputIsReal(k));
if (hasEnabledReal)
return false;
for (const k of outputKeys) {
const o = configEntry.outputs[k];
if (o.make && o.model) {
delete o.disabled;
return true;
}
const firstReal = outputKeys.find(k => profileOutputIsReal(k));
if (!firstReal)
return false;
delete configEntry.outputs[firstReal].disabled;
return true;
}
function profileOutputIsReal(outputId) {
for (const name in outputs) {
if (!profileKeyMatchesOutput(outputId, outputs[name], name))
continue;
return !!(outputs[name].make && outputs[name].model);
}
return false;
return true;
}
function applyConfigEntry(configEntry, configId, profileName, isManual) {
@@ -556,26 +750,6 @@ Singleton {
showHyprlandReadOnlyWarning();
return;
}
ensureEnabledOutput(configEntry);
const outputKeys = Object.keys(configEntry.outputs || {});
const hasRealOutput = outputKeys.some(k => {
const o = configEntry.outputs[k];
return o.make && o.model;
});
if (!hasRealOutput) {
backendWriteOutputsConfig({}, null, success => {
if (success) {
SettingsData.setActiveDisplayProfile(CompositorService.compositor, configId);
WlrOutputService.requestState();
}
});
return;
}
// Capture the entry being applied so disabled-output settings fields can read
// scale/position/transform back even when wlr reports no logical viewport.
root.lastAppliedEntry = JSON.parse(JSON.stringify(configEntry));
const outputsData = generateOutputsDataFromConfig(configEntry);
const onWriteFailed = () => {
if (isManual) {
profilesLoading = false;
@@ -583,6 +757,17 @@ Singleton {
profileError(I18n.tr("Failed to apply profile"));
}
};
ensureEnabledOutput(configEntry);
if (!Object.keys(configEntry.outputs || {}).some(k => profileOutputIsReal(k))) {
log.warn("Profile", configId, "has no real outputs, not applying");
onWriteFailed();
return;
}
// Capture the entry being applied so disabled-output settings fields can read
// scale/position/transform back even when wlr reports no logical viewport.
root.lastAppliedEntry = JSON.parse(JSON.stringify(configEntry));
const outputsData = generateOutputsDataFromConfig(configEntry);
const onWriteSuccess = () => {
SessionData.setActiveDisplayProfile(CompositorService.compositor, configId);
publishActiveProfileModes();
@@ -806,19 +991,17 @@ Singleton {
const outputConfigs = buildCurrentOutputConfigs();
const id = generateAutoProfileId(currentOutputSet);
const entry = {
"id": id,
"name": "",
"outputs": outputConfigs
};
ensureEnabledOutput(entry);
const existingIdx = data.configurations.findIndex(c => c.id === id);
if (existingIdx >= 0)
data.configurations[existingIdx] = {
"id": id,
"name": "",
"outputs": outputConfigs
};
data.configurations[existingIdx] = entry;
else
data.configurations.push({
"id": id,
"name": "",
"outputs": outputConfigs
});
data.configurations.push(entry);
writeMonitorsJson(data, success => {
if (!success)
return;
@@ -939,7 +1122,7 @@ Singleton {
}
Connections {
target: SettingsData
target: SessionData
function onActiveDisplayProfileChanged() {
root.publishActiveProfileModes();
}
@@ -1715,9 +1898,21 @@ Singleton {
MangoService.generateOutputsConfig(outputsData, finish);
break;
default:
WlrOutputService.applyOutputsConfig(outputsData, outputs);
finish(true);
break;
{
if (_cancelOutputWrite)
_cancelOutputWrite();
let completed = false;
const complete = success => {
if (completed)
return;
completed = true;
root._cancelOutputWrite = null;
finish(success);
};
_cancelOutputWrite = () => complete(false);
WlrOutputService.applyOutputsConfig(outputsData, outputs, complete);
break;
}
}
return true;
}
@@ -2153,11 +2348,15 @@ Singleton {
originalDisplayNameMode = "";
}
function restoreDisplayNameMode() {
if (originalDisplayNameMode === "")
return;
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
function discardChanges() {
if (originalDisplayNameMode !== "") {
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
restoreDisplayNameMode();
backendFetchOutputs();
clearPendingChanges();
}
@@ -2229,16 +2428,32 @@ Singleton {
return;
}
changesApplied(changeDescriptions);
if (formatChanged)
SettingsData.saveSettings();
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
if (CompositorService.isAqueous) {
previewAqueousOutputs(changeDescriptions);
return;
}
const mergedOutputs = buildOutputsWithPendingChanges();
backendWriteOutputsConfig(mergedOutputs);
if (CompositorService.isHyprland || CompositorService.isMango) {
changesApplied(changeDescriptions);
if (formatChanged)
SettingsData.saveSettings();
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
backendWriteOutputsConfig(mergedOutputs);
return;
}
validatingConfig = true;
backendWriteOutputsConfig(mergedOutputs, success => {
validatingConfig = false;
if (!success) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to apply profile"));
return;
}
if (formatChanged)
SettingsData.saveSettings();
changesApplied(changeDescriptions);
});
}
function validateAndApplyNiriConfig(changeDescriptions) {
@@ -2349,6 +2564,10 @@ Singleton {
}
function confirmChanges(profileId) {
if (CompositorService.isAqueous) {
finishAqueousPreview(true);
return;
}
const outputConfigs = buildCurrentOutputConfigs();
lastAppliedEntry = {
outputs: outputConfigs
@@ -2380,14 +2599,15 @@ Singleton {
}
function revertChanges() {
if (CompositorService.isAqueous && aqueousPreview) {
finishAqueousPreview(false);
return;
}
const hadFormatChange = originalDisplayNameMode !== "";
const hadNiriChanges = originalNiriSettings !== null;
const hadHyprlandChanges = originalHyprlandSettings !== null;
if (hadFormatChange) {
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
restoreDisplayNameMode();
if (hadNiriChanges) {
SessionData.niriOutputSettings = JSON.parse(JSON.stringify(originalNiriSettings));

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