Compare commits

...
13 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
60 changed files with 3861 additions and 227 deletions
+94 -79
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -46,7 +47,7 @@ var keybindsSetCmd = &cobra.Command{
Short: "Set a keybind override",
Long: "Create or update a keybind override for the specified provider",
Args: cobra.ExactArgs(3),
Run: runKeybindsSet,
Run: runKeybindsEdit,
}
var keybindsRemoveCmd = &cobra.Command{
@@ -54,7 +55,7 @@ var keybindsRemoveCmd = &cobra.Command{
Short: "Remove a keybind",
Long: "Remove a keybind. For Hyprland this writes a negative override to dms/binds-user.lua so the key stays unbound across DMS updates. For other providers it deletes the entry from the managed file.",
Args: cobra.ExactArgs(2),
Run: runKeybindsRemove,
Run: runKeybindsEdit,
}
var keybindsResetCmd = &cobra.Command{
@@ -62,10 +63,14 @@ var keybindsResetCmd = &cobra.Command{
Short: "Reset a keybind override to its DMS default",
Long: "Drop the user override for the given key so the DMS default re-applies. For providers without a separate default file (Niri, MangoWC) this is equivalent to remove.",
Args: cobra.ExactArgs(2),
Run: runKeybindsReset,
Run: runKeybindsEdit,
}
func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
for _, command := range []*cobra.Command{keybindsSetCmd, keybindsRemoveCmd, keybindsResetCmd} {
command.Flags().Bool("json", false, "Return structured mutation results, including errors")
}
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -93,6 +98,9 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
@@ -206,85 +214,92 @@ func runKeybindsShow(cmd *cobra.Command, args []string) {
printCheatSheet(provider)
}
func getWritableProvider(name string) keybinds.WritableProvider {
provider, err := keybinds.GetDefaultRegistry().Get(name)
func keybindEditFailure(err error) map[string]any {
code := "command_failed"
var helperError *providers.AqueousError
if errors.As(err, &helperError) {
code = helperError.Code
}
return map[string]any{"success": false, "code": code, "message": err.Error()}
}
func runKeybindsEdit(cmd *cobra.Command, args []string) {
result, err := editKeybind(cmd, args)
if err == nil {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(result)
return
}
structured, _ := cmd.Flags().GetBool("json")
if !structured {
log.Fatalf("Failed to save keybind: %v", err)
return
}
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(keybindEditFailure(err))
os.Exit(1)
}
func editKeybind(cmd *cobra.Command, args []string) (map[string]any, error) {
provider, err := keybinds.GetDefaultRegistry().Get(args[0])
if err != nil {
log.Fatalf("Error: %v", err)
return nil, err
}
key := args[1]
if aqueous, ok := provider.(*providers.AqueousProvider); ok {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: key, Remove: cmd.Name() != "set"}
if !edit.Remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
return nil, fmt.Errorf("aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := aqueous.Edit(cmd.Context(), edit)
if err != nil {
return nil, err
}
return map[string]any{"success": true, "code": "applied", "generation": result.String("generation"), "key": key}, nil
}
writable, ok := provider.(keybinds.WritableProvider)
if !ok {
log.Fatalf("Provider %s does not support writing keybinds", name)
return nil, fmt.Errorf("provider %s does not support writing keybinds", args[0])
}
return writable
}
func runKeybindsSet(cmd *cobra.Command, args []string) {
providerName, key, action := args[0], args[1], args[2]
writable := getWritableProvider(providerName)
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
desc, _ := cmd.Flags().GetString("desc")
if err := writable.SetBind(key, action, desc, options); err != nil {
log.Fatalf("Error setting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"action": action,
"path": writable.GetOverridePath(),
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.RemoveBind(key); err != nil {
log.Fatalf("Error removing keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"removed": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
if err := writable.ResetBind(key); err != nil {
log.Fatalf("Error resetting keybind: %v", err)
}
output, _ := json.MarshalIndent(map[string]any{
"success": true,
"key": key,
"reset": true,
}, "", " ")
fmt.Fprintln(os.Stdout, string(output))
result := map[string]any{"success": true, "key": key}
switch cmd.Name() {
case "set":
options := make(map[string]any)
if v, _ := cmd.Flags().GetBool("allow-when-locked"); v {
options["allow-when-locked"] = true
}
if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 {
options["cooldown-ms"] = v
}
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = false
}
if v, _ := cmd.Flags().GetBool("no-inhibiting"); v {
options["allow-inhibiting"] = false
}
if v, _ := cmd.Flags().GetString("flags"); v != "" {
options["flags"] = v
}
if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key {
_ = writable.RemoveBind(replaceKey)
}
desc, _ := cmd.Flags().GetString("desc")
err = writable.SetBind(key, args[2], desc, options)
result["action"] = args[2]
result["path"] = writable.GetOverridePath()
case "remove":
err = writable.RemoveBind(key)
result["removed"] = true
case "reset":
err = writable.ResetBind(key)
result["reset"] = true
default:
return nil, fmt.Errorf("unsupported keybind operation: %s", cmd.Name())
}
return result, err
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"errors"
"fmt"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
)
func TestKeybindEditFailure(t *testing.T) {
for _, tc := range []struct {
err error
code string
}{
{&providers.AqueousError{Code: "external_change", Message: "changed"}, "external_change"},
{fmt.Errorf("helper: %w", &providers.AqueousError{Code: "read_only", Message: "denied"}), "read_only"},
{errors.New("transport failed"), "command_failed"},
} {
result := keybindEditFailure(tc.err)
if result["success"] != false || result["code"] != tc.code || result["message"] != tc.err.Error() {
t.Fatalf("unexpected failure result: %#v", result)
}
}
}
+6 -3
View File
@@ -20,6 +20,7 @@ import (
var (
ssOutputName string
ssSeat string
ssCursor string
ssFormat string
ssQuality int
@@ -58,7 +59,7 @@ Modes:
full - Capture the focused output
all - Capture all outputs combined
output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango/niri)
window - Capture the focused window (Hyprland/Mango/niri/Aqueous)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
@@ -72,7 +73,7 @@ Examples:
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window (Hyprland)
dms screenshot window # Focused window
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
@@ -122,7 +123,7 @@ If no previous region exists, falls back to interactive selection.`,
var ssWindowCmd = &cobra.Command{
Use: "window",
Short: "Capture the focused window",
Long: `Capture the currently focused window. Supported on Hyprland, Mango, and niri.`,
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`,
Run: runScreenshotWindow,
}
@@ -165,6 +166,7 @@ var notifyActionCmd = &cobra.Command{
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -198,6 +200,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn
}
+178
View File
@@ -0,0 +1,178 @@
package providers
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
)
type AqueousProvider struct{}
func NewAqueousProvider() *AqueousProvider { return &AqueousProvider{} }
func (*AqueousProvider) Name() string { return "aqueous" }
func aqueousObjects(snapshot aqueousConfig, key string) []aqueousConfig {
values, _ := snapshot[key].([]any)
result := make([]aqueousConfig, 0, len(values))
for _, value := range values {
if object, ok := value.(map[string]any); ok {
result = append(result, aqueousConfig(object))
}
}
return result
}
func AqueousCheatSheet(snapshot aqueousConfig) (*keybinds.CheatSheet, error) {
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
sheet := &keybinds.CheatSheet{Title: "Aqueous", Provider: "aqueous", Generation: snapshot.String("generation"), ModKey: "Super", DMSBindsIncluded: true, Binds: map[string][]keybinds.Keybind{"Compositor": {}, "Custom": {}}}
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
if len(values) == 0 {
values = []any{""}
}
for _, value := range values {
key, ok := value.(string)
if !ok {
return nil, errors.New("invalid keybinding list")
}
sheet.Binds["Compositor"] = append(sheet.Binds["Compositor"], keybinds.Keybind{Key: key, Action: field.String("id"), Description: field.String("label"), Source: "dms"})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
sheet.Binds["Custom"] = append(sheet.Binds["Custom"], keybinds.Keybind{Key: binding.String("chord"), Action: "spawn " + binding.String("command"), Description: binding.String("command"), Source: "dms"})
}
seen := map[string]keybinds.Keybind{}
for _, category := range []string{"Compositor", "Custom"} {
for i, bind := range sheet.Binds[category] {
if bind.Key == "" {
continue
}
if previous, exists := seen[bind.Key]; exists {
sheet.Binds[category][i].Conflict = &previous
}
seen[bind.Key] = bind
}
}
return sheet, nil
}
func (*AqueousProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
snapshot, err := aqueousHelper(context.Background(), "snapshot", nil)
if err != nil {
return nil, err
}
return AqueousCheatSheet(snapshot)
}
type AqueousBindEdit struct {
Generation string
OriginalKey string
Key string
Action string
Remove bool
}
func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousConfig, error) {
if edit.Generation == "" || edit.Generation != snapshot.String("generation") {
return nil, &AqueousError{Code: "external_change", Message: "reload and reconcile the retained keybind draft"}
}
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
if edit.Key == "" {
return nil, errors.New("missing key")
}
original := edit.OriginalKey
if original == "" {
original = edit.Key
}
originalCount := 0
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
for _, value := range values {
if value == original {
originalCount++
}
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
originalCount++
}
}
if originalCount > 1 {
return nil, &AqueousError{Code: "ambiguous_target", Message: "multiple bindings use the original shortcut"}
}
if originalCount == 0 && (edit.Remove || edit.OriginalKey != "") {
return nil, &AqueousError{Code: "target_removed", Message: "the original shortcut no longer exists"}
}
changes := []any{}
customChanges := []any{}
foundAction := false
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
old, _ := field["value"].([]any)
values := make([]any, 0, len(old)+1)
for _, value := range old {
if value == original {
continue
}
if !edit.Remove && value == edit.Key && field.String("id") != edit.Action {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
values = append(values, value)
}
if field.String("id") == edit.Action && !edit.Remove {
foundAction = true
if !slices.Contains(values, any(edit.Key)) {
values = append(values, edit.Key)
}
}
if !slices.Equal(old, values) {
changes = append(changes, map[string]any{"id": field.String("id"), "value": values})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
customChanges = append(customChanges, map[string]any{"op": "delete", "id": binding.String("id")})
continue
}
if !edit.Remove && binding.String("chord") == edit.Key {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
}
if !edit.Remove && !foundAction {
command, ok := strings.CutPrefix(edit.Action, "spawn ")
if !ok || strings.TrimSpace(command) == "" {
return nil, errors.New("unknown Aqueous action; custom commands use spawn")
}
customChanges = append(customChanges, map[string]any{"op": "add", "chord": edit.Key, "command": command})
}
return aqueousConfig{"expected_generation": edit.Generation, "changes": changes, "custom_keybind_changes": customChanges, "create_user_override": true}, nil
}
func (*AqueousProvider) Edit(ctx context.Context, edit AqueousBindEdit) (aqueousConfig, error) {
snapshot, err := aqueousHelper(ctx, "snapshot", nil)
if err != nil {
return nil, err
}
request, err := AqueousBindRequest(snapshot, edit)
if err != nil {
return nil, err
}
return aqueousHelper(ctx, "apply", request)
}
@@ -0,0 +1,115 @@
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func helperResult(ctx context.Context, verb string, input []byte) (aqueousConfig, error) {
var result aqueousConfig
args := []string{verb, "--shell", "dms"}
if input != nil {
args = append(args, "--request", "-")
}
err := utils.RunJSON(ctx, "aqueous-config", args, input, &result)
if err != nil || !result.Bool("ok") {
if result.String("code") != "" {
return result, &AqueousError{Code: result.String("code"), Message: result.String("message")}
}
if err != nil {
if verb == "apply" {
return result, &AqueousError{Code: "uncertain", Message: err.Error()}
}
return result, err
}
return result, errors.New("aqueous-config rejected the request")
}
return result, nil
}
type AqueousError struct {
Code string
Message string
}
func (e *AqueousError) Error() string { return e.Code + ": " + e.Message }
func requireAqueousCapabilities(result aqueousConfig, required ...string) error {
if result.Number("protocol") != 1 {
return errors.New("unsupported aqueous-config protocol")
}
values, ok := result["capabilities"].([]any)
if !ok {
return errors.New("unsupported aqueous-config: capability discovery required")
}
for _, capability := range required {
if !slices.Contains(values, any(capability)) {
return fmt.Errorf("unsupported aqueous-config capability: %s", capability)
}
}
return nil
}
func aqueousHelper(ctx context.Context, verb string, request aqueousConfig) (aqueousConfig, error) {
if verb == "snapshot" {
result, err := helperResult(ctx, verb, nil)
if err != nil {
return result, err
}
if err := requireAqueousCapabilities(result, "schema_fields", "shell_dms"); err != nil {
return result, err
}
if result.String("generation") == "" {
return result, errors.New("missing configuration generation")
}
return result, nil
}
if verb != "validate" && verb != "apply" {
return nil, errors.New("unsupported helper operation")
}
if request.String("expected_generation") == "" {
return nil, errors.New("missing expected_generation; reload configuration")
}
version, err := helperResult(ctx, "version", nil)
if err != nil {
return nil, err
}
required := []string{"keybinds", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"}
if err := requireAqueousCapabilities(version, required...); err != nil {
return nil, err
}
request["protocol"] = 1
if _, exists := request["backup_dir"]; !exists {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
request["backup_dir"] = filepath.Join(configDir, "DankMaterialShell", "aqueous-backups")
}
input, err := json.Marshal(request)
if err != nil {
return nil, err
}
validated, err := helperResult(ctx, "validate", input)
if err != nil || verb == "validate" {
return validated, err
}
return helperResult(ctx, "apply", input)
}
type aqueousConfig map[string]any
func (e aqueousConfig) String(key string) string { value, _ := e[key].(string); return value }
func (e aqueousConfig) Bool(key string) bool { value, _ := e[key].(bool); return value }
func (e aqueousConfig) Number(key string) float64 {
value, _ := e[key].(json.Number)
number, _ := value.Float64()
return number
}
@@ -0,0 +1,133 @@
package providers
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func aqueousFixture(t *testing.T) aqueousConfig {
t.Helper()
var snapshot aqueousConfig
err := utils.DecodeJSON([]byte(`{"ok":true,"protocol":1,"generation":"abc","capabilities":["keybinds"],"fields":[{"id":"spawn_terminal","category":"keybinds","type":"string_list","label":"Terminal","value":["Super+Return","Super+T"]},{"id":"close","category":"keybinds","type":"string_list","label":"Close","value":[]}],"custom_keybinds":[{"id":"custom:12","chord":"Super+R","command":"printf 'hello; $world'"}]}`), &snapshot)
if err != nil {
t.Fatal(err)
}
return snapshot
}
func TestAqueousCheatsheetIncludesUnboundAndCustom(t *testing.T) {
sheet, err := AqueousCheatSheet(aqueousFixture(t))
if err != nil {
t.Fatal(err)
}
if sheet.Generation != "abc" || len(sheet.Binds["Compositor"]) != 3 {
t.Fatal("lost generation or multiple bindings")
}
if sheet.Binds["Compositor"][2].Key != "" || sheet.Binds["Compositor"][2].Action != "close" {
t.Fatal("unbound action lost")
}
if sheet.Binds["Custom"][0].Action != "spawn printf 'hello; $world'" {
t.Fatal("custom command modified")
}
}
func TestAqueousBindingReplacementIsOneRequest(t *testing.T) {
snapshot := aqueousFixture(t)
request, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+Enter", Action: "spawn_terminal"})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["changes"])
if string(data) != `[{"id":"spawn_terminal","value":["Super+T","Super+Enter"]}]` {
t.Fatalf("wrong atomic replacement: %s", data)
}
if request["expected_generation"] != "abc" {
t.Fatal("generation replaced")
}
if !reflect.DeepEqual(snapshot, aqueousFixture(t)) {
t.Fatal("draft mutated snapshot")
}
}
func TestAqueousBindingConflictAndRemove(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "stale", Key: "Super+T", Remove: true},
{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+R", Action: "close"},
{Generation: "abc", Key: "Super+Q", Action: "invented_action"},
} {
if _, err := AqueousBindRequest(aqueousFixture(t), edit); err == nil {
t.Fatal("invalid edit accepted")
}
}
request, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: "abc", Key: "Super+R", Remove: true})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["custom_keybind_changes"])
if string(data) != `[{"id":"custom:12","op":"delete"}]` {
t.Fatalf("wrong removal: %s", data)
}
}
func TestAqueousGenerationConflictCode(t *testing.T) {
for _, generation := range []string{"", "stale"} {
_, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: generation, Key: "Super+R", Remove: true})
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "external_change" {
t.Fatalf("expected structured generation conflict, got %v", err)
}
}
}
func TestAqueousBindingRequiresUniqueOriginal(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "abc", Key: "Super+Missing", Remove: true},
{Generation: "abc", OriginalKey: "Super+Missing", Key: "Super+X", Action: "spawn_terminal"},
} {
_, err := AqueousBindRequest(aqueousFixture(t), edit)
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "target_removed" {
t.Fatalf("expected missing original, got %v", err)
}
}
snapshot := aqueousFixture(t)
snapshot["custom_keybinds"] = append(snapshot["custom_keybinds"].([]any), map[string]any{"id": "duplicate", "chord": "Super+T", "command": "echo duplicate"})
_, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", Key: "Super+T", Remove: true})
var conflict *AqueousError
if !errors.As(err, &conflict) || conflict.Code != "ambiguous_target" {
t.Fatalf("expected ambiguous original, got %v", err)
}
}
func TestAqueousHelperFailureCodes(t *testing.T) {
for _, tc := range []struct {
name string
script string
code string
}{
{"conflict", `printf '%s' '{"ok":false,"code":"external_change","message":"changed"}'; exit 1`, "external_change"},
{"missing_ack", "exit 0", "uncertain"},
{"failed_process", "exit 1", "uncertain"},
{"malformed_ack", "printf '{'", "uncertain"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "aqueous-config"), []byte("#!/bin/sh\n"+tc.script+"\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
_, err := helperResult(context.Background(), "apply", nil)
var failure *AqueousError
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("expected %s, got %v", tc.code, err)
}
})
}
}
+1
View File
@@ -30,6 +30,7 @@ type DMSBindsStatus struct {
}
type CheatSheet struct {
Generation string `json:"generation,omitempty"`
Title string `json:"title"`
Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
+95
View File
@@ -0,0 +1,95 @@
package screenshot
import (
"context"
"errors"
"image"
"math"
)
func aqueousWindowGeometry(model aqueousSnapshotModel, seatName string) (*WindowGeometry, error) {
seat, err := model.Seat(seatName)
if err != nil {
return nil, err
}
if seat.FocusKind != "window" {
return nil, errors.New("no active window: seat focus is not a window")
}
window := model.Entities["window:"+seat.Window]
if window == nil || !window.Visible || window.Minimized || !window.CanActivate {
return nil, errors.New("no eligible active window")
}
output := model.Entities["output:"+window.Output]
if output == nil || !output.Enabled || !output.Powered {
return nil, errors.New("active window output is unavailable")
}
box, bounds := window.OuterGeometry, output.Bounds
if box == nil || bounds == nil || !box.valid() || !bounds.valid() || box.Width <= 0 || box.Height <= 0 || output.Scale <= 0 || math.IsInf(output.Scale, 0) || math.IsNaN(output.Scale) {
return nil, errors.New("invalid active window geometry")
}
return &WindowGeometry{X: int32(box.X), Y: int32(box.Y), Width: int32(box.Width), Height: int32(box.Height), Output: output.Name, Scale: output.Scale, OutputX: int32(bounds.X), OutputY: int32(bounds.Y)}, nil
}
func aqueousCropRect(geom *WindowGeometry, pixels image.Rectangle) image.Rectangle {
left := math.Floor((float64(geom.X) - float64(geom.OutputX)) * geom.Scale)
top := math.Floor((float64(geom.Y) - float64(geom.OutputY)) * geom.Scale)
right := math.Ceil((float64(geom.X) + float64(geom.Width) - float64(geom.OutputX)) * geom.Scale)
bottom := math.Ceil((float64(geom.Y) + float64(geom.Height) - float64(geom.OutputY)) * geom.Scale)
return image.Rect(int(left), int(top), int(right), int(bottom)).Intersect(pixels)
}
func aqueousFocusedOutput(seatName string) (string, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return "", err
}
seat, err := model.Seat(seatName)
if err != nil {
return "", err
}
output := model.Entities["output:"+seat.Output]
if output == nil || !output.Enabled || !output.Powered {
return "", errors.New("selected output is unavailable")
}
return output.Name, nil
}
func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
geom, err := aqueousWindowGeometry(model, s.config.Seat)
if err != nil {
return nil, err
}
region := Region{X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, Output: geom.Output}
if s.config.Geometry {
return geometryResult(region), nil
}
output := s.findOutputByName(geom.Output)
if output == nil {
return nil, errors.New("active window output disappeared")
}
result, err := s.captureWholeOutput(output)
if err != nil {
return nil, err
}
defer result.Buffer.Close()
rect := aqueousCropRect(geom, image.Rect(0, 0, result.Buffer.Width, result.Buffer.Height))
if rect.Empty() {
return nil, errors.New("active window is outside the output")
}
bpp := PixelFormat(result.Format).BytesPerPixel()
cropped, err := CreateShmBuffer(rect.Dx(), rect.Dy(), rect.Dx()*bpp)
if err != nil {
return nil, err
}
src, dst := result.Buffer.Data(), cropped.Data()
for y := 0; y < rect.Dy(); y++ {
start := (rect.Min.Y+y)*result.Buffer.Stride + rect.Min.X*bpp
copy(dst[y*cropped.Stride:y*cropped.Stride+rect.Dx()*bpp], src[start:start+rect.Dx()*bpp])
}
cropped.Format = PixelFormat(result.Format)
return &CaptureResult{Buffer: cropped, Region: region, Format: result.Format, Scale: geom.Scale, CICP: result.CICP}, nil
}
@@ -0,0 +1,119 @@
package screenshot
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"regexp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) {
executable, err := os.Executable()
if err != nil {
return aqueousSnapshotModel{}, err
}
var data json.RawMessage
if err := utils.RunJSON(ctx, executable, []string{"ipc", "call", "aqueous", "snapshot", os.Getenv("AQUEOUS_SOCKET")}, nil, &data); err != nil {
return aqueousSnapshotModel{}, fmt.Errorf("get Aqueous state from DMS; a shell must be running in this session: %w", err)
}
return parseAqueousSnapshot(data)
}
type aqueousBox struct {
X, Y, Width, Height int64
}
func (b aqueousBox) valid() bool {
for _, value := range []int64{b.X, b.Y, b.Width, b.Height} {
if value < math.MinInt32 || value > math.MaxInt32 {
return false
}
}
return true
}
type aqueousCaptureEntity struct {
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
Output string `json:"output"`
Window string `json:"window"`
FocusKind string `json:"focus_kind"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
CanActivate bool `json:"can_activate"`
Enabled bool `json:"enabled"`
Powered bool `json:"powered"`
Scale float64 `json:"scale"`
Bounds *aqueousBox `json:"bounds"`
OuterGeometry *aqueousBox `json:"outer_geometry"`
}
type aqueousSnapshotModel struct {
Entities map[string]*aqueousCaptureEntity
}
func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) {
if name != "" {
if seat := m.Entities["seat:"+name]; seat != nil {
return seat, nil
}
return nil, errors.New("not_found: seat")
}
var seat *aqueousCaptureEntity
for _, entity := range m.Entities {
if entity.Kind != "seat" {
continue
}
if seat != nil {
return nil, errors.New("ambiguous_seat")
}
seat = entity
}
if seat == nil {
return nil, errors.New("unavailable: no seat")
}
return seat, nil
}
func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) {
var batch struct {
Error string `json:"error"`
Schema int `json:"schema"`
Session string `json:"session"`
Sequence string `json:"sequence"`
BaseSequence json.RawMessage `json:"base_sequence"`
Type string `json:"type"`
Upsert []aqueousCaptureEntity `json:"upsert"`
Removed []string `json:"removed"`
}
var model aqueousSnapshotModel
if err := utils.DecodeJSON(data, &batch); err != nil {
return model, err
}
if batch.Error != "" {
return model, errors.New("aqueous state is unavailable in the running DMS shell")
}
if batch.Schema != 1 || batch.Type != "snapshot" || !bytes.Equal(batch.BaseSequence, []byte("null")) || batch.Upsert == nil || batch.Removed == nil || len(batch.Removed) != 0 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(batch.Session) || !regexp.MustCompile(`^[0-9]+$`).MatchString(batch.Sequence) {
return model, errors.New("invalid Aqueous shell snapshot")
}
model.Entities = make(map[string]*aqueousCaptureEntity, len(batch.Upsert))
for i := range batch.Upsert {
entity := &batch.Upsert[i]
key := entity.Kind + ":" + entity.ID
if entity.ID == "" || entity.Kind == "" || model.Entities[key] != nil {
return aqueousSnapshotModel{}, errors.New("invalid or duplicate shell entity")
}
model.Entities[key] = entity
}
if model.Entities["session:session"] == nil {
return aqueousSnapshotModel{}, errors.New("missing session entity")
}
return model, nil
}
+79
View File
@@ -0,0 +1,79 @@
package screenshot
import (
"bytes"
"image"
"os"
"testing"
)
func TestAqueousCropRoundsOutwardAndClips(t *testing.T) {
for _, test := range []struct {
geometry WindowGeometry
pixels image.Rectangle
want image.Rectangle
}{
{WindowGeometry{X: -1919, Y: 1, Width: 101, Height: 51, OutputX: -1920, Scale: 1.25}, image.Rect(0, 0, 2400, 1350), image.Rect(1, 1, 128, 65)},
{WindowGeometry{X: -20, Y: -10, Width: 100, Height: 60, Scale: 1.5}, image.Rect(0, 0, 800, 600), image.Rect(0, 0, 120, 75)},
{WindowGeometry{X: 90, Y: 80, Width: 100, Height: 100, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rect(90, 80, 100, 100)},
{WindowGeometry{X: 200, Y: 200, Width: 20, Height: 20, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rectangle{}},
} {
if got := aqueousCropRect(&test.geometry, test.pixels); got != test.want {
t.Fatalf("got %v want %v", got, test.want)
}
}
}
func TestAqueousGeometryUsesSeatAndOuterBounds(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
model, err := parseAqueousSnapshot(data)
if err != nil {
t.Fatal(err)
}
geometry, err := aqueousWindowGeometry(model, "")
if err != nil {
t.Fatal(err)
}
seat, _ := model.Seat("")
window := model.Entities["window:"+seat.Window]
if geometry.Width != int32(window.OuterGeometry.Width) || geometry.X != int32(window.OuterGeometry.X) {
t.Fatal("wrong bounds")
}
seat.FocusKind = "layer_surface"
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("layer focus captured")
}
seat.FocusKind = "window"
model.Entities["seat:second"] = &aqueousCaptureEntity{Kind: "seat", ID: "second"}
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("ambiguous seat captured")
}
if _, err := aqueousWindowGeometry(model, seat.ID); err != nil {
t.Fatal(err)
}
delete(model.Entities, "window:"+seat.Window)
if _, err := aqueousWindowGeometry(model, seat.ID); err == nil {
t.Fatal("removed window captured")
}
}
func TestAqueousSnapshotRejectsInvalidInput(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
for _, input := range [][]byte{
[]byte(`{}`),
bytes.Replace(data, []byte(`"schema": 1`), []byte(`"schema": 2`), 1),
bytes.Replace(data, []byte(`"type": "snapshot"`), []byte(`"type": "delta"`), 1),
append(data, []byte(`{}`)...),
{0xff},
} {
if _, err := parseAqueousSnapshot(input); err == nil {
t.Fatal("invalid snapshot accepted")
}
}
}
+13 -1
View File
@@ -1,6 +1,7 @@
package screenshot
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -21,6 +22,7 @@ const (
CompositorScroll
CompositorMiracle
CompositorMango
CompositorAqueous
)
var detectedCompositor Compositor = -1
@@ -35,6 +37,7 @@ func DetectCompositor() Compositor {
needsStat bool
compositor Compositor
}{
{os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous},
{os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango},
{os.Getenv("NIRI_SOCKET"), true, CompositorNiri},
{os.Getenv("SCROLLSOCK"), true, CompositorScroll},
@@ -78,8 +81,14 @@ func GetActiveWindow() (*WindowGeometry, error) {
return getHyprlandActiveWindow()
case CompositorMango:
return getMangoActiveWindow()
case CompositorAqueous:
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
return aqueousWindowGeometry(model, "")
default:
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
return nil, fmt.Errorf("window capture requires Hyprland, Mango, niri, or Aqueous")
}
}
@@ -355,6 +364,9 @@ func getNiriFocusedMonitor() string {
func GetFocusedMonitor() string {
switch DetectCompositor() {
case CompositorAqueous:
name, _ := aqueousFocusedOutput("")
return name
case CompositorHyprland:
return getHyprlandFocusedMonitor()
case CompositorSway:
+10
View File
@@ -220,6 +220,9 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
return s.captureAqueousWindow()
}
if DetectCompositor() == CompositorNiri {
if s.config.Geometry {
return nil, fmt.Errorf("window geometry mode is not supported on niri")
@@ -362,6 +365,13 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
}
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
name, err := aqueousFocusedOutput(s.config.Seat)
if err != nil {
return nil, err
}
return s.captureOutput(name)
}
output := s.findFocusedOutput()
if output == nil {
s.outputsMu.Lock()
+1
View File
@@ -0,0 +1 @@
aqueous-snapshot.json contains focused-window state captured from Aqueous 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated session on 2026-09-05. The session token is sanitized.
@@ -0,0 +1 @@
{"schema": 1, "session": "0123456789abcdef0123456789abcdef", "sequence": "5", "base_sequence": null, "type": "snapshot", "upsert": [{"kind": "workspace", "id": "12", "output": "2", "name": "same \"name\"", "number": 3, "active": true, "urgent": false}, {"kind": "workspace", "id": "2", "output": "1", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "session", "id": "session", "locked": false, "default_seat": "default", "overview_output": null, "overview_window": null}, {"kind": "workspace", "id": "11", "output": "2", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "output", "id": "1", "name": "HEADLESS-2", "enabled": true, "powered": true, "bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "1"}, {"kind": "workspace", "id": "18", "output": "2", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "16", "output": "2", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "15", "output": "2", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "10", "output": "2", "name": "1", "number": 1, "active": false, "urgent": false}, {"kind": "workspace", "id": "5", "output": "1", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "9", "output": "1", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "4", "output": "1", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "seat", "id": "default", "output": "1", "window": "ca89b2eb6850b81d12b92763969d82ca", "focus_kind": "window", "keyboard": "1"}, {"kind": "workspace", "id": "3", "output": "1", "name": "3", "number": 3, "active": false, "urgent": false}, {"kind": "workspace", "id": "14", "output": "2", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "7", "output": "1", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "1", "output": "1", "name": "1", "number": 1, "active": true, "urgent": false}, {"kind": "workspace", "id": "6", "output": "1", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "8", "output": "1", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "workspace", "id": "13", "output": "2", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "workspace", "id": "17", "output": "2", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "output", "id": "2", "name": "HEADLESS-1", "enabled": true, "powered": true, "bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "12"}, {"kind": "window", "id": "ca89b2eb6850b81d12b92763969d82ca", "backend": "xdg", "app_id": "aq-shell-test", "class": null, "title": "same title", "workspace": "1", "output": "1", "geometry": {"x": 10, "y": 10, "width": 628, "height": 700}, "outer_geometry": {"x": 8, "y": 8, "width": 632, "height": 704}, "focused": true, "visible": true, "floating": false, "minimized": false, "maximized": false, "fullscreen": false, "skip_taskbar": false, "skip_switcher": false, "always_above": false, "always_below": false, "snapped": false, "fixed_position": false, "layout": "tiled", "can_minimize": false, "can_maximize": false, "can_activate": true}, {"kind": "keyboard", "id": "1", "seat": "default", "layouts": ["English (US)", "German"], "index": 0}, {"kind": "keyboard_device", "id": "1", "name": "wlr_virtual_keyboard_v1", "seat": "default", "group": "1", "virtual": true}], "removed": []}
+1
View File
@@ -56,6 +56,7 @@ type Output struct {
}
type Config struct {
Seat string
Mode Mode
OutputName string
Cursor CursorMode
+69
View File
@@ -1,11 +1,19 @@
package utils
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"unicode/utf8"
)
type AppChecker interface {
@@ -115,3 +123,64 @@ func userBinDirs() []string {
dirs = append(dirs, "/usr/local/bin")
return dirs
}
const maxJSONProcessBytes = 4 * 1024 * 1024
type boundedWriter struct {
bytes.Buffer
limit int
}
func (w *boundedWriter) Write(p []byte) (int, error) {
if len(p) > w.limit-w.Len() {
return 0, errors.New("process output exceeds size bound")
}
return w.Buffer.Write(p)
}
func RunJSON(ctx context.Context, executable string, args []string, input []byte, result any) error {
if len(input) > maxJSONProcessBytes {
return errors.New("request exceeds size bound")
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, executable, args...)
cmd.WaitDelay = time.Second
stdout := &boundedWriter{limit: maxJSONProcessBytes + 1}
stderr := &boundedWriter{limit: 16 * 1024}
cmd.Stdout, cmd.Stderr = stdout, stderr
if input != nil {
cmd.Stdin = bytes.NewReader(input)
}
err := cmd.Run()
if ctx.Err() != nil {
return fmt.Errorf("unavailable: command completion uncertain: %w", ctx.Err())
}
if stdout.Len() != 0 {
if decodeErr := DecodeJSON(bytes.TrimSuffix(stdout.Bytes(), []byte{'\n'}), result); decodeErr != nil {
return errors.Join(err, fmt.Errorf("invalid process JSON: %w", decodeErr))
}
}
if err != nil {
return fmt.Errorf("%s: %w", executable, err)
}
if stdout.Len() == 0 {
return errors.New("missing command acknowledgement")
}
return nil
}
func DecodeJSON(data []byte, target any) error {
if len(data) > maxJSONProcessBytes || !utf8.Valid(data) {
return errors.New("invalid UTF-8 or oversized JSON record")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(new(any)); err != io.EOF {
return errors.New("trailing JSON data")
}
return nil
}
+58
View File
@@ -1,10 +1,13 @@
package utils
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -67,3 +70,58 @@ func TestEnvWithUserBinPathPrependsLocalBin(t *testing.T) {
assert.Contains(t, parts, "/usr/local/bin")
assert.Contains(t, env, "OTHER=value")
}
func TestJSONProcessFixture(t *testing.T) {
mode := os.Getenv("DMS_JSON_PROCESS_FIXTURE")
if mode == "" {
return
}
switch mode {
case "accepted":
fmt.Print(`{"ok":true,"status":"accepted","sequence":"9007199254740993"}`)
case "failure":
fmt.Print(`{"ok":false,"status":"locked","sequence":"1"}`)
os.Exit(1)
case "malformed":
fmt.Print("invalid JSON")
case "timeout":
time.Sleep(time.Minute)
case "oversize":
fmt.Print(strings.Repeat("x", maxJSONProcessBytes+2))
}
os.Exit(0)
}
func TestRunJSONResultsAndTimeout(t *testing.T) {
for _, mode := range []string{"accepted", "failure", "malformed", "oversize", "timeout"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("DMS_JSON_PROCESS_FIXTURE", mode)
limit := 4 * time.Second
if mode == "timeout" {
limit = 100 * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()
var result jsonProcessResult
err := RunJSON(ctx, os.Args[0], []string{"-test.run=^TestJSONProcessFixture$"}, nil, &result)
if mode == "accepted" {
if err != nil || !result.OK || result.Status != "accepted" || result.Sequence != "9007199254740993" {
t.Fatalf("lost acknowledgement: %v %#v", err, result)
}
return
}
if err == nil {
t.Fatal("failure accepted")
}
if mode == "failure" && result.Status != "locked" {
t.Fatal("operation failure lost")
}
})
}
}
type jsonProcessResult struct {
OK bool `json:"ok"`
Status string `json:"status"`
Sequence string `json:"sequence"`
}
+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;
}
+4
View File
@@ -1863,6 +1863,8 @@ Item {
function open(): string {
root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) {
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
@@ -1885,6 +1887,8 @@ Item {
root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED";
}
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
+39 -1
View File
@@ -9,6 +9,8 @@ DankFloatingWindow {
readonly property var log: Log.scoped("WorkspaceRenameModal")
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property var aqueousWorkspace: null
property bool renaming: false
objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace")
@@ -19,9 +21,24 @@ DankFloatingWindow {
onClosed: hide()
function show(name) {
if (CompositorService.isAqueous) {
if (renaming)
return false;
const workspace = AqueousService.workspaces.find(ws => ws.output === AqueousService.seat?.output && ws.active);
if (!AqueousService.available || !AqueousService.seat || !workspace || !AqueousService.capabilities.commands || AqueousService.locked) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Unavailable"));
return false;
}
aqueousWorkspace = {
id: workspace.id,
session: workspace.aqueousSession
};
name = workspace.name;
}
nameInput.text = name;
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
return true;
}
function hide() {
@@ -29,6 +46,24 @@ DankFloatingWindow {
}
function submitAndClose() {
if (aqueousWorkspace || CompositorService.isAqueous) {
if (!aqueousWorkspace || renaming)
return;
const target = aqueousWorkspace;
renaming = true;
AqueousService.command("workspace.rename", {
id: target.id,
session: target.session,
name: nameInput.text
}, success => {
if (root.aqueousWorkspace !== target)
return;
root.renaming = false;
if (success)
root.hide();
});
return;
}
renameWorkspace(nameInput.text);
hide();
}
@@ -49,6 +84,8 @@ DankFloatingWindow {
return;
}
nameInput.text = "";
aqueousWorkspace = null;
renaming = false;
}
FocusScope {
@@ -133,7 +170,7 @@ DankFloatingWindow {
textColor: Theme.surfaceText
placeholderText: I18n.tr("Workspace name")
backgroundColor: "transparent"
enabled: root.visible
enabled: root.visible && !root.renaming
onAccepted: submitAndClose()
}
}
@@ -190,6 +227,7 @@ DankFloatingWindow {
MouseArea {
id: renameArea
enabled: !root.renaming
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
+6 -6
View File
@@ -496,7 +496,7 @@ Item {
hasMaximizedToplevel = false;
return;
}
if (!CompositorService.isHyprland && !CompositorService.isNiri) {
if (!CompositorService.isHyprland && !CompositorService.isNiri && !CompositorService.isAqueous) {
hasMaximizedToplevel = false;
return;
}
@@ -520,7 +520,7 @@ Item {
shouldHideForWindows = false;
return;
}
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) {
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous) {
shouldHideForWindows = false;
return;
}
@@ -795,7 +795,7 @@ Item {
readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr)
readonly property int lengthPaddingPx: Theme.px(barWindow.effectiveBarLengthPadding, barWindow._dpr)
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool inOverviewWithShow: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow)
@@ -986,7 +986,7 @@ Item {
}
property bool reveal: {
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
const inOverviewWithShow = CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview;
if (inOverviewWithShow)
return true;
@@ -997,7 +997,7 @@ Item {
return true;
}
if (CompositorService.isNiri && NiriService.inOverview)
if (CompositorService.overviewActiveOnScreen(barWindow.screenName))
return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal;
return (barConfig?.visible ?? true) && (!autoHide || hoverReveal || popoutPinsReveal || revealSticky || ipcReveal);
@@ -1062,7 +1062,7 @@ Item {
top: barWindow.isVertical ? parent.top : undefined
bottom: barWindow.isVertical ? parent.bottom : undefined
}
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool inOverview: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.popoutPinsReveal
acceptedButtons: barWindow.clickThroughEnabled ? Qt.NoButton : Qt.RightButton
enabled: !inOverview && ((barConfig?.autoHide ?? false) || !barWindow.clickThroughEnabled)
+13 -2
View File
@@ -156,6 +156,8 @@ Item {
function getRealWorkspaces() {
const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.workspacesForOutput(SettingsData.workspaceFollowFocus ? AqueousService.focusedOutput : screenName);
if (CompositorService.isNiri) {
const fallbackWorkspaces = [
{
@@ -238,6 +240,8 @@ Item {
function getCurrentWorkspace() {
const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return getRealWorkspaces().find(ws => ws.active)?.id || "";
if (CompositorService.isNiri) {
if (!screenName || SettingsData.workspaceFollowFocus) {
return NiriService.getCurrentWorkspaceNumber();
@@ -279,7 +283,14 @@ Item {
return;
}
if (CompositorService.isNiri) {
if (CompositorService.isAqueous && AqueousService.available) {
const index = realWorkspaces.findIndex(ws => ws.id === getCurrentWorkspace());
if (index < 0)
return;
const next = Math.max(0, Math.min(realWorkspaces.length - 1, index + (direction > 0 ? 1 : -1)));
if (next !== index)
AqueousService.activateWorkspace(realWorkspaces[next]);
} else if (CompositorService.isNiri) {
const currentWs = getCurrentWorkspace();
const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs);
const validIndex = currentIndex === -1 ? 0 : currentIndex;
@@ -323,7 +334,7 @@ Item {
}
function switchApp(deltaY) {
const windows = sortedToplevels;
const windows = sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2) {
return;
}
+1 -1
View File
@@ -67,7 +67,7 @@ PanelWindow {
implicitWidth: body.surfaceImplicitWidth
exclusiveZone: body.surfaceExclusiveZone
BackgroundEffect.blurRegion: body.blurRegion
BackgroundEffect.blurRegion: BlurService.enabled ? body.blurRegion : null
Component.onCompleted: {
KeyboardFocus.registerBarWindow(barWindow);
@@ -160,7 +160,7 @@ PanelWindow {
return [];
const toplevels = [];
const allToplevels = ToplevelManager.toplevels.values;
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i];
if (toplevel.appId === root.appData.appId) {
@@ -31,7 +31,7 @@ BasePill {
property int availableWidth: maxWidth
readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth))
readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2)
property Toplevel activeWindow: null
property var activeWindow: null
property var activeDesktopEntry: null
property bool isHovered: mouseArea.containsMouse
property bool isAutoHideBar: false
@@ -99,6 +99,11 @@ BasePill {
}
function updateActiveWindow() {
if (CompositorService.isAqueous && AqueousService.available) {
const focused = AqueousService.focusedWindow;
activeWindow = focused && (!parentScreen || focused.screens.some(s => s.name === parentScreen.name)) ? focused : null;
return;
}
let active = ToplevelManager.activeToplevel;
if (!active && CompositorService.isNiri) {
@@ -16,6 +16,8 @@ BasePill {
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"]
property string currentLayout: {
if (CompositorService.isAqueous)
return AqueousService.keyboardLayout;
if (CompositorService.isNiri) {
return NiriService.getCurrentKeyboardLayoutName();
} else if (CompositorService.isMango) {
@@ -25,7 +27,7 @@ BasePill {
}
property string hyprlandKeyboard: ""
property var hyprlandLayoutLabels: []
readonly property var _allLayoutLabels: CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
readonly property var _allLayoutLabels: CompositorService.isAqueous ? AqueousService.keyboardLayouts.map(n => displayLabel(n)) : CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
readonly property string reserveLabel: widestLabel(_allLayoutLabels)
readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n)))
@@ -131,6 +133,8 @@ BasePill {
onClicked: {
if (CompositorService.isNiri) {
NiriService.cycleKeyboardLayout();
} else if (CompositorService.isAqueous) {
AqueousService.cycleKeyboardLayout();
} else if (CompositorService.isHyprland) {
Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]);
} else if (CompositorService.isMango) {
@@ -163,7 +163,7 @@ BasePill {
const deltaY = wheelEvent.angleDelta.y;
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
const windows = root.sortedToplevels;
const windows = root.sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2)
return;
@@ -236,7 +236,7 @@ BasePill {
id: windowRepeater
model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address"
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
}
delegate: Item {
@@ -494,7 +494,7 @@ BasePill {
id: windowRepeater
model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address"
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
}
delegate: Item {
@@ -24,6 +24,7 @@ Item {
property real crossEdgeExtension: 0
readonly property bool isMango: CompositorService.isMango
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1"
readonly property real _leftMargin: {
if (isVertical)
@@ -82,8 +83,10 @@ Item {
}
readonly property bool useUnfocusedAppearance: !isFocusedMonitor && SettingsData.workspaceUnfocusedMonitorSeparateAppearance && BarWidgetService.focusedScreenDetectionSupported
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(parentScreen) : null
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(CompositorService.isAqueous ? Quickshell.screens.find(s => s.name === effectiveScreenName) || parentScreen : parentScreen) : null
readonly property bool useExtWorkspace: {
if (useAqueous)
return false;
if (Quickshell.env("DMS_FORCE_EXTWS") === "1")
return (WindowManager.windowsets?.length ?? 0) > 0;
if (!CompositorService.compositorDetected)
@@ -117,6 +120,8 @@ Item {
}
property var currentWorkspace: {
if (useAqueous)
return AqueousService.workspacesForOutput(effectiveScreenName).find(w => w.active)?.id || "";
if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace();
@@ -143,6 +148,10 @@ Item {
return [];
}
property var workspaceList: {
if (useAqueous) {
const baseList = AqueousService.workspacesForOutput(effectiveScreenName).filter(ws => !SettingsData.showOccupiedWorkspacesOnly || ws.active || AqueousService.toplevels.some(w => w.aqueousWorkspaceId === ws.id));
return SettingsData.showWorkspacePadding && baseList.length ? padWorkspaces(baseList) : baseList;
}
if (useExtWorkspace) {
const baseList = getExtWorkspaceWorkspaces();
return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList;
@@ -322,12 +331,14 @@ Item {
function getWorkspaceIcons(ws) {
_desktopEntriesUpdateTrigger;
if (!SettingsData.showWorkspaceApps || !ws) {
if (!SettingsData.showWorkspaceApps || !ws || useExtWorkspace || (useAqueous && ws._placeholder)) {
return [];
}
let targetWorkspaceId;
if (CompositorService.isNiri) {
if (useAqueous) {
targetWorkspaceId = ws.id;
} else if (CompositorService.isNiri) {
if (!ws || typeof ws !== "object") {
const wsNumber = typeof ws === "number" ? ws : -1;
if (wsNumber <= 0) {
@@ -387,7 +398,9 @@ Item {
return;
} else {
let winWs = null;
if (CompositorService.isNiri) {
if (useAqueous) {
winWs = w.aqueousWorkspaceId;
} else if (CompositorService.isNiri) {
winWs = w.workspace_id;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
winWs = w.workspace?.num;
@@ -405,7 +418,7 @@ Item {
const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown");
const moddedId = Paths.moddedAppId(keyBase);
const groupThisWs = SettingsData.groupWorkspaceApps && (!isActiveWs || SettingsData.groupActiveWorkspaceApps);
const key = groupThisWs ? moddedId : `${moddedId}_${i}`;
const key = groupThisWs ? moddedId : (w.aqueousKey || `${moddedId}_${i}`);
if (!byApp[key]) {
const isQuickshell = keyBase === "org.quickshell" || keyBase === "com.danklinux.dms";
@@ -421,6 +434,7 @@ Item {
"active": !!((w.activated || w.is_focused) || (CompositorService.isNiri && w.is_focused)),
"count": 1,
"windowId": w.address || w.id,
"windowSession": useAqueous ? w.aqueousSession : "",
"fallbackText": appName || ""
};
} else {
@@ -435,7 +449,7 @@ Item {
}
function _makePlaceholder() {
if (useExtWorkspace)
if (useAqueous || useExtWorkspace)
return {
"id": "",
"name": "",
@@ -648,7 +662,7 @@ Item {
if (!extProjection)
return "";
const activeWs = extProjection.windowsets.find(ws => ws.active);
return activeWs ? (activeWs.id || activeWs.name || "") : "";
return activeWs || null;
}
readonly property real dpr: parentScreen ? CompositorService.getScreenScale(parentScreen) : 1
@@ -659,7 +673,7 @@ Item {
function getRealWorkspaces() {
return root.workspaceList.filter(ws => {
if (useExtWorkspace)
if (useAqueous || useExtWorkspace)
return ws && !ws._placeholder;
if (CompositorService.isNiri)
return ws && ws.idx !== -1;
@@ -674,9 +688,14 @@ Item {
}
function switchToWorkspaceByModelData(data) {
if (!data)
if (!data || data._placeholder)
return;
if (useAqueous) {
AqueousService.activateWorkspace(data);
return;
}
if (root.useExtWorkspace) {
if (typeof data.activate === "function")
data.activate();
@@ -727,13 +746,21 @@ Item {
}
function switchWorkspace(direction) {
if (useAqueous) {
const workspaces = getRealWorkspaces();
const index = workspaces.findIndex(w => w.id === currentWorkspace);
const next = Math.max(0, Math.min(workspaces.length - 1, index + (direction > 0 ? 1 : -1)));
if (next !== index)
AqueousService.activateWorkspace(workspaces[next]);
return;
}
if (useExtWorkspace) {
const realWorkspaces = getRealWorkspaces();
if (realWorkspaces.length < 2) {
return;
}
const currentIndex = realWorkspaces.findIndex(ws => (ws.id || ws.name) === root.currentWorkspace);
const currentIndex = realWorkspaces.findIndex(ws => ws === root.currentWorkspace);
const validIndex = currentIndex === -1 ? 0 : currentIndex;
const nextIndex = direction > 0 ? Math.min(validIndex + 1, realWorkspaces.length - 1) : Math.max(validIndex - 1, 0);
@@ -812,6 +839,8 @@ Item {
}
function getWorkspaceIndexFallback(modelData, index) {
if (useAqueous)
return modelData?.number ?? "";
if (root.useExtWorkspace)
return index + 1;
if (CompositorService.isNiri)
@@ -827,8 +856,8 @@ Item {
function getWorkspaceIndex(modelData, index) {
let isPlaceholder;
if (root.useExtWorkspace) {
isPlaceholder = modelData?.hidden === true;
if (root.useAqueous || root.useExtWorkspace) {
isPlaceholder = modelData?._placeholder === true;
} else if (CompositorService.isNiri) {
isPlaceholder = modelData?.idx === -1;
} else if (CompositorService.isHyprland) {
@@ -868,7 +897,7 @@ Item {
return getWorkspaceIndexFallback(modelData, index);
}
readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool hasNativeWorkspaceSupport: useAqueous || CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool hasWorkspaces: getRealWorkspaces().length > 0
readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces)
@@ -962,7 +991,9 @@ Item {
const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y);
switch (mouse.button) {
case Qt.RightButton:
if (CompositorService.isNiri) {
if (CompositorService.isAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1144,8 +1175,10 @@ Item {
}
property bool isActive: {
if (root.useAqueous)
return modelData?.active === true;
if (root.useExtWorkspace)
return (modelData?.id || modelData?.name) === root.currentWorkspace;
return modelData === root.currentWorkspace;
if (CompositorService.isNiri)
return !!(modelData && modelData.idx === root.currentWorkspace);
if (CompositorService.isHyprland)
@@ -1157,6 +1190,8 @@ Item {
return modelData === root.currentWorkspace;
}
property bool isOccupied: {
if (root.useAqueous)
return AqueousService.toplevels.some(w => w.aqueousWorkspaceId === modelData?.id);
if (CompositorService.isHyprland)
return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id);
if (root.isMango)
@@ -1166,7 +1201,7 @@ Item {
return false;
}
property bool isPlaceholder: {
if (root.useExtWorkspace)
if (root.useAqueous || root.useExtWorkspace)
return !!(modelData && modelData._placeholder);
if (CompositorService.isNiri)
return !!(modelData && modelData.idx === -1);
@@ -1183,6 +1218,8 @@ Item {
property var loadedWorkspaceData: null
property bool loadedIsUrgent: false
property bool isUrgent: {
if (root.useAqueous)
return modelData?.urgent === true;
if (root.useExtWorkspace)
return modelData?.urgent ?? false;
if (CompositorService.isHyprland)
@@ -1210,6 +1247,9 @@ Item {
if (!SettingsData.showWorkspaceApps || isPlaceholder)
return 0;
if (root.useAqueous)
return loadedIcons.length;
let targetWorkspaceId;
if (root.useExtWorkspace) {
targetWorkspaceId = modelData?.id || modelData?.name;
@@ -1267,7 +1307,7 @@ Item {
readonly property real baseWidth: root.isVertical ? (SettingsData.showWorkspaceApps ? Math.max(widgetHeight * 0.7, root.appIconSize + Theme.spacingXS * 2) : widgetHeight * 0.5) : (isActive ? Math.max(root.widgetHeight * 1.05, root.appIconSize * 1.6) : Math.max(root.widgetHeight * 0.7, root.appIconSize * 1.2))
readonly property real baseHeight: root.isVertical ? (isActive ? Math.max(root.widgetHeight * 1.05, root.appIconSize * 1.6) : Math.max(root.widgetHeight * 0.7, root.appIconSize * 1.2)) : (SettingsData.showWorkspaceApps ? Math.max(widgetHeight * 0.7, root.appIconSize + Theme.spacingXS * 2) : widgetHeight * 0.5)
readonly property bool hasWorkspaceName: SettingsData.showWorkspaceName && modelData?.name && modelData.name !== ""
readonly property bool workspaceNamesEnabled: SettingsData.showWorkspaceName && (CompositorService.isNiri || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
readonly property bool workspaceNamesEnabled: SettingsData.showWorkspaceName && (root.useAqueous || CompositorService.isNiri || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle)
readonly property real contentImplicitWidth: appIconsLoader.item?.contentWidth ?? 0
readonly property real contentImplicitHeight: appIconsLoader.item?.contentHeight ?? 0
@@ -1481,7 +1521,9 @@ Item {
if (mouse.button === Qt.LeftButton) {
if (delegateRoot.focusWindowAt(mouse.x, mouse.y))
return;
if (root.useExtWorkspace) {
if (root.useAqueous) {
root.switchToWorkspaceByModelData(modelData);
} else if (root.useExtWorkspace) {
if (typeof modelData?.activate === "function")
modelData.activate();
} else if (CompositorService.isNiri) {
@@ -1496,7 +1538,9 @@ Item {
CompositorService.dispatchSwayWorkspace(modelData);
}
} else if (mouse.button === Qt.RightButton) {
if (CompositorService.isNiri) {
if (CompositorService.isAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1519,7 +1563,7 @@ Item {
}
var wsData = null;
if (root.useExtWorkspace) {
if (root.useAqueous || root.useExtWorkspace) {
wsData = modelData;
} else if (CompositorService.isNiri) {
wsData = modelData || null;
@@ -1561,13 +1605,20 @@ Item {
if (!layout)
return null;
const point = layout.mapFromItem(mouseArea, x, y);
return layout.childAt(point.x, point.y)?.windowId ?? null;
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;
@@ -1770,6 +1821,7 @@ Item {
width: root.appIconSize
height: root.appIconSize
readonly property var windowId: modelData.windowId
readonly property string windowSession: modelData.windowSession || ""
readonly property bool appHighlightActive: SettingsData.workspaceActiveAppHighlightEnabled && modelData.active
readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected
readonly property color appGlyphColor: appHighlightActive ? focusedBorderColor : Theme.primary
@@ -1927,6 +1979,7 @@ Item {
width: root.appIconSize
height: root.appIconSize
readonly property var windowId: modelData.windowId
readonly property string windowSession: modelData.windowSession || ""
readonly property bool appHighlightActive: SettingsData.workspaceActiveAppHighlightEnabled && modelData.active
readonly property color appBorderColor: appHighlightActive ? focusedBorderColor : Theme.primarySelected
readonly property color appGlyphColor: appHighlightActive ? focusedBorderColor : Theme.primary
@@ -2123,6 +2176,13 @@ Item {
delegateRoot.updateAllData();
}
}
Connections {
target: AqueousService
function onStateChanged() {
if (root.useAqueous)
delegateRoot.updateAllData();
}
}
property var _extWindowsetsTrigger: root.useExtWorkspace ? WindowManager.windowsets : null
on_ExtWindowsetsTriggerChanged: {
if (root.useExtWorkspace)
+1 -1
View File
@@ -96,7 +96,7 @@ Item {
return toplevel.activated;
} else if (appData.type === "grouped") {
// For grouped apps, check if any window is focused
const allToplevels = ToplevelManager.toplevels.values;
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i];
if (toplevel.appId === appData.appId && toplevel.activated) {
+1 -1
View File
@@ -262,7 +262,7 @@ Item {
const separatePinnedAndRunning = SettingsData.dockSeparatePinnedAndRunningApps;
sortedToplevels.forEach((toplevel, index) => {
let uniqueKey = "window_" + index;
let uniqueKey = toplevel.aqueousKey || "window_" + index;
if (CompositorService.isHyprland && Hyprland.toplevels) {
const hyprlandToplevels = Array.from(Hyprland.toplevels.values);
for (let i = 0; i < hyprlandToplevels.length; i++) {
+5 -2
View File
@@ -249,7 +249,7 @@ Item {
readonly property bool shouldHideForWindows: {
if (!SettingsData.dockSmartAutoHide)
return false;
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango)
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous)
return false;
const screenName = dock.modelData?.name ?? "";
@@ -313,6 +313,9 @@ Item {
return false;
}
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.overlapsDock(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight);
if (CompositorService.isMango) {
MangoService.windows;
MangoService.outputs;
@@ -348,7 +351,7 @@ Item {
onTriggered: dock.startupRevealDone = true
}
readonly property bool overviewReveal: CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(screenName) && SettingsData.dockOpenOnOverview
readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
onOverviewRevealChanged: {
+1 -1
View File
@@ -87,7 +87,7 @@ PanelWindow {
readonly property bool _connectedActive: CompositorService.usesConnectedFrameChromeForScreen(win.targetScreen)
readonly property bool _dockHostedHere: {
const dockVisible = SettingsData.showDock || (CompositorService.isNiri && SettingsData.dockOpenOnOverview);
const dockVisible = SettingsData.showDock || ((CompositorService.isNiri || CompositorService.isAqueous) && SettingsData.dockOpenOnOverview);
if (!win._connectedActive || !dockVisible || SettingsData.dockUseOverlayLayer)
return false;
const screens = SettingsData.getFilteredScreens("dock");
+15 -2
View File
@@ -18,6 +18,7 @@ Item {
property bool isMiracle: CompositorService.isMiracle
property bool isMango: CompositorService.isMango
property bool isLabwc: CompositorService.isLabwc
property bool isAqueous: CompositorService.isAqueous
property string compositorName: {
if (isHyprland)
@@ -32,6 +33,8 @@ Item {
return "mangowc";
if (isLabwc)
return "labwc";
if (isAqueous)
return "aqueous";
return "niri";
}
@@ -48,6 +51,8 @@ Item {
return "/assets/mango.png";
if (isLabwc)
return "/assets/labwc.png";
if (isAqueous)
return "/assets/aqueous.svg";
return "/assets/niri.svg";
}
@@ -64,6 +69,8 @@ Item {
return "https://github.com/DreamMaoMao/mangowc";
if (isLabwc)
return "https://labwc.github.io/";
if (isAqueous)
return "";
return "https://github.com/niri-wm/niri";
}
@@ -80,6 +87,8 @@ Item {
return I18n.tr("mangowc GitHub");
if (isLabwc)
return I18n.tr("LabWC Website");
if (isAqueous)
return "Aqueous";
return I18n.tr("niri GitHub");
}
@@ -376,11 +385,15 @@ Item {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
cursorShape: compositorUrl === "" ? Qt.ArrowCursor : Qt.PointingHandCursor
hoverEnabled: true
onEntered: parent.hovered = true
onExited: parent.hovered = false
onClicked: Qt.openUrlExternally(compositorUrl)
onClicked: {
if (compositorUrl === "")
return;
Qt.openUrlExternally(compositorUrl);
}
}
}
+3 -3
View File
@@ -874,15 +874,15 @@ Item {
height: 1
color: Theme.outline
opacity: 0.15
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
}
SettingsToggleRow {
settingKey: "barOpenOnOverview"
tags: ["bar", "overview", "niri", "show", "frame"]
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
text: I18n.tr("Show on Overview")
description: dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
checked: dankBarTab.selectedBarFrameStyled ? SettingsData.frameShowOnOverview : (selectedBarConfig?.openOnOverview ?? false)
onToggled: toggled => {
if (dankBarTab.selectedBarFrameStyled) {
@@ -41,6 +41,197 @@ Singleton {
property bool hasPendingChanges: Object.keys(pendingChanges).length > 0 || Object.keys(pendingNiriChanges).length > 0 || Object.keys(pendingHyprlandChanges).length > 0 || formatChanged
property bool validatingConfig: false
property var _cancelOutputWrite: null
property var aqueousPreview: null
function outputFingerprint(outputs) {
return JSON.stringify(outputs.map(o => ({
name: o.name,
id: o.id,
enabled: o.enabled,
x: o.x,
y: o.y,
scale: o.scale,
transform: o.transform,
mode: o.currentMode ? [o.currentMode.width, o.currentMode.height, o.currentMode.refresh] : null,
adaptiveSync: o.adaptiveSync
})).sort((a, b) => a.name.localeCompare(b.name)));
}
function outputHeads(outputs) {
return outputs.map(o => ({
name: o.name,
enabled: o.enabled,
modeId: o.currentMode?.id,
position: {
x: o.x,
y: o.y
},
scale: o.scale,
transform: o.transform,
adaptiveSync: o.adaptiveSync
}));
}
function outputHeadsMatch(candidate, actual, original) {
if (candidate.length !== actual.length || original.length !== actual.length || original.some(o => !actual.some(a => a.id === o.id && a.name === o.name)))
return false;
return candidate.every(h => {
const output = actual.find(o => o.name === h.name);
if (!output || output.enabled !== h.enabled)
return false;
if (!h.enabled)
return true;
const mode = h.customMode || original.find(o => o.name === h.name)?.modes?.find(m => m.id === h.modeId);
return output.x === h.position.x && output.y === h.position.y && Math.abs(output.scale - h.scale) < 0.0001 && output.transform === h.transform && (h.adaptiveSync === undefined || output.adaptiveSync === h.adaptiveSync) && !!mode && output.currentMode?.width === mode.width && output.currentMode?.height === mode.height && output.currentMode?.refresh === mode.refresh;
});
}
function freshAqueousOutputs(callback) {
DMSService.sendRequest("wlroutput.getState", null, response => {
callback(response.result?.outputs || null, response.error || "");
}, 5000);
}
function aqueousDisplayError(message) {
validatingConfig = false;
validationError = AqueousService.errorMessage(message);
ToastService.showError(I18n.tr("Error"), validationError, message);
}
function discardAqueousPreview() {
if (validatingConfig)
return;
aqueousPreview = null;
restoreDisplayNameMode();
validationError = "";
clearPendingChanges();
WlrOutputService.requestState();
}
function previewAqueousOutputs(descriptions) {
if (validatingConfig)
return;
if (!AqueousService.available) {
aqueousDisplayError("unavailable: compositor state");
return;
}
if (aqueousPreview) {
observeAqueousPreview(aqueousPreview, descriptions, "");
return;
}
const session = AqueousService.session;
validatingConfig = true;
AqueousConfigService.load((snapshot, error) => {
if (!snapshot) {
aqueousDisplayError(error);
return;
}
freshAqueousOutputs((original, error) => {
if (AqueousService.session !== session) {
aqueousDisplayError("conflict: compositor session changed");
return;
}
if (!original) {
aqueousDisplayError(error);
return;
}
const candidate = WlrOutputService.outputsConfigHeads(buildOutputsWithPendingChanges(), outputs);
WlrOutputService.testConfiguration(candidate, (success, message) => {
if (AqueousService.session !== session) {
aqueousDisplayError("conflict: compositor session changed");
return;
}
if (!success) {
aqueousDisplayError(message);
return;
}
const preview = {
snapshot: snapshot,
original: original,
heads: candidate,
fingerprint: null,
session: session
};
aqueousPreview = preview;
WlrOutputService.applyConfiguration(candidate, (success, message) => {
observeAqueousPreview(preview, descriptions, success ? "" : message);
});
});
});
});
}
function observeAqueousPreview(preview, descriptions, applyError) {
validatingConfig = true;
freshAqueousOutputs((actual, error) => {
if (aqueousPreview !== preview)
return;
if (!actual || AqueousService.session !== preview.session || !outputHeadsMatch(preview.heads, actual, preview.original)) {
if (actual && outputFingerprint(actual) === outputFingerprint(preview.original))
aqueousPreview = null;
aqueousDisplayError(error || applyError || "conflict: display preview changed externally");
return;
}
preview.fingerprint = outputFingerprint(actual);
validatingConfig = false;
validationError = applyError ? AqueousService.errorMessage(applyError) : "";
changesApplied(descriptions);
});
}
function finishAqueousPreview(keep) {
if (validatingConfig || !aqueousPreview)
return;
const preview = aqueousPreview;
validatingConfig = true;
freshAqueousOutputs((actual, error) => {
if (!actual || AqueousService.session !== preview.session || outputFingerprint(actual) !== preview.fingerprint) {
aqueousDisplayError(error || "conflict: output configuration changed after preview");
return;
}
if (keep) {
let draft;
try {
draft = AqueousConfigService.buildOutputsConfig(preview.snapshot, actual, preview.original);
} catch (e) {
aqueousDisplayError(String(e));
return;
}
AqueousConfigService.apply(draft, (snapshot, message) => {
if (!snapshot) {
aqueousDisplayError(message);
return;
}
aqueousPreview = null;
if (formatChanged)
SettingsData.saveSettings();
clearPendingChanges();
freshAqueousOutputs((live, error) => {
validatingConfig = false;
if (!live || AqueousService.session !== preview.session || outputFingerprint(live) !== preview.fingerprint) {
aqueousDisplayError("conflict: configuration saved but live display state changed");
return;
}
changesConfirmed();
});
});
return;
}
WlrOutputService.applyConfiguration(outputHeads(preview.original), (success, message) => {
if (!success) {
aqueousDisplayError(message);
return;
}
validatingConfig = false;
aqueousPreview = null;
restoreDisplayNameMode();
clearPendingChanges();
WlrOutputService.requestState();
changesReverted();
});
});
}
property string validationError: ""
property var currentOutputSet: []
@@ -1707,9 +1898,21 @@ Singleton {
MangoService.generateOutputsConfig(outputsData, finish);
break;
default:
WlrOutputService.applyOutputsConfig(outputsData, outputs);
finish(true);
break;
{
if (_cancelOutputWrite)
_cancelOutputWrite();
let completed = false;
const complete = success => {
if (completed)
return;
completed = true;
root._cancelOutputWrite = null;
finish(success);
};
_cancelOutputWrite = () => complete(false);
WlrOutputService.applyOutputsConfig(outputsData, outputs, complete);
break;
}
}
return true;
}
@@ -2145,11 +2348,15 @@ Singleton {
originalDisplayNameMode = "";
}
function restoreDisplayNameMode() {
if (originalDisplayNameMode === "")
return;
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
function discardChanges() {
if (originalDisplayNameMode !== "") {
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
restoreDisplayNameMode();
backendFetchOutputs();
clearPendingChanges();
}
@@ -2221,16 +2428,32 @@ Singleton {
return;
}
changesApplied(changeDescriptions);
if (formatChanged)
SettingsData.saveSettings();
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
if (CompositorService.isAqueous) {
previewAqueousOutputs(changeDescriptions);
return;
}
const mergedOutputs = buildOutputsWithPendingChanges();
backendWriteOutputsConfig(mergedOutputs);
if (CompositorService.isHyprland || CompositorService.isMango) {
changesApplied(changeDescriptions);
if (formatChanged)
SettingsData.saveSettings();
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
backendWriteOutputsConfig(mergedOutputs);
return;
}
validatingConfig = true;
backendWriteOutputsConfig(mergedOutputs, success => {
validatingConfig = false;
if (!success) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to apply profile"));
return;
}
if (formatChanged)
SettingsData.saveSettings();
changesApplied(changeDescriptions);
});
}
function validateAndApplyNiriConfig(changeDescriptions) {
@@ -2341,6 +2564,10 @@ Singleton {
}
function confirmChanges(profileId) {
if (CompositorService.isAqueous) {
finishAqueousPreview(true);
return;
}
const outputConfigs = buildCurrentOutputConfigs();
lastAppliedEntry = {
outputs: outputConfigs
@@ -2372,14 +2599,15 @@ Singleton {
}
function revertChanges() {
if (CompositorService.isAqueous && aqueousPreview) {
finishAqueousPreview(false);
return;
}
const hadFormatChange = originalDisplayNameMode !== "";
const hadNiriChanges = originalNiriSettings !== null;
const hadHyprlandChanges = originalHyprlandSettings !== null;
if (hadFormatChange) {
SettingsData.displayNameMode = originalDisplayNameMode;
SettingsData.saveSettings();
}
restoreDisplayNameMode();
if (hadNiriChanges) {
SessionData.niriOutputSettings = JSON.parse(JSON.stringify(originalNiriSettings));
@@ -99,6 +99,25 @@ Item {
width: parent.width
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: CompositorService.isAqueous && DisplayConfigState.validationError !== ""
StyledText {
width: parent.width
text: DisplayConfigState.validationError
color: Theme.error
wrapMode: Text.WordWrap
}
DankButton {
text: I18n.tr("Discard draft and reload", "Discard unsaved Aqueous display settings and refresh the current display state")
enabled: !DisplayConfigState.validatingConfig
onClicked: DisplayConfigState.discardAqueousPreview()
}
}
StyledRect {
width: parent.width
height: profileSection.implicitHeight + Theme.spacingL * 2
+3 -3
View File
@@ -70,7 +70,7 @@ Item {
text: I18n.tr("Intelligent Auto-hide")
description: I18n.tr("Show dock when floating windows don't overlap its area")
checked: SettingsData.dockSmartAutoHide
visible: SettingsData.showDock && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango)
visible: SettingsData.showDock && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous)
onToggled: checked => {
if (checked && SettingsData.dockAutoHide) {
SettingsData.set("dockAutoHide", false);
@@ -83,9 +83,9 @@ Item {
settingKey: "dockOpenOnOverview"
tags: ["dock", "overview", "niri"]
text: I18n.tr("Show on Overview")
description: I18n.tr("Always show the dock when niri's overview is open")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : I18n.tr("Always show the dock when niri's overview is open")
checked: SettingsData.dockOpenOnOverview
visible: CompositorService.isNiri
visible: CompositorService.isNiri || CompositorService.isAqueous
onToggled: checked => SettingsData.set("dockOpenOnOverview", checked)
}
+2 -2
View File
@@ -369,13 +369,13 @@ Item {
settingKey: "frameBarIntegration"
collapsible: true
expanded: true
visible: SettingsData.frameEnabled && CompositorService.isNiri
visible: SettingsData.frameEnabled && (CompositorService.isNiri || CompositorService.isAqueous)
SettingsToggleRow {
settingKey: "frameShowOnOverview"
tags: ["frame", "overview", "show", "hide", "niri"]
text: I18n.tr("Show on Overview")
description: I18n.tr("Show during Niri overview")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : I18n.tr("Show during Niri overview")
checked: SettingsData.frameShowOnOverview
onToggled: checked => SettingsData.set("frameShowOnOverview", checked)
}
+33 -8
View File
@@ -22,6 +22,9 @@ Item {
property bool recording: false
property bool isNew: false
property bool readOnly: false
property bool saveBlocked: false
property var retainedEdit: null
readonly property bool retainEdits: retainedEdit !== null
property string restoreKey: ""
property int editingKeyIndex: -1
@@ -70,6 +73,7 @@ Item {
signal removeBind(string key)
signal resetBind(string key)
signal cancelEdit
signal editChanged
clip: true
@@ -91,7 +95,9 @@ Item {
}
Component.onCompleted: {
if (isNew && isExpanded)
if (retainedEdit && isExpanded)
restoreRetainedEdit();
else if (isNew && isExpanded)
resetEdits();
}
@@ -99,6 +105,10 @@ Item {
_userToggledExpand = true;
if (!isExpanded)
return;
if (retainedEdit) {
restoreRetainedEdit();
return;
}
if (restoreKey) {
restoreToKey(restoreKey);
} else {
@@ -112,6 +122,13 @@ Item {
restoreToKey(restoreKey);
}
function restoreRetainedEdit() {
const retained = retainedEdit;
editingKeyIndex = keys.findIndex(key => key.key === retained.originalKey);
addingNewKey = !retained.originalKey;
updateEdit(retained.data);
}
function restoreToKey(keyToFind) {
for (var i = 0; i < keys.length; i++) {
if (keys[i].key === keyToFind) {
@@ -151,7 +168,8 @@ Item {
}
hasChanges = false;
_actionType = Actions.getActionType(editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(KeybindsService.currentProvider, editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !KeybindsService.isKnownCompositorAction(editAction);
editChanged();
return;
}
}
@@ -175,7 +193,8 @@ Item {
editAllowInhibiting = editingKeyIndex >= 0 ? keys[editingKeyIndex].allowInhibiting : undefined;
hasChanges = false;
_actionType = Actions.getActionType(editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(KeybindsService.currentProvider, editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !KeybindsService.isKnownCompositorAction(editAction);
editChanged();
}
function startAddingNewKey() {
@@ -187,6 +206,7 @@ Item {
editingKeyIndex = -1;
editKey = "";
hasChanges = true;
editChanged();
}
function selectKeyForEdit(index) {
@@ -202,6 +222,7 @@ Item {
editRepeat = keys[index].repeat;
editAllowInhibiting = keys[index].allowInhibiting;
hasChanges = false;
editChanged();
}
function updateEdit(changes) {
@@ -232,10 +253,11 @@ Item {
const origRepeat = hasKey ? keys[editingKeyIndex].repeat : undefined;
const origAllowInhibiting = hasKey ? keys[editingKeyIndex].allowInhibiting : undefined;
hasChanges = editKey !== origKey || editAction !== (bindData.action || "") || editDesc !== origDesc || editCooldownMs !== origCooldown || editFlags !== origFlags || editAllowWhenLocked !== origAllowWhenLocked || editRepeat !== origRepeat || editAllowInhibiting !== origAllowInhibiting;
editChanged();
}
function canSave() {
if (readOnly)
if (readOnly || saveBlocked)
return false;
if (!editKey)
return false;
@@ -270,8 +292,10 @@ Item {
"repeat": editRepeat,
"allowInhibiting": editAllowInhibiting
});
hasChanges = false;
addingNewKey = false;
if (!retainEdits) {
hasChanges = false;
addingNewKey = false;
}
}
ShortcutInhibitor {
@@ -1705,6 +1729,7 @@ Item {
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacingM
visible: KeybindsService.currentProvider !== "aqueous"
StyledText {
text: I18n.tr("Title")
@@ -2007,9 +2032,9 @@ Item {
buttonHeight: root._buttonHeight
backgroundColor: Theme.floatingWindowFieldColor
textColor: Theme.surfaceText
visible: root.hasChanges || root.isNew
visible: root.hasChanges || root.isNew || root.retainEdits
onClicked: {
if (root.isNew) {
if (root.isNew || root.retainEdits) {
root.cancelEdit();
} else {
root.resetEdits();
+294 -7
View File
@@ -27,8 +27,154 @@ Item {
property bool _preserveScroll: false
property string _editingKey: ""
property var editDraft: null
property var reviewSnapshot: null
property bool reviewingEdit: false
property bool editBusy: false
property string editError: ""
property int _editRequest: 0
property bool _editAlive: true
readonly property bool hasEditDraft: editDraft !== null
readonly property bool editInvalidated: hasEditDraft && (editDraft.provider !== KeybindsService.currentProvider || !KeybindsService.bindEditSession || editDraft.session !== KeybindsService.bindEditSession)
onEditInvalidatedChanged: {
if (!editInvalidated)
return;
_editRequest++;
editBusy = false;
reviewingEdit = true;
reviewSnapshot = null;
editError = KeybindsService.bindEditError("invalidated");
}
Component.onDestruction: {
_editAlive = false;
_editRequest++;
}
function beginEdit(binding, key) {
if (hasEditDraft || editBusy || KeybindsService.bindMutationBusy) {
ToastService.showInfo(I18n.tr("Save or discard the current edit before editing another shortcut.", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings"));
return false;
}
try {
editDraft = KeybindsService.captureBindEdit(binding, key);
editError = "";
reviewSnapshot = null;
reviewingEdit = false;
return true;
} catch (e) {
ToastService.showError(I18n.tr("Failed to load keybinds", "Aqueous shortcut editor could not load the current bindings"), AqueousService.errorMessage(String(e)), String(e));
return false;
}
}
function updateEditDraft(originalKey, data) {
if (!editDraft || editDraft.operation !== "set" || editBusy || editInvalidated)
return;
editDraft = KeybindsService.updateBindEdit(editDraft, originalKey, data);
}
function submitEdit() {
if (!editDraft || editBusy || reviewingEdit || editInvalidated || KeybindsService.bindMutationBusy)
return;
editBusy = true;
editError = "";
const token = ++_editRequest;
const complete = result => {
if (!keybindsTab._editAlive || token !== keybindsTab._editRequest)
return;
keybindsTab.editBusy = false;
if (result.success) {
const key = keybindsTab.editDraft.operation === "set" ? keybindsTab.editDraft.data.key : "";
const action = keybindsTab.editDraft.data.action;
keybindsTab.editDraft = null;
keybindsTab.reviewSnapshot = null;
keybindsTab.reviewingEdit = false;
keybindsTab._editingKey = key;
keybindsTab.expandedKey = action;
return;
}
keybindsTab.reviewSnapshot = result.snapshot || null;
keybindsTab.reviewingEdit = result.code !== "load_failed" && result.code !== "busy";
keybindsTab.editError = KeybindsService.bindEditError(result.code);
};
switch (editDraft.operation) {
case "set":
KeybindsService.saveBind(editDraft.originalKey, editDraft.data, editDraft, complete);
return;
case "remove":
KeybindsService.removeBind(editDraft.originalKey, editDraft, complete);
return;
case "reset":
KeybindsService.resetBind(editDraft.originalKey, editDraft, complete);
return;
}
}
function reloadEdit() {
if (!editDraft || editBusy || editInvalidated)
return;
editBusy = true;
reviewingEdit = true;
const token = ++_editRequest;
KeybindsService.loadBindReview((snapshot, message) => {
if (!keybindsTab._editAlive || token !== keybindsTab._editRequest)
return;
keybindsTab.editBusy = false;
keybindsTab.reviewSnapshot = snapshot;
keybindsTab.editError = message;
});
}
function acceptReview() {
if (!editDraft || !reviewSnapshot || editBusy || editInvalidated)
return;
const result = KeybindsService.reconcileBindEdit(editDraft, reviewSnapshot);
if (!result.draft) {
editError = KeybindsService.bindEditError(result.code);
return;
}
editDraft = result.draft;
reviewingEdit = false;
editError = "";
if (editDraft.operation !== "set")
confirmEditRemoval();
}
function confirmEditRemoval() {
if (!editDraft)
return;
if (editDraft.operation === "reset") {
confirmResetBind(editDraft.originalKey, "");
return;
}
confirmRemoveBind(editDraft.originalKey, "");
}
function discardEdit() {
if (editBusy || KeybindsService.bindMutationBusy)
return;
_editRequest++;
editDraft = null;
reviewSnapshot = null;
reviewingEdit = false;
editError = "";
expandedKey = "";
showingNewBind = false;
_editingKey = "";
KeybindsService.loadBinds(false);
}
function _updateFiltered() {
const allBinds = KeybindsService.getFlatBinds();
let allBinds = KeybindsService.getFlatBinds();
if (keybindsTab.editDraft?.action) {
const binding = keybindsTab.editDraft.binding;
const found = allBinds.some(bind => bind.action === binding.action);
allBinds = allBinds.map(bind => bind.action === binding.action ? binding : bind);
if (!found)
allBinds.push(binding);
}
if (!searchQuery && !selectedCategory) {
_filteredBinds = allBinds;
return;
@@ -80,6 +226,14 @@ Item {
}
function toggleExpanded(action) {
if (KeybindsService.requiresBindReview && !keybindsTab.hasEditDraft) {
const binding = KeybindsService.getFlatBinds().find(bind => bind.action === action);
if (!binding || !keybindsTab.beginEdit(binding, binding.keys[0]?.key || ""))
return;
} else if (keybindsTab.hasEditDraft && action !== keybindsTab.editDraft.action) {
ToastService.showInfo(I18n.tr("Save or discard the current edit before editing another shortcut.", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings"));
return;
}
expandedKey = expandedKey === action ? "" : action;
}
@@ -88,27 +242,59 @@ Item {
KeybindsService.showHyprlandReadOnlyWarning();
return;
}
if (KeybindsService.requiresBindReview) {
if (!keybindsTab.beginEdit({
action: "",
desc: ""
}, ""))
return;
newBindItem.resetEdits();
}
showingNewBind = true;
expandedKey = "";
}
function cancelNewBind() {
if (keybindsTab.hasEditDraft) {
keybindsTab.discardEdit();
return;
}
showingNewBind = false;
}
function saveNewBind(bindData) {
KeybindsService.saveBind("", bindData);
saveBind("", bindData);
}
function saveBind(originalKey, bindData) {
if (KeybindsService.requiresBindReview) {
keybindsTab.updateEditDraft(originalKey, bindData);
keybindsTab.submitEdit();
return;
}
KeybindsService.saveBind(originalKey, bindData);
_editingKey = bindData.key;
expandedKey = bindData.action;
}
function confirmRemoveBind(key, remainingKey) {
const draft = KeybindsService.requiresBindReview ? prepareRemoval(key, "remove") : null;
if (KeybindsService.requiresBindReview && !draft)
return;
const baselineDraft = keybindsTab.editDraft;
removeBindConfirm.showWithOptions({
title: I18n.tr("Remove Shortcut?"),
message: KeybindsService.currentProvider === "hyprland" ? I18n.tr("Remove the shortcut %1? An unbind entry will be saved to dms/binds-user.lua so it stays removed across DMS updates.").arg(key) : I18n.tr("Remove the shortcut %1?").arg(key),
confirmText: I18n.tr("Remove"),
confirmColor: Theme.primary,
onConfirm: () => {
if (draft) {
if (keybindsTab.editDraft !== baselineDraft)
return;
keybindsTab.editDraft = draft;
keybindsTab.submitEdit();
return;
}
KeybindsService.removeBind(key);
keybindsTab._editingKey = remainingKey;
}
@@ -116,18 +302,40 @@ Item {
}
function confirmResetBind(key, remainingKey) {
const draft = KeybindsService.requiresBindReview ? prepareRemoval(key, "reset") : null;
if (KeybindsService.requiresBindReview && !draft)
return;
const baselineDraft = keybindsTab.editDraft;
removeBindConfirm.showWithOptions({
title: I18n.tr("Reset to default"),
message: I18n.tr("Drop your override for %1 so the DMS default action re-applies?").arg(key),
confirmText: I18n.tr("Reset"),
confirmColor: Theme.primary,
onConfirm: () => {
if (draft) {
if (keybindsTab.editDraft !== baselineDraft)
return;
keybindsTab.editDraft = draft;
keybindsTab.submitEdit();
return;
}
KeybindsService.resetBind(key);
keybindsTab._editingKey = remainingKey;
}
});
}
function prepareRemoval(key, operation) {
if (!hasEditDraft) {
const binding = KeybindsService.getFlatBinds().find(bind => bind.keys.some(entry => entry.key === key));
if (!binding || !beginEdit(binding, key))
return null;
}
if (editBusy || reviewingEdit || editInvalidated)
return null;
return KeybindsService.updateBindEdit(editDraft, key, null, operation);
}
function _onSaveSuccess() {
if (showingNewBind) {
showingNewBind = false;
@@ -198,6 +406,11 @@ Item {
function _ensureCurrentProvider() {
if (!KeybindsService.available)
return;
if (KeybindsService.requiresBindReview) {
if (!keybindsTab.hasEditDraft && !KeybindsService.bindMutationBusy)
KeybindsService.loadBinds(false);
return;
}
const cachedProvider = KeybindsService.keybinds?.provider;
const targetProvider = KeybindsService.currentProvider;
if (cachedProvider !== targetProvider || KeybindsService._dataVersion === 0) {
@@ -296,8 +509,8 @@ Item {
}
StyledText {
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: KeybindsService.readOnly ? I18n.tr("Hyprland conf mode is read-only in Settings") : I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile)
readonly property string bindsFile: KeybindsService.requiresBindReview ? "aqueous-config" : KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: KeybindsService.requiresBindReview ? I18n.tr("Click any shortcut to edit Aqueous configuration", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings") : KeybindsService.readOnly ? I18n.tr("Hyprland conf mode is read-only in Settings") : I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
@@ -508,6 +721,56 @@ Item {
}
}
Column {
width: Math.min(650, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
visible: keybindsTab.hasEditDraft && (keybindsTab.reviewingEdit || keybindsTab.editError !== "" || keybindsTab.editDraft.operation !== "set")
spacing: Theme.spacingM
StyledText {
width: parent.width
visible: keybindsTab.reviewingEdit || keybindsTab.editError !== ""
text: keybindsTab.editError || I18n.tr("Review the current bindings before saving this edit.", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings")
color: Theme.error
wrapMode: Text.WordWrap
}
StyledText {
width: parent.width
visible: keybindsTab.reviewingEdit && !!keybindsTab.reviewSnapshot
text: KeybindsService.describeBindReview(keybindsTab.editDraft, keybindsTab.reviewSnapshot)
color: Theme.surfaceText
wrapMode: Text.WordWrap
}
Flow {
width: parent.width
spacing: Theme.spacingS
DankButton {
text: I18n.tr("Refresh")
enabled: !keybindsTab.editBusy && !keybindsTab.editInvalidated
onClicked: keybindsTab.reloadEdit()
}
DankButton {
text: I18n.tr("Accept reviewed changes", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings")
visible: keybindsTab.reviewingEdit && !!keybindsTab.reviewSnapshot
enabled: !keybindsTab.editBusy && !keybindsTab.editInvalidated
onClicked: keybindsTab.acceptReview()
}
DankButton {
text: I18n.tr("Discard")
enabled: !keybindsTab.editBusy && !KeybindsService.bindMutationBusy
onClicked: keybindsTab.discardEdit()
}
DankButton {
text: I18n.tr("Remove")
visible: keybindsTab.hasEditDraft && keybindsTab.editDraft.operation !== "set"
enabled: !keybindsTab.editBusy && !keybindsTab.reviewingEdit && !keybindsTab.editInvalidated
onClicked: keybindsTab.confirmEditRemoval()
}
}
}
StyledRect {
width: Math.min(650, parent.width - Theme.spacingL * 2)
height: newBindSection.implicitHeight + Theme.spacingL * 2
@@ -545,6 +808,7 @@ Item {
}
KeybindItem {
id: newBindItem
width: parent.width
isNew: true
isExpanded: true
@@ -561,6 +825,17 @@ Item {
})
panelWindow: keybindsTab.parentModal
readOnly: KeybindsService.readOnly
retainedEdit: keybindsTab.hasEditDraft && keybindsTab.showingNewBind ? keybindsTab.editDraft : null
saveBlocked: retainEdits && (keybindsTab.reviewingEdit || keybindsTab.editDraft.operation !== "set")
enabled: !retainEdits || (!keybindsTab.editBusy && !keybindsTab.editInvalidated && !KeybindsService.bindMutationBusy)
onEditChanged: {
if (retainEdits)
keybindsTab.updateEditDraft("", {
key: editKey,
action: editAction,
desc: editDesc
});
}
onSaveBind: (originalKey, newData) => keybindsTab.saveNewBind(newData)
onCancelEdit: keybindsTab.cancelNewBind()
}
@@ -670,13 +945,25 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
bindData: modelData
isExpanded: keybindsTab.expandedKey === modelData.action
retainedEdit: keybindsTab.hasEditDraft && keybindsTab.editDraft.action === modelData.action ? keybindsTab.editDraft : null
saveBlocked: retainEdits && (keybindsTab.reviewingEdit || keybindsTab.editDraft.operation !== "set")
enabled: !retainEdits || (!keybindsTab.editBusy && !keybindsTab.editInvalidated && !KeybindsService.bindMutationBusy)
onEditChanged: {
if (KeybindsService.requiresBindReview && isExpanded && hasChanges && !keybindsTab.hasEditDraft)
keybindsTab.beginEdit(bindData, addingNewKey ? "" : _originalKey);
if (retainEdits)
keybindsTab.updateEditDraft(addingNewKey ? "" : _originalKey, {
key: editKey,
action: editAction,
desc: editDesc
});
}
onCancelEdit: keybindsTab.discardEdit()
panelWindow: keybindsTab.parentModal
readOnly: KeybindsService.readOnly
onToggleExpand: keybindsTab.toggleExpanded(modelData.action)
onSaveBind: (originalKey, newData) => {
KeybindsService.saveBind(originalKey, newData);
keybindsTab._editingKey = newData.key;
keybindsTab.expandedKey = newData.action;
keybindsTab.saveBind(originalKey, newData);
}
onRemoveBind: key => {
const remainingKey = bindItem.keys.find(k => k.key !== key)?.key ?? "";
@@ -455,6 +455,17 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
Loader {
width: parent.width
active: CompositorService.isAqueous
sourceComponent: AqueousAppearanceSettings {
cursor: true
settingKey: "aqueousCursor"
title: I18n.tr("Aqueous cursor", "Aqueous compositor cursor synchronization settings")
visible: CompositorService.isAqueous
}
}
SettingsCard {
tab: "theme"
tags: ["color", "palette", "theme", "appearance"]
@@ -1,6 +1,7 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Services
import qs.Modules.Settings.Widgets
Item {
@@ -55,6 +56,16 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
Loader {
width: parent.width
active: CompositorService.isAqueous
sourceComponent: AqueousAppearanceSettings {
settingKey: "aqueousTypography"
title: I18n.tr("Aqueous typography", "Aqueous compositor font synchronization settings")
visible: CompositorService.isAqueous
}
}
SettingsCard {
tab: "typography"
tags: ["font", "family", "text", "typography"]
@@ -0,0 +1,199 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
SettingsCard {
id: root
property bool cursor: false
property var snapshot: null
property var changes: ({})
property bool busy: false
property string error: ""
readonly property var report: cursor ? snapshot?.desktop_cursor : snapshot?.desktop_typography
readonly property bool partial: (report?.failed_count || 0) > 0
readonly property bool supported: snapshot?.capabilities?.includes(cursor ? "cursor_sync" : "typography_sync") || false
title: cursor ? I18n.tr("Aqueous cursor", "Aqueous compositor cursor synchronization settings") : I18n.tr("Aqueous typography", "Aqueous compositor font synchronization settings")
iconName: cursor ? "mouse" : "text_fields"
tab: cursor ? "theme" : "typography"
tags: ["aqueous", "appearance"]
function reload() {
if (busy || AqueousConfigService.busy)
return;
busy = true;
AqueousConfigService.load((data, message) => {
busy = false;
error = message ? AqueousService.errorMessage(message) : "";
if (!data)
return;
snapshot = data;
changes = ({});
if (!supported)
error = I18n.tr("Unavailable");
});
}
function targetStatus(target) {
switch (target.state) {
case "synced":
return I18n.tr("Synchronized", "Aqueous appearance settings match this application or toolkit");
case "drifted":
return I18n.tr("Not synchronized", "Aqueous appearance settings differ from this application or toolkit");
case "partial":
return I18n.tr("Partially synchronized", "The application or toolkit cannot represent every Aqueous font setting");
case "failed":
return I18n.tr("Sync failed", "Applying Aqueous appearance settings to this application or toolkit failed");
case "unavailable":
return I18n.tr("Unavailable");
case "unmanaged":
return I18n.tr("Disabled");
default:
return I18n.tr("Unknown");
}
}
function value(id) {
if (Object.prototype.hasOwnProperty.call(changes, id))
return changes[id];
return snapshot?.fields?.find(f => f.id === id)?.value;
}
function stage(id, value) {
const updated = Object.assign({}, changes);
updated[id] = value;
changes = updated;
}
function apply(retry) {
if (busy || !snapshot || AqueousConfigService.busy)
return;
const capability = cursor ? "cursor_sync" : "typography_sync";
if (!snapshot.capabilities?.includes(capability))
return;
const draft = {
expected_generation: snapshot.generation,
create_user_override: true,
changes: retry ? [] : Object.keys(changes).map(id => ({
id: id,
value: changes[id]
}))
};
draft[cursor ? "sync_cursor" : "sync_typography"] = true;
busy = true;
AqueousConfigService.apply(draft, (data, message) => {
busy = false;
error = message ? AqueousService.errorMessage(message) : "";
if (!data)
return;
snapshot = data;
if (!retry)
changes = ({});
if (cursor)
return;
const typography = data.desktop_typography;
if (!typography || typeof typography.family !== "string" || !Number.isFinite(typography.weight) || !Number.isFinite(typography.size_pt) || typography.size_pt <= 0)
return;
SettingsData.set("fontFamily", typography.family);
SettingsData.set("fontWeight", typography.weight);
SettingsData.set("fontScale", typography.size_pt * 96 / 72 / 14);
});
}
Component.onCompleted: reload()
Column {
width: parent.width
spacing: Theme.spacingM
SettingsDropdownRow {
width: parent.width
visible: root.cursor
enabled: root.supported && !root.busy
text: I18n.tr("Cursor Theme")
options: root.snapshot?.desktop_cursor?.themes || []
currentValue: root.value("desktop.cursor.theme") || "default"
onValueChanged: value => {
root.stage("desktop.cursor.theme", value);
root.stage("desktop.cursor.managed", true);
}
}
SettingsDropdownRow {
width: parent.width
visible: !root.cursor
enabled: root.supported && !root.busy
text: I18n.tr("Normal Font")
options: root.snapshot?.desktop_typography?.families || []
currentValue: root.value("desktop.font.family") || "sans-serif"
onValueChanged: value => {
root.stage("desktop.font.family", value);
root.stage("desktop.font.style", "");
}
}
SettingsSliderRow {
width: parent.width
enabled: root.supported && !root.busy
text: root.cursor ? I18n.tr("Cursor Size") : I18n.tr("Font Size")
minimum: root.cursor ? 12 : 6
maximum: root.cursor ? 128 : 30
value: root.value(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt") || (root.cursor ? 24 : 12)
unit: root.cursor ? I18n.tr("px", "Cursor size unit, pixels") : I18n.tr("pt", "Font size unit, points")
onSliderValueChanged: value => {
root.stage(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt", value);
if (root.cursor)
root.stage("desktop.cursor.managed", true);
}
}
StyledText {
width: parent.width
visible: root.error !== "" || root.partial
text: root.error || I18n.tr("Error")
color: Theme.error
wrapMode: Text.WordWrap
}
Repeater {
model: root.report?.targets || []
StyledText {
required property var modelData
width: parent.width
text: I18n.tr("%1: %2", "Aqueous appearance sync target and its status").arg(modelData.id).arg(root.targetStatus(modelData))
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
}
StyledText {
width: parent.width
visible: !root.cursor
text: I18n.tr("DMS uses the font family, weight and scale. Exact face, slant, width and separately scaled bars may differ.", "Aqueous font synchronization, describing which font settings DMS can represent")
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Flow {
width: parent.width
spacing: Theme.spacingS
DankButton {
text: I18n.tr("Apply Changes")
enabled: root.supported && !root.busy && !AqueousConfigService.busy && Object.keys(root.changes).length > 0
onClicked: root.apply(false)
}
DankButton {
text: I18n.tr("Retry")
enabled: root.supported && !root.busy && !AqueousConfigService.busy && root.partial
onClicked: root.apply(true)
}
DankButton {
text: I18n.tr("Refresh")
enabled: !root.busy && !AqueousConfigService.busy
onClicked: root.reload()
}
}
}
}
@@ -271,7 +271,7 @@ Column {
menuRoot.closed();
}
BackgroundEffect.blurRegion: visible ? menuBlurRegion : null
BackgroundEffect.blurRegion: visible && BlurService.enabled ? menuBlurRegion : null
Region {
id: menuBlurRegion
@@ -183,7 +183,7 @@ SettingsCard {
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property bool workspaceStateColorsVisible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
readonly property bool workspaceStateColorsVisible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
readonly property bool urgentWorkspaceColorsVisible: workspaceStateColorsVisible || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
function isFocusedAppearanceSection(section) {
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
@@ -61,7 +62,8 @@ Item {
text: I18n.tr("Show Workspace Apps")
description: I18n.tr("Display application icons in workspace indicators")
checked: SettingsData.showWorkspaceApps
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
enabled: !CompositorService.isAqueous || (AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1")
onToggled: checked => SettingsData.set("showWorkspaceApps", checked)
}
@@ -153,7 +155,7 @@ Item {
text: I18n.tr("Follow Monitor Focus")
description: I18n.tr("Show workspaces of the currently focused monitor")
checked: SettingsData.workspaceFollowFocus
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isAqueous
onToggled: checked => SettingsData.set("workspaceFollowFocus", checked)
}
@@ -163,7 +165,8 @@ Item {
text: I18n.tr("Show Occupied Workspaces Only")
description: I18n.tr("Display only workspaces that contain windows")
checked: SettingsData.showOccupiedWorkspacesOnly
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
enabled: !CompositorService.isAqueous || (AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1")
onToggled: checked => SettingsData.set("showOccupiedWorkspacesOnly", checked)
}
@@ -173,7 +176,7 @@ Item {
text: I18n.tr("Reverse Scrolling Direction")
description: I18n.tr("Reverse workspace switch direction when scrolling over the bar")
checked: SettingsData.reverseScrolling
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
onToggled: checked => SettingsData.set("reverseScrolling", checked)
}
@@ -0,0 +1,149 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("AqueousConfigService")
property bool busy: false
function requireCapabilities(result, required) {
if (result?.protocol !== 1 || !Array.isArray(result.capabilities))
throw new Error("unsupported aqueous-config protocol or discovery");
for (const capability of required) {
if (!result.capabilities.includes(capability))
throw new Error("unsupported aqueous-config capability: " + capability);
}
}
function helper(operation, input, callback) {
const args = ["aqueous-config", operation, "--shell", "dms"];
if (input !== null)
args.push("--request", "-");
AqueousService.runJson(args, input, (result, error) => {
if (!error && result?.ok !== true)
error = result?.code ? result.code + ": " + (result.message || "") : "aqueous-config rejected the request";
callback(result, error);
});
}
function request(operation, draft, callback) {
if (!CompositorService.isAqueous || busy) {
callback(null, busy ? "busy" : "unavailable");
return;
}
busy = true;
const complete = (result, error) => {
busy = false;
if (error)
log.warn("helper operation failed:", operation, error);
callback(error ? null : result, error);
};
if (operation === "snapshot") {
helper("snapshot", null, (result, error) => {
try {
if (error)
throw new Error(error);
requireCapabilities(result, ["schema_fields", "shell_dms"]);
if (typeof result.generation !== "string" || !result.generation)
throw new Error("missing configuration generation");
complete(result, "");
} catch (e) {
complete(null, String(e));
}
});
return;
}
if (!["apply", "validate"].includes(operation) || typeof draft?.expected_generation !== "string" || !draft.expected_generation) {
complete(null, "invalid operation or missing expected_generation; reload configuration");
return;
}
const request = Object.assign({}, draft, {
protocol: 1
});
request.backup_dir = request.backup_dir || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/DankMaterialShell/aqueous-backups";
const input = JSON.stringify(request);
const required = ["validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"];
for (const [key, capability] of Object.entries({
monitor_changes: "monitor_modes",
custom_keybind_changes: "keybinds",
sync_cursor: "cursor_sync",
sync_typography: "typography_sync"
})) {
if (Object.prototype.hasOwnProperty.call(request, key))
required.push(capability);
}
helper("version", null, (version, error) => {
try {
if (error)
throw new Error(error);
requireCapabilities(version, required);
} catch (e) {
complete(null, String(e));
return;
}
helper("validate", input, (validated, error) => {
if (error || operation === "validate") {
complete(validated, error);
return;
}
helper("apply", input, complete);
});
});
}
function buildOutputsConfig(snapshot, outputs, original) {
if (!snapshot.capabilities?.includes("monitor_modes"))
throw new Error("unsupported: monitor_modes");
const raw = (snapshot.raw_files?.outputs || "") + "\n" + (snapshot.raw_files?.wm || "");
if (/^\s*edid\s*=/m.test(raw))
throw new Error("unsupported: this aqueous-config version does not expose EDID monitor edits");
const transforms = ["normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"];
const changes = [];
if ((snapshot.monitors || []).some(m => /[*?\[]/.test(m.name)))
throw new Error("unsupported: reconcile wildcard monitor configuration before persisting displays");
for (const output of outputs) {
const before = original.find(o => o.name === output.name);
if (!before)
throw new Error("conflict: output changed during preview");
if (output.enabled !== before.enabled)
throw new Error("unsupported: this aqueous-config version cannot persist output enablement");
if (output.adaptiveSync !== before.adaptiveSync)
throw new Error("unsupported: this aqueous-config version cannot persist adaptive sync");
if (!output.enabled)
continue;
const configured = (snapshot.monitors || []).filter(m => m.name === output.name);
if (configured.length > 1)
throw new Error("conflict: multiple configured monitor entries");
const monitor = configured[0];
if (Math.abs(output.scale - (monitor?.scale ?? 1)) > 0.0001)
throw new Error("unsupported: this aqueous-config version cannot persist output scale");
const mode = output.currentMode;
if (!mode)
throw new Error("unavailable: output mode");
changes.push({
id: monitor?.id || "live:" + output.name,
name: output.name,
x: output.x,
y: output.y,
transform: transforms[output.transform],
mode: mode.width + "x" + mode.height + "@" + (mode.refresh / 1000)
});
}
return {
expected_generation: snapshot.generation,
monitor_changes: changes,
create_user_override: true
};
}
function load(callback) {
request("snapshot", null, callback);
}
function apply(draft, callback) {
request("apply", draft, callback);
}
}
+970
View File
@@ -0,0 +1,970 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import "../Common/AqueousIpc.js" as Ipc
Singleton {
id: root
readonly property var log: Log.scoped("AqueousService")
property var state: ({})
property string seatName: ""
readonly property bool enabled: CompositorService.isAqueous
readonly property bool available: enabled && requestConnection.ready && eventConnection.ready && state.available === true
property var discoveredCapabilities: ({})
property var pendingProcesses: []
property int lifecycle: 0
readonly property string socketPath: Quickshell.env("AQUEOUS_SOCKET")
readonly property string runtimePath: Quickshell.env("XDG_RUNTIME_DIR")
readonly property bool validSocketPath: Ipc.socketPath(socketPath, runtimePath)
property bool watching: false
property bool recovering: false
property bool reconnecting: false
property int reconnectAttempt: 0
property var commandQueue: []
property var currentCommand: null
readonly property bool commandPending: currentCommand !== null
readonly property int maxBatchBytes: 4 * 1024 * 1024
readonly property int maxModelBytes: 2 * 1024 * 1024
readonly property var capabilities: discoveredCapabilities
readonly property string session: available ? state.batch.session : ""
readonly property string sequence: available ? state.batch.sequence : ""
readonly property var entities: available ? state.batch.upsert : []
readonly property var outputs: entities.filter(e => e.kind === "output")
readonly property var workspaces: entities.filter(e => e.kind === "workspace").map(w => Object.assign({}, w, {
aqueousSession: session
}))
readonly property var windows: entities.filter(e => e.kind === "window")
readonly property var seats: entities.filter(e => e.kind === "seat")
readonly property var sessionState: entities.find(e => e.kind === "session") || ({})
readonly property bool locked: sessionState.locked === true
readonly property var seat: seatName ? seats.find(s => s.id === seatName) : (seats.length === 1 ? seats[0] : null)
readonly property var keyboard: entities.find(e => e.kind === "keyboard" && e.id === seat?.keyboard)
readonly property var keyboardLayouts: keyboard?.layouts || []
readonly property string keyboardLayout: keyboardLayouts[keyboard?.index] || ""
readonly property string focusedOutput: outputs.find(o => o.id === seat?.output)?.name || ""
readonly property bool inOverview: !!sessionState.overview_output
readonly property var focusedWindow: {
const window = windows.find(w => w.id === seat?.window);
return window ? windowFacade(window) : null;
}
readonly property var toplevels: windows.filter(w => taskbarEligible(w)).map(w => windowFacade(w))
function taskbarEligible(window) {
return !window.skip_taskbar && (window.visible || window.minimized || !workspaces.find(ws => ws.id === window.workspace)?.active);
}
function outputId(name) {
return outputs.find(o => o.name === name)?.id || "";
}
function workspacesForOutput(name) {
const id = outputId(name || focusedOutput);
return workspaces.filter(w => w.output === id).sort((a, b) => a.number - b.number);
}
function parseJson(text) {
if (Ipc.byteLength(text) > maxBatchBytes + (text.endsWith("\n") ? 1 : 0))
throw new Error("JSON exceeds 4 MiB");
return JSON.parse(text);
}
function validateEntity(entity) {
const fields = {
output: "id:s name:s bounds:r usable_bounds:r scale:n transform:s active_workspace:? enabled:b powered:b",
workspace: "id:s output:s name:s number:i active:b urgent:b",
window: "id:s backend:s app_id:? class:? title:? workspace:? output:? geometry:r outer_geometry:r layout:s focused:b visible:b floating:b minimized:b maximized:b fullscreen:b skip_taskbar:b skip_switcher:b always_above:b always_below:b snapped:b fixed_position:b can_minimize:b can_maximize:b can_activate:b",
seat: "id:s output:? window:? focus_kind:s keyboard:?",
keyboard: "id:s seat:s layouts:a index:i",
keyboard_device: "id:s name:? seat:s group:? virtual:b",
session: "id:s locked:b default_seat:? overview_output:? overview_window:?"
};
if (!entity || !Object.prototype.hasOwnProperty.call(fields, entity.kind) || typeof entity.id !== "string" || !entity.id)
throw new Error("invalid entity kind or ID");
for (const field of fields[entity.kind].split(" ")) {
const [name, type] = field.split(":");
const value = entity[name];
let valid = false;
switch (type) {
case "s":
valid = typeof value === "string";
break;
case "?":
valid = value === null || typeof value === "string";
break;
case "b":
valid = typeof value === "boolean";
break;
case "i":
valid = Number.isSafeInteger(value);
break;
case "n":
valid = typeof value === "number" && Number.isFinite(value);
break;
case "a":
valid = Array.isArray(value) && value.every(v => typeof v === "string");
break;
case "r":
valid = value && ["x", "y", "width", "height"].every(k => Number.isSafeInteger(value[k]));
break;
}
if (!valid)
throw new Error("invalid " + entity.kind + "." + name);
}
if ((entity.kind === "session" && entity.id !== "session") || (entity.kind === "workspace" && entity.number < 1) || (entity.kind === "output" && entity.scale <= 0) || (entity.kind === "keyboard" && (entity.index < 0 || (entity.layouts.length && entity.index >= entity.layouts.length))) || (entity.kind === "window" && !["xdg", "xwayland"].includes(entity.backend)) || (entity.kind === "seat" && !["window", "shell_surface", "layer_surface", "override_redirect", "lock_surface", "none"].includes(entity.focus_kind)))
throw new Error("invalid " + entity.kind);
}
function reduceBatch(previous, batch) {
if (!batch || batch.schema !== 1 || typeof batch.session !== "string" || !/^[0-9a-f]{32}$/.test(batch.session) || typeof batch.sequence !== "string" || !/^[0-9]+$/.test(batch.sequence) || !Array.isArray(batch.upsert) || !Array.isArray(batch.removed))
throw new Error("invalid shell batch envelope");
if (batch.type !== "snapshot" && batch.type !== "delta")
throw new Error("invalid shell batch type");
if (batch.type === "snapshot" && (batch.base_sequence !== null || batch.removed.length))
throw new Error("invalid snapshot baseline");
if (batch.type === "delta" && (!previous || batch.session !== previous.session || batch.base_sequence !== previous.sequence || batch.sequence === previous.sequence))
throw new Error("shell delta continuity mismatch");
const model = Object.assign({}, batch.type === "delta" ? previous.model : {});
const entityBytes = Object.assign({}, batch.type === "delta" ? previous.entityBytes : {});
let modelBytes = batch.type === "delta" ? previous.modelBytes : 1;
const seen = new Set();
for (const entity of batch.upsert) {
validateEntity(entity);
const key = entity.kind + ":" + entity.id;
if (seen.has(key))
throw new Error("duplicate entity key");
seen.add(key);
const size = Ipc.byteLength(JSON.stringify(key) + ":" + JSON.stringify(entity)) + 1;
modelBytes += size - (entityBytes[key] || 0);
entityBytes[key] = size;
model[key] = entity;
}
for (const key of batch.removed) {
if (typeof key !== "string" || !/^(output|workspace|window|seat|keyboard|keyboard_device|session):.+$/.test(key) || seen.has(key))
throw new Error("invalid or duplicate removal");
seen.add(key);
modelBytes -= entityBytes[key] || 0;
delete entityBytes[key];
delete model[key];
}
if (!model["session:session"])
throw new Error("missing session entity");
const references = {
output: {
active_workspace: "workspace"
},
workspace: {
output: "output"
},
window: {
output: "output",
workspace: "workspace"
},
seat: {
output: "output",
window: "window",
keyboard: "keyboard"
},
keyboard: {
seat: "seat"
},
keyboard_device: {
seat: "seat",
group: "keyboard"
},
session: {
default_seat: "seat",
overview_output: "output",
overview_window: "window"
}
};
for (const entity of Object.values(model)) {
for (const [field, kind] of Object.entries(references[entity.kind])) {
if (entity[field] !== null && !model[kind + ":" + entity[field]])
throw new Error("dangling " + entity.kind + "." + field);
}
}
if (modelBytes > maxModelBytes)
throw new Error("shell model exceeds size bound");
return {
session: batch.session,
sequence: batch.sequence,
model: model,
entityBytes: entityBytes,
modelBytes: modelBytes,
upsert: Object.values(model)
};
}
function acceptBatch(batch) {
if (batch.session !== capabilities.session)
throw new Error("shell session changed after discovery");
const next = reduceBatch(state.available ? state.batch : null, batch);
reconnectAttempt = 0;
state = {
available: true,
batch: next
};
}
function commandPayload(action, fields) {
const request = Object.assign({
session: session,
seat: seat?.id || ""
}, fields || {});
if (!available || request.session !== session)
throw new Error("unavailable: stale session");
if (locked)
throw new Error("locked");
const [kind, operation] = action.split(".");
const capability = kind === "keyboard" ? "keyboard" : kind === "overview" ? "overview" : "commands";
if (!capabilities[capability])
throw new Error("unsupported: " + capability);
for (const key of ["id", "seat", "group", "output", "workspace", "name"]) {
if (request[key] !== undefined && (typeof request[key] !== "string" || Ipc.byteLength(request[key]) > 1024))
throw new Error("invalid: command string");
}
const target = entities.find(e => e.kind === kind && e.id === request.id);
if ((kind === "window" || kind === "workspace") && !target)
throw new Error("not_found");
const selectedSeat = request.seat ? seats.find(s => s.id === request.seat) : (seats.length === 1 ? seats[0] : null);
const output = outputs.find(o => o.id === request.output);
let payload;
switch (action) {
case "window.activate":
case "workspace.activate":
if (!selectedSeat)
throw new Error(request.seat ? "not_found: seat" : "ambiguous_seat");
if (kind === "window" && !target.can_activate)
throw new Error("unavailable: window activation");
payload = {
id: target.id,
seat: selectedSeat.id
};
break;
case "window.close":
payload = {
id: target.id
};
break;
case "window.minimized":
case "window.maximized":
case "window.fullscreen":
if (typeof request.value !== "boolean")
throw new Error("invalid: missing state");
if (operation !== "fullscreen" && !target["can_" + operation.slice(0, -1)])
throw new Error("unavailable: window state");
payload = {
id: target.id,
value: request.value
};
break;
case "window.move":
if (!!request.workspace === !!request.output)
throw new Error("invalid: move destination");
if (request.workspace && !workspaces.some(w => w.id === request.workspace))
throw new Error("not_found: workspace");
if (request.output && !output)
throw new Error("not_found: output");
payload = {
id: target.id
};
payload[request.workspace ? "workspace" : "output"] = request.workspace || output.id;
break;
case "workspace.rename":
if (typeof request.name !== "string" || /[\r\n]/.test(request.name))
throw new Error("invalid: workspace name");
payload = {
id: target.id,
name: request.name
};
break;
case "keyboard.set":
case "keyboard.next":
{
if (!selectedSeat)
throw new Error(request.seat ? "not_found: seat" : "ambiguous_seat");
const group = entities.find(e => e.kind === "keyboard" && e.id === (request.group || selectedSeat.keyboard) && e.seat === selectedSeat.id);
if (!group)
throw new Error("not_found: keyboard group");
payload = {
seat: selectedSeat.id,
group: group.id
};
if (operation === "set") {
if (!Number.isInteger(request.index) || request.index < 0 || request.index >= group.layouts.length)
throw new Error("invalid: keyboard index");
payload.index = request.index;
}
break;
}
case "overview.show":
case "overview.toggle":
case "overview.hide":
payload = {};
if (operation !== "hide") {
if (!output)
throw new Error("not_found: output");
payload.output = output.id;
}
break;
case "session.exit":
payload = {};
break;
default:
throw new Error("unsupported: action");
}
return {
action: action,
fields: payload
};
}
function commandResult(action, result, error) {
if (error)
return error;
const status = action === "window.close" || action === "session.exit" ? "accepted" : "applied";
if (result?.status !== status || (status === "applied" || result.sequence !== undefined) && (typeof result.sequence !== "string" || !/^[0-9]{1,20}$/.test(result.sequence)))
return result?.status || "missing command acknowledgement";
return "";
}
function reportCommand(action, result, error, callback) {
const failure = commandResult(action, result, error);
if (failure) {
log.warn("command failed:", action, failure);
ToastService.showError(I18n.tr("Error"), errorMessage(failure), failure);
}
if (callback)
callback(!failure, failure || result.status);
}
function errorMessage(error) {
const code = String(error).replace(/^Error: /, "").split(":")[0];
switch (code) {
case "busy":
return I18n.tr("Another Aqueous operation is in progress.", "Aqueous request rejected because another operation is running");
case "locked":
case "unavailable":
case "not_found":
return I18n.tr("Unavailable");
case "unsupported":
return I18n.tr("This operation is not supported by the installed Aqueous version.", "Aqueous compositor or configuration helper lacks a required capability");
case "conflict":
case "external_change":
case "stale_session":
return I18n.tr("Configuration changed. Refresh to continue.", "Aqueous configuration or compositor session changed while editing");
case "uncertain":
case "command completion uncertain":
return I18n.tr("The result is unknown. Refresh before trying again.", "An Aqueous operation may have completed without a reply");
default:
return I18n.tr("The Aqueous operation failed.", "Fallback error for an Aqueous compositor or configuration request");
}
}
function command(action, fields, callback) {
try {
commandPayload(action, fields);
if (commandQueue.length >= 32)
throw new Error("busy: command queue full");
} catch (e) {
reportCommand(action, null, String(e), callback);
return;
}
commandQueue = commandQueue.concat([
{
action: action,
fields: Object.assign({
session: session,
seat: seat?.id || ""
}, fields || {}),
callback: callback,
generation: lifecycle,
queuedAt: Date.now()
}
]);
drainCommands();
}
function drainCommands() {
if (currentCommand || recovering)
return;
while (commandQueue.length) {
const next = commandQueue[0];
commandQueue = commandQueue.slice(1);
try {
if (next.generation !== lifecycle || Date.now() - next.queuedAt >= 5000)
throw new Error("unavailable: queued command expired before sending");
const payload = commandPayload(next.action, next.fields);
currentCommand = next;
requestConnection.request("command", payload);
return;
} catch (e) {
currentCommand = null;
reportCommand(next.action, null, String(e), next.callback);
}
}
}
function completeCommand(result, error) {
const current = currentCommand;
currentCommand = null;
if (!current || current.generation !== lifecycle)
return;
if (!error && commandResult(current.action, result, "")) {
reportCommand(current.action, null, "command completion uncertain: invalid acknowledgement", current.callback);
disconnected("invalid command acknowledgement");
return;
}
reportCommand(current.action, result, error, current.callback);
if (error.startsWith("stale_session:")) {
disconnected(error);
return;
}
drainCommands();
}
function failCommands(error) {
const current = currentCommand;
const queued = commandQueue;
currentCommand = null;
commandQueue = [];
if (current)
reportCommand(current.action, null, "command completion uncertain: " + error, current.callback);
for (const command of queued)
reportCommand(command.action, null, "unavailable: command not sent: " + error, command.callback);
}
function runJson(args, input, callback) {
if (!enabled) {
callback(null, "unavailable");
return;
}
if (input !== null && Ipc.byteLength(input) > maxBatchBytes) {
callback(null, "request exceeds 4 MiB");
return;
}
const process = jsonProcessComponent.createObject(root, {
command: args,
input: input,
callback: callback
});
pendingProcesses = pendingProcesses.concat([process]);
process.running = true;
process.deadline.start();
}
function startWatching() {
if (!enabled)
return;
if (!validSocketPath) {
state = {
error: "unavailable: missing or invalid AQUEOUS_SOCKET"
};
return;
}
state = {
error: "unavailable: connecting to Aqueous IPC"
};
watching = true;
}
function handshakesReady() {
if (!requestConnection.ready || !eventConnection.ready)
return;
const first = requestConnection.handshake;
const second = eventConnection.handshake;
if (first.session !== second.session || Object.keys(first.capabilities).some(key => first.capabilities[key] !== second.capabilities[key]) || first.schema !== second.schema) {
disconnected("IPC connection sessions or capabilities disagree");
return;
}
discoveredCapabilities = Object.assign({}, first.capabilities, {
session: first.session,
schema: first.schema,
max_batch_bytes: Math.min(first.max_batch_bytes, second.max_batch_bytes)
});
try {
eventConnection.subscribe();
} catch (e) {
disconnected(String(e));
}
}
function disconnected(error) {
if (recovering || !watching)
return;
recovering = true;
lifecycle++;
state = {
error: error
};
discoveredCapabilities = ({});
reconnecting = true;
requestConnection.clear();
eventConnection.clear();
const delay = Math.min(400 * Math.pow(2, Math.min(reconnectAttempt++, 6)), 15000);
reconnectTimer.interval = delay + Math.floor(Math.random() * delay / 4);
reconnectTimer.restart();
failCommands(error);
recovering = false;
}
function stopWatching() {
recovering = true;
lifecycle++;
watching = false;
reconnectTimer.stop();
reconnecting = false;
reconnectAttempt = 0;
requestConnection.clear();
eventConnection.clear();
state = ({});
discoveredCapabilities = ({});
failCommands("backend changed or shell stopped");
for (const process of pendingProcesses.slice())
process.finish(null, "unavailable: backend changed or shell stopped");
recovering = false;
}
onEnabledChanged: {
stopWatching();
startWatching();
}
Component.onCompleted: startWatching()
Component.onDestruction: stopWatching()
IpcConnection {
id: requestConnection
path: root.socketPath
connected: root.watching && !root.reconnecting
onHelloReceived: root.handshakesReady()
onReply: (result, error) => root.completeCommand(result, error)
onFailed: error => root.disconnected(error)
}
IpcConnection {
id: eventConnection
path: root.socketPath
connected: root.watching && !root.reconnecting
events: true
onHelloReceived: root.handshakesReady()
onBatchReceived: batch => {
try {
root.acceptBatch(batch);
} catch (e) {
root.disconnected(String(e));
}
}
onFailed: error => root.disconnected(error)
}
Timer {
id: reconnectTimer
onTriggered: root.reconnecting = false
}
component IpcConnection: DankSocket {
id: connection
property bool events: false
property var handshake: null
property var pending: null
property string lastId: "0"
property bool subscribed: false
property bool installed: false
property string lastDelivery: ""
property var frameParser: null
readonly property bool ready: linkUp && handshake !== null
signal helloReceived
signal batchReceived(var batch)
signal reply(var result, string error)
signal failed(string error)
function clear() {
const previousParser = frameParser;
frameParser = null;
if (previousParser)
previousParser.destroy();
deadline.stop();
initialDeadline.stop();
pending = null;
handshake = null;
subscribed = false;
installed = false;
lastId = "0";
lastDelivery = "";
}
function fail(error) {
if (connected && !root.recovering)
failed(error);
}
function request(op, params) {
if (!linkUp || pending || (op !== "hello" && !handshake))
throw new Error("unavailable: IPC connection");
const id = Ipc.nextId(lastId);
const message = {
ipc: 1,
id: id,
op: op,
params: params
};
if (op !== "hello")
message.session = handshake.session;
const text = JSON.stringify(message);
if (Ipc.byteLength(text) > (handshake?.max_request_bytes || 65536))
throw new Error("invalid: IPC request exceeds size bound");
lastId = id;
pending = {
id: id,
op: op,
params: params
};
deadline.restart();
send(text);
}
function receive(line) {
try {
const value = Ipc.envelope(line, handshake?.max_frame_bytes || 4259840);
if (value.event !== undefined) {
if (!events || !subscribed || pending || value.event !== "state" || !Ipc.decimal(value.delivery) || value.delivery === lastDelivery || value.id !== undefined || value.ok !== undefined || !Ipc.object(value.batch))
throw new Error("unexpected IPC state event");
if (!installed && value.batch.type !== "snapshot")
throw new Error("missing initial snapshot");
if (value.batch.session !== handshake.session || Ipc.byteLength(JSON.stringify(value.batch)) > handshake.max_batch_bytes)
throw new Error("invalid IPC batch identity or size");
batchReceived(value.batch);
if (!ready || root.recovering)
return;
installed = true;
initialDeadline.stop();
lastDelivery = value.delivery;
request("ack", {
delivery: value.delivery
});
return;
}
if (!pending)
throw new Error("unsolicited IPC response");
const error = Ipc.response(value, pending.id);
const operation = pending;
pending = null;
deadline.stop();
if (operation.op === "command") {
reply(value.result || null, error);
return;
}
if (error)
throw new Error(error);
switch (operation.op) {
case "hello":
handshake = Ipc.hello(value.result);
helloReceived();
break;
case "subscribe":
if (value.result.subscribed !== true)
throw new Error("invalid subscription response");
subscribed = true;
break;
case "ack":
if (value.result.acked !== operation.params.delivery)
throw new Error("invalid ack response");
break;
default:
throw new Error("unexpected IPC operation");
}
} catch (e) {
fail(String(e));
}
}
function subscribe() {
initialDeadline.restart();
request("subscribe", {});
}
onConnectionStateChanged: {
clear();
if (root.recovering)
return;
if (!linkUp) {
fail("IPC stream disconnected");
return;
}
frameParser = parserComponent.createObject(connection);
try {
request("hello", {});
} catch (e) {
fail(String(e));
}
}
parser: frameParser
Component {
id: parserComponent
SplitParser {
id: lineParser
onRead: line => {
if (connection.frameParser === lineParser)
connection.receive(line);
}
}
}
Timer {
id: deadline
interval: 5000
onTriggered: connection.fail("IPC request timed out")
}
Timer {
id: initialDeadline
interval: 8000
onTriggered: connection.fail("IPC initial snapshot timed out")
}
}
function windowFacade(window) {
const windowSession = session;
const output = outputs.find(o => o.id === window.output);
return {
id: window.id,
aqueousWindowId: window.id,
aqueousKey: windowSession + ":window:" + window.id,
aqueousSession: windowSession,
aqueousWorkspaceId: window.workspace,
aqueousOutputId: window.output,
appId: window.app_id || window.class || "",
title: window.title || "",
activated: root.seat?.window === window.id,
get fullscreen() {
return window.fullscreen;
},
set fullscreen(value) {
root.command("window.fullscreen", {
id: window.id,
session: windowSession,
value: value
});
},
get maximized() {
return window.maximized;
},
set maximized(value) {
root.command("window.maximized", {
id: window.id,
session: windowSession,
value: value
});
},
visible: window.visible,
skipSwitcher: window.skip_switcher,
canMinimize: window.can_minimize,
canMaximize: window.can_maximize,
canActivate: window.can_activate,
screens: Quickshell.screens.filter(s => s.name === output?.name),
get minimized() {
return window.minimized;
},
set minimized(value) {
root.command("window.minimized", {
id: window.id,
session: windowSession,
value: value
});
},
activate: function () {
root.command("window.activate", {
id: window.id,
session: windowSession
});
},
close: function () {
root.command("window.close", {
id: window.id,
session: windowSession
});
}
};
}
function activateWorkspace(workspace) {
if (!seat || !workspace)
return;
command("workspace.activate", {
id: workspace.id,
session: workspace.aqueousSession
});
}
function cycleKeyboardLayout() {
if (!seat || !keyboard || !capabilities.keyboard)
return;
command("keyboard.next", {
group: keyboard.id
});
}
function toggleOverview(screenName) {
if (!capabilities.overview)
return;
command("overview.toggle", {
output: outputId(screenName || focusedOutput)
});
}
function quit() {
command("session.exit");
}
function snapshot(path) {
if (!available || path !== socketPath)
return JSON.stringify({
error: "unavailable"
});
return JSON.stringify({
schema: 1,
session: session,
sequence: sequence,
type: "snapshot",
base_sequence: null,
upsert: entities,
removed: []
});
}
IpcHandler {
target: "aqueous"
function snapshot(path: string): string {
return root.snapshot(path);
}
function status(): string {
return JSON.stringify({
available: root.available,
session: root.session,
sequence: root.sequence,
focusedOutput: root.focusedOutput,
outputs: root.outputs.length,
windows: root.windows.length,
workspaces: root.workspaces.length,
keyboardLayout: root.keyboardLayout,
overviewOutput: root.sessionState.overview_output || null,
overviewWindow: root.sessionState.overview_window || null,
error: root.state.error || ""
});
}
function overview(action: string, output: string): string {
if (!["show", "hide", "toggle"].includes(action))
return "INVALID_ACTION";
if (!root.available || root.locked || !root.capabilities.overview)
return "UNAVAILABLE";
root.command("overview." + action, {
output: root.outputId(output || root.focusedOutput)
});
return "OVERVIEW_REQUESTED";
}
function selectSeat(name: string): string {
if (name && !root.seats.some(s => s.id === name))
return "SEAT_NOT_FOUND";
root.seatName = name;
return "SEAT_SELECTED";
}
}
function overlapsDock(screenName, position, thickness, width, height) {
const output = outputs.find(o => o.name === screenName);
if (!output)
return false;
return windows.some(w => {
if (w.output !== output.id || !w.visible || w.minimized)
return false;
const box = w.outer_geometry;
const x = box.x - output.bounds.x;
const y = box.y - output.bounds.y;
switch (position) {
case SettingsData.Position.Top:
return y < thickness && y + box.height > 0;
case SettingsData.Position.Left:
return x < thickness && x + box.width > 0;
case SettingsData.Position.Right:
return x < width && x + box.width > width - thickness;
default:
return y < height && y + box.height > height - thickness;
}
});
}
Component {
id: jsonProcessComponent
Process {
id: jsonProcess
property var input: null
property var callback: null
property bool finished: false
property Timer deadline: Timer {
id: jsonDeadline
interval: 8000
onTriggered: jsonProcess.finish(null, "command completion uncertain: timeout")
}
stdinEnabled: input !== null
function finish(result, error) {
if (finished)
return;
finished = true;
jsonDeadline.stop();
if (running)
signal(9);
root.pendingProcesses = root.pendingProcesses.filter(p => p !== jsonProcess);
const complete = callback;
callback = null;
if (complete)
complete(result, error);
Qt.callLater(() => jsonProcess.destroy());
}
onStarted: {
if (input !== null) {
write(input);
stdinEnabled = false;
}
}
stdout: StdioCollector {
waitForEnd: false
onDataChanged: {
if (data.byteLength > root.maxBatchBytes + 1)
jsonProcess.finish(null, "process output exceeds 4 MiB");
}
}
stderr: SplitParser {
splitMarker: ""
}
onExited: (code, status) => {
if (finished)
return;
let result = null;
let error = "";
try {
result = root.parseJson(stdout.text);
if (code !== 0 || status !== 0)
error = result?.code ? result.code + ": " + (result.message || "") : (result?.status || "command failed (" + code + ")");
} catch (e) {
error = String(e);
}
finish(result, error);
}
onRunningChanged: {
if (!running)
Qt.callLater(() => {
if (!jsonProcess.finished)
jsonProcess.finish(null, "command could not start");
});
}
}
}
}
+3 -1
View File
@@ -135,9 +135,11 @@ Singleton {
return screens.length > 0 ? widgetRegistry[widgetId][screens[0]] : null;
}
readonly property bool focusedScreenDetectionSupported: CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool focusedScreenDetectionSupported: (CompositorService.isAqueous && AqueousService.available) || CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
function getFocusedScreenName() {
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.focusedOutput;
if (CompositorService.isHyprland && Hyprland.focusedWorkspace?.monitor)
return Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isNiri && NiriService.currentOutput)
+91 -2
View File
@@ -21,8 +21,47 @@ Singleton {
property bool isScroll: false
property bool isMiracle: false
property bool isLabwc: false
property bool isAqueous: false
property string compositor: "unknown"
property bool compositorDetected: false
property bool outputPowerAvailable: false
readonly property bool genericPowerBackend: compositorDetected && !isNiri && !isHyprland && !isMango && !isSway && !isScroll && !isMiracle && !isLabwc
onGenericPowerBackendChanged: probeOutputPower()
function probeOutputPower() {
outputPowerAvailable = false;
if (!genericPowerBackend)
return;
const backend = compositor;
Proc.runCommand("output-power-probe", [Proc.dmsBin, "dpms", "list"], (output, code) => {
if (root.compositor === backend && root.genericPowerBackend)
root.outputPowerAvailable = code === 0;
}, 0, 5000);
}
function setOutputPower(on) {
Proc.runCommand("output-power-action", [Proc.dmsBin, "dpms", on ? "on" : "off"], (output, code) => {
if (code !== 0)
ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to change display power", "Error shown when changing monitor power fails"));
}, 0, 12000);
}
Connections {
target: DMSService
function onConnectionStateChanged() {
if (DMSService.isConnected)
root.probeOutputPower();
else
root.outputPowerAvailable = false;
}
}
Connections {
target: Quickshell
function onScreensChanged() {
root.probeOutputPower();
}
}
readonly property bool frameCompositorLayoutReady: (!isNiri || NiriService.frameLayoutReady) && (!isHyprland || HyprlandService.frameLayoutReady)
readonly property bool useHyprlandFocusGrab: isHyprland && Quickshell.env("DMS_HYPRLAND_EXCLUSIVE_FOCUS") !== "1"
@@ -45,9 +84,19 @@ Singleton {
signal toplevelsChanged
readonly property bool supportsMinimize: DankCommon.Compositor.supportsMinimize
readonly property bool supportsMinimize: isAqueous && AqueousService.available ? AqueousService.capabilities.commands && !AqueousService.locked : DankCommon.Compositor.supportsMinimize
Connections {
target: AqueousService
function onStateChanged() {
if (root.isAqueous)
root.scheduleSort();
}
}
function canMinimize(toplevel) {
if (isAqueous && toplevel?.aqueousWindowId)
return AqueousService.available && !AqueousService.locked && AqueousService.capabilities.commands && toplevel.canMinimize;
return supportsMinimize && toplevel && toplevel.minimized !== undefined;
}
@@ -59,6 +108,10 @@ Singleton {
function activateToplevel(toplevel) {
if (!toplevel)
return;
if (isAqueous && toplevel.aqueousWindowId) {
toplevel.activate();
return;
}
closeNiriOverviewOnWindowFocus();
if (canMinimize(toplevel) && toplevel.minimized)
toplevel.minimized = false;
@@ -147,7 +200,9 @@ Singleton {
function getFocusedScreen() {
let screenName = "";
if (isHyprland && Hyprland.focusedWorkspace?.monitor)
if (isAqueous && AqueousService.available)
screenName = AqueousService.focusedOutput;
else if (isHyprland && Hyprland.focusedWorkspace?.monitor)
screenName = Hyprland.focusedWorkspace.monitor.name;
else if (isNiri && NiriService.currentOutput)
screenName = NiriService.currentOutput;
@@ -267,6 +322,8 @@ Singleton {
}
function computeSortedToplevels() {
if (isAqueous && AqueousService.available)
return AqueousService.toplevels;
if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values)
return [];
@@ -517,6 +574,10 @@ Singleton {
}
function filterCurrentWorkspace(toplevels, screen) {
if (isAqueous && AqueousService.available) {
const active = AqueousService.workspacesForOutput(_screenName(screen)).filter(w => w.active).map(w => w.id);
return toplevels.filter(t => active.includes(t.aqueousWorkspaceId));
}
if (useNiriSorting)
return NiriService.filterCurrentWorkspace(toplevels, screen);
if (useMangoSorting)
@@ -528,6 +589,8 @@ Singleton {
function fullscreenToplevelOnScreen(screenOrName) {
const screenName = _screenName(screenOrName);
if (isAqueous && AqueousService.available)
return AqueousService.windows.some(w => w.output === AqueousService.outputId(screenName) && w.visible && w.fullscreen);
if (!screenName || !ToplevelManager.toplevels?.values)
return false;
@@ -657,6 +720,12 @@ Singleton {
return frameConfiguredForScreen(screenOrName);
}
function overviewActiveOnScreen(screenOrName) {
if (isAqueous && AqueousService.available)
return !!AqueousService.sessionState.overview_output && AqueousService.sessionState.overview_output === AqueousService.outputId(_screenName(screenOrName));
return isNiri && NiriService.inOverview;
}
function usesConnectedFrameChromeForScreen(screenOrName) {
return FrameTransitionState.effectiveConnectedFrameModeActive && frameWindowVisibleForScreen(screenOrName);
}
@@ -930,6 +999,8 @@ Singleton {
return "miracle";
case "labwc":
return "labwc";
case "aqueous":
return "aqueous";
default:
return "";
}
@@ -943,6 +1014,7 @@ Singleton {
isScroll = name === "scroll";
isMiracle = name === "miracle";
isLabwc = name === "labwc";
isAqueous = name === "aqueous";
compositor = name;
compositorDetected = true;
if (isNiri)
@@ -994,7 +1066,14 @@ Singleton {
// of winning on a stale env var.
function _envDetectionCandidates() {
const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || "";
const aqueousSocket = Quickshell.env("AQUEOUS_SOCKET") || "";
return [
{
name: "aqueous",
present: !!aqueousSocket,
test: ["test", "-S", aqueousSocket],
detail: "AQUEOUS_SOCKET " + aqueousSocket
},
{
name: "mango",
present: !!mangoSignature,
@@ -1076,6 +1155,11 @@ Singleton {
}
if (isLabwc) {
Quickshell.execDetached(["dms", "dpms", "off"]);
return;
}
if (outputPowerAvailable) {
setOutputPower(false);
return;
}
log.warn("Cannot power off monitors, unknown compositor");
}
@@ -1095,6 +1179,11 @@ Singleton {
}
if (isLabwc) {
Quickshell.execDetached(["dms", "dpms", "on"]);
return;
}
if (outputPowerAvailable) {
setOutputPower(true);
return;
}
log.warn("Cannot power on monitors, unknown compositor");
}
+23 -3
View File
@@ -402,7 +402,16 @@ Singleton {
}
}
function sendRequest(method, params, callback) {
Component {
id: requestTimeoutComponent
Timer {
property var requestId
repeat: false
onTriggered: root.handleResponse({id: requestId, error: "Request timed out; operation completion is uncertain"})
}
}
function sendRequest(method, params, callback, timeoutMs) {
if (!isConnected) {
log.warn("DMSService.sendRequest: Not connected, method:", method);
if (callback) {
@@ -424,8 +433,19 @@ Singleton {
request.params = params;
}
if (callback)
pendingRequests[id] = callback;
if (callback) {
if (timeoutMs > 0) {
const timeout = requestTimeoutComponent.createObject(root, {requestId: id, interval: timeoutMs});
pendingRequests[id] = response => {
timeout.stop();
timeout.destroy();
callback(response);
};
timeout.start();
} else {
pendingRequests[id] = callback;
}
}
if (method.startsWith("clipboard")) {
clipboardRequestIds[id] = true;
+387 -7
View File
@@ -14,8 +14,10 @@ Singleton {
id: root
readonly property var log: Log.scoped("KeybindsService")
property bool available: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
property bool available: CompositorService.isAqueous || CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
property string currentProvider: {
if (CompositorService.isAqueous)
return "aqueous";
if (CompositorService.isNiri)
return "niri";
if (CompositorService.isHyprland)
@@ -26,6 +28,8 @@ Singleton {
}
readonly property string cheatsheetProvider: {
if (CompositorService.isAqueous)
return "aqueous";
if (CompositorService.isNiri)
return "niri";
if (CompositorService.isHyprland)
@@ -66,6 +70,23 @@ Singleton {
property var displayList: []
property int _dataVersion: 0
property string _pendingSavedKey: ""
readonly property bool requiresBindReview: currentProvider === "aqueous"
readonly property string bindEditSession: requiresBindReview ? aqueousSession : currentProvider
readonly property bool bindMutationBusy: aqueousBusy || saving || removeProcess.running
property bool aqueousBusy: false
property bool _aqueousLoading: false
property bool _loadPending: false
property int _aqueousRequest: 0
readonly property string aqueousSession: currentProvider === "aqueous" ? AqueousService.session : ""
onCurrentProviderChanged: _pendingSavedKey = ""
onAqueousSessionChanged: {
_aqueousRequest++;
aqueousBusy = false;
_pendingSavedKey = "";
if (aqueousSession)
Qt.callLater(root.loadBinds, false);
}
readonly property var categoryOrder: Actions.getCategoryOrder()
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
@@ -119,7 +140,7 @@ Singleton {
Connections {
target: CompositorService
function onCompositorChanged() {
if (!CompositorService.isNiri && !CompositorService.isMango)
if (!CompositorService.isNiri && !CompositorService.isMango && !CompositorService.isAqueous)
return;
Qt.callLater(root.loadBinds);
}
@@ -161,9 +182,12 @@ Singleton {
Process {
id: loadProcess
running: false
property string provider: ""
stdout: StdioCollector {
onStreamFinished: {
if (loadProcess.provider !== root.currentProvider)
return;
try {
root._rawData = JSON.parse(text);
root._processData();
@@ -175,6 +199,8 @@ Singleton {
}
onExited: exitCode => {
if (provider !== root.currentProvider)
return;
if (exitCode !== 0) {
log.warn("Load process failed with code:", exitCode);
root.loading = false;
@@ -185,9 +211,13 @@ Singleton {
Process {
id: saveProcess
running: false
property string savedKey: ""
property string provider: ""
stderr: StdioCollector {
onStreamFinished: {
if (saveProcess.provider !== root.currentProvider)
return;
if (!text.trim())
return;
root.lastError = text.trim();
@@ -197,12 +227,20 @@ Singleton {
onExited: exitCode => {
root.saving = false;
if (provider !== root.currentProvider) {
savedKey = "";
return;
}
if (exitCode !== 0) {
root._pendingSavedKey = "";
savedKey = "";
log.error("Save failed with code:", exitCode);
root.bindSaveCompleted(false);
return;
}
root.lastError = "";
root._pendingSavedKey = savedKey;
savedKey = "";
root.bindSaveCompleted(true);
if (CompositorService.isMango)
MangoService.reloadConfig();
@@ -213,9 +251,12 @@ Singleton {
Process {
id: removeProcess
running: false
property string provider: ""
stderr: StdioCollector {
onStreamFinished: {
if (removeProcess.provider !== root.currentProvider)
return;
if (!text.trim())
return;
root.lastError = text.trim();
@@ -224,6 +265,8 @@ Singleton {
}
onExited: exitCode => {
if (provider !== root.currentProvider)
return;
if (exitCode !== 0) {
log.error("Remove failed with code:", exitCode);
return;
@@ -339,14 +382,313 @@ Singleton {
}
function loadBinds(showLoading) {
if (currentProvider === "aqueous") {
_loadPending = true;
if (_aqueousLoading || aqueousBusy)
return;
_loadPending = false;
_aqueousLoading = true;
loading = true;
readAqueousBinds((snapshot, error) => {
root._aqueousLoading = false;
root.loading = false;
if (snapshot) {
root.lastError = "";
root._rawData = snapshot;
root._processData();
} else {
root.lastError = error;
root._pendingSavedKey = "";
}
if (root._loadPending) {
root._loadPending = false;
Qt.callLater(root.loadBinds, false);
}
});
return;
}
if (loadProcess.running || !available)
return;
const hasData = Object.keys(_allBinds).length > 0;
loading = showLoading !== false && !hasData;
loadProcess.command = ["dms", "keybinds", "show", currentProvider];
loadProcess.provider = currentProvider;
loadProcess.running = true;
}
function readAqueousBinds(callback) {
const session = aqueousSession;
if (!session) {
callback(null, I18n.tr("Unavailable"));
return;
}
AqueousService.runJson(["dms", "keybinds", "show", "aqueous"], null, (snapshot, error) => {
if (session !== root.aqueousSession) {
callback(null, I18n.tr("Configuration changed. Refresh to continue.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"));
return;
}
try {
if (error)
throw new Error(error);
bindInventory(snapshot);
} catch (e) {
log.warn("Failed to read Aqueous keybindings:", e);
callback(null, AqueousService.errorMessage(String(e)));
return;
}
callback(snapshot, "");
});
}
function bindInventory(snapshot) {
if (snapshot?.provider !== "aqueous" || typeof snapshot.generation !== "string" || !snapshot.generation || !snapshot.binds || Array.isArray(snapshot.binds))
throw new Error("invalid Aqueous keybind snapshot");
if (!Array.isArray(snapshot.binds.Compositor) || !Array.isArray(snapshot.binds.Custom))
throw new Error("missing Aqueous keybind inventory");
return Object.keys(snapshot.binds).sort().map(category => {
if (!Array.isArray(snapshot.binds[category]))
throw new Error("invalid Aqueous keybind category");
return [category, snapshot.binds[category].map(bind => {
if (typeof bind.key !== "string" || typeof bind.action !== "string")
throw new Error("invalid Aqueous binding");
return [bind.key, bind.action, bind.source || ""];
})];
});
}
function bindInventoryChanges(baseline, current) {
const flatten = snapshot => bindInventory(snapshot).reduce((all, group) => all.concat(group[1].map(bind => [group[0], bind])), []);
const before = flatten(baseline);
const after = flatten(current);
const differences = [];
for (let i = 0; i < Math.max(before.length, after.length); i++) {
if (JSON.stringify(before[i]) === JSON.stringify(after[i]))
continue;
differences.push({
before: before[i]?.[1] || null,
after: after[i]?.[1] || null
});
}
return differences;
}
function bindingsForKey(snapshot, key) {
if (!key)
return [];
bindInventory(snapshot);
return Object.values(snapshot.binds).reduce((matches, category) => matches.concat(category.filter(bind => bind.key === key)), []);
}
function bindEditIssue(draft, current, reviewing) {
bindInventory(current);
if (!["set", "remove", "reset"].includes(draft.operation))
return "invalid_binding";
const original = bindingsForKey(current, draft.originalKey);
if (draft.originalKey && original.length !== 1)
return original.length ? "ambiguous_target" : "target_removed";
if (!reviewing && draft.originalKey && original[0].action !== draft.originalAction)
return "target_changed";
if (draft.operation !== "set")
return draft.originalKey ? "" : "target_removed";
const data = draft.data;
if (!data?.key || !data.action)
return "invalid_binding";
if (data.key !== draft.originalKey && bindingsForKey(current, data.key).length)
return "destination_occupied";
if (!data.action.startsWith("spawn ") && !current.binds.Compositor.some(bind => bind.action === data.action))
return "invalid_action";
return "";
}
function captureBindEdit(binding, key) {
if (!requiresBindReview)
return null;
bindInventory(_rawData);
if (!bindEditSession)
throw new Error(I18n.tr("Unavailable"));
return {
provider: currentProvider,
session: bindEditSession,
action: binding.action || "",
binding: JSON.parse(JSON.stringify(binding)),
baseline: JSON.parse(JSON.stringify(_rawData)),
originalKey: key || "",
originalAction: binding.action || "",
operation: "set",
data: {
key: key || "",
action: binding.action || "",
desc: binding.desc || ""
}
};
}
function updateBindEdit(draft, key, data, operation) {
const originals = bindingsForKey(draft.baseline, key);
return Object.assign({}, draft, {
operation: operation || draft.operation,
originalKey: key,
originalAction: originals.length ? originals[0].action : "",
data: JSON.parse(JSON.stringify(data || draft.data))
});
}
function loadBindReview(callback) {
if (requiresBindReview) {
readAqueousBinds(callback);
return;
}
callback(null, I18n.tr("Unavailable"));
}
function reconcileBindEdit(draft, snapshot) {
const issue = bindEditIssue(draft, snapshot, true);
if (issue)
return {
code: issue
};
const originals = bindingsForKey(snapshot, draft.originalKey);
return {
draft: Object.assign({}, draft, {
baseline: JSON.parse(JSON.stringify(snapshot)),
originalAction: originals.length ? originals[0].action : ""
})
};
}
function bindEditError(code) {
switch (code) {
case "external_change":
return I18n.tr("Keybindings changed. Refresh and review your edit before saving.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "target_removed":
return I18n.tr("The original shortcut was removed. Discard this edit or add a new shortcut.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "ambiguous_target":
return I18n.tr("Multiple bindings use the original shortcut. Resolve the duplicate bindings first.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "destination_occupied":
return I18n.tr("The new shortcut is already in use. Choose another shortcut.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "target_changed":
return I18n.tr("The original shortcut changed. Refresh and review your edit.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "invalidated":
return I18n.tr("The compositor session changed. Discard this edit before starting a new one.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "uncertain":
return I18n.tr("The save result is unknown. Refresh and check the current bindings.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
case "invalid_action":
return I18n.tr("The selected action is no longer available.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings");
default:
return I18n.tr("Failed to save keybind");
}
}
function describeBindReview(draft, current) {
if (!draft || !current)
return "";
const describe = (snapshot, key) => {
const matches = bindingsForKey(snapshot, key);
return matches.length ? matches.map(bind => bind.key + " → " + bind.action).join("\n") : I18n.tr("None");
};
const original = describe(draft.baseline, draft.originalKey);
const currentBind = describe(current, draft.originalKey);
const proposed = draft.operation === "set" ? draft.data.key + " → " + draft.data.action : I18n.tr("Remove");
const destination = draft.operation === "set" ? describe(current, draft.data.key) : I18n.tr("None");
const describeChange = bind => bind ? (bind[0] || I18n.tr("Not bound")) + " → " + bind[1] : I18n.tr("None");
const changes = bindInventoryChanges(draft.baseline, current).map(change => I18n.tr("Previous: %1\nCurrent: %2", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings").arg(describeChange(change.before)).arg(describeChange(change.after))).join("\n\n");
return I18n.tr("Original: %1\nCurrent: %2\nProposed: %3\nCurrent destination: %4", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings").arg(original).arg(currentBind).arg(proposed).arg(destination) + (changes ? "\n\n" + changes : "");
}
function _mutateAqueous(draft, callback) {
if (aqueousBusy || saving || removeProcess.running) {
callback({
success: false,
code: "busy"
});
return;
}
if (!draft?.baseline || !aqueousSession || draft.session !== aqueousSession) {
callback({
success: false,
code: "invalidated"
});
return;
}
const edit = JSON.parse(JSON.stringify(draft));
const request = ++_aqueousRequest;
aqueousBusy = true;
const complete = result => {
if (request !== root._aqueousRequest)
return;
root.aqueousBusy = false;
callback(result);
if (result.success || root._loadPending) {
root._loadPending = false;
Qt.callLater(root.loadBinds, false);
}
};
readAqueousBinds((snapshot, error) => {
if (request !== root._aqueousRequest)
return;
if (error) {
complete({
success: false,
code: "load_failed",
message: error
});
return;
}
try {
if (JSON.stringify(bindInventory(edit.baseline)) !== JSON.stringify(bindInventory(snapshot))) {
complete({
success: false,
code: "external_change",
snapshot: snapshot
});
return;
}
const issue = bindEditIssue(edit, snapshot, false);
if (issue) {
complete({
success: false,
code: issue,
snapshot: snapshot
});
return;
}
} catch (e) {
complete({
success: false,
code: "invalid_snapshot",
message: String(e)
});
return;
}
const args = ["dms", "keybinds", edit.operation, currentProvider, edit.operation === "set" ? edit.data.key : edit.originalKey];
if (edit.operation === "set") {
args.push(edit.data.action);
if (edit.originalKey && edit.originalKey !== edit.data.key)
args.push("--replace-key", edit.originalKey);
}
args.push("--expected-generation", snapshot.generation, "--json");
AqueousService.runJson(args, null, (result, error) => {
if (request !== root._aqueousRequest)
return;
if (error || result?.success !== true || !result.generation) {
complete({
success: false,
code: result?.success === false && result.code ? result.code : "uncertain",
message: result?.message || error
});
return;
}
if (edit.operation === "set")
root._pendingSavedKey = edit.data.key;
complete(result);
if (edit.operation === "set")
root.bindSaveCompleted(true);
else
root.bindRemoved(edit.originalKey);
});
});
}
function _processData() {
keybinds = _rawData || {};
modKey = currentProvider === "niri" ? (_rawData?.modKey || "Super") : "Super";
@@ -504,7 +846,11 @@ Singleton {
return [];
}
function saveBind(originalKey, bindData) {
function saveBind(originalKey, bindData, draft, callback) {
if (currentProvider === "aqueous") {
_mutateAqueous(draft, callback || (() => {}));
return;
}
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
@@ -512,7 +858,8 @@ Singleton {
if (!bindData.key || !Actions.isValidAction(bindData.action))
return;
saving = true;
const cmd = ["dms", "keybinds", "set", currentProvider, bindData.key, bindData.action, "--desc", bindData.desc || ""];
const cmd = ["dms", "keybinds", "set", currentProvider, bindData.key, bindData.action];
cmd.push("--desc", bindData.desc || "");
if (originalKey && originalKey !== bindData.key)
cmd.push("--replace-key", originalKey);
if (bindData.cooldownMs > 0)
@@ -526,8 +873,9 @@ Singleton {
if (bindData.flags)
cmd.push("--flags", bindData.flags);
saveProcess.command = cmd;
saveProcess.provider = currentProvider;
saveProcess.savedKey = bindData.key;
saveProcess.running = true;
_pendingSavedKey = bindData.key;
}
property bool _hyprlandLegacyWarnShown: false
@@ -552,7 +900,11 @@ Singleton {
ToastService.showWarning(I18n.tr("Hyprland conf mode"), I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before changing these settings."), "dms setup", "hyprland-migration");
}
function removeBind(key) {
function removeBind(key, draft, callback) {
if (currentProvider === "aqueous") {
_mutateAqueous(draft, callback || (() => {}));
return;
}
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
@@ -560,11 +912,16 @@ Singleton {
if (!key)
return;
removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key];
removeProcess.provider = currentProvider;
removeProcess.running = true;
bindRemoved(key);
}
function resetBind(key) {
function resetBind(key, draft, callback) {
if (currentProvider === "aqueous") {
_mutateAqueous(draft, callback || (() => {}));
return;
}
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
@@ -572,19 +929,42 @@ Singleton {
if (!key)
return;
removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key];
removeProcess.provider = currentProvider;
removeProcess.running = true;
bindRemoved(key);
}
function getActionLabel(action) {
if (currentProvider === "aqueous")
return (_rawData?.binds?.Compositor || []).find(b => b.action === action)?.desc || Actions.getActionLabel(action, currentProvider);
return Actions.getActionLabel(action, currentProvider);
}
function isKnownCompositorAction(action) {
if (currentProvider === "aqueous")
return (_rawData?.binds?.Compositor || []).some(b => b.action === action);
return Actions.isKnownCompositorAction(currentProvider, action);
}
function getCompositorCategories() {
if (currentProvider === "aqueous")
return ["Compositor"];
return Actions.getCompositorCategories(currentProvider);
}
function getCompositorActions(category) {
if (currentProvider === "aqueous") {
const seen = new Set();
return (_rawData?.binds?.Compositor || []).filter(b => {
if (seen.has(b.action))
return false;
seen.add(b.action);
return true;
}).map(b => ({
id: b.action,
label: b.desc
}));
}
return Actions.getCompositorActions(currentProvider, category);
}
+12 -4
View File
@@ -176,20 +176,20 @@ Singleton {
Process {
id: uwsmLogout
property bool notRunning: false
command: ["uwsm", "stop"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: data => {
if (data.trim().toLowerCase().includes("not running")) {
_logout();
}
if (data.trim().toLowerCase().includes("not running"))
uwsmLogout.notRunning = true;
}
}
onExited: function (exitCode) {
if (exitCode === 0) {
if (exitCode === 0 && !notRunning) {
return;
}
_logout();
@@ -393,13 +393,21 @@ Singleton {
// * Session management
function logout() {
if (hasUwsm) {
if (uwsmLogout.running)
return;
uwsmLogout.notRunning = false;
uwsmLogout.running = true;
return;
}
_logout();
}
function _logout() {
if (SettingsData.customPowerActionLogout.length === 0) {
if (CompositorService.isAqueous) {
AqueousService.quit();
return;
}
if (CompositorService.isNiri) {
NiriService.quit();
return;
@@ -44,6 +44,10 @@ Singleton {
"isNiri": () => CompositorService.isNiri,
"isHyprland": () => CompositorService.isHyprland,
"isMango": () => CompositorService.isMango,
"isAqueous": () => CompositorService.isAqueous,
"nativeOverviewCapable": () => CompositorService.isNiri || CompositorService.isAqueous,
"smartDockCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous,
"workspaceFollowFocusCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isAqueous,
"isHyprlandOrNiri": () => CompositorService.isHyprland || CompositorService.isNiri,
"windowRulesCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
"layoutCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
+27 -14
View File
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
Singleton {
@@ -109,7 +110,7 @@ Singleton {
function applyConfiguration(heads, callback) {
if (!DMSService.isConnected || !wlrOutputAvailable) {
if (callback) {
callback(false, "Not connected");
callback(false, I18n.tr("Not connected"));
}
return;
}
@@ -122,11 +123,11 @@ Singleton {
DMSService.sendRequest("wlroutput.applyConfiguration", {
"heads": heads
}, response => {
const success = !response.error;
const success = !response.error && response.result?.success === true;
const message = response.error || response.result?.message || "";
if (response.error) {
log.warn("applyConfiguration error:", response.error);
if (!success) {
log.warn("applyConfiguration error:", message);
} else {
log.debug("Configuration applied successfully");
}
@@ -135,13 +136,13 @@ Singleton {
if (callback) {
callback(success, message);
}
});
}, 5000);
}
function testConfiguration(heads, callback) {
if (!DMSService.isConnected || !wlrOutputAvailable) {
if (callback) {
callback(false, "Not connected");
callback(false, I18n.tr("Not connected"));
}
return;
}
@@ -151,11 +152,11 @@ Singleton {
DMSService.sendRequest("wlroutput.testConfiguration", {
"heads": heads
}, response => {
const success = !response.error;
const success = !response.error && response.result?.success === true;
const message = response.error || response.result?.message || "";
if (response.error) {
log.warn("testConfiguration error:", response.error);
if (!success) {
log.warn("testConfiguration error:", message);
} else {
log.debug("Configuration test passed");
}
@@ -163,7 +164,7 @@ Singleton {
if (callback) {
callback(success, message);
}
});
}, 5000);
}
function setOutputEnabled(outputName, enabled, callback) {
@@ -282,9 +283,22 @@ Singleton {
// NiriService, HyprlandService and MangoService. Instead of writing a
// config file, the changes are applied directly via the
// wlr-output-management protocol.
function applyOutputsConfig(outputsData, connectedOutputs) {
if (!wlrOutputAvailable)
function applyOutputsConfig(outputsData, connectedOutputs, callback) {
if (!wlrOutputAvailable) {
if (callback)
callback(false, I18n.tr("Not connected"));
return;
}
const heads = outputsConfigHeads(outputsData, connectedOutputs);
if (heads.length === 0) {
if (callback)
callback(false, I18n.tr("No monitors"));
return;
}
applyConfiguration(heads, callback);
}
function outputsConfigHeads(outputsData, connectedOutputs) {
const heads = [];
for (const name in outputsData) {
if (!connectedOutputs[name])
@@ -319,8 +333,7 @@ Singleton {
heads.push(head);
}
if (heads.length > 0)
applyConfiguration(heads);
return heads;
}
function transformFromName(name) {
+3 -1
View File
@@ -20,7 +20,7 @@ Item {
property string customPath: ""
property bool fallbackToApps: false
readonly property bool compositorAvailable: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc
readonly property bool compositorAvailable: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc || CompositorService.isAqueous
readonly property bool colorize: colorOverride !== ""
readonly property string resolvedMode: {
const fallback = fallbackToApps ? "apps" : "";
@@ -53,6 +53,8 @@ Item {
return "file://" + Theme.shellDir + "/assets/miraclewm.svg";
case "labwc":
return "file://" + Theme.shellDir + "/assets/labwc.png";
case "aqueous":
return "file://" + Theme.shellDir + "/assets/aqueous.svg";
default:
return "";
}
+1 -1
View File
@@ -73,7 +73,7 @@ Item {
onClipHeightChanged: settleKickAction.restart()
function _runSettleKick() {
if (!targetWindow?.visible)
if (!BlurService.compositorSupported || !targetWindow?.visible)
return;
kick();
settleRepeatTimer.restart();
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">Aqueous logo</title>
<desc id="desc">Vector tracing of the supplied logo, with a transparent background and editable cubic Bézier paths.</desc>
<g id="shadow" fill="#000000" fill-rule="evenodd" transform="translate(10 10)">
<path id="shadow-outer-body" d="M493.00,977.93 C457.96,976.49 421.00,970.80 393.00,962.55 C390.52,961.82 385.80,960.44 382.50,959.48 C357.36,952.19 321.40,937.66 299.00,925.73 C233.85,891.05 182.87,848.02 139.98,791.50 C97.33,735.30 66.02,664.91 53.08,596.09 C47.64,567.20 45.76,546.76 45.64,515.50 C45.48,473.64 48.70,446.26 58.63,405.07 C62.34,389.65 71.56,360.03 75.93,349.50 C76.38,348.40 78.68,342.77 81.03,337.00 C98.01,295.28 123.33,253.68 155.91,214.00 C165.42,202.41 198.02,169.20 209.90,159.00 C218.94,151.24 234.76,138.97 245.96,131.03 C257.06,123.17 272.68,113.00 273.66,113.00 C274.04,113.00 275.05,112.38 275.92,111.62 C278.29,109.54 300.02,97.61 312.50,91.54 C327.18,84.39 354.88,73.43 374.27,67.09 C391.15,61.57 416.83,55.06 431.50,52.58 C458.67,47.99 491.60,45.00 515.06,45.00 C549.18,45.00 599.52,52.73 632.75,63.08 C644.18,66.63 663.45,73.28 664.50,74.03 C665.05,74.42 667.30,75.32 669.50,76.03 C676.79,78.37 705.75,91.88 716.50,97.96 C725.91,103.27 747.69,116.45 749.88,118.15 C751.20,119.17 753.31,120.00 754.57,120.00 C755.85,120.00 762.08,116.32 768.54,111.75 C774.95,107.21 782.80,101.70 785.99,99.50 C789.17,97.30 793.74,94.13 796.14,92.44 C833.80,66.06 845.08,60.53 866.72,57.85 C878.08,56.44 912.19,57.37 926.00,59.48 C954.85,63.87 960.98,67.63 969.98,86.50 C972.66,92.13 975.57,100.21 976.62,105.00 C978.44,113.22 978.50,124.14 978.50,431.23 L978.50,748.96 976.07,750.73 C971.68,753.93 938.26,777.32 925.75,785.96 C913.27,794.57 862.56,829.90 813.00,864.52 C756.41,904.05 734.46,917.79 703.50,933.05 C649.55,959.65 591.05,975.26 536.77,977.53 C530.02,977.81 521.35,978.18 517.50,978.35 C513.65,978.52 502.62,978.33 493.00,977.93 Z M540.50,760.91 C564.52,759.01 587.54,754.04 605.50,746.89 C607.15,746.23 608.95,745.58 609.50,745.44 C616.94,743.53 664.48,720.82 686.65,708.59 C706.72,697.51 739.96,676.85 756.31,665.29 C792.57,639.65 813.12,622.72 837.58,598.35 C867.94,568.09 879.85,550.06 884.01,528.00 C885.10,522.27 885.38,505.41 884.50,498.50 C882.34,481.39 874.28,466.79 854.70,444.50 C846.40,435.05 825.95,414.73 816.95,406.98 C813.45,403.96 808.55,399.67 806.05,397.43 C785.74,379.25 741.50,347.38 711.00,328.97 C683.30,312.25 650.82,295.31 627.93,285.65 C552.68,253.90 471.81,254.04 403.31,286.05 C359.54,306.50 322.34,338.64 295.72,379.00 C276.72,407.80 265.02,437.20 258.91,471.50 C256.22,486.57 255.20,519.62 256.92,536.22 C264.36,608.31 303.55,672.65 366.50,716.14 C374.88,721.92 389.96,730.59 401.00,735.97 C429.02,749.61 459.03,757.91 491.50,760.99 C510.84,762.83 516.31,762.82 540.50,760.91 Z"/>
<path id="shadow-red-accent" d="M865.55,965.86 C850.54,963.93 836.04,957.81 818.82,946.14 C793.89,929.24 782.66,920.90 783.69,920.06 C785.84,918.29 793.34,913.04 814.00,898.84 C826.38,890.33 838.98,881.62 842.00,879.48 C845.02,877.34 858.30,868.11 871.50,858.96 C884.70,849.81 909.23,832.73 926.00,820.99 C942.77,809.26 961.59,796.14 967.80,791.85 L979.11,784.04 978.76,848.77 C978.42,912.30 978.37,913.65 976.18,921.32 C970.01,942.84 959.84,954.60 943.09,959.56 C921.81,965.88 887.45,968.66 865.55,965.86 Z"/>
<path id="shadow-inner-drop" d="M499.00,733.54 C498.18,733.34 492.77,732.64 487.00,731.99 C481.23,731.35 471.02,729.49 464.33,727.86 C393.49,710.66 336.46,665.87 306.03,603.56 C294.75,580.46 288.67,560.15 285.43,534.78 C282.39,510.98 284.76,480.72 291.49,457.61 C309.58,395.42 351.19,345.51 409.79,315.70 C437.33,301.69 459.41,294.91 488.49,291.52 C538.75,285.65 588.07,296.50 646.50,326.28 C717.65,362.54 792.04,417.06 830.89,461.42 C843.82,476.19 846.19,479.34 850.93,488.13 C854.26,494.31 855.16,497.22 856.14,505.05 C857.17,513.21 857.10,515.52 855.65,522.02 C852.00,538.35 842.61,551.59 815.59,578.54 C777.66,616.36 737.65,645.82 676.00,681.30 C659.97,690.52 624.97,708.00 608.55,714.97 C589.97,722.86 570.07,728.63 551.28,731.57 C542.46,732.95 502.69,734.45 499.00,733.54 Z"/>
</g>
<path id="outer-body" fill="#11b5c1" fill-rule="evenodd" d="M493.00,977.93 C457.96,976.49 421.00,970.80 393.00,962.55 C390.52,961.82 385.80,960.44 382.50,959.48 C357.36,952.19 321.40,937.66 299.00,925.73 C233.85,891.05 182.87,848.02 139.98,791.50 C97.33,735.30 66.02,664.91 53.08,596.09 C47.64,567.20 45.76,546.76 45.64,515.50 C45.48,473.64 48.70,446.26 58.63,405.07 C62.34,389.65 71.56,360.03 75.93,349.50 C76.38,348.40 78.68,342.77 81.03,337.00 C98.01,295.28 123.33,253.68 155.91,214.00 C165.42,202.41 198.02,169.20 209.90,159.00 C218.94,151.24 234.76,138.97 245.96,131.03 C257.06,123.17 272.68,113.00 273.66,113.00 C274.04,113.00 275.05,112.38 275.92,111.62 C278.29,109.54 300.02,97.61 312.50,91.54 C327.18,84.39 354.88,73.43 374.27,67.09 C391.15,61.57 416.83,55.06 431.50,52.58 C458.67,47.99 491.60,45.00 515.06,45.00 C549.18,45.00 599.52,52.73 632.75,63.08 C644.18,66.63 663.45,73.28 664.50,74.03 C665.05,74.42 667.30,75.32 669.50,76.03 C676.79,78.37 705.75,91.88 716.50,97.96 C725.91,103.27 747.69,116.45 749.88,118.15 C751.20,119.17 753.31,120.00 754.57,120.00 C755.85,120.00 762.08,116.32 768.54,111.75 C774.95,107.21 782.80,101.70 785.99,99.50 C789.17,97.30 793.74,94.13 796.14,92.44 C833.80,66.06 845.08,60.53 866.72,57.85 C878.08,56.44 912.19,57.37 926.00,59.48 C954.85,63.87 960.98,67.63 969.98,86.50 C972.66,92.13 975.57,100.21 976.62,105.00 C978.44,113.22 978.50,124.14 978.50,431.23 L978.50,748.96 976.07,750.73 C971.68,753.93 938.26,777.32 925.75,785.96 C913.27,794.57 862.56,829.90 813.00,864.52 C756.41,904.05 734.46,917.79 703.50,933.05 C649.55,959.65 591.05,975.26 536.77,977.53 C530.02,977.81 521.35,978.18 517.50,978.35 C513.65,978.52 502.62,978.33 493.00,977.93 Z M540.50,760.91 C564.52,759.01 587.54,754.04 605.50,746.89 C607.15,746.23 608.95,745.58 609.50,745.44 C616.94,743.53 664.48,720.82 686.65,708.59 C706.72,697.51 739.96,676.85 756.31,665.29 C792.57,639.65 813.12,622.72 837.58,598.35 C867.94,568.09 879.85,550.06 884.01,528.00 C885.10,522.27 885.38,505.41 884.50,498.50 C882.34,481.39 874.28,466.79 854.70,444.50 C846.40,435.05 825.95,414.73 816.95,406.98 C813.45,403.96 808.55,399.67 806.05,397.43 C785.74,379.25 741.50,347.38 711.00,328.97 C683.30,312.25 650.82,295.31 627.93,285.65 C552.68,253.90 471.81,254.04 403.31,286.05 C359.54,306.50 322.34,338.64 295.72,379.00 C276.72,407.80 265.02,437.20 258.91,471.50 C256.22,486.57 255.20,519.62 256.92,536.22 C264.36,608.31 303.55,672.65 366.50,716.14 C374.88,721.92 389.96,730.59 401.00,735.97 C429.02,749.61 459.03,757.91 491.50,760.99 C510.84,762.83 516.31,762.82 540.50,760.91 Z"/>
<path id="red-accent" fill="#ee4748" fill-rule="evenodd" d="M865.55,965.86 C850.54,963.93 836.04,957.81 818.82,946.14 C793.89,929.24 782.66,920.90 783.69,920.06 C785.84,918.29 793.34,913.04 814.00,898.84 C826.38,890.33 838.98,881.62 842.00,879.48 C845.02,877.34 858.30,868.11 871.50,858.96 C884.70,849.81 909.23,832.73 926.00,820.99 C942.77,809.26 961.59,796.14 967.80,791.85 L979.11,784.04 978.76,848.77 C978.42,912.30 978.37,913.65 976.18,921.32 C970.01,942.84 959.84,954.60 943.09,959.56 C921.81,965.88 887.45,968.66 865.55,965.86 Z"/>
<path id="inner-drop" fill="#0c8693" fill-rule="evenodd" d="M499.00,733.54 C498.18,733.34 492.77,732.64 487.00,731.99 C481.23,731.35 471.02,729.49 464.33,727.86 C393.49,710.66 336.46,665.87 306.03,603.56 C294.75,580.46 288.67,560.15 285.43,534.78 C282.39,510.98 284.76,480.72 291.49,457.61 C309.58,395.42 351.19,345.51 409.79,315.70 C437.33,301.69 459.41,294.91 488.49,291.52 C538.75,285.65 588.07,296.50 646.50,326.28 C717.65,362.54 792.04,417.06 830.89,461.42 C843.82,476.19 846.19,479.34 850.93,488.13 C854.26,494.31 855.16,497.22 856.14,505.05 C857.17,513.21 857.10,515.52 855.65,522.02 C852.00,538.35 842.61,551.59 815.59,578.54 C777.66,616.36 737.65,645.82 676.00,681.30 C659.97,690.52 624.97,708.00 608.55,714.97 C589.97,722.86 570.07,728.63 551.28,731.57 C542.46,732.95 502.69,734.45 499.00,733.54 Z"/>
<path id="highlight-large" fill="#2ab9c2" fill-rule="evenodd" d="M387.84,425.15 C380.29,422.64 376.22,420.02 370.57,413.98 C355.62,397.99 356.10,373.80 371.67,358.20 C391.22,338.63 423.74,344.45 436.20,369.76 C439.27,375.99 439.50,377.16 439.50,386.53 C439.50,396.14 439.33,396.94 435.77,404.16 C431.28,413.23 425.45,418.96 416.39,423.20 C407.88,427.18 396.35,427.97 387.84,425.15 Z"/>
<path id="highlight-small" fill="#2ab9c2" fill-rule="evenodd" d="M436.49,352.19 C431.29,349.76 426.28,343.99 425.43,339.47 C424.41,334.02 425.49,326.16 427.68,323.23 C428.75,321.79 431.30,319.26 433.35,317.60 C436.41,315.13 438.19,314.52 443.29,314.22 C448.39,313.91 450.30,314.27 454.00,316.25 C456.48,317.57 459.84,320.56 461.48,322.89 C464.01,326.47 464.47,328.07 464.48,333.31 C464.50,341.05 461.92,346.37 456.17,350.44 C450.77,354.25 442.47,354.99 436.49,352.19 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 8.9 KiB

@@ -407,7 +407,16 @@ def find_settings_components(content, filename, wrappers):
visible_raw = extract_property(block, "visible")
condition_key = FILE_CONDITION_MAP.get(filename)
if visible_raw:
if all(c in visible_raw for c in ("CompositorService.isNiri", "CompositorService.isHyprland", "CompositorService.isMango")):
if filename == "WorkspacesTab.qml" and setting_key == "workspaceFollowFocus":
condition_key = "workspaceFollowFocusCapable"
elif "CompositorService.isAqueous" in visible_raw:
if "CompositorService.isHyprland" in visible_raw:
condition_key = "smartDockCapable"
elif "CompositorService.isNiri" in visible_raw:
condition_key = "nativeOverviewCapable"
else:
condition_key = "isAqueous"
elif all(c in visible_raw for c in ("CompositorService.isNiri", "CompositorService.isHyprland", "CompositorService.isMango")):
condition_key = "windowRulesCapable"
elif "CompositorService.isNiri" in visible_raw:
condition_key = "isNiri"
@@ -1240,7 +1240,6 @@
"category": "Bar",
"keywords": [
"bar",
"during",
"frame",
"niri",
"overview",
@@ -1249,8 +1248,8 @@
"statusbar",
"topbar"
],
"description": "Show during Niri overview",
"conditionKey": "isNiri"
"description": "Show on Overview",
"conditionKey": "nativeOverviewCapable"
},
{
"section": "barSize",
@@ -1463,7 +1462,7 @@
"workspaces"
],
"description": "Show workspaces of the currently focused monitor",
"conditionKey": "windowRulesCapable"
"conditionKey": "workspaceFollowFocusCapable"
},
{
"section": "workspaceSettings",
@@ -1488,7 +1487,7 @@
],
"icon": "view_module",
"description": "Show workspace index numbers in the top bar workspace switcher",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "groupActiveWorkspaceApps",
@@ -1611,7 +1610,7 @@
"workspace"
],
"description": "Reverse workspace switch direction when scrolling over the bar",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "workspaceUnfocusedMonitorSeparateAppearance",
@@ -1696,7 +1695,7 @@
"workspaces"
],
"description": "Display only workspaces that contain windows",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "showWorkspaceApps",
@@ -1725,7 +1724,7 @@
"workspace"
],
"description": "Display application icons in workspace indicators",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "showWorkspaceIndex",
@@ -2015,7 +2014,7 @@
"windows"
],
"description": "Show dock when floating windows don",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "dockIsolateDisplays",
@@ -2311,17 +2310,15 @@
"tabIndex": 5,
"category": "Dock",
"keywords": [
"always",
"dock",
"launcher bar",
"niri",
"overview",
"panel",
"show",
"taskbar"
],
"description": "Always show the dock when niri",
"conditionKey": "isNiri"
"description": "Show on Overview",
"conditionKey": "nativeOverviewCapable"
},
{
"section": "dockLauncherLogoSizeOffset",
@@ -3615,6 +3612,23 @@
"icon": "apps",
"description": "Sync dark mode with settings portals for system-wide theme hints"
},
{
"section": "aqueousCursor",
"label": "Aqueous cursor",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"appearance",
"aqueous",
"colors",
"cursor",
"look",
"scheme",
"style",
"theme"
],
"conditionKey": "isAqueous"
},
{
"section": "matugenSmartMode",
"label": "Auto From Wallpaper",
@@ -6254,6 +6268,24 @@
"icon": "animation",
"description": "Globally scale animation durations with geometric motion & spring based physics with bezier curves"
},
{
"section": "aqueousTypography",
"label": "Aqueous typography",
"tabIndex": 14,
"category": "Typography & Motion",
"keywords": [
"animation",
"appearance",
"aqueous",
"font",
"fonts",
"motion",
"text",
"typeface",
"typography"
],
"conditionKey": "isAqueous"
},
{
"section": "modalCustomAnimationDuration",
"label": "Custom Duration",
@@ -9962,7 +9994,6 @@
"keywords": [
"border",
"decoration",
"during",
"frame",
"hide",
"integrations",
@@ -9972,8 +10003,8 @@
"window"
],
"icon": "toolbar",
"description": "Show during Niri overview",
"conditionKey": "isNiri"
"description": "Show on Overview",
"conditionKey": "nativeOverviewCapable"
},
{
"section": "frameLauncherEmergeSide",
@@ -10056,7 +10087,6 @@
"keywords": [
"border",
"decoration",
"during",
"frame",
"hide",
"niri",
@@ -10064,7 +10094,7 @@
"show",
"window"
],
"description": "Show during Niri overview",
"description": "Show on Overview",
"conditionKey": "frameEnabled"
},
{