Compare commits

...
42 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 ASSETS_DIR=assets
APPLICATIONS_DIR=$(DATA_DIR)/applications 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 all: build
@@ -53,14 +53,6 @@ install-bin:
@install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
@echo "Binary installed" @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: install-completions:
@echo "Installing shell completions..." @echo "Installing shell completions..."
@mkdir -p $(DATA_DIR)/bash-completion/completions @mkdir -p $(DATA_DIR)/bash-completion/completions
@@ -98,7 +90,7 @@ install-desktop:
@update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true @update-desktop-database -q $(APPLICATIONS_DIR) 2>/dev/null || true
@echo "Desktop entries installed" @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 ""
@echo "Installation complete!" @echo "Installation complete!"
@echo "" @echo ""
@@ -111,7 +103,7 @@ uninstall-bin:
@echo "Binary removed" @echo "Binary removed"
uninstall-shell: 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) @rm -rf $(SHELL_INSTALL_DIR)
@echo "Shell files removed" @echo "Shell files removed"
@@ -159,7 +151,6 @@ help:
@echo "Install:" @echo "Install:"
@echo " install - Build and install everything (requires sudo)" @echo " install - Build and install everything (requires sudo)"
@echo " install-bin - Install only the binary" @echo " install-bin - Install only the binary"
@echo " install-shell - Install only shell files"
@echo " install-completions - Install only shell completions" @echo " install-completions - Install only shell completions"
@echo " install-systemd - Install only systemd service" @echo " install-systemd - Install only systemd service"
@echo " install-icon - Install only icon" @echo " install-icon - Install only icon"
@@ -168,7 +159,7 @@ help:
@echo "Uninstall:" @echo "Uninstall:"
@echo " uninstall - Remove everything (requires sudo)" @echo " uninstall - Remove everything (requires sudo)"
@echo " uninstall-bin - Remove only the binary" @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-completions - Remove only shell completions"
@echo " uninstall-systemd - Remove only systemd service" @echo " uninstall-systemd - Remove only systemd service"
@echo " uninstall-icon - Remove only icon" @echo " uninstall-icon - Remove only icon"
+1 -1
View File
@@ -9,7 +9,7 @@ Type=dbus
BusName=org.freedesktop.Notifications BusName=org.freedesktop.Notifications
ExecStart=/usr/bin/dms run --session ExecStart=/usr/bin/dms run --session
ExecReload=/bin/kill -USR1 $MAINPID ExecReload=/bin/kill -USR1 $MAINPID
LimitNOFILE=16384 LimitNOFILE=16384:infinity
Restart=on-failure Restart=on-failure
RestartForceExitStatus=TEMPFAIL RestartForceExitStatus=TEMPFAIL
SuccessExitStatus=TEMPFAIL SuccessExitStatus=TEMPFAIL
+14 -10
View File
@@ -11,10 +11,11 @@ import (
) )
var ( var (
colorOutputFmt string colorOutputFmt string
colorAutocopy bool colorAutocopy bool
colorNotify bool colorNotify bool
colorLowercase bool colorLowercase bool
colorAllowMulti bool
) )
var colorCmd = &cobra.Command{ var colorCmd = &cobra.Command{
@@ -54,7 +55,8 @@ Examples:
dms color pick --rgb # Output as RGB dms color pick --rgb # Output as RGB
dms color pick --json # Output all formats as JSON dms color pick --json # Output all formats as JSON
dms color pick --hex -l # Output hex in lowercase 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, Run: runColorPick,
} }
@@ -69,6 +71,7 @@ func init() {
colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template") 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(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard")
colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase") 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") colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json")
@@ -90,11 +93,12 @@ func runColorPick(cmd *cobra.Command, args []string) {
} }
config := colorpicker.Config{ config := colorpicker.Config{
Format: format, Format: format,
CustomFormat: colorOutputFmt, CustomFormat: colorOutputFmt,
Lowercase: colorLowercase, Lowercase: colorLowercase,
Autocopy: colorAutocopy, Autocopy: colorAutocopy,
Notify: colorNotify, Notify: colorNotify,
AllowMultiple: colorAllowMulti,
} }
// One-shot process holding a few screen-sized buffers: skip GC cycles while it runs. // 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 ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -46,7 +47,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override", Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider", Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3), Args: cobra.ExactArgs(3),
Run: runKeybindsSet, Run: runKeybindsEdit,
} }
var keybindsRemoveCmd = &cobra.Command{ var keybindsRemoveCmd = &cobra.Command{
@@ -54,7 +55,7 @@ var keybindsRemoveCmd = &cobra.Command{
Short: "Remove a keybind", 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.", 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), Args: cobra.ExactArgs(2),
Run: runKeybindsRemove, Run: runKeybindsEdit,
} }
var keybindsResetCmd = &cobra.Command{ var keybindsResetCmd = &cobra.Command{
@@ -62,10 +63,14 @@ var keybindsResetCmd = &cobra.Command{
Short: "Reset a keybind override to its DMS default", 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.", 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), Args: cobra.ExactArgs(2),
Run: runKeybindsReset, Run: runKeybindsEdit,
} }
func init() { 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") keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay") keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -93,6 +98,9 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry() registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("") 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 { if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err) log.Warnf("Failed to register Hyprland provider: %v", err)
} }
@@ -206,85 +214,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider) printCheatSheet(provider)
} }
func getWritableProvider(name string) keybinds.WritableProvider { func keybindEditFailure(err error) map[string]any {
provider, err := keybinds.GetDefaultRegistry().Get(name) 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 { 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) writable, ok := provider.(keybinds.WritableProvider)
if !ok { 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 result := map[string]any{"success": true, "key": key}
} switch cmd.Name() {
case "set":
func runKeybindsSet(cmd *cobra.Command, args []string) { options := make(map[string]any)
providerName, key, action := args[0], args[1], args[2] if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
writable := getWritableProvider(providerName) options["allow-when-locked"] = true
}
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key { if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
_ = writable.RemoveBind(replaceKey) options["cooldown-ms"] = v
} }
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options := make(map[string]any) options["repeat"] = false
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v { }
options["allow-when-locked"] = true if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
} options["allow-inhibiting"] = false
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 { }
options["cooldown-ms"] = v if v, _ := cmd.Flags().GetString("flags"); v != "" {
} options["flags"] = v
if v, _ := cmd.Flags().GetBool("no-repeat"); v { }
options["repeat"] = false if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
} _ = writable.RemoveBind(replaceKey)
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v { }
options["allow-inhibiting"] = false desc, _ := cmd.Flags().GetString("desc")
} err = writable.SetBind(key, args[2], desc, options)
if v, _ := cmd.Flags().GetString("flags"); v != "" { result["action"] = args[2]
options["flags"] = v result["path"] = writable.GetOverridePath()
} case "remove":
err = writable.RemoveBind(key)
desc, _ := cmd.Flags().GetString("desc") result["removed"] = true
if err := writable.SetBind(key, action, desc, options); err != nil { case "reset":
log.Fatalf("Error setting keybind: %v", err) err = writable.ResetBind(key)
} result["reset"] = true
default:
output, _ := json.MarshalIndent(map[string]any{ return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
"success": true, }
"key": key, return result, err
"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))
} }
+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 ( var (
ssOutputName string ssOutputName string
ssSeat string
ssCursor string ssCursor string
ssFormat string ssFormat string
ssQuality int ssQuality int
@@ -33,6 +34,7 @@ var (
ssStdout bool ssStdout bool
ssJSON bool ssJSON bool
ssGeometry bool ssGeometry bool
ssAllowMulti bool
) )
type screenshotMetadata struct { type screenshotMetadata struct {
@@ -57,7 +59,7 @@ Modes:
full - Capture the focused output full - Capture the focused output
all - Capture all outputs combined all - Capture all outputs combined
output - Capture a specific output by name 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 last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image 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 full # Full screen of focused output
dms screenshot all # All screens combined dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output 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 last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only dms screenshot --no-clipboard # Save file only
@@ -80,6 +82,7 @@ Examples:
dms screenshot --cursor=on # Include cursor dms screenshot --cursor=on # Include cursor
dms screenshot -f jpg -q 85 # JPEG with quality 85 dms screenshot -f jpg -q 85 # JPEG with quality 85
dms screenshot --json # Print capture metadata as JSON 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 -g # Print selected region geometry (X,Y WxH) to stdout
dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels dms screenshot scroll # Scroll capture, Enter finishes / Esc cancels
dms screenshot scroll --interval 250`, dms screenshot scroll --interval 250`,
@@ -120,7 +123,7 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{ var ssWindowCmd = &cobra.Command{
Use: "window", Use: "window",
Short: "Capture the focused 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, Run: runScreenshotWindow,
} }
@@ -163,6 +166,7 @@ var notifyActionCmd = &cobra.Command{
func init() { func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode") 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().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)") screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)") 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(&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().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().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)") 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 := screenshot.DefaultConfig()
config.Mode = mode config.Mode = mode
config.OutputName = ssOutputName config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") { if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn config.Cursor = screenshot.CursorOn
} }
@@ -205,6 +211,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config.Reset = ssReset config.Reset = ssReset
config.Stdout = ssStdout config.Stdout = ssStdout
config.Geometry = ssGeometry config.Geometry = ssGeometry
config.AllowMultiple = ssAllowMulti
if ssGeometry { if ssGeometry {
config.Clipboard = false 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"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "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 // 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 { func getServerSocketPath() string {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR") runtimeDir := utils.RuntimeDir()
if runtimeDir == "" {
runtimeDir = os.TempDir()
}
if sessionSock, ok := shellApp.SessionSocketPath(); ok { if sessionSock, ok := shellApp.SessionSocketPath(); ok {
return sessionSock return sessionSock
+12 -106
View File
@@ -2,28 +2,19 @@ package clipboard
import ( import (
"fmt" "fmt"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client" wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
const ( const (
xkbKeymapFormatV1 = 1
keyStateReleased = 0 keyStateReleased = 0
keyStatePressed = 1 keyStatePressed = 1
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2 // xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
shiftModMask = 1 << 0 shiftModMask = 1 << 0
ctrlModMask = 1 << 2 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 // SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
@@ -50,25 +41,25 @@ func SendPasteKeystroke(withShift bool) (err error) {
} }
defer keyboard.Release() defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent var seatKeymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) { keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil { if seatKeymap == nil {
keymap = &e seatKeymap = &e
} }
}) })
s.display.Roundtrip() s.display.Roundtrip()
if keymap == nil || keymap.Format != xkbKeymapFormatV1 { if seatKeymap == nil || seatKeymap.Format != keymap.FormatXkbV1 {
return fmt.Errorf("no xkb keymap from seat") 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 { if err != nil {
return fmt.Errorf("read keymap: %w", err) return fmt.Errorf("read keymap: %w", err)
} }
keys := resolveKeycodes(keymapText) keys := keymap.Parse(keymapText)
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat) vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
if err != nil { if err != nil {
@@ -76,15 +67,15 @@ func SendPasteKeystroke(withShift bool) (err error) {
} }
defer vk.Destroy() 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) return fmt.Errorf("set keymap: %w", err)
} }
mods := uint32(ctrlModMask) mods := uint32(ctrlModMask)
held := []uint32{keys.ctrl} held := []uint32{keys.Keycode("Control_L")}
if withShift { if withShift {
mods |= shiftModMask mods |= shiftModMask
held = append(held, keys.shift) held = append(held, keys.Keycode("Shift_L"))
} }
t := uint32(0) t := uint32(0)
@@ -121,93 +112,8 @@ func SendPasteKeystroke(withShift bool) (err error) {
if err := vk.Modifiers(mods, 0, 0, 0); err != nil { if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
return fmt.Errorf("set modifiers: %w", err) 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 fmt.Errorf("key press: %w", err)
} }
return nil 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" "os"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client" wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
@@ -32,20 +33,20 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
} }
defer keyboard.Release() defer keyboard.Release()
var keymap *wlclient.KeyboardKeymapEvent var seatKeymap *wlclient.KeyboardKeymapEvent
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) { keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
if keymap == nil { if seatKeymap == nil {
keymap = &e seatKeymap = &e
} }
}) })
s.display.Roundtrip() s.display.Roundtrip()
if keymap == nil { if seatKeymap == nil {
t.Fatal("no keymap event") 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 { if err != nil {
t.Fatalf("read keymap: %v", err) t.Fatalf("read keymap: %v", err)
} }
@@ -56,10 +57,7 @@ func TestLiveSeatKeymapResolution(t *testing.T) {
} }
} }
keys := resolveKeycodes(text) keys := keymap.Parse(text)
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v) 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"))
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
}
} }
-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/wlr_screencopy"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_color_management" "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/proto/wp_viewporter"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client" "github.com/AvengeMedia/dankgo/wayland/client"
) )
type Config struct { type Config struct {
Format OutputFormat Format OutputFormat
CustomFormat string CustomFormat string
Lowercase bool Lowercase bool
Autocopy bool Autocopy bool
Notify bool Notify bool
AllowMultiple bool
} }
type Output struct { type Output struct {
@@ -62,6 +65,7 @@ type Picker struct {
seat *client.Seat seat *client.Seat
pointer *client.Pointer pointer *client.Pointer
keyboard *client.Keyboard keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1 layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1 screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter viewporter *wp_viewporter.WpViewporter
@@ -91,6 +95,14 @@ func New(config Config) *Picker {
} }
func (p *Picker) Run() (*Color, error) { 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 { if err := p.connect(); err != nil {
return nil, fmt.Errorf("wayland connect: %w", err) return nil, fmt.Errorf("wayland connect: %w", err)
} }
@@ -728,9 +740,14 @@ func (p *Picker) setupPointerHandlers() {
} }
func (p *Picker) setupKeyboardHandlers() { func (p *Picker) setupKeyboardHandlers() {
p.keyboard.SetKeymapHandler(func(e client.KeyboardKeymapEvent) {
p.keymap = keymap.FromEvent(e)
})
p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) { p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
sym := p.keymap.Keysym(e.Key)
for _, ls := range p.surfaces { 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 { if state != 1 {
return return
} }
@@ -274,10 +274,10 @@ func (s *SurfaceState) OnKey(key, state uint32) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
switch key { switch sym {
case 1: // KEY_ESC case "Escape":
s.cancelled = true s.cancelled = true
case 28: // KEY_ENTER case "Return", "KP_Enter":
if s.readyForDisplay && s.screenBuf != nil { if s.readyForDisplay && s.screenBuf != nil {
s.colorPicked = true s.colorPicked = true
} }
+1 -1
View File
@@ -104,7 +104,7 @@ func TestSurfaceState_ConcurrentIsDone(t *testing.T) {
for range goroutines / 3 { for range goroutines / 3 {
wg.Go(func() { wg.Go(func() {
for range iterations { 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 { type CheatSheet struct {
Generation string `json:"generation,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Provider string `json:"provider"` Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"` 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) 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 { func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml") userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
wroteConfig := false cfgFile.WriteString(userConfigSection(opts))
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")
}
baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml") baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
if data, err := os.ReadFile(baseConfigPath); err == nil { if data, err := os.ReadFile(baseConfigPath); err == nil {
@@ -983,7 +985,32 @@ func runMatugenDryRun(opts *Options) (string, error) {
return execDryRun(opts, newFlags) 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) { func execDryRun(opts *Options, flags matugenFlags) (string, error) {
cfgPath, err := writeDryRunConfig(opts)
if err != nil {
return "", err
}
defer os.Remove(cfgPath)
var baseArgs []string var baseArgs []string
switch opts.Kind { switch opts.Kind {
case "hex": case "hex":
@@ -991,7 +1018,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
default: default:
baseArgs = []string{opts.Kind, opts.Value} 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) baseArgs = appendContrastArg(baseArgs, opts.Contrast)
if flags.isV4 { if flags.isV4 {
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...) 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" "fmt"
"io" "io"
"net" "net"
"os"
"path/filepath" "path/filepath"
"time" "time"
"unicode/utf16" "unicode/utf16"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
const ( const (
@@ -148,9 +149,5 @@ func readQString(r io.Reader) (string, error) {
// SocketPathForPID returns the instance IPC socket used by qs --pid. // SocketPathForPID returns the instance IPC socket used by qs --pid.
func SocketPathForPID(pid int) string { func SocketPathForPID(pid int) string {
runtimeDir := os.Getenv("XDG_RUNTIME_DIR") return filepath.Join(utils.RuntimeDir(), "quickshell", "by-pid", fmt.Sprint(pid), "ipc.sock")
if runtimeDir == "" {
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
}
return filepath.Join(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 package screenshot
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -21,6 +22,7 @@ const (
CompositorScroll CompositorScroll
CompositorMiracle CompositorMiracle
CompositorMango CompositorMango
CompositorAqueous
) )
var detectedCompositor Compositor = -1 var detectedCompositor Compositor = -1
@@ -35,6 +37,7 @@ func DetectCompositor() Compositor {
needsStat bool needsStat bool
compositor Compositor compositor Compositor
}{ }{
{os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous},
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango}, {os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri}, {os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
{os.Getenv("SCROLLSOCK"), true, CompositorScroll}, {os.Getenv("SCROLLSOCK"), true, CompositorScroll},
@@ -78,8 +81,14 @@ func GetActiveWindow() (*WindowGeometry, error) {
return getHyprlandActiveWindow() return getHyprlandActiveWindow()
case CompositorMango: case CompositorMango:
return getMangoActiveWindow() return getMangoActiveWindow()
case CompositorAqueous:
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
return aqueousWindowGeometry(model, "")
default: 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 { func GetFocusedMonitor() string {
switch DetectCompositor() { switch DetectCompositor() {
case CompositorAqueous:
name, _ := aqueousFocusedOutput("")
return name
case CompositorHyprland: case CompositorHyprland:
return getHyprlandFocusedMonitor() return getHyprlandFocusedMonitor()
case CompositorSway: case CompositorSway:
+3 -5
View File
@@ -10,6 +10,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
const niriScreenshotTimeout = 5 * time.Second const niriScreenshotTimeout = 5 * time.Second
@@ -24,11 +26,7 @@ func CaptureNiriWindowImage(showPointer bool) (image.Image, error) {
return nil, fmt.Errorf("NIRI_SOCKET not set") return nil, fmt.Errorf("NIRI_SOCKET not set")
} }
dir := os.Getenv("XDG_RUNTIME_DIR") path := filepath.Join(utils.RuntimeDir(), fmt.Sprintf("dms-window-%d.png", os.Getpid()))
if dir == "" {
dir = os.TempDir()
}
path := filepath.Join(dir, fmt.Sprintf("dms-window-%d.png", os.Getpid()))
events, err := subscribeNiriEvents(socket) events, err := subscribeNiriEvents(socket)
if err != nil { 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_cursor_shape"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client" "github.com/AvengeMedia/dankgo/wayland/client"
) )
@@ -108,6 +109,7 @@ type RegionSelector struct {
seat *client.Seat seat *client.Seat
pointer *client.Pointer pointer *client.Pointer
keyboard *client.Keyboard keyboard *client.Keyboard
keymap *keymap.Keymap
layerShell *wlr_layer_shell.ZwlrLayerShellV1 layerShell *wlr_layer_shell.ZwlrLayerShellV1
screencopy *wlr_screencopy.ZwlrScreencopyManagerV1 screencopy *wlr_screencopy.ZwlrScreencopyManagerV1
viewporter *wp_viewporter.WpViewporter 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): case !slot.cacheValid(srcBuf, r.selection.dragging, r.showCapturedCursor, r.phase, handles, shift):
slot.shm.CopyFrom(srcBuf) slot.shm.CopyFrom(srcBuf)
r.dimBackground(slot.shm) 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.backgroundInitialized = true
slot.backgroundSource = srcBuf slot.backgroundSource = srcBuf
slot.backgroundDragging = r.selection.dragging slot.backgroundDragging = r.selection.dragging
+47 -38
View File
@@ -3,6 +3,7 @@ package screenshot
import ( import (
"math" "math"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/keymap"
"github.com/AvengeMedia/dankgo/wayland/client" "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) { r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) {
switch e.Key { r.handleKey(r.keymap.Keysym(e.Key), e.State)
case 29, 97: // Ctrl left/right })
ctrl := e.State != 0 }
if ctrl != r.ctrlHeld {
r.ctrlHeld = ctrl func (r *RegionSelector) handleKey(sym string, state uint32) {
r.refreshCursor() held := state != 0
if r.selection.hasSelection { switch sym {
for _, os := range r.surfaces { case "Control_L", "Control_R":
r.redrawSurface(os) 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 { if r.phase == phaseScroll {
switch e.Key { switch sym {
case 1: case "Escape":
r.cancelled = true
r.running = false
case 28, 96:
r.finishScroll()
}
return
}
switch e.Key {
case 1:
r.cancelled = true r.cancelled = true
r.running = false r.running = false
case 25: case "Return", "KP_Enter":
r.showCapturedCursor = !r.showCapturedCursor r.finishScroll()
for _, os := range r.surfaces {
r.redrawSurface(os)
}
case 28, 57, 96:
if r.selection.hasSelection {
r.finishSelection()
}
} }
}) 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) { 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" "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}, '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}, '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}, '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) { func (r *RegionSelector) drawScrollBar(data []byte, stride, bufW, bufH int, format uint32) {
s := r.scroll s := r.scroll
style := LoadOverlayStyle() style := LoadOverlayStyle()
const charH = 12
r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH, r.fillRect(data, stride, bufW, bufH, s.barX, s.barY, s.barW, s.barH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, 245, format) 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, r.fillRect(data, stride, bufW, bufH, s.doneX, s.doneY, s.doneW, s.btnH,
style.AccentR, style.AccentG, style.AccentB, 255, format) style.AccentR, style.AccentG, style.AccentB, 255, format)
r.drawText(data, stride, bufW, bufH, s.doneX+12, labelY, "done", 10, 10, 10, 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) style.TextR, style.TextG, style.TextB, format)
} }
func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH int) { type hudItem struct {
const charW, charH, padding, itemSpacing = 8, 12, 12, 24 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" cursorLabel := "hide"
if !r.showCapturedCursor { if !r.showCapturedCursor {
cursorLabel = "show" cursorLabel = "show"
@@ -629,16 +648,25 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
captureKey = "Drag+Release" captureKey = "Drag+Release"
} }
items := []struct{ key, desc string }{ return []hudItem{
{captureKey, "capture"}, {captureKey, "capture"},
{"Ctrl", "resize/move"}, {"Ctrl", "resize/move"},
{"P", cursorLabel + " cursor"}, {"P", cursorLabel + " cursor"},
{"Esc", "cancel"}, {"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 totalW := 0
step := fontStep * scale
for i, item := range items { 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 { if i < len(items)-1 {
totalW += itemSpacing totalW += itemSpacing
} }
@@ -647,46 +675,32 @@ func (r *RegionSelector) hudDimensions(bufW, bufH int) (hudX, hudY, hudW, hudH i
hudW = totalW + padding*2 hudW = totalW + padding*2
hudH = charH + padding*2 hudH = charH + padding*2
hudX = (bufW - hudW) / 2 hudX = (bufW - hudW) / 2
hudY = bufH - hudH - 20 hudY = bufH - hudH - margin
return hudX, hudY, hudW, hudH 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 { if r.selection.dragging {
return return
} }
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH) scale = scaleFactor(float64(scale))
hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
style := LoadOverlayStyle() style := LoadOverlayStyle()
r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH, r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH,
style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format) style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format)
const charW, padding, itemSpacing = 8, 12, 24 padding, itemSpacing := 12*scale, 24*scale
cursorLabel := "hide" step := fontStep * scale
if !r.showCapturedCursor { items := r.hudItems()
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"},
}
tx, ty := hudX+padding, hudY+padding tx, ty := hudX+padding, hudY+padding
for i, item := range items { for i, item := range items {
r.drawText(data, stride, bufW, bufH, tx, ty, item.key, r.drawTextScaled(data, stride, bufW, bufH, tx, ty, item.key,
style.AccentR, style.AccentG, style.AccentB, format) style.AccentR, style.AccentG, style.AccentB, format, scale)
tx += len(item.key) * (charW + 1) r.drawTextScaled(data, stride, bufW, bufH, tx+len(item.key)*step, ty, " "+item.desc,
style.TextR, style.TextG, style.TextB, format, scale)
r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc, tx += item.width(step)
style.TextR, style.TextG, style.TextB, format)
tx += (1 + len(item.desc)) * (charW + 1)
if i < len(items)-1 { if i < len(items)-1 {
tx += itemSpacing 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) { func labelRect(b selectionRenderBounds, bufW, bufH int) (dirtyRect, string) {
scale := scaleFactor(b.scaleX)
text := b.labelText text := b.labelText
charH := fontCharH * scale
const charW, charH = 8, 12 step := fontStep * scale
textW := len(text) * (charW + 1) textW := len(text) * step
padX := 4 * scale
padY := 2 * scale
offsetY := 8 * scale
tx := b.x + (b.w-textW)/2 tx := b.x + (b.w-textW)/2
ty := b.y + b.h + 8 ty := b.y + b.h + offsetY
if ty+charH > bufH { if ty+charH > bufH {
ty = b.y - charH - 8 ty = b.y - charH - offsetY
} }
tx = clamp(tx, 0, bufW-textW) 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) { func (r *RegionSelector) drawLabel(data []byte, stride, bufW, bufH int, o *overlay, format uint32) {
scale := scaleFactor(o.scaleX)
l := o.label 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.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 { 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) { 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 { 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] glyph, ok := fontGlyphs[ch]
if !ok { if !ok {
return return
} }
scale = scaleFactor(float64(scale))
c0, c2 := cb, cr c0, c2 := cb, cr
if formatIsBGR(format) { if formatIsBGR(format) {
c0, c2 = cr, cb c0, c2 = cr, cb
} }
for row := range 12 { for row := range fontCharH {
py := y + row
if py < 0 || py >= bufH {
continue
}
bits := glyph[row] bits := glyph[row]
for col := range 8 { for dy := range scale {
if (bits & (1 << (7 - col))) == 0 { py := y + row*scale + dy
if py < 0 || py >= bufH {
continue continue
} }
px := x + col rowOff := py * stride
if px < 0 || px >= bufW { for col := range fontCharW {
continue 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) for _, scale := range []int{1, 2} {
data := make([]byte, bufH*stride) hudX, hudY, hudW, hudH := r.hudDimensions(bufW, bufH, scale)
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888)) data := make([]byte, bufH*stride)
r.drawHUD(data, stride, bufW, bufH, uint32(FormatARGB8888), scale)
// Assert no lit pixel falls outside the pill boundaries // Assert no lit pixel falls outside the pill boundaries
for y := range bufH { for y := range bufH {
for x := range bufW { for x := range bufW {
off := y*stride + x*4 off := y*stride + x*4
if data[off] != 0 || data[off+1] != 0 || data[off+2] != 0 || data[off+3] != 0 { 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 { 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]", t.Fatalf("scale=%d 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) 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/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy" "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_color_management"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client"
"github.com/AvengeMedia/dankgo/wayland/client" "github.com/AvengeMedia/dankgo/wayland/client"
) )
@@ -177,6 +178,14 @@ func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) {
} }
func (s *Screenshoter) captureRegion() (*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 s.config.Reset {
if err := SaveLastRegion(Region{}); err != nil { if err := SaveLastRegion(Region{}); err != nil {
log.Debug("failed to reset last region", "err", err) 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) { func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
return s.captureAqueousWindow()
}
if DetectCompositor() == CompositorNiri { if DetectCompositor() == CompositorNiri {
if s.config.Geometry { if s.config.Geometry {
return nil, fmt.Errorf("window geometry mode is not supported on niri") 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) { 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() output := s.findFocusedOutput()
if output == nil { if output == nil {
s.outputsMu.Lock() 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 { type Config struct {
Mode Mode Seat string
OutputName string Mode Mode
Cursor CursorMode OutputName string
NoConfirm bool Cursor CursorMode
Reset bool NoConfirm bool
Format Format Reset bool
Quality int Format Format
OutputDir string Quality int
Filename string OutputDir string
Clipboard bool Filename string
SaveFile bool Clipboard bool
Notify bool SaveFile bool
Stdout bool Notify bool
Geometry bool Stdout bool
IntervalMs int Geometry bool
AllowMultiple bool
IntervalMs int
// SelectorHook runs as the interactive selector starts (true) and ends (false). // SelectorHook runs as the interactive selector starts (true) and ends (false).
SelectorHook func(begin bool) 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) { func (b *DDCBackend) readInitialBrightness(fd int, dev *ddcDevice) {
cap, err := b.getVCPFeature(fd, VCP_BRIGHTNESS) for attempt := 0; attempt < 3; attempt++ {
if err != nil { 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) 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) { func (b *DDCBackend) GetDevices() ([]Device, error) {
@@ -1,6 +1,7 @@
package network package network
import ( import (
"encoding/hex"
"fmt" "fmt"
"sort" "sort"
@@ -21,15 +22,13 @@ const (
var _ HotspotBackend = (*NetworkManagerBackend)(nil) 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 { func (b *NetworkManagerBackend) ConfigureHotspot(req HotspotRequest) error {
if req.SSID == "" { if req.SSID == "" {
return fmt.Errorf("hotspot SSID cannot be empty") return fmt.Errorf("hotspot SSID cannot be empty")
} }
if err := validateHotspotPassword(req.Password); err != nil {
return err
}
if err := validateHotspotBandName(req.Band); err != nil { if err := validateHotspotBandName(req.Band); err != nil {
return err return err
} }
@@ -495,6 +494,22 @@ func isAPCapableWiFiDevice(devInfo *wifiDeviceInfo) (bool, error) {
return caps&nmWiFiDeviceCapAP != 0, nil 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 { func validateHotspotBandName(band string) error {
if band != "" && band != "bg" && band != "a" { if band != "" && band != "bg" && band != "a" {
return fmt.Errorf("unsupported hotspot band: %s", band) return fmt.Errorf("unsupported hotspot band: %s", band)
@@ -1,6 +1,7 @@
package network package network
import ( import (
"strings"
"testing" "testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" 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) 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 // With several adapters, state must follow the associated device, not whichever
// enumerated first (#2460). // enumerated first (#2460).
func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) { func (b *NetworkManagerBackend) wifiDeviceForState() (gonetworkmanager.Device, gonetworkmanager.DeviceWireless) {
@@ -321,7 +327,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error {
b.state.ConnectingSSID = "" b.state.ConnectingSSID = ""
b.state.LastError = reasonCode b.state.LastError = reasonCode
if reasonCode == errdefs.ErrUserCanceled { if forgetOnConnectFailure(reasonCode, b.state.ConnectingPreExisting) {
forgetSSID = connectingSSID forgetSSID = connectingSSID
} }
@@ -146,3 +146,23 @@ func TestNetworkManagerBackend_UpdateWiFiState_PicksConnectedDevice(t *testing.T
assert.True(t, backend.state.WiFiConnected) assert.True(t, backend.state.WiFiConnected)
assert.Equal(t, "wlan1", backend.state.WiFiDevice) 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" "strings"
"sync" "sync"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
) )
const ( const (
@@ -26,15 +28,8 @@ type wpaCtrlConn struct {
seq int seq int
} }
func wpaCtrlLocalSocketBase() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return dir
}
return os.TempDir()
}
func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) { func newWpaCtrlConn(sockPath string) (*wpaCtrlConn, error) {
localDir, err := os.MkdirTemp(wpaCtrlLocalSocketBase(), "dms-wpa-") localDir, err := os.MkdirTemp(utils.RuntimeDir(), "dms-wpa-")
if err != nil { if err != nil {
return nil, fmt.Errorf("create wpa_ctrl socket dir: %w", err) 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/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/dankgo/ipc" "github.com/AvengeMedia/dankgo/ipc"
"github.com/AvengeMedia/dankgo/paths"
"github.com/AvengeMedia/dankgo/syncmap" "github.com/AvengeMedia/dankgo/syncmap"
) )
@@ -86,7 +86,7 @@ var cupsMu sync.Mutex
var cupsSubscriberCount int var cupsSubscriberCount int
var cupsEverAvailable bool var cupsEverAvailable bool
var appPaths = paths.New("danklinux") var appPaths = utils.App()
func GetSocketPath() string { func GetSocketPath() string {
return appPaths.SocketPath() return appPaths.SocketPath()
+69
View File
@@ -1,11 +1,19 @@
package utils package utils
import ( import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"time"
"unicode/utf8"
) )
type AppChecker interface { type AppChecker interface {
@@ -115,3 +123,64 @@ func userBinDirs() []string {
dirs = append(dirs, "/usr/local/bin") dirs = append(dirs, "/usr/local/bin")
return dirs 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 package utils
import ( import (
"context"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -67,3 +70,58 @@ func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
assert.Contains(t, parts, "/usr/local/bin") assert.Contains(t, parts, "/usr/local/bin")
assert.Contains(t, env, "OTHER=value") 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 ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strings" "strings"
) )
@@ -10,22 +11,35 @@ func dconfPath(schema, key string) string {
return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key 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) { func GsettingsGet(schema, key string) (string, error) {
if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil { if dconfSelected() {
return strings.TrimSpace(string(out)), nil 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 { 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 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 { func GsettingsSet(schema, key, value string) error {
if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil { if dconfSelected() {
return nil 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" "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 XDGStateHome() string { return paths.XDGStateHome() }
func XDGDataHome() string { return paths.XDGDataHome() } 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
import qs.Common.settings import qs.Common.settings
import qs.Services import qs.Services
import "GSettings.js" as GSettings
import "settings/SettingsSpec.js" as Spec import "settings/SettingsSpec.js" as Spec
import "settings/SettingsStore.js" as Store import "settings/SettingsStore.js" as Store
@@ -15,7 +16,7 @@ Singleton {
id: root id: root
readonly property var log: Log.scoped("SettingsData") 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" 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 var notificationRules: []
property bool notificationDndAllowCritical: true property bool notificationDndAllowCritical: true
property bool notificationFocusedMonitor: false property bool notificationFocusedMonitor: false
// Names the bar config rendered as Dank Island instead of a DankBar. Empty = no island. // Island is a per-instance render mode: any bar config with island:true draws as an island
property string dankIslandBarId: "" // instead of a DankBar, and carries its own island* look settings.
readonly property var islandBarConfig: dankIslandBarId ? (barConfigs.find(cfg => cfg.id === dankIslandBarId) || null) : null readonly property var islandBarConfigs: {
readonly property bool dankIslandEnabled: !!islandBarConfig && (islandBarConfig.enabled ?? false) barConfigs;
readonly property int dankIslandPosition: islandBarConfig?.position ?? SettingsData.Position.Top return (barConfigs || []).filter(cfg => cfg && cfg.island === true);
readonly property string dankIslandEdge: dankIslandPosition === SettingsData.Position.Bottom ? "bottom" : "top" }
readonly property int dankIslandReservedStripHeight: { readonly property bool dankIslandEnabled: islandBarConfigs.some(cfg => cfg.enabled ?? false)
const reserve = Math.max(24, Math.min(128, dankIslandReserveHeight)); readonly property var islandDefaults: ({
const compact = Math.max(24, Math.min(72, dankIslandCompactHeight)); "islandFloating": false,
const gap = Math.max(0, Math.min(48, dankIslandOuterGap)); "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); 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 _islandHomeGroupIds: ["media", "clock", "weather", "status", "volume", "brightness", "notifications"]
readonly property var _islandHomeLayoutDefault: [ readonly property var _islandHomeLayoutDefault: [
{ {
@@ -1049,10 +1091,8 @@ Singleton {
"enabled": true "enabled": true
} }
] ]
property var dankIslandHomeLayout: _islandHomeLayoutDefault function getIslandHomeLayout(bc) {
const stored = Array.isArray(bc?.islandHomeLayout) ? bc.islandHomeLayout : [];
function getIslandHomeLayout() {
const stored = Array.isArray(dankIslandHomeLayout) ? dankIslandHomeLayout : [];
const result = []; const result = [];
const seen = {}; const seen = {};
for (const entry of stored) { for (const entry of stored) {
@@ -1075,41 +1115,33 @@ Singleton {
return result; return result;
} }
function islandHomeGroupEnabled(id) { function islandHomeGroupEnabled(bc, id) {
const entry = getIslandHomeLayout().find(g => g.id === id); const entry = getIslandHomeLayout(bc).find(g => g.id === id);
return entry ? entry.enabled : false; return entry ? entry.enabled : false;
} }
function setIslandHomeLayoutOrder(ids) { function setIslandHomeLayoutOrder(barId, ids) {
const current = getIslandHomeLayout(); const current = getIslandHomeLayout(getBarConfig(barId));
const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g); const ordered = ids.map(id => current.find(g => g.id === id)).filter(g => g);
for (const entry of current) { for (const entry of current) {
if (ids.indexOf(entry.id) < 0) if (ids.indexOf(entry.id) < 0)
ordered.push(entry); ordered.push(entry);
} }
set("dankIslandHomeLayout", ordered); updateBarConfig(barId, {
islandHomeLayout: ordered
});
} }
function setIslandHomeGroupEnabled(id, on) { function setIslandHomeGroupEnabled(barId, id, on) {
if (id === "clock") if (id === "clock")
return; return;
set("dankIslandHomeLayout", getIslandHomeLayout().map(g => g.id === id ? { updateBarConfig(barId, {
"id": g.id, islandHomeLayout: getIslandHomeLayout(getBarConfig(barId)).map(g => g.id === id ? {
"enabled": on "id": g.id,
} : g)); "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 bool osdAlwaysShowValue: false
property int osdPosition: SettingsData.Position.BottomCenter property int osdPosition: SettingsData.Position.BottomCenter
@@ -1523,11 +1555,7 @@ Singleton {
return; return;
if (!SessionData.lastAppliedIconTheme) if (!SessionData.lastAppliedIconTheme)
return; return;
const script = `if command -v gsettings >/dev/null 2>&1; then const script = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
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`;
Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => { Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => {
const platform = (output || "").trim(); const platform = (output || "").trim();
@@ -1565,11 +1593,7 @@ Singleton {
const resolved = resolveIconTheme(); const resolved = resolveIconTheme();
let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved; let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved;
if (!cosmicThemeName || cosmicThemeName === "System Default") { if (!cosmicThemeName || cosmicThemeName === "System Default") {
const detectScript = `if command -v gsettings >/dev/null 2>&1; then const detectScript = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme");
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`;
Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => { Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => {
if (exitCode !== 0) if (exitCode !== 0)
@@ -1631,11 +1655,7 @@ Singleton {
fi fi
done done
if command -v gsettings >/dev/null 2>&1; then ${GSettings.setCmd("org.gnome.desktop.interface", "icon-theme", gtkThemeName)} || true
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
pkill -HUP -f 'gtk' 2>/dev/null || true`; pkill -HUP -f 'gtk' 2>/dev/null || true`;
@@ -1738,13 +1758,21 @@ Singleton {
}) })
function set(key, value) { function set(key, value) {
if (key === "dankIslandInteractionMode" && value !== "click" && value !== "hybrid")
value = "hybrid";
Spec.set(root, key, value, saveSettings, _hooks); Spec.set(root, key, value, saveSettings, _hooks);
if (key === "dankIslandBarId" && value && frameEnabled) if (key === "frameEnabled" && value)
set("frameEnabled", false); clearIslandBars();
if (key === "frameEnabled" && value && dankIslandBarId) }
set("dankIslandBarId", "");
// 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() { function loadSettings() {
@@ -1799,8 +1827,8 @@ Singleton {
Store.parse(root, obj); Store.parse(root, obj);
// set() enforces this pair, but a hand-edited settings.json bypasses set() entirely. // set() enforces this pair, but a hand-edited settings.json bypasses set() entirely.
if (frameEnabled && dankIslandBarId) if (frameEnabled && islandBarConfigs.length > 0)
dankIslandBarId = ""; clearIslandBars();
if (obj?.directionalAnimationMode === 3 && frameMode !== "connected") if (obj?.directionalAnimationMode === 3 && frameMode !== "connected")
frameMode = "connected"; frameMode = "connected";
@@ -2131,7 +2159,7 @@ Singleton {
const pathsArg = iconPaths.join(" "); const pathsArg = iconPaths.join(" ");
const script = ` 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 for dir in ${pathsArg}; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
for theme in "$dir"/*/; do for theme in "$dir"/*/; do
@@ -2170,7 +2198,7 @@ Singleton {
const pathsArg = cursorPaths.join(" "); const pathsArg = cursorPaths.join(" ");
const script = ` 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 for dir in ${pathsArg}; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
for theme in "$dir"/*/; do 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. // The island is not a bar, but it is chrome: popouts still have to clear its strip.
if (!isIslandBarConfig(barConfig)) { if (!isIslandBarConfig(barConfig)) {
const islandTop = dankIslandEdgeOffset(screen, "top"); topBar = Math.max(topBar, dankIslandEdgeOffset(screen, "top"));
const islandBottom = dankIslandEdgeOffset(screen, "bottom"); bottomBar = Math.max(bottomBar, dankIslandEdgeOffset(screen, "bottom"));
if (islandTop > 0) leftBar = Math.max(leftBar, dankIslandEdgeOffset(screen, "left"));
topBar = Math.max(topBar, islandTop); rightBar = Math.max(rightBar, dankIslandEdgeOffset(screen, "right"));
if (islandBottom > 0)
bottomBar = Math.max(bottomBar, islandBottom);
} }
return { return {
@@ -2470,21 +2496,20 @@ Singleton {
return barConfigs.find(cfg => cfg.id === barId) || null; return barConfigs.find(cfg => cfg.id === barId) || null;
} }
function setIslandBarId(barId) { function setBarIsland(barId, on) {
const config = getBarConfig(barId); const config = getBarConfig(barId);
if (!config) if (!config || (config.island === true) === (on === true))
return; return;
set("dankIslandBarId", barId); const updates = {
const updates = {}; island: on === true
if (!config.enabled) };
updates.enabled = true; if (on === true) {
if ((config.screenPreferences ?? []).length === 0) if (!config.enabled)
updates.screenPreferences = ["all"]; updates.enabled = true;
const position = config.position ?? SettingsData.Position.Top; if ((config.screenPreferences ?? []).length === 0)
if (position !== SettingsData.Position.Top && position !== SettingsData.Position.Bottom) updates.screenPreferences = ["all"];
updates.position = SettingsData.Position.Top; }
if (Object.keys(updates).length > 0) updateBarConfig(barId, updates);
updateBarConfig(barId, updates);
} }
function isBarIpcRevealed(barId) { function isBarIpcRevealed(barId) {
@@ -2494,7 +2519,7 @@ Singleton {
} }
function setBarIpcReveal(barId, revealed) { function setBarIpcReveal(barId, revealed) {
if (!barId || barId === dankIslandBarId) if (!barId || isIslandBarConfig(getBarConfig(barId)))
return; return;
const nextRevealed = !!revealed; const nextRevealed = !!revealed;
if (!!barIpcRevealStates[barId] === nextRevealed) if (!!barIpcRevealStates[barId] === nextRevealed)
@@ -2528,8 +2553,6 @@ Singleton {
const index = configs.findIndex(cfg => cfg.id === barId); const index = configs.findIndex(cfg => cfg.id === barId);
if (index === -1) if (index === -1)
return; 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; const positionChanged = updates.position !== undefined && configs[index].position !== updates.position;
if (updates.autoHide === false || updates.visible === false) if (updates.autoHide === false || updates.visible === false)
setBarIpcReveal(barId, false); setBarIpcReveal(barId, false);
@@ -2548,8 +2571,6 @@ Singleton {
return; return;
const configs = barConfigs.filter(cfg => cfg.id !== barId); const configs = barConfigs.filter(cfg => cfg.id !== barId);
barConfigs = configs; barConfigs = configs;
if (dankIslandBarId === barId)
dankIslandBarId = "";
if (connectedFrameBarStyleBackups?.[barId] !== undefined) { if (connectedFrameBarStyleBackups?.[barId] !== undefined) {
const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {})); const nextBackups = JSON.parse(JSON.stringify(connectedFrameBarStyleBackups || {}));
delete nextBackups[barId]; delete nextBackups[barId];
@@ -2559,7 +2580,7 @@ Singleton {
updateBarConfigs(); 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() { function getEnabledBarConfigs() {
return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg)); return barConfigs.filter(cfg => cfg.enabled && !isIslandBarConfig(cfg));
} }
@@ -2728,34 +2749,45 @@ Singleton {
} }
function isIslandBarConfig(bc) { function isIslandBarConfig(bc) {
return !!bc && !!dankIslandBarId && bc.id === dankIslandBarId; return !!bc && bc.island === true;
} }
function getIslandScreens() { function activeIslandConfigsForScreen(screen) {
const cfg = islandBarConfig; if (!screen)
if (!cfg || !(cfg.enabled ?? false))
return []; 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) { function dankIslandCoversScreen(screen) {
const cfg = islandBarConfig; return activeIslandConfigsForScreen(screen).length > 0;
return !!screen && !!cfg && (cfg.enabled ?? false) && barConfigCoversScreen(cfg, screen);
} }
function dankIslandOwnsEdge(screen, edge) { 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. // Painted strip thickness on `edge`, including when floating drops the exclusive zone.
function dankIslandEdgeOffset(screen, edge) { function dankIslandEdgeOffset(screen, edge) {
return dankIslandOwnsEdge(screen, edge) ? dankIslandReservedStripHeight : 0; const cfg = islandConfigForEdge(screen, edge);
return cfg ? islandStripThickness(cfg) : 0;
} }
function dankIslandIsSoleBarForScreen(screen) { function dankIslandIsSoleBarForScreen(screen) {
return dankIslandCoversScreen(screen) && getActiveBarEdgesForScreen(screen).length === 0; 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) { function getActiveBarEdgesForScreen(screen) {
return ["top", "bottom", "left", "right"].filter(edge => getActiveBarConfigsForEdge(screen, edge).length > 0); 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.Services
import qs.Modules.Greetd import qs.Modules.Greetd
import "StockThemes.js" as StockThemes import "StockThemes.js" as StockThemes
import "GSettings.js" as GSettings
Singleton { Singleton {
id: root id: root
@@ -144,10 +145,6 @@ Singleton {
Component.onCompleted: { Component.onCompleted: {
Quickshell.execDetached(["mkdir", "-p", stateDir]); 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) => { Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen; matugenAvailable = (code === 0) && !envDisableMatugen;
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode);
@@ -297,32 +294,7 @@ Singleton {
if (currentTheme === "custom") { if (currentTheme === "custom") {
return customThemeData || StockThemes.getThemeByName("purple", isLightMode); return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
} else if (currentTheme === dynamic) { } else if (currentTheme === dynamic) {
return { return buildExtractedDynamicMode(isLightMode ? "light" : "dark", "Dynamic");
"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"
};
} else { } else {
return StockThemes.getThemeByName(currentTheme, isLightMode); return StockThemes.getThemeByName(currentTheme, isLightMode);
} }
@@ -1525,30 +1497,10 @@ Singleton {
const dankBarScale = fontScale !== undefined ? fontScale : 1.0; const dankBarScale = fontScale !== undefined ? fontScale : 1.0;
const maxScale = (maximizeText ?? false) ? 1.5 : 1.0; const maxScale = (maximizeText ?? false) ? 1.5 : 1.0;
if (scale <= 0.75) 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) if (scale >= 1.25)
return evenLineBoxSize(Math.round(fontSizeMedium * dankBarScale * maxScale)); return Math.round(fontSizeMedium * dankBarScale * maxScale);
return evenLineBoxSize(Math.round(fontSizeSmall * dankBarScale * maxScale)); return 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;
} }
function getBatteryIcon(level, isCharging, batteryAvailable) { function getBatteryIcon(level, isCharging, batteryAvailable) {
@@ -1918,30 +1870,19 @@ Singleton {
const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark"; const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark";
const schema = "org.gnome.desktop.interface"; const schema = "org.gnome.desktop.interface";
const key = "gtk-theme"; const key = "gtk-theme";
const reset = GSettings.setCmd(schema, key, "");
const apply = GSettings.setCmd(schema, key, theme);
const makeCmd = (tool, schema, val) => { Proc.runCommand("gtkRefresher", ["sh", "-c", `${reset}; ${apply}`], (output, exitCode) => {
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) => {
if (exitCode !== 0) { if (exitCode !== 0) {
Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => { log.warn("Failed to refresh gtk-theme");
if (exitCode !== 0) {
log.warn("Failed to refresh gtk-theme");
}
});
} }
}); });
} }
function patchGtk3colors() { function patchGtk3colors() {
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode); 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) { switch (exitCode) {
case 0: case 0:
refreshGtkTheme(); refreshGtkTheme();
@@ -1963,7 +1904,7 @@ Singleton {
} }
const isLight = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "true" : "false"; 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 (exitCode === 0) {
if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) { if (typeof ToastService !== "undefined" && !root.matugenToastSuppressed) {
ToastService.showInfo(I18n.tr("GTK colors applied successfully")); ToastService.showInfo(I18n.tr("GTK colors applied successfully"));
@@ -534,39 +534,6 @@ var SPEC = {
notificationDndAllowCritical: { def: true }, notificationDndAllowCritical: { def: true },
notificationFocusedMonitor: { def: false }, 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 }, osdAlwaysShowValue: { def: false },
osdPosition: { def: 5 }, osdPosition: { def: 5 },
@@ -14,6 +14,43 @@ var STALE_WIDGET_KEYS = ["desktopClockEnabled", "desktopClockStyle", "desktopClo
var BAR_WIDGET_LIST_KEYS = ["leftWidgets", "centerWidgets", "rightWidgets"]; 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) { function migrateBatteryPillStyle(target) {
if (!target || typeof target !== "object" || target.batteryPillStyle === undefined) if (!target || typeof target !== "object" || target.batteryPillStyle === undefined)
return; return;
@@ -433,5 +470,28 @@ function migrateToVersion(obj, targetVersion) {
settings.configVersion = 17; 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; return settings;
} }
+1 -1
View File
@@ -37,7 +37,7 @@ Item {
property bool osdSurfacesLoaded: false property bool osdSurfacesLoaded: false
property int pendingOsdResumeReloads: 0 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: { readonly property var notificationPopupScreens: {
const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications"); const screens = SettingsData.notificationFocusedMonitor ? Quickshell.screens : SettingsData.getFilteredScreens("notifications");
return root.withoutDankIslandScreens(screens); return root.withoutDankIslandScreens(screens);
+4 -3
View File
@@ -964,9 +964,6 @@ Item {
const posValue = positionMap[position.toLowerCase()]; const posValue = positionMap[position.toLowerCase()];
if (posValue === undefined) if (posValue === undefined)
return "BAR_INVALID_POSITION"; 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, { SettingsData.updateBarConfig(barConfig.id, {
position: posValue position: posValue
}); });
@@ -1866,6 +1863,8 @@ Item {
function open(): string { function open(): string {
root.workspaceRenameModalLoader.active = true; root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) { 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]; const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || ""); root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED"; return "WORKSPACE_RENAME_MODAL_OPENED";
@@ -1888,6 +1887,8 @@ Item {
root.workspaceRenameModalLoader.item.hide(); root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED"; 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]; const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || ""); root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED"; return "WORKSPACE_RENAME_MODAL_OPENED";
+58 -13
View File
@@ -1688,18 +1688,60 @@ Item {
return result; 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() { function _loadDiskCache() {
var cached = CacheData.loadLauncherCache(); var cached = CacheData.loadLauncherCache();
if (!cached || !Array.isArray(cached) || cached.length === 0) if (!cached || !Array.isArray(cached) || cached.length === 0)
return null; 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 sectionsData = [];
var browseSectionSeen = false;
for (var i = 0; i < cached.length; i++) { for (var i = 0; i < cached.length; i++) {
var s = cached[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 items = [];
var srcItems = s.items || []; var srcItems = s.items || [];
for (var j = 0; j < srcItems.length; j++) { for (var j = 0; j < srcItems.length; j++) {
var it = srcItems[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({ items.push({
id: it.id || "", id: it.id || "",
type: it.type || "app", type: it.type || "app",
@@ -1709,15 +1751,12 @@ Item {
iconType: it.iconType || "image", iconType: it.iconType || "image",
iconFull: it.iconFull || "", iconFull: it.iconFull || "",
section: it.section || "", section: it.section || "",
isCore: it.isCore || false,
isBuiltInLauncher: it.isBuiltInLauncher || false,
pluginId: it.pluginId || "",
source: it.source || "", source: it.source || "",
data: { data: {
id: it.id id: it.id
}, },
actions: _actionsFromDesktopEntry(it.id), actions: _actionsFromDesktopEntry(it.id),
primaryAction: it.type === "app" && !it.isCore ? { primaryAction: it.type === "app" ? {
name: I18n.tr("Launch"), name: I18n.tr("Launch"),
icon: "open_in_new", icon: "open_in_new",
action: "launch" action: "launch"
@@ -1729,16 +1768,22 @@ Item {
_preScored: undefined _preScored: undefined
}); });
} }
sectionsData.push({ if (items.length > 0)
id: s.id || "", sectionsData.push(_diskCacheSection(s, items));
title: s.title || "",
icon: s.icon || "",
priority: s.priority || 0,
items: items,
collapsed: false,
flatStartIndex: 0
});
} }
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; return sectionsData;
} }
@@ -193,7 +193,7 @@ Item {
if (modelData.repo) if (modelData.repo)
Qt.openUrlExternally(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 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 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 minY = topChrome + margin;
const maxY = screenH - modalHeight - bottomChrome - 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)); targetY = Math.max(minY, Math.min(maxY, targetY));
return Qt.point(targetX, targetY); return Qt.point(targetX, targetY);
@@ -92,8 +92,8 @@ Rectangle {
root.leaveHiddenTab(33); root.leaveHiddenTab(33);
} }
function onDankIslandBarIdChanged() { function onIslandBarConfigsChanged() {
if (!SettingsData.dankIslandBarId) if (SettingsData.islandBarConfigs.length === 0)
root.leaveHiddenTab(46); root.leaveHiddenTab(46);
} }
} }
@@ -125,7 +125,7 @@ Rectangle {
return false; return false;
if (item.frameOnly && !SettingsData.frameEnabled) if (item.frameOnly && !SettingsData.frameEnabled)
return false; return false;
if (item.islandOnly && !SettingsData.dankIslandBarId) if (item.islandOnly && SettingsData.islandBarConfigs.length === 0)
return false; return false;
if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0) if (item.cellularOnly && (NetworkService.cellularDevices?.length ?? 0) === 0)
return false; return false;
+39 -1
View File
@@ -9,6 +9,8 @@ DankFloatingWindow {
readonly property var log: Log.scoped("WorkspaceRenameModal") readonly property var log: Log.scoped("WorkspaceRenameModal")
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2 readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property var aqueousWorkspace: null
property bool renaming: false
objectName: "workspaceRenameModal" objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace") title: I18n.tr("Rename Workspace")
@@ -19,9 +21,24 @@ DankFloatingWindow {
onClosed: hide() onClosed: hide()
function show(name) { 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; nameInput.text = name;
visible = true; visible = true;
Qt.callLater(() => nameInput.forceActiveFocus()); Qt.callLater(() => nameInput.forceActiveFocus());
return true;
} }
function hide() { function hide() {
@@ -29,6 +46,24 @@ DankFloatingWindow {
} }
function submitAndClose() { 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); renameWorkspace(nameInput.text);
hide(); hide();
} }
@@ -49,6 +84,8 @@ DankFloatingWindow {
return; return;
} }
nameInput.text = ""; nameInput.text = "";
aqueousWorkspace = null;
renaming = false;
} }
FocusScope { FocusScope {
@@ -133,7 +170,7 @@ DankFloatingWindow {
textColor: Theme.surfaceText textColor: Theme.surfaceText
placeholderText: I18n.tr("Workspace name") placeholderText: I18n.tr("Workspace name")
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.visible && !root.renaming
onAccepted: submitAndClose() onAccepted: submitAndClose()
} }
} }
@@ -190,6 +227,7 @@ DankFloatingWindow {
MouseArea { MouseArea {
id: renameArea id: renameArea
enabled: !root.renaming
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor 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 // M3 elevation shadow — Level 2 baseline (navigation bar), with per-bar override support
readonly property bool hasPerBarOverride: (barConfig?.shadowIntensity ?? 0) > 0 readonly property bool hasPerBarOverride: (barConfig?.shadowIntensity ?? 0) > 0
readonly property var elevLevel: Theme.elevationLevel2 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 autoBarShadowDirection: isTop ? "top" : (isBottom ? "bottom" : (isLeft ? "left" : (isRight ? "right" : "top")))
readonly property string globalShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection readonly property string globalShadowDirection: Theme.elevationLightDirection === "autoBar" ? autoBarShadowDirection : Theme.elevationLightDirection
readonly property string perBarShadowDirectionMode: barConfig?.shadowDirectionMode ?? "inherit" readonly property string perBarShadowDirectionMode: barConfig?.shadowDirectionMode ?? "inherit"
+7 -7
View File
@@ -454,7 +454,7 @@ Item {
readonly property real _wingR: Math.max(0, wingtipsRadius) readonly property real _wingR: Math.max(0, wingtipsRadius)
// Shadow buffer: extra window space for shadow to render beyond bar bounds // 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: { readonly property real _shadowBuffer: {
if (!_shadowActive) if (!_shadowActive)
return 0; return 0;
@@ -496,7 +496,7 @@ Item {
hasMaximizedToplevel = false; hasMaximizedToplevel = false;
return; return;
} }
if (!CompositorService.isHyprland && !CompositorService.isNiri) { if (!CompositorService.isHyprland && !CompositorService.isNiri && !CompositorService.isAqueous) {
hasMaximizedToplevel = false; hasMaximizedToplevel = false;
return; return;
} }
@@ -520,7 +520,7 @@ Item {
shouldHideForWindows = false; shouldHideForWindows = false;
return; return;
} }
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) { if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous) {
shouldHideForWindows = false; shouldHideForWindows = false;
return; return;
} }
@@ -795,7 +795,7 @@ Item {
readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr) readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr)
readonly property int lengthPaddingPx: Theme.px(barWindow.effectiveBarLengthPadding, 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 effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow) readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow)
@@ -986,7 +986,7 @@ Item {
} }
property bool reveal: { property bool reveal: {
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview; const inOverviewWithShow = CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview;
if (inOverviewWithShow) if (inOverviewWithShow)
return true; return true;
@@ -997,7 +997,7 @@ Item {
return true; return true;
} }
if (CompositorService.isNiri && NiriService.inOverview) if (CompositorService.overviewActiveOnScreen(barWindow.screenName))
return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal; return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal;
return (barConfig?.visible ?? true) && (!autoHide || hoverReveal || popoutPinsReveal || revealSticky || ipcReveal); return (barConfig?.visible ?? true) && (!autoHide || hoverReveal || popoutPinsReveal || revealSticky || ipcReveal);
@@ -1062,7 +1062,7 @@ Item {
top: barWindow.isVertical ? parent.top : undefined top: barWindow.isVertical ? parent.top : undefined
bottom: barWindow.isVertical ? parent.bottom : 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 hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.popoutPinsReveal
acceptedButtons: barWindow.clickThroughEnabled ? Qt.NoButton : Qt.RightButton acceptedButtons: barWindow.clickThroughEnabled ? Qt.NoButton : Qt.RightButton
enabled: !inOverview && ((barConfig?.autoHide ?? false) || !barWindow.clickThroughEnabled) enabled: !inOverview && ((barConfig?.autoHide ?? false) || !barWindow.clickThroughEnabled)
+13 -2
View File
@@ -156,6 +156,8 @@ Item {
function getRealWorkspaces() { function getRealWorkspaces() {
const screenName = _barScreenName; const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.workspacesForOutput(SettingsData.workspaceFollowFocus ? AqueousService.focusedOutput : screenName);
if (CompositorService.isNiri) { if (CompositorService.isNiri) {
const fallbackWorkspaces = [ const fallbackWorkspaces = [
{ {
@@ -238,6 +240,8 @@ Item {
function getCurrentWorkspace() { function getCurrentWorkspace() {
const screenName = _barScreenName; const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return getRealWorkspaces().find(ws => ws.active)?.id || "";
if (CompositorService.isNiri) { if (CompositorService.isNiri) {
if (!screenName || SettingsData.workspaceFollowFocus) { if (!screenName || SettingsData.workspaceFollowFocus) {
return NiriService.getCurrentWorkspaceNumber(); return NiriService.getCurrentWorkspaceNumber();
@@ -279,7 +283,14 @@ Item {
return; 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 currentWs = getCurrentWorkspace();
const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs); const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs);
const validIndex = currentIndex === -1 ? 0 : currentIndex; const validIndex = currentIndex === -1 ? 0 : currentIndex;
@@ -323,7 +334,7 @@ Item {
} }
function switchApp(deltaY) { function switchApp(deltaY) {
const windows = sortedToplevels; const windows = sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2) { if (windows.length < 2) {
return; return;
} }
+1 -1
View File
@@ -67,7 +67,7 @@ PanelWindow {
implicitWidth: body.surfaceImplicitWidth implicitWidth: body.surfaceImplicitWidth
exclusiveZone: body.surfaceExclusiveZone exclusiveZone: body.surfaceExclusiveZone
BackgroundEffect.blurRegion: body.blurRegion BackgroundEffect.blurRegion: BlurService.enabled ? body.blurRegion : null
Component.onCompleted: { Component.onCompleted: {
KeyboardFocus.registerBarWindow(barWindow); KeyboardFocus.registerBarWindow(barWindow);
@@ -160,7 +160,7 @@ PanelWindow {
return []; return [];
const toplevels = []; 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++) { for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i]; const toplevel = allToplevels[i];
if (toplevel.appId === root.appData.appId) { if (toplevel.appId === root.appData.appId) {
+11 -1
View File
@@ -204,7 +204,7 @@ BasePill {
spacing: Theme.spacingS spacing: Theme.spacingS
property real fontSize: Theme.barTextSize(root.barThickness, root.barConfig?.fontScale, root.barConfig?.maximizeWidgetText) 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: { property string hoursStr: {
const hours = systemClock?.date?.getHours() ?? 0; const hours = systemClock?.date?.getHours() ?? 0;
@@ -234,6 +234,8 @@ BasePill {
text: "•" text: "•"
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.outlineButton color: Theme.outlineButton
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: !compact visible: !compact
} }
@@ -272,6 +274,8 @@ BasePill {
text: ":" text: ":"
font.pixelSize: clockRow.fontSize font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
} }
StyledText { StyledText {
@@ -295,6 +299,8 @@ BasePill {
text: ":" text: ":"
font.pixelSize: clockRow.fontSize font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
} }
StyledText { StyledText {
@@ -320,6 +326,8 @@ BasePill {
text: clockRow.ampmStr text: clockRow.ampmStr
font.pixelSize: clockRow.fontSize font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
} }
} }
} }
@@ -337,6 +345,8 @@ BasePill {
} }
font.pixelSize: clockRow.fontSize font.pixelSize: clockRow.fontSize
color: Theme.widgetTextColor color: Theme.widgetTextColor
width: Math.ceil(implicitWidth)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: !compact visible: !compact
} }
@@ -31,7 +31,7 @@ BasePill {
property int availableWidth: maxWidth property int availableWidth: maxWidth
readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth)) readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth))
readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2) readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2)
property Toplevel activeWindow: null property var activeWindow: null
property var activeDesktopEntry: null property var activeDesktopEntry: null
property bool isHovered: mouseArea.containsMouse property bool isHovered: mouseArea.containsMouse
property bool isAutoHideBar: false property bool isAutoHideBar: false
@@ -99,6 +99,11 @@ BasePill {
} }
function updateActiveWindow() { 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; let active = ToplevelManager.activeToplevel;
if (!active && CompositorService.isNiri) { if (!active && CompositorService.isNiri) {
@@ -16,6 +16,8 @@ BasePill {
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"] readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"]
property string currentLayout: { property string currentLayout: {
if (CompositorService.isAqueous)
return AqueousService.keyboardLayout;
if (CompositorService.isNiri) { if (CompositorService.isNiri) {
return NiriService.getCurrentKeyboardLayoutName(); return NiriService.getCurrentKeyboardLayoutName();
} else if (CompositorService.isMango) { } else if (CompositorService.isMango) {
@@ -25,7 +27,7 @@ BasePill {
} }
property string hyprlandKeyboard: "" property string hyprlandKeyboard: ""
property var hyprlandLayoutLabels: [] 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 reserveLabel: widestLabel(_allLayoutLabels)
readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n))) readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n)))
@@ -131,6 +133,8 @@ BasePill {
onClicked: { onClicked: {
if (CompositorService.isNiri) { if (CompositorService.isNiri) {
NiriService.cycleKeyboardLayout(); NiriService.cycleKeyboardLayout();
} else if (CompositorService.isAqueous) {
AqueousService.cycleKeyboardLayout();
} else if (CompositorService.isHyprland) { } else if (CompositorService.isHyprland) {
Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]); Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]);
} else if (CompositorService.isMango) { } else if (CompositorService.isMango) {
@@ -163,7 +163,7 @@ BasePill {
const deltaY = wheelEvent.angleDelta.y; const deltaY = wheelEvent.angleDelta.y;
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0; 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) if (windows.length < 2)
return; return;
@@ -236,7 +236,7 @@ BasePill {
id: windowRepeater id: windowRepeater
model: ScriptModel { model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address" objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
} }
delegate: Item { delegate: Item {
@@ -494,7 +494,7 @@ BasePill {
id: windowRepeater id: windowRepeater
model: ScriptModel { model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address" objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
} }
delegate: Item { delegate: Item {
@@ -24,6 +24,7 @@ Item {
property real crossEdgeExtension: 0 property real crossEdgeExtension: 0
readonly property bool isMango: CompositorService.isMango readonly property bool isMango: CompositorService.isMango
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
readonly property real _leftMargin: { readonly property real _leftMargin: {
if (isVertical) if (isVertical)
@@ -82,8 +83,10 @@ Item {
} }
readonly property bool useUnfocusedAppearance: !isFocusedMonitor && SettingsData.workspaceUnfocusedMonitorSeparateAppearance && BarWidgetService.focusedScreenDetectionSupported 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: { readonly property bool useExtWorkspace: {
if (useAqueous)
return false;
if (Quickshell.env("DMS_FORCE_EXTWS") === "1") if (Quickshell.env("DMS_FORCE_EXTWS") === "1")
return (WindowManager.windowsets?.length ?? 0) > 0; return (WindowManager.windowsets?.length ?? 0) > 0;
if (!CompositorService.compositorDetected) if (!CompositorService.compositorDetected)
@@ -117,6 +120,8 @@ Item {
} }
property var currentWorkspace: { property var currentWorkspace: {
if (useAqueous)
return AqueousService.workspacesForOutput(effectiveScreenName).find(w => w.active)?.id || "";
if (useExtWorkspace) if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace(); return getExtWorkspaceActiveWorkspace();
@@ -143,6 +148,10 @@ Item {
return []; return [];
} }
property var workspaceList: { 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) { if (useExtWorkspace) {
const baseList = getExtWorkspaceWorkspaces(); const baseList = getExtWorkspaceWorkspaces();
return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList; return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList;
@@ -322,12 +331,14 @@ Item {
function getWorkspaceIcons(ws) { function getWorkspaceIcons(ws) {
_desktopEntriesUpdateTrigger; _desktopEntriesUpdateTrigger;
if (!SettingsData.showWorkspaceApps || !ws) { if (!SettingsData.showWorkspaceApps || !ws || useExtWorkspace || (useAqueous && ws._placeholder)) {
return []; return [];
} }
let targetWorkspaceId; let targetWorkspaceId;
if (CompositorService.isNiri) { if (useAqueous) {
targetWorkspaceId = ws.id;
} else if (CompositorService.isNiri) {
if (!ws || typeof ws !== "object") { if (!ws || typeof ws !== "object") {
const wsNumber = typeof ws === "number" ? ws : -1; const wsNumber = typeof ws === "number" ? ws : -1;
if (wsNumber <= 0) { if (wsNumber <= 0) {
@@ -387,7 +398,9 @@ Item {
return; return;
} else { } else {
let winWs = null; let winWs = null;
if (CompositorService.isNiri) { if (useAqueous) {
winWs = w.aqueousWorkspaceId;
} else if (CompositorService.isNiri) {
winWs = w.workspace_id; winWs = w.workspace_id;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
winWs = w.workspace?.num; winWs = w.workspace?.num;
@@ -405,7 +418,7 @@ Item {
const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown"); const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown");
const moddedId = Paths.moddedAppId(keyBase); const moddedId = Paths.moddedAppId(keyBase);
const groupThisWs = SettingsData.groupWorkspaceApps && (!isActiveWs || SettingsData.groupActiveWorkspaceApps); const groupThisWs = SettingsData.groupWorkspaceApps && (!isActiveWs || SettingsData.groupActiveWorkspaceApps);
const key = groupThisWs ? moddedId : `${moddedId}_${i}`; const key = groupThisWs ? moddedId : (w.aqueousKey || `${moddedId}_${i}`);
if (!byApp[key]) { if (!byApp[key]) {
const isQuickshell = keyBase === "org.quickshell" || keyBase === "com.danklinux.dms"; 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)), "active": !!((w.activated || w.is_focused) || (CompositorService.isNiri && w.is_focused)),
"count": 1, "count": 1,
"windowId": w.address || w.id, "windowId": w.address || w.id,
"windowSession": useAqueous ? w.aqueousSession : "",
"fallbackText": appName || "" "fallbackText": appName || ""
}; };
} else { } else {
@@ -435,7 +449,7 @@ Item {
} }
function _makePlaceholder() { function _makePlaceholder() {
if (useExtWorkspace) if (useAqueous || useExtWorkspace)
return { return {
"id": "", "id": "",
"name": "", "name": "",
@@ -648,7 +662,7 @@ Item {
if (!extProjection) if (!extProjection)
return ""; return "";
const activeWs = extProjection.windowsets.find(ws => ws.active); 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 readonly property real dpr: parentScreen ? CompositorService.getScreenScale(parentScreen) : 1
@@ -659,7 +673,7 @@ Item {
function getRealWorkspaces() { function getRealWorkspaces() {
return root.workspaceList.filter(ws => { return root.workspaceList.filter(ws => {
if (useExtWorkspace) if (useAqueous || useExtWorkspace)
return ws && !ws._placeholder; return ws && !ws._placeholder;
if (CompositorService.isNiri) if (CompositorService.isNiri)
return ws && ws.idx !== -1; return ws && ws.idx !== -1;
@@ -674,9 +688,14 @@ Item {
} }
function switchToWorkspaceByModelData(data) { function switchToWorkspaceByModelData(data) {
if (!data) if (!data || data._placeholder)
return; return;
if (useAqueous) {
AqueousService.activateWorkspace(data);
return;
}
if (root.useExtWorkspace) { if (root.useExtWorkspace) {
if (typeof data.activate === "function") if (typeof data.activate === "function")
data.activate(); data.activate();
@@ -727,13 +746,21 @@ Item {
} }
function switchWorkspace(direction) { 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) { if (useExtWorkspace) {
const realWorkspaces = getRealWorkspaces(); const realWorkspaces = getRealWorkspaces();
if (realWorkspaces.length < 2) { if (realWorkspaces.length < 2) {
return; 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 validIndex = currentIndex === -1 ? 0 : currentIndex;
const nextIndex = direction > 0 ? Math.min(validIndex + 1, realWorkspaces.length - 1) : Math.max(validIndex - 1, 0); 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) { function getWorkspaceIndexFallback(modelData, index) {
if (useAqueous)
return modelData?.number ?? "";
if (root.useExtWorkspace) if (root.useExtWorkspace)
return index + 1; return index + 1;
if (CompositorService.isNiri) if (CompositorService.isNiri)
@@ -827,8 +856,8 @@ Item {
function getWorkspaceIndex(modelData, index) { function getWorkspaceIndex(modelData, index) {
let isPlaceholder; let isPlaceholder;
if (root.useExtWorkspace) { if (root.useAqueous || root.useExtWorkspace) {
isPlaceholder = modelData?.hidden === true; isPlaceholder = modelData?._placeholder === true;
} else if (CompositorService.isNiri) { } else if (CompositorService.isNiri) {
isPlaceholder = modelData?.idx === -1; isPlaceholder = modelData?.idx === -1;
} else if (CompositorService.isHyprland) { } else if (CompositorService.isHyprland) {
@@ -868,7 +897,7 @@ Item {
return getWorkspaceIndexFallback(modelData, index); 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 hasWorkspaces: getRealWorkspaces().length > 0
readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces) readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces)
@@ -962,7 +991,9 @@ Item {
const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y); const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y);
switch (mouse.button) { switch (mouse.button) {
case Qt.RightButton: case Qt.RightButton:
if (CompositorService.isNiri) { if (CompositorService.isAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview(); NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) { } else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen; root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1144,8 +1175,10 @@ Item {
} }
property bool isActive: { property bool isActive: {
if (root.useAqueous)
return modelData?.active === true;
if (root.useExtWorkspace) if (root.useExtWorkspace)
return (modelData?.id || modelData?.name) === root.currentWorkspace; return modelData === root.currentWorkspace;
if (CompositorService.isNiri) if (CompositorService.isNiri)
return !!(modelData && modelData.idx === root.currentWorkspace); return !!(modelData && modelData.idx === root.currentWorkspace);
if (CompositorService.isHyprland) if (CompositorService.isHyprland)
@@ -1157,6 +1190,8 @@ Item {
return modelData === root.currentWorkspace; return modelData === root.currentWorkspace;
} }
property bool isOccupied: { property bool isOccupied: {
if (root.useAqueous)
return AqueousService.toplevels.some(w => w.aqueousWorkspaceId === modelData?.id);
if (CompositorService.isHyprland) if (CompositorService.isHyprland)
return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id); return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id);
if (root.isMango) if (root.isMango)
@@ -1166,7 +1201,7 @@ Item {
return false; return false;
} }
property bool isPlaceholder: { property bool isPlaceholder: {
if (root.useExtWorkspace) if (root.useAqueous || root.useExtWorkspace)
return !!(modelData && modelData._placeholder); return !!(modelData && modelData._placeholder);
if (CompositorService.isNiri) if (CompositorService.isNiri)
return !!(modelData && modelData.idx === -1); return !!(modelData && modelData.idx === -1);
@@ -1183,6 +1218,8 @@ Item {
property var loadedWorkspaceData: null property var loadedWorkspaceData: null
property bool loadedIsUrgent: false property bool loadedIsUrgent: false
property bool isUrgent: { property bool isUrgent: {
if (root.useAqueous)
return modelData?.urgent === true;
if (root.useExtWorkspace) if (root.useExtWorkspace)
return modelData?.urgent ?? false; return modelData?.urgent ?? false;
if (CompositorService.isHyprland) if (CompositorService.isHyprland)
@@ -1210,6 +1247,9 @@ Item {
if (!SettingsData.showWorkspaceApps || isPlaceholder) if (!SettingsData.showWorkspaceApps || isPlaceholder)
return 0; return 0;
if (root.useAqueous)
return loadedIcons.length;
let targetWorkspaceId; let targetWorkspaceId;
if (root.useExtWorkspace) { if (root.useExtWorkspace) {
targetWorkspaceId = modelData?.id || modelData?.name; 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 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 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 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 contentImplicitWidth: appIconsLoader.item?.contentWidth ?? 0
readonly property real contentImplicitHeight: appIconsLoader.item?.contentHeight ?? 0 readonly property real contentImplicitHeight: appIconsLoader.item?.contentHeight ?? 0
@@ -1479,7 +1519,11 @@ Item {
return; return;
if (mouse.button === Qt.LeftButton) { 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") if (typeof modelData?.activate === "function")
modelData.activate(); modelData.activate();
} else if (CompositorService.isNiri) { } else if (CompositorService.isNiri) {
@@ -1494,7 +1538,9 @@ Item {
CompositorService.dispatchSwayWorkspace(modelData); CompositorService.dispatchSwayWorkspace(modelData);
} }
} else if (mouse.button === Qt.RightButton) { } else if (mouse.button === Qt.RightButton) {
if (CompositorService.isNiri) { if (CompositorService.isAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview(); NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) { } else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen; root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1517,7 +1563,7 @@ Item {
} }
var wsData = null; var wsData = null;
if (root.useExtWorkspace) { if (root.useAqueous || root.useExtWorkspace) {
wsData = modelData; wsData = modelData;
} else if (CompositorService.isNiri) { } else if (CompositorService.isNiri) {
wsData = modelData || null; wsData = modelData || null;
@@ -1554,6 +1600,36 @@ Item {
dataUpdateTimer.restart(); 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 width: root.isVertical ? root.widgetHeight : visualWidth
height: root.isVertical ? visualHeight : root.widgetHeight height: root.isVertical ? visualHeight : root.widgetHeight
@@ -1678,6 +1754,7 @@ Item {
id: contentRoot id: contentRoot
readonly property real contentWidth: contentRow.item?.implicitWidth ?? 0 readonly property real contentWidth: contentRow.item?.implicitWidth ?? 0
readonly property real contentHeight: contentRow.item?.implicitHeight ?? 0 readonly property real contentHeight: contentRow.item?.implicitHeight ?? 0
property alias iconsLayout: contentRow.item
Loader { Loader {
id: contentRow id: contentRow
@@ -1743,16 +1820,18 @@ Item {
delegate: Item { delegate: Item {
width: root.appIconSize width: root.appIconSize
height: 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 bool appHighlightActive: SettingsData.workspaceActiveAppHighlightEnabled && modelData.active
readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected
readonly property color appGlyphColor: appHighlightActive ? focusedBorderColor : Theme.primary 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 { IconImage {
id: rowAppIcon id: rowAppIcon
anchors.fill: parent anchors.fill: parent
source: modelData.icon || "" 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 visible: !modelData.isQuickshell && !modelData.isSteamApp && status === Image.Ready
} }
@@ -1794,7 +1873,7 @@ Item {
IconImage { IconImage {
anchors.fill: parent anchors.fill: parent
source: modelData.icon 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 visible: modelData.isQuickshell
layer.enabled: true layer.enabled: true
layer.effect: MultiEffect { layer.effect: MultiEffect {
@@ -1808,7 +1887,7 @@ Item {
id: rowSteamIcon id: rowSteamIcon
anchors.fill: parent anchors.fill: parent
source: modelData.icon 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 visible: modelData.isSteamApp && modelData.icon
} }
@@ -1817,7 +1896,7 @@ Item {
size: root.appIconSize size: root.appIconSize
name: "sports_esports" name: "sports_esports"
color: appHighlightActive ? focusedBorderColor : Theme.widgetTextColor 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 visible: modelData.isSteamApp && !modelData.icon
} }
@@ -1831,21 +1910,8 @@ Item {
z: 1 z: 1
} }
MouseArea { HoverHandler {
id: rowAppMouseArea id: rowAppHover
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);
}
}
} }
Rectangle { Rectangle {
@@ -1912,16 +1978,18 @@ Item {
delegate: Item { delegate: Item {
width: root.appIconSize width: root.appIconSize
height: 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 bool appHighlightActive: SettingsData.workspaceActiveAppHighlightEnabled && modelData.active
readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected
readonly property color appGlyphColor: appHighlightActive ? focusedBorderColor : Theme.primary 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 { IconImage {
id: colAppIcon id: colAppIcon
anchors.fill: parent anchors.fill: parent
source: modelData.icon || "" 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 visible: !modelData.isQuickshell && !modelData.isSteamApp && status === Image.Ready
} }
@@ -1963,7 +2031,7 @@ Item {
IconImage { IconImage {
anchors.fill: parent anchors.fill: parent
source: modelData.icon 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 visible: modelData.isQuickshell
layer.enabled: true layer.enabled: true
layer.effect: MultiEffect { layer.effect: MultiEffect {
@@ -1977,7 +2045,7 @@ Item {
id: colSteamIcon id: colSteamIcon
anchors.fill: parent anchors.fill: parent
source: modelData.icon 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 visible: modelData.isSteamApp && modelData.icon
} }
@@ -1986,7 +2054,7 @@ Item {
size: root.appIconSize size: root.appIconSize
name: "sports_esports" name: "sports_esports"
color: appHighlightActive ? focusedBorderColor : Theme.widgetTextColor 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 visible: modelData.isSteamApp && !modelData.icon
} }
@@ -2000,21 +2068,8 @@ Item {
z: 1 z: 1
} }
MouseArea { HoverHandler {
id: colAppMouseArea id: colAppHover
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);
}
}
} }
Rectangle { Rectangle {
@@ -2121,6 +2176,13 @@ Item {
delegateRoot.updateAllData(); delegateRoot.updateAllData();
} }
} }
Connections {
target: AqueousService
function onStateChanged() {
if (root.useAqueous)
delegateRoot.updateAllData();
}
}
property var _extWindowsetsTrigger: root.useExtWorkspace ? WindowManager.windowsets : null property var _extWindowsetsTrigger: root.useExtWorkspace ? WindowManager.windowsets : null
on_ExtWindowsetsTriggerChanged: { on_ExtWindowsetsTriggerChanged: {
if (root.useExtWorkspace) if (root.useExtWorkspace)
@@ -543,6 +543,7 @@ Item {
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
font.weight: row.selected ? Font.DemiBold : Font.Medium font.weight: row.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1
} }
StyledText { StyledText {
@@ -551,6 +552,7 @@ Item {
color: Theme.surfaceTextSecondary color: Theme.surfaceTextSecondary
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0 visible: text.length > 0
} }
} }
@@ -3,6 +3,7 @@ import Quickshell
import qs.Common import qs.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import "../../../Common/DateOnly.js" as DateOnly
Rectangle { Rectangle {
id: root id: root
@@ -35,53 +36,39 @@ Rectangle {
return weekStartQt() % 7; return weekStartQt() % 7;
} }
function startOfWeek(dateObj) { function startOfWeek(dateOnly) {
const d = new Date(dateObj); const diff = (dateOnly.dayOfWeek() - weekStartJs() + 7) % 7;
const jsDow = d.getDay(); return dateOnly.addDays(-diff);
const diff = (jsDow - weekStartJs() + 7) % 7;
d.setDate(d.getDate() - diff);
return d;
} }
function endOfWeek(dateObj) { function endOfWeek(dateOnly) {
const d = new Date(dateObj); const add = (weekStartJs() + 6 - dateOnly.dayOfWeek() + 7) % 7;
const jsDow = d.getDay(); return dateOnly.addDays(add);
const add = (weekStartJs() + 6 - jsDow + 7) % 7;
d.setDate(d.getDate() + add);
return d;
} }
function getWeekNumber(dateObj) { function getWeekNumber(dateOnly) {
// Set time to noon to avoid potential Daylight Saving Time related bugs const weekStartDay = startOfWeek(dateOnly);
const weekStartDay = startOfWeek(dateObj);
weekStartDay.setHours(12, 0, 0, 0);
let week1Start; let week1Start;
if (weekStartJs() === 1) { if (weekStartJs() === 1) {
// ISO 8601 Standard, week start on Monday // ISO 8601 Standard, week start on Monday
// A week belongs to the year its Thursday falls in // A week belongs to the year its Thursday falls in
// So we have to get the yearTarget from weekStartDay instead of dateObj // So we have to get the yearTarget from weekStartDay instead of dateOnly
let yearTarget = weekStartDay; const yearTarget = weekStartDay.addDays(3); // Monday + 3 = Thursday
yearTarget.setDate(yearTarget.getDate() + 3); // Monday + 3 = Thursday
// Week 1 is the week containing Jan 4th // Week 1 is the week containing Jan 4th
const jan4 = new Date(yearTarget.getFullYear(), 0, 4); week1Start = startOfWeek(DateOnly.of(yearTarget.year, 0, 4));
week1Start = startOfWeek(jan4);
} else { } else {
// Traditional / US Standard, week start on Sunday // Traditional / US Standard, week start on Sunday
// A week belongs to the year its Sunday falls in // A week belongs to the year its Sunday falls in
let yearTarget = weekStartDay; const yearTarget = weekStartDay.addDays(6); // Monday + 6 = Sunday
yearTarget.setDate(yearTarget.getDate() + 6); // Monday + 6 = Sunday
// Week 1 is the week containing Jan 1st // Week 1 is the week containing Jan 1st
const jan1 = new Date(yearTarget.getFullYear(), 0, 1); week1Start = startOfWeek(DateOnly.of(yearTarget.year, 0, 1));
week1Start = startOfWeek(jan1);
} }
week1Start.setHours(12, 0, 0, 0); const diffDays = week1Start.daysUntil(weekStartDay);
const diffDays = Math.round((weekStartDay.getTime() - week1Start.getTime()) / 86400000); // Number of miliseconds in a day
return Math.floor(diffDays / 7) + 1; return Math.floor(diffDays / 7) + 1;
} }
@@ -97,16 +84,16 @@ Rectangle {
return; return;
} }
const firstOfMonth = new Date(calendarGrid.displayDate.getFullYear(), calendarGrid.displayDate.getMonth(), 1); const year = calendarGrid.displayDate.getFullYear();
const lastOfMonth = new Date(calendarGrid.displayDate.getFullYear(), calendarGrid.displayDate.getMonth() + 1, 0); const month = calendarGrid.displayDate.getMonth();
const startDate = startOfWeek(firstOfMonth); const firstOfMonth = DateOnly.firstOfMonth(year, month);
startDate.setDate(startDate.getDate() - 7); const lastOfMonth = DateOnly.lastOfMonth(year, month);
const endDate = endOfWeek(lastOfMonth); const startDate = startOfWeek(firstOfMonth).addDays(-7);
endDate.setDate(endDate.getDate() + 7); const endDate = endOfWeek(lastOfMonth).addDays(7);
CalendarService.loadEvents(startDate, endDate); CalendarService.loadEvents(startDate.toDate(), endDate.toDate());
} }
function goToToday() { function goToToday() {
@@ -118,11 +105,11 @@ Rectangle {
} }
function moveSelection(days) { function moveSelection(days) {
let d = new Date(calendarGrid.selectedDate); const moved = DateOnly.fromDate(calendarGrid.selectedDate).addDays(days);
d.setDate(d.getDate() + days); const d = moved.toDate();
calendarGrid.selectedDate = d; calendarGrid.selectedDate = d;
root.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; calendarGrid.displayDate = d;
loadEventsForMonth(); loadEventsForMonth();
} }
@@ -216,10 +203,13 @@ Rectangle {
border.width: 1 border.width: 1
Column { Column {
id: cardColumn
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
spacing: Theme.spacingS spacing: Theme.spacingS
readonly property real warningInset: dankWarning.visible ? dankWarning.height + spacing : 0
Rectangle { Rectangle {
id: dankWarning id: dankWarning
width: parent.width width: parent.width
@@ -349,10 +339,27 @@ Rectangle {
} }
DankActionButton { 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 buttonSize: 28
iconSize: 14 iconSize: 14
iconName: "today" 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() onClicked: root.goToToday()
} }
@@ -367,7 +374,7 @@ Rectangle {
Row { Row {
width: parent.width width: parent.width
height: parent.height - 28 - Theme.spacingS height: parent.height - 28 - Theme.spacingS - cardColumn.warningInset
visible: !showEventDetails visible: !showEventDetails
spacing: SettingsData.showWeekNumber ? Theme.spacingS : 0 spacing: SettingsData.showWeekNumber ? Theme.spacingS : 0
@@ -399,8 +406,7 @@ Rectangle {
StyledText { StyledText {
anchors.centerIn: parent anchors.centerIn: parent
text: { text: {
const rowDate = new Date(calendarGrid.firstDay); const rowDate = calendarGrid.firstDay.addDays(index * 7);
rowDate.setDate(rowDate.getDate() + index * 7);
return root.getWeekNumber(rowDate); return root.getWeekNumber(rowDate);
} }
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -458,8 +464,8 @@ Rectangle {
property date displayDate: systemClock.date property date displayDate: systemClock.date
property date selectedDate: systemClock.date property date selectedDate: systemClock.date
readonly property date firstDay: { readonly property var firstDay: {
const firstOfMonth = new Date(displayDate.getFullYear(), displayDate.getMonth(), 1); const firstOfMonth = DateOnly.firstOfMonth(displayDate.getFullYear(), displayDate.getMonth());
return startOfWeek(firstOfMonth); return startOfWeek(firstOfMonth);
} }
@@ -467,14 +473,10 @@ Rectangle {
model: 42 model: 42
Rectangle { Rectangle {
readonly property date dayDate: { readonly property var dayDate: calendarGrid.firstDay.addDays(index)
const date = new Date(parent.firstDay); readonly property bool isCurrentMonth: dayDate.month === calendarGrid.displayDate.getMonth()
date.setDate(date.getDate() + index); readonly property bool isToday: dayDate.equals(DateOnly.fromDate(systemClock.date))
return date; readonly property bool isSelected: dayDate.equals(DateOnly.fromDate(calendarGrid.selectedDate))
}
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()
width: parent.width / 7 width: parent.width / 7
height: parent.height / 6 height: parent.height / 6
@@ -491,7 +493,7 @@ Rectangle {
StyledText { StyledText {
anchors.centerIn: parent anchors.centerIn: parent
text: dayDate.getDate() text: dayDate.day
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: isToday ? Theme.primary : isCurrentMonth ? Theme.surfaceText : Theme.surfaceVariantText color: isToday ? Theme.primary : isCurrentMonth ? Theme.surfaceText : Theme.surfaceVariantText
font.weight: isToday ? Font.Medium : Font.Normal font.weight: isToday ? Font.Medium : Font.Normal
@@ -502,11 +504,11 @@ Rectangle {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: 3 anchors.bottomMargin: 3
spacing: Theme.spacingXXS spacing: Theme.spacingXXS
visible: CalendarService && CalendarService.calendarAvailable && CalendarService.hasEventsForDate(dayDate) visible: CalendarService && CalendarService.calendarAvailable && CalendarService.hasEventsForDate(dayDate.toDate())
Repeater { Repeater {
model: { model: {
const evs = CalendarService.getEventsForDate(dayDate); const evs = CalendarService.getEventsForDate(dayDate.toDate());
const seen = []; const seen = [];
for (let i = 0; i < evs.length && seen.length < 3; i++) { for (let i = 0; i < evs.length && seen.length < 3; i++) {
const c = (evs[i].color && evs[i].color.length) ? evs[i].color : "primary"; const c = (evs[i].color && evs[i].color.length) ? evs[i].color : "primary";
@@ -533,8 +535,8 @@ Rectangle {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
calendarGrid.selectedDate = dayDate; calendarGrid.selectedDate = dayDate.toDate();
root.selectedDate = dayDate; root.selectedDate = dayDate.toDate();
root.showEventDetails = true; root.showEventDetails = true;
} }
} }
@@ -547,7 +549,7 @@ Rectangle {
Flickable { Flickable {
id: flickableArea id: flickableArea
width: parent.width - Theme.spacingS * 2 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 anchors.horizontalCenter: parent.horizontalCenter
visible: showEventDetails visible: showEventDetails
clip: true clip: true
@@ -14,23 +14,27 @@ Item {
property color iconColor: Theme.primary property color iconColor: Theme.primary
property Component leading: null property Component leading: null
function pushMeasuredWidth() { readonly property bool isVertical: root.controller.isVertical
root.controller.setDestinationContentWidth(root.activityId, compactRow.implicitWidth);
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 { Row {
id: compactRow id: compactRow
anchors.centerIn: parent anchors.centerIn: parent
visible: !root.isVertical
spacing: Theme.spacingS spacing: Theme.spacingS
onImplicitWidthChanged: root.pushMeasuredWidth() onImplicitWidthChanged: root.pushMeasuredLength()
Loader { Loader {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
active: root.leading !== null active: !root.isVertical && root.leading !== null
visible: active visible: active
sourceComponent: root.leading sourceComponent: root.leading
} }
@@ -51,4 +55,30 @@ Item {
font.weight: Font.DemiBold 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 required property var systemModel
readonly property string timeText: systemClock.date.toLocaleTimeString(I18n.locale(), SettingsData.getEffectiveTimeFormat()) 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 string dateText: systemClock.date.toLocaleDateString(I18n.locale(), SettingsData.getEffectiveDateFormat("ddd MMM d"))
readonly property bool tight: root.controller.homeCompactTight readonly property bool tight: root.controller.homeCompactTight
readonly property real slotSize: root.tight ? Theme.iconSize : Theme.iconSizeLarge 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 iconSize: root.textSize + Theme.spacingXS
readonly property real statusIconSize: root.textSize + Theme.spacingXXS readonly property real statusIconSize: root.textSize + Theme.spacingXXS
readonly property real groupSpacing: root.controller.homeSlotMargin 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 bool weatherSlotEnabled: root.controller.homeWeatherEnabled
readonly property var brightnessDevice: DisplayService.getCurrentDeviceInfo() readonly property var brightnessDevice: DisplayService.getCurrentDeviceInfo()
readonly property real brightnessMaximum: DisplayService.brightnessMaximum(root.brightnessDevice) readonly property real brightnessMaximum: DisplayService.brightnessMaximum(root.brightnessDevice)
@@ -63,6 +74,29 @@ Item {
return direction; 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) { function adjustSystemLevel(activityId, delta) {
const direction = root.wheelDirection(delta); const direction = root.wheelDirection(delta);
if (direction === 0) if (direction === 0)
@@ -104,10 +138,15 @@ Item {
WeatherService.removeRef(); WeatherService.removeRef();
} }
function pushMeasuredLength() {
root.controller.setHomeContentLength(root.isVertical ? compactColumn.implicitHeight : compactRow.implicitWidth);
}
onWeatherSlotEnabledChanged: root.syncWeatherRef(root.weatherSlotEnabled) onWeatherSlotEnabledChanged: root.syncWeatherRef(root.weatherSlotEnabled)
onIsVerticalChanged: root.pushMeasuredLength()
Component.onCompleted: { Component.onCompleted: {
root.syncWeatherRef(root.weatherSlotEnabled); root.syncWeatherRef(root.weatherSlotEnabled);
root.controller.setHomeContentWidth(compactRow.implicitWidth); root.pushMeasuredLength();
} }
Component.onDestruction: root.syncWeatherRef(false) Component.onDestruction: root.syncWeatherRef(false)
@@ -115,7 +154,15 @@ Item {
target: compactRow target: compactRow
function onImplicitWidthChanged() { function onImplicitWidthChanged() {
root.controller.setHomeContentWidth(compactRow.implicitWidth); root.pushMeasuredLength();
}
}
Connections {
target: compactColumn
function onImplicitHeightChanged() {
root.pushMeasuredLength();
} }
} }
@@ -192,12 +239,15 @@ Item {
Row { Row {
id: clockRow id: clockRow
readonly property string displayMode: root.controller.homeClockDisplay
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: item.isClock visible: item.isClock
spacing: Theme.spacingS spacing: Theme.spacingS
NumericText { NumericText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: clockRow.displayMode !== "date"
isMonospace: false isMonospace: false
text: root.timeText text: root.timeText
reserveText: root.timeText.replace(/\d/g, "0") reserveText: root.timeText.replace(/\d/g, "0")
@@ -208,6 +258,7 @@ Item {
Rectangle { Rectangle {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: clockRow.displayMode === "both"
width: Theme.spacingXS width: Theme.spacingXS
height: Theme.spacingXS height: Theme.spacingXS
radius: height / 2 radius: height / 2
@@ -216,6 +267,7 @@ Item {
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: clockRow.displayMode !== "time"
text: root.dateText text: root.dateText
color: Theme.surfaceTextMedium color: Theme.surfaceTextMedium
font.pixelSize: root.textSize font.pixelSize: root.textSize
@@ -301,8 +353,8 @@ Item {
thickness: root.statusIconSize thickness: root.statusIconSize
fontSize: root.textSize fontSize: root.textSize
hovered: groupArea.containsMouse hovered: groupArea.containsMouse
meterStyle: SettingsData.dankIslandBatteryStyle meterStyle: root.controller.batteryStyle
levelColors: (SettingsData.islandBarConfig?.batteryColorMode ?? "theme") === "level" levelColors: (root.controller.barConfig?.batteryColorMode ?? "theme") === "level"
} }
DankIcon { DankIcon {
@@ -322,29 +374,201 @@ Item {
height: root.height height: root.height
enabled: !item.isClock enabled: !item.isClock
controller: root.controller controller: root.controller
onClicked: { onClicked: root.activateGroup(item.groupId)
if (item.isMedia && root.controller.mediaAvailable) { onWheel: wheel => {
root.controller.requestActivity("media", false, false); if (!item.isVolume && !item.isBrightness)
return; return;
} root.adjustSystemLevel(item.groupId, wheel.angleDelta.y || wheel.angleDelta.x);
if (item.isMedia) { wheel.accepted = true;
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);
} }
}
}
// 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 => { onWheel: wheel => {
if (!item.isVolume && !item.isBrightness) if (!item.isVolume && !item.isBrightness)
return; return;
@@ -358,11 +582,12 @@ Item {
id: compactRow id: compactRow
anchors.centerIn: parent anchors.centerIn: parent
visible: !root.isVertical
height: root.slotSize height: root.slotSize
spacing: root.groupSpacing spacing: root.groupSpacing
Repeater { Repeater {
model: root.groupIds model: root.isVertical ? [] : root.groupIds
GroupItem { GroupItem {
required property var modelData 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 required property var controller
readonly property bool dense: root.controller.compactDense readonly property bool dense: root.controller.compactDense
readonly property bool isVertical: root.controller.isVertical
readonly property real artworkSize: root.controller.compactIconSize readonly property real artworkSize: root.controller.compactIconSize
readonly property real textGutter: Theme.spacingXS readonly property real textGutter: Theme.spacingXS
readonly property real cavaWidth: 20 readonly property real cavaWidth: 20
@@ -21,8 +22,18 @@ Item {
readonly property real clockLeadPad: Theme.spacingXS / 2 readonly property real clockLeadPad: Theme.spacingXS / 2
readonly property real clockTrailPad: Theme.spacingS readonly property real clockTrailPad: Theme.spacingS
readonly property real clockPillHeight: root.dense ? Math.max(22, root.artworkSize - Theme.spacingXS) : 32 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 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 naturalTextWidth: Math.max(root.minTextWidth, titleMetrics.width, root.dense ? 0 : artistMetrics.width) + root.textGutter * 2
readonly property real measuredWidth: { readonly property real measuredWidth: {
let width = Theme.spacingS + root.artworkSize + Theme.spacingXS + root.cavaWidth + Theme.spacingXS + root.naturalTextWidth + Theme.spacingXS; let width = Theme.spacingS + root.artworkSize + Theme.spacingXS + root.cavaWidth + Theme.spacingXS + root.naturalTextWidth + Theme.spacingXS;
@@ -31,12 +42,13 @@ Item {
return width; return width;
} }
function pushMeasuredWidth() { function pushMeasuredLength() {
root.controller.setMediaContentWidth(root.measuredWidth); root.controller.setMediaContentLength(root.isVertical ? verticalFace.implicitHeight + root.verticalPad * 2 : root.measuredWidth);
} }
onMeasuredWidthChanged: root.pushMeasuredWidth() onMeasuredWidthChanged: root.pushMeasuredLength()
Component.onCompleted: root.pushMeasuredWidth() onIsVerticalChanged: root.pushMeasuredLength()
Component.onCompleted: root.pushMeasuredLength()
StyledTextMetrics { StyledTextMetrics {
id: titleMetrics id: titleMetrics
@@ -67,12 +79,14 @@ Item {
leftMargin: Theme.spacingS leftMargin: Theme.spacingS
verticalCenter: parent.verticalCenter verticalCenter: parent.verticalCenter
} }
visible: !root.isVertical
width: root.artworkSize width: root.artworkSize
height: width height: width
artUrl: root.mediaModel.artUrl artUrl: root.mediaModel.artUrl
} }
Row { Row {
visible: !root.isVertical
anchors { anchors {
left: artwork.right left: artwork.right
leftMargin: Theme.spacingXS leftMargin: Theme.spacingXS
@@ -133,7 +147,7 @@ Item {
} }
width: root.clockVisible ? (clockText.width + Theme.spacingXS * 2) : 0 width: root.clockVisible ? (clockText.width + Theme.spacingXS * 2) : 0
height: root.clockPillHeight height: root.clockPillHeight
visible: root.clockVisible visible: root.clockVisible && !root.isVertical
Rectangle { Rectangle {
anchors.fill: parent 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 bool dense: false
property real iconSize: 36 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 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 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) readonly property real measuredWidth: Theme.spacingS * 3 + root.iconSize + Theme.spacingXS + Math.max(root.dense ? 0 : headerLabel.implicitWidth, summaryLabel.implicitWidth)
function pushMeasuredWidth() { function pushMeasuredLength() {
root.controller.setNotificationContentWidth(root.measuredWidth); root.controller.setNotificationContentLength(root.isVertical ? root.iconSize + Theme.spacingS * 2 : root.measuredWidth);
} }
onMeasuredWidthChanged: root.pushMeasuredWidth() onMeasuredWidthChanged: root.pushMeasuredLength()
Component.onCompleted: root.pushMeasuredWidth() 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 { Row {
visible: !root.isVertical
anchors { anchors {
fill: parent fill: parent
leftMargin: Theme.spacingS leftMargin: Theme.spacingS
@@ -3,27 +3,149 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import qs.Common import qs.Common
import qs.Modules.OSD import qs.Modules.OSD
import qs.Widgets
Item { Item {
id: root id: root
required property var systemModel required property var systemModel
property bool isVertical: false
property real iconSize: 36 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 { OsdLevelRow {
anchors.fill: parent anchors.fill: parent
visible: !root.isVertical
iconName: root.systemModel.iconName iconName: root.systemModel.iconName
iconSize: Math.min(Theme.iconSize, root.iconSize) iconSize: Math.min(Theme.iconSize, root.iconSize)
iconColor: root.systemModel.volumeActivity ? Theme.surfaceText : Theme.primary iconColor: root.systemModel.volumeActivity ? Theme.surfaceText : Theme.primary
iconInteractive: root.systemModel.volumeActivity iconInteractive: root.systemModel.volumeActivity
value: Math.round(root.systemModel.value) value: Math.round(root.systemModel.value)
minimum: root.systemModel.minimum minimum: root.systemModel.minimum
maximum: Math.max(root.systemModel.minimum + 1, Math.round(root.systemModel.maximum)) maximum: root.maximum
unit: root.systemModel.unit unit: root.systemModel.unit
displayText: root.systemModel.displayValue displayText: root.systemModel.displayValue
sliderEnabled: root.systemModel.available sliderEnabled: root.systemModel.available
thumbOutlineColor: Theme.surfaceContainerHigh thumbOutlineColor: Theme.surfaceContainerHigh
onIconClicked: root.systemModel.toggleMute() 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 QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.Common
import qs.Services import qs.Services
Item { Item {
id: root 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 launcherOpen: root.activityOpen("launcher")
readonly property bool controlCenterOpen: root.activityOpen("controlcenter") readonly property bool controlCenterOpen: root.activityOpen("controlcenter")
@@ -217,7 +239,7 @@ Item {
"notificationCenterAvailable": true, "notificationCenterAvailable": true,
"launcherInputFocused": host.islandController.launcherInputFocused, "launcherInputFocused": host.islandController.launcherInputFocused,
"launcherResultCount": host.launcherResultCount, "launcherResultCount": host.launcherResultCount,
"compactHeight": host.islandController.compactHeight "compactHeight": host.islandController.compactThickness
}); });
} }
@@ -230,12 +252,14 @@ Item {
Variants { Variants {
id: islandVariants id: islandVariants
model: root.screenModel model: root.hostSlots
delegate: DankIslandHostWindow { delegate: DankIslandHostWindow {
required property var modelData required property var modelData
screen: modelData screen: modelData.screen
barId: modelData.barId
hyprlandOverviewLoader: root.hyprlandOverviewLoader
} }
} }
@@ -12,6 +12,8 @@ import qs.Widgets
PanelWindow { PanelWindow {
id: root id: root
property var hyprlandOverviewLoader: null
function requestKeyboardFocus() { function requestKeyboardFocus() {
if (!controller.keyboardDismissRequested) { if (!controller.keyboardDismissRequested) {
keyboardActivationTimer.stop(); keyboardActivationTimer.stop();
@@ -26,7 +28,7 @@ PanelWindow {
function containsGlobalPoint(gx, gy, padding) { function containsGlobalPoint(gx, gy, padding) {
const pad = padding !== undefined ? padding : 16; 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++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (!item || item.width <= 0 || item.height <= 0) if (!item || item.width <= 0 || item.height <= 0)
@@ -35,40 +37,55 @@ PanelWindow {
if (!topLeft) if (!topLeft)
continue; continue;
// mapToItem(null) is window-local; callers pass screen-space cursor coords. // mapToItem(null) is window-local; callers pass screen-space cursor coords.
const left = topLeft.x + root.hostOriginX;
const top = topLeft.y + root.hostOriginY; 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 true;
} }
return false; return false;
} }
readonly property int reserveHeight: Math.max(24, Math.min(128, SettingsData.dankIslandReserveHeight)) required property string barId
readonly property int compactHeight: Math.max(24, Math.min(72, SettingsData.dankIslandCompactHeight)) readonly property var barConfig: {
readonly property bool floating: SettingsData.dankIslandFloating SettingsData.barConfigs;
readonly property bool usesOverlayLayer: LayerShell.envUsesOverlay("DMS_DANKISLAND_LAYER", SettingsData.dankIslandUseOverlayLayer) 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 var islandLayer: LayerShell.fromEnv("DMS_DANKISLAND_LAYER", usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top)
readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom" readonly property string edge: SettingsData.islandEdge(root.barConfig)
readonly property int reservedStripHeight: Math.max(reserveHeight, outerGap + compactHeight) readonly property bool isVertical: SettingsData.islandVertical(root.barConfig)
readonly property int stripY: root.bottomEdge ? root.height - root.reservedStripHeight : 0 readonly property bool farEdge: root.edge === "bottom" || root.edge === "right"
readonly property int hostOriginY: root.bottomEdge ? Math.max(0, (root.screen?.height ?? 0) - root.height) : 0 readonly property int reservedStripThickness: Math.max(reserveThickness, outerGap + compactThickness)
readonly property int outerGap: Math.max(0, Math.min(48, SettingsData.dankIslandOuterGap)) 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 maxActivityHeight: Math.max(560, Math.min(680, (screen?.height ?? 1080) - 200))
readonly property int hostHeight: outerGap + maxActivityHeight + 8 readonly property int maxActivityWidth: Math.min(736, Math.max(320, (screen?.width ?? 1920) - 200))
readonly property int maxActivityWidth: 736 readonly property int hostThickness: outerGap + (root.isVertical ? maxActivityWidth : maxActivityHeight) + 8
readonly property real maximumHorizontalOffset: Math.max(0, (width - maxActivityWidth) / 2 - 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 property bool keyboardFocusArmed: true
color: "transparent" color: "transparent"
implicitHeight: hostHeight implicitHeight: hostThickness
exclusiveZone: floating ? 0 : reservedStripHeight implicitWidth: hostThickness
exclusiveZone: floating ? 0 : reservedStripThickness
readonly property alias islandController: controller readonly property alias islandController: controller
readonly property int launcherResultCount: launcherController.flatModel?.length ?? 0 readonly property int launcherResultCount: launcherController.flatModel?.length ?? 0
anchors { anchors {
top: !root.bottomEdge top: root.isVertical || root.edge === "top"
bottom: root.bottomEdge bottom: root.isVertical || root.edge === "bottom"
left: true left: !root.isVertical || root.edge === "left"
right: true right: !root.isVertical || root.edge === "right"
} }
WlrLayershell.namespace: "dms:dankisland" WlrLayershell.namespace: "dms:dankisland"
@@ -131,11 +148,11 @@ PanelWindow {
} }
Region { Region {
item: controller.inputSuspended ? null : satelliteHost.leftInputItem item: controller.inputSuspended ? null : satelliteHost.leadingInputItem
} }
Region { Region {
item: controller.inputSuspended ? null : satelliteHost.rightInputItem item: controller.inputSuspended ? null : satelliteHost.trailingInputItem
} }
Region { Region {
@@ -150,18 +167,27 @@ PanelWindow {
IslandController { IslandController {
id: controller id: controller
interactionMode: SettingsData.dankIslandInteractionMode === "click" ? "click" : "hybrid" barConfig: root.barConfig
edge: root.edge
interactionMode: root.setting("islandInteractionMode") === "click" ? "click" : "hybrid"
inputSuspended: PopoutManager.screenshotActive 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 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" launcherCycleEnabled: SettingsData.launcherStyle === "island"
controlCenterMaxHeight: root.maxActivityHeight controlCenterMaxHeight: root.maxActivityHeight
notificationCenterMaxHeight: root.maxActivityHeight notificationCenterMaxHeight: root.maxActivityHeight
notificationExpandAllowed: SettingsData.dankIslandNotificationExpand notificationExpandAllowed: root.setting("islandNotificationExpand")
unreadNotificationCount: SettingsData.dankIslandNotificationBadgeClearOnOpen ? NotificationService.unreadCount : NotificationService.notifications.length unreadNotificationCount: root.setting("islandNotificationBadgeClearOnOpen") ? NotificationService.unreadCount : NotificationService.notifications.length
hoverOpenDelay: Math.max(0, Math.min(1000, SettingsData.dankIslandHoverOpenDelay)) hoverOpenDelay: Math.max(0, Math.min(1000, root.setting("islandHoverOpenDelay")))
hoverCloseDelay: Math.max(0, Math.min(1000, SettingsData.dankIslandHoverCloseDelay)) hoverCloseDelay: Math.max(0, Math.min(1000, root.setting("islandHoverCloseDelay")))
} }
DankLauncher.Controller { DankLauncher.Controller {
@@ -221,11 +247,11 @@ PanelWindow {
MouseArea { MouseArea {
id: satelliteDismissStrip id: satelliteDismissStrip
x: 0 x: root.isVertical ? root.stripPos : 0
y: root.stripY y: root.isVertical ? 0 : root.stripPos
z: -2 z: -2
width: parent.width width: root.isVertical ? root.reservedStripThickness : parent.width
height: root.reservedStripHeight height: root.isVertical ? parent.height : root.reservedStripThickness
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: false hoverEnabled: false
enabled: root.satelliteSurfacesOpen && !controller.inputSuspended enabled: root.satelliteSurfacesOpen && !controller.inputSuspended
@@ -235,11 +261,11 @@ PanelWindow {
BarScrollArea { BarScrollArea {
id: scrollStrip id: scrollStrip
x: 0 x: root.isVertical ? root.stripPos : 0
y: root.stripY y: root.isVertical ? 0 : root.stripPos
z: -1 z: -1
width: parent.width width: root.isVertical ? root.reservedStripThickness : parent.width
height: root.reservedStripHeight height: root.isVertical ? parent.height : root.reservedStripThickness
hoverEnabled: false hoverEnabled: false
enabled: satelliteHost.scrollEnabled && !controller.inputSuspended enabled: satelliteHost.scrollEnabled && !controller.inputSuspended
scrollEnabled: satelliteHost.scrollEnabled scrollEnabled: satelliteHost.scrollEnabled
@@ -261,13 +287,15 @@ PanelWindow {
launcherTransientSurfaceTracker: launcherTransientSurfaces launcherTransientSurfaceTracker: launcherTransientSurfaces
notificationTransientSurfaceTracker: notificationTransientSurfaces notificationTransientSurfaceTracker: notificationTransientSurfaces
effectiveScreen: root.screen effectiveScreen: root.screen
hostOriginX: root.hostOriginX
hostOriginY: root.hostOriginY hostOriginY: root.hostOriginY
reducedMotion: SettingsData.dankIslandReducedMotion || SettingsData.reduceMotion || SettingsData.animationSpeed === SettingsData.AnimationSpeed.None reducedMotion: root.setting("islandReducedMotion") || SettingsData.reduceMotion || SettingsData.animationSpeed === SettingsData.AnimationSpeed.None
springStiffness: Math.max(100, Math.min(1200, SettingsData.dankIslandSpringStiffness)) springStiffness: Math.max(100, Math.min(1200, root.setting("islandSpringStiffness")))
springDamping: Math.max(10, Math.min(100, SettingsData.dankIslandSpringDamping)) springDamping: Math.max(10, Math.min(100, root.setting("islandSpringDamping")))
springMass: Math.max(0.25, Math.min(3, SettingsData.dankIslandSpringMass)) springMass: Math.max(0.25, Math.min(3, root.setting("islandSpringMass")))
palette: SettingsData.dankIslandPalette palette: root.setting("islandPalette")
highContrast: SettingsData.dankIslandHighContrast highContrast: root.setting("islandHighContrast")
transparency: root.setting("islandTransparency")
onScrollWheel: wheel => scrollStrip.processWheel(wheel) onScrollWheel: wheel => scrollStrip.processWheel(wheel)
} }
@@ -277,9 +305,11 @@ PanelWindow {
anchors.fill: parent anchors.fill: parent
hostWindow: root hostWindow: root
targetScreen: root.screen targetScreen: root.screen
barConfig: root.barConfig
controller: controller controller: controller
islandSurface: surface islandSurface: surface
outerGap: root.outerGap outerGap: root.outerGap
hyprlandOverviewLoader: root.hyprlandOverviewLoader
} }
FocusScope { FocusScope {
@@ -325,12 +355,12 @@ PanelWindow {
} }
Region { Region {
item: leftSatelliteHole item: leadingSatelliteHole
intersection: Intersection.Subtract intersection: Intersection.Subtract
} }
Region { Region {
item: rightSatelliteHole item: trailingSatelliteHole
intersection: Intersection.Subtract intersection: Intersection.Subtract
} }
} }
@@ -346,7 +376,7 @@ PanelWindow {
Item { Item {
id: islandHole id: islandHole
x: surface.inputMaskItem.x x: surface.inputMaskItem.x + root.hostOriginX
y: surface.inputMaskItem.y + root.hostOriginY y: surface.inputMaskItem.y + root.hostOriginY
width: surface.inputMaskItem.width width: surface.inputMaskItem.width
height: surface.inputMaskItem.height height: surface.inputMaskItem.height
@@ -355,28 +385,28 @@ PanelWindow {
Item { Item {
id: fittsStripHole id: fittsStripHole
x: surface.fittsStripItem.x x: surface.fittsStripItem.x + root.hostOriginX
y: surface.fittsStripItem.y + root.hostOriginY y: surface.fittsStripItem.y + root.hostOriginY
width: surface.fittsStripItem.visible ? surface.fittsStripItem.width : 0 width: surface.fittsStripItem.visible ? surface.fittsStripItem.width : 0
height: surface.fittsStripItem.visible ? surface.fittsStripItem.height : 0 height: surface.fittsStripItem.visible ? surface.fittsStripItem.height : 0
} }
Item { Item {
id: leftSatelliteHole id: leadingSatelliteHole
x: satelliteHost.leftInputItem.x x: satelliteHost.leadingInputItem.x + root.hostOriginX
y: satelliteHost.leftInputItem.y + root.hostOriginY y: satelliteHost.leadingInputItem.y + root.hostOriginY
width: satelliteHost.leftInputItem.width width: satelliteHost.leadingInputItem.width
height: satelliteHost.leftInputItem.height height: satelliteHost.leadingInputItem.height
} }
Item { Item {
id: rightSatelliteHole id: trailingSatelliteHole
x: satelliteHost.rightInputItem.x x: satelliteHost.trailingInputItem.x + root.hostOriginX
y: satelliteHost.rightInputItem.y + root.hostOriginY y: satelliteHost.trailingInputItem.y + root.hostOriginY
width: satelliteHost.rightInputItem.width width: satelliteHost.trailingInputItem.width
height: satelliteHost.rightInputItem.height height: satelliteHost.trailingInputItem.height
} }
MouseArea { MouseArea {
@@ -22,9 +22,11 @@ Item {
property real springStiffness: 560 property real springStiffness: 560
property real springDamping: 37 property real springDamping: 37
property real springMass: 1 property real springMass: 1
property real hostOriginX: 0
property real hostOriginY: 0 property real hostOriginY: 0
property string palette: "default" property string palette: "default"
property bool highContrast: false property bool highContrast: false
property real transparency: 1
readonly property color surfaceColor: { readonly property color surfaceColor: {
if (root.highContrast) if (root.highContrast)
@@ -38,7 +40,7 @@ Item {
return Theme.surfaceContainerHigh; return Theme.surfaceContainerHigh;
} }
readonly property bool popupStyled: root.controller.expanded 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 color effectiveSurfaceColor: root.highContrast ? Theme.surfaceContainerHighest : Theme.withAlpha(root.surfaceColor, root.islandOpacity)
readonly property real surfaceOpacity: root.effectiveSurfaceColor.a readonly property real surfaceOpacity: root.effectiveSurfaceColor.a
readonly property real currentSurfaceRadius: Math.max(0, motion.currentTopLeftRadius, motion.currentBottomLeftRadius) readonly property real currentSurfaceRadius: Math.max(0, motion.currentTopLeftRadius, motion.currentBottomLeftRadius)
@@ -58,32 +60,51 @@ Item {
readonly property alias fittsStripItem: fittsStrip readonly property alias fittsStripItem: fittsStrip
readonly property bool motionRunning: motion.running readonly property bool motionRunning: motion.running
readonly property real springTimeConstantMs: motion.timeConstantMs readonly property real springTimeConstantMs: motion.timeConstantMs
property real trackedHeight: 0 property real trackedCrossExtent: 0
property real fadeCompactHeight: 48 property real fadeCompactCross: 48
property real fadeExpandedHeight: 352 property real fadeExpandedCross: 352
property rect motionStartBounds: Qt.rect(0, 0, 0, 0) 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 currentVisualWidth: motion.currentWidth
readonly property real currentVisualHeight: motion.currentHeight readonly property real currentVisualHeight: motion.currentHeight
readonly property real currentVisualX: (width - currentVisualWidth) / 2 + motion.currentOffsetX readonly property real currentVisualCross: isVertical ? motion.currentWidth : motion.currentHeight
readonly property real currentVisualY: bottomEdge ? height - motion.currentOffsetY - currentVisualHeight : motion.currentOffsetY readonly property real currentAlongPos: (alongExtent - (isVertical ? currentVisualHeight : currentVisualWidth)) / 2 + motion.currentOffsetAlong
readonly property real targetVisualX: Math.round((width - motion.targetWidth) / 2 + motion.targetOffsetX) readonly property real currentCrossPos: farEdge ? crossExtent - motion.currentOffsetCross - currentVisualCross : motion.currentOffsetCross
readonly property real targetVisualY: bottomEdge ? height - Math.round(motion.targetOffsetY) - motion.targetHeight : Math.round(motion.targetOffsetY) 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 targetScreenY: targetVisualY + root.hostOriginY
readonly property real targetVisualWidth: motion.targetWidth readonly property real targetVisualWidth: motion.targetWidth
readonly property real targetVisualHeight: motion.targetHeight 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: { readonly property real morphProgress: {
const span = fadeExpandedHeight - fadeCompactHeight; const span = fadeExpandedCross - fadeCompactCross;
if (Math.abs(span) < 1) if (Math.abs(span) < 1)
return controller.expanded ? 1 : 0; 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() { function applyTarget() {
if (controller.expanded) if (controller.expanded)
fadeExpandedHeight = controller.expandedTarget.height; fadeExpandedCross = root.descriptorCross(controller.expandedTarget);
else else
fadeCompactHeight = controller.compactTarget.height; fadeCompactCross = root.descriptorCross(controller.compactTarget);
if (motion.running) if (motion.running)
root.unionMotionStartBounds(); root.unionMotionStartBounds();
motion.setTarget(controller.targetDescriptor); motion.setTarget(controller.targetDescriptor);
@@ -105,19 +126,21 @@ Item {
} }
Component.onCompleted: { Component.onCompleted: {
trackedHeight = height; trackedCrossExtent = crossExtent;
fadeCompactHeight = controller.compactTarget.height; fadeCompactCross = root.descriptorCross(controller.compactTarget);
fadeExpandedHeight = controller.expandedTarget.height; fadeExpandedCross = root.descriptorCross(controller.expandedTarget);
motion.snapTo(controller.targetDescriptor); motion.snapTo(controller.targetDescriptor);
} }
onHeightChanged: { // On a far edge the cross coordinate is measured from the far side, so a host resize
const delta = height - trackedHeight; // shifts everything already in flight by the same delta.
trackedHeight = height; onCrossExtentChanged: {
if (!bottomEdge || !motion.running || delta === 0) const delta = crossExtent - trackedCrossExtent;
trackedCrossExtent = crossExtent;
if (!farEdge || !motion.running || delta === 0)
return; return;
const b = motionStartBounds; 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 { Connections {
@@ -222,7 +245,9 @@ Item {
controller: root.controller controller: root.controller
islandX: root.currentVisualX islandX: root.currentVisualX
islandY: root.currentVisualY
hostWidth: root.width hostWidth: root.width
hostHeight: root.height
springTimeConstantMs: root.springTimeConstantMs springTimeConstantMs: root.springTimeConstantMs
morphProgress: root.morphProgress morphProgress: root.morphProgress
expanded: root.controller.expanded expanded: root.controller.expanded
@@ -265,14 +290,17 @@ Item {
Item { Item {
id: fittsStrip id: fittsStrip
readonly property real spanWidth: Math.max(root.controller.compactTarget.width, motion.targetWidth) readonly property real targetCross: root.isVertical ? motion.targetWidth : motion.targetHeight
readonly property real edgeGap: root.bottomEdge ? Math.max(0, root.height - (root.targetVisualY + motion.targetHeight)) : Math.max(0, root.targetVisualY) 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) x: root.isVertical ? crossPos : alongPos
y: root.bottomEdge ? root.targetVisualY + motion.targetHeight : 0 y: root.isVertical ? alongPos : crossPos
width: spanWidth width: root.isVertical ? edgeGap : span
height: edgeGap height: root.isVertical ? span : edgeGap
visible: edgeGap > 0 && spanWidth > 0 visible: edgeGap > 0 && span > 0
HoverHandler { HoverHandler {
id: stripHover id: stripHover
@@ -358,7 +386,7 @@ Item {
launcherController: root.launcherController launcherController: root.launcherController
transientSurfaceTracker: root.launcherTransientSurfaceTracker transientSurfaceTracker: root.launcherTransientSurfaceTracker
effectiveScreen: root.effectiveScreen effectiveScreen: root.effectiveScreen
alignedX: root.targetVisualX alignedX: root.targetScreenX
alignedY: root.targetScreenY alignedY: root.targetScreenY
} }
} }
@@ -380,7 +408,7 @@ Item {
ControlCenterExpanded { ControlCenterExpanded {
controller: root.controller controller: root.controller
effectiveScreen: root.effectiveScreen effectiveScreen: root.effectiveScreen
alignedX: root.targetVisualX alignedX: root.targetScreenX
alignedY: root.targetScreenY alignedY: root.targetScreenY
alignedWidth: root.targetVisualWidth alignedWidth: root.targetVisualWidth
alignedHeight: root.targetVisualHeight alignedHeight: root.targetVisualHeight
@@ -431,6 +459,7 @@ Item {
SystemLevelCompact { SystemLevelCompact {
systemModel: root.systemModel systemModel: root.systemModel
isVertical: root.isVertical
iconSize: root.controller.compactIconSize iconSize: root.controller.compactIconSize
} }
} }
@@ -14,7 +14,9 @@ Item {
required property var controller required property var controller
required property real islandX required property real islandX
required property real islandY
required property real hostWidth required property real hostWidth
required property real hostHeight
required property real springTimeConstantMs required property real springTimeConstantMs
required property real morphProgress required property real morphProgress
required property bool expanded 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 { component CompactFace: Loader {
required property string activity required property string activity
readonly property var target: root.controller.compactTargetFor(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) x: isVertical ? crossPos : alongPos
y: Math.round((parent.height - height) / 2) y: isVertical ? alongPos : crossPos
width: target.width width: target.width
height: target.height height: target.height
asynchronous: false asynchronous: false
@@ -29,14 +29,16 @@ QtObject {
property string controlCenterPendingSection: "" property string controlCenterPendingSection: ""
property bool notificationExpandAllowed: false property bool notificationExpandAllowed: false
property bool keyboardYielded: false property bool keyboardYielded: false
property real horizontalOffset: 0 property real alongOffset: 0
property real outerGap: 8 property real outerGap: 8
readonly property bool bottomEdge: SettingsData.dankIslandEdge === "bottom" property var barConfig: null
readonly property real cornerRadius: Math.max(0, Math.min(64, SettingsData.dankIslandCornerRadius)) 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 edgeCornerRadius: Math.round(cornerRadius * 0.75)
readonly property real topCornerRadius: bottomEdge ? cornerRadius : edgeCornerRadius property real compactThickness: 38
readonly property real bottomCornerRadius: bottomEdge ? edgeCornerRadius : cornerRadius property string batteryStyle: "solid"
property real compactHeight: 38 property bool mediaClockVisible: true
property int transientTimeout: 2200 property int transientTimeout: 2200
property int notificationTimeout: 5000 property int notificationTimeout: 5000
property string transientReturnActivity: "" property string transientReturnActivity: ""
@@ -51,38 +53,38 @@ QtObject {
readonly property real controlCenterHeight: Math.max(320, Math.min(controlCenterMaxHeight, destinationContentHeight("controlcenter"))) 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 real notificationCenterHeight: Math.max(320, Math.min(notificationCenterMaxHeight, destinationContentHeight("notificationcenter")))
readonly property bool compactDense: compactHeight < 40 readonly property bool compactDense: compactThickness < 40
readonly property real compactFaceHeight: compactHeight + (compactDense ? 2 : 4) readonly property real compactFaceThickness: compactThickness + (compactDense ? 2 : 4)
readonly property real compactIconSize: Math.max(14, Math.min(32, compactHeight - 8)) readonly property real compactIconSize: Math.max(14, Math.min(32, compactThickness - 8))
readonly property bool homeCompactTight: SettingsData.dankIslandHomeCompactTight property bool homeCompactTight: false
readonly property real homeCompactFaceHeight: homeCompactTight ? Math.max(16, Math.min(32, compactHeight - 8)) : compactFaceHeight readonly property real homeCompactFaceThickness: homeCompactTight ? Math.max(16, Math.min(32, compactThickness - 8)) : compactFaceThickness
property int unreadNotificationCount: 0 property int unreadNotificationCount: 0
readonly property bool homeNotificationBadge: SettingsData.islandHomeGroupEnabled("notifications") && unreadNotificationCount > 0 readonly property bool homeNotificationBadge: SettingsData.islandHomeGroupEnabled(root.barConfig, "notifications") && unreadNotificationCount > 0
readonly property bool homeWeatherEnabled: SettingsData.weatherEnabled && SettingsData.islandHomeGroupEnabled("weather") readonly property bool homeWeatherEnabled: SettingsData.weatherEnabled && SettingsData.islandHomeGroupEnabled(root.barConfig, "weather")
readonly property real homeSlotMargin: homeCompactTight ? Theme.spacingS : Theme.spacingM readonly property real homeSlotMargin: homeCompactTight ? Theme.spacingS : Theme.spacingM
property real homeContentWidth: 200 property real homeContentLength: 200
property real mediaContentWidth: 360 property real mediaContentLength: 360
property real mediaExpandedHeight: 324 property real mediaExpandedHeight: 324
readonly property real mediaCompactMaxWidth: 360 readonly property real mediaCompactMaxLength: 360
property real notificationContentWidth: 0 property real notificationContentLength: 0
readonly property real notificationCompactMinWidth: compactDense ? 200 : 240 readonly property real notificationCompactMinLength: isVertical ? compactFaceThickness : (compactDense ? 200 : 240)
readonly property real notificationCompactMaxWidth: mediaCompactMaxWidth readonly property real notificationCompactMaxLength: mediaCompactMaxLength
signal sessionStarted(string activityId) signal sessionStarted(string activityId)
function setHomeContentWidth(width) { function setHomeContentLength(length) {
const next = Math.ceil(width); const next = Math.ceil(length);
if (!isFinite(next) || next <= 0 || Math.abs(next - homeContentWidth) < 2) if (!isFinite(next) || next <= 0 || Math.abs(next - homeContentLength) < 2)
return; return;
homeContentWidth = next; homeContentLength = next;
} }
function setNotificationContentWidth(width) { function setNotificationContentLength(length) {
const next = Math.ceil(width); const next = Math.ceil(length);
if (!isFinite(next) || next <= 0 || Math.abs(next - notificationContentWidth) < 2) if (!isFinite(next) || next <= 0 || Math.abs(next - notificationContentLength) < 2)
return; return;
notificationContentWidth = next; notificationContentLength = next;
} }
function setMediaExpandedHeight(height) { function setMediaExpandedHeight(height) {
@@ -92,34 +94,34 @@ QtObject {
mediaExpandedHeight = next; mediaExpandedHeight = next;
} }
function setMediaContentWidth(width) { function setMediaContentLength(length) {
const next = Math.ceil(Math.min(root.mediaCompactMaxWidth, width)); const next = Math.ceil(Math.min(root.mediaCompactMaxLength, length));
if (!isFinite(next) || next <= 0 || Math.abs(next - mediaContentWidth) < 2) if (!isFinite(next) || next <= 0 || Math.abs(next - mediaContentLength) < 2)
return; return;
mediaContentWidth = next; mediaContentLength = next;
} }
readonly property var destinations: ["launcher", "controlcenter", "wallpaper", "weather", "notificationcenter"] readonly property var destinations: ["launcher", "controlcenter", "wallpaper", "weather", "notificationcenter"]
readonly property var blankClickOwners: ["launcher", "controlcenter", "wallpaper", "notificationcenter"] readonly property var blankClickOwners: ["launcher", "controlcenter", "wallpaper", "notificationcenter"]
readonly property var destinationDefaults: ({ readonly property var destinationDefaults: ({
"launcher": { "launcher": {
"contentWidth": 160, "contentLength": 160,
"contentHeight": 0 "contentHeight": 0
}, },
"controlcenter": { "controlcenter": {
"contentWidth": 180, "contentLength": 180,
"contentHeight": 420 "contentHeight": 420
}, },
"wallpaper": { "wallpaper": {
"contentWidth": 150, "contentLength": 150,
"contentHeight": 0 "contentHeight": 0
}, },
"weather": { "weather": {
"contentWidth": 130, "contentLength": 130,
"contentHeight": 0 "contentHeight": 0
}, },
"notificationcenter": { "notificationcenter": {
"contentWidth": 170, "contentLength": 170,
"contentHeight": 320 "contentHeight": 320
} }
}) })
@@ -134,7 +136,7 @@ QtObject {
"ready": false, "ready": false,
"pending": false, "pending": false,
"pendingKeyboardFocus": false, "pendingKeyboardFocus": false,
"contentWidth": destinationDefaults[id].contentWidth, "contentLength": destinationDefaults[id].contentLength,
"contentHeight": destinationDefaults[id].contentHeight "contentHeight": destinationDefaults[id].contentHeight
}; };
} }
@@ -158,20 +160,20 @@ QtObject {
return destinationEntry(activityId)?.ready ?? false; return destinationEntry(activityId)?.ready ?? false;
} }
function destinationContentWidth(activityId) { function destinationContentLength(activityId) {
return destinationEntry(activityId)?.contentWidth ?? 0; return destinationEntry(activityId)?.contentLength ?? 0;
} }
function destinationContentHeight(activityId) { function destinationContentHeight(activityId) {
return destinationEntry(activityId)?.contentHeight ?? 0; return destinationEntry(activityId)?.contentHeight ?? 0;
} }
function setDestinationContentWidth(activityId, width) { function setDestinationContentLength(activityId, length) {
const entry = destinationState[activityId]; const entry = destinationState[activityId];
const next = Math.ceil(width); const next = Math.ceil(length);
if (!entry || !isFinite(next) || next <= 0 || Math.abs(next - entry.contentWidth) < 2) if (!entry || !isFinite(next) || next <= 0 || Math.abs(next - entry.contentLength) < 2)
return; return;
entry.contentWidth = next; entry.contentLength = next;
destinationRevision++; 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) { function destinationCompactLength(activityId) {
return Math.ceil(Math.max(root.compactFaceHeight, destinationContentWidth(activityId) + root.destinationCompactEndPad * 2)); return Math.ceil(Math.max(root.compactFaceThickness, destinationContentLength(activityId) + root.destinationCompactEndPad * 2));
} }
function homeGroups(side) { function homeGroups(side) {
const layout = SettingsData.getIslandHomeLayout(); const layout = SettingsData.getIslandHomeLayout(root.barConfig);
const clock = layout.findIndex(g => g.id === "clock"); const clock = layout.findIndex(g => g.id === "clock");
const groups = side === "left" ? layout.slice(0, clock) : layout.slice(clock + 1); const groups = side === "left" ? layout.slice(0, clock) : layout.slice(clock + 1);
return groups.filter(g => g.enabled).map(g => g.id); 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 homeLeftGroups: homeGroups("left")
readonly property var homeRightGroups: homeGroups("right") readonly property var homeRightGroups: homeGroups("right")
readonly property string homeVolumeDisplay: resolvedSystemLevelDisplay(SettingsData.dankIslandHomeVolumeDisplay) property string homeClockDisplay: "both"
readonly property string homeBrightnessDisplay: resolvedSystemLevelDisplay(SettingsData.dankIslandHomeBrightnessDisplay) property string homeVolumeDisplay: "both"
property string homeBrightnessDisplay: "both"
readonly property real homeCompactWidth: Math.ceil(homeSlotMargin * 2 + Math.max(homeContentWidth, 1)) readonly property real homeCompactLength: Math.ceil(homeSlotMargin * 2 + Math.max(homeContentLength, 1))
readonly property real mediaCompactWidth: Math.ceil(Math.max(1, mediaContentWidth)) readonly property real mediaCompactLength: Math.ceil(Math.max(1, mediaContentLength))
function pillTarget(width, height) { // Corners touching the attached screen edge stay tighter than the ones facing the desktop.
const radius = Math.min(height / 2, root.cornerRadius); 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 { return {
"width": width, "width": root.isVertical ? crossSize : alongSize,
"height": height, "height": root.isVertical ? alongSize : crossSize,
"offsetX": root.horizontalOffset, "offsetAlong": root.alongOffset,
"offsetY": root.outerGap, "offsetCross": root.outerGap,
"topLeftRadius": radius, "topLeftRadius": radius,
"topRightRadius": radius, "topRightRadius": radius,
"bottomLeftRadius": radius, "bottomLeftRadius": radius,
@@ -306,27 +318,28 @@ QtObject {
} }
function sheetTarget(width, height) { function sheetTarget(width, height) {
const radii = root.sheetRadii();
return { return {
"width": width, "width": width,
"height": height, "height": height,
"offsetX": root.horizontalOffset, "offsetAlong": root.alongOffset,
"offsetY": root.outerGap, "offsetCross": root.outerGap,
"topLeftRadius": root.topCornerRadius, "topLeftRadius": radii[0],
"topRightRadius": root.topCornerRadius, "topRightRadius": radii[1],
"bottomLeftRadius": root.bottomCornerRadius, "bottomLeftRadius": radii[2],
"bottomRightRadius": root.bottomCornerRadius "bottomRightRadius": radii[3]
}; };
} }
readonly property var homeCompactTarget: pillTarget(homeCompactWidth, homeCompactFaceHeight) readonly property var homeCompactTarget: pillTarget(homeCompactLength, homeCompactFaceThickness)
readonly property var mediaCompactTarget: pillTarget(mediaCompactWidth, compactFaceHeight) readonly property var mediaCompactTarget: pillTarget(mediaCompactLength, compactFaceThickness)
readonly property var dashSheetTarget: sheetTarget(SettingsData.showWeekNumber ? 736 : 700, 452) readonly property var dashSheetTarget: sheetTarget(SettingsData.showWeekNumber ? 736 : 700, 452)
readonly property var mediaExpandedTarget: sheetTarget(600, mediaExpandedHeight) readonly property var mediaExpandedTarget: sheetTarget(600, mediaExpandedHeight)
readonly property var launcherExpandedTarget: sheetTarget(680, 560) readonly property var launcherExpandedTarget: sheetTarget(680, 560)
readonly property var controlCenterExpandedTarget: sheetTarget(580, controlCenterHeight) 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 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 notificationExpandedTarget: sheetTarget(520, 220)
readonly property var notificationCenterExpandedTarget: sheetTarget(480, notificationCenterHeight) readonly property var notificationCenterExpandedTarget: sheetTarget(480, notificationCenterHeight)
@@ -349,7 +362,7 @@ QtObject {
case "home": case "home":
return homeCompactTarget; return homeCompactTarget;
} }
return isDestination(activityId) ? pillTarget(destinationCompactWidth(activityId), compactFaceHeight) : homeCompactTarget; return isDestination(activityId) ? pillTarget(destinationCompactLength(activityId), compactFaceThickness) : homeCompactTarget;
} }
readonly property var compactTarget: compactTargetFor(activeActivity) readonly property var compactTarget: compactTargetFor(activeActivity)
@@ -7,7 +7,8 @@ Item {
id: root id: root
property bool rightSide: false property bool rightSide: false
property bool bottomEdge: false property bool crossFar: false
property bool isVertical: false
property bool floating: false property bool floating: false
property color fillColor: "transparent" property color fillColor: "transparent"
property bool gothEnabled: true property bool gothEnabled: true
@@ -15,30 +16,37 @@ Item {
property real bodyRadius: Theme.cornerRadius property real bodyRadius: Theme.cornerRadius
property var parentScreen: null property var parentScreen: null
readonly property real cornerR: Math.max(0, Math.min(root.bodyRadius, root.height / 2)) readonly property real alongSize: root.isVertical ? root.height : root.width
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 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: { readonly property string fillPath: {
root.width; root.alongSize;
root.height; root.crossSize;
root.cornerR; root.cornerR;
root.sweepR; root.sweepR;
root.rightSide; root.canonicalRightSide;
root.bottomEdge; root.canonicalBottomEdge;
root.floating; root.floating;
return root.buildPath(); return root.buildPath();
} }
function buildPath() { function buildPath() {
const w = root.width; const w = root.alongSize;
const h = root.height; const h = root.crossSize;
const cr = root.cornerR; const cr = root.cornerR;
const s = root.sweepR; const s = root.sweepR;
if (root.floating) if (root.floating)
return root.bottomEdge ? floatBottomPath(w, h, cr, s) : floatTopPath(w, h, cr, s); return root.canonicalBottomEdge ? floatBottomPath(w, h, cr, s) : floatTopPath(w, h, cr, s);
if (root.bottomEdge) if (root.canonicalBottomEdge)
return root.rightSide ? rightBottomPath(w, h, cr, s) : leftBottomPath(w, h, cr, s); return root.canonicalRightSide ? 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.canonicalRightSide ? rightTopPath(w, h, cr, s) : leftTopPath(w, h, cr, s);
} }
function floatTopPath(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`; 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 { MouseArea {
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
@@ -95,17 +149,26 @@ Item {
} }
} }
Shape { Item {
anchors.fill: parent id: canvas
preferredRendererType: Shape.CurveRenderer
ShapePath { anchors.centerIn: parent
fillColor: root.fillColor width: root.alongSize
strokeColor: "transparent" height: root.crossSize
strokeWidth: 0 rotation: root.isVertical ? (root.crossFar ? 90 : -90) : 0
PathSvg { Shape {
path: root.fillPath 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 hostWindow
required property var targetScreen required property var targetScreen
required property var barConfig
required property IslandController controller required property IslandController controller
required property var islandSurface required property var islandSurface
property real outerGap: 8 property real outerGap: 8
property var hyprlandOverviewLoader: null
readonly property alias leftInputItem: leftInputEnvelope function setting(key) {
readonly property alias rightInputItem: rightInputEnvelope return SettingsData.islandSetting(root.barConfig, key);
readonly property var referenceBarConfig: {
SettingsData.barConfigs;
return SettingsData.islandBarConfig ?? ({});
} }
readonly property alias leadingInputItem: leadingInputEnvelope
readonly property alias trailingInputItem: trailingInputEnvelope
readonly property var referenceBarConfig: root.barConfig ?? ({})
readonly property var satelliteConfig: { readonly property var satelliteConfig: {
const base = SettingsData.effectiveBarConfigForRender(root.referenceBarConfig, false) ?? root.referenceBarConfig ?? ({}); const base = SettingsData.effectiveBarConfigForRender(root.referenceBarConfig, false) ?? root.referenceBarConfig ?? ({});
if (!root.hostWindow?.usesOverlayLayer) if (!root.hostWindow?.usesOverlayLayer)
@@ -38,31 +41,30 @@ Item {
readonly property real widgetThickness: Theme.barWidgetThickness(root.innerPadding, root.screenScale) readonly property real widgetThickness: Theme.barWidgetThickness(root.innerPadding, root.screenScale)
readonly property real barThickness: Theme.barThickness(root.innerPadding, root.screenScale) readonly property real barThickness: Theme.barThickness(root.innerPadding, root.screenScale)
readonly property real satelliteSpacing: root.satelliteConfig?.spacing ?? 4 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 edgeBaseMargin: Math.max(Theme.spacingXS, root.innerPadding * 0.8)
readonly property real edgeInsetRaw: SettingsData.barInsetPaddingSyncAll ? SettingsData.barInsetPaddingShared : (root.satelliteConfig?.barInsetPadding ?? -1) 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 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 bool isVertical: root.controller.isVertical
readonly property real stripHeight: root.hostWindow?.reservedStripHeight ?? (root.outerGap + root.barThickness) readonly property bool crossFar: root.controller.edge === "bottom" || root.controller.edge === "right"
readonly property real rowY: { readonly property real alongExtent: root.isVertical ? root.height : root.width
const centered = Theme.snap((root.stripHeight - root.barThickness) / 2, root.screenScale); readonly property real crossExtent: root.isVertical ? root.width : root.height
return root.bottomEdge ? root.height - root.stripHeight + centered : centered; readonly property real stripThickness: root.hostWindow?.reservedStripThickness ?? (root.outerGap + root.barThickness)
} readonly property real stripStart: root.crossFar ? root.crossExtent - root.stripThickness : 0
readonly property bool backgroundEnabled: SettingsData.dankIslandSatelliteBackground 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 bool spanEdges: root.edgeAligned
readonly property real chromePad: Theme.snap(root.innerPadding + Theme.spacingXS, root.screenScale) readonly property real chromePad: Theme.snap(root.innerPadding + Theme.spacingXS, root.screenScale)
readonly property real chromeInset: root.backgroundEnabled ? root.chromePad : 0 readonly property real chromeInset: root.backgroundEnabled ? root.chromePad : 0
readonly property real chromeY: root.bottomEdge ? root.height - root.stripHeight : 0 readonly property real crossEdgeExtension: root.spanEdges ? (root.crossFar ? root.crossExtent - root.rowCross - root.barThickness : root.rowCross) : 0
readonly property real chromeHeight: root.stripHeight readonly property bool gothCorners: root.setting("islandSatelliteGothCorners")
readonly property real crossEdgeExtension: root.spanEdges ? (root.bottomEdge ? root.height - root.rowY - root.barThickness : root.rowY) : 0 readonly property real chromeOpacity: Math.max(0, Math.min(1, root.setting("islandSatelliteTransparency")))
readonly property bool gothCorners: SettingsData.dankIslandSatelliteGothCorners readonly property real sweepRadius: Math.max(4, Math.min(64, root.setting("islandSatelliteSwoopRadius")))
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 color chromeBase: { readonly property color chromeBase: {
if (SettingsData.dankIslandHighContrast) if (root.setting("islandHighContrast"))
return Theme.surfaceContainerHighest; return Theme.surfaceContainerHighest;
switch (SettingsData.dankIslandPalette) { switch (root.setting("islandPalette")) {
case "bright": case "bright":
return Theme.surfaceBright; return Theme.surfaceBright;
case "dim": case "dim":
@@ -77,13 +79,17 @@ Item {
readonly property bool islandTranslucent: root.islandOpacity > 0 && root.islandOpacity < 1 readonly property bool islandTranslucent: root.islandOpacity > 0 && root.islandOpacity < 1
readonly property bool tracksIsland: !root.edgeAligned && root.visible readonly property bool tracksIsland: !root.edgeAligned && root.visible
readonly property bool motionRunning: root.tracksIsland && (root.islandSurface?.motionRunning ?? false) readonly property bool motionRunning: root.tracksIsland && (root.islandSurface?.motionRunning ?? false)
readonly property real islandStartX: root.islandSurface?.motionStartBounds.x ?? 0 readonly property rect islandStartBounds: root.islandSurface?.motionStartBounds ?? Qt.rect(0, 0, 0, 0)
readonly property real islandStartRight: root.islandStartX + (root.islandSurface?.motionStartBounds.width ?? 0) readonly property real islandStartAlong: root.isVertical ? root.islandStartBounds.y : root.islandStartBounds.x
readonly property real islandTargetX: root.islandSurface?.targetVisualX ?? 0 readonly property real islandStartAlongEnd: root.islandStartAlong + (root.isVertical ? root.islandStartBounds.height : root.islandStartBounds.width)
readonly property real islandTargetRight: root.islandTargetX + (root.islandSurface?.targetVisualWidth ?? 0) 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 leadingEdgeSpread: root.motionRunning ? Math.abs(root.islandStartAlong - root.islandTargetAlong) : 0
readonly property real rightEdgeSpread: root.motionRunning ? Math.abs(root.islandStartRight - root.islandTargetRight) : 0 readonly property real trailingEdgeSpread: root.motionRunning ? Math.abs(root.islandStartAlongEnd - root.islandTargetAlongEnd) : 0
function normalizedWidgets(widgets) { function normalizedWidgets(widgets) {
return (widgets || []).map((widget, index) => { return (widgets || []).map((widget, index) => {
@@ -102,7 +108,7 @@ Item {
}); });
} }
visible: SettingsData.dankIslandSatellitesEnabled visible: root.setting("islandSatellitesEnabled")
function switchWorkspace(direction) { function switchWorkspace(direction) {
componentProvider.switchWorkspace(direction); componentProvider.switchWorkspace(direction);
@@ -111,7 +117,7 @@ Item {
AxisContext { AxisContext {
id: satelliteAxis id: satelliteAxis
edge: SettingsData.dankIslandEdge edge: root.controller.edge
} }
ScriptModel { ScriptModel {
@@ -140,13 +146,13 @@ Item {
property string screenName: root.targetScreen?.name ?? "" property string screenName: root.targetScreen?.name ?? ""
property real widgetThickness: root.widgetThickness property real widgetThickness: root.widgetThickness
property real effectiveBarThickness: root.barThickness property real effectiveBarThickness: root.barThickness
property bool isVertical: false property bool isVertical: root.isVertical
property bool usesFrameBarChrome: false property bool usesFrameBarChrome: false
property bool hasAdjacentTopBar: false property bool hasAdjacentTopBar: false
property bool hasAdjacentBottomBar: false property bool hasAdjacentBottomBar: false
property bool hasAdjacentLeftBar: false property bool hasAdjacentLeftBar: false
property bool hasAdjacentRightBar: false property bool hasAdjacentRightBar: false
property var hyprlandOverviewLoader: null property var hyprlandOverviewLoader: root.hyprlandOverviewLoader
property var controlCenterButtonRef: null property var controlCenterButtonRef: null
property var clockButtonRef: null property var clockButtonRef: null
property var systemUpdateButtonRef: null property var systemUpdateButtonRef: null
@@ -192,7 +198,7 @@ Item {
if (!BlurService.enabled) if (!BlurService.enabled)
return; return;
const widgets = root.chromeCoversWidgets ? [] : root._blurWidgetItems.filter(w => w && w.visible && w.width > 0 && w.height > 0); 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) if (chromes.length === 0 && widgets.length === 0 && !root.islandTranslucent)
return; return;
const region = blurRegionComp.createObject(root.hostWindow); const region = blurRegionComp.createObject(root.hostWindow);
@@ -324,36 +330,31 @@ Item {
property Item chrome 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 x: body.chrome.x
y: body.chrome.y y: body.chrome.y
width: body.chrome.width width: body.chrome.width
height: body.chrome.height height: body.chrome.height
radius: body.cr radius: body.chrome.cornerR
Region { Region {
x: body.x x: body.x + body.chrome.leadCornerSquare.x
y: body.edgeY y: body.y + body.chrome.leadCornerSquare.y
width: body.cr width: body.chrome.leadCornerSquare.width
height: body.cr height: body.chrome.leadCornerSquare.height
} }
Region { Region {
x: body.x + body.width - body.cr x: body.x + body.chrome.trailCornerSquare.x
y: body.edgeY y: body.y + body.chrome.trailCornerSquare.y
width: body.cr width: body.chrome.trailCornerSquare.width
height: body.cr height: body.chrome.trailCornerSquare.height
} }
Region { Region {
x: body.chrome.rightSide ? body.x + body.width - body.cr : body.x x: body.x + body.chrome.sweepCornerSquare.x
y: body.sweepY y: body.y + body.chrome.sweepCornerSquare.y
width: body.sweepSquare ? body.cr : 0 width: body.chrome.sweepCornerSquare.width
height: body.sweepSquare ? body.cr : 0 height: body.chrome.sweepCornerSquare.height
} }
} }
} }
@@ -380,32 +381,21 @@ Item {
property Item chrome property Item chrome
property bool isWing 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: piece.chrome.x + piece.body.x
y: piece.chrome.y + piece.body.y
x: { width: piece.body.width
if (piece.chrome.floating) height: piece.body.height
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
Region { Region {
intersection: Intersection.Subtract intersection: Intersection.Subtract
radius: piece.s radius: piece.chrome.sweepR
width: piece.s * 2 x: piece.chrome.x + piece.disc.x
height: piece.s * 2 y: piece.chrome.y + piece.disc.y
x: piece.x - (piece.leadsIn ? piece.s : 0) width: piece.disc.width
y: piece.y - (piece.chrome.bottomEdge ? piece.s : 0) height: piece.disc.height
} }
} }
} }
@@ -425,72 +415,93 @@ Item {
} }
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) 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)
y: root.spanEdges ? root.chromeY : leftInput.y 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
width: root.spanEdges ? (leftInput.width > 0 ? Math.max(0, leftInput.x + leftInput.width + root.chromeInset) : 0) : leftInput.width + root.leftEdgeSpread + root.chromeInset * 2 readonly property real crossPos: root.spanEdges ? root.stripStart : root.rowCross
height: root.spanEdges ? (leftInput.width > 0 ? root.chromeHeight : 0) : leftInput.height 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 { 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) x: root.isVertical ? crossPos : alongPos
y: root.spanEdges ? root.chromeY : rightInput.y y: root.isVertical ? alongPos : crossPos
width: root.spanEdges ? Math.max(0, root.width - edgeX) : rightInput.width + root.rightEdgeSpread + root.chromeInset * 2 width: root.isVertical ? crossSize : alongSize
height: root.spanEdges ? (rightInput.width > 0 ? root.chromeHeight : 0) : rightInput.height height: root.isVertical ? alongSize : crossSize
} }
IslandSatelliteChrome { 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 floating: !root.edgeAligned
x: root.edgeAligned ? 0 : leftInput.x - root.chromePad x: root.isVertical ? root.stripStart : alongPos
y: root.chromeY y: root.isVertical ? alongPos : root.stripStart
width: leftInput.width > 0 ? (root.edgeAligned ? leftInput.x + leftInput.width + root.chromePad : leftInput.width + root.chromePad * 2) : 0 width: root.isVertical ? root.stripThickness : alongSize
height: root.chromeHeight height: root.isVertical ? alongSize : root.stripThickness
visible: root.backgroundEnabled && leftInput.width > 0 visible: root.backgroundEnabled && leadingInput.alongSize > 0
fillColor: root.chromeColor fillColor: root.chromeColor
gothEnabled: root.gothCorners gothEnabled: root.gothCorners
sweep: root.sweepRadius sweep: root.sweepRadius
bottomEdge: root.bottomEdge isVertical: root.isVertical
crossFar: root.crossFar
parentScreen: root.targetScreen parentScreen: root.targetScreen
onVisibleChanged: blurRebuildTimer.restart() onVisibleChanged: blurRebuildTimer.restart()
} }
IslandSatelliteChrome { 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 rightSide: true
floating: !root.edgeAligned floating: !root.edgeAligned
x: rightInput.x - root.chromePad x: root.isVertical ? root.stripStart : alongPos
y: root.chromeY y: root.isVertical ? alongPos : root.stripStart
width: rightInput.width > 0 ? (root.edgeAligned ? Math.max(0, root.width - x) : rightInput.width + root.chromePad * 2) : 0 width: root.isVertical ? root.stripThickness : alongSize
height: root.chromeHeight height: root.isVertical ? alongSize : root.stripThickness
visible: root.backgroundEnabled && rightInput.width > 0 visible: root.backgroundEnabled && trailingInput.alongSize > 0
fillColor: root.chromeColor fillColor: root.chromeColor
gothEnabled: root.gothCorners gothEnabled: root.gothCorners
sweep: root.sweepRadius sweep: root.sweepRadius
bottomEdge: root.bottomEdge isVertical: root.isVertical
crossFar: root.crossFar
parentScreen: root.targetScreen parentScreen: root.targetScreen
onVisibleChanged: blurRebuildTimer.restart() onVisibleChanged: blurRebuildTimer.restart()
} }
Item { Item {
id: leftInput id: leadingInput
x: root.edgeAligned ? root.edgeInset : Math.round(root.islandSurface.currentVisualX - SettingsData.dankIslandSatelliteGap - width) readonly property real alongPos: root.edgeAligned ? root.edgeInset : Math.round(root.islandCurrentAlong - root.satelliteGap - alongSize)
y: root.rowY readonly property real alongSize: root.visible ? (root.isVertical ? leadingWidgetSection.implicitHeight : leadingWidgetSection.implicitWidth) : 0
width: root.visible ? leftWidgetSection.implicitWidth : 0 readonly property real crossSize: root.visible ? root.barThickness : 0
height: root.visible ? root.barThickness : 0
onXChanged: root.kickBlur() x: root.isVertical ? root.rowCross : alongPos
onWidthChanged: root.kickBlur() y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
LeftSection { LeftSection {
id: leftWidgetSection id: leadingWidgetSection
anchors.fill: parent anchors.fill: parent
objectName: "leftSection" objectName: "leftSection"
@@ -510,17 +521,21 @@ Item {
} }
Item { Item {
id: rightInput id: trailingInput
x: root.edgeAligned ? root.width - root.edgeInset - width : Math.round(root.islandSurface.currentVisualX + root.islandSurface.currentVisualWidth + SettingsData.dankIslandSatelliteGap) readonly property real alongPos: root.edgeAligned ? root.alongExtent - root.edgeInset - alongSize : Math.round(root.islandCurrentAlongEnd + root.satelliteGap)
y: root.rowY readonly property real alongSize: root.visible ? (root.isVertical ? trailingWidgetSection.implicitHeight : trailingWidgetSection.implicitWidth) : 0
width: root.visible ? rightWidgetSection.implicitWidth : 0 readonly property real crossSize: root.visible ? root.barThickness : 0
height: root.visible ? root.barThickness : 0
onXChanged: root.kickBlur() x: root.isVertical ? root.rowCross : alongPos
onWidthChanged: root.kickBlur() y: root.isVertical ? alongPos : root.rowCross
width: root.isVertical ? crossSize : alongSize
height: root.isVertical ? alongSize : crossSize
onAlongPosChanged: root.kickBlur()
onAlongSizeChanged: root.kickBlur()
RightSection { RightSection {
id: rightWidgetSection id: trailingWidgetSection
anchors.fill: parent anchors.fill: parent
objectName: "rightSection" objectName: "rightSection"
@@ -20,8 +20,8 @@ QtObject {
property real currentWidth: 176 property real currentWidth: 176
property real currentHeight: 42 property real currentHeight: 42
property real currentOffsetX: 0 property real currentOffsetAlong: 0
property real currentOffsetY: 8 property real currentOffsetCross: 8
property real currentTopLeftRadius: 21 property real currentTopLeftRadius: 21
property real currentTopRightRadius: 21 property real currentTopRightRadius: 21
property real currentBottomLeftRadius: 21 property real currentBottomLeftRadius: 21
@@ -29,8 +29,8 @@ QtObject {
property real targetWidth: currentWidth property real targetWidth: currentWidth
property real targetHeight: currentHeight property real targetHeight: currentHeight
property real targetOffsetX: currentOffsetX property real targetOffsetAlong: currentOffsetAlong
property real targetOffsetY: currentOffsetY property real targetOffsetCross: currentOffsetCross
property real targetTopLeftRadius: currentTopLeftRadius property real targetTopLeftRadius: currentTopLeftRadius
property real targetTopRightRadius: currentTopRightRadius property real targetTopRightRadius: currentTopRightRadius
property real targetBottomLeftRadius: currentBottomLeftRadius property real targetBottomLeftRadius: currentBottomLeftRadius
@@ -38,15 +38,15 @@ QtObject {
property real velocityWidth: 0 property real velocityWidth: 0
property real velocityHeight: 0 property real velocityHeight: 0
property real velocityOffsetX: 0 property real velocityOffsetAlong: 0
property real velocityOffsetY: 0 property real velocityOffsetCross: 0
property real velocityTopLeftRadius: 0 property real velocityTopLeftRadius: 0
property real velocityTopRightRadius: 0 property real velocityTopRightRadius: 0
property real velocityBottomLeftRadius: 0 property real velocityBottomLeftRadius: 0
property real velocityBottomRightRadius: 0 property real velocityBottomRightRadius: 0
function matchesTarget(target) { 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) { function setTarget(target) {
@@ -54,8 +54,8 @@ QtObject {
return; return;
targetWidth = target.width; targetWidth = target.width;
targetHeight = target.height; targetHeight = target.height;
targetOffsetX = target.offsetX; targetOffsetAlong = target.offsetAlong;
targetOffsetY = target.offsetY; targetOffsetCross = target.offsetCross;
targetTopLeftRadius = target.topLeftRadius; targetTopLeftRadius = target.topLeftRadius;
targetTopRightRadius = target.topRightRadius; targetTopRightRadius = target.topRightRadius;
targetBottomLeftRadius = target.bottomLeftRadius; targetBottomLeftRadius = target.bottomLeftRadius;
@@ -73,8 +73,8 @@ QtObject {
function snapTo(target) { function snapTo(target) {
targetWidth = target.width; targetWidth = target.width;
targetHeight = target.height; targetHeight = target.height;
targetOffsetX = target.offsetX; targetOffsetAlong = target.offsetAlong;
targetOffsetY = target.offsetY; targetOffsetCross = target.offsetCross;
targetTopLeftRadius = target.topLeftRadius; targetTopLeftRadius = target.topLeftRadius;
targetTopRightRadius = target.topRightRadius; targetTopRightRadius = target.topRightRadius;
targetBottomLeftRadius = target.bottomLeftRadius; targetBottomLeftRadius = target.bottomLeftRadius;
@@ -82,8 +82,8 @@ QtObject {
currentWidth = target.width; currentWidth = target.width;
currentHeight = target.height; currentHeight = target.height;
currentOffsetX = target.offsetX; currentOffsetAlong = target.offsetAlong;
currentOffsetY = target.offsetY; currentOffsetCross = target.offsetCross;
currentTopLeftRadius = target.topLeftRadius; currentTopLeftRadius = target.topLeftRadius;
currentTopRightRadius = target.topRightRadius; currentTopRightRadius = target.topRightRadius;
currentBottomLeftRadius = target.bottomLeftRadius; currentBottomLeftRadius = target.bottomLeftRadius;
@@ -91,8 +91,8 @@ QtObject {
velocityWidth = 0; velocityWidth = 0;
velocityHeight = 0; velocityHeight = 0;
velocityOffsetX = 0; velocityOffsetAlong = 0;
velocityOffsetY = 0; velocityOffsetCross = 0;
velocityTopLeftRadius = 0; velocityTopLeftRadius = 0;
velocityTopRightRadius = 0; velocityTopRightRadius = 0;
velocityBottomLeftRadius = 0; velocityBottomLeftRadius = 0;
@@ -101,16 +101,16 @@ QtObject {
} }
function isSettled() { 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 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(velocityOffsetX) <= velocityEpsilon && Math.abs(velocityOffsetY) <= velocityEpsilon && Math.abs(velocityTopLeftRadius) <= velocityEpsilon && Math.abs(velocityTopRightRadius) <= velocityEpsilon && Math.abs(velocityBottomLeftRadius) <= velocityEpsilon && Math.abs(velocityBottomRightRadius) <= velocityEpsilon; 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; return positionSettled && velocitySettled;
} }
function settle() { function settle() {
currentWidth = targetWidth; currentWidth = targetWidth;
currentHeight = targetHeight; currentHeight = targetHeight;
currentOffsetX = targetOffsetX; currentOffsetAlong = targetOffsetAlong;
currentOffsetY = targetOffsetY; currentOffsetCross = targetOffsetCross;
currentTopLeftRadius = targetTopLeftRadius; currentTopLeftRadius = targetTopLeftRadius;
currentTopRightRadius = targetTopRightRadius; currentTopRightRadius = targetTopRightRadius;
currentBottomLeftRadius = targetBottomLeftRadius; currentBottomLeftRadius = targetBottomLeftRadius;
@@ -118,8 +118,8 @@ QtObject {
velocityWidth = 0; velocityWidth = 0;
velocityHeight = 0; velocityHeight = 0;
velocityOffsetX = 0; velocityOffsetAlong = 0;
velocityOffsetY = 0; velocityOffsetCross = 0;
velocityTopLeftRadius = 0; velocityTopLeftRadius = 0;
velocityTopRightRadius = 0; velocityTopRightRadius = 0;
velocityBottomLeftRadius = 0; velocityBottomLeftRadius = 0;
@@ -142,8 +142,8 @@ QtObject {
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps; i++) {
velocityWidth += (stiffness * (targetWidth - currentWidth) - damping * velocityWidth) * inverseMass * step; velocityWidth += (stiffness * (targetWidth - currentWidth) - damping * velocityWidth) * inverseMass * step;
velocityHeight += (stiffness * (targetHeight - currentHeight) - damping * velocityHeight) * inverseMass * step; velocityHeight += (stiffness * (targetHeight - currentHeight) - damping * velocityHeight) * inverseMass * step;
velocityOffsetX += (stiffness * (targetOffsetX - currentOffsetX) - damping * velocityOffsetX) * inverseMass * step; velocityOffsetAlong += (stiffness * (targetOffsetAlong - currentOffsetAlong) - damping * velocityOffsetAlong) * inverseMass * step;
velocityOffsetY += (stiffness * (targetOffsetY - currentOffsetY) - damping * velocityOffsetY) * inverseMass * step; velocityOffsetCross += (stiffness * (targetOffsetCross - currentOffsetCross) - damping * velocityOffsetCross) * inverseMass * step;
velocityTopLeftRadius += (stiffness * (targetTopLeftRadius - currentTopLeftRadius) - damping * velocityTopLeftRadius) * inverseMass * step; velocityTopLeftRadius += (stiffness * (targetTopLeftRadius - currentTopLeftRadius) - damping * velocityTopLeftRadius) * inverseMass * step;
velocityTopRightRadius += (stiffness * (targetTopRightRadius - currentTopRightRadius) - damping * velocityTopRightRadius) * inverseMass * step; velocityTopRightRadius += (stiffness * (targetTopRightRadius - currentTopRightRadius) - damping * velocityTopRightRadius) * inverseMass * step;
velocityBottomLeftRadius += (stiffness * (targetBottomLeftRadius - currentBottomLeftRadius) - damping * velocityBottomLeftRadius) * inverseMass * step; velocityBottomLeftRadius += (stiffness * (targetBottomLeftRadius - currentBottomLeftRadius) - damping * velocityBottomLeftRadius) * inverseMass * step;
@@ -151,8 +151,8 @@ QtObject {
currentWidth += velocityWidth * step; currentWidth += velocityWidth * step;
currentHeight += velocityHeight * step; currentHeight += velocityHeight * step;
currentOffsetX += velocityOffsetX * step; currentOffsetAlong += velocityOffsetAlong * step;
currentOffsetY += velocityOffsetY * step; currentOffsetCross += velocityOffsetCross * step;
currentTopLeftRadius += velocityTopLeftRadius * step; currentTopLeftRadius += velocityTopLeftRadius * step;
currentTopRightRadius += velocityTopRightRadius * step; currentTopRightRadius += velocityTopRightRadius * step;
currentBottomLeftRadius += velocityBottomLeftRadius * step; currentBottomLeftRadius += velocityBottomLeftRadius * step;
+1 -1
View File
@@ -96,7 +96,7 @@ Item {
return toplevel.activated; return toplevel.activated;
} else if (appData.type === "grouped") { } else if (appData.type === "grouped") {
// For grouped apps, check if any window is focused // 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++) { for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i]; const toplevel = allToplevels[i];
if (toplevel.appId === appData.appId && toplevel.activated) { if (toplevel.appId === appData.appId && toplevel.activated) {
+1 -1
View File
@@ -262,7 +262,7 @@ Item {
const separatePinnedAndRunning = SettingsData.dockSeparatePinnedAndRunningApps; const separatePinnedAndRunning = SettingsData.dockSeparatePinnedAndRunningApps;
sortedToplevels.forEach((toplevel, index) => { sortedToplevels.forEach((toplevel, index) => {
let uniqueKey = "window_" + index; let uniqueKey = toplevel.aqueousKey || "window_" + index;
if (CompositorService.isHyprland && Hyprland.toplevels) { if (CompositorService.isHyprland && Hyprland.toplevels) {
const hyprlandToplevels = Array.from(Hyprland.toplevels.values); const hyprlandToplevels = Array.from(Hyprland.toplevels.values);
for (let i = 0; i < hyprlandToplevels.length; i++) { for (let i = 0; i < hyprlandToplevels.length; i++) {
+5 -2
View File
@@ -249,7 +249,7 @@ Item {
readonly property bool shouldHideForWindows: { readonly property bool shouldHideForWindows: {
if (!SettingsData.dockSmartAutoHide) if (!SettingsData.dockSmartAutoHide)
return false; return false;
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous)
return false; return false;
const screenName = dock.modelData?.name ?? ""; const screenName = dock.modelData?.name ?? "";
@@ -313,6 +313,9 @@ Item {
return false; return false;
} }
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.overlapsDock(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight);
if (CompositorService.isMango) { if (CompositorService.isMango) {
MangoService.windows; MangoService.windows;
MangoService.outputs; MangoService.outputs;
@@ -348,7 +351,7 @@ Item {
onTriggered: dock.startupRevealDone = true 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 readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
onOverviewRevealChanged: { onOverviewRevealChanged: {
+1 -1
View File
@@ -87,7 +87,7 @@ PanelWindow {
readonly property bool _connectedActive: CompositorService.usesConnectedFrameChromeForScreen(win.targetScreen) readonly property bool _connectedActive: CompositorService.usesConnectedFrameChromeForScreen(win.targetScreen)
readonly property bool _dockHostedHere: { 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) if (!win._connectedActive || !dockVisible || SettingsData.dockUseOverlayLayer)
return false; return false;
const screens = SettingsData.getFilteredScreens("dock"); const screens = SettingsData.getFilteredScreens("dock");
+82 -41
View File
@@ -834,6 +834,8 @@ Item {
} }
Rectangle { Rectangle {
id: passwordBox
property bool showPassword: false property bool showPassword: false
Layout.fillWidth: true Layout.fillWidth: true
@@ -1230,61 +1232,100 @@ Item {
} }
} }
StyledText { Item {
id: passwordDisplay 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.left: lockIconContainer.right
anchors.leftMargin: Theme.spacingM 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.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.rightMargin: 2
anchors.verticalCenter: parent.verticalCenter anchors.top: parent.top
text: { anchors.bottom: parent.bottom
if (demoMode) { anchors.topMargin: Theme.spacingXS
return "••••••••"; anchors.bottomMargin: Theme.spacingXS
}
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
clip: true clip: true
elide: Text.ElideNone
horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft
Behavior on opacity { onWidthChanged: followCursor()
NumberAnimation { onHeightChanged: followCursor()
duration: Theme.mediumDuration
easing.type: Theme.standardEasing 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 { DankTextCursor {
id: passwordCursorMetrics id: passwordCursor
font: passwordDisplay.font
text: passwordDisplay.text.slice(0, passwordField.cursorPosition)
}
DankTextCursor { x: passwordDisplay.x + passwordDisplay.cursorRectangle.x
id: passwordCursor 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) Connections {
anchors.verticalCenter: parent.verticalCenter target: passwordField
height: passwordDisplay.font.pixelSize + 4
shown: !demoMode && passwordField.activeFocus && !pam.passwd.active && !pam.u2fPending && !root.unlocking
Connections { function onCursorPositionChanged() {
target: passwordField passwordDisplay.cursorPosition = passwordField.cursorPosition;
passwordCursor.resetBlink();
}
function onCursorPositionChanged() { function onTextChanged() {
passwordCursor.resetBlink(); passwordCursor.resetBlink();
} }
function onTextChanged() {
passwordCursor.resetBlink();
} }
} }
} }
@@ -1035,7 +1035,7 @@ PanelWindow {
iconSize: compactMode ? 14 : 16 iconSize: compactMode ? 14 : 16
buttonSize: compactMode ? 20 : 24 buttonSize: compactMode ? 20 : 24
z: 15 z: 15
visible: (SettingsData.notificationPopupPrivacyMode || win.bodyClickInvokesAction) && win.hasExpandableBody visible: win.hasExpandableBody && (SettingsData.notificationPopupPrivacyMode || win.bodyClickInvokesAction || bodyText.hasMoreText || win.descriptionExpanded)
onClicked: { onClicked: {
if (win.hasExpandableBody) if (win.hasExpandableBody)
+15 -2
View File
@@ -18,6 +18,7 @@ Item {
property bool isMiracle: CompositorService.isMiracle property bool isMiracle: CompositorService.isMiracle
property bool isMango: CompositorService.isMango property bool isMango: CompositorService.isMango
property bool isLabwc: CompositorService.isLabwc property bool isLabwc: CompositorService.isLabwc
property bool isAqueous: CompositorService.isAqueous
property string compositorName: { property string compositorName: {
if (isHyprland) if (isHyprland)
@@ -32,6 +33,8 @@ Item {
return "mangowc"; return "mangowc";
if (isLabwc) if (isLabwc)
return "labwc"; return "labwc";
if (isAqueous)
return "aqueous";
return "niri"; return "niri";
} }
@@ -48,6 +51,8 @@ Item {
return "/assets/mango.png"; return "/assets/mango.png";
if (isLabwc) if (isLabwc)
return "/assets/labwc.png"; return "/assets/labwc.png";
if (isAqueous)
return "/assets/aqueous.svg";
return "/assets/niri.svg"; return "/assets/niri.svg";
} }
@@ -64,6 +69,8 @@ Item {
return "https://github.com/DreamMaoMao/mangowc"; return "https://github.com/DreamMaoMao/mangowc";
if (isLabwc) if (isLabwc)
return "https://labwc.github.io/"; return "https://labwc.github.io/";
if (isAqueous)
return "";
return "https://github.com/niri-wm/niri"; return "https://github.com/niri-wm/niri";
} }
@@ -80,6 +87,8 @@ Item {
return I18n.tr("mangowc GitHub"); return I18n.tr("mangowc GitHub");
if (isLabwc) if (isLabwc)
return I18n.tr("LabWC Website"); return I18n.tr("LabWC Website");
if (isAqueous)
return "Aqueous";
return I18n.tr("niri GitHub"); return I18n.tr("niri GitHub");
} }
@@ -376,11 +385,15 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: compositorUrl === "" ? Qt.ArrowCursor : Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
onEntered: parent.hovered = true onEntered: parent.hovered = true
onExited: parent.hovered = false 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. // The selected instance is itself the island, so bar-surface settings do not apply to it.
readonly property bool selectedBarIsIsland: { readonly property bool selectedBarIsIsland: {
SettingsData.dankIslandBarId; SettingsData.barConfigs;
selectedBarId; selectedBarId;
return !!selectedBarId && selectedBarId === SettingsData.dankIslandBarId; return SettingsData.isIslandBarConfig(selectedBarConfig);
} }
readonly property int islandShadowedScreenCount: { readonly property int islandShadowedScreenCount: {
SettingsData.dankIslandBarId;
SettingsData.barConfigs; SettingsData.barConfigs;
if (!selectedBarConfig || dankBarTab.selectedBarIsIsland) if (!selectedBarConfig || dankBarTab.selectedBarIsIsland)
return 0; return 0;
@@ -84,14 +83,12 @@ Item {
readonly property bool selectedBarFrameStyled: SettingsData.frameEnabled && dankBarTab.frameStyledScreenCount === dankBarTab.selectedBarScreens.length 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. // 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 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: { readonly property var positionChoices: {
SettingsData.dankIslandBarId;
SettingsData.barConfigs; SettingsData.barConfigs;
Quickshell.screens; Quickshell.screens;
selectedBarId; selectedBarId;
const all = [SettingsData.Position.Top, SettingsData.Position.Bottom, SettingsData.Position.Left, SettingsData.Position.Right]; 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) if (!selectedBarConfig)
return all; return all;
const screens = dankBarTab.selectedBarScreens; const screens = dankBarTab.selectedBarScreens;
@@ -102,6 +99,8 @@ Item {
if (pos === current) if (pos === current)
return true; return true;
const edge = SettingsData.positionToSide(pos); 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)); return !screens.every(screen => SettingsData.dankIslandOwnsEdge(screen, edge));
}); });
} }
@@ -254,14 +253,14 @@ Item {
function deleteBar(barId) { function deleteBar(barId) {
if (barId === "default") if (barId === "default")
return; return;
if (SettingsData.getBarKindConfigs().length <= 1 && barId !== SettingsData.dankIslandBarId) if (SettingsData.getBarKindConfigs().length <= 1 && !SettingsData.isIslandBarConfig(SettingsData.getBarConfig(barId)))
return; return;
SettingsData.deleteBarConfig(barId); SettingsData.deleteBarConfig(barId);
selectedBarId = "default"; selectedBarId = "default";
} }
function toggleBarEnabled(barId) { function toggleBarEnabled(barId) {
if (barId === "default" && barId !== SettingsData.dankIslandBarId) if (barId === "default" && !SettingsData.isIslandBarConfig(SettingsData.getBarConfig(barId)))
return; return;
const config = SettingsData.getBarConfig(barId); const config = SettingsData.getBarConfig(barId);
if (!config) if (!config)
@@ -502,7 +501,7 @@ Item {
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
visible: SettingsData.dankIslandBarId === barCard.modelData.id visible: SettingsData.isIslandBarConfig(barCard.modelData)
} }
StyledText { StyledText {
@@ -511,7 +510,7 @@ Item {
font.weight: Font.Medium font.weight: Font.Medium
color: Theme.primary color: Theme.primary
horizontalAlignment: Text.AlignLeft 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) backgroundColor: Theme.withAlpha(Theme.error, 0.15)
iconColor: Theme.error iconColor: Theme.error
visible: barCard.modelData.id !== "default" 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 anchors.verticalCenter: parent.verticalCenter
onClicked: dankBarTab.deleteBar(barCard.modelData.id) onClicked: dankBarTab.deleteBar(barCard.modelData.id)
} }
@@ -581,16 +580,16 @@ Item {
target: "island" target: "island"
visible: dankBarTab.selectedBarIsIsland visible: dankBarTab.selectedBarIsIsland
parentModal: dankBarTab.parentModal parentModal: dankBarTab.parentModal
section: "dankIslandPlacement" section: "islandPlacement"
settingLabel: I18n.tr("Island placement") 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 { SettingsControlledBy {
target: "island" target: "island"
visible: dankBarTab.islandShadowedScreenCount > 0 visible: dankBarTab.islandShadowedScreenCount > 0
parentModal: dankBarTab.parentModal parentModal: dankBarTab.parentModal
section: "dankIslandPlacement" section: "islandPlacement"
settingLabel: I18n.tr("%1 position").arg(dankBarTab.positionLabel(selectedBarConfig?.position ?? SettingsData.Position.Top)) 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") 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" target: "island"
visible: dankBarTab.islandOwnsSelectedBarTop visible: dankBarTab.islandOwnsSelectedBarTop
parentModal: dankBarTab.parentModal parentModal: dankBarTab.parentModal
section: "dankIslandPlacement" section: "islandPlacement"
settingLabel: I18n.tr("Bar visibility") settingLabel: I18n.tr("Bar visibility")
reason: I18n.tr("Additional settings are managed by the Island") reason: I18n.tr("Additional settings are managed by the Island")
} }
@@ -875,15 +874,15 @@ Item {
height: 1 height: 1
color: Theme.outline color: Theme.outline
opacity: 0.15 opacity: 0.15
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "barOpenOnOverview" settingKey: "barOpenOnOverview"
tags: ["bar", "overview", "niri", "show", "frame"] tags: ["bar", "overview", "niri", "show", "frame"]
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
text: I18n.tr("Show on Overview") 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) checked: dankBarTab.selectedBarFrameStyled ? SettingsData.frameShowOnOverview : (selectedBarConfig?.openOnOverview ?? false)
onToggled: toggled => { onToggled: toggled => {
if (dankBarTab.selectedBarFrameStyled) { if (dankBarTab.selectedBarFrameStyled) {
@@ -1813,12 +1812,13 @@ Item {
visible: dankBarTab.appearanceOnly && (selectedBarConfig?.enabled ?? false) && !dankBarTab.selectedBarFrameSanitized && !dankBarTab.islandOwnsSelectedBarTop visible: dankBarTab.appearanceOnly && (selectedBarConfig?.enabled ?? false) && !dankBarTab.selectedBarFrameSanitized && !dankBarTab.islandOwnsSelectedBarTop
readonly property bool shadowActive: (selectedBarConfig?.shadowIntensity ?? 0) > 0 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 bool isCustomColor: (selectedBarConfig?.shadowColorMode ?? "default") === "custom"
readonly property string directionSource: selectedBarConfig?.shadowDirectionMode ?? "inherit" readonly property string directionSource: selectedBarConfig?.shadowDirectionMode ?? "inherit"
StyledText { StyledText {
width: parent.width 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 font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
+195 -153
View File
@@ -15,9 +15,8 @@ Item {
readonly property var instanceChoices: { readonly property var instanceChoices: {
SettingsData.barConfigs; SettingsData.barConfigs;
SettingsData.dankIslandBarId;
const rows = []; const rows = [];
const configs = SettingsData.barConfigs || []; const configs = SettingsData.islandBarConfigs || [];
for (let i = 0; i < configs.length; i++) for (let i = 0; i < configs.length; i++)
rows.push({ rows.push({
"id": configs[i].id, "id": configs[i].id,
@@ -25,7 +24,36 @@ Item {
}); });
return rows; 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 systemLevelDisplayValues: ["icon", "percentage", "both"]
readonly property var paletteValues: ["default", "bright", "dim"] readonly property var paletteValues: ["default", "bright", "dim"]
readonly property var batteryStyleValues: ["solid", "outline", "ring"] readonly property var batteryStyleValues: ["solid", "outline", "ring"]
@@ -55,32 +83,32 @@ Item {
width: parent.width width: parent.width
iconName: "view_in_ar" iconName: "view_in_ar"
title: I18n.tr("Dank Island", "island settings: page title") title: I18n.tr("Dank Island", "island settings: page title")
settingKey: "dankIslandInstance" settingKey: "islandInstance"
tags: ["island", "layout", "standard", "frame", "mode", "bar"] tags: ["island", "layout", "standard", "frame", "mode", "bar"]
SettingsLayoutPicker {} SettingsLayoutPicker {}
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandBarId" settingKey: "islandInstanceSelect"
tags: ["island", "activities", "media", "notifications", "osd", "bar"] tags: ["island", "activities", "media", "notifications", "osd", "bar"]
visible: !!SettingsData.dankIslandBarId && SettingsData.barConfigs.length > 1 visible: root.instanceChoices.length > 1
text: I18n.tr("Island Instance", "island settings: which bar config the island replaces") text: I18n.tr("Island Instance", "island settings: which island instance these settings edit")
model: root.instanceChoices.map(row => row.name) model: root.instanceChoices.map(row => row.name)
currentIndex: root.instanceIndex currentIndex: root.instanceIndex
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (selected) if (selected)
SettingsData.setIslandBarId(root.instanceChoices[index]?.id ?? ""); SettingsUiState.selectedBarId = root.instanceChoices[index]?.id ?? "";
} }
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandEnable" settingKey: "islandEnable"
tags: ["island", "enable", "show", "hide"] tags: ["island", "enable", "show", "hide"]
visible: !!SettingsData.dankIslandBarId visible: !!root.selectedIslandId
text: I18n.tr("Enable Island") text: I18n.tr("Enable Island")
description: I18n.tr("Toggle the Island on this instance's displays") description: I18n.tr("Toggle the Island on this instance's displays")
checked: SettingsData.dankIslandEnabled checked: root.islandEnabled
onToggled: checked => SettingsData.updateBarConfig(SettingsData.dankIslandBarId, { onToggled: checked => SettingsData.updateBarConfig(root.selectedIslandId, {
enabled: checked enabled: checked
}) })
} }
@@ -90,15 +118,15 @@ Item {
width: parent.width width: parent.width
iconName: "monitor" iconName: "monitor"
title: I18n.tr("Display Assignment") title: I18n.tr("Display Assignment")
settingKey: "dankIslandDisplays" settingKey: "islandDisplays"
collapsible: true collapsible: true
expanded: false expanded: false
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsDisplayPicker { SettingsDisplayPicker {
emptyMeansAll: false emptyMeansAll: false
displayPreferences: SettingsData.islandBarConfig?.screenPreferences ?? ["all"] displayPreferences: root.config?.screenPreferences ?? ["all"]
onPreferencesChanged: prefs => SettingsData.updateBarConfig(SettingsData.dankIslandBarId, { onPreferencesChanged: prefs => SettingsData.updateBarConfig(root.selectedIslandId, {
screenPreferences: prefs screenPreferences: prefs
}) })
} }
@@ -108,58 +136,58 @@ Item {
width: parent.width width: parent.width
iconName: "open_with" iconName: "open_with"
title: I18n.tr("Position", "island settings: position card title") title: I18n.tr("Position", "island settings: position card title")
settingKey: "dankIslandPlacement" settingKey: "islandPlacement"
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandFloating" settingKey: "islandFloating"
tags: ["island", "placement", "float", "overlay", "exclusive", "reserve"] tags: ["island", "placement", "float", "overlay", "exclusive", "reserve"]
text: I18n.tr("Float", "island settings: float toggle") text: I18n.tr("Float", "island settings: float toggle")
description: I18n.tr("Floats above windows and other content", "island settings: float toggle description") description: I18n.tr("Floats above windows and other content", "island settings: float toggle description")
checked: SettingsData.dankIslandFloating checked: root.setting("islandFloating")
onToggled: checked => SettingsData.set("dankIslandFloating", checked) onToggled: checked => root.apply("islandFloating", checked)
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandUseOverlayLayer" settingKey: "islandUseOverlayLayer"
tags: ["island", "fullscreen", "overlay", "layer"] tags: ["island", "fullscreen", "overlay", "layer"]
text: I18n.tr("Use Overlay Layer", "island layer toggle: use Wayland 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") description: I18n.tr("Stage the Wayland overlay layer to remain visible over fullscreen apps", "island settings: overlay layer description")
checked: SettingsData.dankIslandUseOverlayLayer checked: root.setting("islandUseOverlayLayer")
onToggled: checked => SettingsData.set("dankIslandUseOverlayLayer", checked) onToggled: checked => root.apply("islandUseOverlayLayer", checked)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandReserveHeight" settingKey: "islandReserveThickness"
tags: ["island", "placement", "reservation", "exclusive", "height"] tags: ["island", "placement", "reservation", "exclusive", "height", "width", "thickness"]
text: I18n.tr("Reserved Height", "island settings: reserved strip height slider") 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") description: I18n.tr("Space kept clear for compact island and satellites", "island settings: reserved height description")
unit: "px" unit: "px"
minimum: 24 minimum: 24
maximum: 128 maximum: 128
step: 1 step: 1
defaultValue: 40 defaultValue: 40
value: SettingsData.dankIslandReserveHeight value: root.setting("islandReserveThickness")
enabled: !SettingsData.dankIslandFloating enabled: !root.setting("islandFloating")
onSliderValueChanged: value => SettingsData.set("dankIslandReserveHeight", value) onSliderValueChanged: value => root.apply("islandReserveThickness", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandCompactHeight" settingKey: "islandCompactThickness"
tags: ["island", "placement", "compact", "height", "size", "satellite"] tags: ["island", "placement", "compact", "height", "width", "thickness", "size", "satellite"]
text: I18n.tr("Compact Island Height", "island settings: compact pill height slider") 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") description: I18n.tr("Resize the resting island to align with nearby widgets", "island settings: compact height description")
unit: "px" unit: "px"
minimum: 24 minimum: 24
maximum: 72 maximum: 72
step: 1 step: 1
defaultValue: 38 defaultValue: 38
value: SettingsData.dankIslandCompactHeight value: root.setting("islandCompactThickness")
onSliderValueChanged: value => SettingsData.set("dankIslandCompactHeight", value) onSliderValueChanged: value => root.apply("islandCompactThickness", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandOuterGap" settingKey: "islandOuterGap"
tags: ["island", "placement", "gap", "top", "margin"] tags: ["island", "placement", "gap", "top", "margin"]
text: I18n.tr("Outer Gaps", "island settings: gap between screen edge and island") 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") description: I18n.tr("Distance between display edge and island", "island settings: outer gap description")
@@ -168,22 +196,22 @@ Item {
maximum: 48 maximum: 48
step: 1 step: 1
defaultValue: 4 defaultValue: 4
value: SettingsData.dankIslandOuterGap value: root.setting("islandOuterGap")
onSliderValueChanged: value => SettingsData.set("dankIslandOuterGap", value) onSliderValueChanged: value => root.apply("islandOuterGap", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandHorizontalOffset" settingKey: "islandAlongOffset"
tags: ["island", "placement", "horizontal", "offset", "center"] tags: ["island", "placement", "horizontal", "vertical", "offset", "center"]
text: I18n.tr("Horizontal Offset", "island settings: horizontal offset slider") text: !root.isVertical ? I18n.tr("Horizontal Offset", "island settings: horizontal offset slider") : I18n.tr("Vertical Offset", "island settings: vertical offset slider")
description: I18n.tr("Move island and satellites left or right from display center", "island settings: horizontal offset description") 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" unit: "px"
minimum: -600 minimum: -600
maximum: 600 maximum: 600
step: 1 step: 1
defaultValue: 0 defaultValue: 0
value: SettingsData.dankIslandHorizontalOffset value: root.setting("islandAlongOffset")
onSliderValueChanged: value => SettingsData.set("dankIslandHorizontalOffset", value) onSliderValueChanged: value => root.apply("islandAlongOffset", value)
} }
} }
@@ -191,8 +219,8 @@ Item {
width: parent.width width: parent.width
iconName: "home" iconName: "home"
title: I18n.tr("Home Compact", "island settings: home face card title") title: I18n.tr("Home Compact", "island settings: home face card title")
settingKey: "dankIslandActivities" settingKey: "islandActivities"
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
StyledText { StyledText {
width: parent.width width: parent.width
@@ -204,41 +232,55 @@ Item {
IslandHomeLayoutEditor { IslandHomeLayoutEditor {
width: parent.width width: parent.width
barId: root.selectedIslandId
} }
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandHomeVolumeDisplay" settingKey: "islandHomeClockDisplay"
tags: ["island", "home", "compact", "volume", "icon", "percentage"] tags: ["island", "home", "compact", "clock", "time", "date"]
text: I18n.tr("Volume Style", "island settings: volume display mode row") text: I18n.tr("Clock Style", "island settings: clock display mode row")
visible: SettingsData.islandHomeGroupEnabled("volume") description: I18n.tr("Current time and date pill", "island settings: clock display mode description")
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")] 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.systemLevelDisplayValues, SettingsData.dankIslandHomeVolumeDisplay, "both") currentIndex: root.valueIndex(root.clockDisplayValues, root.setting("islandHomeClockDisplay"), "both")
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (selected) if (selected)
SettingsData.set("dankIslandHomeVolumeDisplay", root.systemLevelDisplayValues[index] ?? "both"); root.apply("islandHomeClockDisplay", root.clockDisplayValues[index] ?? "both");
} }
} }
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandHomeBrightnessDisplay" settingKey: "islandHomeVolumeDisplay"
tags: ["island", "home", "compact", "brightness", "icon", "percentage"] tags: ["island", "home", "compact", "volume", "icon", "percentage"]
text: I18n.tr("Brightness Style", "island settings: brightness display mode row") text: I18n.tr("Volume Style", "island settings: volume display mode row")
visible: SettingsData.islandHomeGroupEnabled("brightness") 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")] 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) => { onSelectionChanged: (index, selected) => {
if (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 { SettingsToggleRow {
settingKey: "dankIslandHomeCompactTight" settingKey: "islandHomeCompactTight"
tags: ["island", "home", "compact", "narrow", "width", "height", "clock"] 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") description: I18n.tr("Compact home clock pill in both width and height", "island settings: tight pill description")
checked: SettingsData.dankIslandHomeCompactTight checked: root.setting("islandHomeCompactTight")
onToggled: checked => SettingsData.set("dankIslandHomeCompactTight", checked) onToggled: checked => root.apply("islandHomeCompactTight", checked)
} }
Flow { Flow {
@@ -273,23 +315,23 @@ Item {
width: parent.width width: parent.width
iconName: "palette" iconName: "palette"
title: I18n.tr("Appearance", "island settings: appearance card title") title: I18n.tr("Appearance", "island settings: appearance card title")
settingKey: "dankIslandAppearance" settingKey: "islandAppearance"
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandPalette" settingKey: "islandPalette"
tags: ["island", "appearance", "palette", "surface", "bright", "dim"] tags: ["island", "appearance", "palette", "surface", "bright", "dim"]
text: I18n.tr("Palette", "island settings: surface tone choice") 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")] 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) => { onSelectionChanged: (index, selected) => {
if (selected) if (selected)
SettingsData.set("dankIslandPalette", root.paletteValues[index] ?? "default"); root.apply("islandPalette", root.paletteValues[index] ?? "default");
} }
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandTransparency" settingKey: "islandTransparency"
tags: ["island", "appearance", "surface", "opacity", "transparency", "blur"] tags: ["island", "appearance", "surface", "opacity", "transparency", "blur"]
text: I18n.tr("Opacity", "island settings: island surface opacity slider") text: I18n.tr("Opacity", "island settings: island surface opacity slider")
unit: "%" unit: "%"
@@ -297,12 +339,12 @@ Item {
maximum: 100 maximum: 100
step: 1 step: 1
defaultValue: 100 defaultValue: 100
value: Math.round(SettingsData.dankIslandTransparency * 100) value: Math.round(root.setting("islandTransparency") * 100)
onSliderValueChanged: value => SettingsData.set("dankIslandTransparency", value / 100) onSliderValueChanged: value => root.apply("islandTransparency", value / 100)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandCornerRadius" settingKey: "islandCornerRadius"
tags: ["island", "appearance", "corner", "radius", "rounding", "pill", "expanded"] tags: ["island", "appearance", "corner", "radius", "rounding", "pill", "expanded"]
text: I18n.tr("Corner Radius", "island settings: island corner radius slider") text: I18n.tr("Corner Radius", "island settings: island corner radius slider")
unit: "px" unit: "px"
@@ -310,39 +352,39 @@ Item {
maximum: 64 maximum: 64
step: 1 step: 1
defaultValue: 34 defaultValue: 34
value: SettingsData.dankIslandCornerRadius value: root.setting("islandCornerRadius")
onSliderValueChanged: value => SettingsData.set("dankIslandCornerRadius", value) onSliderValueChanged: value => root.apply("islandCornerRadius", value)
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandHighContrast" settingKey: "islandHighContrast"
tags: ["island", "appearance", "contrast", "accessibility", "outline"] tags: ["island", "appearance", "contrast", "accessibility", "outline"]
text: I18n.tr("High Contrast", "island settings: high contrast toggle") 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") description: I18n.tr("Draw an outline and use the highest-contrast surface tone", "island settings: high contrast description")
checked: SettingsData.dankIslandHighContrast checked: root.setting("islandHighContrast")
onToggled: checked => SettingsData.set("dankIslandHighContrast", checked) onToggled: checked => root.apply("islandHighContrast", checked)
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandMediaClockVisible" settingKey: "islandMediaClockVisible"
tags: ["island", "media", "clock", "compact", "time"] tags: ["island", "media", "clock", "compact", "time"]
text: I18n.tr("Keep Clock with Media", "island settings: clock in media face toggle") 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") description: I18n.tr("Show a clickable clock beside compact media details", "island settings: media clock description")
checked: SettingsData.dankIslandMediaClockVisible checked: root.setting("islandMediaClockVisible")
onToggled: checked => SettingsData.set("dankIslandMediaClockVisible", checked) onToggled: checked => root.apply("islandMediaClockVisible", checked)
} }
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandBatteryStyle" settingKey: "islandBatteryStyle"
tags: ["island", "battery", "gauge", "solid", "outline", "ring", "circle", "appearance"] tags: ["island", "battery", "gauge", "solid", "outline", "ring", "circle", "appearance"]
text: I18n.tr("Battery Style", "island settings: battery meter style row") 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") description: I18n.tr("Solid or outlined material meter, or a circular gauge", "island settings: battery style description")
visible: BatteryService.batteryAvailable 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")] 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) => { onSelectionChanged: (index, selected) => {
if (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 width: parent.width
iconName: "notifications" iconName: "notifications"
title: I18n.tr("Notifications", "island settings: notifications card title") title: I18n.tr("Notifications", "island settings: notifications card title")
settingKey: "dankIslandNotifications" settingKey: "islandNotifications"
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandNotificationExpand" settingKey: "islandNotificationExpand"
tags: ["island", "notifications", "expand", "arrival", "size"] tags: ["island", "notifications", "expand", "arrival", "size"]
text: I18n.tr("Expand Notifications", "island settings: expanded notification toggle") 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") description: I18n.tr("Expand notifications by default instead of click or hover", "island settings: expanded notification description")
checked: SettingsData.dankIslandNotificationExpand checked: root.setting("islandNotificationExpand")
onToggled: checked => SettingsData.set("dankIslandNotificationExpand", checked) onToggled: checked => root.apply("islandNotificationExpand", checked)
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandNotificationBadgeClearOnOpen" settingKey: "islandNotificationBadgeClearOnOpen"
tags: ["island", "home", "notifications", "badge", "unread", "clear", "dismiss", "open"] 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") 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") description: I18n.tr("Clears the badge on open but keeps notifications active", "island settings: clear badge on open description")
checked: SettingsData.dankIslandNotificationBadgeClearOnOpen checked: root.setting("islandNotificationBadgeClearOnOpen")
enabled: SettingsData.islandHomeGroupEnabled("notifications") enabled: SettingsData.islandHomeGroupEnabled(root.config, "notifications")
onToggled: checked => SettingsData.set("dankIslandNotificationBadgeClearOnOpen", checked) onToggled: checked => root.apply("islandNotificationBadgeClearOnOpen", checked)
} }
} }
@@ -378,42 +420,42 @@ Item {
width: parent.width width: parent.width
iconName: "widgets" iconName: "widgets"
title: I18n.tr("Satellite Widgets", "island settings: satellite widgets card title") title: I18n.tr("Satellite Widgets", "island settings: satellite widgets card title")
settingKey: "dankIslandSatellites" settingKey: "islandSatellites"
collapsible: true collapsible: true
expanded: true expanded: true
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandSatellitesEnabled" settingKey: "islandSatellitesEnabled"
tags: ["island", "satellite", "widgets", "left", "right"] tags: ["island", "satellite", "widgets", "left", "right"]
text: I18n.tr("Show Satellite Widgets", "island settings: satellite widgets toggle") 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") description: I18n.tr("Place independent widgets to left and right of island", "island settings: satellite widgets description")
checked: SettingsData.dankIslandSatellitesEnabled checked: root.setting("islandSatellitesEnabled")
onToggled: checked => SettingsData.set("dankIslandSatellitesEnabled", checked) onToggled: checked => root.apply("islandSatellitesEnabled", checked)
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandSatelliteBackground" settingKey: "islandSatelliteBackground"
tags: ["island", "satellite", "widgets", "background", "chrome"] tags: ["island", "satellite", "widgets", "background", "chrome"]
text: I18n.tr("Background", "island settings: satellite background toggle") 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") description: I18n.tr("Draw an island-styled background behind satellite widgets", "island settings: satellite background description")
checked: SettingsData.dankIslandSatelliteBackground checked: root.setting("islandSatelliteBackground")
enabled: SettingsData.dankIslandSatellitesEnabled enabled: root.setting("islandSatellitesEnabled")
onToggled: checked => SettingsData.set("dankIslandSatelliteBackground", checked) onToggled: checked => root.apply("islandSatelliteBackground", checked)
} }
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandSatelliteGothCorners" settingKey: "islandSatelliteGothCorners"
tags: ["island", "satellite", "goth", "corners", "wing", "sweep"] tags: ["island", "satellite", "goth", "corners", "wing", "sweep"]
text: I18n.tr("Goth Corners", "island settings: satellite goth corners toggle") 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") description: I18n.tr("Sweep the background into the screen edges", "island settings: satellite goth corners description")
checked: SettingsData.dankIslandSatelliteGothCorners checked: root.setting("islandSatelliteGothCorners")
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatelliteBackground enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatelliteBackground")
onToggled: checked => SettingsData.set("dankIslandSatelliteGothCorners", checked) onToggled: checked => root.apply("islandSatelliteGothCorners", checked)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandSatelliteSwoopRadius" settingKey: "islandSatelliteSwoopRadius"
tags: ["island", "satellite", "goth", "corners", "radius", "sweep", "size"] tags: ["island", "satellite", "goth", "corners", "radius", "sweep", "size"]
text: I18n.tr("Goth Corner Radius", "island settings: satellite goth corner radius slider") text: I18n.tr("Goth Corner Radius", "island settings: satellite goth corner radius slider")
unit: "px" unit: "px"
@@ -421,13 +463,13 @@ Item {
maximum: 64 maximum: 64
step: 1 step: 1
defaultValue: 24 defaultValue: 24
value: SettingsData.dankIslandSatelliteSwoopRadius value: root.setting("islandSatelliteSwoopRadius")
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatelliteBackground && SettingsData.dankIslandSatelliteGothCorners enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatelliteBackground") && root.setting("islandSatelliteGothCorners")
onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteSwoopRadius", value) onSliderValueChanged: value => root.apply("islandSatelliteSwoopRadius", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandSatelliteTransparency" settingKey: "islandSatelliteTransparency"
tags: ["island", "satellite", "background", "opacity", "transparency", "blur"] tags: ["island", "satellite", "background", "opacity", "transparency", "blur"]
text: I18n.tr("Opacity", "island settings: satellite background opacity slider") text: I18n.tr("Opacity", "island settings: satellite background opacity slider")
unit: "%" unit: "%"
@@ -435,27 +477,27 @@ Item {
maximum: 100 maximum: 100
step: 1 step: 1
defaultValue: 100 defaultValue: 100
value: Math.round(SettingsData.dankIslandSatelliteTransparency * 100) value: Math.round(root.setting("islandSatelliteTransparency") * 100)
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatelliteBackground enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatelliteBackground")
onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteTransparency", value / 100) onSliderValueChanged: value => root.apply("islandSatelliteTransparency", value / 100)
} }
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandSatellitePosition" settingKey: "islandSatellitePosition"
tags: ["island", "satellite", "widgets", "position", "edges", "center"] tags: ["island", "satellite", "widgets", "position", "edges", "center"]
text: I18n.tr("Position", "island settings: position card title") 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") 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")] 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") currentIndex: root.valueIndex(root.satellitePositionValues, root.setting("islandSatellitePosition"), "island")
enabled: SettingsData.dankIslandSatellitesEnabled enabled: root.setting("islandSatellitesEnabled")
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
if (selected) if (selected)
SettingsData.set("dankIslandSatellitePosition", root.satellitePositionValues[index] ?? "island"); root.apply("islandSatellitePosition", root.satellitePositionValues[index] ?? "island");
} }
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandSatelliteGap" settingKey: "islandSatelliteGap"
tags: ["island", "satellite", "widgets", "gap", "spacing"] tags: ["island", "satellite", "widgets", "gap", "spacing"]
text: I18n.tr("Island Gap", "island settings: satellite to island gap slider") text: I18n.tr("Island Gap", "island settings: satellite to island gap slider")
unit: "px" unit: "px"
@@ -463,9 +505,9 @@ Item {
maximum: 48 maximum: 48
step: 1 step: 1
defaultValue: 12 defaultValue: 12
value: SettingsData.dankIslandSatelliteGap value: root.setting("islandSatelliteGap")
enabled: SettingsData.dankIslandSatellitesEnabled && SettingsData.dankIslandSatellitePosition !== "edges" enabled: root.setting("islandSatellitesEnabled") && root.setting("islandSatellitePosition") !== "edges"
onSliderValueChanged: value => SettingsData.set("dankIslandSatelliteGap", value) onSliderValueChanged: value => root.apply("islandSatelliteGap", value)
} }
StyledText { StyledText {
@@ -474,7 +516,7 @@ Item {
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
visible: SettingsData.dankIslandSatellitesEnabled visible: root.setting("islandSatellitesEnabled")
} }
} }
@@ -482,19 +524,19 @@ Item {
width: parent.width width: parent.width
iconName: "touch_app" iconName: "touch_app"
title: I18n.tr("Behavior", "island settings: behavior card title") title: I18n.tr("Behavior", "island settings: behavior card title")
settingKey: "dankIslandInteraction" settingKey: "islandInteraction"
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsButtonGroupRow { SettingsButtonGroupRow {
settingKey: "dankIslandInteractionMode" settingKey: "islandInteractionMode"
tags: ["island", "interaction", "click", "hybrid", "expand"] tags: ["island", "interaction", "click", "hybrid", "expand"]
text: I18n.tr("Expansion Mode", "island settings: click or hover expansion row") 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") 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")] 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) => { onSelectionChanged: (index, selected) => {
if (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 color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
visible: SettingsData.dankIslandInteractionMode !== "click" visible: root.setting("islandInteractionMode") !== "click"
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandHoverOpenDelay" settingKey: "islandHoverOpenDelay"
tags: ["island", "interaction", "hover", "open", "delay"] tags: ["island", "interaction", "hover", "open", "delay"]
text: I18n.tr("Open Delay", "island settings: hover open delay slider") text: I18n.tr("Open Delay", "island settings: hover open delay slider")
unit: "ms" unit: "ms"
@@ -516,13 +558,13 @@ Item {
maximum: 1000 maximum: 1000
step: 10 step: 10
defaultValue: 150 defaultValue: 150
value: SettingsData.dankIslandHoverOpenDelay value: root.setting("islandHoverOpenDelay")
enabled: SettingsData.dankIslandInteractionMode !== "click" enabled: root.setting("islandInteractionMode") !== "click"
onSliderValueChanged: value => SettingsData.set("dankIslandHoverOpenDelay", value) onSliderValueChanged: value => root.apply("islandHoverOpenDelay", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandHoverCloseDelay" settingKey: "islandHoverCloseDelay"
tags: ["island", "interaction", "hover", "close", "delay"] tags: ["island", "interaction", "hover", "close", "delay"]
text: I18n.tr("Hide Delay", "island settings: hover hide delay slider") text: I18n.tr("Hide Delay", "island settings: hover hide delay slider")
unit: "ms" unit: "ms"
@@ -530,9 +572,9 @@ Item {
maximum: 1000 maximum: 1000
step: 10 step: 10
defaultValue: 150 defaultValue: 150
value: SettingsData.dankIslandHoverCloseDelay value: root.setting("islandHoverCloseDelay")
enabled: SettingsData.dankIslandInteractionMode !== "click" enabled: root.setting("islandInteractionMode") !== "click"
onSliderValueChanged: value => SettingsData.set("dankIslandHoverCloseDelay", value) onSliderValueChanged: value => root.apply("islandHoverCloseDelay", value)
} }
} }
@@ -540,49 +582,49 @@ Item {
width: parent.width width: parent.width
iconName: "animation" iconName: "animation"
title: I18n.tr("Motion & Accessibility", "island settings: motion card title") title: I18n.tr("Motion & Accessibility", "island settings: motion card title")
settingKey: "dankIslandMotion" settingKey: "islandMotion"
collapsible: true collapsible: true
visible: SettingsData.dankIslandEnabled visible: root.islandEnabled
SettingsToggleRow { SettingsToggleRow {
settingKey: "dankIslandReducedMotion" settingKey: "islandReducedMotion"
tags: ["island", "motion", "animation", "reduce", "accessibility", "spring"] tags: ["island", "motion", "animation", "reduce", "accessibility", "spring"]
text: I18n.tr("Reduce Motion", "island settings: reduce motion toggle") 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") description: I18n.tr("Apply island geometry changes immediately without spring overshoot", "island settings: reduce motion description")
checked: SettingsData.dankIslandReducedMotion checked: root.setting("islandReducedMotion")
onToggled: checked => SettingsData.set("dankIslandReducedMotion", checked) onToggled: checked => root.apply("islandReducedMotion", checked)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandSpringStiffness" settingKey: "islandSpringStiffness"
tags: ["island", "motion", "spring", "stiffness", "animation"] tags: ["island", "motion", "spring", "stiffness", "animation"]
text: I18n.tr("Spring Stiffness", "island settings: spring stiffness slider") 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") description: I18n.tr("Higher values pull island toward its target more strongly", "island settings: stiffness description")
minimum: 100 minimum: 100
maximum: 1200 maximum: 1200
step: 10 step: 10
value: Math.round(SettingsData.dankIslandSpringStiffness) value: Math.round(root.setting("islandSpringStiffness"))
defaultValue: 560 defaultValue: 560
enabled: !SettingsData.dankIslandReducedMotion enabled: !root.setting("islandReducedMotion")
onSliderValueChanged: value => SettingsData.set("dankIslandSpringStiffness", value) onSliderValueChanged: value => root.apply("islandSpringStiffness", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandSpringDamping" settingKey: "islandSpringDamping"
tags: ["island", "motion", "spring", "damping", "bounce", "animation"] tags: ["island", "motion", "spring", "damping", "bounce", "animation"]
text: I18n.tr("Spring Damping", "island settings: spring damping slider") text: I18n.tr("Spring Damping", "island settings: spring damping slider")
description: I18n.tr("Higher values settle island with less bounce", "island settings: damping description") description: I18n.tr("Higher values settle island with less bounce", "island settings: damping description")
minimum: 10 minimum: 10
maximum: 100 maximum: 100
step: 1 step: 1
value: Math.round(SettingsData.dankIslandSpringDamping) value: Math.round(root.setting("islandSpringDamping"))
defaultValue: 37 defaultValue: 37
enabled: !SettingsData.dankIslandReducedMotion enabled: !root.setting("islandReducedMotion")
onSliderValueChanged: value => SettingsData.set("dankIslandSpringDamping", value) onSliderValueChanged: value => root.apply("islandSpringDamping", value)
} }
SettingsSliderRow { SettingsSliderRow {
settingKey: "dankIslandSpringMass" settingKey: "islandSpringMass"
tags: ["island", "motion", "spring", "mass", "inertia", "animation"] tags: ["island", "motion", "spring", "mass", "inertia", "animation"]
text: I18n.tr("Spring Mass", "island settings: spring mass slider") text: I18n.tr("Spring Mass", "island settings: spring mass slider")
description: I18n.tr("Higher percentages give island more inertia", "island settings: mass description") description: I18n.tr("Higher percentages give island more inertia", "island settings: mass description")
@@ -590,10 +632,10 @@ Item {
maximum: 300 maximum: 300
step: 5 step: 5
unit: "%" unit: "%"
value: Math.round(SettingsData.dankIslandSpringMass * 100) value: Math.round(root.setting("islandSpringMass") * 100)
defaultValue: 100 defaultValue: 100
enabled: !SettingsData.dankIslandReducedMotion enabled: !root.setting("islandReducedMotion")
onSliderValueChanged: value => SettingsData.set("dankIslandSpringMass", value / 100) 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 hasPendingChanges: Object.keys(pendingChanges).length > 0 || Object.keys(pendingNiriChanges).length > 0 || Object.keys(pendingHyprlandChanges).length > 0 || formatChanged
property bool validatingConfig: false 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 string validationError: ""
property var currentOutputSet: [] property var currentOutputSet: []
@@ -530,20 +721,23 @@ Singleton {
const outputKeys = Object.keys(configEntry.outputs || {}); const outputKeys = Object.keys(configEntry.outputs || {});
if (outputKeys.length === 0) if (outputKeys.length === 0)
return false; return false;
const hasEnabledReal = outputKeys.some(k => { const hasEnabledReal = outputKeys.some(k => !configEntry.outputs[k].disabled && profileOutputIsReal(k));
const o = configEntry.outputs[k];
return !o.disabled && o.make && o.model;
});
if (hasEnabledReal) if (hasEnabledReal)
return false; return false;
for (const k of outputKeys) { const firstReal = outputKeys.find(k => profileOutputIsReal(k));
const o = configEntry.outputs[k]; if (!firstReal)
if (o.make && o.model) { return false;
delete o.disabled; delete configEntry.outputs[firstReal].disabled;
return true; 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) { function applyConfigEntry(configEntry, configId, profileName, isManual) {
@@ -556,26 +750,6 @@ Singleton {
showHyprlandReadOnlyWarning(); showHyprlandReadOnlyWarning();
return; 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 = () => { const onWriteFailed = () => {
if (isManual) { if (isManual) {
profilesLoading = false; profilesLoading = false;
@@ -583,6 +757,17 @@ Singleton {
profileError(I18n.tr("Failed to apply profile")); 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 = () => { const onWriteSuccess = () => {
SessionData.setActiveDisplayProfile(CompositorService.compositor, configId); SessionData.setActiveDisplayProfile(CompositorService.compositor, configId);
publishActiveProfileModes(); publishActiveProfileModes();
@@ -806,19 +991,17 @@ Singleton {
const outputConfigs = buildCurrentOutputConfigs(); const outputConfigs = buildCurrentOutputConfigs();
const id = generateAutoProfileId(currentOutputSet); const id = generateAutoProfileId(currentOutputSet);
const entry = {
"id": id,
"name": "",
"outputs": outputConfigs
};
ensureEnabledOutput(entry);
const existingIdx = data.configurations.findIndex(c => c.id === id); const existingIdx = data.configurations.findIndex(c => c.id === id);
if (existingIdx >= 0) if (existingIdx >= 0)
data.configurations[existingIdx] = { data.configurations[existingIdx] = entry;
"id": id,
"name": "",
"outputs": outputConfigs
};
else else
data.configurations.push({ data.configurations.push(entry);
"id": id,
"name": "",
"outputs": outputConfigs
});
writeMonitorsJson(data, success => { writeMonitorsJson(data, success => {
if (!success) if (!success)
return; return;
@@ -939,7 +1122,7 @@ Singleton {
} }
Connections { Connections {
target: SettingsData target: SessionData
function onActiveDisplayProfileChanged() { function onActiveDisplayProfileChanged() {
root.publishActiveProfileModes(); root.publishActiveProfileModes();
} }
@@ -1715,9 +1898,21 @@ Singleton {
MangoService.generateOutputsConfig(outputsData, finish); MangoService.generateOutputsConfig(outputsData, finish);
break; break;
default: default:
WlrOutputService.applyOutputsConfig(outputsData, outputs); {
finish(true); if (_cancelOutputWrite)
break; _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; return true;
} }
@@ -2153,11 +2348,15 @@ Singleton {
originalDisplayNameMode = ""; originalDisplayNameMode = "";
} }
function restoreDisplayNameMode() {
if (originalDisplayNameMode === "")
return;
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
function discardChanges() { function discardChanges() {
if (originalDisplayNameMode !== "") { restoreDisplayNameMode();
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
backendFetchOutputs(); backendFetchOutputs();
clearPendingChanges(); clearPendingChanges();
} }
@@ -2229,16 +2428,32 @@ Singleton {
return; return;
} }
changesApplied(changeDescriptions); if (CompositorService.isAqueous) {
previewAqueousOutputs(changeDescriptions);
if (formatChanged) return;
SettingsData.saveSettings(); }
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
const mergedOutputs = buildOutputsWithPendingChanges(); 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) { function validateAndApplyNiriConfig(changeDescriptions) {
@@ -2349,6 +2564,10 @@ Singleton {
} }
function confirmChanges(profileId) { function confirmChanges(profileId) {
if (CompositorService.isAqueous) {
finishAqueousPreview(true);
return;
}
const outputConfigs = buildCurrentOutputConfigs(); const outputConfigs = buildCurrentOutputConfigs();
lastAppliedEntry = { lastAppliedEntry = {
outputs: outputConfigs outputs: outputConfigs
@@ -2380,14 +2599,15 @@ Singleton {
} }
function revertChanges() { function revertChanges() {
if (CompositorService.isAqueous && aqueousPreview) {
finishAqueousPreview(false);
return;
}
const hadFormatChange = originalDisplayNameMode !== ""; const hadFormatChange = originalDisplayNameMode !== "";
const hadNiriChanges = originalNiriSettings !== null; const hadNiriChanges = originalNiriSettings !== null;
const hadHyprlandChanges = originalHyprlandSettings !== null; const hadHyprlandChanges = originalHyprlandSettings !== null;
if (hadFormatChange) { restoreDisplayNameMode();
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
if (hadNiriChanges) { if (hadNiriChanges) {
SessionData.niriOutputSettings = JSON.parse(JSON.stringify(originalNiriSettings)); SessionData.niriOutputSettings = JSON.parse(JSON.stringify(originalNiriSettings));

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