diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index 3ab9c927..aec2fbff 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -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.") diff --git a/core/internal/config/deployer_test.go b/core/internal/config/deployer_test.go index 8d772a98..ac3f5933 100644 --- a/core/internal/config/deployer_test.go +++ b/core/internal/config/deployer_test.go @@ -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") diff --git a/core/internal/config/embedded/mango-binds.conf b/core/internal/config/embedded/mango-binds.conf index 546b4156..9498f5f2 100644 --- a/core/internal/config/embedded/mango-binds.conf +++ b/core/internal/config/embedded/mango-binds.conf @@ -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 diff --git a/core/internal/config/embedded/mango.conf b/core/internal/config/embedded/mango.conf index a2a95de9..3cec2322 100644 --- a/core/internal/config/embedded/mango.conf +++ b/core/internal/config/embedded/mango.conf @@ -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 diff --git a/core/internal/keybinds/providers/mangowc.go b/core/internal/keybinds/providers/mangowc.go index 0e4208f2..41e8d06c 100644 --- a/core/internal/keybinds/providers/mangowc.go +++ b/core/internal/keybinds/providers/mangowc.go @@ -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 © } 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) { diff --git a/core/internal/keybinds/providers/mangowc_parser.go b/core/internal/keybinds/providers/mangowc_parser.go index a86dca98..31a437c3 100644 --- a/core/internal/keybinds/providers/mangowc_parser.go +++ b/core/internal/keybinds/providers/mangowc_parser.go @@ -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) { diff --git a/core/internal/keybinds/providers/mangowc_parser_test.go b/core/internal/keybinds/providers/mangowc_parser_test.go index b77f7f8b..ac370791 100644 --- a/core/internal/keybinds/providers/mangowc_parser_test.go +++ b/core/internal/keybinds/providers/mangowc_parser_test.go @@ -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 { diff --git a/core/internal/keybinds/providers/mangowc_test.go b/core/internal/keybinds/providers/mangowc_test.go index d417ce16..16b6cc36 100644 --- a/core/internal/keybinds/providers/mangowc_test.go +++ b/core/internal/keybinds/providers/mangowc_test.go @@ -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) + } + } +} diff --git a/core/internal/tui/views_install.go b/core/internal/tui/views_install.go index bbbe2a52..ad04aac3 100644 --- a/core/internal/tui/views_install.go +++ b/core/internal/tui/views_install.go @@ -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") diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 8e614e19..62ac0aa6 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -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 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index de81b1a4..7e69fd16 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -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 }, diff --git a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml index 7da4fa8e..311db408 100644 --- a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml +++ b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml @@ -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; diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index f44f6678..3380b7f2 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -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"] diff --git a/quickshell/Modules/Settings/WorkspacesTab.qml b/quickshell/Modules/Settings/WorkspacesTab.qml index a5b51f43..8932cad3 100644 --- a/quickshell/Modules/Settings/WorkspacesTab.qml +++ b/quickshell/Modules/Settings/WorkspacesTab.qml @@ -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) diff --git a/quickshell/Services/MangoService.qml b/quickshell/Services/MangoService.qml index b09e3abd..bcc796a4 100644 --- a/quickshell/Services/MangoService.qml +++ b/quickshell/Services/MangoService.qml @@ -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 ,) ───────────── - 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)