1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-16 16:15:23 -04:00

Compare commits

...

3 Commits

Author SHA1 Message Date
purian23 e50ac208e3 feat(mangowm): add live config reloads & misc QOL updates
- Hide workspace tags during Mango overview
- Add HJKL focus/move defaults
- Add Mango natural touchpad scrolling &  cursor configs
- Fix Mango startup
2026-06-05 10:53:26 -04:00
bbedward bcb5617194 plugins: add support for composite plugins
- single plugin can register multiple types - e.g. daemon, bar widget,
  desktop widget
2026-06-05 10:33:34 -04:00
bbedward d3c23ba737 settings: add missing tabs to index and tweak search scoring 2026-06-05 09:49:49 -04:00
28 changed files with 2452 additions and 226 deletions
+8 -1
View File
@@ -294,7 +294,14 @@ func runSetup() error {
wm, wmSelected := promptCompositor()
terminal, terminalSelected := promptTerminal()
useSystemd := promptSystemd()
useSystemd := true
if wmSelected {
if wm == deps.WindowManagerMango {
useSystemd = false
} else {
useSystemd = promptSystemd()
}
}
if !wmSelected && !terminalSelected {
fmt.Println("No configurations selected. Exiting.")
+12
View File
@@ -520,6 +520,18 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandLuaConfig, "input =")
}
func TestMangoConfigStructure(t *testing.T) {
assert.Contains(t, MangoConfig, "exec-once=dms run")
assert.NotContains(t, MangoConfig, "exec_once=dms run")
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,K,focusdir,up")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,L,focusdir,right")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,right,3,viewtoleft_have_client")
assert.Contains(t, MangoBindsConfig, "gesturebind=none,left,3,viewtoright_have_client")
}
func TestGhosttyConfigStructure(t *testing.T) {
assert.Contains(t, GhosttyConfig, "window-decoration = false")
assert.Contains(t, GhosttyConfig, "background-opacity = 1.0")
+13 -55
View File
@@ -1,7 +1,6 @@
# DMS default keybinds (MangoWM) — managed by DMS, regenerated by `dms setup`.
# Format: bind=MODS,key,action[,args]
# Descriptions go on the line ABOVE each bind (mango does not strip inline
# comments — a trailing `# ...` would be passed to spawn as extra arguments).
# Put bind descriptions above bind lines; inline # comments break Mango spawn args.
# === Application Launchers ===
# Open Terminal
@@ -52,131 +51,90 @@ bind=CTRL,Print,spawn,dms screenshot full
bind=ALT,Print,spawn,dms screenshot window
# === Audio Controls ===
# Volume Up
bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3
# Volume Down
bind=none,XF86AudioLowerVolume,spawn,dms ipc call audio decrement 3
# Mute Output
bind=none,XF86AudioMute,spawn,dms ipc call audio mute
# Mute Microphone
bind=none,XF86AudioMicMute,spawn,dms ipc call audio micmute
# Play/Pause
bind=none,XF86AudioPlay,spawn,dms ipc call mpris playPause
# Play/Pause
bind=none,XF86AudioPause,spawn,dms ipc call mpris playPause
# Previous Track
bind=none,XF86AudioPrev,spawn,dms ipc call mpris previous
# Next Track
bind=none,XF86AudioNext,spawn,dms ipc call mpris next
# === Brightness Controls ===
# Brightness Up
bind=none,XF86MonBrightnessUp,spawn,dms ipc call brightness increment 5
# Brightness Down
bind=none,XF86MonBrightnessDown,spawn,dms ipc call brightness decrement 5
# === Window Management ===
# Close Window
bind=SUPER,q,killclient,
# Toggle Fullscreen
bind=SUPER,f,togglefullscreen,
# Toggle Maximize
bind=SUPER,a,togglemaximizescreen,
# Toggle Floating
bind=SUPER+SHIFT,space,togglefloating,
# Toggle Overview
bind=SUPER,o,toggleoverview
bind=ALT,Tab,toggleoverview
# Exit Compositor
bind=SUPER+SHIFT,e,quit,
# === Focus Navigation ===
# Focus Next Window
bind=SUPER,Tab,focusstack,next
# Focus Previous Window
bind=SUPER+SHIFT,Tab,focusstack,prev
# Focus Left
bind=SUPER,Left,focusdir,left
# Focus Right
bind=SUPER,H,focusdir,left
bind=SUPER,Right,focusdir,right
# Focus Up
bind=SUPER,L,focusdir,right
bind=SUPER,Up,focusdir,up
# Focus Down
bind=SUPER,K,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,J,focusdir,down
# === Window Movement ===
# Move Window Left
bind=SUPER+SHIFT,Left,exchange_client,left
# Move Window Right
bind=SUPER+SHIFT,Right,exchange_client,right
# Move Window Up
bind=SUPER+SHIFT,Up,exchange_client,up
# Move Window Down
bind=SUPER+SHIFT,Down,exchange_client,down
bind=SUPER+SHIFT,H,exchange_client,left
bind=SUPER+SHIFT,L,exchange_client,right
bind=SUPER+SHIFT,K,exchange_client,up
bind=SUPER+SHIFT,J,exchange_client,down
# === Monitor Navigation ===
# Focus Monitor Left
bind=SUPER+ALT,Left,focusmon,left
# Focus Monitor Right
bind=SUPER+ALT,Right,focusmon,right
# Move to Monitor Left
bind=SUPER+ALT+SHIFT,Left,tagmon,left
# Move to Monitor Right
bind=SUPER+ALT+SHIFT,Right,tagmon,right
# === Layout ===
# Cycle Layout
bind=SUPER,j,switch_layout
# Increase Gaps
# Cycle Layout - Gaps, Floating, Tiling
bind=SUPER+ALT,j,switch_layout
bind=SUPER+SHIFT,equal,incgaps,1
# Decrease Gaps
bind=SUPER+SHIFT,minus,incgaps,-1
# === Tags (1-9): view tag ===
# View Tag 1
bind=SUPER,1,view,1
# View Tag 2
bind=SUPER,2,view,2
# View Tag 3
bind=SUPER,3,view,3
# View Tag 4
bind=SUPER,4,view,4
# View Tag 5
bind=SUPER,5,view,5
# View Tag 6
bind=SUPER,6,view,6
# View Tag 7
bind=SUPER,7,view,7
# View Tag 8
bind=SUPER,8,view,8
# View Tag 9
bind=SUPER,9,view,9
# === Tags (1-9): move focused window to tag ===
# Move to Tag 1
bind=SUPER+SHIFT,1,tag,1
# Move to Tag 2
bind=SUPER+SHIFT,2,tag,2
# Move to Tag 3
bind=SUPER+SHIFT,3,tag,3
# Move to Tag 4
bind=SUPER+SHIFT,4,tag,4
# Move to Tag 5
bind=SUPER+SHIFT,5,tag,5
# Move to Tag 6
bind=SUPER+SHIFT,6,tag,6
# Move to Tag 7
bind=SUPER+SHIFT,7,tag,7
# Move to Tag 8
bind=SUPER+SHIFT,8,tag,8
# Move to Tag 9
bind=SUPER+SHIFT,9,tag,9
# === Touchpad Gestures ===
# Syntax: gesturebind=MODIFIERS,DIRECTION,FINGERS,COMMAND,PARAMETERS
# 3-finger horizontal swipe: switch between occupied workspaces
gesturebind=none,left,3,viewtoleft_have_client
gesturebind=none,right,3,viewtoright_have_client
gesturebind=none,right,3,viewtoleft_have_client
gesturebind=none,left,3,viewtoright_have_client
# 4-finger vertical swipe: toggle the overview
gesturebind=none,up,4,toggleoverview
gesturebind=none,down,4,toggleoverview
+2 -2
View File
@@ -5,10 +5,10 @@
env=XDG_CURRENT_DESKTOP,mango
env=XDG_SESSION_TYPE,wayland
# exec_once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
exec_once=dms run
exec-once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
+272 -55
View File
@@ -7,6 +7,7 @@ import (
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
@@ -228,11 +229,20 @@ func (m *MangoWCProvider) SetBind(key, action, description string, options map[s
}
normalizedKey := strings.ToLower(key)
prefix := "bind"
if existing, ok := existingBinds[normalizedKey]; ok && existing.Prefix != "" {
prefix = existing.Prefix
}
if optionPrefix := m.bindPrefixFromOptions(options); optionPrefix != "" {
prefix = optionPrefix
}
existingBinds[normalizedKey] = &mangowcOverrideBind{
Key: key,
Action: action,
Description: description,
Options: options,
Prefix: prefix,
}
return m.writeOverrideBinds(existingBinds)
@@ -246,7 +256,7 @@ func (m *MangoWCProvider) RemoveBind(key string) error {
normalizedKey := strings.ToLower(key)
delete(existingBinds, normalizedKey)
return m.writeOverrideBinds(existingBinds)
return m.writeOverrideBindsWithRemoved(existingBinds, map[string]bool{normalizedKey: true})
}
func (m *MangoWCProvider) ResetBind(key string) error {
@@ -258,6 +268,7 @@ type mangowcOverrideBind struct {
Action string
Description string
Options map[string]any
Prefix string
}
func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) {
@@ -272,62 +283,99 @@ func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind,
return nil, err
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
var pendingComment string
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue
}
if !strings.HasPrefix(line, "bind") {
bind, ok := m.parseOverrideBindLine(line, pendingComment)
pendingComment = ""
if !ok || bind == nil {
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,
}
binds[strings.ToLower(bind.Key)] = bind
}
return binds, nil
}
func (m *MangoWCProvider) parseOverrideBindLine(line, precedingComment string) (*mangowcOverrideBind, bool) {
trimmed := strings.TrimSpace(line)
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) < 2 {
return nil, false
}
prefix := strings.TrimSpace(parts[0])
if !m.isBindPrefix(prefix) {
return nil, false
}
content := strings.TrimSpace(parts[1])
commentParts := strings.SplitN(content, "#", 2)
bindContent := strings.TrimSpace(commentParts[0])
description := strings.TrimSpace(precedingComment)
if isMangoWCSectionComment(description) {
description = ""
}
if len(commentParts) > 1 {
description = strings.TrimSpace(commentParts[1])
}
if strings.HasPrefix(description, MangoWCHideComment) {
return nil, true
}
fields := strings.SplitN(bindContent, ",", 4)
if len(fields) < 3 {
return nil, false
}
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])
}
action := command
if params != "" {
action = command + " " + params
}
return &mangowcOverrideBind{
Key: m.buildKeyString(mods, keyName),
Action: action,
Description: description,
Prefix: prefix,
}, true
}
func (m *MangoWCProvider) isBindPrefix(prefix string) bool {
if !strings.HasPrefix(prefix, "bind") {
return false
}
for _, ch := range strings.TrimPrefix(prefix, "bind") {
if !strings.ContainsRune("lsrp", ch) {
return false
}
}
return true
}
func (m *MangoWCProvider) buildKeyString(mods, key string) string {
if mods == "" || strings.EqualFold(mods, "none") {
return key
@@ -362,21 +410,113 @@ func (m *MangoWCProvider) getBindSortPriority(action string) int {
}
func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error {
return m.writeOverrideBindsWithRemoved(binds, nil)
}
func (m *MangoWCProvider) writeOverrideBindsWithRemoved(binds map[string]*mangowcOverrideBind, removed map[string]bool) error {
overridePath := m.GetOverridePath()
content := m.generateBindsContent(binds)
existingContent := ""
if data, err := os.ReadFile(overridePath); err == nil {
existingContent = string(data)
}
content := m.generatePreservedBindsContent(existingContent, binds, removed)
return os.WriteFile(overridePath, []byte(content), 0o644)
}
func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string {
if len(binds) == 0 {
return ""
func (m *MangoWCProvider) generatePreservedBindsContent(existingContent string, binds map[string]*mangowcOverrideBind, removed map[string]bool) string {
useStockScaffold := m.shouldUseStockScaffold(existingContent)
source := existingContent
if useStockScaffold {
source = m.stockBindsScaffold(binds)
}
remaining := make(map[string]*mangowcOverrideBind, len(binds))
for key, bind := range binds {
remaining[key] = bind
}
if useStockScaffold {
m.dropReplacedStockBinds(remaining)
}
var lines []string
for _, line := range strings.Split(source, "\n") {
templateBind, ok := m.parseOverrideBindLine(line, m.previousComment(lines))
if !ok || templateBind == nil {
lines = append(lines, line)
continue
}
normalizedKey := strings.ToLower(templateBind.Key)
m.dropPreviousDescriptionComment(&lines)
if bind, exists := remaining[normalizedKey]; exists {
if useStockScaffold && bind.Description == "" {
bind = m.copyBindWithDescription(bind, templateBind.Description)
}
m.writeBindLineToLines(&lines, bind)
delete(remaining, normalizedKey)
continue
}
if useStockScaffold && !removed[normalizedKey] {
m.writeBindLineToLines(&lines, templateBind)
}
}
if len(remaining) > 0 {
m.trimTrailingEmptyLines(&lines)
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, "# === Custom Keybinds ===")
for _, bind := range m.sortedBinds(remaining) {
m.writeBindLineToLines(&lines, bind)
}
}
m.trimTrailingEmptyLines(&lines)
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n") + "\n"
}
func (m *MangoWCProvider) shouldUseStockScaffold(content string) bool {
if strings.TrimSpace(content) == "" {
return true
}
if strings.Contains(content, "gesturebind=") && strings.Contains(content, "# ===") {
return false
}
return !strings.Contains(content, "gesturebind=") && (strings.Count(content, "\nbind=")+strings.Count(content, "\nbindl=")+strings.Count(content, "\nbinds=")+strings.Count(content, "\nbindr=")+strings.Count(content, "\nbindp=") >= 10 || strings.Contains(content, "dms ipc call"))
}
func (m *MangoWCProvider) stockBindsScaffold(binds map[string]*mangowcOverrideBind) string {
terminalCommand := "ghostty"
for _, key := range []string{"super+t", "super+return"} {
if bind, ok := binds[key]; ok {
command, params := m.parseAction(bind.Action)
if command == "spawn" && strings.TrimSpace(params) != "" && !strings.Contains(params, "dms ") {
terminalCommand = params
break
}
}
}
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
}
func (m *MangoWCProvider) dropReplacedStockBinds(binds map[string]*mangowcOverrideBind) {
if bind, ok := binds["super+j"]; ok && bind.Action == "switch_layout" {
delete(binds, "super+j")
}
}
func (m *MangoWCProvider) sortedBinds(binds map[string]*mangowcOverrideBind) []*mangowcOverrideBind {
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 {
@@ -384,13 +524,55 @@ func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverride
}
return bindList[i].Key < bindList[j].Key
})
return bindList
}
func (m *MangoWCProvider) writeBindLineToLines(lines *[]string, bind *mangowcOverrideBind) {
var sb strings.Builder
for _, bind := range bindList {
m.writeBindLine(&sb, bind)
m.writeBindLine(&sb, bind)
text := strings.TrimSuffix(sb.String(), "\n")
if text == "" {
return
}
*lines = append(*lines, strings.Split(text, "\n")...)
}
return sb.String()
func (m *MangoWCProvider) previousComment(lines []string) string {
if len(lines) == 0 {
return ""
}
trimmed := strings.TrimSpace(lines[len(lines)-1])
if !strings.HasPrefix(trimmed, "#") {
return ""
}
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(comment) {
return ""
}
return comment
}
func (m *MangoWCProvider) dropPreviousDescriptionComment(lines *[]string) {
if len(*lines) == 0 {
return
}
trimmed := strings.TrimSpace((*lines)[len(*lines)-1])
if !strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "# ===") {
return
}
*lines = (*lines)[:len(*lines)-1]
}
func (m *MangoWCProvider) trimTrailingEmptyLines(lines *[]string) {
for len(*lines) > 0 && strings.TrimSpace((*lines)[len(*lines)-1]) == "" {
*lines = (*lines)[:len(*lines)-1]
}
}
func (m *MangoWCProvider) copyBindWithDescription(bind *mangowcOverrideBind, description string) *mangowcOverrideBind {
copy := *bind
copy.Description = description
return &copy
}
func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) {
@@ -405,7 +587,12 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
sb.WriteString("\n")
}
sb.WriteString("bind=")
prefix := bind.Prefix
if prefix == "" {
prefix = "bind"
}
sb.WriteString(prefix)
sb.WriteString("=")
if mods == "" {
sb.WriteString("none")
} else {
@@ -424,6 +611,36 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
sb.WriteString("\n")
}
func (m *MangoWCProvider) bindPrefixFromOptions(options map[string]any) string {
if options == nil {
return ""
}
value, ok := options["flags"]
if !ok {
return ""
}
flags := ""
switch v := value.(type) {
case string:
flags = v
case fmt.Stringer:
flags = v.String()
default:
return ""
}
flags = strings.TrimSpace(flags)
if flags == "" {
return "bind"
}
var clean strings.Builder
for _, ch := range flags {
if strings.ContainsRune("lsrp", ch) && !strings.ContainsRune(clean.String(), ch) {
clean.WriteRune(ch)
}
}
return "bind" + clean.String()
}
func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) {
parts := strings.Split(keyStr, "+")
switch len(parts) {
@@ -15,6 +15,10 @@ const (
var MangoWCModSeparators = []rune{'+', ' '}
func isMangoWCSectionComment(comment string) bool {
return strings.HasPrefix(strings.TrimSpace(comment), "===")
}
type MangoWCKeyBinding struct {
Mods []string `json:"mods"`
Key string `json:"key"`
@@ -235,6 +239,9 @@ func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue
}
if !strings.HasPrefix(trimmed, "bind") {
@@ -414,6 +421,9 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if isMangoWCSectionComment(pendingComment) {
pendingComment = ""
}
continue
}
@@ -483,7 +493,7 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
// line directly above) is the description: mango feeds inline comments to spawn
// as argv, so DMS keeps descriptions on the line above; inline `#` is a fallback.
func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment string) *MangoWCKeyBinding {
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
bindMatch := regexp.MustCompile(`^(bind[lsrp]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
@@ -499,6 +509,9 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, precedingComment st
}
if comment == "" {
comment = strings.TrimSpace(precedingComment)
if isMangoWCSectionComment(comment) {
comment = ""
}
}
if strings.HasPrefix(comment, MangoWCHideComment) {
@@ -71,9 +71,10 @@ func TestMangoWCAutogenerateComment(t *testing.T) {
func TestMangoWCGetKeybindAtLine(t *testing.T) {
tests := []struct {
name string
line string
expected *MangoWCKeyBinding
name string
line string
precedingComment string
expected *MangoWCKeyBinding
}{
{
name: "basic_keybind",
@@ -157,6 +158,41 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
Comment: "dms ipc call lock lock",
},
},
{
name: "bindp_flag",
line: "bindp=SUPER,p,spawn,pass-through",
expected: &MangoWCKeyBinding{
Mods: []string{"SUPER"},
Key: "p",
Command: "spawn",
Params: "pass-through",
Comment: "pass-through",
},
},
{
name: "preceding_comment",
line: "bind=SUPER+SHIFT,S,spawn,dms screenshot",
precedingComment: "Screenshot: Interactive",
expected: &MangoWCKeyBinding{
Mods: []string{"SUPER", "SHIFT"},
Key: "S",
Command: "spawn",
Params: "dms screenshot",
Comment: "Screenshot: Interactive",
},
},
{
name: "section_header_not_description",
line: "bind=none,XF86AudioRaiseVolume,spawn,dms ipc call audio increment 3",
precedingComment: "=== Audio Controls ===",
expected: &MangoWCKeyBinding{
Mods: []string{},
Key: "XF86AudioRaiseVolume",
Command: "spawn",
Params: "dms ipc call audio increment 3",
Comment: "dms ipc call audio increment 3",
},
},
{
name: "keybind_with_spaces",
line: "bind = SUPER, r, reload_config",
@@ -174,7 +210,7 @@ func TestMangoWCGetKeybindAtLine(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
parser := NewMangoWCParser("")
parser.contentLines = []string{tt.line}
result := parser.getKeybindAtLine(0, "")
result := parser.getKeybindAtLine(0, tt.precedingComment)
if tt.expected == nil {
if result != nil {
@@ -3,7 +3,10 @@ package providers
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
)
func TestMangoWCProviderName(t *testing.T) {
@@ -318,3 +321,138 @@ bind=Ctrl,1,view,1,0
t.Error("Did not find terminal keybind with correct key and description")
}
}
func TestMangoWCSetBindPreservesStockCommentsAndGestures(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
t.Fatalf("failed to write stock binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
for _, want := range []string{
"# === Application Launchers ===",
"# === Touchpad Gestures ===",
"gesturebind=none,right,3,viewtoleft_have_client",
"gesturebind=none,left,3,viewtoright_have_client",
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected saved binds to contain %q\ncontent:\n%s", want, content)
}
}
if strings.Contains(content, "# === Audio Controls ===\n# === Audio Controls ===") {
t.Fatalf("section header should not be duplicated as a bind description\ncontent:\n%s", content)
}
}
func TestMangoWCSetBindRestoresScaffoldForStrippedFile(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stripped := `bind=SUPER,t,spawn,ghostty
bind=SUPER,Return,spawn,ghostty
bind=SUPER,space,spawn,dms ipc call spotlight toggle
bind=SUPER,v,spawn,dms ipc call clipboard toggle
bind=SUPER,q,killclient
bind=SUPER,Left,focusdir,left
bind=SUPER,Right,focusdir,right
bind=SUPER,Up,focusdir,up
bind=SUPER,Down,focusdir,down
bind=SUPER,1,view,1
bind=SUPER,2,view,2
bind=SUPER,3,view,3
`
if err := os.WriteFile(bindsPath, []byte(stripped), 0o644); err != nil {
t.Fatalf("failed to write stripped binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.SetBind("SUPER+SHIFT+S", "spawn dms screenshot", "Screenshot: Interactive", nil); err != nil {
t.Fatalf("SetBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
for _, want := range []string{
"# DMS default keybinds (MangoWM)",
"# === Touchpad Gestures ===",
"gesturebind=none,right,3,viewtoleft_have_client",
"bind=SUPER,H,focusdir,left",
"bind=SUPER,J,focusdir,down",
"bind=SUPER,K,focusdir,up",
"bind=SUPER,L,focusdir,right",
"# === Custom Keybinds ===",
"# Screenshot: Interactive\nbind=SUPER+SHIFT,S,spawn,dms screenshot",
"bind=SUPER,t,spawn,ghostty",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected restored binds to contain %q\ncontent:\n%s", want, content)
}
}
if strings.Contains(content, "{{TERMINAL_COMMAND}}") {
t.Fatalf("terminal placeholder should have been resolved\ncontent:\n%s", content)
}
}
func TestMangoWCRemoveBindPreservesNonBindLines(t *testing.T) {
tmpDir := t.TempDir()
dmsDir := filepath.Join(tmpDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
t.Fatalf("failed to create dms dir: %v", err)
}
bindsPath := filepath.Join(dmsDir, "binds.conf")
stock := strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", "ghostty")
if err := os.WriteFile(bindsPath, []byte(stock), 0o644); err != nil {
t.Fatalf("failed to write stock binds: %v", err)
}
provider := NewMangoWCProvider(tmpDir)
if err := provider.RemoveBind("SUPER+Tab"); err != nil {
t.Fatalf("RemoveBind failed: %v", err)
}
contentBytes, err := os.ReadFile(bindsPath)
if err != nil {
t.Fatalf("failed to read binds: %v", err)
}
content := string(contentBytes)
if strings.Contains(content, "bind=SUPER,Tab,focusstack,next") {
t.Fatalf("removed bind should be absent\ncontent:\n%s", content)
}
if strings.Contains(content, "# Focus Next Window") {
t.Fatalf("removed bind description should be absent\ncontent:\n%s", content)
}
for _, want := range []string{
"# === Focus Navigation ===",
"# === Touchpad Gestures ===",
"gesturebind=none,down,4,toggleoverview",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected non-bind line %q to be preserved\ncontent:\n%s", want, content)
}
}
}
+2 -2
View File
@@ -201,7 +201,7 @@ func (m Model) viewInstallComplete() string {
wm := m.selectedWindowManager()
// mango launches DMS via `exec_once=dms run` (not a systemd session target)
// mango launches DMS via `exec-once=dms run` (not a systemd session target)
loginHint := "If you do not have a greeter, login with \"niri-session\" or \"Hyprland\""
switch wm {
case deps.WindowManagerNiri:
@@ -223,7 +223,7 @@ func (m Model) viewInstallComplete() string {
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
if wm == deps.WindowManagerMango {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec_once=dms run' from ~/.config/mango/config.conf") + "\n")
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("remove 'exec-once=dms run' from ~/.config/mango/config.conf") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("qs -p ~/.config/quickshell/dms log") + "\n")
} else {
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
+1
View File
@@ -177,6 +177,7 @@ Singleton {
property int mangoLayoutGapsOverride: -1
property int mangoLayoutRadiusOverride: -1
property int mangoLayoutBorderSize: -1
property bool mangoTrackpadNaturalScrolling: true
property int firstDayOfWeek: -1
property bool showWeekNumber: false
+2 -1
View File
@@ -33,6 +33,7 @@ var SPEC = {
mangoLayoutGapsOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutRadiusOverride: { def: -1, onChange: "updateCompositorLayout" },
mangoLayoutBorderSize: { def: -1, onChange: "updateCompositorLayout" },
mangoTrackpadNaturalScrolling: { def: true, onChange: "updateCompositorCursor" },
firstDayOfWeek: { def: -1 },
showWeekNumber: { def: false },
@@ -237,7 +238,7 @@ 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" },
cursorSettings: { def: { theme: "System Default", size: 24, niri: { hideWhenTyping: false, hideAfterInactiveMs: 0 }, hyprland: { hideOnKeyPress: false, hideOnTouch: false, inactiveTimeout: 0 }, dwl: { cursorHideTimeout: 0 }, mango: { cursorHideTimeout: 0 } }, onChange: "updateCompositorCursor" },
availableCursorThemes: { def: ["System Default"], persist: false },
systemDefaultCursorTheme: { def: "", persist: false },
@@ -92,6 +92,7 @@ Item {
return root.screenName;
}
}
readonly property bool mangoOverviewActive: CompositorService.isMango && MangoService.isOutputInOverview(effectiveScreenName)
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(parentScreen) : null
readonly property bool useExtWorkspace: {
@@ -160,7 +161,11 @@ Item {
baseList = getHyprlandWorkspaces();
break;
case "dwl":
baseList = getDwlTags();
break;
case "mango":
if (root.mangoOverviewActive)
return [];
baseList = getDwlTags();
break;
case "sway":
@@ -977,7 +982,7 @@ Item {
StyledText {
anchors.verticalCenter: parent.verticalCenter
visible: !root.isVertical
text: I18n.tr("OVERVIEW")
text: I18n.tr("Overview")
color: Theme.primary
font.pixelSize: overviewPill.labelSize
font.weight: Font.DemiBold
@@ -1115,7 +1120,7 @@ Item {
targetWorkspaceId = modelData?.id;
} else if (CompositorService.isHyprland) {
targetWorkspaceId = modelData?.id;
} else if (CompositorService.isDwl) {
} else if (root.isDwlLike) {
targetWorkspaceId = modelData?.tag;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
targetWorkspaceId = modelData?.num;
@@ -2432,6 +2432,17 @@ Item {
onSliderValueChanged: newValue => SettingsData.setCursorSize(newValue)
}
SettingsToggleRow {
tab: "theme"
tags: ["mango", "touchpad", "trackpad", "natural", "scrolling"]
settingKey: "mangoTrackpadNaturalScrolling"
text: I18n.tr("Natural Touchpad Scrolling")
description: I18n.tr("Invert touchpad scroll direction")
visible: CompositorService.isMango
checked: SettingsData.mangoTrackpadNaturalScrolling
onToggled: checked => SettingsData.set("mangoTrackpadNaturalScrolling", checked)
}
SettingsToggleRow {
tab: "theme"
tags: ["cursor", "hide", "typing"]
@@ -189,7 +189,7 @@ Item {
settingKey: "dwlShowAllTags"
tags: ["dwl", "tags", "workspace"]
text: I18n.tr("Show All Tags")
description: I18n.tr("Show all 9 tags instead of only occupied tags (DWL only)")
description: I18n.tr("Show all 9 tags instead of only occupied tags")
checked: SettingsData.dwlShowAllTags
visible: CompositorService.isDwl || CompositorService.isMango
onToggled: checked => SettingsData.set("dwlShowAllTags", checked)
@@ -0,0 +1,137 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Plugins
PluginComponent {
id: root
layerNamespacePlugin: "composite-example"
property var enabledEmojis: pluginData.emojis || ["😊", "😢", "❤️"]
property int cycleInterval: pluginData.cycleInterval || 3000
property int maxBarEmojis: pluginData.maxBarEmojis || 3
property int currentIndex: 0
property var displayedEmojis: []
Timer {
interval: root.cycleInterval
running: true
repeat: true
onTriggered: {
if (root.enabledEmojis.length > 0) {
root.currentIndex = (root.currentIndex + 1) % root.enabledEmojis.length;
root.updateDisplayedEmojis();
}
}
}
function updateDisplayedEmojis() {
const maxToShow = Math.min(root.maxBarEmojis, root.enabledEmojis.length);
let emojis = [];
for (let i = 0; i < maxToShow; i++) {
const idx = (root.currentIndex + i) % root.enabledEmojis.length;
emojis.push(root.enabledEmojis[idx]);
}
root.displayedEmojis = emojis;
}
Component.onCompleted: {
updateDisplayedEmojis();
}
onEnabledEmojisChanged: updateDisplayedEmojis()
onMaxBarEmojisChanged: updateDisplayedEmojis()
horizontalBarPill: Component {
Row {
id: emojiRow
spacing: Theme.spacingXS
Repeater {
model: root.displayedEmojis
StyledText {
text: modelData
font.pixelSize: Theme.fontSizeLarge
}
}
}
}
verticalBarPill: Component {
Column {
id: emojiColumn
spacing: Theme.spacingXS
Repeater {
model: root.displayedEmojis
StyledText {
text: modelData
font.pixelSize: Theme.fontSizeMedium
anchors.horizontalCenter: parent.horizontalCenter
}
}
}
}
popoutContent: Component {
PopoutComponent {
id: popoutColumn
headerText: "Emoji Picker"
detailsText: "Click an emoji to copy it to clipboard"
showCloseButton: true
property var allEmojis: ["😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😌", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", "🤟", "🤘"]
Item {
width: parent.width
implicitHeight: root.popoutHeight - popoutColumn.headerHeight - popoutColumn.detailsHeight - Theme.spacingXL
DankGridView {
id: emojiGrid
anchors.horizontalCenter: parent.horizontalCenter
width: Math.floor(parent.width / 50) * 50
height: parent.height
clip: true
cellWidth: 50
cellHeight: 50
model: popoutColumn.allEmojis
delegate: StyledRect {
width: 45
height: 45
radius: Theme.cornerRadius
color: emojiMouseArea.containsMouse ? Theme.surfaceContainerHighest : Theme.surfaceContainerHigh
border.width: 0
StyledText {
anchors.centerIn: parent
text: modelData
font.pixelSize: Theme.fontSizeXLarge
}
MouseArea {
id: emojiMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
Quickshell.execDetached(["dms", "cl", "copy", modelData]);
ToastService.showInfo("Copied " + modelData + " to clipboard");
popoutColumn.closePopout();
}
}
}
}
}
}
}
popoutWidth: 400
popoutHeight: 500
}
@@ -0,0 +1,79 @@
import QtQuick
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Modules.Plugins
PluginComponent {
id: root
property string scriptPath: pluginData.scriptPath || ""
property var popoutService: null
Connections {
target: SessionData
function onWallpaperPathChanged() {
if (scriptPath) {
var scriptProcess = scriptProcessComponent.createObject(root, {
wallpaperPath: SessionData.wallpaperPath
});
scriptProcess.running = true;
}
}
}
Component {
id: scriptProcessComponent
Process {
property string wallpaperPath: ""
command: [scriptPath, wallpaperPath]
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
console.log("CompositeDaemon script output:", text.trim());
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
ToastService.showError("Wallpaper Change Script Error", text.trim());
}
}
}
onExited: exitCode => {
if (exitCode !== 0) {
ToastService.showError("Wallpaper Change Script Error", "Script exited with code: " + exitCode);
}
destroy();
}
}
}
IpcHandler {
target: "compositeExample"
function runHook(): string {
if (!root.scriptPath)
return "no script configured";
var scriptProcess = scriptProcessComponent.createObject(root, {
wallpaperPath: SessionData.wallpaperPath
});
scriptProcess.running = true;
return "ran hook";
}
}
Component.onCompleted: {
console.info("CompositeDaemon: Started monitoring wallpaper changes");
}
Component.onDestruction: {
console.info("CompositeDaemon: Stopped monitoring wallpaper changes");
}
}
@@ -0,0 +1,172 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Modules.Plugins
DesktopPluginComponent {
id: root
minWidth: 120
minHeight: 120
property bool showSeconds: pluginData.showSeconds ?? true
property bool showDate: pluginData.showDate ?? true
property string clockStyle: pluginData.clockStyle ?? "analog"
property real backgroundOpacity: (pluginData.backgroundOpacity ?? 50) / 100
SystemClock {
id: systemClock
precision: root.showSeconds ? SystemClock.Seconds : SystemClock.Minutes
}
Rectangle {
id: background
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.surfaceContainer
opacity: root.backgroundOpacity
}
Loader {
anchors.fill: parent
anchors.margins: Theme.spacingM
sourceComponent: root.clockStyle === "digital" ? digitalClock : analogClock
}
Component {
id: analogClock
Item {
id: analogClockRoot
property real clockSize: Math.min(width, height) - (root.showDate ? 30 : 0)
Item {
id: clockFace
width: analogClockRoot.clockSize
height: analogClockRoot.clockSize
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: Theme.spacingS
Repeater {
model: 12
Rectangle {
required property int index
property real markAngle: index * 30
property real markRadius: clockFace.width / 2 - 8
x: clockFace.width / 2 + markRadius * Math.sin(markAngle * Math.PI / 180) - width / 2
y: clockFace.height / 2 - markRadius * Math.cos(markAngle * Math.PI / 180) - height / 2
width: index % 3 === 0 ? 8 : 4
height: width
radius: width / 2
color: index % 3 === 0 ? Theme.primary : Theme.outlineVariant
}
}
Rectangle {
id: hourHand
property int hours: systemClock.date?.getHours() % 12 ?? 0
property int minutes: systemClock.date?.getMinutes() ?? 0
x: clockFace.width / 2 - width / 2
y: clockFace.height / 2 - height + 4
width: 6
height: clockFace.height * 0.25
radius: 3
color: Theme.primary
antialiasing: true
transformOrigin: Item.Bottom
rotation: (hours + minutes / 60) * 30
}
Rectangle {
id: minuteHand
property int minutes: systemClock.date?.getMinutes() ?? 0
property int seconds: systemClock.date?.getSeconds() ?? 0
x: clockFace.width / 2 - width / 2
y: clockFace.height / 2 - height + 4
width: 4
height: clockFace.height * 0.35
radius: 2
color: Theme.onSurface
antialiasing: true
transformOrigin: Item.Bottom
rotation: (minutes + seconds / 60) * 6
}
Rectangle {
id: secondHand
visible: root.showSeconds
property int seconds: systemClock.date?.getSeconds() ?? 0
x: clockFace.width / 2 - width / 2
y: clockFace.height / 2 - height + 4
width: 2
height: clockFace.height * 0.4
radius: 1
color: Theme.error
antialiasing: true
transformOrigin: Item.Bottom
rotation: seconds * 6
}
Rectangle {
anchors.centerIn: parent
width: 10
height: 10
radius: 5
color: Theme.primary
}
}
Text {
visible: root.showDate
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Theme.spacingXS
text: systemClock.date?.toLocaleDateString(I18n.locale(), "ddd, MMM d") ?? ""
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
}
}
}
Component {
id: digitalClock
Item {
id: digitalRoot
property real timeFontSize: Math.min(width * 0.16, height * (root.showDate ? 0.4 : 0.5))
property real dateFontSize: Math.max(Theme.fontSizeSmall, timeFontSize * 0.35)
Text {
id: timeText
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: root.showDate ? -digitalRoot.dateFontSize * 0.8 : 0
text: systemClock.date?.toLocaleTimeString(Qt.locale(), root.showSeconds ? "hh:mm:ss" : "hh:mm") ?? ""
font.pixelSize: digitalRoot.timeFontSize
font.weight: Font.Bold
font.family: "monospace"
color: Theme.primary
}
Text {
id: dateText
visible: root.showDate
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: timeText.bottom
anchors.topMargin: Theme.spacingXS
text: systemClock.date?.toLocaleDateString(I18n.locale(), "ddd, MMM d") ?? ""
font.pixelSize: digitalRoot.dateFontSize
color: Theme.surfaceText
}
}
}
}
@@ -0,0 +1,145 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Modules.Plugins
PluginSettings {
id: root
pluginId: "exampleComposite"
StyledText {
width: parent.width
text: "Bar Widget — Emoji Cycler"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
SelectionSetting {
settingKey: "emojiSet"
label: "Emoji Set"
description: "Choose which collection of emojis to cycle through"
options: [
{
label: "Happy & Sad",
value: "happySad"
},
{
label: "Hearts",
value: "hearts"
},
{
label: "Hand Gestures",
value: "hands"
},
{
label: "All Mixed",
value: "mixed"
}
]
defaultValue: "happySad"
onValueChanged: {
const sets = {
"happySad": ["😊", "😢", "😂", "😭", "😍", "😡"],
"hearts": ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍"],
"hands": ["👍", "👎", "👊", "✌️", "🤘", "👌", "✋", "🤚"],
"mixed": ["😊", "❤️", "👍", "🎉", "🔥", "✨", "🌟", "💯"]
};
root.saveValue("emojis", sets[value] || sets["happySad"]);
}
Component.onCompleted: {
const currentSet = value || defaultValue;
const sets = {
"happySad": ["😊", "😢", "😂", "😭", "😍", "😡"],
"hearts": ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍"],
"hands": ["👍", "👎", "👊", "✌️", "🤘", "👌", "✋", "🤚"],
"mixed": ["😊", "❤️", "👍", "🎉", "🔥", "✨", "🌟", "💯"]
};
root.saveValue("emojis", sets[currentSet] || sets["happySad"]);
}
}
SliderSetting {
settingKey: "cycleInterval"
label: "Cycle Speed"
description: "How quickly emojis rotate"
defaultValue: 3000
minimum: 500
maximum: 10000
unit: "ms"
leftIcon: "schedule"
}
SliderSetting {
settingKey: "maxBarEmojis"
label: "Max Bar Emojis"
description: "Maximum number of emojis to display in the bar at once"
defaultValue: 3
minimum: 1
maximum: 8
rightIcon: "emoji_emotions"
}
StyledText {
width: parent.width
text: "Desktop Widget — Clock"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
SelectionSetting {
settingKey: "clockStyle"
label: "Clock Style"
options: [
{
label: "Analog",
value: "analog"
},
{
label: "Digital",
value: "digital"
}
]
defaultValue: "analog"
}
ToggleSetting {
settingKey: "showSeconds"
label: "Show Seconds"
defaultValue: true
}
ToggleSetting {
settingKey: "showDate"
label: "Show Date"
defaultValue: true
}
SliderSetting {
settingKey: "backgroundOpacity"
label: "Background Opacity"
defaultValue: 50
minimum: 0
maximum: 100
unit: "%"
}
StyledText {
width: parent.width
text: "Daemon — Wallpaper Hook"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.surfaceText
}
StringSetting {
settingKey: "scriptPath"
label: "Script Path"
description: "Script executed when the wallpaper changes. The new wallpaper path is passed as the first argument."
placeholder: "/path/to/your/script.sh"
defaultValue: ""
}
}
@@ -0,0 +1,53 @@
# Composite Example
A single plugin that provides **all three surfaces at once** by combining three of
the standalone example plugins:
| Surface | Source example | File |
|---------|----------------|------|
| `daemon` | WallpaperWatcherDaemon | `CompositeDaemon.qml` |
| `widget` | Emoji Cycler (bar widget + popout) | `CompositeBarWidget.qml` |
| `desktop` | Desktop Clock | `CompositeDesktopWidget.qml` |
It demonstrates the `components` manifest map, where each surface points at its own
QML file:
```json
"type": "composite",
"components": {
"daemon": "./CompositeDaemon.qml",
"widget": "./CompositeBarWidget.qml",
"desktop": "./CompositeDesktopWidget.qml"
}
```
All surfaces share one settings UI (`CompositeSettings.qml`) and one plugin-settings
namespace (`exampleComposite`), so `pluginData` is the same for every surface.
## Surfaces
- **Daemon** — watches `SessionData.wallpaperPath` and runs a user-configured script
on change. Also registers an `IpcHandler` (`target: "compositeExample"`) exposing a
`runHook` call, so you can trigger the hook over IPC.
- **Bar widget** — cycles emojis in the bar; click the pill for an emoji picker popout
that copies to the clipboard.
- **Desktop widget** — an analog/digital clock you can drag and resize on the desktop.
## Usage
1. Copy this directory into `$CONFIGPATH/DankMaterialShell/plugins/`.
2. Settings → Plugins → **Scan for Plugins**, then enable **Composite Example**.
(Composite plugins respect the enable toggle — unlike a pure `desktop` plugin they
do not auto-load, because they also carry a daemon.)
3. Add the bar widget via Settings → Appearance → DankBar Layout.
4. Place the desktop clock via Settings → Desktop Widgets.
## Notes
- The daemon surface is instantiated once and lives for as long as the plugin is
enabled. The bar and desktop surfaces are instantiated per bar/placement per screen.
- Cross-surface runtime state (not needed here) is best shared via
`PluginService.getGlobalVar` / `setGlobalVar` or the daemon instance, since each
surface is a separate object.
- `requires_dms` is `>=1.5.0` because the `components` multi-surface manifest is only
understood by DMS 1.5.0 and later.
@@ -0,0 +1,21 @@
{
"id": "exampleComposite",
"name": "Composite Example",
"description": "One plugin providing all three surfaces at once: a wallpaper-watcher daemon, an emoji bar widget with popout, and a desktop clock",
"version": "1.0.0",
"author": "DankMaterialShell",
"type": "composite",
"capabilities": ["daemon", "dankbar-widget", "desktop-widget", "clipboard"],
"icon": "extension",
"components": {
"daemon": "./CompositeDaemon.qml",
"widget": "./CompositeBarWidget.qml",
"desktop": "./CompositeDesktopWidget.qml"
},
"settings": "./CompositeSettings.qml",
"requires_dms": ">=1.5.0",
"permissions": [
"settings_read",
"settings_write"
]
}
+74
View File
@@ -1635,6 +1635,79 @@ See `PLUGINS/ExampleDesktopClock/` for a complete working example demonstrating:
- Responsive sizing
- Edit mode handling
## Composite Plugins
A single plugin can provide **multiple surfaces at once** — for example a background
daemon (for IPC / monitoring), a bar widget, and a desktop widget. Because each surface
has a different lifecycle (the daemon is instantiated once; bar and desktop widgets are
instantiated per bar/placement per screen), each surface is its own QML file.
### Plugin Type Configuration
Instead of a single `type` + `component`, declare a `components` map. Set `type` to
`composite` (any value works; `composite` is conventional):
```json
{
"id": "myComposite",
"name": "My Composite Plugin",
"description": "A daemon plus a bar widget plus a desktop widget",
"version": "1.0.0",
"author": "Your Name",
"type": "composite",
"capabilities": ["daemon", "dankbar-widget", "desktop-widget"],
"components": {
"daemon": "./MyDaemon.qml",
"widget": "./MyBarWidget.qml",
"desktop": "./MyDesktopWidget.qml",
"launcher": "./MyLauncher.qml"
},
"trigger": "#",
"settings": "./MySettings.qml",
"requires_dms": ">=1.5.0",
"permissions": ["settings_read", "settings_write"]
}
```
### Surfaces
Provide any subset of these keys in `components`:
| Surface | Component contract | Notes |
|---------|--------------------|-------|
| `widget` | `PluginComponent` (bar pills + optional Control Center widget) | see [Widget Component](#widget-component) |
| `desktop` | `DesktopPluginComponent` (or an `Item` following the desktop contract) | see [Desktop Plugins](#desktop-plugins) |
| `daemon` | any `Item` exposing `pluginService` / `pluginId` | instantiated once; ideal for IPC handlers and background monitoring |
| `launcher` | launcher contract (`getItems` / `executeItem`) | requires `trigger` (or empty-trigger mode); see [Launcher Plugins](#launcher-plugins) |
Each surface is loaded independently into its own registry, so the same plugin can show
up in the bar **and** on the desktop **and** run a daemon simultaneously.
### Shared State
Each surface is a separate object, so share runtime state through:
- `PluginService.getGlobalVar(pluginId, name, default)` / `setGlobalVar(...)` — reactive,
in-process, namespaced per plugin (see [Plugin Global Variables](#plugin-global-variables)).
- The daemon instance — register `IpcHandler`s or expose data other surfaces read via
global vars.
- `savePluginData` / `loadPluginData` for persisted settings (all surfaces of a plugin
share one settings namespace, so one `settings` component configures them all).
### Settings, Enabling, and Backwards Compatibility
- Declare a single top-level `settings` component; it configures every surface.
- Composite plugins respect the **enable toggle** in Settings → Plugins (they are not
auto-loaded). A pure `desktop` plugin still auto-loads for backwards compatibility.
- The legacy single `type` + `component` form is unchanged and fully supported — it is
treated internally as a one-entry `components` map.
### Example Plugin
See `PLUGINS/ExampleCompositePlugin/` for a working composite that combines the
WallpaperWatcher daemon, the Emoji Cycler bar widget, and the Desktop Clock into one
plugin.
## Resources
- **Plugin Schema**: `plugin-schema.json` - JSON Schema for validation
@@ -1644,6 +1717,7 @@ See `PLUGINS/ExampleDesktopClock/` for a complete working example demonstrating:
- [LauncherExample](./LauncherExample/)
- [Calculator](https://github.com/rochacbruno/DankCalculator)
- [Desktop Clock](./ExampleDesktopClock/)
- [Composite Example](./ExampleCompositePlugin/)
- **PluginService**: `Services/PluginService.qml`
- **Settings UI**: `Modules/Settings/PluginsTab.qml`
- **DankBar Integration**: `Modules/DankBar/DankBar.qml`
+55 -5
View File
@@ -11,8 +11,7 @@
"version",
"author",
"type",
"capabilities",
"component"
"capabilities"
],
"properties": {
"id": {
@@ -42,8 +41,8 @@
},
"type": {
"type": "string",
"description": "Plugin type",
"enum": ["widget", "daemon", "launcher", "desktop"]
"description": "Plugin type. Use 'composite' (or any value) together with 'components' to provide multiple surfaces from one plugin.",
"enum": ["widget", "daemon", "launcher", "desktop", "composite"]
},
"capabilities": {
"type": "array",
@@ -55,9 +54,37 @@
},
"component": {
"type": "string",
"description": "Relative path to main QML component file",
"description": "Relative path to main QML component file. Required unless 'components' is provided.",
"pattern": "^\\./.*\\.qml$"
},
"components": {
"type": "object",
"description": "Map of surface name to relative QML component path, for multi-surface (composite) plugins. Provide any subset of surfaces; each is loaded independently.",
"properties": {
"widget": {
"type": "string",
"description": "Bar/Control Center widget component (PluginComponent)",
"pattern": "^\\./.*\\.qml$"
},
"desktop": {
"type": "string",
"description": "Desktop widget component",
"pattern": "^\\./.*\\.qml$"
},
"daemon": {
"type": "string",
"description": "Background daemon component (instantiated once)",
"pattern": "^\\./.*\\.qml$"
},
"launcher": {
"type": "string",
"description": "Launcher provider component (requires 'trigger')",
"pattern": "^\\./.*\\.qml$"
}
},
"additionalProperties": false,
"minProperties": 1
},
"trigger": {
"type": "string",
"description": "Trigger string for launcher activation (required for launcher type)"
@@ -109,6 +136,29 @@
"then": {
"required": ["trigger"]
}
},
{
"if": {
"required": ["components"],
"properties": {
"components": {
"required": ["launcher"]
}
}
},
"then": {
"required": ["trigger"]
}
},
{
"anyOf": [
{
"required": ["component"]
},
{
"required": ["components"]
}
]
}
],
"additionalProperties": true
@@ -21,8 +21,7 @@ Singleton {
Connections {
target: PluginService
function onPluginLoaded(pluginId) {
const plugin = PluginService.availablePlugins[pluginId];
if (plugin?.type === "desktop")
if (PluginService.pluginDesktopComponents[pluginId] !== undefined)
syncPluginWidgets();
}
function onPluginUnloaded(pluginId) {
+95 -14
View File
@@ -21,12 +21,18 @@ Singleton {
readonly property bool available: socketPath.length > 0
readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
readonly property string configPath: configDir + "/mango/config.conf"
readonly property string mangoDmsDir: configDir + "/mango/dms"
readonly property string bindsPath: mangoDmsDir + "/binds.conf"
readonly property string colorsPath: mangoDmsDir + "/colors.conf"
readonly property string outputsPath: mangoDmsDir + "/outputs.conf"
readonly property string layoutPath: mangoDmsDir + "/layout.conf"
readonly property string cursorPath: mangoDmsDir + "/cursor.conf"
readonly property string windowRulesPath: mangoDmsDir + "/windowrules.conf"
property int _lastGapValue: -1
property real _ignoreWatchedReloadUntil: 0
property real _lastWatchedReloadAt: 0
// name -> { name, active, x, y, width, height, scale, layoutIndex,
// layoutSymbol, lastOpenSurface, kbLayout, keymode,
@@ -47,6 +53,55 @@ Singleton {
// One connection per watch target; mango streams a fresh full snapshot on
// every change, so each line is treated as the complete state.
FileView {
id: mangoConfigWatcher
path: CompositorService.isMango ? root.configPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
FileView {
id: mangoBindsWatcher
path: CompositorService.isMango ? root.bindsPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
FileView {
id: mangoColorsWatcher
path: CompositorService.isMango ? root.colorsPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
FileView {
id: mangoLayoutWatcher
path: CompositorService.isMango ? root.layoutPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
FileView {
id: mangoCursorWatcher
path: CompositorService.isMango ? root.cursorPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
FileView {
id: mangoOutputsWatcher
path: CompositorService.isMango ? root.outputsPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
FileView {
id: mangoWindowRulesWatcher
path: CompositorService.isMango ? root.windowRulesPath : ""
watchChanges: CompositorService.isMango
onFileChanged: root.handleWatchedConfigChanged()
}
DankSocket {
id: monitorsSocket
path: root.socketPath
@@ -100,12 +155,14 @@ Singleton {
for (const m of monitors) {
if (!m.name)
continue;
const activeTags = m.active_tags || [];
const inOverview = activeTags.length === 0 || activeTags.every(t => t === 0);
const tags = (m.tags || []).map(t => ({
// 0-based to match the legacy dwl tag model used by consumers
"tag": (t.index ?? 1) - 1,
"state": t.is_urgent ? 2 : (t.is_active ? 1 : 0),
"state": t.is_urgent ? 2 : (!inOverview && t.is_active ? 1 : 0),
"clients": t.client_count ?? 0,
"focused": !!t.is_active,
"focused": !inOverview && !!t.is_active,
"urgent": !!t.is_urgent,
"layout": t.layout ?? ""
}));
@@ -119,7 +176,8 @@ Singleton {
"scale": m.scale ?? 1.0,
"layoutIndex": m.layout_index ?? 0,
"layout": m.layout_index ?? 0,
"activeTags": m.active_tags || [],
"activeTags": activeTags,
"inOverview": inOverview,
"layoutSymbol": m.layout_symbol ?? "",
"lastOpenSurface": m.last_open_surface ?? "",
"keymode": m.keymode ?? "",
@@ -179,6 +237,8 @@ Singleton {
const output = getOutputState(outputName);
if (!output)
return false;
if (output.inOverview !== undefined)
return output.inOverview;
const at = output.activeTags || [];
return at.length === 0 || at.every(t => t === 0);
}
@@ -201,6 +261,8 @@ Singleton {
const output = getOutputState(outputName);
if (!output || !output.tags)
return [];
if (isOutputInOverview(outputName))
return [];
const visibleTags = new Set();
output.tags.forEach(tag => {
if (tag.state === 1 || tag.clients > 0)
@@ -336,10 +398,36 @@ Singleton {
// ── Commands (mango verb IPC: mmsg dispatch <func>,<args>) ─────────────
function reloadConfig() {
function suppressWatchedConfigReloads(ms) {
root._ignoreWatchedReloadUntil = Math.max(root._ignoreWatchedReloadUntil, Date.now() + (ms || 1500));
}
function handleWatchedConfigChanged() {
if (!CompositorService.isMango || !root.available)
return;
const now = Date.now();
if (now < root._ignoreWatchedReloadUntil)
return;
if (now - root._lastWatchedReloadAt < 700)
return;
root._lastWatchedReloadAt = now;
root.reloadConfig(true, false);
}
function reloadConfig(showToast, suppressWatch) {
const shouldShowToast = showToast !== false;
const shouldSuppressWatch = suppressWatch !== false;
if (shouldSuppressWatch)
suppressWatchedConfigReloads(1500);
Proc.runCommand("mango-reload", ["mmsg", "dispatch", "reload_config"], (output, exitCode) => {
if (exitCode !== 0)
if (exitCode !== 0) {
log.warn("mmsg reload_config failed:", output);
if (shouldShowToast)
ToastService.showError(I18n.tr("mango: failed to reload config"), output || "", "", "mango-config");
return;
}
if (shouldShowToast)
ToastService.showInfo(I18n.tr("mango: config reloaded"), "", "", "mango-config");
});
}
@@ -538,17 +626,10 @@ borderpx=${borderSize}
const themeName = settings.theme === "System Default" ? (SettingsData.systemDefaultCursorTheme || "") : settings.theme;
const size = settings.size || 24;
const hideTimeout = settings.mango?.cursorHideTimeout || 0;
const isDefaultConfig = !themeName && size === 24 && hideTimeout === 0;
if (isDefaultConfig) {
Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => {
if (exitCode !== 0)
log.warn("Failed to write cursor config:", output);
});
return;
}
const naturalScrolling = SettingsData.mangoTrackpadNaturalScrolling ? 1 : 0;
let content = `# Auto-generated by DMS - do not edit manually
trackpad_natural_scrolling=${naturalScrolling}
cursor_size=${size}`;
if (themeName)
+122 -57
View File
@@ -1,7 +1,6 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
import Qt.labs.folderlistmodel
import Quickshell
@@ -202,8 +201,51 @@ Singleton {
}
}
readonly property var pluginSurfaceKeys: ["widget", "desktop", "daemon", "launcher"]
function _stripDotSlash(p) {
return p.startsWith("./") ? p.slice(2) : p;
}
function _deriveLegacySurface(type, capabilities) {
if (type === "daemon")
return "daemon";
if (type === "launcher" || (capabilities && capabilities.includes("launcher")))
return "launcher";
if (type === "desktop")
return "desktop";
return "widget";
}
function _resolveComponentPaths(manifest, dir) {
const paths = {};
if (manifest.components && typeof manifest.components === "object") {
for (const surface in manifest.components) {
if (!pluginSurfaceKeys.includes(surface)) {
log.warn("unknown plugin surface", surface, "in", dir);
continue;
}
const rel = manifest.components[surface];
if (!rel)
continue;
paths[surface] = dir + "/" + _stripDotSlash(rel);
}
return paths;
}
if (manifest.component) {
const surface = _deriveLegacySurface(manifest.type, manifest.capabilities);
paths[surface] = dir + "/" + _stripDotSlash(manifest.component);
}
return paths;
}
function pluginHasSurface(pluginId, surface) {
const plugin = availablePlugins[pluginId];
return !!(plugin && plugin.surfaces && plugin.surfaces.includes(surface));
}
function _onManifestParsed(absPath, manifest, sourceTag, mtimeEpochMs) {
if (!manifest || !manifest.id || !manifest.name || !manifest.component) {
if (!manifest || !manifest.id || !manifest.name || (!manifest.component && !manifest.components)) {
log.error("invalid manifest fields:", absPath);
knownManifests[absPath] = {
mtime: mtimeEpochMs,
@@ -214,13 +256,22 @@ Singleton {
}
const dir = absPath.substring(0, absPath.lastIndexOf('/'));
let comp = manifest.component;
if (comp.startsWith("./"))
comp = comp.slice(2);
let settings = manifest.settings;
if (settings && settings.startsWith("./"))
settings = settings.slice(2);
const componentPaths = _resolveComponentPaths(manifest, dir);
const surfaces = Object.keys(componentPaths);
if (surfaces.length === 0) {
log.error("no valid component surfaces in manifest:", absPath);
knownManifests[absPath] = {
mtime: mtimeEpochMs,
source: sourceTag,
bad: true
};
return;
}
const info = {};
for (const k in manifest)
info[k] = manifest[k];
@@ -236,10 +287,12 @@ Singleton {
info.manifestPath = absPath;
info.pluginDirectory = dir;
info.componentPath = dir + "/" + comp;
info.componentPaths = componentPaths;
info.surfaces = surfaces;
info.componentPath = componentPaths.widget || componentPaths[surfaces[0]];
info.settingsPath = settings ? (dir + "/" + settings) : null;
info.loaded = isPluginLoaded(manifest.id);
info.type = manifest.type || "widget";
info.type = manifest.type || (manifest.components ? "composite" : "widget");
info.source = sourceTag;
info.requires_dms = manifest.requires_dms || null;
@@ -260,7 +313,8 @@ Singleton {
};
_updateAvailablePluginsList();
pluginListUpdated();
const enabled = info.type === "desktop" || SettingsData.getPluginSetting(manifest.id, "enabled", false);
const isPureDesktop = surfaces.length === 1 && surfaces[0] === "desktop";
const enabled = isPureDesktop || SettingsData.getPluginSetting(manifest.id, "enabled", false);
if (enabled && !info.loaded)
loadPlugin(manifest.id);
} else {
@@ -296,59 +350,70 @@ Singleton {
return true;
}
const isDaemon = plugin.type === "daemon";
const isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher"));
const isDesktop = plugin.type === "desktop";
const componentPaths = plugin.componentPaths || {};
const surfaces = Object.keys(componentPaths);
if (surfaces.length === 0) {
log.error("Plugin has no component surfaces:", pluginId);
pluginLoadFailed(pluginId, "No component surfaces");
return false;
}
const prevInstance = pluginInstances[pluginId];
const newWidgets = Object.assign({}, pluginWidgetComponents);
const newDesktop = Object.assign({}, pluginDesktopComponents);
const newDaemons = Object.assign({}, pluginDaemonComponents);
const newLaunchers = Object.assign({}, pluginLauncherComponents);
const newInstances = Object.assign({}, pluginInstances);
const prevInstance = newInstances[pluginId];
if (prevInstance) {
prevInstance.destroy();
const newInstances = Object.assign({}, pluginInstances);
delete newInstances[pluginId];
pluginInstances = newInstances;
}
try {
let url = "file://" + plugin.componentPath;
if (bustCache)
url += "?t=" + Date.now();
const comp = Qt.createComponent(url, Component.PreferSynchronous);
if (comp.status === Component.Error) {
log.error("component error", pluginId, comp.errorString());
pluginLoadFailed(pluginId, comp.errorString());
return false;
}
if (isDaemon) {
const newDaemons = Object.assign({}, pluginDaemonComponents);
newDaemons[pluginId] = comp;
pluginDaemonComponents = newDaemons;
} else if (isLauncher) {
const instance = comp.createObject(root, {
"pluginService": root
});
if (!instance) {
log.error("failed to instantiate plugin:", pluginId, comp.errorString());
for (const surface of surfaces) {
let url = "file://" + componentPaths[surface];
if (bustCache)
url += "?t=" + Date.now();
const comp = Qt.createComponent(url, Component.PreferSynchronous);
if (comp.status === Component.Error) {
log.error("component error", pluginId, surface, comp.errorString());
pluginLoadFailed(pluginId, comp.errorString());
return false;
}
const newInstances = Object.assign({}, pluginInstances);
newInstances[pluginId] = instance;
pluginInstances = newInstances;
const newLaunchers = Object.assign({}, pluginLauncherComponents);
newLaunchers[pluginId] = comp;
pluginLauncherComponents = newLaunchers;
} else if (isDesktop) {
const newDesktop = Object.assign({}, pluginDesktopComponents);
newDesktop[pluginId] = comp;
pluginDesktopComponents = newDesktop;
} else {
const newComponents = Object.assign({}, pluginWidgetComponents);
newComponents[pluginId] = comp;
pluginWidgetComponents = newComponents;
switch (surface) {
case "daemon":
newDaemons[pluginId] = comp;
break;
case "desktop":
newDesktop[pluginId] = comp;
break;
case "launcher": {
const instance = comp.createObject(root, {
"pluginService": root
});
if (!instance) {
log.error("failed to instantiate launcher surface:", pluginId, comp.errorString());
pluginLoadFailed(pluginId, comp.errorString());
return false;
}
newInstances[pluginId] = instance;
newLaunchers[pluginId] = comp;
break;
}
default:
newWidgets[pluginId] = comp;
break;
}
}
pluginWidgetComponents = newWidgets;
pluginDesktopComponents = newDesktop;
pluginDaemonComponents = newDaemons;
pluginLauncherComponents = newLaunchers;
pluginInstances = newInstances;
plugin.loaded = true;
const newLoaded = Object.assign({}, loadedPlugins);
newLoaded[pluginId] = plugin;
@@ -371,10 +436,6 @@ Singleton {
}
try {
const isDaemon = plugin.type === "daemon";
const isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher"));
const isDesktop = plugin.type === "desktop";
const instance = pluginInstances[pluginId];
if (instance) {
instance.destroy();
@@ -383,19 +444,22 @@ Singleton {
pluginInstances = newInstances;
}
if (isDaemon && pluginDaemonComponents[pluginId]) {
if (pluginDaemonComponents[pluginId]) {
const newDaemons = Object.assign({}, pluginDaemonComponents);
delete newDaemons[pluginId];
pluginDaemonComponents = newDaemons;
} else if (isLauncher && pluginLauncherComponents[pluginId]) {
}
if (pluginLauncherComponents[pluginId]) {
const newLaunchers = Object.assign({}, pluginLauncherComponents);
delete newLaunchers[pluginId];
pluginLauncherComponents = newLaunchers;
} else if (isDesktop && pluginDesktopComponents[pluginId]) {
}
if (pluginDesktopComponents[pluginId]) {
const newDesktop = Object.assign({}, pluginDesktopComponents);
delete newDesktop[pluginId];
pluginDesktopComponents = newDesktop;
} else if (pluginWidgetComponents[pluginId]) {
}
if (pluginWidgetComponents[pluginId]) {
const newComponents = Object.assign({}, pluginWidgetComponents);
delete newComponents[pluginId];
pluginWidgetComponents = newComponents;
@@ -452,7 +516,8 @@ Singleton {
const result = [];
for (const pluginId in availablePlugins) {
const plugin = availablePlugins[pluginId];
if (plugin.type !== "widget") {
const hasWidgetSurface = plugin.surfaces ? plugin.surfaces.includes("widget") : (plugin.type === "widget");
if (!hasWidgetSurface) {
continue;
}
const variants = getPluginVariants(pluginId);
+66 -10
View File
@@ -157,6 +157,9 @@ Singleton {
var item = settingsIndex[i];
var t = translateItem(item);
var sourceDescription = item.description || "";
var labelLower = _lowerVariants([item.label, t.label]);
var categoryLower = _lowerVariants([item.category, t.category]);
var descriptionLower = _lowerVariants([sourceDescription, t.description]);
cache.push({
section: t.section,
label: t.label,
@@ -166,9 +169,14 @@ Singleton {
icon: t.icon,
description: t.description,
conditionKey: t.conditionKey,
labelSearch: _lowerVariants([item.label, t.label]),
categorySearch: _lowerVariants([item.category, t.category]),
descriptionSearch: _lowerVariants([sourceDescription, t.description])
isTab: String(t.section).startsWith("_tab_"),
labelSearch: labelLower,
categorySearch: categoryLower,
descriptionSearch: descriptionLower,
labelSquash: _squashVariants(labelLower),
categorySquash: _squashVariants(categoryLower),
descriptionSquash: _squashVariants(descriptionLower),
keywordsSquash: _squashVariants(t.keywords)
});
}
_translatedCache = cache;
@@ -187,6 +195,22 @@ Singleton {
return out;
}
function _squash(value) {
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function _squashVariants(values) {
var out = [];
for (var i = 0; i < values.length; i++) {
if (!values[i])
continue;
var squashed = _squash(values[i]);
if (squashed && out.indexOf(squashed) === -1)
out.push(squashed);
}
return out;
}
function _bestFieldScore(fields, queryLower, exactScore, prefixScore, includesScore) {
var score = 0;
for (var i = 0; i < fields.length; i++) {
@@ -223,6 +247,7 @@ Singleton {
return [];
var queryLower = text.toLowerCase().trim();
var querySquash = _squash(queryLower);
var queryWords = queryLower.split(/\s+/).filter(w => w.length > 0);
var scored = [];
var cache = _translatedCache;
@@ -233,25 +258,47 @@ Singleton {
if (!checkCondition(entry))
continue;
var score = 0;
var labelScore = _bestFieldScore(entry.labelSearch, queryLower, 10000, 5000, 1000);
if (querySquash)
labelScore = Math.max(labelScore, _bestFieldScore(entry.labelSquash, querySquash, 9000, 4500, 900));
score = Math.max(score, _bestFieldScore(entry.labelSearch, queryLower, 10000, 5000, 1000));
var score = labelScore;
score = Math.max(score, _bestFieldScore(entry.categorySearch, queryLower, 500, 500, 500));
score = Math.max(score, _bestFieldScore(entry.descriptionSearch, queryLower, 250, 250, 250));
if (querySquash) {
score = Math.max(score, _bestFieldScore(entry.categorySquash, querySquash, 500, 500, 500));
score = Math.max(score, _bestFieldScore(entry.descriptionSquash, querySquash, 250, 250, 250));
}
if (score === 0) {
var keywords = entry.keywords;
for (var k = 0; k < keywords.length; k++) {
if (keywords[k].startsWith(queryLower)) {
score = 800;
var keyword = keywords[k];
if (keyword === queryLower) {
score = 900;
break;
}
if (keywords[k].includes(queryLower) && score < 400) {
if (keyword.startsWith(queryLower)) {
score = Math.max(score, 800);
} else if (keyword.includes(queryLower) && score < 400) {
score = 400;
}
}
}
if (score === 0 && querySquash) {
var keywordsSquash = entry.keywordsSquash;
for (var ks = 0; ks < keywordsSquash.length; ks++) {
if (keywordsSquash[ks] === querySquash) {
score = Math.max(score, 850);
break;
}
if (keywordsSquash[ks].startsWith(querySquash)) {
score = Math.max(score, 750);
}
}
}
if (score === 0 && queryWords.length > 1) {
var allMatch = true;
for (var w = 0; w < queryWords.length; w++) {
@@ -281,12 +328,21 @@ Singleton {
if (score > 0) {
scored.push({
item: entry,
score: score
score: score,
labelScore: labelScore
});
}
}
scored.sort((a, b) => b.score - a.score);
scored.sort((a, b) => {
if (b.score !== a.score)
return b.score - a.score;
if (b.labelScore !== a.labelScore)
return b.labelScore - a.labelScore;
if (a.item.isTab !== b.item.isTab)
return a.item.isTab ? 1 : -1;
return a.item.label.length - b.item.label.length;
});
return scored.slice(0, limit).map(s => s.item);
}
@@ -91,6 +91,12 @@ CATEGORY_KEYWORDS = {
"opacity",
],
"Locale": ["locale", "language", "country"],
"Greeter": ["login", "greetd", "display manager"],
"Multiplexers": ["tmux", "zellij", "terminal"],
"Frame": ["window", "border", "decoration"],
"Default Apps": ["browser", "terminal", "handlers", "mime"],
"Users": ["accounts", "user", "profile"],
"Autostart": ["startup", "launch", "boot"],
}
TAB_INDEX_MAP = {
@@ -124,6 +130,12 @@ TAB_INDEX_MAP = {
"WindowRulesTab.qml": 28,
"AudioTab.qml": 29,
"LocaleTab.qml": 30,
"GreeterTab.qml": 31,
"MuxTab.qml": 32,
"FrameTab.qml": 33,
"DefaultAppsTab.qml": 34,
"UsersTab.qml": 35,
"AutoStartTab.qml": 36,
}
TAB_CATEGORY_MAP = {
@@ -157,6 +169,12 @@ TAB_CATEGORY_MAP = {
28: "Window Rules",
29: "Audio",
30: "Locale",
31: "Greeter",
32: "Multiplexers",
33: "Frame",
34: "Default Apps",
35: "Users",
36: "Autostart",
}
SEARCHABLE_COMPONENTS = [
@@ -1051,6 +1051,31 @@
"description": "Show workspaces of the currently focused monitor",
"conditionKey": "isNiri"
},
{
"section": "groupActiveWorkspaceApps",
"label": "Group Active Workspace",
"tabIndex": 4,
"category": "Workspaces",
"keywords": [
"active",
"app",
"application",
"apps",
"desktop",
"focused",
"group",
"grouped",
"icons",
"program",
"repeated",
"spaces",
"virtual",
"virtual desktops",
"workspace",
"workspaces"
],
"description": "Also group repeated application icons on the active workspace"
},
{
"section": "groupWorkspaceApps",
"label": "Group Workspace Apps",
@@ -3901,6 +3926,30 @@
],
"description": "Shadow elevation on popouts, OSDs, and dropdowns"
},
{
"section": "hyprlandResizeOnBorder",
"label": "Resize on Border",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"appearance",
"border",
"colors",
"drag",
"dragging",
"edges",
"hyprland",
"look",
"mouse",
"resize",
"scheme",
"style",
"their",
"theme",
"windows"
],
"description": "Resize windows by dragging their edges with the mouse"
},
{
"section": "runDmsMatugenTemplates",
"label": "Run DMS Templates",
@@ -7585,8 +7634,7 @@
"rules",
"window"
],
"icon": "select_window",
"conditionKey": "isNiri"
"icon": "select_window"
},
{
"section": "audioInputDevices",
@@ -7691,61 +7739,874 @@
],
"description": "Change the locale used for date and time formatting, independent of the interface language."
},
{
"section": "greeterUse24Hour",
"label": "24-hour clock",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"24hour",
"affect",
"clock",
"display manager",
"does",
"greetd",
"greeter",
"hour",
"login",
"main",
"time",
"watch"
],
"description": "Greeter only — does not affect main clock"
},
{
"section": "greeterAutoLogin",
"label": "Auto-login on startup",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"after",
"auto",
"autologin",
"boot",
"display manager",
"effect",
"greetd",
"greeter",
"lock",
"lockscreen",
"login",
"next",
"password",
"reboot",
"screen",
"security",
"sign",
"skip",
"startup",
"sync",
"takes",
"unchanged",
"unlock",
"until"
],
"description": "Skip the greeter password after boot until you sign out. Lock screen unlock is unchanged. Takes effect on the next reboot after sync."
},
{
"section": "greeterLockDateFormat",
"label": "Date Format",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"date",
"display manager",
"format",
"greetd",
"greeter",
"login",
"screen"
],
"description": "Greeter only — format for the date on the login screen"
},
{
"section": "greeterDeps",
"label": "Dependencies & documentation",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"dependencies",
"display manager",
"documentation",
"greetd",
"greeter",
"login"
],
"icon": "extension"
},
{
"section": "greeterEnableFprint",
"label": "Enable fingerprint at login",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"auth",
"display manager",
"enable",
"fingerprint",
"fprintd",
"greetd",
"greeter",
"login"
]
},
{
"section": "greeterEnableU2f",
"label": "Enable security key at login",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"auth",
"display manager",
"enable",
"greetd",
"greeter",
"key",
"login",
"security",
"u2f"
]
},
{
"section": "_tab_31",
"label": "Greeter",
"tabIndex": 31,
"category": "Settings",
"category": "Greeter",
"keywords": [
"display manager",
"greetd",
"greeter",
"settings"
"login"
],
"icon": "login"
},
{
"section": "greeterAppearance",
"label": "Greeter Appearance",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"appearance",
"display manager",
"font",
"greetd",
"greeter",
"login",
"screen",
"typography"
],
"icon": "palette",
"description": "Font used on the login screen"
},
{
"section": "greeterBehavior",
"label": "Greeter Behavior",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"behavior",
"display manager",
"greetd",
"greeter",
"last",
"login",
"remember",
"select",
"session"
],
"icon": "history",
"description": "Pre-select the last used session on the greeter"
},
{
"section": "greeterStatus",
"label": "Greeter Status",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"greetd",
"greeter",
"login",
"status"
],
"icon": "info"
},
{
"section": "greeterFontFamily",
"label": "Greeter font",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"font",
"greetd",
"greeter",
"login",
"screen",
"typography"
],
"description": "Font used on the login screen"
},
{
"section": "greeterAuth",
"label": "Login Authentication",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"auth",
"authentication",
"display manager",
"fingerprint",
"fprintd",
"greetd",
"greeter",
"login"
],
"icon": "fingerprint"
},
{
"section": "greeterPadHours",
"label": "Pad hours (02:00 vs 2:00)",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"(02:00",
"12hour",
"2:00)",
"display manager",
"greetd",
"greeter",
"hours",
"login",
"pad",
"time"
]
},
{
"section": "greeterRememberLastSession",
"label": "Remember last session",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"greetd",
"greeter",
"last",
"login",
"remember",
"select",
"session"
],
"description": "Pre-select the last used session on the greeter"
},
{
"section": "greeterRememberLastUser",
"label": "Remember last user",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"fill",
"greetd",
"greeter",
"last",
"login",
"remember",
"successful",
"user",
"username"
],
"description": "Pre-fill the last successful username on the greeter"
},
{
"section": "greeterShowSeconds",
"label": "Show Seconds",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"display manager",
"greetd",
"greeter",
"login",
"seconds",
"show",
"time"
]
},
{
"section": "greeterWallpaperFillMode",
"label": "Wallpaper fill mode",
"tabIndex": 31,
"category": "Greeter",
"keywords": [
"background",
"bg",
"desktop",
"display manager",
"fill",
"greetd",
"greeter",
"image",
"login",
"mode",
"picture",
"scaled",
"wallpaper"
],
"description": "How the background image is scaled"
},
{
"section": "muxType",
"label": "Multiplexer",
"tabIndex": 32,
"category": "Multiplexers",
"keywords": [
"backend",
"multiplexer",
"multiplexers",
"mux",
"terminal",
"tmux",
"type",
"zellij"
],
"icon": "terminal",
"description": "Terminal multiplexer backend to use"
},
{
"section": "_tab_32",
"label": "Multiplexers",
"tabIndex": 32,
"category": "Settings",
"category": "Multiplexers",
"keywords": [
"multiplexers",
"settings"
"terminal",
"tmux",
"zellij"
],
"icon": "terminal"
},
{
"section": "muxUseCustomCommand",
"label": "Terminal",
"tabIndex": 32,
"category": "Multiplexers",
"keywords": [
"command",
"custom",
"multiplexers",
"mux",
"override",
"script",
"terminal",
"tmux",
"zellij"
],
"icon": "desktop_windows",
"description": "Override terminal with a custom command or script"
},
{
"section": "frameLauncherArcExtender",
"label": "Arc Extender",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"app drawer",
"app menu",
"applications",
"arc",
"border",
"center",
"connected",
"content",
"decoration",
"extended",
"extender",
"frame",
"launcher",
"start menu",
"surface",
"window"
],
"description": "Use the extended surface for launcher content"
},
{
"section": "frameBorder",
"label": "Border",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"corner",
"decoration",
"frame",
"horizontal",
"panel",
"radius",
"rounding",
"statusbar",
"taskbar",
"thickness",
"topbar",
"vertical",
"window"
],
"icon": "border_outer",
"description": "Horizontal and vertical bar thickness"
},
{
"section": "frameColor",
"label": "Border Color",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"color",
"colour",
"decoration",
"default",
"frame",
"hue",
"primary",
"surface",
"theme",
"tint",
"window"
]
},
{
"section": "frameRounding",
"label": "Border Radius",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"corner",
"decoration",
"frame",
"radius",
"rounding",
"window"
]
},
{
"section": "frameThickness",
"label": "Border Width",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"decoration",
"frame",
"size",
"thickness",
"width",
"window"
]
},
{
"section": "frameConnectedOptions",
"label": "Connected Options",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"arcs",
"border",
"connected",
"curves",
"decoration",
"edge",
"expose",
"frame",
"gap",
"meet",
"notification",
"options",
"popout",
"reveal",
"surfaces",
"where",
"window"
],
"icon": "blur_linear",
"description": "Reveal the arcs where surfaces meet the frame"
},
{
"section": "frameDisplays",
"label": "Display Assignment",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"assignment",
"border",
"decoration",
"display",
"frame",
"monitor",
"output",
"screen",
"window"
],
"icon": "monitor"
},
{
"section": "frameEnable",
"label": "Enable Frame",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"around",
"border",
"connected",
"decoration",
"display",
"draw",
"enable",
"entire",
"frame",
"monitor",
"outline",
"output",
"picture",
"screen",
"window"
],
"description": "Draw a connected picture-frame border around the entire display"
},
{
"section": "frameCloseGaps",
"label": "Expose the Arcs",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"arcs",
"border",
"connected",
"curves",
"decoration",
"edge",
"expose",
"frame",
"gap",
"meet",
"notification",
"popout",
"reveal",
"surfaces",
"where",
"window"
],
"description": "Reveal the arcs where surfaces meet the frame"
},
{
"section": "_tab_33",
"label": "Frame",
"tabIndex": 33,
"category": "Settings",
"category": "Frame",
"keywords": [
"border",
"decoration",
"frame",
"settings"
"window"
],
"icon": "frame_source"
},
{
"section": "frameEnabled",
"label": "Frame",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"around",
"border",
"connected",
"decoration",
"display",
"draw",
"entire",
"frame",
"monitor",
"outline",
"output",
"picture",
"screen",
"window"
],
"icon": "frame_source",
"description": "Draw a connected picture-frame border around the entire display"
},
{
"section": "frameBlurEnabled",
"label": "Frame Blur",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"background",
"blur",
"border",
"decoration",
"frame",
"frosted",
"glass",
"newer",
"quickshell",
"requires",
"transparency",
"version",
"window"
],
"description": "Requires a newer version of Quickshell"
},
{
"section": "frameBarIntegration",
"label": "Integrations",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"decoration",
"during",
"frame",
"hide",
"integrations",
"niri",
"overview",
"show",
"window"
],
"icon": "toolbar",
"description": "Show during Niri overview",
"conditionKey": "isNiri"
},
{
"section": "frameLauncherEmergeSide",
"label": "Launcher Emerge Side",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"app drawer",
"app menu",
"applications",
"border",
"bottom",
"connected",
"decoration",
"direction",
"edge",
"emerge",
"frame",
"launcher",
"modal",
"side",
"slides",
"start menu",
"top",
"window"
],
"description": "Edge the launcher slides from"
},
{
"section": "frameMode",
"label": "Mode",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"connected",
"decoration",
"emerge",
"flush",
"frame",
"mode",
"panel",
"popout",
"separate",
"statusbar",
"surfaces",
"taskbar",
"topbar",
"window"
],
"icon": "tune",
"description": "Surfaces emerge flush from the bar"
},
{
"section": "frameShowOnOverview",
"label": "Show on Overview",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"decoration",
"during",
"frame",
"hide",
"niri",
"overview",
"show",
"window"
],
"description": "Show during Niri overview"
},
{
"section": "frameBarSize",
"label": "Size",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"bar",
"border",
"decoration",
"frame",
"height",
"horizontal",
"panel",
"size",
"statusbar",
"taskbar",
"thickness",
"topbar",
"vertical",
"width",
"window"
],
"description": "Horizontal and vertical bar thickness"
},
{
"section": "frameModeSelector",
"label": "Surface Behavior",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"behavior",
"border",
"connected",
"decoration",
"emerge",
"flush",
"frame",
"mode",
"panel",
"popout",
"separate",
"statusbar",
"surface",
"surfaces",
"taskbar",
"topbar",
"window"
],
"description": "Surfaces emerge flush from the bar"
},
{
"section": "frameOpacity",
"label": "Surface Opacity",
"tabIndex": 33,
"category": "Frame",
"keywords": [
"border",
"decoration",
"frame",
"opacity",
"popup",
"surface",
"transparency",
"window"
]
},
{
"section": "_tab_34",
"label": "Applications",
"tabIndex": 34,
"category": "Settings",
"category": "Default Apps",
"keywords": [
"app",
"applications",
"apps",
"browser",
"default",
"handlers",
"mime",
"program",
"programs",
"settings"
"terminal"
],
"icon": "apps"
},
{
"section": "createUserGreeter",
"label": "Allow greeter login access",
"tabIndex": 35,
"category": "Users",
"keywords": [
"access",
"accounts",
"allow",
"greeter",
"group",
"login",
"profile",
"sync",
"they",
"user",
"users"
],
"description": "Add the new user to the %1 group so they can run dms greeter sync --profile."
},
{
"section": "createUser",
"label": "Create User",
"tabIndex": 35,
"category": "Users",
"keywords": [
"accounts",
"admin",
"create",
"group",
"profile",
"sudo",
"they",
"user",
"users",
"wheel"
],
"icon": "person_add",
"description": "Add the new user to the %1 group so they can use sudo."
},
{
"section": "usersList",
"label": "Existing Users",
"tabIndex": 35,
"category": "Users",
"keywords": [
"accounts",
"existing",
"profile",
"user",
"users"
],
"icon": "group"
},
{
"section": "createUserAdmin",
"label": "Grant administrator privileges",
"tabIndex": 35,
"category": "Users",
"keywords": [
"accounts",
"admin",
"administrator",
"grant",
"group",
"privileges",
"profile",
"sudo",
"they",
"user",
"users",
"wheel"
],
"description": "Add the new user to the %1 group so they can use sudo."
},
{
"section": "_tab_35",
"label": "Users",
"tabIndex": 35,
"category": "Settings",
"category": "Users",
"keywords": [
"settings",
"accounts",
"profile",
"user",
"users"
],
"icon": "manage_accounts"
@@ -7754,11 +8615,27 @@
"section": "_tab_36",
"label": "Autostart Apps",
"tabIndex": 36,
"category": "Settings",
"category": "Autostart",
"keywords": [
"apps",
"autostart",
"settings"
"boot",
"launch",
"startup"
],
"icon": "line_start"
},
{
"section": "autostartEntries",
"label": "Autostart Entries",
"tabIndex": 36,
"category": "Autostart",
"keywords": [
"autostart",
"boot",
"entries",
"launch",
"startup"
],
"icon": "line_start"
}