1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

Compare commits

..

95 Commits

Author SHA1 Message Date
bbedward
cd9d92d884 update changelog link and VERSION 2026-01-13 08:31:50 -05:00
Lucas
1b69a5e62b nix: add wtype dependency (#1346) 2026-01-13 08:27:46 -05:00
bbedward
61d311b157 widgets: fix running apps positioning and popup manager 2026-01-13 08:26:29 -05:00
bbedward
6b76b86930 notifications: remove redundant trimStored and add null safety 2026-01-12 23:37:49 -05:00
bbedward
dcfb947c36 desktop widgets: sync position across screens option, clickthrough
option, grouping in settings, repositioning, new IPCs for control
fixes #1300
fixes #1301
2026-01-12 15:31:34 -05:00
bbedward
59893b7f44 notifications: use Theme.primary to represent do not distrub in bar 2026-01-12 11:57:42 -05:00
bbedward
d2c62f5533 matugen: add support for vscode-insiders 2026-01-12 11:46:29 -05:00
bbedward
2bbe9a0c45 core/wlcontext: use infinite poll timeout 2026-01-12 11:26:35 -05:00
bbedward
4e2ce82c0a notifications: swipe to dismiss on history 2026-01-12 11:08:22 -05:00
bbedward
104762186f widgets: respect radius for inactive DankButtonGroup i tems 2026-01-12 10:26:50 -05:00
bbedward
f1233ab1e3 matugen: add post_hook for mango 2026-01-12 10:05:19 -05:00
bbedward
d6b407ec37 settings: fix wallpaper preview cache update on per-mode change 2026-01-12 09:58:58 -05:00
bbedward
022b4b4bb3 enable changelog 2026-01-12 09:46:50 -05:00
bbedward
49b322582d keybinds: fix sh, fix screenshot-window options, empty args
part of #914
2026-01-12 09:35:30 -05:00
bbedward
1280bd047d settings: fix sidebar binding when clicked by emitting signal 2026-01-11 22:43:29 -05:00
bbedward
6f206d7523 dankdash: fix 24H format in weather tab
fixes #1283
2026-01-11 21:45:28 -05:00
bbedward
2e58283859 dgop: use used mem directly from API
- conditionally because it depends on newer dgop
2026-01-11 17:32:36 -05:00
Marcus Ramberg
99a5721fe8 settings: extract tab headings for search (#1333)
* settings: extract tab headings for search

* fix pre-commit

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-01-11 17:14:45 -05:00
bbedward
5302ebd840 notifications: spacing improvements
fixes #1241
2026-01-11 14:35:34 -05:00
bbedward
fa427ea1ac settings: fix clipping of generic color selector
fixes #1242
2026-01-11 14:04:48 -05:00
bbedward
7027bd1646 systemtray: use Theme radius for menu options
fixes #1331
2026-01-11 14:03:23 -05:00
bbedward
3c38e17472 notifications: add compact mode, expansion in history, expansion in
popup
fixes #1282
2026-01-11 12:11:44 -05:00
shalevc1098
510ea5d2e4 feat: configurable app id substitutions (#1317)
* feat: add configurable app ID substitutions setting

* feat: add live icon updates when substitutions change

* fix: cursor not showing on headerActions in non-collapsible cards

* fix: address PR review feedback

- add tags for search index
- remove hardcoded height from text fields
2026-01-10 21:00:15 -05:00
bbedward
bb2234d328 cc: dont show preference flip if not on ethernet and wifi 2026-01-10 10:35:48 -05:00
bbedward
edbdeb0fb8 widgets: add artix and void NF mappings 2026-01-10 10:18:09 -05:00
Kostiantyn To
19541fc573 update-service: add Artix Linux to supported distributions list (#1318) 2026-01-10 10:18:00 -05:00
bbedward
7c936cacfb niri: fix effectiveScreenAssignment in modal 2026-01-10 10:13:41 -05:00
bbedward
c60cd3a341 modals/auth: add show password option
fixes #1311
2026-01-09 22:20:18 -05:00
shalevc1098
e37135f80d feat: map steam_app_ID to steam_icon_ID for actual game icons (#1312)
Steam Proton games use window class steam_app_XXXXX. Steam installs
icons as steam_icon_XXXXX. This maps between them so actual game
icons display instead of generic controller fallback.
2026-01-09 21:40:35 -05:00
bbedward
aac937cbcc settingns: fix missing help text on desktop widgets 2026-01-09 19:07:37 -05:00
bbedward
4b46d022af workspaces: add color options, add focus follows monitor, remove
per-monitor option (was misleading)
relevant to #1207
2026-01-09 14:10:57 -05:00
bbedward
7f0181b310 matugen/vscode: fix selection contrast 2026-01-09 10:16:03 -05:00
bbedward
6a109274f8 hyprland: always use single window 2026-01-09 09:57:31 -05:00
bbedward
0f09cc693a lock: handle case where session lock is rejected 2026-01-09 09:46:39 -05:00
bbedward
af0166a553 dankbar: add bar get/setPosition IPC 2026-01-09 00:09:49 -05:00
bbedward
a283017f26 audio: recreate media players on pipewire device change 2026-01-08 23:35:42 -05:00
bbedward
5ae2cd1dfb i18n: fix RTL in plugin settings 2026-01-08 19:16:55 -05:00
bbedward
eece811fb0 i18n: more RTL repairs 2026-01-08 18:45:38 -05:00
bbedward
1ff1f3a7f2 i18n: more RTL layout enhancements 2026-01-08 16:11:30 -05:00
bbedward
a21a846bf5 wallpaper: encode image URIs
fixes #1306
2026-01-08 14:32:12 -05:00
Anton Kesy
f5f21e738a fix typos (#1304) 2026-01-08 14:10:24 -05:00
bbedward
033e62418a hyprland: fix cursor setting 2026-01-08 09:30:52 -05:00
bbedward
3c69e8b1cc revert readme 2026-01-07 22:59:28 -05:00
bbedward
118be27796 update readme 2026-01-07 22:56:16 -05:00
bbedward
721d35d417 readme:update vid url 2026-01-07 22:54:38 -05:00
bbedward
7bc3d5910d settings: fade to lock and monitor off by default on 2026-01-07 21:31:12 -05:00
bbedward
ccc7047be0 welcome: make the first page stuff clickable
fixes #1295
2026-01-07 21:22:15 -05:00
bbedward
a5e107c89d changelog: capability to display new release message 2026-01-07 20:15:50 -05:00
bbedward
646d60dcbf displays: fix text-alignment in model mode 2026-01-07 16:54:31 -05:00
bbedward
5dc7c0d797 core: add resolve-include recursive
fixes #1294
2026-01-07 16:45:31 -05:00
bbedward
db1de9df38 keybinds: fix empty string args, more writable provider options 2026-01-07 15:38:44 -05:00
bbedward
3dd21382ba network: support hidden SSIDs 2026-01-07 14:13:03 -05:00
bbedward
ec2b3d0d4b vpn: aggregate all import errors
- we are dumb about importing by just trying to import everythting
- that caused errors to not be represented correctly
- just aggregate them all and present them in toast details
- Better would be to detect the type of file being imported, but this is
  better than nothing
2026-01-07 13:22:56 -05:00
bbedward
a205df1bd6 keybinds: initial support for writable hyprland and mangoWC
fixes #1204
2026-01-07 12:15:38 -05:00
bbedward
e822fa73da cursor: make min/max wider 2026-01-07 10:04:47 -05:00
bbedward
634e75b80c plugins: improve version check 2026-01-07 09:46:55 -05:00
bbedward
ec5b507efc greeter: change hypr startup to exec-once 2026-01-07 09:18:32 -05:00
bbedward
e6d289d48c workflow: update stable workflow to use GH app 2026-01-06 22:45:34 -05:00
bbedward
745d7f26ce cursor: create/update XResources for XWL apps 2026-01-06 22:06:01 -05:00
bbedward
ad43053b94 cursor: hypr, mango, and dankinstall support for configs 2026-01-06 20:35:22 -05:00
purian23
721700190b feat: DMS Cursor Control - Size & Theme in niri 2026-01-06 19:08:05 -05:00
bbedward
8c9c936d0e clipboard: add cliphist-migrate CLI 2026-01-06 16:49:18 -05:00
dms-ci[bot]
842bf6e3ff nix: update vendorHash for go.mod changes 2026-01-06 21:03:38 +00:00
bbedward
c1fbeb3f5e network: listen to NM Wired interface + use nmcli for route metrics
- Some other misc floating window change, too lazy to separate the
  commit
2026-01-06 16:01:28 -05:00
bbedward
c45eb2cccf plugins: ipc visibility conditions 2026-01-06 13:22:36 -05:00
bbedward
1b5abca83a launcher remove right key 2026-01-06 10:37:58 -05:00
bbedward
45818b202f launcher: support for plugins to define context menus
fixes #1279
2026-01-06 10:08:22 -05:00
Ethan Todd
1c8ce46f25 notifications: fix notifications being completely transient if history is disabled (#1284) 2026-01-06 09:39:33 -05:00
bbedward
f762f9ae49 theme: fix gtk apply button on empty file
fixes #1280
2026-01-05 21:56:50 -05:00
bbedward
4484f6bd61 launcher: built-in plugins, add settings search plugin with ? default
trigger
2026-01-05 21:46:12 -05:00
purian23
0076c45496 shell: dmsCoreApp updates 2026-01-05 20:31:06 -05:00
bbedward
ab071e12aa icons: fix transmission-gtk modded app ID again 2026-01-05 16:44:31 -05:00
bbedward
8386b40c50 launcher: F10 as alt for menu key 2026-01-05 14:58:50 -05:00
bbedward
03a985228d dankbar: add shadow option
fixes #916
2026-01-05 13:43:15 -05:00
bbedward
ef7d7ec13d desktop widgets: niri overview only option + grid on overlay when on
overview
2026-01-05 13:01:10 -05:00
bbedward
824792cca7 notifications: add support for none, count, app name, and full detail
for lock screen
fixes #557
2026-01-05 12:22:05 -05:00
bbedward
850e5b6572 session: handle hibernate error
fixes #308
2026-01-05 12:01:17 -05:00
bbedward
64310854a6 compositor+matugen: border override, hypr/mango layout overrides, new
templates, respect XDG paths
- Add Hyprland and MangoWC templates
- Add GUI gaps, window radius, and border thickness overrides for niri,
  Hyprland, and MangoWC
- Add replacement support in matugen templates for DATA_DIR, CACHE_DIR,
  CONFIG_DIR
fixes #1274
fixes #1273
2026-01-05 11:25:13 -05:00
bbedward
4005a55bf2 session: blockLoading true 2026-01-05 09:11:19 -05:00
bbedward
0236fe3276 session: fix persist on empty file 2026-01-05 09:07:00 -05:00
bbedward
c1d95a3086 launcher: fix invalid icon rendering wrong icon 2026-01-04 22:58:20 -05:00
bbedward
9b027df1d5 doctor: add links to dr command 2026-01-04 22:44:19 -05:00
purian23
5e03afe7f0 feat: Implement DMS Core Persistent Apps 2026-01-04 22:33:50 -05:00
bbedward
145a974b6d welcome: add IPC targets and button on about page 2026-01-04 21:45:02 -05:00
bbedward
d23fc9f2df welcome: add a first launch welcome page with doctor integration
fixes #760
2026-01-04 19:07:34 -05:00
bbedward
7ac5191e8d matugen: fix app checking
- double nil for flatpak + bin required to skip
2026-01-04 17:53:47 -05:00
bbedward
29d27ebd6d mautgen: update vscode package 2026-01-04 17:19:51 -05:00
bbedward
e45075dd84 launcher: fix binding loop 2026-01-04 17:19:35 -05:00
bbedward
80bc87e76b clock: fixed width chars in vertical mode 2026-01-04 13:20:20 -05:00
bbedward
76d88517ec matugen: publish vscode theme to marketplace/ovsix 2026-01-04 13:07:23 -05:00
bbedward
151d695212 launcher: optimize bindings and filters 2026-01-04 11:49:24 -05:00
Ethan Todd
2e1bed5fb5 nix: update home-manager module to remove default*, add clsettings (#1233)
* nix: update home-manager module, add clsettings

* nix: resolve message and rename clsettings->clipboardSettings.

* nix: fix home-manager plugin_settings management. add option for whether plugin settings should be managed by nix.
2026-01-04 11:18:28 -03:00
Lucas
f163b97c17 doctor: add json output (#1263)
* doctor: add json output

* doctor: fix systemd failed state as ok
2026-01-03 20:41:10 -05:00
bbedward
436c99927e settings: detect read-only on save attempts 2026-01-03 20:34:36 -05:00
bbedward
aa72eacae7 notifications: add image persistence 2026-01-03 19:56:08 -05:00
270 changed files with 24565 additions and 3902 deletions

View File

@@ -5,15 +5,27 @@ on:
tags:
- "v*"
permissions:
contents: write
jobs:
update-stable:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create GitHub App token
id: app_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ steps.app_token.outputs.token }}
- name: Push to stable branch
run: git push origin HEAD:refs/heads/stable --force
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: git push https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git HEAD:refs/heads/stable --force

2
.gitignore vendored
View File

@@ -108,3 +108,5 @@ bin/
# direnv
.envrc
.direnv/
quickshell/dms-plugins
__pycache__

View File

@@ -16,3 +16,8 @@ This file is more of a quick reference so I know what to account for before next
- Initial RTL support/i18n
- Theme registry
- Notification persistence & history
- **BREAKING** vscode theme needs re-installed
- dms doctor cmd
- niri/hypr/mango gaps/window/border overrides
- settings search
- notification display ops on lock screen

View File

@@ -68,3 +68,9 @@ packages:
outpkg: mocks_wlclient
interfaces:
WaylandDisplay:
github.com/AvengeMedia/DankMaterialShell/core/internal/utils:
config:
dir: "internal/mocks/utils"
outpkg: mocks_utils
interfaces:
AppChecker:

View File

@@ -1,17 +1,29 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
bolt "go.etcd.io/bbolt"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
"github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
@@ -144,6 +156,30 @@ var (
clipConfigEnabled bool
)
var clipExportCmd = &cobra.Command{
Use: "export [file]",
Short: "Export clipboard history to JSON",
Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.",
Run: runClipExport,
}
var clipImportCmd = &cobra.Command{
Use: "import <file>",
Short: "Import clipboard history from JSON",
Long: "Import clipboard history from JSON file exported by 'dms cl export'.",
Args: cobra.ExactArgs(1),
Run: runClipImport,
}
var clipMigrateCmd = &cobra.Command{
Use: "cliphist-migrate [db-path]",
Short: "Migrate from cliphist",
Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.",
Run: runClipMigrate,
}
var clipMigrateDelete bool
func init() {
clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking")
clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste")
@@ -170,8 +206,10 @@ func init() {
clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)")
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd)
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
}
func runClipCopy(cmd *cobra.Command, args []string) {
@@ -606,3 +644,154 @@ func runClipConfigSet(cmd *cobra.Command, args []string) {
fmt.Println("Config updated")
}
func runClipExport(cmd *cobra.Command, args []string) {
req := models.Request{
ID: 1,
Method: "clipboard.getHistory",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get clipboard history: %v", err)
}
if resp.Error != "" {
log.Fatalf("Error: %s", resp.Error)
}
if resp.Result == nil {
log.Fatal("No clipboard history")
}
out, err := json.MarshalIndent(resp.Result, "", " ")
if err != nil {
log.Fatalf("Failed to marshal: %v", err)
}
if len(args) == 0 {
fmt.Println(string(out))
return
}
if err := os.WriteFile(args[0], out, 0644); err != nil {
log.Fatalf("Failed to write file: %v", err)
}
fmt.Printf("Exported to %s\n", args[0])
}
func runClipImport(cmd *cobra.Command, args []string) {
data, err := os.ReadFile(args[0])
if err != nil {
log.Fatalf("Failed to read file: %v", err)
}
var entries []map[string]any
if err := json.Unmarshal(data, &entries); err != nil {
log.Fatalf("Failed to parse JSON: %v", err)
}
var imported int
for _, entry := range entries {
dataStr, ok := entry["data"].(string)
if !ok {
continue
}
mimeType, _ := entry["mimeType"].(string)
if mimeType == "" {
mimeType = "text/plain"
}
var entryData []byte
if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil {
entryData = decoded
} else {
entryData = []byte(dataStr)
}
if err := clipboard.Store(entryData, mimeType); err != nil {
log.Errorf("Failed to store entry: %v", err)
continue
}
imported++
}
fmt.Printf("Imported %d entries\n", imported)
}
func runClipMigrate(cmd *cobra.Command, args []string) {
dbPath := getCliphistPath()
if len(args) > 0 {
dbPath = args[0]
}
if _, err := os.Stat(dbPath); err != nil {
log.Fatalf("Cliphist db not found: %s", dbPath)
}
db, err := bolt.Open(dbPath, 0644, &bolt.Options{
ReadOnly: true,
Timeout: 1 * time.Second,
})
if err != nil {
log.Fatalf("Failed to open cliphist db: %v", err)
}
defer db.Close()
var migrated int
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("b"))
if b == nil {
return fmt.Errorf("cliphist bucket not found")
}
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
if len(v) == 0 {
continue
}
mimeType := detectMimeType(v)
if err := clipboard.Store(v, mimeType); err != nil {
log.Errorf("Failed to store entry %d: %v", btoi(k), err)
continue
}
migrated++
}
return nil
})
if err != nil {
log.Fatalf("Migration failed: %v", err)
}
fmt.Printf("Migrated %d entries from cliphist\n", migrated)
if !clipMigrateDelete {
return
}
db.Close()
if err := os.Remove(dbPath); err != nil {
log.Errorf("Failed to delete cliphist db: %v", err)
return
}
os.Remove(filepath.Dir(dbPath))
fmt.Println("Deleted cliphist db")
}
func getCliphistPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db")
}
return filepath.Join(cacheDir, "cliphist", "db")
}
func detectMimeType(data []byte) string {
if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
return "image/png"
}
return "text/plain"
}
func btoi(v []byte) uint64 {
return binary.BigEndian.Uint64(v)
}

View File

@@ -514,5 +514,6 @@ func getCommonCommands() []*cobra.Command {
matugenCmd,
clipboardCmd,
doctorCmd,
configCmd,
}
}

View File

@@ -0,0 +1,318 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/spf13/cobra"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Configuration utilities",
}
var resolveIncludeCmd = &cobra.Command{
Use: "resolve-include <compositor> <filename>",
Short: "Check if a file is included in compositor config",
Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.",
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
switch len(args) {
case 0:
return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp
case 1:
return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
Run: runResolveInclude,
}
func init() {
configCmd.AddCommand(resolveIncludeCmd)
}
type IncludeResult struct {
Exists bool `json:"exists"`
Included bool `json:"included"`
}
func runResolveInclude(cmd *cobra.Command, args []string) {
compositor := strings.ToLower(args[0])
filename := args[1]
var result IncludeResult
var err error
switch compositor {
case "hyprland":
result, err = checkHyprlandInclude(filename)
case "niri":
result, err = checkNiriInclude(filename)
case "mangowc", "dwl", "mango":
result, err = checkMangoWCInclude(filename)
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
if err != nil {
log.Fatalf("Error checking include: %v", err)
}
output, _ := json.Marshal(result)
fmt.Fprintln(os.Stdout, string(output))
}
func checkHyprlandInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/hypr")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "hyprland.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if hyprlandFindInclude(expanded, target, processed) {
return true
}
}
return false
}
func checkNiriInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/niri")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "config.kdl")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func niriFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
content := string(data)
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "include") {
continue
}
startQuote := strings.Index(trimmed, "\"")
if startQuote == -1 {
continue
}
endQuote := strings.LastIndex(trimmed, "\"")
if endQuote <= startQuote {
continue
}
includePath := trimmed[startQuote+1 : endQuote]
if matchesTarget(includePath, target) {
return true
}
fullPath := includePath
if !filepath.IsAbs(includePath) {
fullPath = filepath.Join(baseDir, includePath)
}
if niriFindInclude(fullPath, target, processed) {
return true
}
}
return false
}
func checkMangoWCInclude(filename string) (IncludeResult, error) {
configDir, err := utils.ExpandPath("$HOME/.config/mango")
if err != nil {
return IncludeResult{}, err
}
targetPath := filepath.Join(configDir, "dms", filename)
result := IncludeResult{}
if _, err := os.Stat(targetPath); err == nil {
result.Exists = true
}
mainConfig := filepath.Join(configDir, "config.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
mainConfig = filepath.Join(configDir, "mango.conf")
}
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
return result, nil
}
processed := make(map[string]bool)
result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed)
return result, nil
}
func mangowcFindInclude(filePath, target string, processed map[string]bool) bool {
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
if processed[absPath] {
return false
}
processed[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return false
}
baseDir := filepath.Dir(absPath)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "source") {
continue
}
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
continue
}
sourcePath := strings.TrimSpace(parts[1])
if matchesTarget(sourcePath, target) {
return true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
continue
}
if mangowcFindInclude(expanded, target, processed) {
return true
}
}
return false
}
func matchesTarget(path, target string) bool {
path = strings.TrimPrefix(path, "./")
target = strings.TrimPrefix(target, "./")
return path == target || strings.HasSuffix(path, "/"+target)
}

View File

@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -95,10 +96,14 @@ var doctorCmd = &cobra.Command{
Run: runDoctor,
}
var doctorVerbose bool
var (
doctorVerbose bool
doctorJSON bool
)
func init() {
doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions")
doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format")
}
type category int
@@ -142,6 +147,7 @@ func (c category) String() string {
const (
checkNameMaxLength = 21
doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor"
)
type checkResult struct {
@@ -150,10 +156,43 @@ type checkResult struct {
status status
message string
details string
url string
}
type checkResultJSON struct {
Category string `json:"category"`
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
URL string `json:"url,omitempty"`
}
type doctorOutputJSON struct {
Summary struct {
Errors int `json:"errors"`
Warnings int `json:"warnings"`
OK int `json:"ok"`
Info int `json:"info"`
} `json:"summary"`
Results []checkResultJSON `json:"results"`
}
func (r checkResult) toJSON() checkResultJSON {
return checkResultJSON{
Category: r.category.String(),
Name: r.name,
Status: string(r.status),
Message: r.message,
Details: r.details,
URL: r.url,
}
}
func runDoctor(cmd *cobra.Command, args []string) {
printDoctorHeader()
if !doctorJSON {
printDoctorHeader()
}
qsFeatures, qsMissingFeatures := checkQuickshellFeatures()
@@ -169,8 +208,12 @@ func runDoctor(cmd *cobra.Command, args []string) {
checkEnvironmentVars(),
)
printResults(results)
printSummary(results, qsMissingFeatures)
if doctorJSON {
printResultsJSON(results)
} else {
printResults(results)
printSummary(results, qsMissingFeatures)
}
}
func printDoctorHeader() {
@@ -192,20 +235,21 @@ func checkSystemInfo() []checkResult {
if strings.Contains(err.Error(), "Unsupported distribution") {
osRelease := readOSRelease()
if osRelease["ID"] == "nixos" {
switch {
case osRelease["ID"] == "nixos":
status = statusOK
message = osRelease["PRETTY_NAME"]
if message == "" {
message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"])
}
details = "Supported for runtime (install via NixOS module or Flake)"
} else if osRelease["PRETTY_NAME"] != "" {
case osRelease["PRETTY_NAME"] != "":
message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"])
details = "DMS may work but automatic installation is not available"
}
}
results = append(results, checkResult{catSystem, "Operating System", status, message, details})
results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"})
} else {
status := statusOK
message := osInfo.PrettyName
@@ -219,6 +263,7 @@ func checkSystemInfo() []checkResult {
results = append(results, checkResult{
catSystem, "Operating System", status, message,
fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture),
doctorDocsURL + "#operating-system",
})
}
@@ -227,7 +272,7 @@ func checkSystemInfo() []checkResult {
if arch != "amd64" && arch != "arm64" {
archStatus = statusError
}
results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, ""})
results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, "", doctorDocsURL + "#architecture"})
waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
xdgSessionType := os.Getenv("XDG_SESSION_TYPE")
@@ -237,13 +282,15 @@ func checkSystemInfo() []checkResult {
results = append(results, checkResult{
catSystem, "Display Server", statusOK, "Wayland",
fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay),
doctorDocsURL + "#display-server",
})
case xdgSessionType == "x11":
results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", ""})
results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", "", doctorDocsURL + "#display-server"})
default:
results = append(results, checkResult{
catSystem, "Display Server", statusWarn, "Unknown (ensure you're running Wayland)",
fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType),
doctorDocsURL + "#display-server",
})
}
@@ -260,9 +307,10 @@ func checkEnvironmentVars() []checkResult {
func checkEnvVar(name string) []checkResult {
value := os.Getenv(name)
if value != "" {
return []checkResult{{catEnvironment, name, statusInfo, value, ""}}
} else if doctorVerbose {
return []checkResult{{catEnvironment, name, statusInfo, "Not set", ""}}
return []checkResult{{catEnvironment, name, statusInfo, value, "", doctorDocsURL + "#environment-variables"}}
}
if doctorVerbose {
return []checkResult{{catEnvironment, name, statusInfo, "Not set", "", doctorDocsURL + "#environment-variables"}}
}
return nil
}
@@ -289,7 +337,7 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
}
results := []checkResult{
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails},
{catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"},
}
qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures)
@@ -297,13 +345,13 @@ func checkVersions(qsMissingFeatures bool) []checkResult {
if doctorVerbose && qsPath != "" {
qsDetails = qsPath
}
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails})
results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"})
dmsVersion, dmsPath := getDMSShellVersion()
if dmsVersion != "" {
results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath})
results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath, doctorDocsURL + "#dms-shell"})
} else {
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install"})
results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"})
}
return results
@@ -366,16 +414,16 @@ func checkDMSInstallation() []checkResult {
}
if dmsPath == "" {
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path"}}
return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}}
}
results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath})
results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath, doctorDocsURL + "#dms-configuration"})
shellQml := filepath.Join(dmsPath, "shell.qml")
if _, err := os.Stat(shellQml); err != nil {
results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml})
results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml, doctorDocsURL + "#dms-configuration"})
} else {
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml})
results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml, doctorDocsURL + "#dms-configuration"})
}
if doctorVerbose {
@@ -388,7 +436,7 @@ func checkDMSInstallation() []checkResult {
case strings.Contains(dmsPath, ".config"):
installType = "User config"
}
results = append(results, checkResult{catInstallation, "Install Type", statusInfo, installType, dmsPath})
results = append(results, checkResult{catInstallation, "Install Type", statusInfo, installType, dmsPath, doctorDocsURL + "#dms-configuration"})
}
return results
@@ -411,24 +459,26 @@ func checkWindowManagers() []checkResult {
foundAny := false
for _, c := range compositors {
if slices.ContainsFunc(c.commands, utils.CommandExists) {
foundAny = true
var compositorPath string
for _, cmd := range c.commands {
if path, err := exec.LookPath(cmd); err == nil {
compositorPath = path
break
}
}
details := ""
if doctorVerbose && compositorPath != "" {
details = compositorPath
}
results = append(results, checkResult{
catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
})
if !slices.ContainsFunc(c.commands, utils.CommandExists) {
continue
}
foundAny = true
var compositorPath string
for _, cmd := range c.commands {
if path, err := exec.LookPath(cmd); err == nil {
compositorPath = path
break
}
}
details := ""
if doctorVerbose && compositorPath != "" {
details = compositorPath
}
results = append(results, checkResult{
catCompositor, c.name, statusOK,
getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details,
doctorDocsURL + "#compositor",
})
}
if !foundAny {
@@ -436,11 +486,12 @@ func checkWindowManagers() []checkResult {
catCompositor, "Compositor", statusError,
"No supported Wayland compositor found",
"Install Hyprland, niri, Sway, River, or Wayfire",
doctorDocsURL + "#compositor",
})
}
if wm := detectRunningWM(); wm != "" {
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, ""})
results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"})
}
return results
@@ -562,7 +613,7 @@ ShellRoot {
status, message = statusInfo, "Not available"
missingFeatures = true
}
results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc})
results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc, doctorDocsURL + "#quickshell-features"})
}
return results, missingFeatures
@@ -571,16 +622,16 @@ ShellRoot {
func checkI2CAvailability() checkResult {
ddc, err := brightness.NewDDCBackend()
if err != nil {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control"}
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
defer ddc.Close()
devices, err := ddc.GetDevices()
if err != nil || len(devices) == 0 {
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control"}
return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control"}
return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"}
}
func detectNetworkBackend() string {
@@ -610,25 +661,24 @@ func checkOptionalDependencies() []checkResult {
var results []checkResult
if utils.IsServiceActive("accounts-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusOK, "Running", "User accounts"})
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusOK, "Running", "User accounts", doctorDocsURL + "#optional-features"})
} else {
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusWarn, "Not running", "User accounts"})
results = append(results, checkResult{catOptionalFeatures, "accountsservice", statusWarn, "Not running", "User accounts", doctorDocsURL + "#optional-features"})
}
if utils.IsServiceActive("power-profiles-daemon", false) {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusOK, "Running", "Power profile management"})
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusOK, "Running", "Power profile management", doctorDocsURL + "#optional-features"})
} else {
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusInfo, "Not running", "Power profile management"})
results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", statusInfo, "Not running", "Power profile management", doctorDocsURL + "#optional-features"})
}
i2cStatus := checkI2CAvailability()
results = append(results, i2cStatus)
results = append(results, checkI2CAvailability())
terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"}
if idx := slices.IndexFunc(terminals, utils.CommandExists); idx >= 0 {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], ""})
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, terminals[idx], "", doctorDocsURL + "#optional-features"})
} else {
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty"})
results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, or alacritty", doctorDocsURL + "#optional-features"})
}
deps := []struct {
@@ -637,7 +687,7 @@ func checkOptionalDependencies() []checkResult {
}{
{"matugen", "matugen", "", "Dynamic theming", true},
{"dgop", "dgop", "", "System monitoring", true},
{"cava", "cava", "", "Audio waveform", false},
{"cava", "cava", "", "Audio visualizer", true},
{"khal", "khal", "", "Calendar events", false},
{"Network", "nmcli", "iwctl", "Network management", false},
{"danksearch", "dsearch", "", "File search", false},
@@ -647,13 +697,12 @@ func checkOptionalDependencies() []checkResult {
for _, d := range deps {
found, foundCmd := utils.CommandExists(d.cmd), d.cmd
if !found && d.altCmd != "" {
if utils.CommandExists(d.altCmd) {
found, foundCmd = true, d.altCmd
}
if !found && d.altCmd != "" && utils.CommandExists(d.altCmd) {
found, foundCmd = true, d.altCmd
}
if found {
switch {
case found:
message := "Installed"
details := d.desc
if d.name == "Network" {
@@ -672,11 +721,11 @@ func checkOptionalDependencies() []checkResult {
}
}
}
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, message, details})
} else if d.important {
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc})
} else {
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc})
results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, message, details, doctorDocsURL + "#optional-features"})
case d.important:
results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, doctorDocsURL + "#optional-features"})
default:
results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, doctorDocsURL + "#optional-features"})
}
}
@@ -699,19 +748,18 @@ func checkConfigurationFiles() []checkResult {
var results []checkResult
for _, cf := range configFiles {
info, err := os.Stat(cf.path)
if err == nil {
status := statusOK
message := "Present"
if info.Mode().Perm()&0200 == 0 {
status = statusWarn
message += " (read-only)"
}
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path})
} else {
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path})
if err != nil {
results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path, doctorDocsURL + "#config-files"})
continue
}
status := statusOK
message := "Present"
if info.Mode().Perm()&0200 == 0 {
status = statusWarn
message += " (read-only)"
}
results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"})
}
return results
}
@@ -725,27 +773,31 @@ func checkSystemdServices() []checkResult {
dmsState := getServiceState("dms", true)
if !dmsState.exists {
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service"})
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
} else {
status, message := statusOK, dmsState.enabled
if dmsState.active != "" {
message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active)
}
if dmsState.enabled == "disabled" {
switch {
case dmsState.enabled == "disabled":
status, message = statusWarn, "Disabled"
case dmsState.active == "failed" || dmsState.active == "inactive":
status = statusError
}
results = append(results, checkResult{catServices, "dms.service", status, message, ""})
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
}
greetdState := getServiceState("greetd", false)
if greetdState.exists {
switch {
case greetdState.exists:
status := statusOK
if greetdState.enabled == "disabled" {
status = statusInfo
}
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, ""})
} else if doctorVerbose {
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service"})
results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"})
case doctorVerbose:
results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"})
}
return results
@@ -799,6 +851,31 @@ func printResults(results []checkResult) {
}
}
func printResultsJSON(results []checkResult) {
var ds DoctorStatus
for _, r := range results {
ds.Add(r)
}
output := doctorOutputJSON{}
output.Summary.Errors = ds.ErrorCount()
output.Summary.Warnings = ds.WarningCount()
output.Summary.OK = ds.OKCount()
output.Summary.Info = len(ds.Info)
output.Results = make([]checkResultJSON, 0, len(results))
for _, r := range results {
output.Results = append(output.Results, r.toJSON())
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(output); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
}
func printResultLine(r checkResult, styles tui.Styles) {
icon, style := r.status.IconStyle(styles)

View File

@@ -57,12 +57,14 @@ var keybindsRemoveCmd = &cobra.Command{
}
func init() {
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")
keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked")
keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds")
keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat")
keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)")
keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)")
keybindsCmd.AddCommand(keybindsListCmd)
keybindsCmd.AddCommand(keybindsShowCmd)
@@ -110,12 +112,21 @@ func initializeProviders() {
}
}
func runKeybindsList(_ *cobra.Command, _ []string) {
func runKeybindsList(cmd *cobra.Command, _ []string) {
providerList := keybinds.GetDefaultRegistry().List()
asJSON, _ := cmd.Flags().GetBool("json")
if asJSON {
output, _ := json.Marshal(providerList)
fmt.Fprintln(os.Stdout, string(output))
return
}
if len(providerList) == 0 {
fmt.Fprintln(os.Stdout, "No providers available")
return
}
fmt.Fprintln(os.Stdout, "Available providers:")
for _, name := range providerList {
fmt.Fprintf(os.Stdout, " - %s\n", name)
@@ -201,6 +212,9 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
if v, _ := cmd.Flags().GetBool("no-repeat"); v {
options["repeat"] = 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 {

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -29,9 +30,16 @@ var matugenQueueCmd = &cobra.Command{
Run: runMatugenQueue,
}
var matugenCheckCmd = &cobra.Command{
Use: "check",
Short: "Check which template apps are detected",
Run: runMatugenCheck,
}
func init() {
matugenCmd.AddCommand(matugenGenerateCmd)
matugenCmd.AddCommand(matugenQueueCmd)
matugenCmd.AddCommand(matugenCheckCmd)
for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} {
cmd.Flags().String("state-dir", "", "State directory for cache files")
@@ -162,3 +170,12 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
log.Fatalf("Timeout waiting for theme generation")
}
}
func runMatugenCheck(cmd *cobra.Command, args []string) {
checks := matugen.CheckTemplates(nil)
data, err := json.Marshal(checks)
if err != nil {
log.Fatalf("Failed to marshal check results: %v", err)
}
fmt.Println(string(data))
}

View File

@@ -9,28 +9,28 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v0.4.2
github.com/fsnotify/fsnotify v1.9.0
github.com/godbus/dbus/v5 v5.2.0
github.com/godbus/dbus/v5 v5.2.2
github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83
github.com/pilebones/go-udev v0.9.1
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a
github.com/spf13/cobra v1.10.1
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
golang.org/x/image v0.34.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudflare/circl v1.6.2 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -38,21 +38,21 @@ require (
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.2 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -66,7 +66,7 @@ require (
github.com/spf13/afero v1.15.0
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0
golang.org/x/sys v0.39.0
golang.org/x/text v0.32.0
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -18,6 +18,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -26,18 +28,24 @@ github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsy
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
@@ -58,15 +66,22 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c=
github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k=
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0=
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs=
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY=
github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs=
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 h1:0lz2eJScP8v5YZQsrEw+ggWC5jNySjg4bIZo5BIh6iI=
github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19/go.mod h1:L+Evfcs7EdTqxwv854354cb6+++7TFL3hJn3Wy4g+3w=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -114,12 +129,16 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI=
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 h1:msKaIZrrNpvofLPDzNBW3152PJBsnPZsoNNosOCS+C0=
github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -133,22 +152,32 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -55,7 +55,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize)
}
dbPath, err := getDBPath()
dbPath, err := GetDBPath()
if err != nil {
return fmt.Errorf("get db path: %w", err)
}
@@ -111,7 +111,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
})
}
func getDBPath() (string, error) {
func GetDBPath() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
homeDir, err := os.UserHomeDir()
@@ -121,12 +121,31 @@ func getDBPath() (string, error) {
cacheDir = filepath.Join(homeDir, ".cache")
}
dbDir := filepath.Join(cacheDir, "dms-clipboard")
if err := os.MkdirAll(dbDir, 0700); err != nil {
return "", err
newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard")
newPath := filepath.Join(newDir, "db")
if _, err := os.Stat(newPath); err == nil {
return newPath, nil
}
return filepath.Join(dbDir, "db"), nil
oldDir := filepath.Join(cacheDir, "dms-clipboard")
oldPath := filepath.Join(oldDir, "db")
if _, err := os.Stat(oldPath); err == nil {
if err := os.MkdirAll(newDir, 0700); err != nil {
return "", err
}
if err := os.Rename(oldPath, newPath); err != nil {
return "", err
}
os.Remove(oldDir)
return newPath, nil
}
if err := os.MkdirAll(newDir, 0700); err != nil {
return "", err
}
return newPath, nil
}
func deduplicateInTx(b *bolt.Bucket, hash uint64) error {

View File

@@ -176,7 +176,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bo
}
if existingConfig != "" {
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig)
mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig, dmsDir)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err))
} else {
@@ -209,6 +209,8 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
{"layout.kdl", NiriLayoutConfig},
{"alttab.kdl", NiriAlttabConfig},
{"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.kdl", ""},
{"cursor.kdl", ""},
}
for _, cfg := range configs {
@@ -421,24 +423,31 @@ func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) {
return results, nil
}
// mergeNiriOutputSections extracts output sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig string) (string, error) {
// Regular expression to match output sections (including commented ones)
func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dmsDir string) (string, error) {
outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
// Find all output sections in the existing config
existingOutputs := outputRegex.FindAllString(existingConfig, -1)
if len(existingOutputs) == 0 {
// No output sections to merge
return newConfig, nil
}
// Remove the example output section from the new config
outputsPath := filepath.Join(dmsDir, "outputs.kdl")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, output := range existingOutputs {
outputsContent.WriteString(output)
outputsContent.WriteString("\n\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err))
} else {
cd.log("Migrated output sections to dms/outputs.kdl")
}
}
exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "")
// Find where to insert the output sections (after the input section)
inputEndRegex := regexp.MustCompile(`(?m)^}$`)
inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1)
@@ -446,7 +455,6 @@ func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig stri
return "", fmt.Errorf("could not find insertion point for output sections")
}
// Insert after the first closing brace (end of input section)
insertPos := inputMatches[0][1]
var builder strings.Builder
@@ -476,6 +484,12 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var existingConfig string
if _, err := os.Stat(result.Path); err == nil {
cd.log("Found existing Hyprland configuration")
@@ -515,7 +529,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
}
if existingConfig != "" {
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig)
mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir)
if err != nil {
cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err))
} else {
@@ -529,13 +543,44 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystem
return result, result.Error
}
if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Hyprland configuration")
return result, nil
}
// mergeHyprlandMonitorSections extracts monitor sections from existing config and merges them into the new config
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig string) (string, error) {
func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error {
configs := []struct {
name string
content string
}{
{"colors.conf", HyprColorsConfig},
{"layout.conf", HyprLayoutConfig},
{"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)},
{"outputs.conf", ""},
{"cursor.conf", ""},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if _, err := os.Stat(path); err == nil {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
if err := os.WriteFile(path, []byte(cfg.content), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) {
monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`)
existingMonitors := monitorRegex.FindAllString(existingConfig, -1)
@@ -543,6 +588,20 @@ func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig
return newConfig, nil
}
outputsPath := filepath.Join(dmsDir, "outputs.conf")
if _, err := os.Stat(outputsPath); err != nil {
var outputsContent strings.Builder
for _, monitor := range existingMonitors {
outputsContent.WriteString(monitor)
outputsContent.WriteString("\n")
}
if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0644); err != nil {
cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err))
} else {
cd.log("Migrated monitor sections to dms/outputs.conf")
}
}
exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`)
mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "")

View File

@@ -161,7 +161,8 @@ layout {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig)
tmpDir := t.TempDir()
result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError {
assert.Error(t, err)
@@ -362,7 +363,8 @@ input {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig)
tmpDir := t.TempDir()
result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir)
if tt.wantError {
assert.Error(t, err)
@@ -406,7 +408,7 @@ func TestHyprlandConfigDeployment(t *testing.T) {
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Contains(t, string(content), "# MONITOR CONFIG")
assert.Contains(t, string(content), "bind = $mod, T, exec, ghostty")
assert.Contains(t, string(content), "source = ./dms/binds.conf")
assert.Contains(t, string(content), "exec-once = ")
})
@@ -442,7 +444,7 @@ general {
require.NoError(t, err)
assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144")
assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60")
assert.Contains(t, string(newContent), "bind = $mod, T, exec, kitty")
assert.Contains(t, string(newContent), "source = ./dms/binds.conf")
assert.NotContains(t, string(newContent), "monitor = eDP-2")
})
}
@@ -459,10 +461,7 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG")
assert.Contains(t, HyprlandConfig, "# STARTUP APPS")
assert.Contains(t, HyprlandConfig, "# INPUT CONFIG")
assert.Contains(t, HyprlandConfig, "# KEYBINDINGS")
assert.Contains(t, HyprlandConfig, "bind = $mod, T, exec, {{TERMINAL_COMMAND}}")
assert.Contains(t, HyprlandConfig, "bind = $mod, space, exec, dms ipc call spotlight toggle")
assert.Contains(t, HyprlandConfig, "windowrule = border_size 0, match:class ^(com\\.mitchellh\\.ghostty)$")
assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf")
}
func TestGhosttyConfigStructure(t *testing.T) {

View File

@@ -0,0 +1,156 @@
# === Application Launchers ===
bind = SUPER, T, exec, {{TERMINAL_COMMAND}}
bind = SUPER, space, exec, dms ipc call spotlight toggle
bind = SUPER, V, exec, dms ipc call clipboard toggle
bind = SUPER, M, exec, dms ipc call processlist focusOrToggle
bind = SUPER, comma, exec, dms ipc call settings focusOrToggle
bind = SUPER, N, exec, dms ipc call notifications toggle
bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle
bind = SUPER, Y, exec, dms ipc call dankdash wallpaper
bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview
bind = SUPER, X, exec, dms ipc call powermenu toggle
# === Cheat sheet
bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = SUPER ALT, L, exec, dms ipc call lock lock
bind = SUPER SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, F, fullscreen, 0
bind = SUPER SHIFT, T, togglefloating
bind = SUPER, W, togglegroup
# === Focus Navigation ===
bind = SUPER, left, movefocus, l
bind = SUPER, down, movefocus, d
bind = SUPER, up, movefocus, u
bind = SUPER, right, movefocus, r
bind = SUPER, H, movefocus, l
bind = SUPER, J, movefocus, d
bind = SUPER, K, movefocus, u
bind = SUPER, L, movefocus, r
# === Window Movement ===
bind = SUPER SHIFT, left, movewindow, l
bind = SUPER SHIFT, down, movewindow, d
bind = SUPER SHIFT, up, movewindow, u
bind = SUPER SHIFT, right, movewindow, r
bind = SUPER SHIFT, H, movewindow, l
bind = SUPER SHIFT, J, movewindow, d
bind = SUPER SHIFT, K, movewindow, u
bind = SUPER SHIFT, L, movewindow, r
# === Column Navigation ===
bind = SUPER, Home, focuswindow, first
bind = SUPER, End, focuswindow, last
# === Monitor Navigation ===
bind = SUPER CTRL, left, focusmonitor, l
bind = SUPER CTRL, right, focusmonitor, r
bind = SUPER CTRL, H, focusmonitor, l
bind = SUPER CTRL, J, focusmonitor, d
bind = SUPER CTRL, K, focusmonitor, u
bind = SUPER CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = SUPER SHIFT CTRL, left, movewindow, mon:l
bind = SUPER SHIFT CTRL, down, movewindow, mon:d
bind = SUPER SHIFT CTRL, up, movewindow, mon:u
bind = SUPER SHIFT CTRL, right, movewindow, mon:r
bind = SUPER SHIFT CTRL, H, movewindow, mon:l
bind = SUPER SHIFT CTRL, J, movewindow, mon:d
bind = SUPER SHIFT CTRL, K, movewindow, mon:u
bind = SUPER SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = SUPER, Page_Down, workspace, e+1
bind = SUPER, Page_Up, workspace, e-1
bind = SUPER, U, workspace, e+1
bind = SUPER, I, workspace, e-1
bind = SUPER CTRL, down, movetoworkspace, e+1
bind = SUPER CTRL, up, movetoworkspace, e-1
bind = SUPER CTRL, U, movetoworkspace, e+1
bind = SUPER CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1
bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1
bind = SUPER SHIFT, U, movetoworkspace, e+1
bind = SUPER SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = SUPER, mouse_down, workspace, e+1
bind = SUPER, mouse_up, workspace, e-1
bind = SUPER CTRL, mouse_down, movetoworkspace, e+1
bind = SUPER CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
bind = SUPER, 6, workspace, 6
bind = SUPER, 7, workspace, 7
bind = SUPER, 8, workspace, 8
bind = SUPER, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 6, movetoworkspace, 6
bind = SUPER SHIFT, 7, movetoworkspace, 7
bind = SUPER SHIFT, 8, movetoworkspace, 8
bind = SUPER SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = SUPER, bracketleft, layoutmsg, preselect l
bind = SUPER, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = SUPER, R, layoutmsg, togglesplit
bind = SUPER CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = SUPER, mouse:272, Move window, movewindow
bindmd = SUPER, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = SUPER, code:20, Expand window left, resizeactive, -100 0
bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = SUPER, minus, resizeactive, -10% 0
binde = SUPER, equal, resizeactive, 10% 0
binde = SUPER SHIFT, minus, resizeactive, 0 -10%
binde = SUPER SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = SUPER SHIFT, P, dpms, toggle

View File

@@ -0,0 +1,25 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
$primary = rgb(d0bcff)
$outline = rgb(948f99)
$error = rgb(f2b8b5)
general {
col.active_border = $primary
col.inactive_border = $outline
}
group {
col.border_active = $primary
col.border_inactive = $outline
col.border_locked_active = $error
col.border_locked_inactive = $outline
groupbar {
col.active = $primary
col.inactive = $outline
col.locked_active = $error
col.locked_inactive = $outline
}
}

View File

@@ -0,0 +1,11 @@
# Auto-generated by DMS - do not edit manually
general {
gaps_in = 4
gaps_out = 4
border_size = 2
}
decoration {
rounding = 12
}

View File

@@ -27,10 +27,7 @@ input {
general {
gaps_in = 5
gaps_out = 5
border_size = 0 # off in niri
col.active_border = rgba(707070ff)
col.inactive_border = rgba(d0d0d0ff)
border_size = 2
layout = dwindle
}
@@ -42,7 +39,7 @@ decoration {
rounding = 12
active_opacity = 1.0
inactive_opacity = 0.9
inactive_opacity = 1.0
shadow {
enabled = true
@@ -93,7 +90,6 @@ misc {
windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = rounding 12, match:class ^(org\.gnome\.)
windowrule = border_size 0, match:class ^(org\.gnome\.)
windowrule = tile on, match:class ^(gnome-control-center)$
windowrule = tile on, match:class ^(pavucontrol)$
@@ -106,178 +102,17 @@ windowrule = float on, match:class ^(org\.gnome\.Nautilus)$
windowrule = float on, match:class ^(steam)$
windowrule = float on, match:class ^(xdg-desktop-portal)$
windowrule = border_size 0, match:class ^(org\.wezfurlong\.wezterm)$
windowrule = border_size 0, match:class ^(Alacritty)$
windowrule = border_size 0, match:class ^(zen)$
windowrule = border_size 0, match:class ^(com\.mitchellh\.ghostty)$
windowrule = border_size 0, match:class ^(kitty)$
windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$
windowrule = float on, match:class ^(zoom)$
# DMS windows floating by default
windowrule = float on, match:class ^(org.quickshell)$
windowrule = opacity 0.9 0.9, match:float false, match:focus false
# ! Hyprland doesn't size these windows correctly so disabling by default here
# windowrule = float on, match:class ^(org.quickshell)$
layerrule = no_anim on, match:namespace ^(quickshell)$
# ==================
# KEYBINDINGS
# ==================
$mod = SUPER
# === Application Launchers ===
bind = $mod, T, exec, {{TERMINAL_COMMAND}}
bind = $mod, space, exec, dms ipc call spotlight toggle
bind = $mod, V, exec, dms ipc call clipboard toggle
bind = $mod, M, exec, dms ipc call processlist focusOrToggle
bind = $mod, comma, exec, dms ipc call settings focusOrToggle
bind = $mod, N, exec, dms ipc call notifications toggle
bind = $mod SHIFT, N, exec, dms ipc call notepad toggle
bind = $mod, Y, exec, dms ipc call dankdash wallpaper
bind = $mod, TAB, exec, dms ipc call hypr toggleOverview
# === Cheat sheet
bind = $mod SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland
# === Security ===
bind = $mod ALT, L, exec, dms ipc call lock lock
bind = $mod SHIFT, E, exit
bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle
# === Audio Controls ===
bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3
bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3
bindl = , XF86AudioMute, exec, dms ipc call audio mute
bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute
bindl = , XF86AudioPause, exec, dms ipc call mpris playPause
bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause
bindl = , XF86AudioPrev, exec, dms ipc call mpris previous
bindl = , XF86AudioNext, exec, dms ipc call mpris next
# === Brightness Controls ===
bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 ""
bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 ""
# === Window Management ===
bind = $mod, Q, killactive
bind = $mod, F, fullscreen, 1
bind = $mod SHIFT, F, fullscreen, 0
bind = $mod SHIFT, T, togglefloating
bind = $mod, W, togglegroup
# === Focus Navigation ===
bind = $mod, left, movefocus, l
bind = $mod, down, movefocus, d
bind = $mod, up, movefocus, u
bind = $mod, right, movefocus, r
bind = $mod, H, movefocus, l
bind = $mod, J, movefocus, d
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
# === Window Movement ===
bind = $mod SHIFT, left, movewindow, l
bind = $mod SHIFT, down, movewindow, d
bind = $mod SHIFT, up, movewindow, u
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, J, movewindow, d
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
# === Column Navigation ===
bind = $mod, Home, focuswindow, first
bind = $mod, End, focuswindow, last
# === Monitor Navigation ===
bind = $mod CTRL, left, focusmonitor, l
bind = $mod CTRL, right, focusmonitor, r
bind = $mod CTRL, H, focusmonitor, l
bind = $mod CTRL, J, focusmonitor, d
bind = $mod CTRL, K, focusmonitor, u
bind = $mod CTRL, L, focusmonitor, r
# === Move to Monitor ===
bind = $mod SHIFT CTRL, left, movewindow, mon:l
bind = $mod SHIFT CTRL, down, movewindow, mon:d
bind = $mod SHIFT CTRL, up, movewindow, mon:u
bind = $mod SHIFT CTRL, right, movewindow, mon:r
bind = $mod SHIFT CTRL, H, movewindow, mon:l
bind = $mod SHIFT CTRL, J, movewindow, mon:d
bind = $mod SHIFT CTRL, K, movewindow, mon:u
bind = $mod SHIFT CTRL, L, movewindow, mon:r
# === Workspace Navigation ===
bind = $mod, Page_Down, workspace, e+1
bind = $mod, Page_Up, workspace, e-1
bind = $mod, U, workspace, e+1
bind = $mod, I, workspace, e-1
bind = $mod CTRL, down, movetoworkspace, e+1
bind = $mod CTRL, up, movetoworkspace, e-1
bind = $mod CTRL, U, movetoworkspace, e+1
bind = $mod CTRL, I, movetoworkspace, e-1
# === Move Workspaces ===
bind = $mod SHIFT, Page_Down, movetoworkspace, e+1
bind = $mod SHIFT, Page_Up, movetoworkspace, e-1
bind = $mod SHIFT, U, movetoworkspace, e+1
bind = $mod SHIFT, I, movetoworkspace, e-1
# === Mouse Wheel Navigation ===
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1
bind = $mod CTRL, mouse_down, movetoworkspace, e+1
bind = $mod CTRL, mouse_up, movetoworkspace, e-1
# === Numbered Workspaces ===
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5
bind = $mod, 6, workspace, 6
bind = $mod, 7, workspace, 7
bind = $mod, 8, workspace, 8
bind = $mod, 9, workspace, 9
# === Move to Numbered Workspaces ===
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5
bind = $mod SHIFT, 6, movetoworkspace, 6
bind = $mod SHIFT, 7, movetoworkspace, 7
bind = $mod SHIFT, 8, movetoworkspace, 8
bind = $mod SHIFT, 9, movetoworkspace, 9
# === Column Management ===
bind = $mod, bracketleft, layoutmsg, preselect l
bind = $mod, bracketright, layoutmsg, preselect r
# === Sizing & Layout ===
bind = $mod, R, layoutmsg, togglesplit
bind = $mod CTRL, F, resizeactive, exact 100%
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindmd = $mod, mouse:272, Move window, movewindow
bindmd = $mod, mouse:273, Resize window, resizewindow
# === Move/resize windows with mainMod + LMB/RMB and dragging ===
bindd = $mod, code:20, Expand window left, resizeactive, -100 0
bindd = $mod, code:21, Shrink window left, resizeactive, 100 0
# === Manual Sizing ===
binde = $mod, minus, resizeactive, -10% 0
binde = $mod, equal, resizeactive, 10% 0
binde = $mod SHIFT, minus, resizeactive, 0 -10%
binde = $mod SHIFT, equal, resizeactive, 0 10%
# === Screenshots ===
bind = , Print, exec, dms screenshot
bind = CTRL, Print, exec, dms screenshot full
bind = ALT, Print, exec, dms screenshot window
# === System Controls ===
bind = $mod SHIFT, P, dpms, toggle
source = ./dms/colors.conf
source = ./dms/outputs.conf
source = ./dms/layout.conf
source = ./dms/cursor.conf
source = ./dms/binds.conf

View File

@@ -1,8 +1,3 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
binds {
// === System & Overview ===
Mod+D repeat=false { toggle-overview; }
@@ -20,6 +15,8 @@ binds {
Mod+M hotkey-overlay-title="Task Manager" {
spawn "dms" "ipc" "call" "processlist" "focusOrToggle";
}
Super+X hotkey-overlay-title="Power Menu: Toggle" { spawn "dms" "ipc" "call" "powermenu" "toggle"; }
Mod+Comma hotkey-overlay-title="Settings" {
spawn "dms" "ipc" "call" "settings" "focusOrToggle";
}

View File

@@ -1,21 +1,19 @@
// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
// ! Auto-generated file. Do not edit directly.
// Remove `include "dms/colors.kdl"` from your config to override.
layout {
background-color "transparent"
focus-ring {
active-color "#9dcbfb"
inactive-color "#8c9199"
urgent-color "#ffb4ab"
active-color "#d0bcff"
inactive-color "#948f99"
urgent-color "#f2b8b5"
}
border {
active-color "#9dcbfb"
inactive-color "#8c9199"
urgent-color "#ffb4ab"
active-color "#d0bcff"
inactive-color "#948f99"
urgent-color "#f2b8b5"
}
shadow {
@@ -23,19 +21,19 @@ layout {
}
tab-indicator {
active-color "#9dcbfb"
inactive-color "#8c9199"
urgent-color "#ffb4ab"
active-color "#d0bcff"
inactive-color "#948f99"
urgent-color "#f2b8b5"
}
insert-hint {
color "#9dcbfb80"
color "#d0bcff80"
}
}
recent-windows {
highlight {
active-color "#124a73"
urgent-color "#ffb4ab"
active-color "#4f378b"
urgent-color "#f2b8b5"
}
}

View File

@@ -240,10 +240,6 @@ window-rule {
match app-id="kitty"
draw-border-with-background false
}
window-rule {
match is-active=false
opacity 0.9
}
window-rule {
match app-id=r#"firefox$"# title="^Picture-in-Picture$"
match app-id="zoom"
@@ -273,3 +269,5 @@ include "dms/colors.kdl"
include "dms/layout.kdl"
include "dms/alttab.kdl"
include "dms/binds.kdl"
include "dms/outputs.kdl"
include "dms/cursor.kdl"

View File

@@ -4,3 +4,12 @@ import _ "embed"
//go:embed embedded/hyprland.conf
var HyprlandConfig string
//go:embed embedded/hypr-colors.conf
var HyprColorsConfig string
//go:embed embedded/hypr-layout.conf
var HyprLayoutConfig string
//go:embed embedded/hypr-binds.conf
var HyprBindsConfig string

View File

@@ -153,7 +153,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
}
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "solopasha/hyprland"}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {

View File

@@ -2,45 +2,93 @@ package providers
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type HyprlandProvider struct {
configPath string
configPath string
dmsBindsIncluded bool
parsed bool
}
func NewHyprlandProvider(configPath string) *HyprlandProvider {
if configPath == "" {
configPath = "$HOME/.config/hypr"
configPath = defaultHyprlandConfigDir()
}
return &HyprlandProvider{
configPath: configPath,
}
}
func defaultHyprlandConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "hypr")
}
func (h *HyprlandProvider) Name() string {
return "hyprland"
}
func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
section, err := ParseHyprlandKeys(h.configPath)
result, err := ParseHyprlandKeysWithDMS(h.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse hyprland config: %w", err)
}
categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(section, "", categorizedBinds)
h.dmsBindsIncluded = result.DMSBindsIncluded
h.parsed = true
return &keybinds.CheatSheet{
Title: "Hyprland Keybinds",
Provider: h.Name(),
Binds: categorizedBinds,
}, nil
categorizedBinds := make(map[string][]keybinds.Keybind)
h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs)
sheet := &keybinds.CheatSheet{
Title: "Hyprland Keybinds",
Provider: h.Name(),
Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded,
}
if result.DMSStatus != nil {
sheet.DMSStatus = &keybinds.DMSBindsStatus{
Exists: result.DMSStatus.Exists,
Included: result.DMSStatus.Included,
IncludePosition: result.DMSStatus.IncludePosition,
TotalIncludes: result.DMSStatus.TotalIncludes,
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
}
}
return sheet, nil
}
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) {
func (h *HyprlandProvider) HasDMSBindsIncluded() bool {
if h.parsed {
return h.dmsBindsIncluded
}
result, err := ParseHyprlandKeysWithDMS(h.configPath)
if err != nil {
return false
}
h.dmsBindsIncluded = result.DMSBindsIncluded
h.parsed = true
return h.dmsBindsIncluded
}
func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) {
currentSubcat := subcategory
if section.Name != "" {
currentSubcat = section.Name
@@ -48,12 +96,12 @@ func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory
for _, kb := range section.Keybinds {
category := h.categorizeByDispatcher(kb.Dispatcher)
bind := h.convertKeybind(&kb, currentSubcat)
bind := h.convertKeybind(&kb, currentSubcat, conflicts)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
for _, child := range section.Children {
h.convertSection(&child, currentSubcat, categorizedBinds)
h.convertSection(&child, currentSubcat, categorizedBinds, conflicts)
}
}
@@ -85,8 +133,8 @@ func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string {
}
}
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string) keybinds.Keybind {
key := h.formatKey(kb)
func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind {
keyStr := h.formatKey(kb)
rawAction := h.formatRawAction(kb.Dispatcher, kb.Params)
desc := kb.Comment
@@ -94,12 +142,33 @@ func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory st
desc = rawAction
}
return keybinds.Keybind{
Key: key,
source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") {
source = "dms"
}
bind := keybinds.Keybind{
Key: keyStr,
Description: desc,
Action: rawAction,
Subcategory: subcategory,
Source: source,
Flags: kb.Flags,
}
if source == "dms" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Comment,
Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params),
Source: "config",
}
}
}
return bind
}
func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string {
@@ -115,3 +184,314 @@ func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string {
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (h *HyprlandProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(h.configPath)
if err != nil {
return filepath.Join(h.configPath, "dms", "binds.conf")
}
return filepath.Join(expanded, "dms", "binds.conf")
}
func (h *HyprlandProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "exec" || action == "exec ":
return fmt.Errorf("exec dispatcher requires arguments")
case strings.HasPrefix(action, "exec "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "exec "))
if rest == "" {
return fmt.Errorf("exec dispatcher requires arguments")
}
}
return nil
}
func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error {
if err := h.validateAction(action); err != nil {
return err
}
overridePath := h.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := h.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*hyprlandOverrideBind)
}
// Extract flags from options
var flags string
if options != nil {
if f, ok := options["flags"].(string); ok {
flags = f
}
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &hyprlandOverrideBind{
Key: key,
Action: action,
Description: description,
Flags: flags,
Options: options,
}
return h.writeOverrideBinds(existingBinds)
}
func (h *HyprlandProvider) RemoveBind(key string) error {
existingBinds, err := h.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return h.writeOverrideBinds(existingBinds)
}
type hyprlandOverrideBind struct {
Key string
Action string
Description string
Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
Options map[string]any
}
func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) {
overridePath := h.GetOverridePath()
binds := make(map[string]*hyprlandOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
// Extract flags from bind type
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
// For bindd, format is: mods, key, description, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3
dispatcherIndex = 2
}
fields := strings.SplitN(bindContent, ",", minFields+2)
if len(fields) < minFields {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
var dispatcher, params string
if hasDescFlag {
if comment == "" {
comment = strings.TrimSpace(fields[descIndex])
}
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(fields[dispatcherIndex])
if len(fields) > dispatcherIndex+1 {
paramParts := fields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
keyStr := h.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := dispatcher
if params != "" {
action = dispatcher + " " + params
}
binds[normalizedKey] = &hyprlandOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
Flags: flags,
}
}
return binds, nil
}
func (h *HyprlandProvider) buildKeyString(mods, key string) string {
if mods == "" {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
}
func (h *HyprlandProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"):
return 0
case strings.Contains(action, "workspace"):
return 1
case strings.Contains(action, "window") || strings.Contains(action, "focus") ||
strings.Contains(action, "move") || strings.Contains(action, "swap") ||
strings.Contains(action, "resize"):
return 2
case strings.Contains(action, "monitor"):
return 3
case strings.HasPrefix(action, "exec"):
return 4
case action == "exit" || strings.Contains(action, "dpms"):
return 5
default:
return 6
}
}
func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error {
overridePath := h.GetOverridePath()
content := h.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string {
if len(binds) == 0 {
return ""
}
bindList := make([]*hyprlandOverrideBind, 0, len(binds))
for _, bind := range binds {
bindList = append(bindList, bind)
}
sort.Slice(bindList, func(i, j int) bool {
pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action)
if pi != pj {
return pi < pj
}
return bindList[i].Key < bindList[j].Key
})
var sb strings.Builder
for _, bind := range bindList {
h.writeBindLine(&sb, bind)
}
return sb.String()
}
func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) {
mods, key := h.parseKeyString(bind.Key)
dispatcher, params := h.parseAction(bind.Action)
// Write bind type with flags (e.g., "bind", "binde", "bindel")
sb.WriteString("bind")
if bind.Flags != "" {
sb.WriteString(bind.Flags)
}
sb.WriteString(" = ")
sb.WriteString(mods)
sb.WriteString(", ")
sb.WriteString(key)
sb.WriteString(", ")
// For bindd (description flag), include description before dispatcher
if strings.Contains(bind.Flags, "d") && bind.Description != "" {
sb.WriteString(bind.Description)
sb.WriteString(", ")
}
sb.WriteString(dispatcher)
if params != "" {
sb.WriteString(", ")
sb.WriteString(params)
}
// Only add comment if not using bindd (which has inline description)
if bind.Description != "" && !strings.Contains(bind.Flags, "d") {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
default:
return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1]
}
}
func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
dispatcher = parts[0]
default:
dispatcher = parts[0]
params = parts[1]
}
// Convert internal spawn format to Hyprland's exec
if dispatcher == "spawn" {
dispatcher = "exec"
}
return dispatcher, params
}

View File

@@ -23,6 +23,8 @@ type HyprlandKeyBinding struct {
Dispatcher string `json:"dispatcher"`
Params string `json:"params"`
Comment string `json:"comment"`
Source string `json:"source"`
Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press
}
type HyprlandSection struct {
@@ -32,14 +34,36 @@ type HyprlandSection struct {
}
type HyprlandParser struct {
contentLines []string
readingLine int
contentLines []string
readingLine int
configDir string
currentSource string
dmsBindsExists bool
dmsBindsIncluded bool
includeCount int
dmsIncludePos int
bindsAfterDMS int
dmsBindKeys map[string]bool
configBindKeys map[string]bool
conflictingConfigs map[string]*HyprlandKeyBinding
bindMap map[string]*HyprlandKeyBinding
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
}
func NewHyprlandParser() *HyprlandParser {
func NewHyprlandParser(configDir string) *HyprlandParser {
return &HyprlandParser{
contentLines: []string{},
readingLine: 0,
contentLines: []string{},
readingLine: 0,
configDir: configDir,
dmsIncludePos: -1,
dmsBindKeys: make(map[string]bool),
configBindKeys: make(map[string]bool),
conflictingConfigs: make(map[string]*HyprlandKeyBinding),
bindMap: make(map[string]*HyprlandKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
}
}
@@ -195,71 +219,7 @@ func hyprlandAutogenerateComment(dispatcher, params string) string {
func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding {
line := p.contentLines[lineNumber]
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
keyFields := strings.SplitN(keys, ",", 5)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
dispatcher := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
paramParts := keyFields[3:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
if comment != "" {
if strings.HasPrefix(comment, HideComment) {
return nil
}
} else {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
return &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
}
return p.parseBindLine(line)
}
func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection {
@@ -320,9 +280,348 @@ func (p *HyprlandParser) ParseKeys() *HyprlandSection {
}
func ParseHyprlandKeys(path string) (*HyprlandSection, error) {
parser := NewHyprlandParser()
parser := NewHyprlandParser(path)
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}
type HyprlandParseResult struct {
Section *HyprlandSection
DMSBindsIncluded bool
DMSStatus *HyprlandDMSStatus
ConflictingConfigs map[string]*HyprlandKeyBinding
}
type HyprlandDMSStatus struct {
Exists bool
Included bool
IncludePosition int
TotalIncludes int
BindsAfterDMS int
Effective bool
OverriddenBy int
StatusMessage string
}
func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus {
status := &HyprlandDMSStatus{
Exists: p.dmsBindsExists,
Included: p.dmsBindsIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
}
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
status.StatusMessage = "Some DMS binds may be overridden by config binds"
default:
status.Effective = true
status.StatusMessage = "DMS binds are active"
}
return status
}
func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (p *HyprlandParser) normalizeKey(key string) string {
return strings.ToLower(key)
}
func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf")
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return false
} else {
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key)
}
p.bindMap[normalizedKey] = kb
return true
}
func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
p.dmsBindsExists = true
}
mainConfig := filepath.Join(expandedDir, "hyprland.conf")
section, err := p.parseFileWithSource(mainConfig, "")
if err != nil {
return nil, err
}
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath, section)
}
return section, nil
}
func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if p.processedFiles[absPath] {
return &HyprlandSection{Name: sectionName}, nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
prevSource := p.currentSource
p.currentSource = absPath
section := &HyprlandSection{Name: sectionName}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, section, filepath.Dir(absPath))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = p.currentSource
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
return section, nil
}
func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
p.includeCount++
if isDMSSource {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
return
}
includedSection, err := p.parseFileWithSource(expanded, "")
if err != nil {
return
}
section.Children = append(section.Children, *includedSection)
}
func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) {
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
lines := strings.Split(string(data), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.parseBindLine(line)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
if p.addBind(kb) {
section.Keybinds = append(section.Keybinds, *kb)
}
}
p.currentSource = prevSource
p.dmsProcessed = true
}
func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return nil
}
// Extract bind type and flags from the left side of "="
bindType := strings.TrimSpace(parts[0])
flags := extractBindFlags(bindType)
hasDescFlag := strings.Contains(flags, "d")
keys := parts[1]
keyParts := strings.SplitN(keys, "#", 2)
keys = keyParts[0]
var comment string
if len(keyParts) > 1 {
comment = strings.TrimSpace(keyParts[1])
}
// For bindd, the format is: bindd = MODS, key, description, dispatcher, params
// For regular binds: bind = MODS, key, dispatcher, params
var minFields, descIndex, dispatcherIndex int
if hasDescFlag {
minFields = 4 // mods, key, description, dispatcher
descIndex = 2
dispatcherIndex = 3
} else {
minFields = 3 // mods, key, dispatcher
dispatcherIndex = 2
}
keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params
if len(keyFields) < minFields {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
var dispatcher, params string
if hasDescFlag {
// bindd format: description is in the bind itself
if comment == "" {
comment = strings.TrimSpace(keyFields[descIndex])
}
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
if len(keyFields) > dispatcherIndex+1 {
paramParts := keyFields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
} else {
dispatcher = strings.TrimSpace(keyFields[dispatcherIndex])
if len(keyFields) > dispatcherIndex+1 {
paramParts := keyFields[dispatcherIndex+1:]
params = strings.TrimSpace(strings.Join(paramParts, ","))
}
}
if comment != "" && strings.HasPrefix(comment, HideComment) {
return nil
}
if comment == "" {
comment = hyprlandAutogenerateComment(dispatcher, params)
}
var modList []string
if mods != "" {
modstring := mods + string(ModSeparators[0])
idx := 0
for index, char := range modstring {
isModSep := false
for _, sep := range ModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &HyprlandKeyBinding{
Mods: modList,
Key: key,
Dispatcher: dispatcher,
Params: params,
Comment: comment,
Flags: flags,
}
}
// extractBindFlags extracts the flags from a bind type string
// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d"
func extractBindFlags(bindType string) string {
bindType = strings.TrimSpace(bindType)
if !strings.HasPrefix(bindType, "bind") {
return ""
}
return bindType[4:] // Everything after "bind"
}
func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) {
parser := NewHyprlandParser(path)
section, err := parser.ParseWithDMS()
if err != nil {
return nil, err
}
return &HyprlandParseResult{
Section: section,
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
}, nil
}

View File

@@ -130,7 +130,7 @@ func TestHyprlandGetKeybindAtLine(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser()
parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
@@ -285,7 +285,7 @@ func TestHyprlandReadContentMultipleFiles(t *testing.T) {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewHyprlandParser()
parser := NewHyprlandParser("")
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
@@ -343,7 +343,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
t.Skip("Cannot create relative path")
}
parser := NewHyprlandParser()
parser := NewHyprlandParser("")
tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch)
@@ -353,7 +353,7 @@ func TestHyprlandReadContentWithTildeExpansion(t *testing.T) {
}
func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) {
parser := NewHyprlandParser()
parser := NewHyprlandParser("")
parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"}
result := parser.getKeybindAtLine(0)
@@ -394,3 +394,126 @@ bind = SUPER, T, exec, kitty
t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds))
}
}
func TestExtractBindFlags(t *testing.T) {
tests := []struct {
bindType string
expected string
}{
{"bind", ""},
{"binde", "e"},
{"bindl", "l"},
{"bindr", "r"},
{"bindd", "d"},
{"bindo", "o"},
{"bindel", "el"},
{"bindler", "ler"},
{"bindem", "em"},
{" bind ", ""},
{" binde ", "e"},
{"notbind", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.bindType, func(t *testing.T) {
result := extractBindFlags(tt.bindType)
if result != tt.expected {
t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected)
}
})
}
}
func TestHyprlandBindFlags(t *testing.T) {
tests := []struct {
name string
line string
expectedFlags string
expectedKey string
expectedDisp string
expectedDesc string
}{
{
name: "regular bind",
line: "bind = SUPER, Q, killactive",
expectedFlags: "",
expectedKey: "Q",
expectedDisp: "killactive",
expectedDesc: "Close window",
},
{
name: "binde (repeat on hold)",
line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "e",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+",
},
{
name: "bindl (locked/inhibitor bypass)",
line: "bindl = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
expectedFlags: "l",
expectedKey: "XF86AudioLowerVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-",
},
{
name: "bindr (release trigger)",
line: "bindr = SUPER, SUPER_L, exec, pkill wofi || wofi",
expectedFlags: "r",
expectedKey: "SUPER_L",
expectedDisp: "exec",
expectedDesc: "pkill wofi || wofi",
},
{
name: "bindd (description)",
line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty",
expectedFlags: "d",
expectedKey: "Q",
expectedDisp: "exec",
expectedDesc: "Open my favourite terminal",
},
{
name: "bindo (long press)",
line: "bindo = SUPER, XF86AudioNext, exec, playerctl next",
expectedFlags: "o",
expectedKey: "XF86AudioNext",
expectedDisp: "exec",
expectedDesc: "playerctl next",
},
{
name: "bindel (combined flags)",
line: "bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
expectedFlags: "el",
expectedKey: "XF86AudioRaiseVolume",
expectedDisp: "exec",
expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewHyprlandParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
if result == nil {
t.Fatal("Expected keybind, got nil")
}
if result.Flags != tt.expectedFlags {
t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags)
}
if result.Key != tt.expectedKey {
t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey)
}
if result.Dispatcher != tt.expectedDisp {
t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp)
}
if result.Comment != tt.expectedDesc {
t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc)
}
})
}
}

View File

@@ -7,35 +7,30 @@ import (
)
func TestNewHyprlandProvider(t *testing.T) {
tests := []struct {
name string
configPath string
wantPath string
}{
{
name: "custom path",
configPath: "/custom/path",
wantPath: "/custom/path",
},
{
name: "empty path defaults",
configPath: "",
wantPath: "$HOME/.config/hypr",
},
}
t.Run("custom path", func(t *testing.T) {
p := NewHyprlandProvider("/custom/path")
if p == nil {
t.Fatal("NewHyprlandProvider returned nil")
}
if p.configPath != "/custom/path" {
t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path")
}
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewHyprlandProvider(tt.configPath)
if p == nil {
t.Fatal("NewHyprlandProvider returned nil")
}
if p.configPath != tt.wantPath {
t.Errorf("configPath = %q, want %q", p.configPath, tt.wantPath)
}
})
}
t.Run("empty path defaults", func(t *testing.T) {
p := NewHyprlandProvider("")
if p == nil {
t.Fatal("NewHyprlandProvider returned nil")
}
configDir, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir failed: %v", err)
}
expected := filepath.Join(configDir, "hypr")
if p.configPath != expected {
t.Errorf("configPath = %q, want %q", p.configPath, expected)
}
})
}
func TestHyprlandProviderName(t *testing.T) {
@@ -109,7 +104,7 @@ func TestHyprlandProviderGetCheatSheetError(t *testing.T) {
func TestFormatKey(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
configFile := filepath.Join(tmpDir, "hyprland.conf")
tests := []struct {
name string
@@ -163,7 +158,7 @@ func TestFormatKey(t *testing.T) {
func TestDescriptionFallback(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.conf")
configFile := filepath.Join(tmpDir, "hyprland.conf")
tests := []struct {
name string

View File

@@ -2,46 +2,94 @@ package providers
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type MangoWCProvider struct {
configPath string
configPath string
dmsBindsIncluded bool
parsed bool
}
func NewMangoWCProvider(configPath string) *MangoWCProvider {
if configPath == "" {
configPath = "$HOME/.config/mango"
configPath = defaultMangoWCConfigDir()
}
return &MangoWCProvider{
configPath: configPath,
}
}
func defaultMangoWCConfigDir() string {
configDir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(configDir, "mango")
}
func (m *MangoWCProvider) Name() string {
return "mangowc"
}
func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
keybinds_list, err := ParseMangoWCKeys(m.configPath)
result, err := ParseMangoWCKeysWithDMS(m.configPath)
if err != nil {
return nil, fmt.Errorf("failed to parse mangowc config: %w", err)
}
m.dmsBindsIncluded = result.DMSBindsIncluded
m.parsed = true
categorizedBinds := make(map[string][]keybinds.Keybind)
for _, kb := range keybinds_list {
for _, kb := range result.Keybinds {
category := m.categorizeByCommand(kb.Command)
bind := m.convertKeybind(&kb)
bind := m.convertKeybind(&kb, result.ConflictingConfigs)
categorizedBinds[category] = append(categorizedBinds[category], bind)
}
return &keybinds.CheatSheet{
Title: "MangoWC Keybinds",
Provider: m.Name(),
Binds: categorizedBinds,
}, nil
sheet := &keybinds.CheatSheet{
Title: "MangoWC Keybinds",
Provider: m.Name(),
Binds: categorizedBinds,
DMSBindsIncluded: result.DMSBindsIncluded,
}
if result.DMSStatus != nil {
sheet.DMSStatus = &keybinds.DMSBindsStatus{
Exists: result.DMSStatus.Exists,
Included: result.DMSStatus.Included,
IncludePosition: result.DMSStatus.IncludePosition,
TotalIncludes: result.DMSStatus.TotalIncludes,
BindsAfterDMS: result.DMSStatus.BindsAfterDMS,
Effective: result.DMSStatus.Effective,
OverriddenBy: result.DMSStatus.OverriddenBy,
StatusMessage: result.DMSStatus.StatusMessage,
}
}
return sheet, nil
}
func (m *MangoWCProvider) HasDMSBindsIncluded() bool {
if m.parsed {
return m.dmsBindsIncluded
}
result, err := ParseMangoWCKeysWithDMS(m.configPath)
if err != nil {
return false
}
m.dmsBindsIncluded = result.DMSBindsIncluded
m.parsed = true
return m.dmsBindsIncluded
}
func (m *MangoWCProvider) categorizeByCommand(command string) string {
@@ -82,8 +130,8 @@ func (m *MangoWCProvider) categorizeByCommand(command string) string {
}
}
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind {
key := m.formatKey(kb)
func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind {
keyStr := m.formatKey(kb)
rawAction := m.formatRawAction(kb.Command, kb.Params)
desc := kb.Comment
@@ -91,11 +139,31 @@ func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding) keybinds.Keybind
desc = rawAction
}
return keybinds.Keybind{
Key: key,
source := "config"
if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") {
source = "dms"
}
bind := keybinds.Keybind{
Key: keyStr,
Description: desc,
Action: rawAction,
Source: source,
}
if source == "dms" && conflicts != nil {
normalizedKey := strings.ToLower(keyStr)
if conflictKb, ok := conflicts[normalizedKey]; ok {
bind.Conflict = &keybinds.Keybind{
Key: keyStr,
Description: conflictKb.Comment,
Action: m.formatRawAction(conflictKb.Command, conflictKb.Params),
Source: "config",
}
}
}
return bind
}
func (m *MangoWCProvider) formatRawAction(command, params string) string {
@@ -111,3 +179,264 @@ func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string {
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (m *MangoWCProvider) GetOverridePath() string {
expanded, err := utils.ExpandPath(m.configPath)
if err != nil {
return filepath.Join(m.configPath, "dms", "binds.conf")
}
return filepath.Join(expanded, "dms", "binds.conf")
}
func (m *MangoWCProvider) validateAction(action string) error {
action = strings.TrimSpace(action)
switch {
case action == "":
return fmt.Errorf("action cannot be empty")
case action == "spawn" || action == "spawn ":
return fmt.Errorf("spawn command requires arguments")
case action == "spawn_shell" || action == "spawn_shell ":
return fmt.Errorf("spawn_shell command requires arguments")
case strings.HasPrefix(action, "spawn "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn "))
if rest == "" {
return fmt.Errorf("spawn command requires arguments")
}
case strings.HasPrefix(action, "spawn_shell "):
rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell "))
if rest == "" {
return fmt.Errorf("spawn_shell command requires arguments")
}
}
return nil
}
func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error {
if err := m.validateAction(action); err != nil {
return err
}
overridePath := m.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0755); err != nil {
return fmt.Errorf("failed to create dms directory: %w", err)
}
existingBinds, err := m.loadOverrideBinds()
if err != nil {
existingBinds = make(map[string]*mangowcOverrideBind)
}
normalizedKey := strings.ToLower(key)
existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key,
Action: action,
Description: description,
Options: options,
}
return m.writeOverrideBinds(existingBinds)
}
func (m *MangoWCProvider) RemoveBind(key string) error {
existingBinds, err := m.loadOverrideBinds()
if err != nil {
return nil
}
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return m.writeOverrideBinds(existingBinds)
}
type mangowcOverrideBind struct {
Key string
Action string
Description string
Options map[string]any
}
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
overridePath := m.GetOverridePath()
binds := make(map[string]*mangowcOverrideBind)
data, err := os.ReadFile(overridePath)
if os.IsNotExist(err) {
return binds, nil
}
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !strings.HasPrefix(line, "bind") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
var comment string
if len(commentParts) > 1 {
comment = strings.TrimSpace(commentParts[1])
}
fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 {
continue
}
mods := strings.TrimSpace(fields[0])
keyName := strings.TrimSpace(fields[1])
command := strings.TrimSpace(fields[2])
var params string
if len(fields) > 3 {
params = strings.TrimSpace(fields[3])
}
keyStr := m.buildKeyString(mods, keyName)
normalizedKey := strings.ToLower(keyStr)
action := command
if params != "" {
action = command + " " + params
}
binds[normalizedKey] = &mangowcOverrideBind{
Key: keyStr,
Action: action,
Description: comment,
}
}
return binds, nil
}
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
if mods == "" || strings.EqualFold(mods, "none") {
return key
}
modList := strings.FieldsFunc(mods, func(r rune) bool {
return r == '+' || r == ' '
})
parts := append(modList, key)
return strings.Join(parts, "+")
}
func (m *MangoWCProvider) getBindSortPriority(action string) int {
switch {
case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"):
return 0
case strings.Contains(action, "view") || strings.Contains(action, "tag"):
return 1
case strings.Contains(action, "focus") || strings.Contains(action, "exchange") ||
strings.Contains(action, "resize") || strings.Contains(action, "move"):
return 2
case strings.Contains(action, "mon"):
return 3
case strings.HasPrefix(action, "spawn"):
return 4
case action == "quit" || action == "reload_config":
return 5
default:
return 6
}
}
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds)
return os.WriteFile(overridePath, []byte(content), 0644)
}
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
if len(binds) == 0 {
return ""
}
bindList := make([]*mangowcOverrideBind, 0, len(binds))
for _, bind := range binds {
bindList = append(bindList, bind)
}
sort.Slice(bindList, func(i, j int) bool {
pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action)
if pi != pj {
return pi < pj
}
return bindList[i].Key < bindList[j].Key
})
var sb strings.Builder
for _, bind := range bindList {
m.writeBindLine(&sb, bind)
}
return sb.String()
}
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action)
sb.WriteString("bind=")
if mods == "" {
sb.WriteString("none")
} else {
sb.WriteString(mods)
}
sb.WriteString(",")
sb.WriteString(key)
sb.WriteString(",")
sb.WriteString(command)
if params != "" {
sb.WriteString(",")
sb.WriteString(params)
}
if bind.Description != "" {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
case 0:
return "", keyStr
case 1:
return "", parts[0]
default:
return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1]
}
}
func (m *MangoWCProvider) parseAction(action string) (command, params string) {
parts := strings.SplitN(action, " ", 2)
switch len(parts) {
case 0:
return action, ""
case 1:
return parts[0], ""
default:
return parts[0], parts[1]
}
}

View File

@@ -21,17 +21,40 @@ type MangoWCKeyBinding struct {
Command string `json:"command"`
Params string `json:"params"`
Comment string `json:"comment"`
Source string `json:"source"`
}
type MangoWCParser struct {
contentLines []string
readingLine int
contentLines []string
readingLine int
configDir string
currentSource string
dmsBindsExists bool
dmsBindsIncluded bool
includeCount int
dmsIncludePos int
bindsAfterDMS int
dmsBindKeys map[string]bool
configBindKeys map[string]bool
conflictingConfigs map[string]*MangoWCKeyBinding
bindMap map[string]*MangoWCKeyBinding
bindOrder []string
processedFiles map[string]bool
dmsProcessed bool
}
func NewMangoWCParser() *MangoWCParser {
func NewMangoWCParser(configDir string) *MangoWCParser {
return &MangoWCParser{
contentLines: []string{},
readingLine: 0,
contentLines: []string{},
readingLine: 0,
configDir: configDir,
dmsIncludePos: -1,
dmsBindKeys: make(map[string]bool),
configBindKeys: make(map[string]bool),
conflictingConfigs: make(map[string]*MangoWCKeyBinding),
bindMap: make(map[string]*MangoWCKeyBinding),
bindOrder: []string{},
processedFiles: make(map[string]bool),
}
}
@@ -294,9 +317,320 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
}
func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) {
parser := NewMangoWCParser()
parser := NewMangoWCParser(path)
if err := parser.ReadContent(path); err != nil {
return nil, err
}
return parser.ParseKeys(), nil
}
type MangoWCParseResult struct {
Keybinds []MangoWCKeyBinding
DMSBindsIncluded bool
DMSStatus *MangoWCDMSStatus
ConflictingConfigs map[string]*MangoWCKeyBinding
}
type MangoWCDMSStatus struct {
Exists bool
Included bool
IncludePosition int
TotalIncludes int
BindsAfterDMS int
Effective bool
OverriddenBy int
StatusMessage string
}
func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus {
status := &MangoWCDMSStatus{
Exists: p.dmsBindsExists,
Included: p.dmsBindsIncluded,
IncludePosition: p.dmsIncludePos,
TotalIncludes: p.includeCount,
BindsAfterDMS: p.bindsAfterDMS,
}
switch {
case !p.dmsBindsExists:
status.Effective = false
status.StatusMessage = "dms/binds.conf does not exist"
case !p.dmsBindsIncluded:
status.Effective = false
status.StatusMessage = "dms/binds.conf is not sourced in config"
case p.bindsAfterDMS > 0:
status.Effective = true
status.OverriddenBy = p.bindsAfterDMS
status.StatusMessage = "Some DMS binds may be overridden by config binds"
default:
status.Effective = true
status.StatusMessage = "DMS binds are active"
}
return status
}
func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string {
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, kb.Key)
return strings.Join(parts, "+")
}
func (p *MangoWCParser) normalizeKey(key string) string {
return strings.ToLower(key)
}
func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) {
key := p.formatBindKey(kb)
normalizedKey := p.normalizeKey(key)
isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf")
if isDMSBind {
p.dmsBindKeys[normalizedKey] = true
} else if p.dmsBindKeys[normalizedKey] {
p.bindsAfterDMS++
p.conflictingConfigs[normalizedKey] = kb
p.configBindKeys[normalizedKey] = true
return
} else {
p.configBindKeys[normalizedKey] = true
}
if _, exists := p.bindMap[normalizedKey]; !exists {
p.bindOrder = append(p.bindOrder, key)
}
p.bindMap[normalizedKey] = kb
}
func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) {
expandedDir, err := utils.ExpandPath(p.configDir)
if err != nil {
return nil, err
}
dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf")
if _, err := os.Stat(dmsBindsPath); err == nil {
p.dmsBindsExists = true
}
mainConfig := filepath.Join(expandedDir, "config.conf")
if _, err := os.Stat(mainConfig); os.IsNotExist(err) {
mainConfig = filepath.Join(expandedDir, "mango.conf")
}
_, err = p.parseFileWithSource(mainConfig)
if err != nil {
return nil, err
}
if p.dmsBindsExists && !p.dmsProcessed {
p.parseDMSBindsDirectly(dmsBindsPath)
}
var keybinds []MangoWCKeyBinding
for _, key := range p.bindOrder {
normalizedKey := p.normalizeKey(key)
if kb, exists := p.bindMap[normalizedKey]; exists {
keybinds = append(keybinds, *kb)
}
}
return keybinds, nil
}
func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return nil, err
}
if p.processedFiles[absPath] {
return nil, nil
}
p.processedFiles[absPath] = true
data, err := os.ReadFile(absPath)
if err != nil {
return nil, err
}
prevSource := p.currentSource
p.currentSource = absPath
var keybinds []MangoWCKeyBinding
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
continue
}
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
kb.Source = p.currentSource
p.addBind(kb)
keybinds = append(keybinds, *kb)
}
p.currentSource = prevSource
return keybinds, nil
}
func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) {
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
return
}
sourcePath := strings.TrimSpace(parts[1])
isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf")
p.includeCount++
if isDMSSource {
p.dmsBindsIncluded = true
p.dmsIncludePos = p.includeCount
p.dmsProcessed = true
}
fullPath := sourcePath
if !filepath.IsAbs(sourcePath) {
fullPath = filepath.Join(baseDir, sourcePath)
}
expanded, err := utils.ExpandPath(fullPath)
if err != nil {
return
}
includedBinds, err := p.parseFileWithSource(expanded)
if err != nil {
return
}
*keybinds = append(*keybinds, includedBinds...)
}
func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding {
data, err := os.ReadFile(dmsBindsPath)
if err != nil {
return nil
}
prevSource := p.currentSource
p.currentSource = dmsBindsPath
var keybinds []MangoWCKeyBinding
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "bind") {
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
if kb == nil {
continue
}
kb.Source = dmsBindsPath
p.addBind(kb)
keybinds = append(keybinds, *kb)
}
p.currentSource = prevSource
p.dmsProcessed = true
return keybinds
}
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
idx := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-idx > 1 {
modList = append(modList, modstring[idx:index])
}
idx = index + 1
}
}
}
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
}
func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) {
parser := NewMangoWCParser(path)
keybinds, err := parser.ParseWithDMS()
if err != nil {
return nil, err
}
return &MangoWCParseResult{
Keybinds: keybinds,
DMSBindsIncluded: parser.dmsBindsIncluded,
DMSStatus: parser.buildDMSStatus(),
ConflictingConfigs: parser.conflictingConfigs,
}, nil
}

View File

@@ -172,7 +172,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser()
parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)
@@ -283,7 +283,7 @@ func TestMangoWCReadContentMultipleFiles(t *testing.T) {
t.Fatalf("Failed to write file2: %v", err)
}
parser := NewMangoWCParser()
parser := NewMangoWCParser("")
if err := parser.ReadContent(tmpDir); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
@@ -304,7 +304,7 @@ func TestMangoWCReadContentSingleFile(t *testing.T) {
t.Fatalf("Failed to write config: %v", err)
}
parser := NewMangoWCParser()
parser := NewMangoWCParser("")
if err := parser.ReadContent(configFile); err != nil {
t.Fatalf("ReadContent failed: %v", err)
}
@@ -362,7 +362,7 @@ func TestMangoWCReadContentWithTildeExpansion(t *testing.T) {
t.Skip("Cannot create relative path")
}
parser := NewMangoWCParser()
parser := NewMangoWCParser("")
tildePathMatch := "~/" + relPath
err = parser.ReadContent(tildePathMatch)
@@ -419,7 +419,7 @@ func TestMangoWCInvalidBindLines(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser()
parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0)

View File

@@ -15,8 +15,17 @@ func TestMangoWCProviderName(t *testing.T) {
func TestMangoWCProviderDefaultPath(t *testing.T) {
provider := NewMangoWCProvider("")
if provider.configPath != "$HOME/.config/mango" {
t.Errorf("configPath = %q, want %q", provider.configPath, "$HOME/.config/mango")
configDir, err := os.UserConfigDir()
if err != nil {
// Fall back to testing for non-empty path
if provider.configPath == "" {
t.Error("configPath should not be empty")
}
return
}
expected := filepath.Join(configDir, "mango")
if provider.configPath != expected {
t.Errorf("configPath = %q, want %q", provider.configPath, expected)
}
}
@@ -174,7 +183,7 @@ func TestMangoWCConvertKeybind(t *testing.T) {
provider := NewMangoWCProvider("")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.convertKeybind(tt.keybind)
result := provider.convertKeybind(tt.keybind, nil)
if result.Key != tt.wantKey {
t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey)
}

View File

@@ -187,7 +187,15 @@ func (n *NiriProvider) formatRawAction(action string, args []string) string {
}
}
return action + " " + strings.Join(args, " ")
quotedArgs := make([]string, len(args))
for i, arg := range args {
if arg == "" {
quotedArgs[i] = `""`
} else {
quotedArgs[i] = arg
}
}
return action + " " + strings.Join(quotedArgs, " ")
}
func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string {
@@ -293,9 +301,15 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
continue
}
keyStr := parser.formatBindKey(kb)
action := n.buildActionFromNode(child)
if action == "" {
action = n.formatRawAction(kb.Action, kb.Args)
}
binds[keyStr] = &overrideBind{
Key: keyStr,
Action: n.formatRawAction(kb.Action, kb.Args),
Action: action,
Description: kb.Description,
Options: n.extractOptions(child),
}
@@ -305,6 +319,42 @@ func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) {
return binds, nil
}
func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string {
if len(bindNode.Children) == 0 {
return ""
}
actionNode := bindNode.Children[0]
actionName := actionNode.Name.String()
if actionName == "" {
return ""
}
parts := []string{actionName}
for _, arg := range actionNode.Arguments {
val := arg.ValueString()
if val == "" {
parts = append(parts, `""`)
} else {
parts = append(parts, val)
}
}
if actionNode.Properties != nil {
if val, ok := actionNode.Properties.Get("focus"); ok {
parts = append(parts, "focus="+val.String())
}
if val, ok := actionNode.Properties.Get("show-pointer"); ok {
parts = append(parts, "show-pointer="+val.String())
}
if val, ok := actionNode.Properties.Get("write-to-disk"); ok {
parts = append(parts, "write-to-disk="+val.String())
}
}
return strings.Join(parts, " ")
}
func (n *NiriProvider) extractOptions(node *document.Node) map[string]any {
if node.Properties == nil {
return make(map[string]any)
@@ -461,16 +511,9 @@ func (n *NiriProvider) getBindSortPriority(action string) int {
}
}
const dmsWarningHeader = `// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
`
func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string {
if len(binds) == 0 {
return dmsWarningHeader + "binds {}\n"
return "binds {}\n"
}
var regularBinds, recentWindowsBinds []*overrideBind
@@ -497,7 +540,6 @@ func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) stri
var sb strings.Builder
sb.WriteString(dmsWarningHeader)
sb.WriteString("binds {\n")
for _, bind := range regularBinds {
n.writeBindNode(&sb, bind, " ")

View File

@@ -6,13 +6,6 @@ import (
"testing"
)
const testHeader = `// ! DO NOT EDIT !
// ! AUTO-GENERATED BY DMS !
// ! CHANGES WILL BE OVERWRITTEN !
// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
`
func TestNiriProviderName(t *testing.T) {
provider := NewNiriProvider("")
if provider.Name() != "niri" {
@@ -128,6 +121,8 @@ func TestNiriFormatRawAction(t *testing.T) {
}{
{"spawn", []string{"kitty"}, "spawn kitty"},
{"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"},
{"spawn", []string{"dms", "ipc", "call", "brightness", "increment", "5", ""}, `spawn dms ipc call brightness increment 5 ""`},
{"spawn", []string{"dms", "ipc", "call", "dash", "toggle", ""}, `spawn dms ipc call dash toggle ""`},
{"close-window", nil, "close-window"},
{"fullscreen-window", nil, "fullscreen-window"},
{"focus-workspace", []string{"1"}, "focus-workspace 1"},
@@ -204,7 +199,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
{
name: "empty binds",
binds: map[string]*overrideBind{},
expected: testHeader + "binds {}\n",
expected: "binds {}\n",
},
{
name: "simple spawn bind",
@@ -215,7 +210,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Open Terminal",
},
},
expected: testHeader + `binds {
expected: `binds {
Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; }
}
`,
@@ -229,7 +224,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Application Launcher",
},
},
expected: testHeader + `binds {
expected: `binds {
Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; }
}
`,
@@ -243,7 +238,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Options: map[string]any{"allow-when-locked": true},
},
},
expected: testHeader + `binds {
expected: `binds {
XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; }
}
`,
@@ -257,7 +252,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Description: "Close Window",
},
},
expected: testHeader + `binds {
expected: `binds {
Mod+Q hotkey-overlay-title="Close Window" { close-window; }
}
`,
@@ -270,7 +265,7 @@ func TestNiriGenerateBindsContent(t *testing.T) {
Action: "next-window",
},
},
expected: testHeader + `binds {
expected: `binds {
}
recent-windows {
@@ -331,6 +326,58 @@ func TestNiriGenerateBindsContentRoundTrip(t *testing.T) {
}
}
func TestNiriEmptyArgsPreservation(t *testing.T) {
provider := NewNiriProvider("")
binds := map[string]*overrideBind{
"XF86MonBrightnessUp": {
Key: "XF86MonBrightnessUp",
Action: `spawn dms ipc call brightness increment 5 ""`,
Description: "Brightness Up",
},
"XF86MonBrightnessDown": {
Key: "XF86MonBrightnessDown",
Action: `spawn dms ipc call brightness decrement 5 ""`,
Description: "Brightness Down",
},
"Super+Alt+Page_Up": {
Key: "Super+Alt+Page_Up",
Action: `spawn dms ipc call dash toggle ""`,
Description: "Dashboard Toggle",
},
}
content := provider.generateBindsContent(binds)
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0755); err != nil {
t.Fatalf("Failed to create dms directory: %v", err)
}
bindsFile := filepath.Join(dmsDir, "binds.kdl")
if err := os.WriteFile(bindsFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write binds file: %v", err)
}
testProvider := NewNiriProvider(tmpDir)
loadedBinds, err := testProvider.loadOverrideBinds()
if err != nil {
t.Fatalf("Failed to load binds: %v\nContent was:\n%s", err, content)
}
for key, expected := range binds {
loaded, ok := loadedBinds[key]
if !ok {
t.Errorf("Missing bind for key %s", key)
continue
}
if loaded.Action != expected.Action {
t.Errorf("Action mismatch for %s:\n got: %q\n want: %q", key, loaded.Action, expected.Action)
}
}
}
func TestNiriProviderWithRealWorldConfig(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
@@ -422,7 +469,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Focus Workspace 1",
},
},
expected: testHeader + `binds {
expected: `binds {
Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; }
}
`,
@@ -436,7 +483,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Focus Workspace 10",
},
},
expected: testHeader + `binds {
expected: `binds {
Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; }
}
`,
@@ -450,7 +497,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Adjust Column Width -10%",
},
},
expected: testHeader + `binds {
expected: `binds {
Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; }
}
`,
@@ -464,7 +511,7 @@ func TestNiriGenerateBindsContentNumericArgs(t *testing.T) {
Description: "Adjust Column Width +10%",
},
},
expected: testHeader + `binds {
expected: `binds {
Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; }
}
`,
@@ -493,7 +540,7 @@ func TestNiriGenerateActionWithUnquotedPercentArg(t *testing.T) {
}
content := provider.generateBindsContent(binds)
expected := testHeader + `binds {
expected := `binds {
Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; }
}
`
@@ -514,7 +561,7 @@ func TestNiriGenerateSpawnWithNumericArgs(t *testing.T) {
}
content := provider.generateBindsContent(binds)
expected := testHeader + `binds {
expected := `binds {
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
}
`
@@ -535,7 +582,7 @@ func TestNiriGenerateSpawnNumericArgFromCLI(t *testing.T) {
}
content := provider.generateBindsContent(binds)
expected := testHeader + `binds {
expected := `binds {
XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; }
}
`

View File

@@ -8,6 +8,7 @@ type Keybind struct {
Source string `json:"source,omitempty"`
HideOnOverlay bool `json:"hideOnOverlay,omitempty"`
CooldownMs int `json:"cooldownMs,omitempty"`
Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press
Conflict *Keybind `json:"conflict,omitempty"`
}

View File

@@ -23,6 +23,47 @@ const (
ColorModeLight ColorMode = "light"
)
type TemplateKind int
const (
TemplateKindNormal TemplateKind = iota
TemplateKindTerminal
TemplateKindGTK
TemplateKindVSCode
)
type TemplateDef struct {
ID string
Commands []string
Flatpaks []string
ConfigFile string
Kind TemplateKind
RunUnconditionally bool
}
var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
{ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
{ID: "zenbrowser", Commands: []string{"zen", "zen-browser"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
{ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
{ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
{ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
{ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
{ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
{ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal},
{ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal},
{ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal},
{ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
{ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
{ID: "vscode", Kind: TemplateKindVSCode},
}
func (c *ColorMode) GTKTheme() string {
switch *c {
case ColorModeDark:
@@ -51,6 +92,7 @@ type Options struct {
SyncModeWithPortal bool
TerminalsAlwaysDark bool
SkipTemplates string
AppChecker utils.AppChecker
}
type ColorsOutput struct {
@@ -101,6 +143,9 @@ func Run(opts Options) error {
if opts.IconTheme == "" {
opts.IconTheme = "System Default"
}
if opts.AppChecker == nil {
opts.AppChecker = utils.DefaultAppChecker{}
}
if err := os.MkdirAll(opts.StateDir, 0755); err != nil {
return fmt.Errorf("failed to create state dir: %w", err)
@@ -236,7 +281,7 @@ func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
if strings.TrimSpace(line) == "[config]" {
continue
}
cfgFile.WriteString(substituteShellDir(line, opts.ShellDir) + "\n")
cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n")
}
cfgFile.WriteString("\n")
}
@@ -247,73 +292,32 @@ output_path = '%s'
`, opts.ShellDir, opts.ColorsOutput())
if !opts.ShouldSkipTemplate("gtk") {
switch opts.Mode {
case "light":
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
homeDir, _ := os.UserHomeDir()
for _, tmpl := range templateRegistry {
if opts.ShouldSkipTemplate(tmpl.ID) {
continue
}
}
if !opts.ShouldSkipTemplate("niri") {
appendConfig(opts, cfgFile, []string{"niri"}, nil, "niri.toml")
}
if !opts.ShouldSkipTemplate("qt5ct") {
appendConfig(opts, cfgFile, []string{"qt5ct"}, nil, "qt5ct.toml")
}
if !opts.ShouldSkipTemplate("qt6ct") {
appendConfig(opts, cfgFile, []string{"qt6ct"}, nil, "qt6ct.toml")
}
if !opts.ShouldSkipTemplate("firefox") {
appendConfig(opts, cfgFile, []string{"firefox"}, nil, "firefox.toml")
}
if !opts.ShouldSkipTemplate("pywalfox") {
appendConfig(opts, cfgFile, []string{"pywalfox"}, nil, "pywalfox.toml")
}
if !opts.ShouldSkipTemplate("zenbrowser") {
appendConfig(opts, cfgFile, []string{"zen", "zen-browser"}, []string{"app.zen_browser.zen"}, "zenbrowser.toml")
}
if !opts.ShouldSkipTemplate("vesktop") {
appendConfig(opts, cfgFile, []string{"vesktop"}, []string{"dev.vencord.Vesktop"}, "vesktop.toml")
}
if !opts.ShouldSkipTemplate("equibop") {
appendConfig(opts, cfgFile, []string{"equibop"}, nil, "equibop.toml")
}
if !opts.ShouldSkipTemplate("ghostty") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"ghostty"}, nil, "ghostty.toml")
}
if !opts.ShouldSkipTemplate("kitty") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"kitty"}, nil, "kitty.toml")
}
if !opts.ShouldSkipTemplate("foot") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"foot"}, nil, "foot.toml")
}
if !opts.ShouldSkipTemplate("alacritty") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"alacritty"}, nil, "alacritty.toml")
}
if !opts.ShouldSkipTemplate("wezterm") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"wezterm"}, nil, "wezterm.toml")
}
if !opts.ShouldSkipTemplate("nvim") {
appendTerminalConfig(opts, cfgFile, tmpDir, []string{"nvim"}, nil, "neovim.toml")
}
if !opts.ShouldSkipTemplate("dgop") {
appendConfig(opts, cfgFile, []string{"dgop"}, nil, "dgop.toml")
}
if !opts.ShouldSkipTemplate("kcolorscheme") {
appendConfig(opts, cfgFile, nil, nil, "kcolorscheme.toml")
}
if !opts.ShouldSkipTemplate("vscode") {
homeDir, _ := os.UserHomeDir()
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions/local.dynamic-base16-dankshell-0.0.1"), opts.ShellDir)
switch tmpl.Kind {
case TemplateKindGTK:
switch opts.Mode {
case ColorModeLight:
appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
default:
appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
}
case TemplateKindTerminal:
appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
case TemplateKindVSCode:
appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
default:
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
}
}
if opts.RunUserTemplates {
@@ -353,17 +357,14 @@ func appendConfig(
if _, err := os.Stat(configPath); err != nil {
return
}
cmdExists := checkCmd == nil || utils.AnyCommandExists(checkCmd...)
flatpakExists := checkFlatpaks == nil || utils.AnyFlatpakExists(checkFlatpaks...)
if !cmdExists && !flatpakExists {
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
return
}
data, err := os.ReadFile(configPath)
if err != nil {
return
}
cfgFile.WriteString(substituteShellDir(string(data), opts.ShellDir))
cfgFile.WriteString(substituteVars(string(data), opts.ShellDir))
cfgFile.WriteString("\n")
}
@@ -372,10 +373,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
if _, err := os.Stat(configPath); err != nil {
return
}
cmdExists := checkCmd == nil || utils.AnyCommandExists(checkCmd...)
flatpakExists := checkFlatpaks == nil || utils.AnyFlatpakExists(checkFlatpaks...)
if !cmdExists && !flatpakExists {
if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
return
}
data, err := os.ReadFile(configPath)
@@ -386,7 +384,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
content := string(data)
if !opts.TerminalsAlwaysDark {
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
cfgFile.WriteString(substituteVars(content, opts.ShellDir))
cfgFile.WriteString("\n")
return
}
@@ -424,14 +422,32 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
fmt.Sprintf("'%s'", tmpPath))
}
cfgFile.WriteString(substituteShellDir(content, opts.ShellDir))
cfgFile.WriteString(substituteVars(content, opts.ShellDir))
cfgFile.WriteString("\n")
}
func appendVSCodeConfig(cfgFile *os.File, name, extDir, shellDir string) {
if _, err := os.Stat(extDir); err != nil {
func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
// Both nil is treated as "skip check" / unconditionally run
if checkCmd == nil && checkFlatpaks == nil {
return true
}
if checkCmd != nil && checker.AnyCommandExists(checkCmd...) {
return true
}
if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) {
return true
}
return false
}
func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return
}
extDir := matches[0]
templateDir := filepath.Join(shellDir, "matugen", "templates")
fmt.Fprintf(cfgFile, `[templates.dms%sdefault]
input_path = '%s/vscode-color-theme-default.json'
@@ -451,8 +467,12 @@ output_path = '%s/themes/dankshell-light.json'
log.Infof("Added %s theme config (extension found at %s)", name, extDir)
}
func substituteShellDir(content, shellDir string) string {
return strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
func substituteVars(content, shellDir string) string {
result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
return result
}
func extractTOMLSection(content, startMarker, endMarker string) string {
@@ -662,3 +682,52 @@ func syncColorScheme(mode ColorMode) {
exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run()
}
}
type TemplateCheck struct {
ID string `json:"id"`
Detected bool `json:"detected"`
}
func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
if checker == nil {
checker = utils.DefaultAppChecker{}
}
homeDir, _ := os.UserHomeDir()
checks := make([]TemplateCheck, 0, len(templateRegistry))
for _, tmpl := range templateRegistry {
detected := false
switch {
case tmpl.RunUnconditionally:
detected = true
case tmpl.Kind == TemplateKindVSCode:
detected = checkVSCodeExtension(homeDir)
default:
detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
}
checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
}
return checks
}
func checkVSCodeExtension(homeDir string) bool {
extDirs := []string{
filepath.Join(homeDir, ".vscode/extensions"),
filepath.Join(homeDir, ".vscode-oss/extensions"),
filepath.Join(homeDir, ".config/Code - OSS/extensions"),
filepath.Join(homeDir, ".cursor/extensions"),
filepath.Join(homeDir, ".windsurf/extensions"),
}
for _, extDir := range extDirs {
pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
return true
}
}
return false
}

View File

@@ -4,6 +4,10 @@ import (
"os"
"path/filepath"
"testing"
mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/stretchr/testify/assert"
)
func TestAppendConfigBinaryExists(t *testing.T) {
@@ -28,7 +32,10 @@ func TestAppendConfigBinaryExists(t *testing.T) {
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
@@ -68,7 +75,11 @@ func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists().Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
@@ -105,7 +116,10 @@ func TestAppendConfigFlatpakExists(t *testing.T) {
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyFlatpakExists("app.zen_browser.zen").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
@@ -142,7 +156,11 @@ func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists().Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
@@ -179,7 +197,10 @@ func TestAppendConfigBothExist(t *testing.T) {
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
@@ -216,7 +237,11 @@ func TestAppendConfigNeitherExists(t *testing.T) {
}
defer cfgFile.Close()
opts := &Options{ShellDir: shellDir}
mockChecker := mocks_utils.NewMockAppChecker(t)
mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
@@ -298,3 +323,72 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) {
t.Errorf("expected no config when file doesn't exist, got: %q", string(output))
}
}
func TestSubstituteVars(t *testing.T) {
configDir := utils.XDGConfigHome()
dataDir := utils.XDGDataHome()
cacheDir := utils.XDGCacheHome()
tests := []struct {
name string
input string
shellDir string
expected string
}{
{
name: "substitutes SHELL_DIR",
input: "input_path = 'SHELL_DIR/matugen/templates/foo.conf'",
shellDir: "/home/user/shell",
expected: "input_path = '/home/user/shell/matugen/templates/foo.conf'",
},
{
name: "substitutes CONFIG_DIR",
input: "output_path = 'CONFIG_DIR/kitty/theme.conf'",
shellDir: "/home/user/shell",
expected: "output_path = '" + configDir + "/kitty/theme.conf'",
},
{
name: "substitutes DATA_DIR",
input: "output_path = 'DATA_DIR/color-schemes/theme.colors'",
shellDir: "/home/user/shell",
expected: "output_path = '" + dataDir + "/color-schemes/theme.colors'",
},
{
name: "substitutes CACHE_DIR",
input: "output_path = 'CACHE_DIR/wal/colors.json'",
shellDir: "/home/user/shell",
expected: "output_path = '" + cacheDir + "/wal/colors.json'",
},
{
name: "substitutes all dir types",
input: "'SHELL_DIR/a' 'CONFIG_DIR/b' 'DATA_DIR/c' 'CACHE_DIR/d'",
shellDir: "/shell",
expected: "'/shell/a' '" + configDir + "/b' '" + dataDir + "/c' '" + cacheDir + "/d'",
},
{
name: "no substitution when no placeholders",
input: "input_path = '/absolute/path/foo.conf'",
shellDir: "/home/user/shell",
expected: "input_path = '/absolute/path/foo.conf'",
},
{
name: "multiple SHELL_DIR occurrences",
input: "'SHELL_DIR/a' and 'SHELL_DIR/b'",
shellDir: "/shell",
expected: "'/shell/a' and '/shell/b'",
},
{
name: "only substitutes quoted paths",
input: "SHELL_DIR/unquoted and 'SHELL_DIR/quoted'",
shellDir: "/shell",
expected: "SHELL_DIR/unquoted and '/shell/quoted'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := substituteVars(tc.input, tc.shellDir)
assert.Equal(t, tc.expected, result)
})
}
}

View File

@@ -0,0 +1,242 @@
// Code generated by mockery v2.53.5. DO NOT EDIT.
package mocks_utils
import mock "github.com/stretchr/testify/mock"
// MockAppChecker is an autogenerated mock type for the AppChecker type
type MockAppChecker struct {
mock.Mock
}
type MockAppChecker_Expecter struct {
mock *mock.Mock
}
func (_m *MockAppChecker) EXPECT() *MockAppChecker_Expecter {
return &MockAppChecker_Expecter{mock: &_m.Mock}
}
// AnyCommandExists provides a mock function with given fields: cmds
func (_m *MockAppChecker) AnyCommandExists(cmds ...string) bool {
_va := make([]interface{}, len(cmds))
for _i := range cmds {
_va[_i] = cmds[_i]
}
var _ca []interface{}
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AnyCommandExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(...string) bool); ok {
r0 = rf(cmds...)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_AnyCommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyCommandExists'
type MockAppChecker_AnyCommandExists_Call struct {
*mock.Call
}
// AnyCommandExists is a helper method to define mock.On call
// - cmds ...string
func (_e *MockAppChecker_Expecter) AnyCommandExists(cmds ...interface{}) *MockAppChecker_AnyCommandExists_Call {
return &MockAppChecker_AnyCommandExists_Call{Call: _e.mock.On("AnyCommandExists",
append([]interface{}{}, cmds...)...)}
}
func (_c *MockAppChecker_AnyCommandExists_Call) Run(run func(cmds ...string)) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-0)
for i, a := range args[0:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(variadicArgs...)
})
return _c
}
func (_c *MockAppChecker_AnyCommandExists_Call) Return(_a0 bool) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_AnyCommandExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyCommandExists_Call {
_c.Call.Return(run)
return _c
}
// AnyFlatpakExists provides a mock function with given fields: flatpaks
func (_m *MockAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
_va := make([]interface{}, len(flatpaks))
for _i := range flatpaks {
_va[_i] = flatpaks[_i]
}
var _ca []interface{}
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for AnyFlatpakExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(...string) bool); ok {
r0 = rf(flatpaks...)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_AnyFlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyFlatpakExists'
type MockAppChecker_AnyFlatpakExists_Call struct {
*mock.Call
}
// AnyFlatpakExists is a helper method to define mock.On call
// - flatpaks ...string
func (_e *MockAppChecker_Expecter) AnyFlatpakExists(flatpaks ...interface{}) *MockAppChecker_AnyFlatpakExists_Call {
return &MockAppChecker_AnyFlatpakExists_Call{Call: _e.mock.On("AnyFlatpakExists",
append([]interface{}{}, flatpaks...)...)}
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) Run(run func(flatpaks ...string)) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]string, len(args)-0)
for i, a := range args[0:] {
if a != nil {
variadicArgs[i] = a.(string)
}
}
run(variadicArgs...)
})
return _c
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) Return(_a0 bool) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_AnyFlatpakExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyFlatpakExists_Call {
_c.Call.Return(run)
return _c
}
// CommandExists provides a mock function with given fields: cmd
func (_m *MockAppChecker) CommandExists(cmd string) bool {
ret := _m.Called(cmd)
if len(ret) == 0 {
panic("no return value specified for CommandExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(cmd)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_CommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommandExists'
type MockAppChecker_CommandExists_Call struct {
*mock.Call
}
// CommandExists is a helper method to define mock.On call
// - cmd string
func (_e *MockAppChecker_Expecter) CommandExists(cmd interface{}) *MockAppChecker_CommandExists_Call {
return &MockAppChecker_CommandExists_Call{Call: _e.mock.On("CommandExists", cmd)}
}
func (_c *MockAppChecker_CommandExists_Call) Run(run func(cmd string)) *MockAppChecker_CommandExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockAppChecker_CommandExists_Call) Return(_a0 bool) *MockAppChecker_CommandExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_CommandExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_CommandExists_Call {
_c.Call.Return(run)
return _c
}
// FlatpakExists provides a mock function with given fields: name
func (_m *MockAppChecker) FlatpakExists(name string) bool {
ret := _m.Called(name)
if len(ret) == 0 {
panic("no return value specified for FlatpakExists")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(name)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// MockAppChecker_FlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FlatpakExists'
type MockAppChecker_FlatpakExists_Call struct {
*mock.Call
}
// FlatpakExists is a helper method to define mock.On call
// - name string
func (_e *MockAppChecker_Expecter) FlatpakExists(name interface{}) *MockAppChecker_FlatpakExists_Call {
return &MockAppChecker_FlatpakExists_Call{Call: _e.mock.On("FlatpakExists", name)}
}
func (_c *MockAppChecker_FlatpakExists_Call) Run(run func(name string)) *MockAppChecker_FlatpakExists_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockAppChecker_FlatpakExists_Call) Return(_a0 bool) *MockAppChecker_FlatpakExists_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockAppChecker_FlatpakExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_FlatpakExists_Call {
_c.Call.Return(run)
return _c
}
// NewMockAppChecker creates a new instance of MockAppChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAppChecker(t interface {
mock.TestingT
Cleanup(func())
}) *MockAppChecker {
mock := &MockAppChecker{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -26,6 +26,7 @@ type Plugin struct {
Compositors []string `json:"compositors"`
Distro []string `json:"distro"`
Screenshot string `json:"screenshot,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
}
type GitClient interface {

View File

@@ -24,20 +24,21 @@ import (
bolt "go.etcd.io/bbolt"
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
// These mime types wont be stored in history
// These mime types won't be stored in history
var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint",
}
func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
display := wlCtx.Display()
dbPath, err := getDBPath()
dbPath, err := clipboardstore.GetDBPath()
if err != nil {
return nil, fmt.Errorf("failed to get db path: %w", err)
}
@@ -102,24 +103,6 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
return m, nil
}
func getDBPath() (string, error) {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
cacheDir = filepath.Join(homeDir, ".cache")
}
dbDir := filepath.Join(cacheDir, "dms-clipboard")
if err := os.MkdirAll(dbDir, 0700); err != nil {
return "", err
}
return filepath.Join(dbDir, "db"), nil
}
func openDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0644, &bolt.Options{
Timeout: 1 * time.Second,

View File

@@ -13,6 +13,7 @@ const (
dbusNMPath = "/org/freedesktop/NetworkManager"
dbusNMInterface = "org.freedesktop.NetworkManager"
dbusNMDeviceInterface = "org.freedesktop.NetworkManager.Device"
dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired"
dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless"
dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint"
dbusPropsInterface = "org.freedesktop.DBus.Properties"

View File

@@ -81,44 +81,24 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err
}
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
for _, info := range b.wifiDevices {
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
conn.RemoveSignal(signals)
conn.Close()
return err
}
}
if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
for _, info := range b.ethernetDevices {
if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
conn.RemoveSignal(signals)
conn.Close()
return err
@@ -157,19 +137,17 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("PropertiesChanged"),
)
if b.wifiDevice != nil {
dev := b.wifiDevice.(gonetworkmanager.Device)
for _, info := range b.wifiDevices {
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
if b.ethernetDevice != nil {
dev := b.ethernetDevice.(gonetworkmanager.Device)
for _, info := range b.ethernetDevices {
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dev.GetPath())),
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
@@ -232,7 +210,10 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
b.handleNetworkManagerChange(changes)
case dbusNMDeviceInterface:
b.handleDeviceChange(changes)
b.handleDeviceChange(sig.Path, changes)
case dbusNMWiredInterface:
b.handleWiredChange(changes)
case dbusNMWirelessInterface:
b.handleWiFiChange(changes)
@@ -278,9 +259,10 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db
}
}
func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Variant) {
func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) {
var needsUpdate bool
var stateChanged bool
var managedChanged bool
for key := range changes {
switch key {
@@ -289,21 +271,61 @@ func (b *NetworkManagerBackend) handleDeviceChange(changes map[string]dbus.Varia
needsUpdate = true
case "Ip4Config":
needsUpdate = true
case "Managed":
managedChanged = true
default:
continue
}
}
if needsUpdate {
b.updateEthernetState()
b.updateWiFiState()
if stateChanged {
b.updatePrimaryConnection()
if managedChanged {
if managedVariant, ok := changes["Managed"]; ok {
if managed, ok := managedVariant.Value().(bool); ok && managed {
b.handleDeviceAdded(devicePath)
return
}
}
if b.onStateChange != nil {
b.onStateChange()
}
if !needsUpdate {
return
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.updateAllWiFiDevices()
b.updateWiFiState()
if stateChanged {
b.listEthernetConnections()
b.updatePrimaryConnection()
}
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *NetworkManagerBackend) handleWiredChange(changes map[string]dbus.Variant) {
var needsUpdate bool
for key := range changes {
switch key {
case "Carrier", "Speed", "HwAddress":
needsUpdate = true
default:
continue
}
}
if !needsUpdate {
return
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.updatePrimaryConnection()
if b.onStateChange != nil {
b.onStateChange()
}
}
func (b *NetworkManagerBackend) handleWiFiChange(changes map[string]dbus.Variant) {
@@ -369,6 +391,18 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
return
}
if devType != gonetworkmanager.NmDeviceTypeEthernet && devType != gonetworkmanager.NmDeviceTypeWifi {
return
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
managed, _ := dev.GetPropertyManaged()
if !managed {
return
@@ -398,14 +432,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
b.ethernetDevice = dev
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllEthernetDevices()
b.updateEthernetState()
b.listEthernetConnections()
@@ -430,14 +456,6 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) {
b.wifiDev = w
}
if b.dbusConn != nil {
b.dbusConn.AddMatchSignal(
dbus.WithMatchObjectPath(devicePath),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
}
b.updateAllWiFiDevices()
b.updateWiFiState()
}

View File

@@ -159,7 +159,7 @@ func TestNetworkManagerBackend_HandleDeviceChange(t *testing.T) {
}
assert.NotPanics(t, func() {
backend.handleDeviceChange(changes)
backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
})
}
@@ -174,7 +174,7 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) {
}
assert.NotPanics(t, func() {
backend.handleDeviceChange(changes)
backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes)
})
}

View File

@@ -3,6 +3,7 @@ package network
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
@@ -925,25 +926,24 @@ func (b *NetworkManagerBackend) ImportVPN(filePath string, name string) (*VPNImp
func (b *NetworkManagerBackend) importVPNWithNmcli(filePath string, name string) (*VPNImportResult, error) {
vpnTypes := []string{"openvpn", "wireguard", "vpnc", "pptp", "l2tp", "openconnect", "strongswan"}
var output []byte
var err error
var allErrors []error
var outputStr string
for _, vpnType := range vpnTypes {
args := []string{"connection", "import", "type", vpnType, "file", filePath}
cmd := exec.Command("nmcli", args...)
output, err = cmd.CombinedOutput()
cmd := exec.Command("nmcli", "connection", "import", "type", vpnType, "file", filePath)
output, err := cmd.CombinedOutput()
if err == nil {
outputStr = string(output)
break
}
allErrors = append(allErrors, fmt.Errorf("%s: %s", vpnType, strings.TrimSpace(string(output))))
}
if err != nil {
if len(allErrors) == len(vpnTypes) {
return &VPNImportResult{
Success: false,
Error: fmt.Sprintf("import failed: %s", strings.TrimSpace(string(output))),
Error: errors.Join(allErrors...).Error(),
}, nil
}
outputStr := string(output)
var connUUID, connName string
lines := strings.Split(outputStr, "\n")

View File

@@ -357,31 +357,51 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
savedSSIDs := make(map[string]bool)
autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
if connMeta, ok := connSettings["connection"]; ok {
if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" {
if wifiSettings, ok := connSettings["802-11-wireless"]; ok {
if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok {
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
}
}
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok {
continue
}
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
}
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected
wifiSignal := b.state.WiFiSignal
wifiBSSID := b.state.WiFiBSSID
b.stateMutex.RUnlock()
seenSSIDs := make(map[string]*WiFiNetwork)
@@ -444,6 +464,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
Connected: ssid == currentSSID,
Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid],
Hidden: hiddenSSIDs[ssid],
Frequency: freq,
Mode: modeStr,
Rate: maxBitrate / 1000,
@@ -454,6 +475,23 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
networks = append(networks, network)
}
if wifiConnected && currentSSID != "" {
if _, exists := seenSSIDs[currentSSID]; !exists {
hiddenNetwork := WiFiNetwork{
SSID: currentSSID,
BSSID: wifiBSSID,
Signal: wifiSignal,
Secured: true,
Connected: true,
Saved: savedSSIDs[currentSSID],
Autoconnect: autoconnectMap[currentSSID],
Hidden: true,
Mode: "infrastructure",
}
networks = append(networks, hiddenNetwork)
}
}
sortWiFiNetworks(networks)
b.stateMutex.Lock()
@@ -515,40 +553,53 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
nm := b.nmConn.(gonetworkmanager.NetworkManager)
dev := devInfo.device
w := devInfo.wireless
apPaths, err := w.GetAccessPoints()
if err != nil {
return fmt.Errorf("failed to get access points: %w", err)
}
var targetAP gonetworkmanager.AccessPoint
for _, ap := range apPaths {
ssid, err := ap.GetPropertySSID()
if err != nil || ssid != req.SSID {
continue
var flags, wpaFlags, rsnFlags uint32
if !req.Hidden {
apPaths, err := w.GetAccessPoints()
if err != nil {
return fmt.Errorf("failed to get access points: %w", err)
}
targetAP = ap
break
}
if targetAP == nil {
return fmt.Errorf("access point not found: %s", req.SSID)
}
for _, ap := range apPaths {
ssid, err := ap.GetPropertySSID()
if err != nil || ssid != req.SSID {
continue
}
targetAP = ap
break
}
flags, _ := targetAP.GetPropertyFlags()
wpaFlags, _ := targetAP.GetPropertyWPAFlags()
rsnFlags, _ := targetAP.GetPropertyRSNFlags()
if targetAP == nil {
return fmt.Errorf("access point not found: %s", req.SSID)
}
flags, _ = targetAP.GetPropertyFlags()
wpaFlags, _ = targetAP.GetPropertyWPAFlags()
rsnFlags, _ = targetAP.GetPropertyRSNFlags()
}
const KeyMgmt8021x = uint32(512)
const KeyMgmtPsk = uint32(256)
const KeyMgmtSae = uint32(1024)
isEnterprise := (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
isPsk := (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
isSae := (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
var isEnterprise, isPsk, isSae, secured bool
secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
switch {
case req.Hidden:
secured = req.Password != "" || req.Username != ""
isEnterprise = req.Username != ""
isPsk = req.Password != "" && !isEnterprise
default:
isEnterprise = (wpaFlags&KeyMgmt8021x) != 0 || (rsnFlags&KeyMgmt8021x) != 0
isPsk = (wpaFlags&KeyMgmtPsk) != 0 || (rsnFlags&KeyMgmtPsk) != 0
isSae = (wpaFlags&KeyMgmtSae) != 0 || (rsnFlags&KeyMgmtSae) != 0
secured = flags != uint32(gonetworkmanager.Nm80211APFlagsNone) ||
wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) ||
rsnFlags != uint32(gonetworkmanager.Nm80211APSecNone)
}
if isEnterprise {
log.Infof("[createAndConnectWiFi] Enterprise network detected (802.1x) - SSID: %s, interactive: %v",
@@ -567,11 +618,15 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
settings["ipv6"] = map[string]any{"method": "auto"}
if secured {
settings["802-11-wireless"] = map[string]any{
wifiSettings := map[string]any{
"ssid": []byte(req.SSID),
"mode": "infrastructure",
"security": "802-11-wireless-security",
}
if req.Hidden {
wifiSettings["hidden"] = true
}
settings["802-11-wireless"] = wifiSettings
switch {
case isEnterprise || req.Username != "":
@@ -658,10 +713,14 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
return fmt.Errorf("secured network but not SAE/PSK/802.1X (rsn=0x%x wpa=0x%x)", rsnFlags, wpaFlags)
}
} else {
settings["802-11-wireless"] = map[string]any{
wifiSettings := map[string]any{
"ssid": []byte(req.SSID),
"mode": "infrastructure",
}
if req.Hidden {
wifiSettings["hidden"] = true
}
settings["802-11-wireless"] = wifiSettings
}
if req.Interactive {
@@ -685,14 +744,23 @@ func (b *NetworkManagerBackend) createAndConnectWiFiOnDevice(req ConnectionReque
log.Infof("[createAndConnectWiFi] Enterprise connection added, activating (secret agent will be called)")
}
_, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
if req.Hidden {
_, err = nm.ActivateConnection(conn, dev, nil)
} else {
_, err = nm.ActivateWirelessConnection(conn, dev, targetAP)
}
if err != nil {
return fmt.Errorf("failed to activate connection: %w", err)
}
log.Infof("[createAndConnectWiFi] Connection activation initiated, waiting for NetworkManager state changes...")
} else {
_, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
var err error
if req.Hidden {
_, err = nm.AddAndActivateConnection(settings, dev)
} else {
_, err = nm.AddAndActivateWirelessConnection(settings, dev, targetAP)
}
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
@@ -813,6 +881,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
savedSSIDs := make(map[string]bool)
autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
@@ -846,6 +915,10 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
}
var devices []WiFiDevice
@@ -939,6 +1012,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
Connected: connected && apSSID == ssid,
Saved: savedSSIDs[apSSID],
Autoconnect: autoconnectMap[apSSID],
Hidden: hiddenSSIDs[apSSID],
Frequency: freq,
Mode: modeStr,
Rate: maxBitrate / 1000,
@@ -949,6 +1023,25 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
seenSSIDs[apSSID] = &network
networks = append(networks, network)
}
if connected && ssid != "" {
if _, exists := seenSSIDs[ssid]; !exists {
hiddenNetwork := WiFiNetwork{
SSID: ssid,
BSSID: bssid,
Signal: signal,
Secured: true,
Connected: true,
Saved: savedSSIDs[ssid],
Autoconnect: autoconnectMap[ssid],
Hidden: true,
Mode: "infrastructure",
Device: name,
}
networks = append(networks, hiddenNetwork)
}
}
sortWiFiNetworks(networks)
}

View File

@@ -2,9 +2,21 @@ package network
import (
"fmt"
"os/exec"
"time"
"github.com/Wifx/gonetworkmanager/v2"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
)
const (
priorityHigh = int32(100)
priorityLow = int32(10)
priorityDefault = int32(0)
metricPreferred = int64(100)
metricNonPreferred = int64(300)
metricDefault = int64(100)
)
func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
@@ -36,83 +48,124 @@ func (m *Manager) SetConnectionPreference(pref ConnectionPreference) error {
}
func (m *Manager) prioritizeWiFi() error {
if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil {
return err
if err := m.setConnectionPriority("802-11-wireless", priorityHigh, metricPreferred); err != nil {
log.Warnf("Failed to set WiFi priority: %v", err)
}
if err := m.setConnectionMetrics("802-3-ethernet", 100); err != nil {
return err
if err := m.setConnectionPriority("802-3-ethernet", priorityLow, metricNonPreferred); err != nil {
log.Warnf("Failed to set Ethernet priority: %v", err)
}
m.reapplyActiveConnections()
m.notifySubscribers()
return nil
}
func (m *Manager) prioritizeEthernet() error {
if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil {
return err
if err := m.setConnectionPriority("802-3-ethernet", priorityHigh, metricPreferred); err != nil {
log.Warnf("Failed to set Ethernet priority: %v", err)
}
if err := m.setConnectionMetrics("802-11-wireless", 100); err != nil {
return err
if err := m.setConnectionPriority("802-11-wireless", priorityLow, metricNonPreferred); err != nil {
log.Warnf("Failed to set WiFi priority: %v", err)
}
m.reapplyActiveConnections()
m.notifySubscribers()
return nil
}
func (m *Manager) balancePriorities() error {
if err := m.setConnectionMetrics("802-3-ethernet", 50); err != nil {
return err
if err := m.setConnectionPriority("802-3-ethernet", priorityDefault, metricDefault); err != nil {
log.Warnf("Failed to reset Ethernet priority: %v", err)
}
if err := m.setConnectionMetrics("802-11-wireless", 50); err != nil {
return err
if err := m.setConnectionPriority("802-11-wireless", priorityDefault, metricDefault); err != nil {
log.Warnf("Failed to reset WiFi priority: %v", err)
}
m.reapplyActiveConnections()
m.notifySubscribers()
return nil
}
func (m *Manager) setConnectionMetrics(connType string, metric uint32) error {
settingsMgr, err := gonetworkmanager.NewSettings()
func (m *Manager) reapplyActiveConnections() {
m.stateMutex.RLock()
ethDev := m.state.EthernetDevice
wifiDev := m.state.WiFiDevice
m.stateMutex.RUnlock()
if ethDev != "" {
exec.Command("nmcli", "dev", "reapply", ethDev).Run()
}
if wifiDev != "" {
exec.Command("nmcli", "dev", "reapply", wifiDev).Run()
}
}
func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int32, routeMetric int64) error {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return fmt.Errorf("failed to get settings: %w", err)
return fmt.Errorf("failed to connect to system bus: %w", err)
}
defer conn.Close()
settingsObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager/Settings")
var connPaths []dbus.ObjectPath
if err := settingsObj.Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0).Store(&connPaths); err != nil {
return fmt.Errorf("failed to list connections: %w", err)
}
connections, err := settingsMgr.ListConnections()
if err != nil {
return fmt.Errorf("failed to get connections: %w", err)
}
for _, connPath := range connPaths {
connObj := conn.Object("org.freedesktop.NetworkManager", connPath)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
var settings map[string]map[string]dbus.Variant
if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&settings); err != nil {
continue
}
if connMeta, ok := connSettings["connection"]; ok {
if cType, ok := connMeta["type"].(string); ok && cType == connType {
if connSettings["ipv4"] == nil {
connSettings["ipv4"] = make(map[string]any)
}
if ipv4Map := connSettings["ipv4"]; ipv4Map != nil {
ipv4Map["route-metric"] = int64(metric)
}
if connSettings["ipv6"] == nil {
connSettings["ipv6"] = make(map[string]any)
}
if ipv6Map := connSettings["ipv6"]; ipv6Map != nil {
ipv6Map["route-metric"] = int64(metric)
}
err = conn.Update(connSettings)
if err != nil {
continue
}
}
connSection, ok := settings["connection"]
if !ok {
continue
}
typeVariant, ok := connSection["type"]
if !ok {
continue
}
cType, ok := typeVariant.Value().(string)
if !ok || cType != connType {
continue
}
connName := ""
if idVariant, ok := connSection["id"]; ok {
connName, _ = idVariant.Value().(string)
}
if connName == "" {
continue
}
if err := exec.Command("nmcli", "con", "mod", connName,
"connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority)).Run(); err != nil {
log.Warnf("Failed to set autoconnect-priority for %v: %v", connName, err)
continue
}
if err := exec.Command("nmcli", "con", "mod", connName,
"ipv4.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv4.route-metric for %v: %v", connName, err)
}
if err := exec.Command("nmcli", "con", "mod", connName,
"ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil {
log.Warnf("Failed to set ipv6.route-metric for %v: %v", connName, err)
}
log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric)
}
return nil
@@ -125,14 +178,18 @@ func (m *Manager) GetConnectionPreference() ConnectionPreference {
}
func (m *Manager) WasRecentlyFailed(ssid string) bool {
if nm, ok := m.backend.(*NetworkManagerBackend); ok {
nm.failedMutex.RLock()
defer nm.failedMutex.RUnlock()
if nm.lastFailedSSID == ssid {
elapsed := time.Now().Unix() - nm.lastFailedTime
return elapsed < 10
}
nm, ok := m.backend.(*NetworkManagerBackend)
if !ok {
return false
}
return false
nm.failedMutex.RLock()
defer nm.failedMutex.RUnlock()
if nm.lastFailedSSID != ssid {
return false
}
elapsed := time.Now().Unix() - nm.lastFailedTime
return elapsed < 10
}

View File

@@ -33,6 +33,7 @@ type WiFiNetwork struct {
Connected bool `json:"connected"`
Saved bool `json:"saved"`
Autoconnect bool `json:"autoconnect"`
Hidden bool `json:"hidden"`
Frequency uint32 `json:"frequency"`
Mode string `json:"mode"`
Rate uint32 `json:"rate"`
@@ -127,6 +128,7 @@ type ConnectionRequest struct {
AnonymousIdentity string `json:"anonymousIdentity,omitempty"`
DomainSuffixMatch string `json:"domainSuffixMatch,omitempty"`
Interactive bool `json:"interactive,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Device string `json:"device,omitempty"`
EAPMethod string `json:"eapMethod,omitempty"`
Phase2Auth string `json:"phase2Auth,omitempty"`

View File

@@ -44,6 +44,7 @@ func HandleList(conn net.Conn, req models.Request) {
Dependencies: p.Dependencies,
Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
RequiresDMS: p.RequiresDMS,
}
}

View File

@@ -60,6 +60,7 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
Dependencies: plugin.Dependencies,
FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"),
HasUpdate: hasUpdate,
RequiresDMS: plugin.RequiresDMS,
})
} else {
result = append(result, PluginInfo{

View File

@@ -66,6 +66,7 @@ func HandleSearch(conn net.Conn, req models.Request) {
Dependencies: p.Dependencies,
Installed: installed,
FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"),
RequiresDMS: p.RequiresDMS,
}
}

View File

@@ -15,6 +15,7 @@ type PluginInfo struct {
FirstParty bool `json:"firstParty,omitempty"`
Note string `json:"note,omitempty"`
HasUpdate bool `json:"hasUpdate,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
}
type SuccessResult struct {

View File

@@ -124,27 +124,23 @@ func (sc *SharedContext) eventDispatcher() {
}
for {
sc.drainCmdQueue()
select {
case <-sc.stopChan:
return
default:
}
sc.drainCmdQueue()
n, err := unix.Poll(pollFds, 50)
if err != nil {
if err == unix.EINTR {
continue
}
_, err := unix.Poll(pollFds, -1)
switch {
case err == unix.EINTR:
continue
case err != nil:
log.Errorf("Poll error: %v", err)
return
}
if n == 0 {
continue
}
if pollFds[1].Revents&unix.POLLIN != 0 {
var buf [64]byte
if _, err := unix.Read(sc.wakeR, buf[:]); err != nil && err != unix.EAGAIN {
@@ -152,13 +148,13 @@ func (sc *SharedContext) eventDispatcher() {
}
}
if pollFds[0].Revents&unix.POLLIN != 0 {
if err := ctx.Dispatch(); err != nil {
if !os.IsTimeout(err) {
log.Errorf("Wayland connection error: %v", err)
return
}
}
if pollFds[0].Revents&unix.POLLIN == 0 {
continue
}
if err := ctx.Dispatch(); err != nil && !os.IsTimeout(err) {
log.Errorf("Wayland connection error: %v", err)
return
}
}
}
@@ -176,12 +172,16 @@ func (sc *SharedContext) drainCmdQueue() {
func (sc *SharedContext) Close() {
close(sc.stopChan)
if _, err := unix.Write(sc.wakeW, []byte{1}); err != nil && err != unix.EAGAIN {
log.Errorf("wake pipe write error on close: %v", err)
}
sc.wg.Wait()
unix.Close(sc.wakeR)
unix.Close(sc.wakeW)
if sc.display != nil {
sc.display.Context().Close()
if sc.display == nil {
return
}
sc.display.Context().Close()
}

View File

@@ -5,6 +5,31 @@ import (
"strings"
)
type AppChecker interface {
CommandExists(cmd string) bool
AnyCommandExists(cmds ...string) bool
FlatpakExists(name string) bool
AnyFlatpakExists(flatpaks ...string) bool
}
type DefaultAppChecker struct{}
func (DefaultAppChecker) CommandExists(cmd string) bool {
return CommandExists(cmd)
}
func (DefaultAppChecker) AnyCommandExists(cmds ...string) bool {
return AnyCommandExists(cmds...)
}
func (DefaultAppChecker) FlatpakExists(name string) bool {
return FlatpakExists(name)
}
func (DefaultAppChecker) AnyFlatpakExists(flatpaks ...string) bool {
return AnyFlatpakExists(flatpaks...)
}
func CommandExists(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil

View File

@@ -210,21 +210,43 @@ func TestFlatpakInstallationDirCommandFailure(t *testing.T) {
}
func TestAnyFlatpakExistsSomeExist(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
tempDir := t.TempDir()
fakeFlatpak := filepath.Join(tempDir, "flatpak")
// Script that succeeds only for "app.exists.test"
script := `#!/bin/sh
if [ "$1" = "info" ] && [ "$2" = "app.exists.test" ]; then
exit 0
fi
exit 1
`
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
result := AnyFlatpakExists("com.nonexistent.flatpak", "app.zen_browser.zen", "com.another.nonexistent")
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+":"+originalPath)
result := AnyFlatpakExists("com.nonexistent.flatpak", "app.exists.test", "com.another.nonexistent")
if !result {
t.Errorf("expected true when at least one flatpak exists")
}
}
func TestAnyFlatpakExistsNoneExist(t *testing.T) {
if !FlatpakInPath() {
t.Skip("flatpak not in PATH")
tempDir := t.TempDir()
fakeFlatpak := filepath.Join(tempDir, "flatpak")
script := "#!/bin/sh\nexit 1\n"
err := os.WriteFile(fakeFlatpak, []byte(script), 0755)
if err != nil {
t.Fatalf("failed to create fake flatpak: %v", err)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+":"+originalPath)
result := AnyFlatpakExists("com.nonexistent.flatpak1", "com.nonexistent.flatpak2")
if result {
t.Errorf("expected false when no flatpaks exist")

View File

@@ -10,9 +10,32 @@ func XDGStateHome() string {
if dir := os.Getenv("XDG_STATE_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(append([]string{home}, ".local", "state")...)
return filepath.Join(home, ".local", "state")
}
func XDGDataHome() string {
if dir := os.Getenv("XDG_DATA_HOME"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share")
}
func XDGCacheHome() string {
if dir, err := os.UserCacheDir(); err == nil {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache")
}
func XDGConfigHome() string {
if dir, err := os.UserConfigDir(); err == nil {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config")
}
func ExpandPath(path string) (string, error) {

View File

@@ -96,7 +96,7 @@ func (c *CUPSClient) RejectJobs(printer string) error {
return err
}
// AddPrinterToClass adds a printer to a class, if the class does not exists it will be crated
// AddPrinterToClass adds a printer to a class, if the class does not exists it will be created
func (c *CUPSClient) AddPrinterToClass(class, printer string) error {
attributes, err := c.GetPrinterAttributes(class, []string{AttributeMemberURIs})
if err != nil && !IsNotExistsError(err) {

View File

@@ -19,7 +19,8 @@ in
]
++ lib.optional cfg.enableDynamicTheming pkgs.matugen
++ lib.optional cfg.enableAudioWavelength pkgs.cava
++ lib.optional cfg.enableCalendarEvents pkgs.khal;
++ lib.optional cfg.enableCalendarEvents pkgs.khal
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
plugins = lib.mapAttrs (name: plugin: {
source = plugin.src;

View File

@@ -16,6 +16,12 @@ let
dmsPkgs
;
};
hasPluginSettings = lib.any (plugin: plugin.settings != { }) (
lib.attrValues (lib.filterAttrs (n: v: v.enable) cfg.plugins)
);
pluginSettings = lib.mapAttrs (name: plugin: { enabled = plugin.enable; } // plugin.settings) (
lib.filterAttrs (n: v: v.enable) cfg.plugins
);
in
{
imports = [
@@ -24,25 +30,48 @@ in
"programs"
"dank-material-shell"
"enableNightMode"
] "Night mode is now always available.")
] "Night mode is now always available")
(lib.mkRemovedOptionModule [
"programs"
"dank-material-shell"
"default"
"settings"
] "Default settings have been removed and been replaced with programs.dank-material-shell.settings")
(lib.mkRemovedOptionModule [
"programs"
"dank-material-shell"
"default"
"session"
] "Default session has been removed and been replaced with programs.dank-material-shell.session")
(lib.mkRenamedOptionModule
[ "programs" "dank-material-shell" "enableSystemd" ]
[ "programs" "dank-material-shell" "systemd" "enable" ]
)
];
options.programs.dank-material-shell = with lib.types; {
default = {
settings = lib.mkOption {
type = jsonFormat.type;
default = { };
description = "The default settings are only read if the settings.json file don't exist";
};
session = lib.mkOption {
type = jsonFormat.type;
default = { };
description = "The default session are only read if the session.json file don't exist";
};
options.programs.dank-material-shell = {
settings = lib.mkOption {
type = jsonFormat.type;
default = { };
description = "DankMaterialShell configuration settings as an attribute set, to be written to ~/.config/DankMaterialShell/settings.json.";
};
clipboardSettings = lib.mkOption {
type = jsonFormat.type;
default = { };
description = "DankMaterialShell clipboard settings as an attribute set, to be written to ~/.config/DankMaterialShell/clsettings.json.";
};
session = lib.mkOption {
type = jsonFormat.type;
default = { };
description = "DankMaterialShell session settings as an attribute set, to be written to ~/.local/state/DankMaterialShell/session.json.";
};
managePluginSettings = lib.mkOption {
type = lib.types.bool;
default = hasPluginSettings;
description = ''Whether to manage plugin settings. Automatically enabled if any plugins have settings configured.'';
};
};
@@ -67,22 +96,28 @@ in
Install.WantedBy = [ config.wayland.systemd.target ];
};
xdg.stateFile."DankMaterialShell/default-session.json" = lib.mkIf (cfg.default.session != { }) {
source = jsonFormat.generate "default-session.json" cfg.default.session;
xdg.stateFile."DankMaterialShell/session.json" = lib.mkIf (cfg.session != { }) {
source = jsonFormat.generate "session.json" cfg.session;
};
xdg.configFile = lib.mkMerge [
(lib.mapAttrs' (name: value: {
name = "DankMaterialShell/plugins/${name}";
inherit value;
}) common.plugins)
{
"DankMaterialShell/default-settings.json" = lib.mkIf (cfg.default.settings != { }) {
source = jsonFormat.generate "default-settings.json" cfg.default.settings;
};
}
];
xdg.configFile = {
"DankMaterialShell/settings.json" = lib.mkIf (cfg.settings != { }) {
source = jsonFormat.generate "settings.json" cfg.settings;
};
"DankMaterialShell/clsettings.json" = lib.mkIf (cfg.clipboardSettings != { }) {
source = jsonFormat.generate "clsettings.json" cfg.clipboardSettings;
};
"DankMaterialShell/plugin_settings.json" = lib.mkIf cfg.managePluginSettings {
source = jsonFormat.generate "plugin_settings.json" pluginSettings;
};
}
// (lib.mapAttrs' (name: value: {
name = "DankMaterialShell/plugins/${name}";
inherit value;
}) common.plugins);
warnings =
lib.optional (!cfg.managePluginSettings && hasPluginSettings)
"You have disabled managePluginSettings but provided plugin settings. These settings will be ignored.";
home.packages = common.packages;
};
}

View File

@@ -10,7 +10,7 @@ let
"programs"
"dank-material-shell"
];
jsonFormat = pkgs.formats.json { };
builtInRemovedMsg = "This is now built-in in DMS and doesn't need additional dependencies.";
in
{
@@ -37,7 +37,7 @@ in
};
dgop = {
package = lib.mkPackageOption pkgs "dgop" {};
package = lib.mkPackageOption pkgs "dgop" { };
};
enableSystemMonitoring = lib.mkOption {
@@ -70,6 +70,12 @@ in
description = "Add calendar events support via khal";
};
enableClipboardPaste = lib.mkOption {
type = types.bool;
default = true;
description = "Adds needed dependencies for directly pasting items from the clipboard history.";
};
quickshell = {
package = lib.mkPackageOption dmsPkgs "quickshell" {
extraDescription = "The quickshell package to use (defaults to be built from source, due to unreleased features used by DMS).";
@@ -89,6 +95,11 @@ in
type = types.either types.package types.path;
description = "Source of the plugin package or path";
};
settings = lib.mkOption {
type = jsonFormat.type;
default = { };
description = "Plugin settings as an attribute set";
};
};
}
);

View File

@@ -76,7 +76,7 @@
inherit version;
pname = "dms-shell";
src = ./core;
vendorHash = "sha256-DINaA5LCOWoxBIewuc39Rnwj6NdZoET7Q++B11Qg5rI=";
vendorHash = "sha256-9CnZFtjXXWYELRiBX2UbZvWopnl9Y1ILuK+xP6YQZ9U=";
subPackages = [ "cmd/dms" ];

View File

@@ -7,120 +7,109 @@ import Quickshell
import Quickshell.Io
Singleton {
id: root
property var appUsageRanking: {
}
property var appUsageRanking: {}
Component.onCompleted: {
loadSettings()
loadSettings();
}
function loadSettings() {
parseSettings(settingsFile.text())
parseSettings(settingsFile.text());
}
function parseSettings(content) {
try {
if (content && content.trim()) {
var settings = JSON.parse(content)
appUsageRanking = settings.appUsageRanking || {}
var settings = JSON.parse(content);
appUsageRanking = settings.appUsageRanking || {};
}
} catch (e) {
}
} catch (e) {}
}
function saveSettings() {
settingsFile.setText(JSON.stringify({
"appUsageRanking": appUsageRanking
}, null, 2))
"appUsageRanking": appUsageRanking
}, null, 2));
}
function addAppUsage(app) {
if (!app)
return
var appId = app.id || (app.execString || app.exec || "")
return;
var appId = app.id || (app.execString || app.exec || "");
if (!appId)
return
var currentRanking = Object.assign({}, appUsageRanking)
return;
var currentRanking = Object.assign({}, appUsageRanking);
if (currentRanking[appId]) {
currentRanking[appId].usageCount = (currentRanking[appId].usageCount
|| 1) + 1
currentRanking[appId].lastUsed = Date.now()
currentRanking[appId].icon = app.icon || currentRanking[appId].icon
|| "application-x-executable"
currentRanking[appId].name = app.name
|| currentRanking[appId].name || ""
currentRanking[appId].usageCount = (currentRanking[appId].usageCount || 1) + 1;
currentRanking[appId].lastUsed = Date.now();
currentRanking[appId].icon = app.icon ? String(app.icon) : (currentRanking[appId].icon || "application-x-executable");
currentRanking[appId].name = app.name || currentRanking[appId].name || "";
} else {
currentRanking[appId] = {
"name": app.name || "",
"exec": app.execString || app.exec || "",
"icon": app.icon || "application-x-executable",
"icon": app.icon ? String(app.icon) : "application-x-executable",
"comment": app.comment || "",
"usageCount": 1,
"lastUsed": Date.now()
}
};
}
appUsageRanking = currentRanking
saveSettings()
appUsageRanking = currentRanking;
saveSettings();
}
function getRankedApps() {
var apps = []
var apps = [];
for (var appId in appUsageRanking) {
var appData = appUsageRanking[appId]
var appData = appUsageRanking[appId];
apps.push({
"id": appId,
"name": appData.name,
"exec": appData.exec,
"icon": appData.icon,
"comment": appData.comment,
"usageCount": appData.usageCount,
"lastUsed": appData.lastUsed
})
"id": appId,
"name": appData.name,
"exec": appData.exec,
"icon": appData.icon,
"comment": appData.comment,
"usageCount": appData.usageCount,
"lastUsed": appData.lastUsed
});
}
return apps.sort(function (a, b) {
if (a.usageCount !== b.usageCount)
return b.usageCount - a.usageCount
return a.name.localeCompare(b.name)
})
return b.usageCount - a.usageCount;
return a.name.localeCompare(b.name);
});
}
function cleanupAppUsageRanking(availableAppIds) {
var currentRanking = Object.assign({}, appUsageRanking)
var hasChanges = false
var currentRanking = Object.assign({}, appUsageRanking);
var hasChanges = false;
for (var appId in currentRanking) {
if (availableAppIds.indexOf(appId) === -1) {
delete currentRanking[appId]
hasChanges = true
delete currentRanking[appId];
hasChanges = true;
}
}
if (hasChanges) {
appUsageRanking = currentRanking
saveSettings()
appUsageRanking = currentRanking;
saveSettings();
}
}
FileView {
id: settingsFile
path: StandardPaths.writableLocation(
StandardPaths.GenericStateLocation) + "/DankMaterialShell/appusage.json"
path: StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/DankMaterialShell/appusage.json"
blockLoading: true
blockWrites: true
watchChanges: true
onLoaded: {
parseSettings(settingsFile.text())
parseSettings(settingsFile.text());
}
onLoadFailed: error => {}
}

View File

@@ -46,7 +46,9 @@ const KEY_MAP = {
16777349: "XF86AudioMedia",
16777350: "XF86AudioRecord",
16842798: "XF86MonBrightnessUp",
16777394: "XF86MonBrightnessUp",
16842797: "XF86MonBrightnessDown",
16777395: "XF86MonBrightnessDown",
16842800: "XF86KbdBrightnessUp",
16842799: "XF86KbdBrightnessDown",
16842796: "XF86PowerOff",

View File

@@ -103,7 +103,7 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call wallpaper prev", label: "Wallpaper: Previous" }
];
const COMPOSITOR_ACTIONS = {
const NIRI_ACTIONS = {
"Window": [
{ id: "close-window", label: "Close Window" },
{ id: "fullscreen-window", label: "Fullscreen" },
@@ -179,9 +179,246 @@ const COMPOSITOR_ACTIONS = {
]
};
const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Window", "Monitor", "Screenshot", "System", "Overview", "Alt-Tab", "Other"];
const MANGOWC_ACTIONS = {
"Window": [
{ id: "killclient", label: "Close Window" },
{ id: "focuslast", label: "Focus Last Window" },
{ id: "focusstack next", label: "Focus Next in Stack" },
{ id: "focusstack prev", label: "Focus Previous in Stack" },
{ id: "focusdir left", label: "Focus Left" },
{ id: "focusdir right", label: "Focus Right" },
{ id: "focusdir up", label: "Focus Up" },
{ id: "focusdir down", label: "Focus Down" },
{ id: "exchange_client left", label: "Swap Left" },
{ id: "exchange_client right", label: "Swap Right" },
{ id: "exchange_client up", label: "Swap Up" },
{ id: "exchange_client down", label: "Swap Down" },
{ id: "exchange_stack_client next", label: "Swap Next in Stack" },
{ id: "exchange_stack_client prev", label: "Swap Previous in Stack" },
{ id: "togglefloating", label: "Toggle Floating" },
{ id: "togglefullscreen", label: "Toggle Fullscreen" },
{ id: "togglefakefullscreen", label: "Toggle Fake Fullscreen" },
{ id: "togglemaximizescreen", label: "Toggle Maximize" },
{ id: "toggleglobal", label: "Toggle Global (Sticky)" },
{ id: "toggleoverlay", label: "Toggle Overlay" },
{ id: "minimized", label: "Minimize Window" },
{ id: "restore_minimized", label: "Restore Minimized" },
{ id: "toggle_render_border", label: "Toggle Border" },
{ id: "centerwin", label: "Center Window" },
{ id: "zoom", label: "Swap with Master" }
],
"Move/Resize": [
{ id: "smartmovewin left", label: "Smart Move Left" },
{ id: "smartmovewin right", label: "Smart Move Right" },
{ id: "smartmovewin up", label: "Smart Move Up" },
{ id: "smartmovewin down", label: "Smart Move Down" },
{ id: "smartresizewin left", label: "Smart Resize Left" },
{ id: "smartresizewin right", label: "Smart Resize Right" },
{ id: "smartresizewin up", label: "Smart Resize Up" },
{ id: "smartresizewin down", label: "Smart Resize Down" },
{ id: "movewin", label: "Move Window (x,y)" },
{ id: "resizewin", label: "Resize Window (w,h)" }
],
"Tags": [
{ id: "view", label: "View Tag" },
{ id: "viewtoleft", label: "View Left Tag" },
{ id: "viewtoright", label: "View Right Tag" },
{ id: "viewtoleft_have_client", label: "View Left (with client)" },
{ id: "viewtoright_have_client", label: "View Right (with client)" },
{ id: "viewcrossmon", label: "View Cross-Monitor" },
{ id: "tag", label: "Move to Tag" },
{ id: "tagsilent", label: "Move to Tag (silent)" },
{ id: "tagtoleft", label: "Move to Left Tag" },
{ id: "tagtoright", label: "Move to Right Tag" },
{ id: "tagcrossmon", label: "Move Cross-Monitor" },
{ id: "toggletag", label: "Toggle Tag on Window" },
{ id: "toggleview", label: "Toggle Tag View" },
{ id: "comboview", label: "Combo View Tags" }
],
"Layout": [
{ id: "setlayout", label: "Set Layout" },
{ id: "switch_layout", label: "Cycle Layouts" },
{ id: "set_proportion", label: "Set Proportion" },
{ id: "switch_proportion_preset", label: "Cycle Proportion Presets" },
{ id: "incnmaster +1", label: "Increase Masters" },
{ id: "incnmaster -1", label: "Decrease Masters" },
{ id: "setmfact", label: "Set Master Factor" },
{ id: "incgaps", label: "Adjust Gaps" },
{ id: "togglegaps", label: "Toggle Gaps" }
],
"Monitor": [
{ id: "focusmon left", label: "Focus Monitor Left" },
{ id: "focusmon right", label: "Focus Monitor Right" },
{ id: "focusmon up", label: "Focus Monitor Up" },
{ id: "focusmon down", label: "Focus Monitor Down" },
{ id: "tagmon left", label: "Move to Monitor Left" },
{ id: "tagmon right", label: "Move to Monitor Right" },
{ id: "tagmon up", label: "Move to Monitor Up" },
{ id: "tagmon down", label: "Move to Monitor Down" },
{ id: "disable_monitor", label: "Disable Monitor" },
{ id: "enable_monitor", label: "Enable Monitor" },
{ id: "toggle_monitor", label: "Toggle Monitor" },
{ id: "create_virtual_output", label: "Create Virtual Output" },
{ id: "destroy_all_virtual_output", label: "Destroy Virtual Outputs" }
],
"Scratchpad": [
{ id: "toggle_scratchpad", label: "Toggle Scratchpad" },
{ id: "toggle_name_scratchpad", label: "Toggle Named Scratchpad" }
],
"Overview": [
{ id: "toggleoverview", label: "Toggle Overview" }
],
"System": [
{ id: "reload_config", label: "Reload Config" },
{ id: "quit", label: "Quit MangoWC" },
{ id: "setkeymode", label: "Set Keymode" },
{ id: "switch_keyboard_layout", label: "Switch Keyboard Layout" },
{ id: "setoption", label: "Set Option" },
{ id: "toggle_trackpad_enable", label: "Toggle Trackpad" }
]
};
const ACTION_ARGS = {
const HYPRLAND_ACTIONS = {
"Window": [
{ id: "killactive", label: "Close Window" },
{ id: "forcekillactive", label: "Force Kill Window" },
{ id: "closewindow", label: "Close Window (by selector)" },
{ id: "killwindow", label: "Kill Window (by selector)" },
{ id: "togglefloating", label: "Toggle Floating" },
{ id: "setfloating", label: "Set Floating" },
{ id: "settiled", label: "Set Tiled" },
{ id: "fullscreen", label: "Toggle Fullscreen" },
{ id: "fullscreenstate", label: "Set Fullscreen State" },
{ id: "pin", label: "Pin Window" },
{ id: "centerwindow", label: "Center Window" },
{ id: "resizeactive", label: "Resize Active Window" },
{ id: "moveactive", label: "Move Active Window" },
{ id: "resizewindowpixel", label: "Resize Window (pixels)" },
{ id: "movewindowpixel", label: "Move Window (pixels)" },
{ id: "alterzorder", label: "Change Z-Order" },
{ id: "bringactivetotop", label: "Bring to Top" },
{ id: "setprop", label: "Set Window Property" },
{ id: "toggleswallow", label: "Toggle Swallow" }
],
"Focus": [
{ id: "movefocus l", label: "Focus Left" },
{ id: "movefocus r", label: "Focus Right" },
{ id: "movefocus u", label: "Focus Up" },
{ id: "movefocus d", label: "Focus Down" },
{ id: "movefocus", label: "Move Focus (direction)" },
{ id: "cyclenext", label: "Cycle Next Window" },
{ id: "cyclenext prev", label: "Cycle Previous Window" },
{ id: "focuswindow", label: "Focus Window (by selector)" },
{ id: "focuscurrentorlast", label: "Focus Current or Last" },
{ id: "focusurgentorlast", label: "Focus Urgent or Last" }
],
"Move": [
{ id: "movewindow l", label: "Move Window Left" },
{ id: "movewindow r", label: "Move Window Right" },
{ id: "movewindow u", label: "Move Window Up" },
{ id: "movewindow d", label: "Move Window Down" },
{ id: "movewindow", label: "Move Window (direction)" },
{ id: "swapwindow l", label: "Swap Left" },
{ id: "swapwindow r", label: "Swap Right" },
{ id: "swapwindow u", label: "Swap Up" },
{ id: "swapwindow d", label: "Swap Down" },
{ id: "swapwindow", label: "Swap Window (direction)" },
{ id: "swapnext", label: "Swap with Next" },
{ id: "swapnext prev", label: "Swap with Previous" },
{ id: "movecursortocorner", label: "Move Cursor to Corner" },
{ id: "movecursor", label: "Move Cursor (x,y)" }
],
"Workspace": [
{ id: "workspace", label: "Focus Workspace" },
{ id: "workspace +1", label: "Next Workspace" },
{ id: "workspace -1", label: "Previous Workspace" },
{ id: "workspace e+1", label: "Next Open Workspace" },
{ id: "workspace e-1", label: "Previous Open Workspace" },
{ id: "workspace previous", label: "Previous Visited Workspace" },
{ id: "workspace previous_per_monitor", label: "Previous on Monitor" },
{ id: "workspace empty", label: "First Empty Workspace" },
{ id: "movetoworkspace", label: "Move to Workspace" },
{ id: "movetoworkspace +1", label: "Move to Next Workspace" },
{ id: "movetoworkspace -1", label: "Move to Previous Workspace" },
{ id: "movetoworkspacesilent", label: "Move to Workspace (silent)" },
{ id: "movetoworkspacesilent +1", label: "Move to Next (silent)" },
{ id: "movetoworkspacesilent -1", label: "Move to Previous (silent)" },
{ id: "togglespecialworkspace", label: "Toggle Special Workspace" },
{ id: "focusworkspaceoncurrentmonitor", label: "Focus Workspace on Current Monitor" },
{ id: "renameworkspace", label: "Rename Workspace" }
],
"Monitor": [
{ id: "focusmonitor l", label: "Focus Monitor Left" },
{ id: "focusmonitor r", label: "Focus Monitor Right" },
{ id: "focusmonitor u", label: "Focus Monitor Up" },
{ id: "focusmonitor d", label: "Focus Monitor Down" },
{ id: "focusmonitor +1", label: "Focus Next Monitor" },
{ id: "focusmonitor -1", label: "Focus Previous Monitor" },
{ id: "focusmonitor", label: "Focus Monitor (by selector)" },
{ id: "movecurrentworkspacetomonitor", label: "Move Workspace to Monitor" },
{ id: "moveworkspacetomonitor", label: "Move Specific Workspace to Monitor" },
{ id: "swapactiveworkspaces", label: "Swap Active Workspaces" }
],
"Groups": [
{ id: "togglegroup", label: "Toggle Group" },
{ id: "changegroupactive f", label: "Next in Group" },
{ id: "changegroupactive b", label: "Previous in Group" },
{ id: "changegroupactive", label: "Change Active in Group" },
{ id: "moveintogroup l", label: "Move into Group Left" },
{ id: "moveintogroup r", label: "Move into Group Right" },
{ id: "moveintogroup u", label: "Move into Group Up" },
{ id: "moveintogroup d", label: "Move into Group Down" },
{ id: "moveoutofgroup", label: "Move out of Group" },
{ id: "movewindoworgroup l", label: "Move Window/Group Left" },
{ id: "movewindoworgroup r", label: "Move Window/Group Right" },
{ id: "movewindoworgroup u", label: "Move Window/Group Up" },
{ id: "movewindoworgroup d", label: "Move Window/Group Down" },
{ id: "movegroupwindow f", label: "Swap Forward in Group" },
{ id: "movegroupwindow b", label: "Swap Backward in Group" },
{ id: "lockgroups lock", label: "Lock All Groups" },
{ id: "lockgroups unlock", label: "Unlock All Groups" },
{ id: "lockgroups toggle", label: "Toggle Groups Lock" },
{ id: "lockactivegroup lock", label: "Lock Active Group" },
{ id: "lockactivegroup unlock", label: "Unlock Active Group" },
{ id: "lockactivegroup toggle", label: "Toggle Active Group Lock" },
{ id: "denywindowfromgroup on", label: "Deny Window from Group" },
{ id: "denywindowfromgroup off", label: "Allow Window in Group" },
{ id: "denywindowfromgroup toggle", label: "Toggle Deny from Group" },
{ id: "setignoregrouplock on", label: "Ignore Group Lock" },
{ id: "setignoregrouplock off", label: "Respect Group Lock" },
{ id: "setignoregrouplock toggle", label: "Toggle Ignore Group Lock" }
],
"Layout": [
{ id: "splitratio", label: "Adjust Split Ratio" }
],
"System": [
{ id: "exit", label: "Exit Hyprland" },
{ id: "forcerendererreload", label: "Force Renderer Reload" },
{ id: "dpms on", label: "DPMS On" },
{ id: "dpms off", label: "DPMS Off" },
{ id: "dpms toggle", label: "DPMS Toggle" },
{ id: "forceidle", label: "Force Idle" },
{ id: "submap", label: "Enter Submap" },
{ id: "submap reset", label: "Reset Submap" },
{ id: "global", label: "Global Shortcut" },
{ id: "event", label: "Emit Custom Event" }
],
"Pass-through": [
{ id: "pass", label: "Pass Key to Window" },
{ id: "sendshortcut", label: "Send Shortcut to Window" },
{ id: "sendkeystate", label: "Send Key State" }
]
};
const COMPOSITOR_ACTIONS = {
niri: NIRI_ACTIONS,
mangowc: MANGOWC_ACTIONS,
hyprland: HYPRLAND_ACTIONS
};
const CATEGORY_ORDER = ["DMS", "Execute", "Workspace", "Tags", "Window", "Move/Resize", "Focus", "Move", "Layout", "Groups", "Monitor", "Scratchpad", "Screenshot", "System", "Pass-through", "Overview", "Alt-Tab", "Other"];
const NIRI_ACTION_ARGS = {
"set-column-width": {
args: [{ name: "value", type: "text", label: "Width", placeholder: "+10%, -10%, 50%" }]
},
@@ -213,13 +450,257 @@ const ACTION_ARGS = {
]
},
"screenshot-window": {
args: [
{ name: "show-pointer", type: "bool", label: "Show pointer" },
{ name: "write-to-disk", type: "bool", label: "Save to disk" }
]
args: [{ name: "write-to-disk", type: "bool", label: "Save to disk" }]
}
};
const MANGOWC_ACTION_ARGS = {
"view": {
args: [
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
]
},
"tag": {
args: [
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
]
},
"tagsilent": {
args: [
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
]
},
"toggletag": {
args: [
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
]
},
"toggleview": {
args: [
{ name: "tag", type: "number", label: "Tag", placeholder: "1-9" },
{ name: "monitor", type: "number", label: "Monitor", placeholder: "0", default: "0" }
]
},
"comboview": {
args: [{ name: "tags", type: "text", label: "Tags", placeholder: "1,2,3" }]
},
"setlayout": {
args: [{ name: "layout", type: "text", label: "Layout", placeholder: "tile, monocle, grid, deck" }]
},
"set_proportion": {
args: [{ name: "value", type: "text", label: "Proportion", placeholder: "0.5, +0.1, -0.1" }]
},
"setmfact": {
args: [{ name: "value", type: "text", label: "Factor", placeholder: "+0.05, -0.05" }]
},
"incgaps": {
args: [{ name: "value", type: "number", label: "Amount", placeholder: "+5, -5" }]
},
"movewin": {
args: [{ name: "value", type: "text", label: "Position", placeholder: "x,y or +10,+10" }]
},
"resizewin": {
args: [{ name: "value", type: "text", label: "Size", placeholder: "w,h or +10,+10" }]
},
"setkeymode": {
args: [{ name: "mode", type: "text", label: "Mode", placeholder: "default, custom" }]
},
"setoption": {
args: [{ name: "option", type: "text", label: "Option", placeholder: "option_name value" }]
},
"toggle_name_scratchpad": {
args: [{ name: "name", type: "text", label: "Name", placeholder: "scratchpad name" }]
},
"incnmaster": {
args: [{ name: "value", type: "number", label: "Amount", placeholder: "+1, -1" }]
}
};
const HYPRLAND_ACTION_ARGS = {
"workspace": {
args: [{ name: "value", type: "text", label: "Workspace", placeholder: "1, +1, -1, name:..." }]
},
"movetoworkspace": {
args: [
{ name: "workspace", type: "text", label: "Workspace", placeholder: "1, +1, special:name" },
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
]
},
"movetoworkspacesilent": {
args: [
{ name: "workspace", type: "text", label: "Workspace", placeholder: "1, +1, special:name" },
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
]
},
"focusworkspaceoncurrentmonitor": {
args: [{ name: "value", type: "text", label: "Workspace", placeholder: "1, +1, name:..." }]
},
"togglespecialworkspace": {
args: [{ name: "name", type: "text", label: "Name (optional)", placeholder: "scratchpad" }]
},
"focusmonitor": {
args: [{ name: "value", type: "text", label: "Monitor", placeholder: "l, r, +1, DP-1" }]
},
"movecurrentworkspacetomonitor": {
args: [{ name: "monitor", type: "text", label: "Monitor", placeholder: "l, r, DP-1" }]
},
"moveworkspacetomonitor": {
args: [
{ name: "workspace", type: "text", label: "Workspace", placeholder: "1, name:..." },
{ name: "monitor", type: "text", label: "Monitor", placeholder: "DP-1" }
]
},
"swapactiveworkspaces": {
args: [
{ name: "monitor1", type: "text", label: "Monitor 1", placeholder: "DP-1" },
{ name: "monitor2", type: "text", label: "Monitor 2", placeholder: "DP-2" }
]
},
"renameworkspace": {
args: [
{ name: "id", type: "number", label: "Workspace ID", placeholder: "1" },
{ name: "name", type: "text", label: "New Name", placeholder: "work" }
]
},
"fullscreen": {
args: [{ name: "mode", type: "text", label: "Mode", placeholder: "0=full, 1=max, 2=fake" }]
},
"fullscreenstate": {
args: [
{ name: "internal", type: "text", label: "Internal", placeholder: "-1, 0, 1, 2, 3" },
{ name: "client", type: "text", label: "Client", placeholder: "-1, 0, 1, 2, 3" }
]
},
"resizeactive": {
args: [{ name: "value", type: "text", label: "Size", placeholder: "10 -10, 20% 0" }]
},
"moveactive": {
args: [{ name: "value", type: "text", label: "Position", placeholder: "10 -10, exact 100 100" }]
},
"resizewindowpixel": {
args: [
{ name: "size", type: "text", label: "Size", placeholder: "100 100" },
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }
]
},
"movewindowpixel": {
args: [
{ name: "position", type: "text", label: "Position", placeholder: "100 100" },
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }
]
},
"splitratio": {
args: [{ name: "value", type: "text", label: "Ratio", placeholder: "+0.1, -0.1, exact 0.5" }]
},
"closewindow": {
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
},
"killwindow": {
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
},
"focuswindow": {
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
},
"tagwindow": {
args: [
{ name: "tag", type: "text", label: "Tag", placeholder: "+mytag, -mytag" },
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
]
},
"alterzorder": {
args: [
{ name: "zheight", type: "text", label: "Z-Height", placeholder: "top, bottom" },
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
]
},
"setprop": {
args: [
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" },
{ name: "property", type: "text", label: "Property", placeholder: "opaque, alpha..." },
{ name: "value", type: "text", label: "Value", placeholder: "1, toggle" }
]
},
"signal": {
args: [{ name: "signal", type: "number", label: "Signal", placeholder: "9" }]
},
"signalwindow": {
args: [
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" },
{ name: "signal", type: "number", label: "Signal", placeholder: "9" }
]
},
"submap": {
args: [{ name: "name", type: "text", label: "Submap Name", placeholder: "resize, reset" }]
},
"global": {
args: [{ name: "name", type: "text", label: "Shortcut Name", placeholder: "app:action" }]
},
"event": {
args: [{ name: "data", type: "text", label: "Event Data", placeholder: "custom data" }]
},
"pass": {
args: [{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }]
},
"sendshortcut": {
args: [
{ name: "mod", type: "text", label: "Modifier", placeholder: "SUPER, ALT" },
{ name: "key", type: "text", label: "Key", placeholder: "F4" },
{ name: "window", type: "text", label: "Window (optional)", placeholder: "class:^(app)$" }
]
},
"sendkeystate": {
args: [
{ name: "mod", type: "text", label: "Modifier", placeholder: "SUPER" },
{ name: "key", type: "text", label: "Key", placeholder: "a" },
{ name: "state", type: "text", label: "State", placeholder: "down, repeat, up" },
{ name: "window", type: "text", label: "Window", placeholder: "class:^(app)$" }
]
},
"forceidle": {
args: [{ name: "seconds", type: "number", label: "Seconds", placeholder: "300" }]
},
"movecursortocorner": {
args: [{ name: "corner", type: "number", label: "Corner", placeholder: "0-3 (BL, BR, TR, TL)" }]
},
"movecursor": {
args: [
{ name: "x", type: "number", label: "X", placeholder: "100" },
{ name: "y", type: "number", label: "Y", placeholder: "100" }
]
},
"changegroupactive": {
args: [{ name: "direction", type: "text", label: "Direction/Index", placeholder: "f, b, or index" }]
},
"movefocus": {
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
},
"movewindow": {
args: [{ name: "direction", type: "text", label: "Direction/Monitor", placeholder: "l, r, mon:DP-1" }]
},
"swapwindow": {
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
},
"moveintogroup": {
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
},
"movewindoworgroup": {
args: [{ name: "direction", type: "text", label: "Direction", placeholder: "l, r, u, d" }]
},
"cyclenext": {
args: [{ name: "options", type: "text", label: "Options", placeholder: "prev, tiled, floating" }]
}
};
const ACTION_ARGS = {
niri: NIRI_ACTION_ARGS,
mangowc: MANGOWC_ACTION_ARGS,
hyprland: HYPRLAND_ACTION_ARGS
};
const DMS_ACTION_ARGS = {
"audio increment": {
base: "spawn dms ipc call audio increment",
@@ -287,12 +768,18 @@ function getDmsActions(isNiri, isHyprland) {
return result;
}
function getCompositorCategories() {
return Object.keys(COMPOSITOR_ACTIONS);
function getCompositorCategories(compositor) {
var actions = COMPOSITOR_ACTIONS[compositor];
if (!actions)
return [];
return Object.keys(actions);
}
function getCompositorActions(category) {
return COMPOSITOR_ACTIONS[category] || [];
function getCompositorActions(compositor, category) {
var actions = COMPOSITOR_ACTIONS[compositor];
if (!actions)
return [];
return actions[category] || [];
}
function getCategoryOrder() {
@@ -307,9 +794,12 @@ function findDmsAction(actionId) {
return null;
}
function findCompositorAction(actionId) {
for (const cat in COMPOSITOR_ACTIONS) {
const acts = COMPOSITOR_ACTIONS[cat];
function findCompositorAction(compositor, actionId) {
var actions = COMPOSITOR_ACTIONS[compositor];
if (!actions)
return null;
for (const cat in actions) {
const acts = actions[cat];
for (let i = 0; i < acts.length; i++) {
if (acts[i].id === actionId)
return acts[i];
@@ -318,7 +808,7 @@ function findCompositorAction(actionId) {
return null;
}
function getActionLabel(action) {
function getActionLabel(action, compositor) {
if (!action)
return "";
@@ -326,10 +816,15 @@ function getActionLabel(action) {
if (dmsAct)
return dmsAct.label;
var base = action.split(" ")[0];
var compAct = findCompositorAction(base);
if (compAct)
return compAct.label;
if (compositor) {
var compAct = findCompositorAction(compositor, action);
if (compAct)
return compAct.label;
var base = action.split(" ")[0];
compAct = findCompositorAction(compositor, base);
if (compAct)
return compAct.label;
}
if (action.startsWith("spawn sh -c "))
return action.slice(12).replace(/^["']|["']$/g, "");
@@ -343,7 +838,7 @@ function getActionType(action) {
return "compositor";
if (action.startsWith("spawn dms ipc call "))
return "dms";
if (action.startsWith("spawn sh -c ") || action.startsWith("spawn bash -c "))
if (/^spawn \w+ -c /.test(action) || action.startsWith("spawn_shell "))
return "shell";
if (action.startsWith("spawn "))
return "spawn";
@@ -364,16 +859,21 @@ function isValidAction(action) {
case "spawn ":
case "spawn sh -c \"\"":
case "spawn sh -c ''":
case "spawn_shell":
case "spawn_shell ":
return false;
}
return true;
}
function isKnownCompositorAction(action) {
if (!action)
function isKnownCompositorAction(compositor, action) {
if (!action || !compositor)
return false;
var found = findCompositorAction(compositor, action);
if (found)
return true;
var base = action.split(" ")[0];
return findCompositorAction(base) !== null;
return findCompositorAction(compositor, base) !== null;
}
function buildSpawnAction(command, args) {
@@ -385,10 +885,13 @@ function buildSpawnAction(command, args) {
return "spawn " + parts.join(" ");
}
function buildShellAction(shellCmd) {
function buildShellAction(compositor, shellCmd, shell) {
if (!shellCmd)
return "";
return "spawn sh -c \"" + shellCmd.replace(/"/g, "\\\"") + "\"";
if (compositor === "mangowc")
return "spawn_shell " + shellCmd;
var shellBin = shell || "sh";
return "spawn " + shellBin + " -c \"" + shellCmd.replace(/"/g, "\\\"") + "\"";
}
function parseSpawnCommand(action) {
@@ -405,21 +908,33 @@ function parseSpawnCommand(action) {
function parseShellCommand(action) {
if (!action)
return "";
if (!action.startsWith("spawn sh -c "))
return "";
var content = action.slice(12);
if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'")))
content = content.slice(1, -1);
return content.replace(/\\"/g, "\"");
var match = action.match(/^spawn (\w+) -c (.+)$/);
if (match) {
var content = match[2];
if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'")))
content = content.slice(1, -1);
return content.replace(/\\"/g, "\"");
}
if (action.startsWith("spawn_shell "))
return action.slice(12);
return "";
}
function getActionArgConfig(action) {
function getShellFromAction(action) {
if (!action)
return "sh";
var match = action.match(/^spawn (\w+) -c /);
return match ? match[1] : "sh";
}
function getActionArgConfig(compositor, action) {
if (!action)
return null;
var baseAction = action.split(" ")[0];
if (ACTION_ARGS[baseAction])
return { type: "compositor", base: baseAction, config: ACTION_ARGS[baseAction] };
var compositorArgs = ACTION_ARGS[compositor];
if (compositorArgs && compositorArgs[baseAction])
return { type: "compositor", base: baseAction, config: compositorArgs[baseAction] };
for (var key in DMS_ACTION_ARGS) {
if (action.startsWith(DMS_ACTION_ARGS[key].base))
@@ -429,7 +944,7 @@ function getActionArgConfig(action) {
return null;
}
function parseCompositorActionArgs(action) {
function parseCompositorActionArgs(compositor, action) {
if (!action)
return { base: "", args: {} };
@@ -437,44 +952,144 @@ function parseCompositorActionArgs(action) {
var base = parts[0];
var args = {};
if (!ACTION_ARGS[base])
var compositorArgs = ACTION_ARGS[compositor];
if (!compositorArgs || !compositorArgs[base])
return { base: action, args: {} };
var argConfig = compositorArgs[base];
var argParts = parts.slice(1);
switch (base) {
case "move-column-to-workspace":
for (var i = 0; i < argParts.length; i++) {
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
args.focus = argParts[i] === "focus=true";
} else if (!args.index) {
args.index = argParts[i];
switch (compositor) {
case "niri":
switch (base) {
case "move-column-to-workspace":
for (var i = 0; i < argParts.length; i++) {
if (argParts[i] === "focus=true" || argParts[i] === "focus=false") {
args.focus = argParts[i] === "focus=true";
} else if (!args.index) {
args.index = argParts[i];
}
}
break;
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
for (var k = 0; k < argParts.length; k++) {
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
args.focus = argParts[k] === "focus=true";
}
break;
default:
if (base.startsWith("screenshot")) {
for (var j = 0; j < argParts.length; j++) {
var kv = argParts[j].split("=");
if (kv.length === 2)
args[kv[0]] = kv[1] === "true";
}
} else if (argParts.length > 0) {
args.value = argParts.join(" ");
}
}
break;
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
for (var k = 0; k < argParts.length; k++) {
if (argParts[k] === "focus=true" || argParts[k] === "focus=false")
args.focus = argParts[k] === "focus=true";
case "mangowc":
if (argConfig.args && argConfig.args.length > 0 && argParts.length > 0) {
var paramStr = argParts.join(" ");
var paramValues = paramStr.split(",");
for (var m = 0; m < argConfig.args.length && m < paramValues.length; m++) {
args[argConfig.args[m].name] = paramValues[m];
}
}
break;
case "hyprland":
if (argConfig.args && argConfig.args.length > 0) {
switch (base) {
case "resizewindowpixel":
case "movewindowpixel":
var commaIdx = argParts.join(" ").indexOf(",");
if (commaIdx !== -1) {
var fullStr = argParts.join(" ");
args[argConfig.args[0].name] = fullStr.substring(0, commaIdx);
args[argConfig.args[1].name] = fullStr.substring(commaIdx + 1);
} else if (argParts.length > 0) {
args[argConfig.args[0].name] = argParts.join(" ");
}
break;
case "movetoworkspace":
case "movetoworkspacesilent":
case "tagwindow":
case "alterzorder":
if (argParts.length >= 2) {
args[argConfig.args[0].name] = argParts[0];
args[argConfig.args[1].name] = argParts.slice(1).join(" ");
} else if (argParts.length === 1) {
args[argConfig.args[0].name] = argParts[0];
}
break;
case "moveworkspacetomonitor":
case "swapactiveworkspaces":
case "renameworkspace":
case "fullscreenstate":
case "movecursor":
if (argParts.length >= 2) {
args[argConfig.args[0].name] = argParts[0];
args[argConfig.args[1].name] = argParts[1];
} else if (argParts.length === 1) {
args[argConfig.args[0].name] = argParts[0];
}
break;
case "setprop":
if (argParts.length >= 3) {
args.window = argParts[0];
args.property = argParts[1];
args.value = argParts.slice(2).join(" ");
} else if (argParts.length === 2) {
args.window = argParts[0];
args.property = argParts[1];
}
break;
case "sendshortcut":
if (argParts.length >= 3) {
args.mod = argParts[0];
args.key = argParts[1];
args.window = argParts.slice(2).join(" ");
} else if (argParts.length >= 2) {
args.mod = argParts[0];
args.key = argParts[1];
}
break;
case "sendkeystate":
if (argParts.length >= 4) {
args.mod = argParts[0];
args.key = argParts[1];
args.state = argParts[2];
args.window = argParts.slice(3).join(" ");
}
break;
case "signalwindow":
if (argParts.length >= 2) {
args.window = argParts[0];
args.signal = argParts[1];
}
break;
default:
if (argParts.length > 0) {
if (argConfig.args.length === 1) {
args[argConfig.args[0].name] = argParts.join(" ");
} else {
args.value = argParts.join(" ");
}
}
}
}
break;
default:
if (base.startsWith("screenshot")) {
for (var j = 0; j < argParts.length; j++) {
var kv = argParts[j].split("=");
if (kv.length === 2)
args[kv[0]] = kv[1] === "true";
}
} else if (argParts.length > 0) {
if (argParts.length > 0)
args.value = argParts.join(" ");
}
}
return { base: base, args: args };
}
function buildCompositorAction(base, args) {
function buildCompositorAction(compositor, base, args) {
if (!base)
return "";
@@ -483,29 +1098,126 @@ function buildCompositorAction(base, args) {
if (!args || Object.keys(args).length === 0)
return base;
switch (base) {
case "move-column-to-workspace":
if (args.index)
parts.push(args.index);
if (args.focus === false)
parts.push("focus=false");
switch (compositor) {
case "niri":
switch (base) {
case "move-column-to-workspace":
if (args.index)
parts.push(args.index);
if (args.focus === false)
parts.push("focus=false");
break;
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
if (args.focus === false)
parts.push("focus=false");
break;
default:
switch (base) {
case "screenshot":
if (args["show-pointer"] === true)
parts.push("show-pointer=true");
else if (args["show-pointer"] === false)
parts.push("show-pointer=false");
break;
case "screenshot-screen":
if (args["show-pointer"] === true)
parts.push("show-pointer=true");
else if (args["show-pointer"] === false)
parts.push("show-pointer=false");
if (args["write-to-disk"] === true)
parts.push("write-to-disk=true");
break;
case "screenshot-window":
if (args["write-to-disk"] === true)
parts.push("write-to-disk=true");
break;
}
if (args.value) {
parts.push(args.value);
} else if (args.index) {
parts.push(args.index);
}
}
break;
case "move-column-to-workspace-down":
case "move-column-to-workspace-up":
if (args.focus === false)
parts.push("focus=false");
break;
default:
if (base.startsWith("screenshot")) {
if (args["show-pointer"] === true)
parts.push("show-pointer=true");
if (args["write-to-disk"] === true)
parts.push("write-to-disk=true");
case "mangowc":
var compositorArgs = ACTION_ARGS.mangowc;
if (compositorArgs && compositorArgs[base] && compositorArgs[base].args) {
var argConfig = compositorArgs[base].args;
var argValues = [];
for (var i = 0; i < argConfig.length; i++) {
var argDef = argConfig[i];
var val = args[argDef.name];
if (val === undefined || val === "")
val = argDef.default || "";
if (val === "" && argValues.length === 0)
continue;
argValues.push(val);
}
if (argValues.length > 0)
parts.push(argValues.join(","));
} else if (args.value) {
parts.push(args.value);
} else if (args.index) {
parts.push(args.index);
}
break;
case "hyprland":
var hyprArgs = ACTION_ARGS.hyprland;
if (hyprArgs && hyprArgs[base] && hyprArgs[base].args) {
var hyprConfig = hyprArgs[base].args;
switch (base) {
case "resizewindowpixel":
case "movewindowpixel":
if (args[hyprConfig[0].name])
parts.push(args[hyprConfig[0].name]);
if (args[hyprConfig[1].name])
parts[parts.length - 1] += "," + args[hyprConfig[1].name];
break;
case "setprop":
if (args.window)
parts.push(args.window);
if (args.property)
parts.push(args.property);
if (args.value)
parts.push(args.value);
break;
case "sendshortcut":
if (args.mod)
parts.push(args.mod);
if (args.key)
parts.push(args.key);
if (args.window)
parts.push(args.window);
break;
case "sendkeystate":
if (args.mod)
parts.push(args.mod);
if (args.key)
parts.push(args.key);
if (args.state)
parts.push(args.state);
if (args.window)
parts.push(args.window);
break;
case "signalwindow":
if (args.window)
parts.push(args.window);
if (args.signal)
parts.push(args.signal);
break;
default:
for (var j = 0; j < hyprConfig.length; j++) {
var hVal = args[hyprConfig[j].name];
if (hVal !== undefined && hVal !== "")
parts.push(hVal);
}
}
} else if (args.value) {
parts.push(args.value);
}
break;
default:
if (args.value)
parts.push(args.value);
}
return parts.join(" ");

View File

@@ -13,17 +13,16 @@ Singleton {
property var currentModalsByScreen: ({})
function openModal(modal) {
if (!modal.allowStacking) {
closeAllModalsExcept(modal);
}
if (!modal.keepPopoutsOpen) {
PopoutManager.closeAllPopouts();
}
TrayMenuManager.closeAllMenus();
const screenName = modal.effectiveScreen?.name ?? "unknown";
currentModalsByScreen[screenName] = modal;
modalChanged();
Qt.callLater(() => {
if (!modal.allowStacking)
closeAllModalsExcept(modal);
if (!modal.keepPopoutsOpen)
PopoutManager.closeAllPopouts();
TrayMenuManager.closeAllMenus();
});
}
function closeModal(modal) {

View File

@@ -46,14 +46,20 @@ Singleton {
}
function moddedAppId(appId: string): string {
if (appId === "Spotify")
return "spotify";
if (appId === "beepertexts")
return "beeper";
if (appId === "home assistant desktop")
return "homeassistant-desktop";
if (appId.includes("com.transmissionbt.transmission"))
return "transmission";
const subs = SettingsData.appIdSubstitutions || [];
for (let i = 0; i < subs.length; i++) {
const sub = subs[i];
if (sub.type === "exact" && appId === sub.pattern) {
return sub.replacement;
} else if (sub.type === "contains" && appId.includes(sub.pattern)) {
return sub.replacement;
} else if (sub.type === "regex") {
const match = appId.match(new RegExp(sub.pattern));
if (match) {
return sub.replacement.replace(/\$(\d+)/g, (_, n) => match[n] || "");
}
}
}
return appId;
}
@@ -63,8 +69,8 @@ Singleton {
}
const moddedId = moddedAppId(appId);
if (moddedId.toLowerCase().includes("steam_app")) {
return "";
if (moddedId !== appId) {
return Quickshell.iconPath(moddedId, true);
}
return desktopEntry && desktopEntry.icon ? Quickshell.iconPath(desktopEntry.icon, true) : "";

View File

@@ -118,8 +118,56 @@ Singleton {
parseSettings(greeterSessionFile.text());
return;
}
parseSettings(settingsFile.text());
_checkSessionWritable();
try {
const txt = settingsFile.text();
let obj = (txt && txt.trim()) ? JSON.parse(txt) : null;
if (obj?.brightnessLogarithmicDevices && !obj?.brightnessExponentialDevices)
obj.brightnessExponentialDevices = obj.brightnessLogarithmicDevices;
if (obj?.nightModeStartTime !== undefined) {
const parts = obj.nightModeStartTime.split(":");
obj.nightModeStartHour = parseInt(parts[0]) || 18;
obj.nightModeStartMinute = parseInt(parts[1]) || 0;
}
if (obj?.nightModeEndTime !== undefined) {
const parts = obj.nightModeEndTime.split(":");
obj.nightModeEndHour = parseInt(parts[0]) || 6;
obj.nightModeEndMinute = parseInt(parts[1]) || 0;
}
const oldVersion = obj?.configVersion ?? 0;
if (obj && oldVersion === 0)
migrateFromUndefinedToV1(obj);
if (obj && oldVersion < sessionConfigVersion) {
const settingsDataRef = (typeof SettingsData !== "undefined") ? SettingsData : null;
const migrated = Store.migrateToVersion(obj, sessionConfigVersion, settingsDataRef);
if (migrated) {
_pendingMigration = migrated;
obj = migrated;
}
}
Store.parse(root, obj);
_loadedSessionSnapshot = getCurrentSessionJson();
_hasLoaded = true;
if (!isGreeterMode && typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme();
if (typeof WallpaperCyclingService !== "undefined")
WallpaperCyclingService.updateCyclingState();
_checkSessionWritable();
} catch (e) {
_parseError = true;
const msg = e.message;
console.error("SessionData: Failed to parse session.json - file will not be overwritten. Error:", msg);
Qt.callLater(() => ToastService.showError(I18n.tr("Failed to parse session.json"), msg));
}
}
function _checkSessionWritable() {
@@ -127,11 +175,19 @@ Singleton {
}
function _onWritableCheckComplete(writable) {
const wasReadOnly = _isReadOnly;
_isReadOnly = !writable;
if (_isReadOnly) {
console.info("SessionData: session.json is read-only (NixOS home-manager mode)");
} else if (_pendingMigration) {
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
_hasUnsavedChanges = _checkForUnsavedChanges();
if (!wasReadOnly)
console.info("SessionData: session.json is now read-only");
} else {
_loadedSessionSnapshot = getCurrentSessionJson();
_hasUnsavedChanges = false;
if (wasReadOnly)
console.info("SessionData: session.json is now writable");
if (_pendingMigration)
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
}
_pendingMigration = null;
}
@@ -150,34 +206,27 @@ Singleton {
function parseSettings(content) {
_parseError = false;
try {
if (!content || !content.trim()) {
_parseError = true;
return;
}
let obj = (content && content.trim()) ? JSON.parse(content) : null;
let obj = JSON.parse(content);
if (obj.brightnessLogarithmicDevices && !obj.brightnessExponentialDevices) {
if (obj?.brightnessLogarithmicDevices && !obj?.brightnessExponentialDevices)
obj.brightnessExponentialDevices = obj.brightnessLogarithmicDevices;
}
if (obj.nightModeStartTime !== undefined) {
if (obj?.nightModeStartTime !== undefined) {
const parts = obj.nightModeStartTime.split(":");
obj.nightModeStartHour = parseInt(parts[0]) || 18;
obj.nightModeStartMinute = parseInt(parts[1]) || 0;
}
if (obj.nightModeEndTime !== undefined) {
if (obj?.nightModeEndTime !== undefined) {
const parts = obj.nightModeEndTime.split(":");
obj.nightModeEndHour = parseInt(parts[0]) || 6;
obj.nightModeEndMinute = parseInt(parts[1]) || 0;
}
const oldVersion = obj.configVersion ?? 0;
if (oldVersion === 0) {
const oldVersion = obj?.configVersion ?? 0;
if (obj && oldVersion === 0)
migrateFromUndefinedToV1(obj);
}
if (oldVersion < sessionConfigVersion) {
if (obj && oldVersion < sessionConfigVersion) {
const settingsDataRef = (typeof SettingsData !== "undefined") ? SettingsData : null;
const migrated = Store.migrateToVersion(obj, sessionConfigVersion, settingsDataRef);
if (migrated) {
@@ -188,22 +237,14 @@ Singleton {
Store.parse(root, obj);
if (wallpaperPath && wallpaperPath.startsWith("we:")) {
console.warn("WallpaperEngine wallpaper detected, resetting wallpaper");
wallpaperPath = "";
Quickshell.execDetached(["notify-send", "-u", "critical", "-a", "DMS", "-i", "dialog-warning", "WallpaperEngine Support Moved", "WallpaperEngine support has been moved to a plugin. Please enable the Linux Wallpaper Engine plugin in Settings → Plugins to continue using WallpaperEngine."]);
}
_hasLoaded = true;
_loadedSessionSnapshot = getCurrentSessionJson();
_hasLoaded = true;
if (!isGreeterMode && typeof Theme !== "undefined") {
if (!isGreeterMode && typeof Theme !== "undefined")
Theme.generateSystemThemesFromCurrentTheme();
}
if (typeof WallpaperCyclingService !== "undefined") {
if (typeof WallpaperCyclingService !== "undefined")
WallpaperCyclingService.updateCyclingState();
}
} catch (e) {
_parseError = true;
const msg = e.message;
@@ -215,11 +256,9 @@ Singleton {
function saveSettings() {
if (isGreeterMode || _parseError || !_hasLoaded)
return;
if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges();
return;
}
settingsFile.setText(getCurrentSessionJson());
if (_isReadOnly)
_checkSessionWritable();
}
function migrateFromUndefinedToV1(settings) {
@@ -330,7 +369,7 @@ Singleton {
if (typeof SettingsData !== "undefined" && SettingsData.theme) {
Theme.switchTheme(SettingsData.theme);
} else {
Theme.switchTheme("blue");
Theme.switchTheme("purple");
}
}
}
@@ -944,8 +983,9 @@ Singleton {
id: settingsFile
path: isGreeterMode ? "" : StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/DankMaterialShell/session.json"
blockLoading: isGreeterMode
blockLoading: true
blockWrites: true
atomicWrites: true
watchChanges: !isGreeterMode
onLoaded: {
if (!isGreeterMode) {
@@ -953,6 +993,10 @@ Singleton {
parseSettings(settingsFile.text());
}
}
onSaveFailed: error => {
root._isReadOnly = true;
root._hasUnsavedChanges = root._checkForUnsavedChanges();
}
}
FileView {

View File

@@ -1,5 +1,5 @@
pragma Singleton
pragma ComponentBehavior
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
@@ -62,12 +62,28 @@ Singleton {
property bool _hasUnsavedChanges: false
property var _loadedSettingsSnapshot: null
property var pluginSettings: ({})
property var builtInPluginSettings: ({})
function getBuiltInPluginSetting(pluginId, key, defaultValue) {
if (!builtInPluginSettings[pluginId])
return defaultValue;
return builtInPluginSettings[pluginId][key] !== undefined ? builtInPluginSettings[pluginId][key] : defaultValue;
}
function setBuiltInPluginSetting(pluginId, key, value) {
const updated = JSON.parse(JSON.stringify(builtInPluginSettings));
if (!updated[pluginId])
updated[pluginId] = {};
updated[pluginId][key] = value;
builtInPluginSettings = updated;
saveSettings();
}
property alias dankBarLeftWidgetsModel: leftWidgetsModel
property alias dankBarCenterWidgetsModel: centerWidgetsModel
property alias dankBarRightWidgetsModel: rightWidgetsModel
property string currentThemeName: "blue"
property string currentThemeName: "purple"
property string currentThemeCategory: "generic"
property string customThemeFile: ""
property var registryThemeVariants: ({})
@@ -81,6 +97,13 @@ Singleton {
property real cornerRadius: 12
property int niriLayoutGapsOverride: -1
property int niriLayoutRadiusOverride: -1
property int niriLayoutBorderSize: -1
property int hyprlandLayoutGapsOverride: -1
property int hyprlandLayoutRadiusOverride: -1
property int hyprlandLayoutBorderSize: -1
property int mangoLayoutGapsOverride: -1
property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1
property bool use24HourClock: true
property bool showSeconds: false
@@ -177,10 +200,16 @@ Singleton {
property bool showWorkspaceApps: false
property bool groupWorkspaceApps: true
property int maxWorkspaceIcons: 3
property bool workspacesPerMonitor: true
property bool workspaceFollowFocus: false
property bool showOccupiedWorkspacesOnly: false
property bool reverseScrolling: false
property bool dwlShowAllTags: false
property string workspaceColorMode: "default"
property string workspaceUnfocusedColorMode: "default"
property string workspaceUrgentColorMode: "default"
property bool workspaceFocusedBorderEnabled: false
property string workspaceFocusedBorderColor: "primary"
property int workspaceFocusedBorderThickness: 2
property var workspaceNameIcons: ({})
property bool waveProgressEnabled: true
property bool scrollTitleEnabled: true
@@ -192,6 +221,7 @@ Singleton {
property bool keyboardLayoutNameCompactMode: false
property bool runningAppsCurrentWorkspace: false
property bool runningAppsGroupByApp: false
property var appIdSubstitutions: []
property string centeringMode: "index"
property string clockDateFormat: ""
property string lockDateFormat: ""
@@ -223,6 +253,25 @@ Singleton {
property bool qt6ctAvailable: false
property bool gtkAvailable: false
property var cursorSettings: ({
"theme": "System Default",
"size": 24,
"niri": {
"hideWhenTyping": false,
"hideAfterInactiveMs": 0
},
"hyprland": {
"hideOnKeyPress": false,
"hideOnTouch": false,
"inactiveTimeout": 0
},
"dwl": {
"cursorHideTimeout": 0
}
})
property var availableCursorThemes: ["System Default"]
property string systemDefaultCursorTheme: ""
property string launcherLogoMode: "apps"
property string launcherLogoCustomPath: ""
property string launcherLogoColorOverride: ""
@@ -276,9 +325,9 @@ Singleton {
property int batteryChargeLimit: 100
property bool lockBeforeSuspend: false
property bool loginctlLockIntegration: true
property bool fadeToLockEnabled: false
property bool fadeToLockEnabled: true
property int fadeToLockGracePeriod: 5
property bool fadeToDpmsEnabled: false
property bool fadeToDpmsEnabled: true
property int fadeToDpmsGracePeriod: 5
property string launchPrefix: ""
property var brightnessDevicePins: ({})
@@ -295,6 +344,8 @@ Singleton {
property bool runDmsMatugenTemplates: true
property bool matugenTemplateGtk: true
property bool matugenTemplateNiri: true
property bool matugenTemplateHyprland: true
property bool matugenTemplateMangowc: true
property bool matugenTemplateQt5ct: true
property bool matugenTemplateQt6ct: true
property bool matugenTemplateFirefox: true
@@ -347,11 +398,13 @@ Singleton {
property bool fprintdAvailable: false
property string lockScreenActiveMonitor: "all"
property string lockScreenInactiveColor: "#000000"
property int lockScreenNotificationMode: 0
property bool hideBrightnessSlider: false
property int notificationTimeoutLow: 5000
property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0
property bool notificationCompactMode: false
property int notificationPopupPosition: SettingsData.Position.Top
property bool notificationHistoryEnabled: true
property int notificationHistoryMaxCount: 50
@@ -434,7 +487,11 @@ Singleton {
"maximizeDetection": true,
"scrollEnabled": true,
"scrollXBehavior": "column",
"scrollYBehavior": "workspace"
"scrollYBehavior": "workspace",
"shadowIntensity": 0,
"shadowOpacity": 60,
"shadowColorMode": "text",
"shadowCustomColor": "#000000"
}
]
@@ -481,6 +538,7 @@ Singleton {
property var desktopWidgetPositions: ({})
property var desktopWidgetGridSettings: ({})
property var desktopWidgetInstances: []
property var desktopWidgetGroups: []
function getDesktopWidgetGridSetting(screenKey, property, defaultValue) {
const val = desktopWidgetGridSettings?.[screenKey]?.[property];
@@ -632,6 +690,38 @@ Singleton {
saveSettings();
}
function syncDesktopWidgetPositionToAllScreens(instanceId) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1)
return;
const positions = instances[idx].positions || {};
const screenKeys = Object.keys(positions).filter(k => k !== "_synced");
if (screenKeys.length === 0)
return;
const sourceKey = screenKeys[0];
const sourcePos = positions[sourceKey];
if (!sourcePos)
return;
const screen = Array.from(Quickshell.screens.values()).find(s => getScreenDisplayName(s) === sourceKey);
if (!screen)
return;
const screenW = screen.width;
const screenH = screen.height;
const synced = {};
if (sourcePos.x !== undefined)
synced.x = sourcePos.x / screenW;
if (sourcePos.y !== undefined)
synced.y = sourcePos.y / screenH;
if (sourcePos.width !== undefined)
synced.width = sourcePos.width;
if (sourcePos.height !== undefined)
synced.height = sourcePos.height;
instances[idx].positions["_synced"] = synced;
desktopWidgetInstances = instances;
saveSettings();
}
function duplicateDesktopWidgetInstance(instanceId) {
const source = getDesktopWidgetInstance(instanceId);
if (!source)
@@ -664,6 +754,110 @@ Singleton {
return (desktopWidgetInstances || []).filter(inst => inst.enabled);
}
function moveDesktopWidgetInstance(instanceId, direction) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1)
return false;
const targetIdx = direction === "up" ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= instances.length)
return false;
const temp = instances[idx];
instances[idx] = instances[targetIdx];
instances[targetIdx] = temp;
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function reorderDesktopWidgetInstance(instanceId, newIndex) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const idx = instances.findIndex(inst => inst.id === instanceId);
if (idx === -1 || newIndex < 0 || newIndex >= instances.length)
return false;
const [item] = instances.splice(idx, 1);
instances.splice(newIndex, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function reorderDesktopWidgetInstanceInGroup(instanceId, groupId, newIndexInGroup) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
const groups = desktopWidgetGroups || [];
const groupMatches = inst => {
if (groupId === null)
return !inst.group || !groups.some(g => g.id === inst.group);
return inst.group === groupId;
};
const groupInstances = instances.filter(groupMatches);
const currentGroupIdx = groupInstances.findIndex(inst => inst.id === instanceId);
if (currentGroupIdx === -1 || currentGroupIdx === newIndexInGroup)
return false;
if (newIndexInGroup < 0 || newIndexInGroup >= groupInstances.length)
return false;
const globalIdx = instances.findIndex(inst => inst.id === instanceId);
if (globalIdx === -1)
return false;
const [item] = instances.splice(globalIdx, 1);
const targetInstance = groupInstances[newIndexInGroup];
let targetGlobalIdx = instances.findIndex(inst => inst.id === targetInstance.id);
if (newIndexInGroup > currentGroupIdx)
targetGlobalIdx++;
instances.splice(targetGlobalIdx, 0, item);
desktopWidgetInstances = instances;
saveSettings();
return true;
}
function createDesktopWidgetGroup(name) {
const id = "dwg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
const group = {
id: id,
name: name,
collapsed: false
};
const groups = JSON.parse(JSON.stringify(desktopWidgetGroups || []));
groups.push(group);
desktopWidgetGroups = groups;
saveSettings();
return group;
}
function updateDesktopWidgetGroup(groupId, updates) {
const groups = JSON.parse(JSON.stringify(desktopWidgetGroups || []));
const idx = groups.findIndex(g => g.id === groupId);
if (idx === -1)
return;
Object.assign(groups[idx], updates);
desktopWidgetGroups = groups;
saveSettings();
}
function removeDesktopWidgetGroup(groupId) {
const instances = JSON.parse(JSON.stringify(desktopWidgetInstances || []));
for (let i = 0; i < instances.length; i++) {
if (instances[i].group === groupId)
instances[i].group = null;
}
desktopWidgetInstances = instances;
const groups = (desktopWidgetGroups || []).filter(g => g.id !== groupId);
desktopWidgetGroups = groups;
saveSettings();
}
function getDesktopWidgetGroup(groupId) {
return (desktopWidgetGroups || []).find(g => g.id === groupId) || null;
}
function getDesktopWidgetInstancesByGroup(groupId) {
return (desktopWidgetInstances || []).filter(inst => inst.group === groupId);
}
function getUngroupedDesktopWidgetInstances() {
return (desktopWidgetInstances || []).filter(inst => !inst.group);
}
signal forceDankBarLayoutRefresh
signal forceDockLayoutRefresh
signal widgetDataChanged
@@ -699,10 +893,15 @@ Singleton {
}
}
function updateNiriLayout() {
if (typeof NiriService !== "undefined" && typeof CompositorService !== "undefined" && CompositorService.isNiri) {
function updateCompositorLayout() {
if (typeof CompositorService === "undefined")
return;
if (CompositorService.isNiri && typeof NiriService !== "undefined")
NiriService.generateNiriLayoutConfig();
}
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined")
HyprlandService.generateLayoutConfig();
if (CompositorService.isDwl && typeof DwlService !== "undefined")
DwlService.generateLayoutConfig();
}
function applyStoredIconTheme() {
@@ -778,9 +977,10 @@ Singleton {
readonly property var _hooks: ({
"applyStoredTheme": applyStoredTheme,
"regenSystemThemes": regenSystemThemes,
"updateNiriLayout": updateNiriLayout,
"updateCompositorLayout": updateCompositorLayout,
"applyStoredIconTheme": applyStoredIconTheme,
"updateBarConfigs": updateBarConfigs
"updateBarConfigs": updateBarConfigs,
"updateCompositorCursor": updateCompositorCursor
})
function set(key, value) {
@@ -817,6 +1017,7 @@ Singleton {
_hasLoaded = true;
applyStoredTheme();
applyStoredIconTheme();
updateCompositorCursor();
Processes.detectQtTools();
_checkSettingsWritable();
@@ -840,11 +1041,19 @@ Singleton {
}
function _onWritableCheckComplete(writable) {
const wasReadOnly = _isReadOnly;
_isReadOnly = !writable;
if (_isReadOnly) {
console.info("SettingsData: settings.json is read-only (NixOS home-manager mode)");
} else if (_pendingMigration) {
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
_hasUnsavedChanges = _checkForUnsavedChanges();
if (!wasReadOnly)
console.info("SettingsData: settings.json is now read-only");
} else {
_loadedSettingsSnapshot = JSON.stringify(Store.toJson(root));
_hasUnsavedChanges = false;
if (wasReadOnly)
console.info("SettingsData: settings.json is now writable");
if (_pendingMigration)
settingsFile.setText(JSON.stringify(_pendingMigration, null, 2));
}
_pendingMigration = null;
}
@@ -889,11 +1098,9 @@ Singleton {
function saveSettings() {
if (_loading || _parseError || !_hasLoaded)
return;
if (_isReadOnly) {
_hasUnsavedChanges = _checkForUnsavedChanges();
return;
}
settingsFile.setText(JSON.stringify(Store.toJson(root), null, 2));
if (_isReadOnly)
_checkSettingsWritable();
}
function savePluginSettings() {
@@ -941,6 +1148,46 @@ Singleton {
});
}
function detectAvailableCursorThemes() {
const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS") || "";
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
const homeDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.HomeLocation));
const dataDirs = xdgDataDirs.trim() !== "" ? xdgDataDirs.split(":").concat([localData]) : ["/usr/share", "/usr/local/share", localData];
const cursorPaths = dataDirs.map(d => d + "/icons").concat([homeDir + "/.icons", homeDir + "/.local/share/icons"]);
const pathsArg = cursorPaths.join(" ");
const script = `
echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface cursor-theme 2>/dev/null | sed "s/'//g" || echo '')"
for dir in ${pathsArg}; do
[ -d "$dir" ] || continue
for theme in "$dir"/*/; do
[ -d "$theme" ] || continue
[ -d "$theme/cursors" ] || continue
basename "$theme"
done
done | grep -v '^icons$' | grep -v '^default$' | sort -u
`;
Proc.runCommand("detectCursorThemes", ["sh", "-c", script], (output, exitCode) => {
const themes = ["System Default"];
if (output && output.trim()) {
const lines = output.trim().split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith("SYSDEFAULT:")) {
systemDefaultCursorTheme = line.substring(11).trim();
continue;
}
if (line)
themes.push(line);
}
}
availableCursorThemes = themes;
});
}
function getEffectiveTimeFormat() {
if (use24HourClock) {
return showSeconds ? "hh:mm:ss" : "hh:mm";
@@ -1453,7 +1700,7 @@ Singleton {
function setCornerRadius(radius) {
set("cornerRadius", radius);
NiriService.generateNiriLayoutConfig();
updateCompositorLayout();
}
function setWeatherLocation(displayName, coordinates) {
@@ -1469,6 +1716,94 @@ Singleton {
Theme.generateSystemThemesFromCurrentTheme();
}
function setCursorTheme(themeName) {
const updated = JSON.parse(JSON.stringify(cursorSettings));
updated.theme = themeName;
cursorSettings = updated;
saveSettings();
updateCompositorCursor();
}
function setCursorSize(size) {
const updated = JSON.parse(JSON.stringify(cursorSettings));
updated.size = size;
cursorSettings = updated;
saveSettings();
updateCompositorCursor();
}
// This solution for xwayland cursor themes is from the xwls discussion:
// https://github.com/Supreeeme/xwayland-satellite/issues/104
// no idea if this matters on other compositors but we also set XCURSOR stuff in the launcher
function updateCompositorCursor() {
updateXResources();
if (typeof CompositorService === "undefined")
return;
if (CompositorService.isNiri && typeof NiriService !== "undefined") {
NiriService.generateNiriCursorConfig();
return;
}
if (CompositorService.isHyprland && typeof HyprlandService !== "undefined") {
HyprlandService.generateCursorConfig();
return;
}
if (CompositorService.isDwl && typeof DwlService !== "undefined") {
DwlService.generateCursorConfig();
return;
}
}
function updateXResources() {
const homeDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.HomeLocation));
const xresourcesPath = homeDir + "/.Xresources";
const themeName = cursorSettings.theme === "System Default" ? systemDefaultCursorTheme : cursorSettings.theme;
const size = cursorSettings.size || 24;
if (!themeName)
return;
const script = `
xresources_file="${xresourcesPath}"
temp_file="\${xresources_file}.tmp.$$"
theme_name="${themeName}"
cursor_size="${size}"
if [ -f "$xresources_file" ]; then
grep -v '^[[:space:]]*Xcursor\\.theme:' "$xresources_file" | grep -v '^[[:space:]]*Xcursor\\.size:' > "$temp_file" 2>/dev/null || true
else
touch "$temp_file"
fi
echo "Xcursor.theme: $theme_name" >> "$temp_file"
echo "Xcursor.size: $cursor_size" >> "$temp_file"
mv "$temp_file" "$xresources_file"
xrdb -merge "$xresources_file" 2>/dev/null || true
`;
Quickshell.execDetached(["sh", "-c", script]);
}
function getCursorEnvironment() {
const isSystemDefault = cursorSettings.theme === "System Default";
const isDefaultSize = !cursorSettings.size || cursorSettings.size === 24;
if (isSystemDefault && isDefaultSize)
return {};
const themeName = isSystemDefault ? "" : cursorSettings.theme;
const size = String(cursorSettings.size || 24);
const env = {};
if (!isDefaultSize) {
env["XCURSOR_SIZE"] = size;
env["HYPRCURSOR_SIZE"] = size;
}
if (themeName) {
env["XCURSOR_THEME"] = themeName;
env["HYPRCURSOR_THEME"] = themeName;
}
return env;
}
function setGtkThemingEnabled(enabled) {
set("gtkThemingEnabled", enabled);
if (enabled && typeof Theme !== "undefined") {
@@ -1535,9 +1870,7 @@ Singleton {
"spacing": spacing
});
}
if (typeof NiriService !== "undefined" && CompositorService.isNiri) {
NiriService.generateNiriLayoutConfig();
}
updateCompositorLayout();
}
function setDankBarPosition(position) {
@@ -1648,6 +1981,48 @@ Singleton {
return workspaceNameIcons[workspaceName] || null;
}
function addAppIdSubstitution(pattern, replacement, type) {
var subs = JSON.parse(JSON.stringify(appIdSubstitutions));
subs.push({
pattern: pattern,
replacement: replacement,
type: type
});
appIdSubstitutions = subs;
saveSettings();
}
function updateAppIdSubstitution(index, pattern, replacement, type) {
var subs = JSON.parse(JSON.stringify(appIdSubstitutions));
if (index < 0 || index >= subs.length)
return;
subs[index] = {
pattern: pattern,
replacement: replacement,
type: type
};
appIdSubstitutions = subs;
saveSettings();
}
function removeAppIdSubstitution(index) {
var subs = JSON.parse(JSON.stringify(appIdSubstitutions));
if (index < 0 || index >= subs.length)
return;
subs.splice(index, 1);
appIdSubstitutions = subs;
saveSettings();
}
function getDefaultAppIdSubstitutions() {
return Spec.SPEC.appIdSubstitutions.def;
}
function resetAppIdSubstitutions() {
appIdSubstitutions = JSON.parse(JSON.stringify(Spec.SPEC.appIdSubstitutions.def));
saveSettings();
}
function getRegistryThemeVariant(themeId, defaultVariant) {
var stored = registryThemeVariants[themeId];
if (typeof stored === "string")
@@ -1875,6 +2250,7 @@ Singleton {
_hasLoaded = true;
applyStoredTheme();
applyStoredIconTheme();
updateCompositorCursor();
} catch (e) {
_parseError = true;
const msg = e.message;
@@ -1889,6 +2265,10 @@ Singleton {
applyStoredTheme();
}
}
onSaveFailed: error => {
root._isReadOnly = true;
root._hasUnsavedChanges = root._checkForUnsavedChanges();
}
}
FileView {

View File

@@ -30,7 +30,7 @@ Singleton {
return useAuto ? Math.max(4, spacing) : manualValue;
}
property string currentTheme: "blue"
property string currentTheme: "purple"
property string currentThemeCategory: "generic"
property bool isLightMode: typeof SessionData !== "undefined" ? SessionData.isLightMode : false
property bool colorsFileLoadFailed: false
@@ -89,6 +89,8 @@ Singleton {
property bool qtThemingEnabled: typeof SettingsData !== "undefined" ? (SettingsData.qt5ctAvailable || SettingsData.qt6ctAvailable) : false
property var workerRunning: false
property var pendingThemeRequest: null
signal matugenCompleted(string mode, string result)
property var matugenColors: ({})
property var _pendingGenerateParams: null
@@ -196,7 +198,7 @@ Singleton {
readonly property var currentThemeData: {
if (currentTheme === "custom") {
return customThemeData || StockThemes.getThemeByName("blue", isLightMode);
return customThemeData || StockThemes.getThemeByName("purple", isLightMode);
} else if (currentTheme === dynamic) {
return {
"primary": getMatugenColor("primary", "#42a5f5"),
@@ -544,7 +546,7 @@ Singleton {
if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode)
SessionData.setLightMode(light);
if (!isGreeterMode) {
// Skip with matugen becuase, our script runner will do it.
// Skip with matugen because, our script runner will do it.
if (!matugenAvailable) {
PortalService.setLightMode(light);
}
@@ -908,6 +910,10 @@ Singleton {
skipTemplates.push("gtk");
if (!SettingsData.matugenTemplateNiri)
skipTemplates.push("niri");
if (!SettingsData.matugenTemplateHyprland)
skipTemplates.push("hyprland");
if (!SettingsData.matugenTemplateMangowc)
skipTemplates.push("mangowc");
if (!SettingsData.matugenTemplateQt5ct)
skipTemplates.push("qt5ct");
if (!SettingsData.matugenTemplateQt6ct)
@@ -1272,24 +1278,32 @@ Singleton {
onExited: exitCode => {
workerRunning = false;
const currentMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark";
if (exitCode === 0) {
switch (exitCode) {
case 0:
console.info("Theme: Matugen worker completed successfully");
} else if (exitCode === 2) {
root.matugenCompleted(currentMode, "success");
break;
case 2:
console.log("Theme: Matugen worker completed with code 2 (no changes needed)");
} else {
root.matugenCompleted(currentMode, "no-changes");
break;
default:
if (typeof ToastService !== "undefined") {
ToastService.showError("Theme worker failed (" + exitCode + ")");
}
console.warn("Theme: Matugen worker failed with exit code:", exitCode);
root.matugenCompleted(currentMode, "error");
}
if (pendingThemeRequest) {
const req = pendingThemeRequest;
pendingThemeRequest = null;
console.info("Theme: Processing queued theme request");
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
}
if (!pendingThemeRequest)
return;
const req = pendingThemeRequest;
pendingThemeRequest = null;
console.info("Theme: Processing queued theme request");
setDesiredTheme(req.kind, req.value, req.isLight, req.iconTheme, req.matugenType, req.stockColors);
}
}

View File

@@ -1,5 +1,5 @@
.pragma library
// This exists only beacause I haven't been able to get linkColor to work with MarkdownText
// This exists only because I haven't been able to get linkColor to work with MarkdownText
// May not be necessary if that's possible tbh.
function markdownToHtml(text) {
if (!text) return "";

View File

@@ -6,7 +6,7 @@ function percentToUnit(v) {
}
var SPEC = {
currentThemeName: { def: "blue", onChange: "applyStoredTheme" },
currentThemeName: { def: "purple", onChange: "applyStoredTheme" },
currentThemeCategory: { def: "generic" },
customThemeFile: { def: "" },
registryThemeVariants: { def: {} },
@@ -19,9 +19,16 @@ var SPEC = {
widgetBackgroundColor: { def: "sch" },
widgetColorMode: { def: "default" },
cornerRadius: { def: 12, onChange: "updateNiriLayout" },
niriLayoutGapsOverride: { def: -1, onChange: "updateNiriLayout" },
niriLayoutRadiusOverride: { def: -1, onChange: "updateNiriLayout" },
cornerRadius: { def: 12, onChange: "updateCompositorLayout" },
niriLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
niriLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
hyprlandLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
use24HourClock: { def: true },
showSeconds: { def: false },
@@ -87,10 +94,16 @@ var SPEC = {
showWorkspaceApps: { def: false },
maxWorkspaceIcons: { def: 3 },
groupWorkspaceApps: { def: true },
workspacesPerMonitor: { def: true },
workspaceFollowFocus: { def: false },
showOccupiedWorkspacesOnly: { def: false },
reverseScrolling: { def: false },
dwlShowAllTags: { def: false },
workspaceColorMode: { def: "default" },
workspaceUnfocusedColorMode: { def: "default" },
workspaceUrgentColorMode: { def: "default" },
workspaceFocusedBorderEnabled: { def: false },
workspaceFocusedBorderColor: { def: "primary" },
workspaceFocusedBorderThickness: { def: 2 },
workspaceNameIcons: { def: {} },
waveProgressEnabled: { def: true },
scrollTitleEnabled: { def: true },
@@ -102,6 +115,13 @@ var SPEC = {
keyboardLayoutNameCompactMode: { def: false },
runningAppsCurrentWorkspace: { def: false },
runningAppsGroupByApp: { def: false },
appIdSubstitutions: { def: [
{ pattern: "Spotify", replacement: "spotify", type: "exact" },
{ pattern: "beepertexts", replacement: "beeper", type: "exact" },
{ pattern: "home assistant desktop", replacement: "homeassistant-desktop", type: "exact" },
{ pattern: "com.transmissionbt.transmission", replacement: "transmission-gtk", type: "contains" },
{ pattern: "^steam_app_(\\d+)$", replacement: "steam_icon_$1", type: "regex" }
]},
centeringMode: { def: "index" },
clockDateFormat: { def: "" },
lockDateFormat: { def: "" },
@@ -127,6 +147,10 @@ var SPEC = {
qt6ctAvailable: { def: false, persist: false },
gtkAvailable: { def: false, persist: false },
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
availableCursorThemes: { def: ["System Default"], persist: false },
systemDefaultCursorTheme: { def: "", persist: false },
launcherLogoMode: { def: "apps" },
launcherLogoCustomPath: { def: "" },
launcherLogoColorOverride: { def: "" },
@@ -166,9 +190,9 @@ var SPEC = {
batteryChargeLimit: { def: 100 },
lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true },
fadeToLockEnabled: { def: false },
fadeToLockEnabled: { def: true },
fadeToLockGracePeriod: { def: 5 },
fadeToDpmsEnabled: { def: false },
fadeToDpmsEnabled: { def: true },
fadeToDpmsGracePeriod: { def: 5 },
launchPrefix: { def: "" },
brightnessDevicePins: { def: {} },
@@ -185,6 +209,8 @@ var SPEC = {
runDmsMatugenTemplates: { def: true },
matugenTemplateGtk: { def: true },
matugenTemplateNiri: { def: true },
matugenTemplateHyprland: { def: true },
matugenTemplateMangowc: { def: true },
matugenTemplateQt5ct: { def: true },
matugenTemplateQt6ct: { def: true },
matugenTemplateFirefox: { def: true },
@@ -236,11 +262,13 @@ var SPEC = {
fprintdAvailable: { def: false, persist: false },
lockScreenActiveMonitor: { def: "all" },
lockScreenInactiveColor: { def: "#000000" },
lockScreenNotificationMode: { def: 0 },
hideBrightnessSlider: { def: false },
notificationTimeoutLow: { def: 5000 },
notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 },
notificationCompactMode: { def: false },
notificationPopupPosition: { def: 0 },
notificationHistoryEnabled: { def: true },
notificationHistoryMaxCount: { def: 50 },
@@ -322,7 +350,11 @@ var SPEC = {
maximizeDetection: true,
scrollEnabled: true,
scrollXBehavior: "column",
scrollYBehavior: "workspace"
scrollYBehavior: "workspace",
shadowIntensity: 0,
shadowOpacity: 60,
shadowColorMode: "text",
shadowCustomColor: "#000000"
}], onChange: "updateBarConfigs" },
desktopClockEnabled: { def: false },
@@ -368,7 +400,11 @@ var SPEC = {
desktopWidgetPositions: { def: {} },
desktopWidgetGridSettings: { def: {} },
desktopWidgetInstances: { def: [] }
desktopWidgetInstances: { def: [] },
desktopWidgetGroups: { def: [] },
builtInPluginSettings: { def: {} }
};
function getValidKeys() {

View File

@@ -2,7 +2,9 @@ import QtQuick
import Quickshell
import qs.Common
import qs.Modals
import qs.Modals.Changelog
import qs.Modals.Clipboard
import qs.Modals.Greeter
import qs.Modals.Settings
import qs.Modals.Spotlight
import qs.Modules
@@ -605,6 +607,8 @@ Item {
active: false
Component.onCompleted: PopoutService.processListModalLoader = processListModalLoader
ProcessListModal {
id: processListModal
@@ -657,6 +661,9 @@ Item {
}
}
}
onInstancesChanged: PopoutService.notepadSlideouts = instances
Component.onCompleted: PopoutService.notepadSlideouts = instances
}
LazyLoader {
@@ -816,4 +823,44 @@ Item {
id: niriOverviewOverlay
}
}
Loader {
id: greeterLoader
active: false
sourceComponent: GreeterModal {
onGreeterCompleted: greeterLoader.active = false
Component.onCompleted: show()
}
Connections {
target: FirstLaunchService
function onGreeterRequested() {
if (greeterLoader.active && greeterLoader.item) {
greeterLoader.item.show();
return;
}
greeterLoader.active = true;
}
}
}
Loader {
id: changelogLoader
active: false
sourceComponent: ChangelogModal {
onChangelogDismissed: changelogLoader.active = false
Component.onCompleted: show()
}
Connections {
target: ChangelogService
function onChangelogRequested() {
if (changelogLoader.active && changelogLoader.item) {
changelogLoader.item.show();
return;
}
changelogLoader.active = true;
}
}
}
}

View File

@@ -189,6 +189,13 @@ Item {
if (CompositorService.isNiri && NiriService.currentOutput) {
return NiriService.currentOutput;
}
if ((CompositorService.isSway || CompositorService.isScroll) && I3.workspaces?.values) {
const focusedWs = I3.workspaces.values.find(ws => ws.focused === true);
return focusedWs?.monitor?.name || "";
}
if (CompositorService.isDwl && DwlService.activeOutput) {
return DwlService.activeOutput;
}
return "";
}
@@ -592,6 +599,39 @@ Item {
return barConfig.autoHide ? "BAR_MANUAL_HIDE_SUCCESS" : "BAR_AUTO_HIDE_SUCCESS";
}
function getPosition(selector: string, value: string): string {
const {
barConfig,
error
} = getBarConfig(selector, value);
if (error)
return error;
const positions = ["top", "bottom", "left", "right"];
return positions[barConfig.position] || "unknown";
}
function setPosition(selector: string, value: string, position: string): string {
const {
barConfig,
error
} = getBarConfig(selector, value);
if (error)
return error;
const positionMap = {
"top": SettingsData.Position.Top,
"bottom": SettingsData.Position.Bottom,
"left": SettingsData.Position.Left,
"right": SettingsData.Position.Right
};
const posValue = positionMap[position.toLowerCase()];
if (posValue === undefined)
return "BAR_INVALID_POSITION";
SettingsData.updateBarConfig(barConfig.id, {
position: posValue
});
return "BAR_POSITION_SET_SUCCESS";
}
target: "bar"
}
@@ -787,7 +827,16 @@ Item {
const widgets = BarWidgetService.getRegisteredWidgetIds();
if (widgets.length === 0)
return "No widgets registered";
return widgets.join("\n");
const lines = [];
for (const widgetId of widgets) {
const widget = BarWidgetService.getWidgetOnFocusedScreen(widgetId);
let state = "";
if (widget?.effectiveVisible !== undefined)
state = widget.effectiveVisible ? " [visible]" : " [hidden]";
lines.push(widgetId + state);
}
return lines.join("\n");
}
function status(widgetId: string): string {
@@ -806,6 +855,76 @@ Item {
return "hidden";
}
function reveal(widgetId: string): string {
if (!widgetId)
return "ERROR: No widget ID specified";
if (!BarWidgetService.hasWidget(widgetId))
return `WIDGET_NOT_FOUND: ${widgetId}`;
const widget = BarWidgetService.getWidgetOnFocusedScreen(widgetId);
if (!widget)
return `WIDGET_NOT_AVAILABLE: ${widgetId}`;
if (typeof widget.setVisibilityOverride === "function") {
widget.setVisibilityOverride(true);
return `WIDGET_REVEAL_SUCCESS: ${widgetId}`;
}
return `WIDGET_REVEAL_NOT_SUPPORTED: ${widgetId}`;
}
function hide(widgetId: string): string {
if (!widgetId)
return "ERROR: No widget ID specified";
if (!BarWidgetService.hasWidget(widgetId))
return `WIDGET_NOT_FOUND: ${widgetId}`;
const widget = BarWidgetService.getWidgetOnFocusedScreen(widgetId);
if (!widget)
return `WIDGET_NOT_AVAILABLE: ${widgetId}`;
if (typeof widget.setVisibilityOverride === "function") {
widget.setVisibilityOverride(false);
return `WIDGET_HIDE_SUCCESS: ${widgetId}`;
}
return `WIDGET_HIDE_NOT_SUPPORTED: ${widgetId}`;
}
function reset(widgetId: string): string {
if (!widgetId)
return "ERROR: No widget ID specified";
if (!BarWidgetService.hasWidget(widgetId))
return `WIDGET_NOT_FOUND: ${widgetId}`;
const widget = BarWidgetService.getWidgetOnFocusedScreen(widgetId);
if (!widget)
return `WIDGET_NOT_AVAILABLE: ${widgetId}`;
if (typeof widget.clearVisibilityOverride === "function") {
widget.clearVisibilityOverride();
return `WIDGET_RESET_SUCCESS: ${widgetId}`;
}
return `WIDGET_RESET_NOT_SUPPORTED: ${widgetId}`;
}
function visibility(widgetId: string): string {
if (!widgetId)
return "ERROR: No widget ID specified";
if (!BarWidgetService.hasWidget(widgetId))
return `WIDGET_NOT_FOUND: ${widgetId}`;
const widget = BarWidgetService.getWidgetOnFocusedScreen(widgetId);
if (!widget)
return `WIDGET_NOT_AVAILABLE: ${widgetId}`;
if (widget.effectiveVisible !== undefined)
return widget.effectiveVisible ? "visible" : "hidden";
return "unknown";
}
target: "widget"
}
@@ -894,6 +1013,26 @@ Item {
target: "clipboard"
}
IpcHandler {
function open(): string {
FirstLaunchService.showWelcome();
return "WELCOME_OPEN_SUCCESS";
}
function doctor(): string {
FirstLaunchService.showDoctor();
return "WELCOME_DOCTOR_SUCCESS";
}
function page(pageNum: string): string {
const num = parseInt(pageNum) || 0;
FirstLaunchService.showGreeter(num);
return `WELCOME_PAGE_SUCCESS: ${num}`;
}
target: "welcome"
}
IpcHandler {
function toggleOverlay(instanceId: string): string {
if (!instanceId)
@@ -929,7 +1068,7 @@ Item {
const instances = SettingsData.desktopWidgetInstances || [];
if (instances.length === 0)
return "No desktop widgets configured";
return instances.map(i => `${i.id} [${i.widgetType}] ${i.name || i.widgetType}`).join("\n");
return instances.map(i => `${i.id} [${i.widgetType}] ${i.name || i.widgetType} ${i.enabled ? "[enabled]" : "[disabled]"}`).join("\n");
}
function status(instanceId: string): string {
@@ -940,9 +1079,115 @@ Item {
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
const enabled = instance.enabled ?? true;
const overlay = instance.config?.showOnOverlay ?? false;
const overview = instance.config?.showOnOverview ?? false;
return `overlay: ${overlay}, overview: ${overview}`;
const clickThrough = instance.config?.clickThrough ?? false;
const syncPosition = instance.config?.syncPositionAcrossScreens ?? false;
return `enabled: ${enabled}, overlay: ${overlay}, overview: ${overview}, clickThrough: ${clickThrough}, syncPosition: ${syncPosition}`;
}
function enable(instanceId: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
SettingsData.updateDesktopWidgetInstance(instanceId, {
enabled: true
});
return `DESKTOP_WIDGET_ENABLED: ${instanceId}`;
}
function disable(instanceId: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
SettingsData.updateDesktopWidgetInstance(instanceId, {
enabled: false
});
return `DESKTOP_WIDGET_DISABLED: ${instanceId}`;
}
function toggleEnabled(instanceId: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
const currentValue = instance.enabled ?? true;
SettingsData.updateDesktopWidgetInstance(instanceId, {
enabled: !currentValue
});
return !currentValue ? `DESKTOP_WIDGET_ENABLED: ${instanceId}` : `DESKTOP_WIDGET_DISABLED: ${instanceId}`;
}
function toggleClickThrough(instanceId: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
const currentValue = instance.config?.clickThrough ?? false;
SettingsData.updateDesktopWidgetInstanceConfig(instanceId, {
clickThrough: !currentValue
});
return !currentValue ? `DESKTOP_WIDGET_CLICK_THROUGH_ENABLED: ${instanceId}` : `DESKTOP_WIDGET_CLICK_THROUGH_DISABLED: ${instanceId}`;
}
function setClickThrough(instanceId: string, enabled: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
const enabledBool = enabled === "true" || enabled === "1";
SettingsData.updateDesktopWidgetInstanceConfig(instanceId, {
clickThrough: enabledBool
});
return enabledBool ? `DESKTOP_WIDGET_CLICK_THROUGH_ENABLED: ${instanceId}` : `DESKTOP_WIDGET_CLICK_THROUGH_DISABLED: ${instanceId}`;
}
function toggleSyncPosition(instanceId: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
const currentValue = instance.config?.syncPositionAcrossScreens ?? false;
SettingsData.updateDesktopWidgetInstanceConfig(instanceId, {
syncPositionAcrossScreens: !currentValue
});
return !currentValue ? `DESKTOP_WIDGET_SYNC_POSITION_ENABLED: ${instanceId}` : `DESKTOP_WIDGET_SYNC_POSITION_DISABLED: ${instanceId}`;
}
function setSyncPosition(instanceId: string, enabled: string): string {
if (!instanceId)
return "ERROR: No instance ID specified";
const instance = SettingsData.getDesktopWidgetInstance(instanceId);
if (!instance)
return `DESKTOP_WIDGET_NOT_FOUND: ${instanceId}`;
const enabledBool = enabled === "true" || enabled === "1";
SettingsData.updateDesktopWidgetInstanceConfig(instanceId, {
syncPositionAcrossScreens: enabledBool
});
return enabledBool ? `DESKTOP_WIDGET_SYNC_POSITION_ENABLED: ${instanceId}` : `DESKTOP_WIDGET_SYNC_POSITION_DISABLED: ${instanceId}`;
}
target: "desktopWidget"

View File

@@ -0,0 +1,246 @@
import QtQuick
import QtQuick.Effects
import qs.Common
import qs.Services
import qs.Widgets
Column {
id: root
readonly property real logoSize: Math.round(Theme.iconSize * 2.8)
readonly property real badgeHeight: Math.round(Theme.fontSizeSmall * 1.7)
topPadding: Theme.spacingL
spacing: Theme.spacingL
Column {
width: parent.width
spacing: Theme.spacingM
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
Image {
width: root.logoSize
height: width * (569.94629 / 506.50931)
anchors.verticalCenter: parent.verticalCenter
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: true
asynchronous: true
source: "file://" + Theme.shellDir + "/assets/danklogonormal.svg"
layer.enabled: true
layer.smooth: true
layer.mipmap: true
layer.effect: MultiEffect {
saturation: 0
colorization: 1
colorizationColor: Theme.primary
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Row {
spacing: Theme.spacingS
StyledText {
text: "DMS " + ChangelogService.currentVersion
font.pixelSize: Theme.fontSizeXLarge + 2
font.weight: Font.Bold
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: codenameText.implicitWidth + Theme.spacingM * 2
height: root.badgeHeight
radius: root.badgeHeight / 2
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: codenameText
anchors.centerIn: parent
text: "Spicy Miso"
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.primary
}
}
}
StyledText {
text: "Desktop widgets, theme registry, native clipboard & more"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.3
}
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: "What's New"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
Grid {
width: parent.width
columns: 2
rowSpacing: Theme.spacingS
columnSpacing: Theme.spacingS
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "widgets"
title: "Desktop Widgets"
description: "Widgets on your desktop"
onClicked: PopoutService.openSettingsWithTab("desktop_widgets")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "palette"
title: "Theme Registry"
description: "Community themes"
onClicked: PopoutService.openSettingsWithTab("theme")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "content_paste"
title: "Native Clipboard"
description: "Zero-dependency history"
onClicked: PopoutService.openSettingsWithTab("clipboard")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "display_settings"
title: "Monitor Config"
description: "Full display setup"
onClicked: PopoutService.openSettingsWithTab("display_config")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "notifications_active"
title: "Notifications"
description: "History & gestures"
onClicked: PopoutService.openSettingsWithTab("notifications")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "healing"
title: "DMS Doctor"
description: "Diagnose issues"
onClicked: FirstLaunchService.showDoctor()
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "keyboard"
title: "Keybinds Editor"
description: "niri, Hyprland, & MangoWC"
visible: KeybindsService.available
onClicked: PopoutService.openSettingsWithTab("keybinds")
}
ChangelogFeatureCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "search"
title: "Settings Search"
description: "Find settings fast"
onClicked: PopoutService.openSettings()
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.3
}
Column {
width: parent.width
spacing: Theme.spacingS
Row {
spacing: Theme.spacingS
DankIcon {
name: "warning"
size: Theme.iconSizeSmall
color: Theme.warning
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: "Upgrade Notes"
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
width: parent.width
height: upgradeNotesColumn.height + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.warning, 0.08)
border.width: 1
border.color: Theme.withAlpha(Theme.warning, 0.2)
Column {
id: upgradeNotesColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
ChangelogUpgradeNote {
width: parent.width
text: "Ghostty theme path changed to ~/.config/ghostty/themes/danktheme"
}
ChangelogUpgradeNote {
width: parent.width
text: "VS Code theme reinstall required"
}
ChangelogUpgradeNote {
width: parent.width
text: "Clipboard history migration available from cliphist"
}
}
}
StyledText {
text: "See full release notes for migration steps"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
}
}
}

View File

@@ -0,0 +1,78 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string iconName: ""
property string title: ""
property string description: ""
signal clicked
readonly property real iconContainerSize: Math.round(Theme.iconSize * 1.3)
height: Math.round(Theme.fontSizeMedium * 4.2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.primary
opacity: mouseArea.containsMouse ? 0.12 : 0
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Rectangle {
width: root.iconContainerSize
height: root.iconContainerSize
radius: Math.round(root.iconContainerSize * 0.28)
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: root.iconName
size: Theme.iconSize - 6
color: Theme.primary
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
width: parent.width - root.iconContainerSize - Theme.spacingS
StyledText {
text: root.title
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}

View File

@@ -0,0 +1,155 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
id: root
readonly property int modalWidth: 680
readonly property int modalHeight: screen ? Math.min(720, screen.height - 80) : 720
signal changelogDismissed
function show() {
visible = true;
}
objectName: "changelogModal"
title: "What's New"
minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false
FocusScope {
id: contentFocusScope
anchors.fill: parent
focus: true
Keys.onEscapePressed: event => {
root.dismiss();
event.accepted = true;
}
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Return:
case Qt.Key_Enter:
root.dismiss();
event.accepted = true;
break;
}
}
MouseArea {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: headerRow.height + Theme.spacingM
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
}
Item {
id: headerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingM
height: Math.round(Theme.fontSizeMedium * 2.85)
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankActionButton {
visible: windowControls.supported && windowControls.canMaximize
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: windowControls.tryToggleMaximize()
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.dismiss()
DankTooltip {
text: "Close"
}
}
}
}
DankFlickable {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: headerRow.bottom
anchors.bottom: footerRow.top
anchors.topMargin: Theme.spacingS
clip: true
contentHeight: mainColumn.height + Theme.spacingL * 2
contentWidth: width
ChangelogContent {
id: mainColumn
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(600, parent.width - Theme.spacingXL * 2)
}
}
Rectangle {
id: footerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.surfaceContainerHigh
Rectangle {
anchors.top: parent.top
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.5
}
Row {
anchors.centerIn: parent
spacing: Theme.spacingM
DankButton {
text: "Read Full Release Notes"
iconName: "open_in_new"
backgroundColor: Theme.surfaceContainerHighest
textColor: Theme.surfaceText
onClicked: Qt.openUrlExternally("https://danklinux.com/blog/v1.2-release")
}
DankButton {
text: "Got It"
iconName: "check"
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: root.dismiss()
}
}
}
}
FloatingWindowControls {
id: windowControls
targetWindow: root
}
function dismiss() {
ChangelogService.dismissChangelog();
changelogDismissed();
visible = false;
}
}

View File

@@ -0,0 +1,27 @@
import QtQuick
import qs.Common
import qs.Widgets
Row {
id: root
property alias text: noteText.text
spacing: Theme.spacingS
DankIcon {
name: "arrow_right"
size: Theme.iconSizeSmall - 2
color: Theme.surfaceVariantText
anchors.top: parent.top
anchors.topMargin: 2
}
StyledText {
id: noteText
width: root.width - Theme.iconSizeSmall - Theme.spacingS
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
wrapMode: Text.WordWrap
}
}

View File

@@ -49,7 +49,7 @@ Item {
readonly property alias clickCatcher: clickCatcher
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property bool useBackground: showBackground && SettingsData.modalDarkenBackground
readonly property bool useSingleWindow: useHyprlandFocusGrab || useBackground
readonly property bool useSingleWindow: CompositorService.isHyprland || useBackground
signal opened
signal dialogClosed
@@ -58,7 +58,6 @@ Item {
property bool animationsEnabled: true
function open() {
ModalManager.openModal(root);
closeTimer.stop();
const focusedScreen = CompositorService.getFocusedScreen();
if (focusedScreen) {
@@ -66,6 +65,7 @@ Item {
if (!useSingleWindow)
clickCatcher.screen = focusedScreen;
}
ModalManager.openModal(root);
shouldBeVisible = true;
if (!useSingleWindow)
clickCatcher.visible = true;
@@ -302,7 +302,7 @@ Item {
MouseArea {
anchors.fill: parent
enabled: root.useSingleWindow
enabled: root.useSingleWindow && root.shouldBeVisible
hoverEnabled: false
acceptedButtons: Qt.AllButtons
onPressed: mouse.accepted = true

View File

@@ -8,6 +8,9 @@ import qs.Widgets
FocusScope {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation)
property string docsDir: StandardPaths.writableLocation(StandardPaths.DocumentsLocation)
property string musicDir: StandardPaths.writableLocation(StandardPaths.MusicLocation)
@@ -52,6 +55,12 @@ FocusScope {
signal fileSelected(string path)
signal closeRequested
function encodeFileUrl(path) {
if (!path)
return "";
return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/');
}
function initialize() {
loadSettings();
currentPath = getLastPath();
@@ -188,7 +197,7 @@ FocusScope {
function handleSaveFile(filePath) {
var normalizedPath = filePath;
if (!normalizedPath.startsWith("file://")) {
normalizedPath = "file://" + filePath;
normalizedPath = encodeFileUrl(filePath);
}
var exists = false;
@@ -274,7 +283,7 @@ FocusScope {
nameFilters: fileExtensions
showFiles: true
showDirs: true
folder: currentPath ? "file://" + currentPath : "file://" + homeDir
folder: encodeFileUrl(currentPath || homeDir)
sortField: {
switch (sortBy) {
case "name":
@@ -727,7 +736,7 @@ FocusScope {
id: gridScrollbar
}
ScrollBar.horizontal: ScrollBar {
ScrollBar.horizontal: DankScrollbar {
policy: ScrollBar.AlwaysOff
}

View File

@@ -21,67 +21,67 @@ StyledRect {
signal itemSelected(int index, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.')
const parts = fileName.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase()
return parts[parts.length - 1].toLowerCase();
}
return ""
return "";
}
function determineFileType(fileName) {
const ext = getFileExtension(fileName)
const ext = getFileExtension(fileName);
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico"]
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico"];
if (imageExts.includes(ext)) {
return "image"
return "image";
}
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"]
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"];
if (videoExts.includes(ext)) {
return "video"
return "video";
}
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"]
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"];
if (audioExts.includes(ext)) {
return "audio"
return "audio";
}
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"]
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"];
if (codeExts.includes(ext)) {
return "code"
return "code";
}
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"]
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"];
if (docExts.includes(ext)) {
return "document"
return "document";
}
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"]
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"];
if (archiveExts.includes(ext)) {
return "archive"
return "archive";
}
if (!ext || fileName.indexOf('.') === -1) {
return "binary"
return "binary";
}
return "file"
return "file";
}
function isImageFile(fileName) {
if (!fileName) {
return false
return false;
}
return determineFileType(fileName) === "image"
return determineFileType(fileName) === "image";
}
function getIconForFile(fileName) {
const lowerName = fileName.toLowerCase()
const lowerName = fileName.toLowerCase();
if (lowerName.startsWith("dockerfile")) {
return "docker"
return "docker";
}
const ext = fileName.split('.').pop()
return ext || ""
const ext = fileName.split('.').pop();
return ext || "";
}
width: weMode ? 245 : iconSizes[iconSizeIndex] + 16
@@ -89,21 +89,21 @@ StyledRect {
radius: Theme.cornerRadius
color: {
if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
return Theme.surfacePressed
return Theme.surfacePressed;
return mouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : "transparent"
return mouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : "transparent";
}
border.color: keyboardNavigationActive && delegateRoot.index === selectedIndex ? Theme.primary : "transparent"
border.width: (keyboardNavigationActive && delegateRoot.index === selectedIndex) ? 2 : 0
Component.onCompleted: {
if (keyboardNavigationActive && delegateRoot.index === selectedIndex)
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
}
onSelectedIndexChanged: {
if (keyboardNavigationActive && selectedIndex === delegateRoot.index)
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
itemSelected(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
}
Column {
@@ -115,30 +115,31 @@ StyledRect {
height: weMode ? 165 : (iconSizes[iconSizeIndex] - 8)
anchors.horizontalCenter: parent.horizontalCenter
CachingImage {
Image {
id: gridPreviewImage
anchors.fill: parent
anchors.margins: 2
property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga"]
property int weExtIndex: 0
source: {
if (weMode && delegateRoot.fileIsDir) {
return "file://" + delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]
}
return (!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) ? ("file://" + delegateRoot.filePath) : ""
property string imagePath: {
if (weMode && delegateRoot.fileIsDir)
return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex];
return (!delegateRoot.fileIsDir && isImageFile(delegateRoot.fileName)) ? delegateRoot.filePath : "";
}
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
onStatusChanged: {
if (weMode && delegateRoot.fileIsDir && status === Image.Error) {
if (weExtIndex < weExtensions.length - 1) {
weExtIndex++
source = "file://" + delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]
weExtIndex++;
} else {
source = ""
imagePath = "";
}
}
}
fillMode: Image.PreserveAspectCrop
maxCacheSize: weMode ? 225 : iconSizes[iconSizeIndex]
sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex]
sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex]
asynchronous: true
visible: false
}
@@ -198,7 +199,7 @@ StyledRect {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir)
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
}
}
}

View File

@@ -20,97 +20,97 @@ StyledRect {
signal itemSelected(int index, string path, string name, bool isDir)
function getFileExtension(fileName) {
const parts = fileName.split('.')
const parts = fileName.split('.');
if (parts.length > 1) {
return parts[parts.length - 1].toLowerCase()
return parts[parts.length - 1].toLowerCase();
}
return ""
return "";
}
function determineFileType(fileName) {
const ext = getFileExtension(fileName)
const ext = getFileExtension(fileName);
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico"]
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico"];
if (imageExts.includes(ext)) {
return "image"
return "image";
}
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"]
const videoExts = ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v"];
if (videoExts.includes(ext)) {
return "video"
return "video";
}
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"]
const audioExts = ["mp3", "wav", "flac", "ogg", "m4a", "aac", "wma"];
if (audioExts.includes(ext)) {
return "audio"
return "audio";
}
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"]
const codeExts = ["js", "ts", "jsx", "tsx", "py", "go", "rs", "c", "cpp", "h", "java", "kt", "swift", "rb", "php", "html", "css", "scss", "json", "xml", "yaml", "yml", "toml", "sh", "bash", "zsh", "fish", "qml", "vue", "svelte"];
if (codeExts.includes(ext)) {
return "code"
return "code";
}
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"]
const docExts = ["txt", "md", "pdf", "doc", "docx", "odt", "rtf"];
if (docExts.includes(ext)) {
return "document"
return "document";
}
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"]
const archiveExts = ["zip", "tar", "gz", "bz2", "xz", "7z", "rar"];
if (archiveExts.includes(ext)) {
return "archive"
return "archive";
}
if (!ext || fileName.indexOf('.') === -1) {
return "binary"
return "binary";
}
return "file"
return "file";
}
function isImageFile(fileName) {
if (!fileName) {
return false
return false;
}
return determineFileType(fileName) === "image"
return determineFileType(fileName) === "image";
}
function getIconForFile(fileName) {
const lowerName = fileName.toLowerCase()
const lowerName = fileName.toLowerCase();
if (lowerName.startsWith("dockerfile")) {
return "docker"
return "docker";
}
const ext = fileName.split('.').pop()
return ext || ""
const ext = fileName.split('.').pop();
return ext || "";
}
function formatFileSize(size) {
if (size < 1024)
return size + " B"
return size + " B";
if (size < 1024 * 1024)
return (size / 1024).toFixed(1) + " KB"
return (size / 1024).toFixed(1) + " KB";
if (size < 1024 * 1024 * 1024)
return (size / (1024 * 1024)).toFixed(1) + " MB"
return (size / (1024 * 1024 * 1024)).toFixed(1) + " GB"
return (size / (1024 * 1024)).toFixed(1) + " MB";
return (size / (1024 * 1024 * 1024)).toFixed(1) + " GB";
}
height: 44
radius: Theme.cornerRadius
color: {
if (keyboardNavigationActive && listDelegateRoot.index === selectedIndex)
return Theme.surfacePressed
return listMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : "transparent"
return Theme.surfacePressed;
return listMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : "transparent";
}
border.color: keyboardNavigationActive && listDelegateRoot.index === selectedIndex ? Theme.primary : "transparent"
border.width: (keyboardNavigationActive && listDelegateRoot.index === selectedIndex) ? 2 : 0
Component.onCompleted: {
if (keyboardNavigationActive && listDelegateRoot.index === selectedIndex)
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir)
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
}
onSelectedIndexChanged: {
if (keyboardNavigationActive && selectedIndex === listDelegateRoot.index)
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir)
itemSelected(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
}
Row {
@@ -124,12 +124,15 @@ StyledRect {
height: 28
anchors.verticalCenter: parent.verticalCenter
CachingImage {
Image {
id: listPreviewImage
anchors.fill: parent
source: (!listDelegateRoot.fileIsDir && isImageFile(listDelegateRoot.fileName)) ? ("file://" + listDelegateRoot.filePath) : ""
property string imagePath: (!listDelegateRoot.fileIsDir && isImageFile(listDelegateRoot.fileName)) ? listDelegateRoot.filePath : ""
source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : ""
fillMode: Image.PreserveAspectCrop
maxCacheSize: 32
sourceSize.width: 32
sourceSize.height: 32
asynchronous: true
visible: false
}
@@ -203,7 +206,7 @@ StyledRect {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir)
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
}
}
}

View File

@@ -41,14 +41,18 @@ FloatingWindow {
onVisibleChanged: {
if (visible) {
if (parentModal) {
if (parentModal && "shouldHaveFocus" in parentModal) {
parentModal.shouldHaveFocus = false;
parentModal.allowFocusOverride = true;
}
content.reset();
Qt.callLater(() => content.forceActiveFocus());
Qt.callLater(() => {
if (content) {
content.reset();
content.forceActiveFocus();
}
});
} else {
if (parentModal) {
if (parentModal && "allowFocusOverride" in parentModal) {
parentModal.allowFocusOverride = false;
parentModal.shouldHaveFocus = Qt.binding(() => parentModal.shouldBeVisible);
}
@@ -56,27 +60,35 @@ FloatingWindow {
}
}
FileBrowserContent {
id: content
Loader {
id: contentLoader
anchors.fill: parent
focus: true
closeOnEscape: false
windowControls: windowControls
active: fileBrowserModal.visible
sourceComponent: FileBrowserContent {
id: content
anchors.fill: parent
focus: true
closeOnEscape: false
windowControls: fileBrowserModal.windowControlsRef
browserTitle: fileBrowserModal.browserTitle
browserIcon: fileBrowserModal.browserIcon
browserType: fileBrowserModal.browserType
fileExtensions: fileBrowserModal.fileExtensions
showHiddenFiles: fileBrowserModal.showHiddenFiles
saveMode: fileBrowserModal.saveMode
defaultFileName: fileBrowserModal.defaultFileName
browserTitle: fileBrowserModal.browserTitle
browserIcon: fileBrowserModal.browserIcon
browserType: fileBrowserModal.browserType
fileExtensions: fileBrowserModal.fileExtensions
showHiddenFiles: fileBrowserModal.showHiddenFiles
saveMode: fileBrowserModal.saveMode
defaultFileName: fileBrowserModal.defaultFileName
Component.onCompleted: initialize()
Component.onCompleted: initialize()
onFileSelected: path => fileBrowserModal.fileSelected(path)
onCloseRequested: fileBrowserModal.close()
onFileSelected: path => fileBrowserModal.fileSelected(path)
onCloseRequested: fileBrowserModal.close()
}
}
property alias content: contentLoader.item
property alias windowControlsRef: windowControls
FloatingWindowControls {
id: windowControls
targetWindow: fileBrowserModal

View File

@@ -33,8 +33,12 @@ DankModal {
if (parentPopout) {
parentPopout.customKeyboardFocus = WlrKeyboardFocus.None;
}
content.reset();
Qt.callLater(() => content.forceActiveFocus());
Qt.callLater(() => {
if (contentLoader.item) {
contentLoader.item.reset();
contentLoader.item.forceActiveFocus();
}
});
}
onDialogClosed: {
@@ -43,8 +47,7 @@ DankModal {
}
}
directContent: FileBrowserContent {
id: content
content: FileBrowserContent {
focus: true
browserTitle: fileBrowserSurfaceModal.browserTitle

View File

@@ -0,0 +1,492 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
property var greeterRoot: parent ? parent.greeterRoot : null
readonly property real headerIconContainerSize: Math.round(Theme.iconSize * 2)
readonly property real sectionIconSize: Theme.iconSizeSmall + 2
readonly property real keybindRowHeight: Math.round(Theme.fontSizeMedium * 2)
readonly property real keyBadgeHeight: Math.round(Theme.fontSizeSmall * 1.83)
readonly property var featureNames: ({
"spotlight": "App Launcher",
"clipboard": "Clipboard",
"processlist": "Task Manager",
"settings": "Settings",
"notifications": "Notifications",
"notepad": "Notepad",
"hotkeys": "Keybinds",
"lock": "Lock Screen",
"dankdash": "Dashboard"
})
function getFeatureDesc(action) {
const match = action.match(/dms\s+ipc\s+call\s+(\w+)/);
if (match && featureNames[match[1]])
return featureNames[match[1]];
return null;
}
readonly property var dmsKeybinds: {
if (!greeterRoot || !greeterRoot.cheatsheetLoaded || !greeterRoot.cheatsheetData || !greeterRoot.cheatsheetData.binds)
return [];
const seen = new Set();
const binds = [];
const allBinds = greeterRoot.cheatsheetData.binds;
for (const category in allBinds) {
const categoryBinds = allBinds[category];
for (let i = 0; i < categoryBinds.length; i++) {
const bind = categoryBinds[i];
if (!bind.key || !bind.action)
continue;
if (!bind.action.includes("dms"))
continue;
if (!(bind.action.includes("spawn") || bind.action.includes("exec")))
continue;
const feature = getFeatureDesc(bind.action);
if (!feature)
continue;
if (seen.has(feature))
continue;
seen.add(feature);
binds.push({
key: bind.key,
desc: feature
});
}
}
return binds;
}
readonly property bool hasKeybinds: dmsKeybinds.length > 0
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingL * 2
contentWidth: width
Column {
id: mainColumn
anchors.horizontalCenter: parent.horizontalCenter
width: Math.min(640, parent.width - Theme.spacingXL * 2)
topPadding: Theme.spacingL
spacing: Theme.spacingL
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
Rectangle {
width: root.headerIconContainerSize
height: root.headerIconContainerSize
radius: Math.round(root.headerIconContainerSize * 0.29)
color: Theme.withAlpha(Theme.success, 0.15)
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "check_circle"
size: Theme.iconSize + 4
color: Theme.success
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
StyledText {
text: I18n.tr("You're All Set!", "greeter completion page title")
font.pixelSize: Theme.fontSizeXLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("DankMaterialShell is ready to use", "greeter completion page subtitle")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: root.hasKeybinds
Row {
width: parent.width
spacing: Theme.spacingS
DankIcon {
name: "keyboard"
size: root.sectionIconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("DMS Shortcuts", "greeter keybinds section header")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
id: keybindsRect
width: parent.width
height: keybindsGrid.height + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
readonly property bool useTwoColumns: width > 500
readonly property int columnCount: useTwoColumns ? 2 : 1
readonly property real itemWidth: useTwoColumns ? (width - Theme.spacingM * 3) / 2 : width - Theme.spacingM * 2
property real maxKeyWidth: 0
Grid {
id: keybindsGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingM
columns: keybindsRect.columnCount
rowSpacing: Theme.spacingS
columnSpacing: Theme.spacingM
Repeater {
model: root.dmsKeybinds
Row {
width: keybindsRect.itemWidth
height: root.keybindRowHeight
spacing: Theme.spacingS
Item {
width: keybindsRect.maxKeyWidth
height: parent.height
Row {
id: keysRow
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
property real naturalWidth: {
let w = 0;
for (let i = 0; i < children.length; i++) {
if (children[i].visible)
w += children[i].width + (i > 0 ? Theme.spacingXS : 0);
}
return w;
}
Component.onCompleted: {
Qt.callLater(() => {
if (naturalWidth > keybindsRect.maxKeyWidth)
keybindsRect.maxKeyWidth = naturalWidth;
});
}
Repeater {
model: (modelData.key || "").split("+")
Rectangle {
width: singleKeyText.implicitWidth + Theme.spacingM
height: root.keyBadgeHeight
radius: Theme.spacingXS
color: Theme.surfaceContainerHighest
border.width: 1
border.color: Theme.outline
StyledText {
id: singleKeyText
anchors.centerIn: parent
color: Theme.secondary
text: modelData
font.pixelSize: Theme.fontSizeSmall - 1
font.weight: Font.Medium
isMonospace: true
}
}
}
}
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - keybindsRect.maxKeyWidth - Theme.spacingS
text: modelData.desc || ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
elide: Text.ElideRight
}
}
}
}
}
}
Rectangle {
width: parent.width
height: noKeybindsColumn.height + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
visible: !root.hasKeybinds
Column {
id: noKeybindsColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Row {
spacing: Theme.spacingS
DankIcon {
name: "keyboard"
size: root.sectionIconSize
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("No DMS shortcuts configured", "greeter no keybinds message")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
Rectangle {
width: parent.width
height: Math.round(Theme.fontSizeMedium * 2.85)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.primary
opacity: noKeybindsLinkMouse.containsMouse ? 0.12 : 0
}
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: "menu_book"
size: root.sectionIconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Configure Keybinds", "greeter configure keybinds link")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
name: "open_in_new"
size: Theme.iconSizeSmall - 2
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: noKeybindsLinkMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let url = "https://danklinux.com/docs/dankmaterialshell/keybinds-ipc";
if (CompositorService.isNiri)
url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings";
else if (CompositorService.isHyprland)
url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-1";
else if (CompositorService.isDwl)
url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-2";
Qt.openUrlExternally(url);
}
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.3
visible: root.hasKeybinds
}
Column {
width: parent.width
spacing: Theme.spacingS
Row {
width: parent.width
spacing: Theme.spacingS
DankIcon {
name: "settings"
size: root.sectionIconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Configure", "greeter settings section header")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Grid {
width: parent.width
columns: 2
rowSpacing: Theme.spacingS
columnSpacing: Theme.spacingS
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "display_settings"
title: I18n.tr("Displays", "greeter settings link")
description: I18n.tr("Resolution, position, scale", "greeter displays description")
onClicked: PopoutService.openSettingsWithTab("display_config")
}
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "wallpaper"
title: I18n.tr("Wallpaper", "greeter settings link")
description: I18n.tr("Background image", "greeter wallpaper description")
onClicked: PopoutService.openSettingsWithTab("wallpaper")
}
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "format_paint"
title: I18n.tr("Theme & Colors", "greeter settings link")
description: I18n.tr("Dynamic colors, presets", "greeter theme description")
onClicked: PopoutService.openSettingsWithTab("theme")
}
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "notifications"
title: I18n.tr("Notifications", "greeter settings link")
description: I18n.tr("Popup behavior, position", "greeter notifications description")
onClicked: PopoutService.openSettingsWithTab("notifications")
}
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "toolbar"
title: I18n.tr("DankBar", "greeter settings link")
description: I18n.tr("Widgets, layout, style", "greeter dankbar description")
onClicked: PopoutService.openSettingsWithTab("dankbar_settings")
}
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "keyboard"
title: I18n.tr("Keybinds", "greeter settings link")
description: I18n.tr("niri shortcuts config", "greeter keybinds niri description")
visible: KeybindsService.available
onClicked: PopoutService.openSettingsWithTab("keybinds")
}
GreeterSettingsCard {
width: (parent.width - Theme.spacingS) / 2
iconName: "dock_to_bottom"
title: I18n.tr("Dock", "greeter settings link")
description: I18n.tr("Position, pinned apps", "greeter dock description")
visible: !KeybindsService.available
onClicked: PopoutService.openSettingsWithTab("dock")
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.3
}
Column {
width: parent.width
spacing: Theme.spacingS
Row {
width: parent.width
spacing: Theme.spacingS
DankIcon {
name: "explore"
size: root.sectionIconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Explore", "greeter explore section header")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
width: parent.width
spacing: Theme.spacingS
GreeterQuickLink {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "menu_book"
title: I18n.tr("Docs", "greeter documentation link")
isExternal: true
onClicked: Qt.openUrlExternally("https://danklinux.com/docs")
}
GreeterQuickLink {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "extension"
title: I18n.tr("Plugins", "greeter plugins link")
isExternal: true
onClicked: Qt.openUrlExternally("https://danklinux.com/plugins")
}
GreeterQuickLink {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "palette"
title: I18n.tr("Themes", "greeter themes link")
isExternal: true
onClicked: Qt.openUrlExternally("https://danklinux.com/plugins?tab=themes")
}
}
}
}
}
}

View File

@@ -0,0 +1,421 @@
import QtQuick
import Quickshell.Io
import qs.Common
import qs.Widgets
Item {
id: root
property bool isRunning: false
property bool hasRun: false
property var doctorResults: null
property int errorCount: 0
property int warningCount: 0
property int okCount: 0
property int infoCount: 0
property string selectedFilter: "error"
readonly property real loadingContainerSize: Math.round(Theme.iconSize * 5)
readonly property real pulseRingSize: Math.round(Theme.iconSize * 3.3)
readonly property real centerIconContainerSize: Math.round(Theme.iconSize * 2.67)
readonly property real headerIconContainerSize: Math.round(Theme.iconSize * 2)
readonly property var filteredResults: {
if (!doctorResults?.results)
return [];
return doctorResults.results.filter(r => r.status === selectedFilter);
}
function runDoctor() {
hasRun = false;
isRunning = true;
doctorProcess.running = true;
}
Component.onCompleted: runDoctor()
Item {
id: loadingView
anchors.fill: parent
visible: root.isRunning
Column {
anchors.centerIn: parent
spacing: Theme.spacingXL
Item {
width: root.loadingContainerSize
height: root.loadingContainerSize
anchors.horizontalCenter: parent.horizontalCenter
Rectangle {
id: pulseRing1
anchors.centerIn: parent
width: root.pulseRingSize
height: root.pulseRingSize
radius: root.pulseRingSize / 2
color: "transparent"
border.width: Math.round(Theme.spacingXS * 0.75)
border.color: Theme.primary
opacity: 0
SequentialAnimation on opacity {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
}
SequentialAnimation on scale {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.5
to: 1.5
duration: 1500
easing.type: Easing.OutQuad
}
}
}
Rectangle {
id: pulseRing2
anchors.centerIn: parent
width: root.pulseRingSize
height: root.pulseRingSize
radius: root.pulseRingSize / 2
color: "transparent"
border.width: Math.round(Theme.spacingXS * 0.75)
border.color: Theme.secondary
opacity: 0
SequentialAnimation on opacity {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.8
to: 0
duration: 1500
easing.type: Easing.OutQuad
}
}
SequentialAnimation on scale {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 0.3
to: 1.3
duration: 1500
easing.type: Easing.OutQuad
}
}
}
Rectangle {
anchors.centerIn: parent
width: root.centerIconContainerSize
height: root.centerIconContainerSize
radius: root.centerIconContainerSize / 2
color: Theme.primaryContainer
DankIcon {
anchors.centerIn: parent
name: "vital_signs"
size: Theme.iconSizeLarge
color: Theme.primary
}
SequentialAnimation on scale {
running: root.isRunning
loops: Animation.Infinite
NumberAnimation {
from: 1
to: 1.1
duration: 750
easing.type: Easing.InOutQuad
}
NumberAnimation {
from: 1.1
to: 1
duration: 750
easing.type: Easing.InOutQuad
}
}
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingS
StyledText {
text: I18n.tr("System Check", "greeter doctor page title")
font.pixelSize: Theme.fontSizeXLarge
font.weight: Font.Bold
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("Analyzing configuration...", "greeter doctor page loading text")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
Item {
id: resultsView
anchors.fill: parent
visible: root.hasRun && !root.isRunning
opacity: (root.hasRun && !root.isRunning) ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Column {
id: headerSection
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Theme.spacingL
anchors.leftMargin: Theme.spacingXL
anchors.rightMargin: Theme.spacingXL
spacing: Theme.spacingL
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
Rectangle {
width: root.headerIconContainerSize
height: root.headerIconContainerSize
radius: Math.round(root.headerIconContainerSize * 0.29)
color: root.errorCount > 0 ? Theme.errorContainer : Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: root.errorCount > 0 ? "warning" : "check_circle"
size: Theme.iconSize + 4
color: root.errorCount > 0 ? Theme.error : Theme.primary
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
StyledText {
text: I18n.tr("System Check", "greeter doctor page title")
font.pixelSize: Theme.fontSizeXLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
StyledText {
text: root.errorCount > 0 ? I18n.tr("%1 issue(s) found", "greeter doctor page error count").arg(root.errorCount) : I18n.tr("All checks passed", "greeter doctor page success")
font.pixelSize: Theme.fontSizeMedium
color: root.errorCount > 0 ? Theme.error : Theme.surfaceVariantText
}
}
}
Row {
width: parent.width
spacing: Theme.spacingS
GreeterStatusCard {
width: (parent.width - Theme.spacingS * 3) / 4
count: root.errorCount
label: I18n.tr("Errors", "greeter doctor page status card")
iconName: "error"
iconColor: Theme.error
bgColor: Theme.errorContainer || Theme.withAlpha(Theme.error, 0.15)
selected: root.selectedFilter === "error"
onClicked: root.selectedFilter = "error"
}
GreeterStatusCard {
width: (parent.width - Theme.spacingS * 3) / 4
count: root.warningCount
label: I18n.tr("Warnings", "greeter doctor page status card")
iconName: "warning"
iconColor: Theme.warning
bgColor: Theme.withAlpha(Theme.warning, 0.15)
selected: root.selectedFilter === "warn"
onClicked: root.selectedFilter = "warn"
}
GreeterStatusCard {
width: (parent.width - Theme.spacingS * 3) / 4
count: root.infoCount
label: I18n.tr("Info", "greeter doctor page status card")
iconName: "info"
iconColor: Theme.secondary
bgColor: Theme.withAlpha(Theme.secondary, 0.15)
selected: root.selectedFilter === "info"
onClicked: root.selectedFilter = "info"
}
GreeterStatusCard {
width: (parent.width - Theme.spacingS * 3) / 4
count: root.okCount
label: I18n.tr("OK", "greeter doctor page status card")
iconName: "check_circle"
iconColor: Theme.success
bgColor: Theme.withAlpha(Theme.success, 0.15)
selected: root.selectedFilter === "ok"
onClicked: root.selectedFilter = "ok"
}
}
}
Rectangle {
id: resultsContainer
anchors.top: headerSection.bottom
anchors.bottom: footerSection.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Theme.spacingL
anchors.bottomMargin: Theme.spacingM
anchors.leftMargin: Theme.spacingXL
anchors.rightMargin: Theme.spacingXL
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
clip: true
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
visible: root.filteredResults.length === 0
DankIcon {
name: {
switch (root.selectedFilter) {
case "error":
return "check_circle";
case "warn":
return "thumb_up";
case "info":
return "info";
default:
return "verified";
}
}
size: Math.round(Theme.iconSize * 1.67)
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: {
switch (root.selectedFilter) {
case "error":
return I18n.tr("No errors", "greeter doctor page empty state");
case "warn":
return I18n.tr("No warnings", "greeter doctor page empty state");
case "info":
return I18n.tr("No info items", "greeter doctor page empty state");
default:
return I18n.tr("No checks passed", "greeter doctor page empty state");
}
}
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
DankFlickable {
anchors.fill: parent
anchors.margins: Theme.spacingM
clip: true
contentHeight: resultsColumn.height
contentWidth: width
visible: root.filteredResults.length > 0
Column {
id: resultsColumn
width: parent.width
spacing: Theme.spacingS
Repeater {
model: root.filteredResults
GreeterDoctorResultItem {
width: resultsColumn.width
resultData: modelData
}
}
}
}
}
Row {
id: footerSection
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: Theme.spacingL
spacing: Theme.spacingM
DankButton {
text: I18n.tr("Run Again", "greeter doctor page button")
iconName: "refresh"
backgroundColor: Theme.surfaceContainerHighest
textColor: Theme.surfaceText
onClicked: root.runDoctor()
}
}
}
Process {
id: doctorProcess
command: ["dms", "doctor", "--json"]
running: false
stdout: StdioCollector {
onStreamFinished: {
root.isRunning = false;
root.hasRun = true;
try {
root.doctorResults = JSON.parse(text);
if (root.doctorResults?.summary) {
root.errorCount = root.doctorResults.summary.errors || 0;
root.warningCount = root.doctorResults.summary.warnings || 0;
root.okCount = root.doctorResults.summary.ok || 0;
root.infoCount = root.doctorResults.summary.info || 0;
}
if (root.errorCount > 0)
root.selectedFilter = "error";
else if (root.warningCount > 0)
root.selectedFilter = "warn";
else if (root.infoCount > 0)
root.selectedFilter = "info";
else
root.selectedFilter = "ok";
} catch (e) {
console.error("GreeterDoctorPage: Failed to parse doctor output:", e);
}
}
}
onExited: exitCode => {
if (exitCode !== 0) {
root.isRunning = false;
root.hasRun = true;
}
}
}
}

View File

@@ -0,0 +1,109 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property var resultData: null
readonly property string status: resultData?.status || "ok"
readonly property string statusIcon: {
switch (status) {
case "error":
return "error";
case "warn":
return "warning";
case "info":
return "info";
default:
return "check_circle";
}
}
readonly property color statusColor: {
switch (status) {
case "error":
return Theme.error;
case "warn":
return Theme.warning;
case "info":
return Theme.secondary;
default:
return Theme.success;
}
}
height: Math.round(Theme.fontSizeMedium * 3.4)
radius: Theme.cornerRadius
color: Theme.withAlpha(statusColor, 0.08)
DankIcon {
id: statusIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
name: root.statusIcon
size: Theme.iconSize - 4
color: root.statusColor
}
Column {
anchors.left: statusIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: categoryChip.visible ? categoryChip.left : (urlButton.visible ? urlButton.left : parent.right)
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: 1
StyledText {
width: parent.width
text: root.resultData?.name || ""
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
}
StyledText {
width: parent.width
text: root.resultData?.message || ""
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
elide: Text.ElideRight
visible: text.length > 0
}
}
Rectangle {
id: categoryChip
anchors.right: urlButton.visible ? urlButton.left : parent.right
anchors.rightMargin: urlButton.visible ? Theme.spacingXS : Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
height: Math.round(Theme.fontSizeSmall * 1.67)
width: categoryText.implicitWidth + Theme.spacingS
radius: Theme.spacingXS
color: Theme.surfaceContainerHighest
visible: !!(root.resultData?.category)
StyledText {
id: categoryText
anchors.centerIn: parent
text: root.resultData?.category || ""
font.pixelSize: Theme.fontSizeSmall - 2
color: Theme.surfaceVariantText
}
}
DankActionButton {
id: urlButton
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
iconName: "open_in_new"
iconSize: Theme.iconSize - 6
buttonSize: 24
visible: !!(root.resultData?.url)
tooltipText: root.resultData?.url || ""
onClicked: Qt.openUrlExternally(root.resultData.url)
}
}

View File

@@ -0,0 +1,74 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string iconName: ""
property string title: ""
property string description: ""
signal clicked
readonly property real iconContainerSize: Math.round(Theme.iconSize * 1.5)
height: Math.round(Theme.fontSizeMedium * 6.4)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.primary
opacity: mouseArea.containsMouse ? 0.12 : 0
}
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
Rectangle {
width: root.iconContainerSize
height: root.iconContainerSize
radius: Math.round(root.iconContainerSize * 0.28)
color: Theme.primaryContainer
anchors.horizontalCenter: parent.horizontalCenter
DankIcon {
anchors.centerIn: parent
name: root.iconName
size: Theme.iconSize - 4
color: Theme.primary
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 2
StyledText {
text: root.title
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}

View File

@@ -0,0 +1,332 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
FloatingWindow {
id: root
property int currentPage: 0
readonly property int totalPages: 3
readonly property var pageComponents: [welcomePage, doctorPage, completePage]
property var cheatsheetData: ({})
property bool cheatsheetLoaded: false
readonly property int modalWidth: 720
readonly property int modalHeight: screen ? Math.min(760, screen.height - 80) : 760
signal greeterCompleted
Component.onCompleted: Qt.callLater(loadCheatsheet)
function loadCheatsheet() {
const provider = KeybindsService.cheatsheetProvider;
if (KeybindsService.cheatsheetAvailable && provider && !cheatsheetLoaded) {
cheatsheetProcess.command = ["dms", "keybinds", "show", provider];
cheatsheetProcess.running = true;
}
}
Connections {
target: KeybindsService
function onCheatsheetAvailableChanged() {
if (KeybindsService.cheatsheetAvailable && !root.cheatsheetLoaded)
loadCheatsheet();
}
}
function getKeybind(actionPattern) {
if (!cheatsheetLoaded || !cheatsheetData.binds)
return "";
for (const category in cheatsheetData.binds) {
const binds = cheatsheetData.binds[category];
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
if (bind.action && bind.action.includes(actionPattern))
return bind.key || "";
}
}
return "";
}
function show() {
currentPage = FirstLaunchService.requestedStartPage || 0;
visible = true;
}
function showAtPage(page) {
currentPage = page;
visible = true;
}
function nextPage() {
if (currentPage < totalPages - 1)
currentPage++;
}
function prevPage() {
if (currentPage > 0)
currentPage--;
}
function finish() {
FirstLaunchService.markFirstLaunchComplete();
greeterCompleted();
visible = false;
}
function skip() {
FirstLaunchService.markFirstLaunchComplete();
greeterCompleted();
visible = false;
}
objectName: "greeterModal"
title: I18n.tr("Welcome", "greeter modal window title")
minimumSize: Qt.size(modalWidth, modalHeight)
maximumSize: Qt.size(modalWidth, modalHeight)
color: Theme.surfaceContainer
visible: false
Process {
id: cheatsheetProcess
running: false
stdout: StdioCollector {
onStreamFinished: {
const trimmed = text.trim();
if (trimmed.length === 0)
return;
try {
root.cheatsheetData = JSON.parse(trimmed);
root.cheatsheetLoaded = true;
} catch (e) {
console.warn("Greeter: Failed to parse cheatsheet:", e);
}
}
}
}
FocusScope {
id: contentFocusScope
anchors.fill: parent
focus: true
Keys.onEscapePressed: event => {
root.skip();
event.accepted = true;
}
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_Return:
case Qt.Key_Enter:
if (root.currentPage < root.totalPages - 1)
root.nextPage();
else
root.finish();
event.accepted = true;
break;
case Qt.Key_Left:
if (root.currentPage > 0)
root.prevPage();
event.accepted = true;
break;
case Qt.Key_Right:
if (root.currentPage < root.totalPages - 1)
root.nextPage();
event.accepted = true;
break;
}
}
MouseArea {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: headerRow.height + Theme.spacingM
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
}
Item {
id: headerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacingM
height: Math.round(Theme.fontSizeMedium * 2.85)
Rectangle {
id: pageIndicatorContainer
readonly property real indicatorHeight: Math.round(Theme.fontSizeMedium * 2)
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: pageIndicatorRow.width + Theme.spacingM * 2
height: indicatorHeight
radius: indicatorHeight / 2
color: Theme.surfaceContainerHigh
Row {
id: pageIndicatorRow
anchors.centerIn: parent
spacing: Theme.spacingS
Repeater {
model: root.totalPages
Rectangle {
required property int index
property bool isActive: index === root.currentPage
readonly property real dotSize: Math.round(Theme.spacingS * 1.3)
width: isActive ? dotSize * 3 : dotSize
height: dotSize
radius: dotSize / 2
color: isActive ? Theme.primary : Theme.surfaceTextAlpha
anchors.verticalCenter: parent.verticalCenter
Behavior on width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
}
}
}
}
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankActionButton {
visible: windowControls.supported && windowControls.canMaximize
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: windowControls.tryToggleMaximize()
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.skip()
DankTooltip {
text: I18n.tr("Skip setup", "greeter skip button tooltip")
}
}
}
}
Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: headerRow.bottom
anchors.bottom: footerRow.top
anchors.topMargin: Theme.spacingS
Loader {
id: pageLoader
anchors.fill: parent
sourceComponent: root.pageComponents[root.currentPage]
property var greeterRoot: root
}
}
Rectangle {
id: footerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: Math.round(Theme.fontSizeMedium * 4.5)
color: Theme.surfaceContainerHigh
Rectangle {
anchors.top: parent.top
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.5
}
Row {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingL
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankButton {
visible: root.currentPage < root.totalPages - 1
text: I18n.tr("Skip", "greeter skip button")
backgroundColor: "transparent"
textColor: Theme.surfaceVariantText
onClicked: root.skip()
}
DankButton {
visible: root.currentPage > 0
text: I18n.tr("Back", "greeter back button")
iconName: "arrow_back"
backgroundColor: Theme.surfaceContainerHighest
textColor: Theme.surfaceText
onClicked: root.prevPage()
}
DankButton {
visible: root.currentPage < root.totalPages - 1
enabled: !(root.currentPage === 1 && pageLoader.item && pageLoader.item.isRunning)
text: root.currentPage === 0 ? I18n.tr("Get Started", "greeter first page button") : I18n.tr("Next", "greeter next button")
iconName: "arrow_forward"
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: root.nextPage()
}
DankButton {
visible: root.currentPage === root.totalPages - 1
text: I18n.tr("Finish", "greeter finish button")
iconName: "check"
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: root.finish()
}
}
}
}
FloatingWindowControls {
id: windowControls
targetWindow: root
}
Component {
id: welcomePage
GreeterWelcomePage {}
}
Component {
id: doctorPage
GreeterDoctorPage {}
}
Component {
id: completePage
GreeterCompletePage {}
}
}

View File

@@ -0,0 +1,60 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string iconName: ""
property string title: ""
property bool isExternal: false
signal clicked
height: Math.round(Theme.fontSizeMedium * 3.1)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.primary
opacity: mouseArea.containsMouse ? 0.12 : 0
}
Row {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
name: root.iconName
size: Theme.iconSizeSmall + 2
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.title
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
visible: root.isExternal
name: "open_in_new"
size: Theme.iconSizeSmall - 2
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}

View File

@@ -0,0 +1,78 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property string iconName: ""
property string title: ""
property string description: ""
signal clicked
readonly property real iconContainerSize: Math.round(Theme.iconSize * 1.5)
height: Math.round(Theme.fontSizeMedium * 4.5)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Rectangle {
anchors.fill: parent
radius: parent.radius
color: Theme.primary
opacity: mouseArea.containsMouse ? 0.12 : 0
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Rectangle {
width: root.iconContainerSize
height: root.iconContainerSize
radius: Math.round(root.iconContainerSize * 0.28)
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: root.iconName
size: Theme.iconSize - 4
color: Theme.primaryText
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
width: parent.width - root.iconContainerSize - Theme.spacingM
StyledText {
text: root.title
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}

View File

@@ -0,0 +1,75 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: root
property int count: 0
property string label: ""
property string iconName: ""
property color iconColor: Theme.surfaceText
property color bgColor: Theme.surfaceContainerHigh
property bool selected: false
signal clicked
height: Math.round(Theme.fontSizeMedium * 5)
radius: Theme.cornerRadius
color: bgColor
border.width: selected ? 2 : 0
border.color: selected ? iconColor : "transparent"
scale: mouseArea.pressed ? 0.97 : 1
Behavior on scale {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on border.width {
NumberAnimation {
duration: Theme.shortDuration
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
Column {
anchors.centerIn: parent
spacing: Theme.spacingXS
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingS
DankIcon {
name: root.iconName
size: Theme.iconSize - 4
color: root.iconColor
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.count.toString()
font.pixelSize: Theme.fontSizeXLarge
font.weight: Font.Bold
color: root.iconColor
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
text: root.label
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}

View File

@@ -0,0 +1,165 @@
import QtQuick
import QtQuick.Effects
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
readonly property real logoSize: Math.round(Theme.iconSize * 5.3)
Column {
id: mainColumn
anchors.centerIn: parent
width: Math.min(Math.round(Theme.fontSizeMedium * 43), parent.width - Theme.spacingXL * 2)
spacing: Theme.spacingXL
Column {
width: parent.width
spacing: Theme.spacingM
Image {
width: root.logoSize
height: width * (569.94629 / 506.50931)
anchors.horizontalCenter: parent.horizontalCenter
fillMode: Image.PreserveAspectFit
smooth: true
mipmap: true
asynchronous: true
source: "file://" + Theme.shellDir + "/assets/danklogonormal.svg"
layer.enabled: true
layer.smooth: true
layer.mipmap: true
layer.effect: MultiEffect {
saturation: 0
colorization: 1
colorizationColor: Theme.primary
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Welcome to DankMaterialShell", "greeter welcome page title")
font.pixelSize: Theme.fontSizeXLarge + 4
font.weight: Font.Bold
color: Theme.surfaceText
anchors.horizontalCenter: parent.horizontalCenter
}
StyledText {
text: I18n.tr("A modern desktop shell for Wayland compositors", "greeter welcome page tagline")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outlineMedium
opacity: 0.3
}
Column {
width: parent.width
spacing: Theme.spacingM
StyledText {
text: I18n.tr("Features", "greeter welcome page section header")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
Grid {
width: parent.width
columns: 3
rowSpacing: Theme.spacingS
columnSpacing: Theme.spacingS
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "auto_awesome"
title: I18n.tr("Dynamic Theming", "greeter feature card title")
description: I18n.tr("Colors from wallpaper", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("theme")
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "format_paint"
title: I18n.tr("App Theming", "greeter feature card title")
description: I18n.tr("GTK, Qt, IDEs, more", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("theme")
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "download"
title: I18n.tr("Theme Registry", "greeter feature card title")
description: I18n.tr("Community themes", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("theme")
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "view_carousel"
title: I18n.tr("DankBar", "greeter feature card title")
description: I18n.tr("Modular widget bar", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("dankbar_settings")
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "extension"
title: I18n.tr("Plugins", "greeter feature card title")
description: I18n.tr("Extensible architecture", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("plugins")
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "layers"
title: I18n.tr("Multi-Monitor", "greeter feature card title")
description: I18n.tr("Per-screen config", "greeter feature card description")
onClicked: {
const hasDisplayConfig = CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl;
PopoutService.openSettingsWithTab(hasDisplayConfig ? "display_config" : "display_widgets");
}
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "nightlight"
title: I18n.tr("Display Control", "greeter feature card title")
description: I18n.tr("Night mode & gamma", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("display_gamma")
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "tune"
title: I18n.tr("Control Center", "greeter feature card title")
description: I18n.tr("Quick system toggles", "greeter feature card description")
// This is doing an IPC since its just easier and lazier to access the bar ref
onClicked: Quickshell.execDetached(["dms", "ipc", "call", "control-center", "open"])
}
GreeterFeatureCard {
width: (parent.width - Theme.spacingS * 2) / 3
iconName: "lock"
title: I18n.tr("Lock Screen", "greeter feature card title")
description: I18n.tr("Security & privacy", "greeter feature card description")
onClicked: PopoutService.openSettingsWithTab("lock_screen")
}
}
}
}
}

View File

@@ -11,7 +11,6 @@ FloatingWindow {
property var currentFlow: PolkitService.agent?.flow
property bool isLoading: false
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property int calculatedHeight: Math.max(240, headerRow.implicitHeight + mainColumn.implicitHeight + Theme.spacingM * 3)
function focusPasswordField() {
passwordField.forceActiveFocus();
@@ -37,15 +36,19 @@ FloatingWindow {
}
function cancelAuth() {
if (!currentFlow || isLoading)
if (isLoading)
return;
currentFlow.cancelAuthenticationRequest();
if (currentFlow) {
currentFlow.cancelAuthenticationRequest();
return;
}
hide();
}
objectName: "polkitAuthModal"
title: I18n.tr("Authentication")
minimumSize: Qt.size(420, calculatedHeight)
maximumSize: Qt.size(420, calculatedHeight)
minimumSize: Qt.size(460, 220)
maximumSize: Qt.size(460, 220)
color: Theme.surfaceContainer
visible: false
@@ -108,26 +111,25 @@ FloatingWindow {
event.accepted = true;
}
MouseArea {
Item {
id: headerSection
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: headerRow.height + Theme.spacingM
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
}
anchors.margins: Theme.spacingM
height: Math.max(titleColumn.implicitHeight, windowButtonRow.implicitHeight)
Row {
id: headerRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingM
MouseArea {
anchors.fill: parent
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
}
Column {
width: parent.width - 60
id: titleColumn
anchors.left: parent.left
anchors.right: windowButtonRow.left
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingXS
StyledText {
@@ -137,35 +139,38 @@ FloatingWindow {
font.weight: Font.Medium
}
Column {
StyledText {
text: currentFlow?.message ?? ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
width: parent.width
spacing: Theme.spacingXS
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text !== ""
}
StyledText {
text: currentFlow?.message ?? ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
width: parent.width
wrapMode: Text.Wrap
}
StyledText {
visible: (currentFlow?.supplementaryMessage ?? "") !== ""
text: currentFlow?.supplementaryMessage ?? ""
font.pixelSize: Theme.fontSizeSmall
color: (currentFlow?.supplementaryIsError ?? false) ? Theme.error : Theme.surfaceTextMedium
width: parent.width
wrapMode: Text.Wrap
opacity: (currentFlow?.supplementaryIsError ?? false) ? 1 : 0.8
}
StyledText {
text: currentFlow?.supplementaryMessage ?? ""
font.pixelSize: Theme.fontSizeSmall
color: (currentFlow?.supplementaryIsError ?? false) ? Theme.error : Theme.surfaceTextMedium
width: parent.width
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
opacity: (currentFlow?.supplementaryIsError ?? false) ? 1 : 0.8
visible: text !== ""
}
}
Row {
id: windowButtonRow
anchors.right: parent.right
anchors.top: parent.top
spacing: Theme.spacingXS
DankActionButton {
visible: windowControls.supported
visible: windowControls.supported && windowControls.canMaximize
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
@@ -184,21 +189,19 @@ FloatingWindow {
}
Column {
id: mainColumn
id: bottomSection
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: Theme.spacingM
spacing: Theme.spacingM
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
StyledText {
text: currentFlow?.inputPrompt ?? ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width
visible: (currentFlow?.inputPrompt ?? "") !== ""
visible: text !== ""
}
Rectangle {
@@ -223,7 +226,8 @@ FloatingWindow {
font.pixelSize: Theme.fontSizeMedium
textColor: Theme.surfaceText
text: passwordInput
echoMode: (currentFlow?.responseVisible ?? false) ? TextInput.Normal : TextInput.Password
showPasswordToggle: !(currentFlow?.responseVisible ?? false)
echoMode: (currentFlow?.responseVisible ?? false) || passwordVisible ? TextInput.Normal : TextInput.Password
placeholderText: ""
backgroundColor: "transparent"
enabled: !isLoading
@@ -232,38 +236,17 @@ FloatingWindow {
}
}
Item {
StyledText {
text: I18n.tr("Authentication failed, please try again")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
width: parent.width
height: (currentFlow?.failed ?? false) ? failedText.implicitHeight : 0
visible: height > 0
StyledText {
id: failedText
text: I18n.tr("Authentication failed, please try again")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
width: parent.width
opacity: (currentFlow?.failed ?? false) ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Behavior on height {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
visible: currentFlow?.failed ?? false
}
Item {
width: parent.width
height: 40
height: 36
Row {
anchors.right: parent.right

View File

@@ -130,6 +130,7 @@ Rectangle {
color: Theme.surfaceText
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
@@ -138,6 +139,7 @@ Rectangle {
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
}

View File

@@ -34,15 +34,19 @@ FloatingWindow {
}
function showWithTab(tabIndex: int) {
if (tabIndex >= 0)
if (tabIndex >= 0) {
currentTabIndex = tabIndex;
sidebar.autoExpandForTab(tabIndex);
}
visible = true;
}
function showWithTabName(tabName: string) {
var idx = sidebar.resolveTabIndex(tabName);
if (idx >= 0)
if (idx >= 0) {
currentTabIndex = idx;
sidebar.autoExpandForTab(idx);
}
visible = true;
}
@@ -315,8 +319,8 @@ FloatingWindow {
visible: settingsModal.isCompactMode ? settingsModal.menuVisible : true
parentModal: settingsModal
currentIndex: settingsModal.currentTabIndex
onCurrentIndexChanged: {
settingsModal.currentTabIndex = currentIndex;
onTabChangeRequested: tabIndex => {
settingsModal.currentTabIndex = tabIndex;
if (settingsModal.isCompactMode) {
settingsModal.enableAnimations = true;
settingsModal.menuVisible = false;

View File

@@ -15,6 +15,8 @@ Rectangle {
property int currentIndex: 0
property var parentModal: null
signal tabChangeRequested(int tabIndex)
property var expandedCategories: ({})
property var autoExpandedCategories: ({})
property bool searchActive: searchField.text.length > 0
@@ -55,8 +57,9 @@ Rectangle {
if (keyboardHighlightIndex < 0)
return;
var oldIndex = currentIndex;
currentIndex = keyboardHighlightIndex;
autoCollapseIfNeeded(oldIndex, currentIndex);
var newIndex = keyboardHighlightIndex;
tabChangeRequested(newIndex);
autoCollapseIfNeeded(oldIndex, newIndex);
keyboardHighlightIndex = -1;
Qt.callLater(searchField.forceActiveFocus);
}
@@ -398,28 +401,32 @@ Rectangle {
var flatItems = getFlatNavigableItems();
var currentPos = flatItems.findIndex(item => item.tabIndex === currentIndex);
var oldIndex = currentIndex;
var newIndex;
if (currentPos === -1) {
currentIndex = flatItems[0]?.tabIndex ?? 0;
newIndex = flatItems[0]?.tabIndex ?? 0;
} else {
var nextPos = (currentPos + 1) % flatItems.length;
currentIndex = flatItems[nextPos].tabIndex;
newIndex = flatItems[nextPos].tabIndex;
}
autoCollapseIfNeeded(oldIndex, currentIndex);
autoExpandForTab(currentIndex);
tabChangeRequested(newIndex);
autoCollapseIfNeeded(oldIndex, newIndex);
autoExpandForTab(newIndex);
}
function navigatePrevious() {
var flatItems = getFlatNavigableItems();
var currentPos = flatItems.findIndex(item => item.tabIndex === currentIndex);
var oldIndex = currentIndex;
var newIndex;
if (currentPos === -1) {
currentIndex = flatItems[0]?.tabIndex ?? 0;
newIndex = flatItems[0]?.tabIndex ?? 0;
} else {
var prevPos = (currentPos - 1 + flatItems.length) % flatItems.length;
currentIndex = flatItems[prevPos].tabIndex;
newIndex = flatItems[prevPos].tabIndex;
}
autoCollapseIfNeeded(oldIndex, currentIndex);
autoExpandForTab(currentIndex);
tabChangeRequested(newIndex);
autoCollapseIfNeeded(oldIndex, newIndex);
autoExpandForTab(newIndex);
}
function getFlatNavigableItems() {
@@ -488,7 +495,7 @@ Rectangle {
SettingsSearchService.navigateToSection(result.section);
}
var oldIndex = root.currentIndex;
root.currentIndex = result.tabIndex;
tabChangeRequested(result.tabIndex);
autoCollapseIfNeeded(oldIndex, result.tabIndex);
autoExpandForTab(result.tabIndex);
searchField.text = "";
@@ -807,7 +814,7 @@ Rectangle {
if (categoryDelegate.modelData.children) {
root.toggleCategory(categoryDelegate.modelData.id);
} else if (categoryDelegate.modelData.tabIndex !== undefined) {
root.currentIndex = categoryDelegate.modelData.tabIndex;
root.tabChangeRequested(categoryDelegate.modelData.tabIndex);
}
Qt.callLater(searchField.forceActiveFocus);
}
@@ -882,7 +889,7 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onClicked: {
root.keyboardHighlightIndex = -1;
root.currentIndex = childDelegate.modelData.tabIndex;
root.tabChangeRequested(childDelegate.modelData.tabIndex);
Qt.callLater(searchField.forceActiveFocus);
}
}

View File

@@ -151,7 +151,7 @@ Item {
fileSearchController.openSelected();
}
event.accepted = true;
} else if (event.key === Qt.Key_Menu) {
} else if (event.key === Qt.Key_Menu || event.key == Qt.Key_F10) {
if (searchMode === "apps" && appLauncher.model.count > 0) {
const selectedApp = appLauncher.model.get(appLauncher.selectedIndex);
const menu = usePopupContextMenu ? popupContextMenu : layerContextMenuLoader.item;
@@ -467,6 +467,7 @@ Item {
Item {
width: parent.width
height: parent.height - y
opacity: parentModal?.isClosing ? 0 : 1
SpotlightResults {
id: resultsView

View File

@@ -11,12 +11,88 @@ Item {
property int selectedMenuIndex: 0
property bool keyboardNavigation: false
signal hideRequested()
signal hideRequested
readonly property var desktopEntry: (currentApp && !currentApp.isPlugin && appLauncher && appLauncher._uniqueApps && currentApp.appIndex >= 0 && currentApp.appIndex < appLauncher._uniqueApps.length) ? appLauncher._uniqueApps[currentApp.appIndex] : null
readonly property var actualItem: (currentApp && appLauncher && appLauncher._uniqueApps && currentApp.appIndex >= 0 && currentApp.appIndex < appLauncher._uniqueApps.length) ? appLauncher._uniqueApps[currentApp.appIndex] : null
function getPluginContextMenuActions() {
if (!currentApp || !currentApp.isPlugin || !actualItem)
return [];
const pluginId = appLauncher.getPluginIdForItem(actualItem);
if (!pluginId) {
console.log("[ContextMenu] No pluginId found for item:", JSON.stringify(actualItem.categories));
return [];
}
const instance = PluginService.pluginInstances[pluginId];
if (!instance) {
console.log("[ContextMenu] No instance for pluginId:", pluginId);
return [];
}
if (typeof instance.getContextMenuActions !== "function") {
console.log("[ContextMenu] Instance has no getContextMenuActions:", pluginId);
return [];
}
const actions = instance.getContextMenuActions(actualItem);
if (!Array.isArray(actions))
return [];
return actions;
}
function executePluginAction(actionData) {
if (!currentApp || !actualItem)
return;
const pluginId = appLauncher.getPluginIdForItem(actualItem);
if (!pluginId)
return;
const instance = PluginService.pluginInstances[pluginId];
if (!instance)
return;
if (typeof actionData === "function") {
actionData();
} else if (typeof instance.executeContextMenuAction === "function") {
instance.executeContextMenuAction(actualItem, actionData);
}
if (appLauncher)
appLauncher.updateFilteredModel();
hideRequested();
}
readonly property var menuItems: {
const items = [];
if (currentApp && currentApp.isPlugin) {
const pluginActions = getPluginContextMenuActions();
for (let i = 0; i < pluginActions.length; i++) {
const act = pluginActions[i];
items.push({
type: "item",
icon: act.icon || "",
text: act.text || act.name || "",
action: () => executePluginAction(act.action)
});
}
if (items.length === 0) {
items.push({
type: "item",
icon: "content_copy",
text: I18n.tr("Copy"),
action: launchCurrentApp
});
}
return items;
}
const appId = desktopEntry ? (desktopEntry.id || desktopEntry.execString || "") : "";
const isPinned = SessionData.isPinnedApp(appId);
@@ -172,18 +248,25 @@ Item {
focus: keyboardNavigation
Keys.onPressed: event => {
if (event.key === Qt.Key_Down) {
switch (event.key) {
case Qt.Key_Down:
selectNext();
event.accepted = true;
} else if (event.key === Qt.Key_Up) {
break;
case Qt.Key_Up:
selectPrevious();
event.accepted = true;
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
break;
case Qt.Key_Return:
case Qt.Key_Enter:
activateSelected();
event.accepted = true;
} else if (event.key === Qt.Key_Escape) {
break;
case Qt.Key_Escape:
case Qt.Key_Left:
hideRequested();
event.accepted = true;
break;
}
}

View File

@@ -3,7 +3,6 @@ import Quickshell.Hyprland
import Quickshell.Io
import qs.Common
import qs.Modals.Common
import qs.Services
DankModal {
id: spotlightModal
@@ -18,62 +17,64 @@ DankModal {
property bool spotlightOpen: false
property alias spotlightContent: spotlightContentInstance
property bool openedFromOverview: false
property bool isClosing: false
function resetContent() {
if (!spotlightContent)
return;
if (spotlightContent.appLauncher)
spotlightContent.appLauncher.reset();
if (spotlightContent.fileSearchController)
spotlightContent.fileSearchController.reset();
if (spotlightContent.resetScroll)
spotlightContent.resetScroll();
if (spotlightContent.searchField)
spotlightContent.searchField.text = "";
spotlightContent.searchMode = "apps";
}
function show() {
openedFromOverview = false;
isClosing = false;
resetContent();
spotlightOpen = true;
open();
Qt.callLater(() => {
if (spotlightContent && spotlightContent.searchField) {
if (spotlightContent?.appLauncher)
spotlightContent.appLauncher.ensureInitialized();
if (spotlightContent?.searchField)
spotlightContent.searchField.forceActiveFocus();
}
});
}
function showWithQuery(query) {
if (spotlightContent) {
if (spotlightContent.appLauncher) {
openedFromOverview = false;
isClosing = false;
resetContent();
spotlightOpen = true;
if (spotlightContent?.searchField)
spotlightContent.searchField.text = query;
open();
Qt.callLater(() => {
if (spotlightContent?.appLauncher) {
spotlightContent.appLauncher.ensureInitialized();
spotlightContent.appLauncher.searchQuery = query;
}
if (spotlightContent.searchField) {
spotlightContent.searchField.text = query;
}
}
spotlightOpen = true;
open();
Qt.callLater(() => {
if (spotlightContent && spotlightContent.searchField) {
if (spotlightContent?.searchField)
spotlightContent.searchField.forceActiveFocus();
}
});
}
function hide() {
openedFromOverview = false;
isClosing = true;
spotlightOpen = false;
close();
}
onDialogClosed: {
if (spotlightContent) {
if (spotlightContent.appLauncher) {
spotlightContent.appLauncher.searchQuery = "";
spotlightContent.appLauncher.selectedIndex = 0;
spotlightContent.appLauncher.setCategory(I18n.tr("All"));
}
if (spotlightContent.fileSearchController) {
spotlightContent.fileSearchController.reset();
}
if (spotlightContent.resetScroll) {
spotlightContent.resetScroll();
}
if (spotlightContent.searchField) {
spotlightContent.searchField.text = "";
}
}
isClosing = false;
resetContent();
}
function toggle() {

View File

@@ -11,6 +11,7 @@ FloatingWindow {
property string wifiPasswordInput: ""
property string wifiUsernameInput: ""
property bool requiresEnterprise: false
property bool isHiddenNetwork: false
property string wifiAnonymousIdentityInput: ""
property string wifiDomainInput: ""
@@ -32,7 +33,6 @@ FloatingWindow {
readonly property bool showPasswordField: fieldsInfo.length === 0
readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt
readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt
readonly property bool showShowPasswordCheckbox: fieldsInfo.length === 0
readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11"
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
@@ -44,12 +44,18 @@ FloatingWindow {
property int calculatedHeight: {
let h = headerHeight + buttonRowHeight + Theme.spacingL * 2;
h += fieldsInfo.length * inputFieldWithSpacing;
if (showUsernameField) h += inputFieldWithSpacing;
if (showPasswordField) h += inputFieldWithSpacing;
if (showAnonField) h += inputFieldWithSpacing;
if (showDomainField) h += inputFieldWithSpacing;
if (showShowPasswordCheckbox) h += checkboxRowHeight;
if (showSavePasswordCheckbox) h += checkboxRowHeight;
if (isHiddenNetwork)
h += inputFieldWithSpacing;
if (showUsernameField)
h += inputFieldWithSpacing;
if (showPasswordField)
h += inputFieldWithSpacing;
if (showAnonField)
h += inputFieldWithSpacing;
if (showDomainField)
h += inputFieldWithSpacing;
if (showSavePasswordCheckbox)
h += checkboxRowHeight;
return h;
}
@@ -62,6 +68,10 @@ FloatingWindow {
}
return;
}
if (isHiddenNetwork) {
ssidInput.forceActiveFocus();
return;
}
if (requiresEnterprise && !isVpnPrompt) {
usernameInput.forceActiveFocus();
return;
@@ -76,6 +86,7 @@ FloatingWindow {
wifiAnonymousIdentityInput = "";
wifiDomainInput = "";
isPromptMode = false;
isHiddenNetwork = false;
promptToken = "";
promptReason = "";
promptFields = [];
@@ -94,6 +105,30 @@ FloatingWindow {
Qt.callLater(focusFirstField);
}
function showHidden() {
wifiPasswordSSID = "";
wifiPasswordInput = "";
wifiUsernameInput = "";
wifiAnonymousIdentityInput = "";
wifiDomainInput = "";
isPromptMode = false;
isHiddenNetwork = true;
promptToken = "";
promptReason = "";
promptFields = [];
promptSetting = "";
isVpnPrompt = false;
connectionName = "";
vpnServiceType = "";
connectionType = "";
fieldsInfo = [];
secretValues = {};
requiresEnterprise = false;
visible = true;
Qt.callLater(focusFirstField);
}
function showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fInfo) {
isPromptMode = true;
promptToken = token;
@@ -178,8 +213,9 @@ FloatingWindow {
}
NetworkService.submitCredentials(promptToken, secrets, savePasswordCheckbox.checked);
} else {
const ssid = isHiddenNetwork ? ssidInput.text : wifiPasswordSSID;
const username = requiresEnterprise ? usernameInput.text : "";
NetworkService.connectToWifi(wifiPasswordSSID, passwordInput.text, username, wifiAnonymousIdentityInput, wifiDomainInput);
NetworkService.connectToWifi(ssid, passwordInput.text, username, wifiAnonymousIdentityInput, wifiDomainInput, isHiddenNetwork);
}
hide();
@@ -190,6 +226,8 @@ FloatingWindow {
passwordInput.text = "";
if (requiresEnterprise)
usernameInput.text = "";
if (isHiddenNetwork)
ssidInput.text = "";
}
function clearAndClose() {
@@ -209,6 +247,8 @@ FloatingWindow {
return I18n.tr("Smartcard PIN");
if (isVpnPrompt)
return I18n.tr("VPN Password");
if (isHiddenNetwork)
return I18n.tr("Hidden Network");
return I18n.tr("Wi-Fi Password");
}
minimumSize: Qt.size(420, calculatedHeight)
@@ -230,6 +270,7 @@ FloatingWindow {
usernameInput.text = "";
anonInput.text = "";
domainMatchInput.text = "";
ssidInput.text = "";
for (var i = 0; i < dynamicFieldsRepeater.count; i++) {
const item = dynamicFieldsRepeater.itemAt(i);
if (item?.children[0])
@@ -267,11 +308,14 @@ FloatingWindow {
width: parent.width - Theme.spacingL * 2
spacing: Theme.spacingM
Row {
Item {
width: contentCol.width
height: Math.max(headerCol.height, buttonRow.height)
MouseArea {
width: parent.width - 60
anchors.left: parent.left
anchors.right: buttonRow.left
anchors.rightMargin: Theme.spacingM
height: headerCol.height
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
@@ -287,6 +331,8 @@ FloatingWindow {
return I18n.tr("Smartcard Authentication");
if (isVpnPrompt)
return I18n.tr("Connect to VPN");
if (isHiddenNetwork)
return I18n.tr("Connect to Hidden Network");
return I18n.tr("Connect to Wi-Fi");
}
font.pixelSize: Theme.fontSizeLarge
@@ -306,6 +352,8 @@ FloatingWindow {
return I18n.tr("Enter credentials for ") + wifiPasswordSSID;
if (isVpnPrompt)
return I18n.tr("Enter password for ") + wifiPasswordSSID;
if (isHiddenNetwork)
return I18n.tr("Enter network name and password");
const prefix = requiresEnterprise ? I18n.tr("Enter credentials for ") : I18n.tr("Enter password for ");
return prefix + wifiPasswordSSID;
}
@@ -327,10 +375,12 @@ FloatingWindow {
}
Row {
id: buttonRow
anchors.right: parent.right
spacing: Theme.spacingXS
DankActionButton {
visible: windowControls.supported
visible: windowControls.supported && windowControls.canMaximize
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
@@ -346,6 +396,34 @@ FloatingWindow {
}
}
Rectangle {
width: parent.width
height: inputFieldHeight
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: ssidInput.activeFocus ? Theme.primary : Theme.outlineStrong
border.width: ssidInput.activeFocus ? 2 : 1
visible: isHiddenNetwork
MouseArea {
anchors.fill: parent
onClicked: ssidInput.forceActiveFocus()
}
DankTextField {
id: ssidInput
anchors.fill: parent
font.pixelSize: Theme.fontSizeMedium
textColor: Theme.surfaceText
placeholderText: I18n.tr("Network Name (SSID)")
backgroundColor: "transparent"
enabled: root.visible
keyNavigationTab: passwordInput
onAccepted: passwordInput.forceActiveFocus()
}
}
Repeater {
id: dynamicFieldsRepeater
model: fieldsInfo
@@ -366,7 +444,8 @@ FloatingWindow {
anchors.fill: parent
font.pixelSize: Theme.fontSizeMedium
textColor: Theme.surfaceText
echoMode: modelData.isSecret ? TextInput.Password : TextInput.Normal
showPasswordToggle: modelData.isSecret
echoMode: modelData.isSecret && !passwordVisible ? TextInput.Password : TextInput.Normal
placeholderText: getFieldLabel(modelData.name)
backgroundColor: "transparent"
enabled: root.visible
@@ -468,7 +547,8 @@ FloatingWindow {
font.pixelSize: Theme.fontSizeMedium
textColor: Theme.surfaceText
text: wifiPasswordInput
echoMode: showPasswordCheckbox.checked ? TextInput.Normal : TextInput.Password
showPasswordToggle: true
echoMode: passwordVisible ? TextInput.Normal : TextInput.Password
placeholderText: (requiresEnterprise && !isVpnPrompt) ? I18n.tr("Password") : ""
backgroundColor: "transparent"
enabled: root.visible
@@ -547,88 +627,43 @@ FloatingWindow {
}
}
Column {
Row {
spacing: Theme.spacingS
width: parent.width
visible: showSavePasswordCheckbox
Row {
spacing: Theme.spacingS
visible: showShowPasswordCheckbox
Rectangle {
id: savePasswordCheckbox
Rectangle {
id: showPasswordCheckbox
property bool checked: true
property bool checked: false
width: 20
height: 20
radius: 4
color: checked ? Theme.primary : "transparent"
border.color: checked ? Theme.primary : Theme.outlineButton
border.width: 2
width: 20
height: 20
radius: 4
color: checked ? Theme.primary : "transparent"
border.color: checked ? Theme.primary : Theme.outlineButton
border.width: 2
DankIcon {
anchors.centerIn: parent
name: "check"
size: 12
color: Theme.background
visible: parent.checked
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: showPasswordCheckbox.checked = !showPasswordCheckbox.checked
}
DankIcon {
anchors.centerIn: parent
name: "check"
size: 12
color: Theme.background
visible: parent.checked
}
StyledText {
text: I18n.tr("Show password")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: savePasswordCheckbox.checked = !savePasswordCheckbox.checked
}
}
Row {
spacing: Theme.spacingS
visible: showSavePasswordCheckbox
Rectangle {
id: savePasswordCheckbox
property bool checked: false
width: 20
height: 20
radius: 4
color: checked ? Theme.primary : "transparent"
border.color: checked ? Theme.primary : Theme.outlineButton
border.width: 2
DankIcon {
anchors.centerIn: parent
name: "check"
size: 12
color: Theme.background
visible: parent.checked
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: savePasswordCheckbox.checked = !savePasswordCheckbox.checked
}
}
StyledText {
text: I18n.tr("Save password")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Save password")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
@@ -685,6 +720,8 @@ FloatingWindow {
}
if (isVpnPrompt)
return passwordInput.text.length > 0;
if (isHiddenNetwork)
return ssidInput.text.length > 0;
return requiresEnterprise ? (usernameInput.text.length > 0 && passwordInput.text.length > 0) : passwordInput.text.length > 0;
}
opacity: enabled ? 1 : 0.5

View File

@@ -52,6 +52,7 @@ DankPopout {
onOpened: {
searchMode = "apps";
appLauncher.ensureInitialized();
appLauncher.searchQuery = "";
appLauncher.selectedIndex = 0;
appLauncher.setCategory(I18n.tr("All"));
@@ -344,7 +345,7 @@ DankPopout {
width: parent.width - Theme.spacingS * 2
height: 40
anchors.horizontalCenter: parent.horizontalCenter
visible: searchField.text.length === 0 && appDrawerPopout.searchMode === "apps"
visible: appDrawerPopout.searchMode === "apps"
Rectangle {
width: 180
@@ -404,7 +405,7 @@ DankPopout {
height: {
let usedHeight = 40 + Theme.spacingS;
usedHeight += 52 + Theme.spacingS;
usedHeight += (searchField.text.length === 0 && appDrawerPopout.searchMode === "apps" ? 40 : 0);
usedHeight += appDrawerPopout.searchMode === "apps" ? 40 : 0;
return parent.height - usedHeight;
}
radius: Theme.cornerRadius

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