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

feat(mango): first-class MangoWM support across DMS, dankinstaller & UI tools

- Bring up Mango to parity with niri/hyprland via a native JSON-IPC w/Native MangoServic., replaces the legacy dwl/`mmsg` path and recent breaking changes
- Dankinstall: mango supported installer, config/binds templates, and packaging (Arch AUR, Fedora Terra auto-enable, Gentoo GURU)
- Window rules: Go provider + CLI + Settings GUI editor
- Keybinds + config reload on edit (mmsg dispatch reload_config)
- Misc new supported options in DMS settings
This commit is contained in:
purian23
2026-06-04 18:45:04 -04:00
parent 4181343ef3
commit 8eb23bcc29
63 changed files with 2282 additions and 301 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ configure passwordless sudo for your user.`,
}
func init() {
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)")
rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri, hyprland, or mango (enables headless mode)")
rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)")
rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)")
rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation")
@@ -95,7 +95,7 @@ func runDankinstall(cmd *cobra.Command, args []string) error {
func runHeadless() error {
// Validate required flags
if compositor == "" {
return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)")
return fmt.Errorf("--compositor is required for headless mode (niri, hyprland, or mango)")
}
if term == "" {
return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)")
+58 -29
View File
@@ -100,56 +100,72 @@ var setupWindowrulesCmd = &cobra.Command{
}
type dmsConfigSpec struct {
niriFile string
hyprFile string
niriContent func(terminal string) string
hyprContent func(terminal string) string
niriFile string
hyprFile string
mangoFile string
niriContent func(terminal string) string
hyprContent func(terminal string) string
mangoContent func(terminal string) string
}
var dmsConfigSpecs = map[string]dmsConfigSpec{
"binds": {
niriFile: "binds.kdl",
hyprFile: "binds.lua",
niriFile: "binds.kdl",
hyprFile: "binds.lua",
mangoFile: "binds.conf",
niriContent: func(t string) string {
return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
hyprContent: func(t string) string {
return strings.ReplaceAll(config.DMSBindsLuaConfig, "{{TERMINAL_COMMAND}}", t)
},
mangoContent: func(t string) string {
return strings.ReplaceAll(config.MangoBindsConfig, "{{TERMINAL_COMMAND}}", t)
},
},
"layout": {
niriFile: "layout.kdl",
hyprFile: "layout.lua",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
niriFile: "layout.kdl",
hyprFile: "layout.lua",
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
},
"colors": {
niriFile: "colors.kdl",
hyprFile: "colors.lua",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
niriFile: "colors.kdl",
hyprFile: "colors.lua",
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
},
"alttab": {
niriFile: "alttab.kdl",
niriContent: func(_ string) string { return config.NiriAlttabConfig },
},
"outputs": {
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
niriFile: "outputs.kdl",
hyprFile: "outputs.lua",
mangoFile: "outputs.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSOutputsLuaConfig },
mangoContent: func(_ string) string { return "" },
},
"cursor": {
niriFile: "cursor.kdl",
hyprFile: "cursor.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
niriFile: "cursor.kdl",
hyprFile: "cursor.lua",
mangoFile: "cursor.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSCursorLuaConfig },
mangoContent: func(_ string) string { return "" },
},
"windowrules": {
niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
niriFile: "windowrules.kdl",
hyprFile: "windowrules.lua",
mangoFile: "windowrules.conf",
niriContent: func(_ string) string { return "" },
hyprContent: func(_ string) string { return config.DMSWindowRulesLuaConfig },
mangoContent: func(_ string) string { return "" },
},
}
@@ -192,7 +208,7 @@ func detectCompositorForSetup() (string, error) {
switch len(compositors) {
case 0:
return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)")
return "", fmt.Errorf("no supported compositors found (niri, Hyprland, or mango required)")
case 1:
return strings.ToLower(compositors[0]), nil
}
@@ -224,6 +240,9 @@ func runSetupDmsConfig(name string) error {
case "hyprland":
filename = spec.hyprFile
contentFn = spec.hyprContent
case "mango", "mangowc":
filename = spec.mangoFile
contentFn = spec.mangoContent
default:
return fmt.Errorf("unsupported compositor: %s", compositor)
}
@@ -238,6 +257,8 @@ func runSetupDmsConfig(name string) error {
dmsDir = filepath.Join(utils.XDGConfigHome(), "niri", "dms")
case "hyprland":
dmsDir = filepath.Join(utils.XDGConfigHome(), "hypr", "dms")
case "mango", "mangowc":
dmsDir = filepath.Join(utils.XDGConfigHome(), "mango", "dms")
}
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
@@ -379,10 +400,11 @@ func promptCompositor() (deps.WindowManager, bool) {
fmt.Println("Select compositor:")
fmt.Println("1) Niri")
fmt.Println("2) Hyprland")
fmt.Println("3) None")
fmt.Println("3) Mango")
fmt.Println("4) None")
var response string
fmt.Print("\nChoice (1-3): ")
fmt.Print("\nChoice (1-4): ")
fmt.Scanln(&response)
response = strings.TrimSpace(response)
@@ -391,6 +413,8 @@ func promptCompositor() (deps.WindowManager, bool) {
return deps.WindowManagerNiri, true
case "2":
return deps.WindowManagerHyprland, true
case "3":
return deps.WindowManagerMango, true
default:
return deps.WindowManagerNiri, false
}
@@ -447,6 +471,11 @@ func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.
filepath.Join(homeDir, ".config", "hypr", "hyprland.lua"),
filepath.Join(homeDir, ".config", "hypr", "hyprland.conf"),
}
case deps.WindowManagerMango:
configPaths = []string{
filepath.Join(homeDir, ".config", "mango", "config.conf"),
filepath.Join(homeDir, ".config", "mango", "mango.conf"),
}
}
for _, configPath := range configPaths {
+44 -6
View File
@@ -27,7 +27,7 @@ var windowrulesListCmd = &cobra.Command{
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -41,7 +41,7 @@ var windowrulesAddCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -55,7 +55,7 @@ var windowrulesUpdateCmd = &cobra.Command{
Args: cobra.ExactArgs(3),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -69,7 +69,7 @@ var windowrulesRemoveCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -83,7 +83,7 @@ var windowrulesReorderCmd = &cobra.Command{
Args: cobra.ExactArgs(2),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp
return []string{"hyprland", "niri", "mango"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
},
@@ -121,6 +121,9 @@ func getCompositor(args []string) string {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" {
return "hyprland"
}
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
return "mango"
}
return ""
}
@@ -140,7 +143,7 @@ func writeRuleSuccess(id, path string) {
func runWindowrulesList(cmd *cobra.Command, args []string) {
compositor := getCompositor(args)
if compositor == "" {
log.Fatalf("Could not detect compositor. Please specify: hyprland or niri")
log.Fatalf("Could not detect compositor. Please specify: hyprland, niri, or mango")
}
var result WindowRulesListResult
@@ -212,6 +215,38 @@ func runWindowrulesList(cmd *cobra.Command, args []string) {
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
parseResult, err := providers.ParseMangoWindowRules(configDir)
if err != nil {
log.Fatalf("Failed to parse mango window rules: %v", err)
}
allRules := providers.ConvertMangoRulesToWindowRules(parseResult.Rules)
provider := providers.NewMangoWritableProvider(configDir)
dmsRules, _ := provider.LoadDMSRules()
dmsRuleMap := make(map[int]windowrules.WindowRule)
for i, dr := range dmsRules {
dmsRuleMap[i] = dr
}
dmsIdx := 0
for i, r := range allRules {
if r.Source == "dms/windowrules.conf" {
if dmr, ok := dmsRuleMap[dmsIdx]; ok {
allRules[i].ID = dmr.ID
allRules[i].Name = dmr.Name
}
dmsIdx++
}
}
result.Rules = allRules
result.DMSStatus = parseResult.DMSStatus
default:
log.Fatalf("Unknown compositor: %s", compositor)
}
@@ -315,6 +350,9 @@ func getWindowRulesProvider(compositor string) windowrules.WritableProvider {
case "hyprland":
configDir := filepath.Join(utils.XDGConfigHome(), "hypr")
return providers.NewHyprlandWritableProvider(configDir)
case "mango", "mangowc":
configDir := filepath.Join(utils.XDGConfigHome(), "mango")
return providers.NewMangoWritableProvider(configDir)
default:
return nil
}
+102
View File
@@ -73,6 +73,10 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua"),
filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"),
},
"Mango": {
filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf"),
},
"Ghostty": {
filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"),
},
@@ -126,6 +130,14 @@ func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm d
return results, fmt.Errorf("failed to deploy Hyprland config: %w", err)
}
}
case deps.WindowManagerMango:
if shouldReplaceConfig("Mango") {
result, err := cd.deployMangoConfig(terminal, useSystemd)
results = append(results, result)
if err != nil {
return results, fmt.Errorf("failed to deploy Mango config: %w", err)
}
}
}
switch terminal {
@@ -269,6 +281,96 @@ func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) e
return nil
}
func (cd *ConfigDeployer) deployMangoConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) {
result := DeploymentResult{
ConfigType: "Mango",
Path: filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf"),
}
configDir := filepath.Dir(result.Path)
if err := os.MkdirAll(configDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create config directory: %w", err)
return result, result.Error
}
dmsDir := filepath.Join(configDir, "dms")
if err := os.MkdirAll(dmsDir, 0o755); err != nil {
result.Error = fmt.Errorf("failed to create dms directory: %w", err)
return result, result.Error
}
var terminalCommand string
switch terminal {
case deps.TerminalGhostty:
terminalCommand = "ghostty"
case deps.TerminalKitty:
terminalCommand = "kitty"
case deps.TerminalAlacritty:
terminalCommand = "alacritty"
default:
terminalCommand = "ghostty"
}
// DMS owns config.conf for mango (like niri/hyprland): back up and replace.
if existingData, err := os.ReadFile(result.Path); err == nil {
cd.log("Found existing Mango configuration")
timestamp := time.Now().Format("2006-01-02_15-04-05")
result.BackupPath = result.Path + ".backup." + timestamp
if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil {
result.Error = fmt.Errorf("failed to create backup: %w", err)
return result, result.Error
}
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil {
result.Error = fmt.Errorf("failed to write config: %w", err)
return result, result.Error
}
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
result.Deployed = true
cd.log("Successfully deployed Mango configuration")
return result, nil
}
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string) error {
configs := []struct {
name string
content string
overwrite bool
}{
// binds.conf is DMS-owned (overwrite); the rest are runtime/user-managed.
{"binds.conf", strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand), true},
{"colors.conf", MangoColorsConfig, false},
{"layout.conf", MangoLayoutConfig, false},
{"outputs.conf", "", false},
{"cursor.conf", "", false},
{"windowrules.conf", "", false},
}
for _, cfg := range configs {
path := filepath.Join(dmsDir, cfg.name)
if !cfg.overwrite {
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
}
if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil {
return fmt.Errorf("failed to write %s: %w", cfg.name, err)
}
cd.log(fmt.Sprintf("Deployed %s", cfg.name))
}
return nil
}
func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) {
var results []DeploymentResult
@@ -0,0 +1,176 @@
# 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).
# === Application Launchers ===
# Open Terminal
bind=SUPER,t,spawn,{{TERMINAL_COMMAND}}
# Open Terminal
bind=SUPER,Return,spawn,{{TERMINAL_COMMAND}}
# Application Launcher
bind=SUPER,space,spawn,dms ipc call spotlight toggle
# Spotlight Bar
bind=ALT,space,spawn,dms ipc call spotlight-bar toggle
# Clipboard Manager
bind=SUPER,v,spawn,dms ipc call clipboard toggle
# Task Manager
bind=SUPER,m,spawn,dms ipc call processlist focusOrToggle
# Settings
bind=SUPER,comma,spawn,dms ipc call settings focusOrToggle
# Notification Center
bind=SUPER,n,spawn,dms ipc call notifications toggle
# Notepad
bind=SUPER+SHIFT,n,spawn,dms ipc call notepad toggle
# Browse Wallpapers
bind=SUPER,y,spawn,dms ipc call dankdash wallpaper
# Power Menu
bind=SUPER,x,spawn,dms ipc call powermenu toggle
# Cycle Display Profile
bind=SUPER,p,spawn,dms ipc outputs cycleProfile
# === Cheat sheet ===
# Keyboard Shortcuts
bind=SUPER+SHIFT,slash,spawn,dms ipc call keybinds toggle mangowc
# === Security ===
# Lock Screen
bind=SUPER+ALT,l,spawn,dms ipc call lock lock
# Task Manager
bind=CTRL+ALT,Delete,spawn,dms ipc call processlist focusOrToggle
# === Window Rules ===
# Create Window Rule
bind=SUPER+SHIFT,w,spawn,dms ipc call window-rules toggle
# === Screenshots ===
# Screenshot: Interactive
bind=none,Print,spawn,dms screenshot
# Screenshot: Full Screen
bind=CTRL,Print,spawn,dms screenshot full
# Screenshot: Window
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,
# 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,Right,focusdir,right
# Focus Up
bind=SUPER,Up,focusdir,up
# Focus Down
bind=SUPER,Down,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
# === 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
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
@@ -0,0 +1,6 @@
# Auto-generated by DMS. Overwritten by matugen (dms/colors.conf).
# Remove `source=./dms/colors.conf` from config.conf to override manually.
bordercolor = 0x595959ff
focuscolor = 0x8ab4f8ff
urgentcolor = 0xff5555ff
@@ -0,0 +1,8 @@
# Auto-generated by DMS. Regenerated from DMS settings (dms/layout.conf).
border_radius=12
gappih=5
gappiv=5
gappoh=5
gappov=5
borderpx=2
+18
View File
@@ -0,0 +1,18 @@
# DankMaterialShell — MangoWM configuration (managed by `dms setup`)
# Keybinds, colors, layout, outputs, cursor and window rules are pulled from the
# ./dms fragments below. Add your own binds/rules here; they sit alongside DMS's.
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
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
exec_once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
source=./dms/cursor.conf
source=./dms/outputs.conf
source=./dms/windowrules.conf
source=./dms/binds.conf
+15
View File
@@ -0,0 +1,15 @@
package config
import _ "embed"
//go:embed embedded/mango.conf
var MangoConfig string
//go:embed embedded/mango-colors.conf
var MangoColorsConfig string
//go:embed embedded/mango-layout.conf
var MangoLayoutConfig string
//go:embed embedded/mango-binds.conf
var MangoBindsConfig string
+1
View File
@@ -35,6 +35,7 @@ type WindowManager int
const (
WindowManagerHyprland WindowManager = iota
WindowManagerNiri
WindowManagerMango
)
type Terminal int
+21 -1
View File
@@ -112,6 +112,11 @@ func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, w
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, a.detectXwaylandSatellite())
}
dependencies = append(dependencies, a.detectMatugen())
dependencies = append(dependencies, a.detectDgop())
@@ -172,6 +177,11 @@ func (a *ArchDistribution) isInSystemRepo(pkg string) bool {
return exec.Command("pacman", "-Si", pkg).Run() == nil
}
// isSonameProvides reports whether dep is a shared-library soname
func isSonameProvides(dep string) bool {
return strings.HasSuffix(dep, ".so") || strings.Contains(dep, ".so.")
}
func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
}
@@ -199,6 +209,9 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
case deps.WindowManagerNiri:
packages["niri"] = a.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
packages["mango"] = a.getMangoMapping(variants["mango"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
@@ -222,6 +235,13 @@ func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMa
return PackageMapping{Name: "niri", Repository: RepoTypeSystem}
}
func (a *ArchDistribution) getMangoMapping(variant deps.PackageVariant) PackageMapping {
if variant == deps.VariantGit {
return PackageMapping{Name: "mangowm-git", Repository: RepoTypeAUR}
}
return PackageMapping{Name: "mangowm", Repository: RepoTypeAUR}
}
func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping {
if runtime.GOARCH == "arm64" {
return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR}
@@ -724,7 +744,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
continue
}
seen[dep] = true
if a.isInSystemRepo(dep) {
if isSonameProvides(dep) || a.isInSystemRepo(dep) {
systemPkgs = append(systemPkgs, dep)
} else {
aurPkgs = append(aurPkgs, dep)
+30
View File
@@ -337,6 +337,36 @@ func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Depen
Variant: variant,
CanToggle: true,
}
case deps.WindowManagerMango:
status := deps.StatusMissing
variant := deps.VariantStable
version := ""
if b.commandExists("mango") {
status = deps.StatusInstalled
cmd := exec.Command("mango", "-v")
if output, err := cmd.Output(); err == nil {
outStr := string(output)
if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") {
variant = deps.VariantGit
}
if versionRegex := regexp.MustCompile(`(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) {
matches := versionRegex.FindStringSubmatch(outStr)
if len(matches) > 1 {
version = matches[1]
}
}
}
}
return deps.Dependency{
Name: "mango",
Status: status,
Version: version,
Description: "dwl-based dynamic tiling Wayland compositor",
Required: true,
Variant: variant,
CanToggle: true,
}
default:
return deps.Dependency{
Name: "unknown-wm",
+55 -2
View File
@@ -77,7 +77,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
// Common detections using base methods
dependencies = append(dependencies, f.detectGit())
dependencies = append(dependencies, f.detectWindowManager(wm))
wmDep := f.detectWindowManager(wm)
if wm == deps.WindowManagerMango {
wmDep.Description = "MangoWM (Wayland compositor) — the Terra repo will be enabled automatically to install it"
}
dependencies = append(dependencies, wmDep)
dependencies = append(dependencies, f.detectQuickshell())
dependencies = append(dependencies, f.detectDMSGreeter())
dependencies = append(dependencies, f.detectXDGPortal())
@@ -93,6 +97,11 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, f.detectXwaylandSatellite())
}
dependencies = append(dependencies, f.detectMatugen())
dependencies = append(dependencies, f.detectDgop())
@@ -139,6 +148,10 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri:
packages["niri"] = f.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
case deps.WindowManagerMango:
// mangowm resolves via Terra, enabled automatically by enableTerraRepo.
packages["mango"] = PackageMapping{Name: "mangowm", Repository: RepoTypeSystem}
packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem}
}
return packages
@@ -159,7 +172,7 @@ func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageM
}
func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"}
return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "lionheartp/Hyprland"}
}
func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping {
@@ -297,6 +310,22 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
}
}
// Phase 2b: Enable Terra repo for MangoWM (not in Fedora's repos). Must run
// before the DNF phase so `mangowm` resolves.
if wm == deps.WindowManagerMango {
progressChan <- InstallProgressMsg{
Phase: PhaseSystemPackages,
Progress: 0.25,
Step: "Enabling Terra repository for MangoWM...",
IsComplete: false,
NeedsSudo: true,
LogOutput: "Setting up the Terra repo (fyralabs) to provide mango",
}
if err := f.enableTerraRepo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
}
// Phase 3: System Packages (DNF)
if len(dnfPkgs) > 0 {
progressChan <- InstallProgressMsg{
@@ -423,6 +452,30 @@ func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []st
return names
}
// enableTerraRepo registers the persistent Terra repo (via terra-release) so
// `mangowm` resolves in the DNF phase. $releasever is single-quoted so dnf, not
// the shell, expands it.
func (f *FedoraDistribution) enableTerraRepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
// Skip if Terra is already configured
if exec.CommandContext(ctx, "sh", "-c",
"rpm -q terra-release >/dev/null 2>&1 || test -f /etc/yum.repos.d/terra.repo").Run() == nil {
f.log("Terra repository already configured, skipping enable")
return nil
}
f.log("Enabling Terra repository (fyralabs) for mango...")
cmd := privesc.ExecCommand(ctx, sudoPassword,
`dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release 2>&1`)
output, err := cmd.CombinedOutput()
if err != nil {
f.logError("failed to enable Terra repository", err)
f.log(fmt.Sprintf("Terra enable output: %s", string(output)))
return fmt.Errorf("failed to enable Terra repository: %w", err)
}
f.log(fmt.Sprintf("Terra repository enabled: %s", string(output)))
return nil
}
func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
enabledRepos := make(map[string]bool)
+13
View File
@@ -106,6 +106,11 @@ func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context,
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
// Mango-specific tools (dwl-based, uses xwayland-satellite like niri)
if wm == deps.WindowManagerMango {
dependencies = append(dependencies, g.detectXwaylandSatellite())
}
dependencies = append(dependencies, g.detectMatugen())
dependencies = append(dependencies, g.detectDgop())
@@ -176,6 +181,10 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
case deps.WindowManagerNiri:
packages["niri"] = g.getNiriMapping(variants["niri"])
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
case deps.WindowManagerMango:
packages["mango"] = g.getMangoMapping(variants["mango"])
packages["scenefx"] = PackageMapping{Name: "gui-libs/scenefx", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}
}
return packages
@@ -197,6 +206,10 @@ func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMappin
return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getMangoMapping(_ deps.PackageVariant) PackageMapping {
return PackageMapping{Name: "gui-wm/mangowm", Repository: RepoTypeGURU, AcceptKeywords: g.getArchKeyword()}
}
func (g *GentooDistribution) getPrerequisites() []string {
return []string{
"app-eselect/eselect-repository",
+3
View File
@@ -680,6 +680,9 @@ func DetectCompositors() []string {
if utils.CommandExists("Hyprland") {
compositors = append(compositors, "Hyprland")
}
if utils.CommandExists("mango") {
compositors = append(compositors, "mango")
}
return compositors
}
+3 -1
View File
@@ -364,8 +364,10 @@ func (r *Runner) parseWindowManager() (deps.WindowManager, error) {
return deps.WindowManagerNiri, nil
case "hyprland":
return deps.WindowManagerHyprland, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
default:
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor)
return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri', 'hyprland', or 'mango'", r.cfg.Compositor)
}
}
+8 -5
View File
@@ -397,6 +397,14 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
mods, key := m.parseKeyString(bind.Key)
command, params := m.parseAction(bind.Action)
// Description goes on the line ABOVE the bind: mango doesn't strip inline `#`
// comments from a value, so a trailing comment would break spawn (extra argv).
if bind.Description != "" {
sb.WriteString("# ")
sb.WriteString(bind.Description)
sb.WriteString("\n")
}
sb.WriteString("bind=")
if mods == "" {
sb.WriteString("none")
@@ -413,11 +421,6 @@ func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverri
sb.WriteString(params)
}
if bind.Description != "" {
sb.WriteString(" # ")
sb.WriteString(bind.Description)
}
sb.WriteString("\n")
}
@@ -216,101 +216,37 @@ func mangowcAutogenerateComment(command, params string) string {
}
}
func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding {
func (p *MangoWCParser) getKeybindAtLine(lineNumber int, precedingComment string) *MangoWCKeyBinding {
if lineNumber >= len(p.contentLines) {
return nil
}
line := p.contentLines[lineNumber]
bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bindType := matches[1]
content := matches[2]
parts := strings.SplitN(content, "#", 2)
keys := parts[0]
var comment string
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
}
keyFields := strings.SplitN(keys, ",", 4)
if len(keyFields) < 3 {
return nil
}
mods := strings.TrimSpace(keyFields[0])
key := strings.TrimSpace(keyFields[1])
command := strings.TrimSpace(keyFields[2])
var params string
if len(keyFields) > 3 {
params = strings.TrimSpace(keyFields[3])
}
if comment == "" {
comment = mangowcAutogenerateComment(command, params)
}
var modList []string
if mods != "" && !strings.EqualFold(mods, "none") {
modstring := mods + string(MangoWCModSeparators[0])
p := 0
for index, char := range modstring {
isModSep := false
for _, sep := range MangoWCModSeparators {
if char == sep {
isModSep = true
break
}
}
if isModSep {
if index-p > 1 {
modList = append(modList, modstring[p:index])
}
p = index + 1
}
}
}
_ = bindType
return &MangoWCKeyBinding{
Mods: modList,
Key: key,
Command: command,
Params: params,
Comment: comment,
}
return p.getKeybindAtLineContent(p.contentLines[lineNumber], precedingComment)
}
func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding {
var keybinds []MangoWCKeyBinding
var pendingComment string
for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ {
line := p.contentLines[lineNumber]
if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
trimmed := strings.TrimSpace(p.contentLines[lineNumber])
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue
}
if !strings.HasPrefix(strings.TrimSpace(line), "bind") {
continue
}
keybind := p.getKeybindAtLine(lineNumber)
keybind := p.getKeybindAtLine(lineNumber, pendingComment)
if keybind != nil {
keybinds = append(keybinds, *keybind)
}
pendingComment = ""
}
return keybinds
@@ -459,21 +395,35 @@ func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBindin
p.currentSource = absPath
var keybinds []MangoWCKeyBinding
var pendingComment string
lines := strings.Split(string(data), "\n")
for lineNum, line := range lines {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "source") {
p.handleSource(trimmed, filepath.Dir(absPath), &keybinds)
pendingComment = ""
continue
}
if strings.HasPrefix(trimmed, "#") {
pendingComment = strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
continue
}
if !strings.HasPrefix(trimmed, "bind") {
pendingComment = ""
continue
}
kb := p.getKeybindAtLineContent(line, lineNum)
kb := p.getKeybindAtLineContent(line, pendingComment)
pendingComment = ""
if kb == nil {
continue
}
@@ -529,7 +479,10 @@ func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyB
return keybinds
}
func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding {
// getKeybindAtLineContent parses one `bind=` line. precedingComment (a `# ...`
// 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*(.+)$`)
matches := bindMatch.FindStringSubmatch(line)
if len(matches) < 3 {
@@ -544,6 +497,9 @@ func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyB
if len(parts) > 1 {
comment = strings.TrimSpace(parts[1])
}
if comment == "" {
comment = strings.TrimSpace(precedingComment)
}
if strings.HasPrefix(comment, MangoWCHideComment) {
return nil
@@ -174,7 +174,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, "")
if tt.expected == nil {
if result != nil {
@@ -421,7 +421,7 @@ func TestMangoWCInvalidBindLines(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, "")
if result != nil {
t.Errorf("expected nil for invalid line, got %+v", result)
+20 -11
View File
@@ -103,15 +103,7 @@ func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) deployConfigurations() tea.Cmd {
return func() tea.Msg {
// Determine the selected window manager
var wm deps.WindowManager
switch m.selectedWM {
case 0:
wm = deps.WindowManagerNiri
case 1:
wm = deps.WindowManagerHyprland
default:
wm = deps.WindowManagerNiri
}
wm := m.selectedWindowManager()
// Determine the selected terminal
var terminal deps.Terminal
@@ -288,7 +280,8 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
return func() tea.Msg {
var configs []ExistingConfigInfo
if m.selectedWM == 0 {
switch m.selectedWindowManager() {
case deps.WindowManagerNiri:
niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl")
niriExists := false
if _, err := os.Stat(niriPath); err == nil {
@@ -299,7 +292,23 @@ func (m Model) checkExistingConfigurations() tea.Cmd {
Path: niriPath,
Exists: niriExists,
})
} else {
case deps.WindowManagerMango:
mangoConfPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "config.conf")
mangoMainPath := filepath.Join(os.Getenv("HOME"), ".config", "mango", "mango.conf")
mangoPath := mangoConfPath
mangoExists := false
if _, err := os.Stat(mangoConfPath); err == nil {
mangoExists = true
} else if _, err := os.Stat(mangoMainPath); err == nil {
mangoPath = mangoMainPath
mangoExists = true
}
configs = append(configs, ExistingConfigInfo{
ConfigType: "Mango",
Path: mangoPath,
Exists: mangoExists,
})
default:
hyprlandLuaPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.lua")
hyprlandConfPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf")
hyprlandPath := hyprlandLuaPath
+5 -7
View File
@@ -209,12 +209,7 @@ func (m Model) installPackages() tea.Cmd {
}
// Convert TUI selection to deps enum
var wm deps.WindowManager
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri
} else {
wm = deps.WindowManagerHyprland
}
wm := m.selectedWindowManager()
installerProgressChan := make(chan distros.InstallProgressMsg, 100)
@@ -245,8 +240,11 @@ func (m Model) installPackages() tea.Cmd {
}
if greeterSelected {
compositorName := "niri"
if m.selectedWM == 1 {
switch m.selectedWindowManager() {
case deps.WindowManagerHyprland:
compositorName = "Hyprland"
case deps.WindowManagerMango:
compositorName = "mango"
}
m.packageProgressChan <- packageInstallProgressMsg{
progress: 0.92,
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
tea "github.com/charmbracelet/bubbletea"
)
@@ -65,7 +66,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) {
m.skipGentooUseFlags = !m.skipGentooUseFlags
return m, nil
case "enter":
if m.selectedWM == 1 {
if m.selectedWindowManager() == deps.WindowManagerHyprland {
return m, m.checkGCCVersion()
}
return m.enterAuthPhase()
+21 -3
View File
@@ -199,8 +199,21 @@ func (m Model) viewInstallComplete() string {
b.WriteString("\n")
}
wm := m.selectedWindowManager()
// 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:
loginHint = "If you do not have a greeter, login with \"niri-session\""
case deps.WindowManagerHyprland:
loginHint = "If you do not have a greeter, login with \"Hyprland\""
case deps.WindowManagerMango:
loginHint = "If you do not have a greeter, login with \"mango\""
}
b.WriteString("\n")
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"")
info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\n" + loginHint)
b.WriteString(info)
b.WriteString("\n\n")
@@ -209,8 +222,13 @@ func (m Model) viewInstallComplete() string {
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle))
b.WriteString(labelStyle.Render("Troubleshooting:") + "\n")
b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\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(" 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")
b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n")
}
if m.osInfo != nil {
if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" {
+27 -10
View File
@@ -10,6 +10,26 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
// windowManagerOptions returns the WM enums in selection-list order (debian omits
// Hyprland). selectedWM indexes into this, so all index->WM conversions use it.
func (m Model) windowManagerOptions() []deps.WindowManager {
opts := []deps.WindowManager{deps.WindowManagerNiri}
if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" {
opts = append(opts, deps.WindowManagerHyprland)
}
opts = append(opts, deps.WindowManagerMango)
return opts
}
// selectedWindowManager maps the current selectedWM index to its WM enum.
func (m Model) selectedWindowManager() deps.WindowManager {
opts := m.windowManagerOptions()
if m.selectedWM >= 0 && m.selectedWM < len(opts) {
return opts[m.selectedWM]
}
return deps.WindowManagerNiri
}
func (m Model) viewSelectWindowManager() string {
var b strings.Builder
@@ -34,6 +54,11 @@ func (m Model) viewSelectWindowManager() string {
}{"Hyprland", "Dynamic tiling Wayland compositor."})
}
options = append(options, struct {
name string
description string
}{"mango", "dwl-based dynamic tiling Wayland compositor."})
for i, option := range options {
if i == m.selectedWM {
selected := m.styles.SelectedOption.Render("▶ " + option.name)
@@ -152,10 +177,7 @@ func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
maxWMIndex := 1
if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" {
maxWMIndex = 0
}
maxWMIndex := len(m.windowManagerOptions()) - 1
switch keyMsg.String() {
case "up":
@@ -190,12 +212,7 @@ func (m Model) detectDependencies() tea.Cmd {
}
// Convert TUI selection to deps enum
var wm deps.WindowManager
if m.selectedWM == 0 {
wm = deps.WindowManagerNiri // First option is Niri
} else {
wm = deps.WindowManagerHyprland // Second option is Hyprland
}
wm := m.selectedWindowManager()
var terminal deps.Terminal
if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" {
@@ -0,0 +1,378 @@
package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
// Mango window rules are flat `windowrule=key:value,...` lines. DMS-managed rules
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
// `# @id=<id> @name=<name>` comment so they round-trip.
type MangoWindowRule struct {
Source string
Fields map[string]string
}
var mangoWindowRuleRegex = regexp.MustCompile(`^windowrule\s*=\s*(.+)$`)
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
func parseMangoWindowRuleLine(value string) map[string]string {
fields := map[string]string{}
for _, pair := range strings.Split(value, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
colon := strings.Index(pair, ":")
if colon < 0 {
continue
}
key := strings.TrimSpace(pair[:colon])
val := strings.TrimSpace(pair[colon+1:])
if key != "" {
fields[key] = val
}
}
return fields
}
// mangoConfigPath returns the main mango config (config.conf or mango.conf).
func mangoConfigPath(configDir string) string {
candidates := []string{
filepath.Join(configDir, "config.conf"),
filepath.Join(configDir, "mango.conf"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return candidates[0]
}
func mangoOverridePath(configDir string) string {
return filepath.Join(configDir, "dms", "windowrules.conf")
}
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
func parseMangoRulesFile(path, source string) []MangoWindowRule {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var rules []MangoWindowRule
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
rules = append(rules, MangoWindowRule{Source: source, Fields: parseMangoWindowRuleLine(m[1])})
}
}
return rules
}
type MangoRulesParseResult struct {
Rules []MangoWindowRule
DMSRulesIncluded bool
DMSStatus *windowrules.DMSRulesStatus
}
func ParseMangoWindowRules(configDir string) (*MangoRulesParseResult, error) {
mainPath := mangoConfigPath(configDir)
overridePath := mangoOverridePath(configDir)
var rules []MangoWindowRule
rules = append(rules, parseMangoRulesFile(mainPath, "config.conf")...)
rules = append(rules, parseMangoRulesFile(overridePath, "dms/windowrules.conf")...)
included := mangoDMSRulesIncluded(mainPath)
return &MangoRulesParseResult{
Rules: rules,
DMSRulesIncluded: included,
DMSStatus: &windowrules.DMSRulesStatus{
Exists: fileExists(overridePath),
Included: included,
Effective: included,
ConfigFormat: "conf",
StatusMessage: mangoIncludeMessage(included),
},
}, nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func mangoDMSRulesIncluded(mainPath string) bool {
data, err := os.ReadFile(mainPath)
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "source") && strings.Contains(trimmed, "dms/windowrules.conf") {
return true
}
}
return false
}
func mangoIncludeMessage(included bool) string {
if included {
return "DMS window rules are sourced from config.conf"
}
return "Add `source=./dms/windowrules.conf` to config.conf to apply DMS window rules"
}
func mangoBoolField(fields map[string]string, key string) *bool {
v, ok := fields[key]
if !ok {
return nil
}
b := v == "1" || strings.EqualFold(v, "true")
return &b
}
func mangoBoolStr(b *bool) string {
if b != nil && *b {
return "1"
}
return "0"
}
func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.WindowRule {
result := make([]windowrules.WindowRule, 0, len(mangoRules))
for i, mr := range mangoRules {
f := mr.Fields
actions := windowrules.Actions{
OpenFloating: mangoBoolField(f, "isfloating"),
OpenFullscreen: mangoBoolField(f, "isfullscreen"),
NoBlur: mangoBoolField(f, "noblur"),
NoBorder: mangoBoolField(f, "isnoborder"),
NoShadow: mangoBoolField(f, "isnoshadow"),
NoRounding: mangoBoolField(f, "isnoradius"),
NoAnim: mangoBoolField(f, "isnoanimation"),
}
if tags, ok := f["tags"]; ok {
actions.Workspace = tags
}
if mon, ok := f["monitor"]; ok {
actions.Monitor = mon
}
if w, ok := f["width"]; ok {
if h, ok2 := f["height"]; ok2 {
actions.Size = w + "x" + h
}
}
result = append(result, windowrules.WindowRule{
ID: fmt.Sprintf("rule_%d", i),
Enabled: true,
Source: mr.Source,
MatchCriteria: windowrules.MatchCriteria{
AppID: f["appid"],
Title: f["title"],
},
Actions: actions,
})
}
return result
}
// formatMangoRule serializes a shared WindowRule into a mango windowrule= line.
func formatMangoRule(rule windowrules.WindowRule) string {
var parts []string
add := func(k, v string) {
if v != "" {
parts = append(parts, k+":"+v)
}
}
add("appid", rule.MatchCriteria.AppID)
add("title", rule.MatchCriteria.Title)
add("tags", rule.Actions.Workspace)
add("monitor", rule.Actions.Monitor)
if rule.Actions.Size != "" {
if w, h, ok := splitSize(rule.Actions.Size); ok {
add("width", w)
add("height", h)
}
}
addBool := func(k string, b *bool) {
if b != nil {
parts = append(parts, k+":"+mangoBoolStr(b))
}
}
addBool("isfloating", rule.Actions.OpenFloating)
addBool("isfullscreen", rule.Actions.OpenFullscreen)
addBool("noblur", rule.Actions.NoBlur)
addBool("isnoborder", rule.Actions.NoBorder)
addBool("isnoshadow", rule.Actions.NoShadow)
addBool("isnoradius", rule.Actions.NoRounding)
addBool("isnoanimation", rule.Actions.NoAnim)
return "windowrule=" + strings.Join(parts, ",")
}
func splitSize(size string) (w, h string, ok bool) {
for _, sep := range []string{"x", "X", " "} {
if parts := strings.Split(size, sep); len(parts) == 2 {
w = strings.TrimSpace(parts[0])
h = strings.TrimSpace(parts[1])
if _, err := strconv.ParseFloat(w, 64); err == nil {
return w, h, true
}
}
}
return "", "", false
}
type MangoWritableProvider struct {
configDir string
}
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
return &MangoWritableProvider{configDir: configDir}
}
func (p *MangoWritableProvider) Name() string { return "mango" }
func (p *MangoWritableProvider) GetOverridePath() string {
return mangoOverridePath(p.configDir)
}
func (p *MangoWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) {
result, err := ParseMangoWindowRules(p.configDir)
if err != nil {
return nil, err
}
return &windowrules.RuleSet{
Title: "Mango Window Rules",
Provider: "mango",
Rules: ConvertMangoRulesToWindowRules(result.Rules),
DMSRulesIncluded: result.DMSRulesIncluded,
DMSStatus: result.DMSStatus,
}, nil
}
func (p *MangoWritableProvider) SetRule(rule windowrules.WindowRule) error {
rules, err := p.LoadDMSRules()
if err != nil {
rules = []windowrules.WindowRule{}
}
found := false
for i, r := range rules {
if r.ID == rule.ID {
rules[i] = rule
found = true
break
}
}
if !found {
rules = append(rules, rule)
}
return p.writeDMSRules(rules)
}
func (p *MangoWritableProvider) RemoveRule(id string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
newRules := make([]windowrules.WindowRule, 0, len(rules))
for _, r := range rules {
if r.ID != id {
newRules = append(newRules, r)
}
}
return p.writeDMSRules(newRules)
}
func (p *MangoWritableProvider) ReorderRules(ids []string) error {
rules, err := p.LoadDMSRules()
if err != nil {
return err
}
ruleMap := make(map[string]windowrules.WindowRule, len(rules))
for _, r := range rules {
ruleMap[r.ID] = r
}
newRules := make([]windowrules.WindowRule, 0, len(ids))
for _, id := range ids {
if r, ok := ruleMap[id]; ok {
newRules = append(newRules, r)
delete(ruleMap, id)
}
}
for _, r := range ruleMap {
newRules = append(newRules, r)
}
return p.writeDMSRules(newRules)
}
// LoadDMSRules parses only the DMS override file, preserving @id/@name metadata.
func (p *MangoWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) {
data, err := os.ReadFile(p.GetOverridePath())
if err != nil {
if os.IsNotExist(err) {
return []windowrules.WindowRule{}, nil
}
return nil, err
}
var rules []windowrules.WindowRule
var curID, curName string
idx := 0
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if m := mangoMetaCommentRegex.FindStringSubmatch(trimmed); m != nil {
curID = m[1]
curName = strings.TrimSpace(m[2])
continue
}
if m := mangoWindowRuleRegex.FindStringSubmatch(trimmed); m != nil {
converted := ConvertMangoRulesToWindowRules([]MangoWindowRule{{Source: "dms/windowrules.conf", Fields: parseMangoWindowRuleLine(m[1])}})
wr := converted[0]
if curID != "" {
wr.ID = curID
} else {
wr.ID = fmt.Sprintf("rule_%d", idx)
}
wr.Name = curName
rules = append(rules, wr)
curID, curName = "", ""
idx++
}
}
return rules, nil
}
func (p *MangoWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error {
overridePath := p.GetOverridePath()
if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil {
return err
}
var sb strings.Builder
sb.WriteString("# Auto-generated by DMS - DMS-managed mango window rules\n\n")
for i, r := range rules {
id := r.ID
if id == "" {
id = fmt.Sprintf("rule_%d", i)
}
fmt.Fprintf(&sb, "# @id=%s @name=%s\n", id, r.Name)
sb.WriteString(formatMangoRule(r))
sb.WriteString("\n\n")
}
return os.WriteFile(overridePath, []byte(sb.String()), 0o644)
}
@@ -0,0 +1,116 @@
package providers
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules"
)
func TestParseMangoWindowRuleLine(t *testing.T) {
fields := parseMangoWindowRuleLine("appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1")
if fields["appid"] != "firefox" {
t.Errorf("appid = %q, want firefox", fields["appid"])
}
if fields["title"] != "Gmail" {
t.Errorf("title = %q, want Gmail", fields["title"])
}
if fields["isfloating"] != "1" {
t.Errorf("isfloating = %q, want 1", fields["isfloating"])
}
if fields["tags"] != "2" {
t.Errorf("tags = %q, want 2", fields["tags"])
}
if fields["monitor"] != "HDMI-A-1" {
t.Errorf("monitor = %q, want HDMI-A-1", fields["monitor"])
}
}
func TestConvertMangoRulesToWindowRules(t *testing.T) {
mangoRules := []MangoWindowRule{
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
}
rules := ConvertMangoRulesToWindowRules(mangoRules)
if len(rules) != 1 {
t.Fatalf("got %d rules, want 1", len(rules))
}
r := rules[0]
if r.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", r.MatchCriteria.AppID)
}
if r.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", r.Actions.Workspace)
}
if r.Actions.OpenFloating == nil || !*r.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", r.Actions.OpenFloating)
}
if r.Actions.NoBlur == nil || !*r.Actions.NoBlur {
t.Errorf("NoBlur = %v, want true", r.Actions.NoBlur)
}
}
func TestMangoSetAndLoadRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
provider := NewMangoWritableProvider(tmpDir)
floating := true
rule := windowrules.WindowRule{
ID: "rule_test",
Name: "Float Discord",
Enabled: true,
MatchCriteria: windowrules.MatchCriteria{
AppID: "discord",
},
Actions: windowrules.Actions{
OpenFloating: &floating,
Workspace: "9",
Size: "1000x900",
},
}
if err := provider.SetRule(rule); err != nil {
t.Fatalf("SetRule: %v", err)
}
expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf")
if _, err := os.Stat(expectedPath); err != nil {
t.Fatalf("override file not written: %v", err)
}
loaded, err := provider.LoadDMSRules()
if err != nil {
t.Fatalf("LoadDMSRules: %v", err)
}
if len(loaded) != 1 {
t.Fatalf("got %d rules, want 1", len(loaded))
}
got := loaded[0]
if got.ID != "rule_test" {
t.Errorf("ID = %q, want rule_test", got.ID)
}
if got.Name != "Float Discord" {
t.Errorf("Name = %q, want 'Float Discord'", got.Name)
}
if got.MatchCriteria.AppID != "discord" {
t.Errorf("AppID = %q, want discord", got.MatchCriteria.AppID)
}
if got.Actions.Workspace != "9" {
t.Errorf("Workspace = %q, want 9", got.Actions.Workspace)
}
if got.Actions.Size != "1000x900" {
t.Errorf("Size = %q, want 1000x900", got.Actions.Size)
}
if got.Actions.OpenFloating == nil || !*got.Actions.OpenFloating {
t.Errorf("OpenFloating = %v, want true", got.Actions.OpenFloating)
}
// Remove and confirm empty.
if err := provider.RemoveRule("rule_test"); err != nil {
t.Fatalf("RemoveRule: %v", err)
}
loaded, _ = provider.LoadDMSRules()
if len(loaded) != 0 {
t.Errorf("after remove got %d rules, want 0", len(loaded))
}
}