diff --git a/core/cmd/dankinstall/main.go b/core/cmd/dankinstall/main.go index 042506ea..3560a5e5 100644 --- a/core/cmd/dankinstall/main.go +++ b/core/cmd/dankinstall/main.go @@ -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)") diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index 8637f0d5..3ab9c927 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -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 { diff --git a/core/cmd/dms/commands_windowrules.go b/core/cmd/dms/commands_windowrules.go index a19ab43c..8633709f 100644 --- a/core/cmd/dms/commands_windowrules.go +++ b/core/cmd/dms/commands_windowrules.go @@ -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 } diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index 2494e8ba..82c1bcbf 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -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 diff --git a/core/internal/config/embedded/mango-binds.conf b/core/internal/config/embedded/mango-binds.conf new file mode 100644 index 00000000..4d34cc03 --- /dev/null +++ b/core/internal/config/embedded/mango-binds.conf @@ -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 diff --git a/core/internal/config/embedded/mango-colors.conf b/core/internal/config/embedded/mango-colors.conf new file mode 100644 index 00000000..009aea48 --- /dev/null +++ b/core/internal/config/embedded/mango-colors.conf @@ -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 diff --git a/core/internal/config/embedded/mango-layout.conf b/core/internal/config/embedded/mango-layout.conf new file mode 100644 index 00000000..9631c40c --- /dev/null +++ b/core/internal/config/embedded/mango-layout.conf @@ -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 diff --git a/core/internal/config/embedded/mango.conf b/core/internal/config/embedded/mango.conf new file mode 100644 index 00000000..a2a95de9 --- /dev/null +++ b/core/internal/config/embedded/mango.conf @@ -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 diff --git a/core/internal/config/mango.go b/core/internal/config/mango.go new file mode 100644 index 00000000..073ed363 --- /dev/null +++ b/core/internal/config/mango.go @@ -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 diff --git a/core/internal/deps/detector.go b/core/internal/deps/detector.go index e4f54a7c..84108c4d 100644 --- a/core/internal/deps/detector.go +++ b/core/internal/deps/detector.go @@ -35,6 +35,7 @@ type WindowManager int const ( WindowManagerHyprland WindowManager = iota WindowManagerNiri + WindowManagerMango ) type Terminal int diff --git a/core/internal/distros/arch.go b/core/internal/distros/arch.go index 997479d1..5ce2d261 100644 --- a/core/internal/distros/arch.go +++ b/core/internal/distros/arch.go @@ -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) diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index c1627759..194ad8bb 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -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", diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index f3c440d9..dc6f5731 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -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) diff --git a/core/internal/distros/gentoo.go b/core/internal/distros/gentoo.go index 09526a2b..c5500dd1 100644 --- a/core/internal/distros/gentoo.go +++ b/core/internal/distros/gentoo.go @@ -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", diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index 507f2d67..4bcdfc25 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -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 } diff --git a/core/internal/headless/runner.go b/core/internal/headless/runner.go index 33c57528..efb1550b 100644 --- a/core/internal/headless/runner.go +++ b/core/internal/headless/runner.go @@ -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) } } diff --git a/core/internal/keybinds/providers/mangowc.go b/core/internal/keybinds/providers/mangowc.go index daa73499..0e4208f2 100644 --- a/core/internal/keybinds/providers/mangowc.go +++ b/core/internal/keybinds/providers/mangowc.go @@ -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") } diff --git a/core/internal/keybinds/providers/mangowc_parser.go b/core/internal/keybinds/providers/mangowc_parser.go index 5dac4b6e..a86dca98 100644 --- a/core/internal/keybinds/providers/mangowc_parser.go +++ b/core/internal/keybinds/providers/mangowc_parser.go @@ -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 diff --git a/core/internal/keybinds/providers/mangowc_parser_test.go b/core/internal/keybinds/providers/mangowc_parser_test.go index 016f50e4..b77f7f8b 100644 --- a/core/internal/keybinds/providers/mangowc_parser_test.go +++ b/core/internal/keybinds/providers/mangowc_parser_test.go @@ -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) diff --git a/core/internal/tui/views_config.go b/core/internal/tui/views_config.go index 09b6978b..0edadde4 100644 --- a/core/internal/tui/views_config.go +++ b/core/internal/tui/views_config.go @@ -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 diff --git a/core/internal/tui/views_dependencies.go b/core/internal/tui/views_dependencies.go index 46597814..e9eb44f3 100644 --- a/core/internal/tui/views_dependencies.go +++ b/core/internal/tui/views_dependencies.go @@ -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, diff --git a/core/internal/tui/views_gentoo_use_flags.go b/core/internal/tui/views_gentoo_use_flags.go index 440287af..8601823e 100644 --- a/core/internal/tui/views_gentoo_use_flags.go +++ b/core/internal/tui/views_gentoo_use_flags.go @@ -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() diff --git a/core/internal/tui/views_install.go b/core/internal/tui/views_install.go index d4639ce3..bbbe2a52 100644 --- a/core/internal/tui/views_install.go +++ b/core/internal/tui/views_install.go @@ -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 != "" { diff --git a/core/internal/tui/views_selection.go b/core/internal/tui/views_selection.go index e45105ec..4c1bf568 100644 --- a/core/internal/tui/views_selection.go +++ b/core/internal/tui/views_selection.go @@ -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" { diff --git a/core/internal/windowrules/providers/mango_parser.go b/core/internal/windowrules/providers/mango_parser.go new file mode 100644 index 00000000..923cdc9f --- /dev/null +++ b/core/internal/windowrules/providers/mango_parser.go @@ -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= @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) +} diff --git a/core/internal/windowrules/providers/mango_parser_test.go b/core/internal/windowrules/providers/mango_parser_test.go new file mode 100644 index 00000000..2581ccfb --- /dev/null +++ b/core/internal/windowrules/providers/mango_parser_test.go @@ -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)) + } +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 1f85f54f..8e614e19 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -490,6 +490,9 @@ Singleton { }, "dwl": { "cursorHideTimeout": 0 + }, + "mango": { + "cursorHideTimeout": 0 } }) property var availableCursorThemes: ["System Default"] @@ -1222,6 +1225,8 @@ Singleton { HyprlandService.generateLayoutConfig(); if (CompositorService.isDwl && typeof DwlService !== "undefined") DwlService.generateLayoutConfig(); + if (CompositorService.isMango && typeof MangoService !== "undefined") + MangoService.generateLayoutConfig(); } function applyStoredIconTheme() { @@ -2235,7 +2240,7 @@ Singleton { function getFilteredScreens(componentId) { var prefs = screenPreferences && screenPreferences[componentId] || ["all"]; - if (prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) { + if (!prefs || prefs.length === 0 || prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all")) { return Quickshell.screens; } var filtered = Quickshell.screens.filter(screen => isScreenInPreferences(screen, prefs)); @@ -2446,6 +2451,10 @@ Singleton { DwlService.generateCursorConfig(); return; } + if (CompositorService.isMango && typeof MangoService !== "undefined") { + MangoService.generateCursorConfig(); + return; + } } function updateXResources() { diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 8ef1af36..55c41c2b 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -340,6 +340,9 @@ Item { if (CompositorService.isDwl && DwlService.activeOutput) { return DwlService.activeOutput; } + if (CompositorService.isMango && MangoService.activeOutput) { + return MangoService.activeOutput; + } return ""; } diff --git a/quickshell/Modals/Greeter/GreeterCompletePage.qml b/quickshell/Modals/Greeter/GreeterCompletePage.qml index 8a5ca005..a468a16e 100644 --- a/quickshell/Modals/Greeter/GreeterCompletePage.qml +++ b/quickshell/Modals/Greeter/GreeterCompletePage.qml @@ -322,6 +322,8 @@ Item { url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-1"; else if (CompositorService.isDwl) url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-2"; + else if (CompositorService.isMango) + url = "https://danklinux.com/docs/dankmaterialshell/compositors#dms-keybindings-2"; Qt.openUrlExternally(url); } } diff --git a/quickshell/Modals/Greeter/GreeterWelcomePage.qml b/quickshell/Modals/Greeter/GreeterWelcomePage.qml index fcb4fc59..233f88b3 100644 --- a/quickshell/Modals/Greeter/GreeterWelcomePage.qml +++ b/quickshell/Modals/Greeter/GreeterWelcomePage.qml @@ -130,7 +130,7 @@ Item { title: I18n.tr("Multi-Monitor", "greeter feature card title") description: I18n.tr("Per-screen config", "greeter feature card description") onClicked: { - const hasDisplayConfig = CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl; + const hasDisplayConfig = CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango; PopoutService.openSettingsWithTab(hasDisplayConfig ? "display_config" : "display_widgets"); } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 54d50771..303e185a 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -311,7 +311,7 @@ Rectangle { "text": I18n.tr("Window Rules"), "icon": "select_window", "tabIndex": 28, - "hyprlandNiriOnly": true + "windowRulesCapable": true } ] }, @@ -370,6 +370,8 @@ Rectangle { return false; if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland) return false; + if (item.windowRulesCapable && !CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) + return false; if (item.niriOnly && !CompositorService.isNiri) return false; if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23)) diff --git a/quickshell/Modals/WindowRuleModal.qml b/quickshell/Modals/WindowRuleModal.qml index 3abcbb52..4f458a60 100644 --- a/quickshell/Modals/WindowRuleModal.qml +++ b/quickshell/Modals/WindowRuleModal.qml @@ -12,6 +12,7 @@ FloatingWindow { property bool isEditMode: editingRule !== null property bool isNiri: CompositorService.isNiri property bool isHyprland: CompositorService.isHyprland + property bool isMango: CompositorService.isMango property bool submitting: false property var targetWindow: null @@ -95,6 +96,14 @@ FloatingWindow { moveInput.text = ""; monitorInput.text = ""; hyprWorkspaceInput.text = ""; + mangoTagsInput.text = ""; + mangoMonitorInput.text = ""; + mangoSizeInput.text = ""; + mangoNoBlurToggle.checked = false; + mangoNoBorderToggle.checked = false; + mangoNoShadowToggle.checked = false; + mangoNoRoundingToggle.checked = false; + mangoNoAnimToggle.checked = false; } function show(window) { @@ -103,7 +112,10 @@ FloatingWindow { resetForm(); if (targetWindow) { nameInput.text = targetWindow.appId || ""; - appIdInput.text = targetWindow.appId ? "^" + targetWindow.appId + "$" : ""; + if (targetWindow.appId) + appIdInput.text = isMango ? targetWindow.appId : "^" + targetWindow.appId + "$"; + else + appIdInput.text = ""; } visible = true; Qt.callLater(() => nameInput.forceActiveFocus()); @@ -209,6 +221,15 @@ FloatingWindow { moveInput.text = actions.move || ""; monitorInput.text = actions.monitor || ""; hyprWorkspaceInput.text = actions.workspace || ""; + + mangoTagsInput.text = actions.workspace || ""; + mangoMonitorInput.text = actions.monitor || ""; + mangoSizeInput.text = actions.size || ""; + mangoNoBlurToggle.checked = actions.noblur || false; + mangoNoBorderToggle.checked = actions.noborder || false; + mangoNoShadowToggle.checked = actions.noshadow || false; + mangoNoRoundingToggle.checked = actions.norounding || false; + mangoNoAnimToggle.checked = actions.noanim || false; } function showEdit(rule) { @@ -387,6 +408,25 @@ FloatingWindow { actions.workspace = hyprWorkspaceInput.text.trim(); } + if (isMango) { + if (mangoTagsInput.text.trim()) + actions.workspace = mangoTagsInput.text.trim(); + if (mangoMonitorInput.text.trim()) + actions.monitor = mangoMonitorInput.text.trim(); + if (mangoSizeInput.text.trim()) + actions.size = mangoSizeInput.text.trim(); + if (mangoNoBlurToggle.checked) + actions.noblur = true; + if (mangoNoBorderToggle.checked) + actions.noborder = true; + if (mangoNoShadowToggle.checked) + actions.noshadow = true; + if (mangoNoRoundingToggle.checked) + actions.norounding = true; + if (mangoNoAnimToggle.checked) + actions.noanim = true; + } + const name = nameInput.text.trim() || matchCriteria.appId || I18n.tr("Rule"); const compositor = CompositorService.compositor; @@ -411,6 +451,8 @@ FloatingWindow { return; if (shouldValidate) NiriService.validate(); + if (CompositorService.isMango) + MangoService.reloadConfig(); root.ruleSubmitted(); root.hide(); }); @@ -422,6 +464,8 @@ FloatingWindow { return; if (shouldValidate) NiriService.validate(); + if (CompositorService.isMango) + MangoService.reloadConfig(); root.ruleSubmitted(); root.hide(); }); @@ -664,7 +708,7 @@ FloatingWindow { anchors.fill: parent font.pixelSize: Theme.fontSizeSmall textColor: Theme.surfaceText - placeholderText: isNiri ? I18n.tr("App ID regex (e.g. ^firefox$)") : I18n.tr("Class regex (e.g. ^firefox$)") + placeholderText: isMango ? I18n.tr("App ID (e.g. firefox)") : isHyprland ? I18n.tr("Class regex (e.g. ^firefox$)") : I18n.tr("App ID regex (e.g. ^firefox$)") backgroundColor: "transparent" enabled: root.visible } @@ -682,7 +726,7 @@ FloatingWindow { anchors.fill: parent font.pixelSize: Theme.fontSizeSmall textColor: Theme.surfaceText - placeholderText: I18n.tr("Title regex (optional)") + placeholderText: isMango ? I18n.tr("Title (optional)") : I18n.tr("Title regex (optional)") backgroundColor: "transparent" enabled: root.visible } @@ -702,7 +746,7 @@ FloatingWindow { onClicked: { if (!root.targetWindow?.title) return; - titleInput.text = "^" + root.targetWindow.title + "$"; + titleInput.text = isMango ? root.targetWindow.title : "^" + root.targetWindow.title + "$"; } } } @@ -807,10 +851,12 @@ FloatingWindow { SectionHeader { title: I18n.tr("Match Conditions") + visible: isNiri || isHyprland } StyledText { width: parent.width + visible: isNiri || isHyprland text: I18n.tr("Optional state-based conditions applied to the first match.") font.pixelSize: Theme.fontSizeSmall - 1 color: Theme.surfaceVariantText @@ -820,6 +866,7 @@ FloatingWindow { Flow { width: parent.width spacing: Theme.spacingS + visible: isNiri || isHyprland MatchCond { id: condFloating @@ -892,6 +939,7 @@ FloatingWindow { CheckboxRow { id: maximizedToggle label: I18n.tr("Maximize") + visible: !isMango } CheckboxRow { id: fullscreenToggle @@ -912,7 +960,7 @@ FloatingWindow { Row { width: parent.width spacing: Theme.spacingM - visible: true + visible: isNiri || isHyprland Column { width: (parent.width - Theme.spacingM) / 2 @@ -1031,11 +1079,13 @@ FloatingWindow { SectionHeader { title: I18n.tr("Dynamic Properties") + visible: isNiri || isHyprland } Row { width: parent.width spacing: Theme.spacingM + visible: isNiri || isHyprland CheckboxRow { id: opacityEnabled @@ -1154,6 +1204,7 @@ FloatingWindow { Row { width: parent.width spacing: Theme.spacingM + visible: isNiri || isHyprland CheckboxRow { id: cornerRadiusEnabled @@ -1352,11 +1403,13 @@ FloatingWindow { SectionHeader { title: I18n.tr("Size Constraints") + visible: isNiri || isHyprland } Row { width: parent.width spacing: Theme.spacingM + visible: isNiri || isHyprland Column { width: (parent.width - Theme.spacingM * 3) / 4 @@ -1639,6 +1692,131 @@ FloatingWindow { } } + SectionHeader { + title: I18n.tr("Mango Options") + visible: isMango + } + + Flow { + width: parent.width + spacing: Theme.spacingL + visible: isMango + + CheckboxRow { + id: mangoNoBlurToggle + label: I18n.tr("No Blur") + } + CheckboxRow { + id: mangoNoBorderToggle + label: I18n.tr("No Border") + } + CheckboxRow { + id: mangoNoShadowToggle + label: I18n.tr("No Shadow") + } + CheckboxRow { + id: mangoNoRoundingToggle + label: I18n.tr("No Rounding") + } + CheckboxRow { + id: mangoNoAnimToggle + label: I18n.tr("No Anim") + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: isMango + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Tags") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + InputField { + width: parent.width + hasFocus: mangoTagsInput.activeFocus + DankTextField { + id: mangoTagsInput + anchors.fill: parent + font.pixelSize: Theme.fontSizeSmall + textColor: Theme.surfaceText + placeholderText: "1" + backgroundColor: "transparent" + enabled: root.visible + } + } + } + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Monitor") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + InputField { + width: parent.width + hasFocus: mangoMonitorInput.activeFocus + DankTextField { + id: mangoMonitorInput + anchors.fill: parent + font.pixelSize: Theme.fontSizeSmall + textColor: Theme.surfaceText + placeholderText: "HDMI-A-1" + backgroundColor: "transparent" + enabled: root.visible + } + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: isMango + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Size") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + InputField { + width: parent.width + hasFocus: mangoSizeInput.activeFocus + DankTextField { + id: mangoSizeInput + anchors.fill: parent + font.pixelSize: Theme.fontSizeSmall + textColor: Theme.surfaceText + placeholderText: "800x600" + backgroundColor: "transparent" + enabled: root.visible + } + } + } + } + Item { width: 1 height: Theme.spacingM diff --git a/quickshell/Modules/DankBar/DankBar.qml b/quickshell/Modules/DankBar/DankBar.qml index 392bf9b7..6c971505 100644 --- a/quickshell/Modules/DankBar/DankBar.qml +++ b/quickshell/Modules/DankBar/DankBar.qml @@ -110,6 +110,8 @@ Item { focusedScreenName = focusedWs?.monitor?.name || ""; } else if (CompositorService.isDwl && DwlService.activeOutput) { focusedScreenName = DwlService.activeOutput; + } else if (CompositorService.isMango && MangoService.activeOutput) { + focusedScreenName = MangoService.activeOutput; } if (!focusedScreenName && barVariants.instances.length > 0) { @@ -139,6 +141,8 @@ Item { focusedScreenName = focusedWs?.monitor?.name || ""; } else if (CompositorService.isDwl && DwlService.activeOutput) { focusedScreenName = DwlService.activeOutput; + } else if (CompositorService.isMango && MangoService.activeOutput) { + focusedScreenName = MangoService.activeOutput; } if (!focusedScreenName && barVariants.instances.length > 0) { diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index d57fac61..9888d297 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -29,6 +29,7 @@ Item { readonly property real _frameEdgeFloorInset: (SettingsData.frameEnabled && _usesFrameBarChrome) ? Math.max(0, SettingsData.frameThickness - _edgeBaseMargin) : 0 readonly property bool _barIsVertical: _hasBarWindow ? barWindow.isVertical : false readonly property string _barScreenName: _hasBarWindow ? (barWindow.screenName || "") : "" + readonly property var dwlSvc: CompositorService.isMango ? MangoService : DwlService readonly property bool hasAdjacentTopBarLive: _hasBarWindow && barWindow.hasAdjacentTopBar readonly property bool hasAdjacentBottomBarLive: _hasBarWindow && barWindow.hasAdjacentBottomBar readonly property bool hasAdjacentLeftBarLive: _hasBarWindow && barWindow.hasAdjacentLeftBar @@ -189,16 +190,16 @@ Item { } return monitorWorkspaces.sort((a, b) => a.id - b.id); - } else if (CompositorService.isDwl) { - if (!DwlService.dwlAvailable) { + } else if (CompositorService.isDwl || CompositorService.isMango) { + if (!dwlSvc.available) { return [0]; } if (SettingsData.dwlShowAllTags) { return Array.from({ - length: DwlService.tagCount + length: dwlSvc.tagCount }, (_, i) => i); } - return DwlService.getVisibleTags(screenName); + return dwlSvc.getVisibleTags(screenName); } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { const workspaces = I3.workspaces?.values || []; if (workspaces.length === 0) @@ -234,13 +235,13 @@ Item { const monitors = Hyprland.monitors?.values || []; const currentMonitor = monitors.find(monitor => monitor.name === screenName); return currentMonitor?.activeWorkspace?.id ?? 1; - } else if (CompositorService.isDwl) { - if (!DwlService.dwlAvailable) + } else if (CompositorService.isDwl || CompositorService.isMango) { + if (!dwlSvc.available) return 0; - const outputState = DwlService.getOutputState(screenName); + const outputState = dwlSvc.getOutputState(screenName); if (!outputState || !outputState.tags) return 0; - const activeTags = DwlService.getActiveTags(screenName); + const activeTags = dwlSvc.getActiveTags(screenName); return activeTags.length > 0 ? activeTags[0] : 0; } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { if (!screenName || SettingsData.workspaceFollowFocus) { @@ -282,14 +283,14 @@ Item { if (nextIndex !== validIndex) { HyprlandService.focusWorkspace(realWorkspaces[nextIndex].id); } - } else if (CompositorService.isDwl) { + } else if (CompositorService.isDwl || CompositorService.isMango) { const currentTag = getCurrentWorkspace(); const currentIndex = realWorkspaces.findIndex(tag => tag === currentTag); const validIndex = currentIndex === -1 ? 0 : currentIndex; const nextIndex = direction > 0 ? Math.min(validIndex + 1, realWorkspaces.length - 1) : Math.max(validIndex - 1, 0); if (nextIndex !== validIndex) { - DwlService.switchToTag(_barScreenName, realWorkspaces[nextIndex]); + dwlSvc.switchToTag(_barScreenName, realWorkspaces[nextIndex]); } } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { const currentWs = getCurrentWorkspace(); diff --git a/quickshell/Modules/DankBar/DankBarWindow.qml b/quickshell/Modules/DankBar/DankBarWindow.qml index 6a33048b..8cca42dc 100644 --- a/quickshell/Modules/DankBar/DankBarWindow.qml +++ b/quickshell/Modules/DankBar/DankBarWindow.qml @@ -327,6 +327,24 @@ PanelWindow { hasMaximizedToplevel = false; return; } + if (CompositorService.isMango) { + const out = MangoService.outputs[screenName]; + const active = new Set((out?.activeTags) || []); + const wins = MangoService.windows || []; + for (let i = 0; i < wins.length; i++) { + const w = wins[i]; + if (!w || w.monitor !== screenName || w.is_minimized) + continue; + if (active.size > 0 && !(w.tags || []).some(t => active.has(t))) + continue; + if (w.is_maximized || w.is_fullscreen) { + hasMaximizedToplevel = true; + return; + } + } + hasMaximizedToplevel = false; + return; + } if (!CompositorService.isHyprland && !CompositorService.isNiri) { hasMaximizedToplevel = false; return; @@ -351,7 +369,7 @@ PanelWindow { shouldHideForWindows = false; return; } - if (!CompositorService.isNiri && !CompositorService.isHyprland) { + if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) { shouldHideForWindows = false; return; } @@ -825,7 +843,7 @@ PanelWindow { return true; const showOnWindowsSetting = barConfig?.showOnWindowsOpen ?? false; - if (showOnWindowsSetting && autoHide && (CompositorService.isNiri || CompositorService.isHyprland)) { + if (showOnWindowsSetting && autoHide && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango)) { if (barWindow.shouldHideForWindows) return topBarMouseArea.containsMouse || popoutPinsReveal || revealSticky || ipcReveal; return true; diff --git a/quickshell/Modules/DankBar/Popouts/DWLLayoutPopout.qml b/quickshell/Modules/DankBar/Popouts/DWLLayoutPopout.qml index 9daed8d5..57e0dfd5 100644 --- a/quickshell/Modules/DankBar/Popouts/DWLLayoutPopout.qml +++ b/quickshell/Modules/DankBar/Popouts/DWLLayoutPopout.qml @@ -10,6 +10,10 @@ DankPopout { property var triggerScreen: null + // mango shares dwl's layout model; route to the right service. + readonly property bool isDwlLike: CompositorService.isDwl || CompositorService.isMango + readonly property var dwlSvc: CompositorService.isMango ? MangoService : DwlService + function setTriggerPosition(x, y, width, section, screen, barPosition, barThickness, barSpacing, barConfig) { triggerX = x; triggerY = y; @@ -33,8 +37,8 @@ DankPopout { onScreenChanged: updateOutputState() function updateOutputState() { - if (screen && DwlService.dwlAvailable) { - outputState = DwlService.getOutputState(screen.name); + if (screen && root.dwlSvc.available) { + outputState = root.dwlSvc.getOutputState(screen.name); } else { outputState = null; } @@ -215,7 +219,7 @@ DankPopout { spacing: Theme.spacingS Repeater { - model: DwlService.layouts + model: root.dwlSvc.layouts delegate: Rectangle { required property string modelData @@ -269,11 +273,11 @@ DankPopout { if (!root.triggerScreen) { return; } - if (!DwlService.dwlAvailable) { + if (!root.dwlSvc.available) { return; } - DwlService.setLayout(root.triggerScreen.name, index); + root.dwlSvc.setLayout(root.triggerScreen.name, index); root.close(); } } diff --git a/quickshell/Modules/DankBar/WidgetHost.qml b/quickshell/Modules/DankBar/WidgetHost.qml index 01d1c735..3a0fefb5 100644 --- a/quickshell/Modules/DankBar/WidgetHost.qml +++ b/quickshell/Modules/DankBar/WidgetHost.qml @@ -282,7 +282,7 @@ Loader { "cpuTemp": dgopAvailable, "gpuTemp": dgopAvailable, "network_speed_monitor": dgopAvailable, - "layout": CompositorService.isDwl && DwlService.dwlAvailable + "layout": (CompositorService.isDwl && DwlService.dwlAvailable) || (CompositorService.isMango && MangoService.available) }; return widgetVisibility[widgetId] ?? true; diff --git a/quickshell/Modules/DankBar/Widgets/DWLLayout.qml b/quickshell/Modules/DankBar/Widgets/DWLLayout.qml index 70937707..95738e6c 100644 --- a/quickshell/Modules/DankBar/Widgets/DWLLayout.qml +++ b/quickshell/Modules/DankBar/Widgets/DWLLayout.qml @@ -12,9 +12,13 @@ BasePill { signal toggleLayoutPopup - visible: CompositorService.isDwl && DwlService.dwlAvailable + // mango shares dwl's tag/layout model; route to the right service. + readonly property bool isDwlLike: CompositorService.isDwl || CompositorService.isMango + readonly property var dwlSvc: CompositorService.isMango ? MangoService : DwlService - property var outputState: parentScreen ? DwlService.getOutputState(parentScreen.name) : null + visible: layout.isDwlLike && layout.dwlSvc.available + + property var outputState: parentScreen ? layout.dwlSvc.getOutputState(parentScreen.name) : null property string currentLayoutSymbol: outputState?.layoutSymbol || "" property int currentLayoutIndex: outputState?.layout || 0 @@ -37,9 +41,9 @@ BasePill { } Connections { - target: DwlService + target: layout.dwlSvc function onStateChanged() { - outputState = parentScreen ? DwlService.getOutputState(parentScreen.name) : null; + outputState = parentScreen ? layout.dwlSvc.getOutputState(parentScreen.name) : null; } } @@ -97,13 +101,13 @@ BasePill { } onRightClicked: { - if (!parentScreen || !DwlService.dwlAvailable || DwlService.layouts.length === 0) { + if (!parentScreen || !layout.dwlSvc.available || layout.dwlSvc.layouts.length === 0) { return; } const currentIndex = layout.currentLayoutIndex; - const nextIndex = (currentIndex + 1) % DwlService.layouts.length; + const nextIndex = (currentIndex + 1) % layout.dwlSvc.layouts.length; - DwlService.setLayout(parentScreen.name, nextIndex); + layout.dwlSvc.setLayout(parentScreen.name, nextIndex); } } diff --git a/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml b/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml index a3f7ccc4..af9240a9 100644 --- a/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml +++ b/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml @@ -114,6 +114,8 @@ BasePill { return NiriService.getCurrentKeyboardLayoutName(); } else if (CompositorService.isDwl) { return DwlService.currentKeyboardLayout; + } else if (CompositorService.isMango) { + return MangoService.currentKeyboardLayout; } return ""; } @@ -208,7 +210,9 @@ BasePill { } else if (CompositorService.isHyprland) { Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]); } else if (CompositorService.isDwl) { - Quickshell.execDetached(["mmsg", "-d", "switch_keyboard_layout"]); + Quickshell.execDetached(["mmsg", "dispatch", "switch_keyboard_layout"]); + } else if (CompositorService.isMango) { + MangoService.cycleKeyboardLayout(); } } } diff --git a/quickshell/Modules/DankBar/Widgets/LauncherButton.qml b/quickshell/Modules/DankBar/Widgets/LauncherButton.qml index 81e6457f..80d3fdb8 100644 --- a/quickshell/Modules/DankBar/Widgets/LauncherButton.qml +++ b/quickshell/Modules/DankBar/Widgets/LauncherButton.qml @@ -55,7 +55,7 @@ BasePill { } IconImage { - visible: SettingsData.launcherLogoMode === "compositor" && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc) + visible: SettingsData.launcherLogoMode === "compositor" && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc) anchors.centerIn: parent width: Theme.barIconSize(root.barThickness, SettingsData.launcherLogoSizeOffset, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale) height: Theme.barIconSize(root.barThickness, SettingsData.launcherLogoSizeOffset, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale) @@ -68,6 +68,8 @@ BasePill { return "file://" + Theme.shellDir + "/assets/hyprland.svg"; } else if (CompositorService.isDwl) { return "file://" + Theme.shellDir + "/assets/mango.png"; + } else if (CompositorService.isMango) { + return "file://" + Theme.shellDir + "/assets/mango.png"; } else if (CompositorService.isSway) { return "file://" + Theme.shellDir + "/assets/sway.svg"; } else if (CompositorService.isScroll) { diff --git a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml index eb907184..6d43e67e 100644 --- a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml +++ b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml @@ -22,6 +22,11 @@ Item { property var hyprlandOverviewLoader: null property var parentScreen: null + // mango shares dwl's tag model; route to the right service so one set of + // branches serves both. + readonly property bool isDwlLike: CompositorService.isDwl || CompositorService.isMango + readonly property var dwlSvc: CompositorService.isMango ? MangoService : DwlService + readonly property real _leftMargin: { if (isVertical) return 0; @@ -76,7 +81,8 @@ Item { case "hyprland": return Hyprland.focusedWorkspace?.monitor?.name || root.screenName; case "dwl": - return DwlService.activeOutput || root.screenName; + case "mango": + return root.dwlSvc.activeOutput || root.screenName; case "sway": case "scroll": case "miracle": @@ -95,6 +101,7 @@ Item { case "niri": case "hyprland": case "dwl": + case "mango": case "sway": case "scroll": case "miracle": @@ -121,6 +128,7 @@ Item { case "hyprland": return getHyprlandActiveWorkspace(); case "dwl": + case "mango": const activeTags = getDwlActiveTags(); return activeTags.length > 0 ? activeTags[0] : -1; case "sway": @@ -132,7 +140,7 @@ Item { } } property var dwlActiveTags: { - if (CompositorService.isDwl) { + if (root.isDwlLike) { return getDwlActiveTags(); } return []; @@ -152,6 +160,7 @@ Item { baseList = getHyprlandWorkspaces(); break; case "dwl": + case "mango": baseList = getDwlTags(); break; case "sway": @@ -288,7 +297,7 @@ Item { } } else if (CompositorService.isHyprland) { targetWorkspaceId = ws.id !== undefined ? ws.id : ws; - } else if (CompositorService.isDwl) { + } else if (root.isDwlLike) { if (typeof ws !== "object" || ws.tag === undefined) { return []; } @@ -308,8 +317,8 @@ Item { } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { const focusedWs = I3.workspaces?.values?.find(ws => ws.focused === true); isActiveWs = focusedWs ? (focusedWs.num === targetWorkspaceId) : false; - } else if (CompositorService.isDwl) { - const output = DwlService.getOutputState(root.effectiveScreenName); + } else if (root.isDwlLike) { + const output = root.dwlSvc.getOutputState(root.effectiveScreenName); if (output && output.tags) { const tag = output.tags.find(t => t.tag === targetWorkspaceId); isActiveWs = tag ? (tag.state === 1) : false; @@ -323,19 +332,25 @@ Item { return; } - let winWs = null; - if (CompositorService.isNiri) { - winWs = w.workspace_id; - } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { - winWs = w.workspace?.num; + if (CompositorService.isMango) { + // mangoTags are 1-based; targetWorkspaceId is 0-based. + if (!(w.mangoTags || []).includes(targetWorkspaceId + 1)) + return; } else { - const hyprlandToplevels = Array.from(Hyprland.toplevels?.values || []); - const hyprToplevel = hyprlandToplevels.find(ht => ht.wayland === w); - winWs = hyprToplevel?.workspace?.id; - } + let winWs = null; + if (CompositorService.isNiri) { + winWs = w.workspace_id; + } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { + winWs = w.workspace?.num; + } else { + const hyprlandToplevels = Array.from(Hyprland.toplevels?.values || []); + const hyprToplevel = hyprlandToplevels.find(ht => ht.wayland === w); + winWs = hyprToplevel?.workspace?.id; + } - if (winWs === undefined || winWs === null || winWs !== targetWorkspaceId) { - return; + if (winWs === undefined || winWs === null || winWs !== targetWorkspaceId) { + return; + } } const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown"); @@ -391,7 +406,7 @@ Item { "id": -1, "name": "" }; - } else if (CompositorService.isDwl) { + } else if (root.isDwlLike) { placeholder = { "tag": -1 }; @@ -473,11 +488,11 @@ Item { } function getDwlTags() { - if (!DwlService.dwlAvailable) + if (!root.dwlSvc.available) return []; const targetScreen = root.effectiveScreenName; - const output = DwlService.getOutputState(targetScreen); + const output = root.dwlSvc.getOutputState(targetScreen); if (!output || !output.tags || output.tags.length === 0) return []; @@ -490,7 +505,7 @@ Item { })); } - const visibleTagIndices = DwlService.getVisibleTags(targetScreen); + const visibleTagIndices = root.dwlSvc.getVisibleTags(targetScreen); return visibleTagIndices.map(tagIndex => { const tagData = output.tags.find(t => t.tag === tagIndex); return { @@ -503,10 +518,10 @@ Item { } function getDwlActiveTags() { - if (!DwlService.dwlAvailable) + if (!root.dwlSvc.available) return []; - return DwlService.getActiveTags(root.effectiveScreenName); + return root.dwlSvc.getActiveTags(root.effectiveScreenName); } function getExtWorkspaceWorkspaces() { @@ -557,7 +572,7 @@ Item { return ws && ws.idx !== -1; if (CompositorService.isHyprland) return ws && ws.id !== -1; - if (CompositorService.isDwl) + if (root.isDwlLike) return ws && ws.tag !== -1; if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) return ws && ws.num !== -1; @@ -586,8 +601,9 @@ Item { } break; case "dwl": + case "mango": if (data.tag !== undefined) - DwlService.switchToTag(root.screenName, data.tag); + root.dwlSvc.switchToTag(root.screenName, data.tag); break; case "sway": case "scroll": @@ -673,7 +689,7 @@ Item { } HyprlandService.focusWorkspace(realWorkspaces[nextIndex].id); - } else if (CompositorService.isDwl) { + } else if (root.isDwlLike) { const realWorkspaces = getRealWorkspaces(); if (realWorkspaces.length < 2) { return; @@ -687,7 +703,7 @@ Item { return; } - DwlService.switchToTag(root.screenName, realWorkspaces[nextIndex].tag); + root.dwlSvc.switchToTag(root.screenName, realWorkspaces[nextIndex].tag); } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { const realWorkspaces = getRealWorkspaces(); if (realWorkspaces.length < 2) { @@ -715,7 +731,7 @@ Item { return (modelData?.idx !== undefined && modelData?.idx !== -1) ? modelData.idx : ""; if (CompositorService.isHyprland) return modelData?.id || ""; - if (CompositorService.isDwl) + if (root.isDwlLike) return (modelData?.tag !== undefined) ? (modelData.tag + 1) : ""; if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) return modelData?.num || ""; @@ -730,7 +746,7 @@ Item { isPlaceholder = modelData?.idx === -1; } else if (CompositorService.isHyprland) { isPlaceholder = modelData?.id === -1; - } else if (CompositorService.isDwl) { + } else if (root.isDwlLike) { isPlaceholder = modelData?.tag === -1; } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { isPlaceholder = modelData?.num === -1; @@ -765,7 +781,7 @@ Item { return getWorkspaceIndexFallback(modelData, index); } - readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle + readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || root.isDwlLike || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle readonly property bool hasWorkspaces: getRealWorkspaces().length > 0 readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces) @@ -983,7 +999,7 @@ Item { return !!(modelData && modelData.idx === root.currentWorkspace); if (CompositorService.isHyprland) return !!(modelData && modelData.id === root.currentWorkspace); - if (CompositorService.isDwl) + if (root.isDwlLike) return !!(modelData && root.dwlActiveTags.includes(modelData.tag)); if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) return !!(modelData && modelData.num === root.currentWorkspace); @@ -992,7 +1008,7 @@ Item { property bool isOccupied: { if (CompositorService.isHyprland) return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id); - if (CompositorService.isDwl) + if (root.isDwlLike) return modelData.clients > 0; if (CompositorService.isNiri) { const workspace = NiriService.allWorkspaces.find(ws => ws.idx + 1 === modelData && ws.output === root.effectiveScreenName); @@ -1007,7 +1023,7 @@ Item { return !!(modelData && modelData.idx === -1); if (CompositorService.isHyprland) return !!(modelData && modelData.id === -1); - if (CompositorService.isDwl) + if (root.isDwlLike) return !!(modelData && modelData.tag === -1); if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) return !!(modelData && modelData.num === -1); @@ -1024,7 +1040,7 @@ Item { return modelData?.urgent ?? false; if (CompositorService.isNiri) return loadedIsUrgent; - if (CompositorService.isDwl) + if (root.isDwlLike) return modelData?.state === 2; if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) return loadedIsUrgent; @@ -1081,8 +1097,12 @@ Item { winWs = hyprToplevel?.workspace?.id; } - if (winWs !== targetWorkspaceId) + if (CompositorService.isMango) { + if (!(w.mangoTags || []).includes(targetWorkspaceId + 1)) + continue; + } else if (winWs !== targetWorkspaceId) { continue; + } totalCount++; const appKey = w.app_id || w.appId || w.class || w.windowClass || "unknown"; @@ -1311,8 +1331,8 @@ Item { } } else if (CompositorService.isHyprland && modelData?.id) { HyprlandService.focusWorkspace(modelData.id); - } else if (CompositorService.isDwl && modelData?.tag !== undefined) { - DwlService.switchToTag(root.screenName, modelData.tag); + } else if (root.isDwlLike && modelData?.tag !== undefined) { + root.dwlSvc.switchToTag(root.screenName, modelData.tag); } else if ((CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) && modelData?.num) { try { I3.dispatch(`workspace number ${modelData.num}`); @@ -1323,8 +1343,8 @@ Item { NiriService.toggleOverview(); } else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) { root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen; - } else if (CompositorService.isDwl && modelData?.tag !== undefined) { - DwlService.toggleTag(root.screenName, modelData.tag); + } else if (root.isDwlLike && modelData?.tag !== undefined) { + root.dwlSvc.toggleTag(root.screenName, modelData.tag); } } } @@ -1348,7 +1368,7 @@ Item { wsData = modelData || null; } else if (CompositorService.isHyprland) { wsData = modelData; - } else if (CompositorService.isDwl) { + } else if (root.isDwlLike) { wsData = modelData; } else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { wsData = modelData; @@ -1362,7 +1382,7 @@ Item { } if (SettingsData.showWorkspaceApps) { - if (CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { + if (root.isDwlLike || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) { delegateRoot.loadedIcons = root.getWorkspaceIcons(modelData); } else if (CompositorService.isNiri) { delegateRoot.loadedIcons = root.getWorkspaceIcons(isPlaceholder ? null : modelData); @@ -1922,8 +1942,8 @@ Item { } } Connections { - target: DwlService - enabled: CompositorService.isDwl + target: root.dwlSvc + enabled: root.isDwlLike function onStateChanged() { delegateRoot.updateAllData(); } diff --git a/quickshell/Modules/DankDash/Overview/UserInfoCard.qml b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml index dfe10c49..9f5c019b 100644 --- a/quickshell/Modules/DankDash/Overview/UserInfoCard.qml +++ b/quickshell/Modules/DankDash/Overview/UserInfoCard.qml @@ -70,6 +70,8 @@ Card { // technically they might not be on mangowc, but its what we support in the docs if (CompositorService.isDwl) return I18n.tr("on MangoWC"); + if (CompositorService.isMango) + return I18n.tr("on MangoWC"); if (CompositorService.isSway) return I18n.tr("on Sway"); if (CompositorService.isScroll) diff --git a/quickshell/Modules/Dock/Dock.qml b/quickshell/Modules/Dock/Dock.qml index be2004b0..413d4dfb 100644 --- a/quickshell/Modules/Dock/Dock.qml +++ b/quickshell/Modules/Dock/Dock.qml @@ -227,7 +227,7 @@ Variants { readonly property bool shouldHideForWindows: { if (!SettingsData.dockSmartAutoHide) return false; - if (!CompositorService.isNiri && !CompositorService.isHyprland) + if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) return false; const screenName = dock.modelData?.name ?? ""; @@ -291,6 +291,12 @@ Variants { return false; } + if (CompositorService.isMango) { + MangoService.windows; + MangoService.outputs; + return CompositorService.mangoDockOverlapForSmartAutoHide(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight); + } + // Hyprland implementation (current workspace + visible special workspaces) Hyprland.focusedWorkspace; Hyprland.toplevels; diff --git a/quickshell/Modules/Dock/DockLauncherButton.qml b/quickshell/Modules/Dock/DockLauncherButton.qml index 157bcf94..9380c2ec 100644 --- a/quickshell/Modules/Dock/DockLauncherButton.qml +++ b/quickshell/Modules/Dock/DockLauncherButton.qml @@ -236,7 +236,7 @@ Item { } IconImage { - visible: SettingsData.dockLauncherLogoMode === "compositor" && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc) + visible: SettingsData.dockLauncherLogoMode === "compositor" && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isLabwc) anchors.centerIn: parent width: actualIconSize + SettingsData.dockLauncherLogoSizeOffset height: actualIconSize + SettingsData.dockLauncherLogoSizeOffset @@ -249,6 +249,8 @@ Item { return "file://" + Theme.shellDir + "/assets/hyprland.svg"; } else if (CompositorService.isDwl) { return "file://" + Theme.shellDir + "/assets/mango.png"; + } else if (CompositorService.isMango) { + return "file://" + Theme.shellDir + "/assets/mango.png"; } else if (CompositorService.isSway) { return "file://" + Theme.shellDir + "/assets/sway.svg"; } else if (CompositorService.isScroll) { diff --git a/quickshell/Modules/Greetd/assets/dms-greeter b/quickshell/Modules/Greetd/assets/dms-greeter index 181477ef..65e5aa90 100755 --- a/quickshell/Modules/Greetd/assets/dms-greeter +++ b/quickshell/Modules/Greetd/assets/dms-greeter @@ -429,9 +429,9 @@ MIRACLE_EOF mango|mangowc) require_command "mango" if [[ -n "$COMPOSITOR_CONFIG" ]]; then - exec_compositor "mango" mango -c "$COMPOSITOR_CONFIG" -s "$QS_CMD && mmsg -d quit" + exec_compositor "mango" mango -c "$COMPOSITOR_CONFIG" -s "$QS_CMD && mmsg dispatch quit" else - exec_compositor "mango" mango -s "$QS_CMD && mmsg -d quit" + exec_compositor "mango" mango -s "$QS_CMD && mmsg dispatch quit" fi ;; diff --git a/quickshell/Modules/Settings/AboutTab.qml b/quickshell/Modules/Settings/AboutTab.qml index 4cc0bf4a..c71f1ece 100644 --- a/quickshell/Modules/Settings/AboutTab.qml +++ b/quickshell/Modules/Settings/AboutTab.qml @@ -15,7 +15,7 @@ Item { property bool isSway: CompositorService.isSway property bool isScroll: CompositorService.isScroll property bool isMiracle: CompositorService.isMiracle - property bool isDwl: CompositorService.isDwl + property bool isDwl: CompositorService.isDwl || CompositorService.isMango property bool isLabwc: CompositorService.isLabwc property string compositorName: { diff --git a/quickshell/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index 7f17a9f3..0944c665 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -659,7 +659,7 @@ Item { SettingsToggleRow { width: parent.width - parent.leftPadding - visible: CompositorService.isNiri || CompositorService.isHyprland + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango text: I18n.tr("Hide When Windows Open") description: I18n.tr("Show the bar only when no windows are open") checked: selectedBarConfig?.showOnWindowsOpen ?? false @@ -1144,7 +1144,7 @@ Item { iconName: "fit_screen" title: I18n.tr("Maximize Detection") description: I18n.tr("Remove gaps and border when windows are maximized") - visible: selectedBarConfig?.enabled && (CompositorService.isNiri || CompositorService.isHyprland) + visible: selectedBarConfig?.enabled && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) checked: selectedBarConfig?.maximizeDetection ?? true onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { maximizeDetection: checked diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index 32463dd7..7e59185f 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -158,12 +158,14 @@ Singleton { const compositorDirs = { "niri": configDir + "/niri/dms/profiles", "hyprland": configDir + "/hypr/dms/profiles", - "dwl": configDir + "/mango/dms/profiles" + "dwl": configDir + "/mango/dms/profiles", + "mango": configDir + "/mango/dms/profiles" }; const compositorExts = { "niri": ".kdl", "hyprland": ".conf", - "dwl": ".conf" + "dwl": ".conf", + "mango": ".conf" }; const tasks = []; @@ -542,6 +544,14 @@ Singleton { onWriteFailed(); }); break; + case "mango": + MangoService.generateOutputsConfig(outputsData, success => { + if (success) + onWriteSuccess(); + else + onWriteFailed(); + }); + break; case "dwl": DwlService.generateOutputsConfig(outputsData, success => { if (success) @@ -1032,6 +1042,7 @@ Singleton { case "hyprland": return parseHyprlandOutputs(content); case "dwl": + case "mango": return parseMangoOutputs(content); default: return {}; @@ -1302,7 +1313,7 @@ Singleton { params[pair.substring(0, colonIdx).trim()] = pair.substring(colonIdx + 1).trim(); } - const name = params.name; + const name = (params.name || "").replace(/^\^/, "").replace(/\$$/, ""); if (!name) continue; @@ -1370,6 +1381,7 @@ Singleton { "includeLine": "require(\"dms.outputs\")" }; case "dwl": + case "mango": return { "configFile": configDir + "/mango/config.conf", "outputsFile": configDir + "/mango/dms/outputs.conf", @@ -1383,7 +1395,7 @@ Singleton { function checkIncludeStatus() { const compositor = CompositorService.compositor; - if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl" && compositor !== "mango") { includeStatus = { "exists": false, "included": false, @@ -1394,7 +1406,8 @@ Singleton { } const filename = (compositor === "niri") ? "outputs.kdl" : ((compositor === "hyprland") ? "outputs.lua" : "outputs.conf"); - const compositorArg = (compositor === "dwl") ? "mangowc" : compositor; + // mango and dwl both use outputs.conf under ~/.config/mango + const compositorArg = (compositor === "dwl" || compositor === "mango") ? "mangowc" : compositor; checkingInclude = true; Proc.runCommand("check-outputs-include", ["dms", "config", "resolve-include", compositorArg, filename], (output, exitCode) => { @@ -1569,6 +1582,9 @@ Singleton { } HyprlandService.generateOutputsConfig(outputsData, buildMergedHyprlandSettings()); break; + case "mango": + MangoService.generateOutputsConfig(outputsData); + break; case "dwl": DwlService.generateOutputsConfig(outputsData); break; diff --git a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml index 20bcd73b..7be85d34 100644 --- a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml +++ b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml @@ -317,7 +317,7 @@ StyledRect { DankToggle { width: parent.width text: I18n.tr("Variable Refresh Rate") - visible: root.isConnected && !root.isDisabled && !CompositorService.isDwl && !CompositorService.isHyprland && !CompositorService.isNiri && (DisplayConfigState.outputs[root.outputName]?.vrr_supported ?? false) + visible: root.isConnected && !root.isDisabled && !CompositorService.isDwl && !CompositorService.isMango && !CompositorService.isHyprland && !CompositorService.isNiri && (DisplayConfigState.outputs[root.outputName]?.vrr_supported ?? false) checked: { const pendingVrr = DisplayConfigState.getPendingValue(root.outputName, "vrr"); if (pendingVrr !== undefined) diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml index 1f229141..0bdde3ad 100644 --- a/quickshell/Modules/Settings/DisplayConfigTab.qml +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -500,7 +500,7 @@ Item { Column { id: displayFormatColumn - visible: !CompositorService.isDwl + visible: !CompositorService.isDwl && !CompositorService.isMango spacing: Theme.spacingXS anchors.verticalCenter: parent.verticalCenter diff --git a/quickshell/Modules/Settings/DockTab.qml b/quickshell/Modules/Settings/DockTab.qml index afd8e7f1..6d91880f 100644 --- a/quickshell/Modules/Settings/DockTab.qml +++ b/quickshell/Modules/Settings/DockTab.qml @@ -70,7 +70,7 @@ Item { text: I18n.tr("Intelligent Auto-hide") description: I18n.tr("Show dock when floating windows don't overlap its area") checked: SettingsData.dockSmartAutoHide - visible: SettingsData.showDock && (CompositorService.isNiri || CompositorService.isHyprland) + visible: SettingsData.showDock && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) onToggled: checked => { if (checked && SettingsData.dockAutoHide) { SettingsData.set("dockAutoHide", false); @@ -284,6 +284,8 @@ Item { modes.push("Hyprland"); } else if (CompositorService.isDwl) { modes.push("mango"); + } else if (CompositorService.isMango) { + modes.push("mango"); } else if (CompositorService.isSway) { modes.push("Sway"); } else if (CompositorService.isScroll) { diff --git a/quickshell/Modules/Settings/LauncherTab.qml b/quickshell/Modules/Settings/LauncherTab.qml index 0447e7df..1671c79b 100644 --- a/quickshell/Modules/Settings/LauncherTab.qml +++ b/quickshell/Modules/Settings/LauncherTab.qml @@ -306,6 +306,8 @@ Item { modes.push("Hyprland"); } else if (CompositorService.isDwl) { modes.push("mango"); + } else if (CompositorService.isMango) { + modes.push("mango"); } else if (CompositorService.isSway) { modes.push("Sway"); } else if (CompositorService.isScroll) { diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index 8f8fb333..f44f6678 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -49,6 +49,7 @@ Item { "includeLine": "require(\"dms.cursor\")" }; case "dwl": + case "mango": return { "configFile": configDir + "/mango/config.conf", "cursorFile": configDir + "/mango/dms/cursor.conf", @@ -62,7 +63,7 @@ Item { function checkCursorIncludeStatus() { const compositor = CompositorService.compositor; - if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl" && compositor !== "mango") { cursorIncludeStatus = { "exists": false, "included": false, @@ -73,7 +74,7 @@ Item { } const filename = (compositor === "niri") ? "cursor.kdl" : ((compositor === "hyprland") ? "cursor.lua" : "cursor.conf"); - const compositorArg = (compositor === "dwl") ? "mangowc" : compositor; + const compositorArg = (compositor === "dwl" || compositor === "mango") ? "mangowc" : compositor; checkingCursorInclude = true; Proc.runCommand("check-cursor-include", ["dms", "config", "resolve-include", compositorArg, filename], (output, exitCode) => { @@ -193,7 +194,7 @@ Item { themeColorsTab.templateDetection = JSON.parse(output.trim()); } catch (e) {} }); - if (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl) + if (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango) checkCursorIncludeStatus(); } @@ -2177,7 +2178,7 @@ Item { title: I18n.tr("MangoWC Layout Overrides") settingKey: "mangoLayout" iconName: "crop_square" - visible: CompositorService.isDwl + visible: CompositorService.isDwl || CompositorService.isMango SettingsToggleRow { tab: "theme" @@ -2334,7 +2335,7 @@ Item { title: I18n.tr("Cursor Theme") settingKey: "cursorTheme" iconName: "mouse" - visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango Column { width: parent.width @@ -2490,6 +2491,8 @@ Item { return SettingsData.cursorSettings.hyprland?.inactiveTimeout || 0; if (CompositorService.isDwl) return SettingsData.cursorSettings.dwl?.cursorHideTimeout || 0; + if (CompositorService.isMango) + return SettingsData.cursorSettings.mango?.cursorHideTimeout || 0; return 0; } minimum: 0 @@ -2510,6 +2513,10 @@ Item { if (!updated.dwl) updated.dwl = {}; updated.dwl.cursorHideTimeout = newValue; + } else if (CompositorService.isMango) { + if (!updated.mango) + updated.mango = {}; + updated.mango.cursorHideTimeout = newValue; } SettingsData.set("cursorSettings", updated); } diff --git a/quickshell/Modules/Settings/WidgetsTab.qml b/quickshell/Modules/Settings/WidgetsTab.qml index 5c53309d..92137711 100644 --- a/quickshell/Modules/Settings/WidgetsTab.qml +++ b/quickshell/Modules/Settings/WidgetsTab.qml @@ -37,8 +37,8 @@ Item { "text": I18n.tr("Layout"), "description": I18n.tr("Display and switch DWL layouts"), "icon": "view_quilt", - "enabled": CompositorService.isDwl && DwlService.dwlAvailable, - "warning": !CompositorService.isDwl ? I18n.tr("Requires DWL compositor") : (!DwlService.dwlAvailable ? I18n.tr("DWL service not available") : undefined) + "enabled": (CompositorService.isDwl && DwlService.dwlAvailable) || (CompositorService.isMango && MangoService.available), + "warning": CompositorService.isMango ? (!MangoService.available ? I18n.tr("DWL service not available") : undefined) : (!CompositorService.isDwl ? I18n.tr("Requires DWL compositor") : (!DwlService.dwlAvailable ? I18n.tr("DWL service not available") : undefined)) }, { "id": "launcherButton", diff --git a/quickshell/Modules/Settings/WindowRulesTab.qml b/quickshell/Modules/Settings/WindowRulesTab.qml index a6dadf4d..661f6c84 100644 --- a/quickshell/Modules/Settings/WindowRulesTab.qml +++ b/quickshell/Modules/Settings/WindowRulesTab.qml @@ -30,6 +30,7 @@ Item { property var externalRules: [] property var activeWindows: getActiveWindows() property string expandedExternalId: "" + readonly property string dmsRulesFileName: CompositorService.isNiri ? "dms/windowrules.kdl" : CompositorService.isMango ? "dms/windowrules.conf" : "dms/windowrules.lua" readonly property var matchLabels: ({ "appId": I18n.tr("App ID"), @@ -166,6 +167,13 @@ Item { "grepPattern": "dms.windowrules", "includeLine": "require(\"dms.windowrules\")" }; + case "mango": + return { + "configFile": configDir + "/mango/config.conf", + "rulesFile": configDir + "/mango/dms/windowrules.conf", + "grepPattern": "dms/windowrules.conf", + "includeLine": "source=./dms/windowrules.conf" + }; default: return null; } @@ -173,7 +181,7 @@ Item { function loadWindowRules() { const compositor = CompositorService.compositor; - if (compositor !== "niri" && compositor !== "hyprland") { + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango") { windowRules = []; externalRules = []; return; @@ -211,11 +219,13 @@ Item { return; } const compositor = CompositorService.compositor; - if (compositor !== "niri" && compositor !== "hyprland") + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango") return; Proc.runCommand("remove-windowrule", ["dms", "config", "windowrules", "remove", compositor, ruleId], (output, exitCode) => { if (exitCode === 0) { + if (CompositorService.isMango) + MangoService.reloadConfig(); loadWindowRules(); rulesChanged(); } @@ -231,7 +241,7 @@ Item { return; const compositor = CompositorService.compositor; - if (compositor !== "niri" && compositor !== "hyprland") + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango") return; let ids = windowRules.map(r => r.id); @@ -240,6 +250,8 @@ Item { Proc.runCommand("reorder-windowrules", ["dms", "config", "windowrules", "reorder", compositor, JSON.stringify(ids)], (output, exitCode) => { if (exitCode === 0) { + if (CompositorService.isMango) + MangoService.reloadConfig(); loadWindowRules(); rulesChanged(); } @@ -248,7 +260,7 @@ Item { function checkWindowRulesIncludeStatus() { const compositor = CompositorService.compositor; - if (compositor !== "niri" && compositor !== "hyprland") { + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "mango") { windowRulesIncludeStatus = { "exists": false, "included": false, @@ -258,7 +270,7 @@ Item { return; } - const filename = (compositor === "niri") ? "windowrules.kdl" : "windowrules.lua"; + const filename = (compositor === "niri") ? "windowrules.kdl" : (compositor === "mango") ? "windowrules.conf" : "windowrules.lua"; checkingInclude = true; Proc.runCommand("check-windowrules-include", ["dms", "config", "resolve-include", compositor, filename], (output, exitCode) => { checkingInclude = false; @@ -306,6 +318,8 @@ Item { fixingInclude = false; if (exitCode !== 0) return; + if (CompositorService.isMango) + MangoService.reloadConfig(); checkWindowRulesIncludeStatus(); loadWindowRules(); }); @@ -358,7 +372,7 @@ Item { } Component.onCompleted: { - if (CompositorService.isNiri || CompositorService.isHyprland) { + if (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) { checkWindowRulesIncludeStatus(); loadWindowRules(); } @@ -415,7 +429,7 @@ Item { } StyledText { - text: I18n.tr("Define rules for window behavior. Saves to %1").arg(CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua") + text: I18n.tr("Define rules for window behavior. Saves to %1").arg(root.dmsRulesFileName) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -489,7 +503,7 @@ Item { color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.15) : "transparent" border.color: (showLegacy || showError || showSetup) ? Theme.withAlpha(Theme.warning, 0.3) : "transparent" border.width: 1 - visible: (showLegacy || showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland) + visible: (showLegacy || showError || showSetup) && !root.checkingInclude && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango) Row { id: warningSection @@ -519,7 +533,7 @@ Item { } StyledText { - readonly property string rulesFile: CompositorService.isNiri ? "dms/windowrules.kdl" : "dms/windowrules.lua" + readonly property string rulesFile: root.dmsRulesFileName text: warningBox.showLegacy ? I18n.tr("This install is still using hyprland.conf. Run dms setup to migrate before editing window rules in Settings.") : (warningBox.showSetup ? I18n.tr("Click 'Setup' to create %1 and add include to your compositor config.").arg(rulesFile) : I18n.tr("%1 exists but is not included. Window rules won't apply.").arg(rulesFile)) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText diff --git a/quickshell/Modules/Settings/WorkspacesTab.qml b/quickshell/Modules/Settings/WorkspacesTab.qml index dca767dc..a5b51f43 100644 --- a/quickshell/Modules/Settings/WorkspacesTab.qml +++ b/quickshell/Modules/Settings/WorkspacesTab.qml @@ -59,7 +59,7 @@ Item { text: I18n.tr("Show Workspace Apps") description: I18n.tr("Display application icons in workspace indicators") checked: SettingsData.showWorkspaceApps - visible: CompositorService.isNiri || CompositorService.isHyprland + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango onToggled: checked => SettingsData.set("showWorkspaceApps", checked) } @@ -151,7 +151,7 @@ Item { text: I18n.tr("Follow Monitor Focus") description: I18n.tr("Show workspaces of the currently focused monitor") checked: SettingsData.workspaceFollowFocus - visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle onToggled: checked => SettingsData.set("workspaceFollowFocus", checked) } @@ -161,7 +161,7 @@ Item { text: I18n.tr("Show Occupied Workspaces Only") description: I18n.tr("Display only workspaces that contain windows") checked: SettingsData.showOccupiedWorkspacesOnly - visible: CompositorService.isNiri || CompositorService.isHyprland + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango onToggled: checked => SettingsData.set("showOccupiedWorkspacesOnly", checked) } @@ -171,7 +171,7 @@ Item { text: I18n.tr("Reverse Scrolling Direction") description: I18n.tr("Reverse workspace switch direction when scrolling over the bar") checked: SettingsData.reverseScrolling - visible: CompositorService.isNiri || CompositorService.isHyprland + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango onToggled: checked => SettingsData.set("reverseScrolling", checked) } @@ -191,7 +191,7 @@ Item { text: I18n.tr("Show All Tags") description: I18n.tr("Show all 9 tags instead of only occupied tags (DWL only)") checked: SettingsData.dwlShowAllTags - visible: CompositorService.isDwl + visible: CompositorService.isDwl || CompositorService.isMango onToggled: checked => SettingsData.set("dwlShowAllTags", checked) } } @@ -243,7 +243,7 @@ Item { SettingsButtonGroupRow { text: I18n.tr("Occupied Color") model: ["none", "sec", "s", "sc", "sch", "schh"] - visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango buttonHeight: 22 minButtonWidth: 36 buttonPadding: Theme.spacingS @@ -279,7 +279,7 @@ Item { height: 1 color: Theme.outline opacity: 0.15 - visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango } SettingsButtonGroupRow { @@ -316,12 +316,12 @@ Item { height: 1 color: Theme.outline opacity: 0.15 - visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle } SettingsButtonGroupRow { text: I18n.tr("Urgent Color") - visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle + visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle model: ["err", "pri", "sec", "s", "sc"] buttonHeight: 22 minButtonWidth: 36 diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index aff7db9f..4dfd209d 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -16,6 +16,7 @@ Singleton { property bool isHyprland: false property bool isNiri: false property bool isDwl: false + property bool isMango: false property bool isSway: false property bool isScroll: false property bool isMiracle: false @@ -29,7 +30,9 @@ Singleton { readonly property string scrollSocket: Quickshell.env("SWAYSOCK") readonly property string miracleSocket: Quickshell.env("MIRACLESOCK") readonly property string labwcPid: Quickshell.env("LABWC_PID") + readonly property string mangoSignature: Quickshell.env("MANGO_INSTANCE_SIGNATURE") property bool useNiriSorting: isNiri && NiriService + property bool useMangoSorting: isMango && MangoService property var randrScales: ({}) property bool randrReady: false @@ -100,6 +103,12 @@ Singleton { return dwlScale; } + if (isMango && screen) { + const mangoScale = MangoService.getOutputScale(screen.name); + if (mangoScale !== undefined && mangoScale > 0) + return mangoScale; + } + return screen?.devicePixelRatio || 1; } @@ -114,6 +123,8 @@ Singleton { screenName = focusedWs?.monitor?.name || ""; } else if (isDwl && DwlService.activeOutput) screenName = DwlService.activeOutput; + else if (isMango && MangoService.activeOutput) + screenName = MangoService.activeOutput; if (!screenName) return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null; @@ -194,6 +205,18 @@ Singleton { } } + Connections { + target: MangoService + function onStateChanged() { + if (isMango) + scheduleSort(); + } + function onWindowsChanged() { + if (isMango) + scheduleSort(); + } + } + function computeSortedToplevels() { if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values) return []; @@ -201,6 +224,9 @@ Singleton { if (useNiriSorting) return NiriService.sortToplevels(ToplevelManager.toplevels.values); + if (useMangoSorting) + return MangoService.sortToplevels(ToplevelManager.toplevels.values); + if (isHyprland) return sortHyprlandToplevelsSafe(); @@ -697,6 +723,51 @@ Singleton { return false; } + // Mango clients carry absolute geometry + tags; count those on the screen's + // active tags (not minimized), made screen-relative via the monitor offset. + function mangoDockOverlapForSmartAutoHide(screenName, dockPosition, dockThickness, screenWidth, screenHeight) { + if (!isMango || !screenName || !MangoService.windows) + return false; + + const out = MangoService.outputs[screenName]; + const active = new Set((out?.activeTags) || []); + const monX = out?.x ?? 0; + const monY = out?.y ?? 0; + + for (let i = 0; i < MangoService.windows.length; i++) { + const win = MangoService.windows[i]; + if (!win || win.monitor !== screenName || win.is_minimized) + continue; + if (active.size > 0 && !(win.tags || []).some(t => active.has(t))) + continue; + + const winX = (win.x ?? 0) - monX; + const winY = (win.y ?? 0) - monY; + const winW = win.width ?? 0; + const winH = win.height ?? 0; + + switch (dockPosition) { + case SettingsData.Position.Top: + if (winY < dockThickness) + return true; + break; + case SettingsData.Position.Bottom: + if (winY + winH > screenHeight - dockThickness) + return true; + break; + case SettingsData.Position.Left: + if (winX < dockThickness) + return true; + break; + case SettingsData.Position.Right: + if (winX + winW > screenWidth - dockThickness) + return true; + break; + } + } + return false; + } + function filterHyprlandCurrentDisplaySafe(toplevels, screenName) { if (!toplevels || toplevels.length === 0 || !Hyprland.toplevels) return toplevels; @@ -790,15 +861,31 @@ Singleton { NiriService.generateNiriLayoutConfig(); HyprlandService.generateLayoutConfig(); DwlService.generateLayoutConfig(); + MangoService.generateLayoutConfig(); }); } } function detectCompositor() { + if (mangoSignature && mangoSignature.length > 0) { + isHyprland = false; + isNiri = false; + isDwl = false; + isMango = true; + isSway = false; + isScroll = false; + isMiracle = false; + isLabwc = false; + compositor = "mango"; + log.info("Detected MangoWM via MANGO_INSTANCE_SIGNATURE"); + return; + } + if (hyprlandSignature && hyprlandSignature.length > 0 && !niriSocket && !swaySocket && !scrollSocket && !miracleSocket && !labwcPid) { isHyprland = true; isNiri = false; isDwl = false; + isMango = false; isSway = false; isScroll = false; isMiracle = false; @@ -814,6 +901,7 @@ Singleton { isNiri = true; isHyprland = false; isDwl = false; + isMango = false; isSway = false; isScroll = false; isMiracle = false; @@ -849,6 +937,7 @@ Singleton { isNiri = false; isHyprland = false; isDwl = false; + isMango = false; isSway = false; isScroll = false; isMiracle = true; @@ -866,6 +955,7 @@ Singleton { isNiri = false; isHyprland = false; isDwl = false; + isMango = false; isSway = false; isScroll = true; isMiracle = false; @@ -881,6 +971,7 @@ Singleton { isHyprland = false; isNiri = false; isDwl = false; + isMango = false; isSway = false; isScroll = false; isMiracle = false; @@ -896,6 +987,7 @@ Singleton { isHyprland = false; isNiri = false; isDwl = false; + isMango = false; isSway = false; isScroll = false; isMiracle = false; @@ -908,13 +1000,15 @@ Singleton { Connections { target: DMSService function onCapabilitiesReceived() { - if (!isHyprland && !isNiri && !isDwl && !isLabwc) { + if (!isHyprland && !isNiri && !isDwl && !isMango && !isLabwc) { checkForDwl(); } } } function checkForDwl() { + if (isMango) + return; if (DMSService.apiVersion >= 12 && DMSService.capabilities.includes("dwl")) { isHyprland = false; isNiri = false; @@ -935,6 +1029,8 @@ Singleton { return HyprlandService.dpmsOff(); if (isDwl) return _dwlPowerOffMonitors(); + if (isMango) + return MangoService.powerOffMonitors(); if (isSway || isScroll || isMiracle) { try { I3.dispatch("output * dpms off"); @@ -954,6 +1050,8 @@ Singleton { return HyprlandService.dpmsOn(); if (isDwl) return _dwlPowerOnMonitors(); + if (isMango) + return MangoService.powerOnMonitors(); if (isSway || isScroll || isMiracle) { try { I3.dispatch("output * dpms on"); @@ -975,7 +1073,7 @@ Singleton { for (let i = 0; i < Quickshell.screens.length; i++) { const screen = Quickshell.screens[i]; if (screen && screen.name) { - Quickshell.execDetached(["mmsg", "-d", "disable_monitor," + screen.name]); + Quickshell.execDetached(["mmsg", "dispatch", "disable_monitor," + screen.name]); } } } @@ -989,7 +1087,7 @@ Singleton { for (let i = 0; i < Quickshell.screens.length; i++) { const screen = Quickshell.screens[i]; if (screen && screen.name) { - Quickshell.execDetached(["mmsg", "-d", "enable_monitor," + screen.name]); + Quickshell.execDetached(["mmsg", "dispatch", "enable_monitor," + screen.name]); } } } diff --git a/quickshell/Services/DwlService.qml b/quickshell/Services/DwlService.qml index 8636b351..f9023b4a 100644 --- a/quickshell/Services/DwlService.qml +++ b/quickshell/Services/DwlService.qml @@ -21,6 +21,8 @@ Singleton { property int _lastGapValue: -1 property bool dwlAvailable: false + // Alias so consumers can treat DwlService/MangoService uniformly via `.available`. + readonly property bool available: dwlAvailable property var outputs: ({}) property var tagCount: 9 property var layouts: [] @@ -233,27 +235,23 @@ Singleton { } function quit() { - Quickshell.execDetached(["mmsg", "-d", "quit"]); + Quickshell.execDetached(["mmsg", "dispatch", "quit"]); } Process { id: scaleQueryProcess - command: ["mmsg", "-A"] + command: ["mmsg", "get", "all-monitors"] running: false stdout: StdioCollector { onStreamFinished: { try { const newScales = {}; - const lines = text.trim().split('\n'); - for (const line of lines) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 3 && parts[1] === "scale_factor") { - const outputName = parts[0]; - const scale = parseFloat(parts[2]); - if (!isNaN(scale)) { - newScales[outputName] = scale; - } + const data = JSON.parse(text.trim()); + const monitors = data.monitors || []; + for (const mon of monitors) { + if (mon.name && typeof mon.scale === "number" && mon.scale > 0) { + newScales[mon.name] = mon.scale; } } outputScales = newScales; @@ -327,7 +325,7 @@ Singleton { const transform = transformToMango(output.logical?.transform ?? "Normal"); const vrr = output.vrr_enabled ? 1 : 0; - const rule = ["name:" + outputName, "width:" + width, "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(","); + const rule = ["name:^" + outputName + "$", "width:" + width, "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(","); lines.push("monitorrule=" + rule); } @@ -352,7 +350,7 @@ Singleton { } function reloadConfig() { - Proc.runCommand("mango-reload", ["mmsg", "-d", "reload_config"], (output, exitCode) => { + Proc.runCommand("mango-reload", ["mmsg", "dispatch", "reload_config"], (output, exitCode) => { if (exitCode !== 0) log.warn("mmsg reload_config failed:", output); }); diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index eac8ab2f..ca6cb162 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -14,13 +14,13 @@ Singleton { id: root readonly property var log: Log.scoped("KeybindsService") - property bool available: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl + property bool available: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl || CompositorService.isMango property string currentProvider: { if (CompositorService.isNiri) return "niri"; if (CompositorService.isHyprland) return "hyprland"; - if (CompositorService.isDwl) + if (CompositorService.isDwl || CompositorService.isMango) return "mangowc"; return ""; } @@ -30,7 +30,7 @@ Singleton { return "niri"; if (CompositorService.isHyprland) return "hyprland"; - if (CompositorService.isDwl) + if (CompositorService.isDwl || CompositorService.isMango) return "mangowc"; return ""; } @@ -118,7 +118,7 @@ Singleton { Connections { target: CompositorService function onCompositorChanged() { - if (!CompositorService.isNiri) + if (!CompositorService.isNiri && !CompositorService.isMango) return; Qt.callLater(root.loadBinds); } @@ -203,6 +203,8 @@ Singleton { } root.lastError = ""; root.bindSaveCompleted(true); + if (CompositorService.isMango) + MangoService.reloadConfig(); root.loadBinds(false); } } @@ -226,6 +228,8 @@ Singleton { return; } root.lastError = ""; + if (CompositorService.isMango) + MangoService.reloadConfig(); root.loadBinds(false); } } @@ -254,6 +258,8 @@ Singleton { root.dmsBindsFixed(); const bindsRel = root.currentProvider === "niri" ? "dms/binds.kdl" : root.currentProvider === "hyprland" ? "dms/binds.lua" : "dms/binds.conf"; ToastService.showInfo(I18n.tr("Binds include added"), I18n.tr("%1 is now included in config").arg(bindsRel), "", "keybinds"); + if (CompositorService.isMango) + MangoService.reloadConfig(); Qt.callLater(root.forceReload); } } diff --git a/quickshell/Services/MangoService.qml b/quickshell/Services/MangoService.qml new file mode 100644 index 00000000..b4a559a1 --- /dev/null +++ b/quickshell/Services/MangoService.qml @@ -0,0 +1,561 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtCore +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services + +// Native MangoWM IPC client. mango advertises a JSON-over-Unix-socket protocol +// via MANGO_INSTANCE_SIGNATURE; each connection issues one `watch ` verb +// and gets a full JSON snapshot followed by newline-delimited updates. Replaces +// the legacy dwl-ipc-v2 path (DwlService) for mango, exposing a +// DwlService-compatible tag API plus a per-client window list. +Singleton { + id: root + readonly property var log: Log.scoped("MangoService") + + readonly property string socketPath: Quickshell.env("MANGO_INSTANCE_SIGNATURE") + readonly property bool available: socketPath.length > 0 + + readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + readonly property string mangoDmsDir: configDir + "/mango/dms" + readonly property string outputsPath: mangoDmsDir + "/outputs.conf" + readonly property string layoutPath: mangoDmsDir + "/layout.conf" + readonly property string cursorPath: mangoDmsDir + "/cursor.conf" + + property int _lastGapValue: -1 + + // name -> { name, active, x, y, width, height, scale, layoutIndex, + // layoutSymbol, lastOpenSurface, kbLayout, keymode, + // tags: [{ tag, state, clients, focused, urgent, layout }] } + property var outputs: ({}) + property string activeOutput: "" + property int tagCount: 9 + property var displayScales: ({}) + property string currentKeyboardLayout: "" + // Rich client list from `watch all-clients` (mango "clients"). + property var windows: [] + + // windowsChanged is auto-generated by the `windows` property's change signal. + signal stateChanged + + // ── State sockets ────────────────────────────────────────────────────── + // One connection per watch target; mango streams a fresh full snapshot on + // every change, so each line is treated as the complete state. + + DankSocket { + id: monitorsSocket + path: root.socketPath + connected: root.available + + onConnectionStateChanged: { + if (connected) + send("watch all-monitors"); + } + + parser: SplitParser { + onRead: line => root._handleMonitors(line) + } + } + + DankSocket { + id: clientsSocket + path: root.socketPath + connected: root.available + + onConnectionStateChanged: { + if (connected) + send("watch all-clients"); + } + + parser: SplitParser { + onRead: line => root._handleClients(line) + } + } + + function _handleMonitors(line) { + if (!line || !line.trim()) + return; + let data; + try { + data = JSON.parse(line); + } catch (e) { + log.warn("Failed to parse all-monitors:", e); + return; + } + const monitors = data.monitors; + if (!Array.isArray(monitors)) + return; + + const newOutputs = {}; + const newScales = {}; + let newActive = ""; + let newTagCount = root.tagCount; + let newKbLayout = root.currentKeyboardLayout; + + for (const m of monitors) { + if (!m.name) + continue; + 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), + "clients": t.client_count ?? 0, + "focused": !!t.is_active, + "urgent": !!t.is_urgent, + "layout": t.layout ?? "" + })); + newOutputs[m.name] = { + "name": m.name, + "active": !!m.active, + "x": m.x ?? 0, + "y": m.y ?? 0, + "width": m.width ?? 0, + "height": m.height ?? 0, + "scale": m.scale ?? 1.0, + "layoutIndex": m.layout_index ?? 0, + "layout": m.layout_index ?? 0, + "activeTags": m.active_tags || [], + "layoutSymbol": m.layout_symbol ?? "", + "lastOpenSurface": m.last_open_surface ?? "", + "keymode": m.keymode ?? "", + "kbLayout": m.keyboardlayout ?? "", + "tags": tags + }; + if (typeof m.scale === "number" && m.scale > 0) + newScales[m.name] = m.scale; + if (m.active) { + newActive = m.name; + if (m.keyboardlayout) + newKbLayout = m.keyboardlayout; + } + if (tags.length > 0) + newTagCount = tags.length; + } + + root.outputs = newOutputs; + root.displayScales = newScales; + root.tagCount = newTagCount; + if (newActive) + root.activeOutput = newActive; + root.currentKeyboardLayout = newKbLayout; + root.stateChanged(); + } + + function _handleClients(line) { + if (!line || !line.trim()) + return; + let data; + try { + data = JSON.parse(line); + } catch (e) { + log.warn("Failed to parse all-clients:", e); + return; + } + if (!Array.isArray(data.clients)) + return; + root.windows = data.clients; + } + + // ── DwlService-compatible tag API ────────────────────────────────────── + + function getOutputState(outputName) { + return (outputs && outputs[outputName]) ? outputs[outputName] : null; + } + + function getActiveTags(outputName) { + const output = getOutputState(outputName); + if (!output || !output.tags) + return []; + return output.tags.filter(tag => tag.state === 1).map(tag => tag.tag); + } + + function getTagsWithClients(outputName) { + const output = getOutputState(outputName); + if (!output || !output.tags) + return []; + return output.tags.filter(tag => tag.clients > 0).map(tag => tag.tag); + } + + function getUrgentTags(outputName) { + const output = getOutputState(outputName); + if (!output || !output.tags) + return []; + return output.tags.filter(tag => tag.state === 2).map(tag => tag.tag); + } + + function getVisibleTags(outputName) { + const output = getOutputState(outputName); + if (!output || !output.tags) + return []; + const visibleTags = new Set(); + output.tags.forEach(tag => { + if (tag.state === 1 || tag.clients > 0) + visibleTags.add(tag.tag); + }); + return Array.from(visibleTags).sort((a, b) => a - b); + } + + function getOutputScale(outputName) { + return displayScales[outputName]; + } + + // ── Window list ↔ wlr toplevel matching (per-tag sort/filter) ────────── + // Match mango clients to wlr foreign-toplevels by appId+title to enrich them + // with owning tags/monitor for per-tag filtering and stable ordering. + + function _screenName(screenOrName) { + return (typeof screenOrName === "string") ? screenOrName : (screenOrName?.name ?? ""); + } + + function _orderedClients() { + const list = (windows || []).slice(); + list.sort((a, b) => { + const ma = outputs[a.monitor], mb = outputs[b.monitor]; + const ax = ma?.x ?? 1e9, ay = ma?.y ?? 1e9; + const bx = mb?.x ?? 1e9, by = mb?.y ?? 1e9; + if (ax !== bx) + return ax - bx; + if (ay !== by) + return ay - by; + if ((a.y ?? 0) !== (b.y ?? 0)) + return (a.y ?? 0) - (b.y ?? 0); + if ((a.x ?? 0) !== (b.x ?? 0)) + return (a.x ?? 0) - (b.x ?? 0); + return (a.id ?? 0) - (b.id ?? 0); + }); + return list; + } + + function _matchAndEnrich(toplevels, clients) { + const used = new Set(); + const result = []; + for (const client of clients) { + let bestMatch = null; + let bestScore = -1; + for (const toplevel of toplevels) { + if (used.has(toplevel)) + continue; + if (toplevel.appId !== client.appid) + continue; + let score = 1; + if (client.title && toplevel.title) { + if (toplevel.title === client.title) + score = 3; + else if (toplevel.title.includes(client.title) || client.title.includes(toplevel.title)) + score = 2; + } + if (score > bestScore) { + bestScore = score; + bestMatch = toplevel; + if (score === 3) + break; + } + } + if (!bestMatch) + continue; + used.add(bestMatch); + + const enriched = { + "appId": bestMatch.appId, + "title": bestMatch.title, + "activated": !!client.is_focused, + "mangoWindowId": client.id, + "mangoTags": client.tags || [], + "mangoMonitor": client.monitor + }; + for (let prop in bestMatch) { + if (!(prop in enriched)) + enriched[prop] = bestMatch[prop]; + } + result.push(enriched); + } + return result; + } + + function sortToplevels(toplevels) { + if (!toplevels || toplevels.length === 0 || windows.length === 0) + return [...toplevels]; + const enriched = _matchAndEnrich(toplevels, _orderedClients()); + const used = new Set(enriched.map(e => e.mangoWindowId)); + // Append wlr toplevels that had no mango client match (rare). + const matchedTitles = new Set(enriched.map(e => e.title + "\u0000" + e.appId)); + for (const t of toplevels) { + if (!matchedTitles.has((t.title || "") + "\u0000" + (t.appId || ""))) + enriched.push(t); + } + return enriched; + } + + function _activeTagSet(screenName) { + const out = outputs[screenName]; + return new Set((out?.activeTags) || []); + } + + function filterCurrentWorkspace(toplevels, screenOrName) { + const screenName = _screenName(screenOrName); + if (!screenName) + return toplevels; + const active = _activeTagSet(screenName); + if (active.size === 0) + return toplevels; + + const onActive = tags => (tags || []).some(t => active.has(t)); + + if (toplevels.length > 0 && toplevels[0].mangoTags !== undefined) + return toplevels.filter(t => t.mangoMonitor === screenName && onActive(t.mangoTags)); + + const clients = (windows || []).filter(c => c.monitor === screenName && onActive(c.tags)); + return _matchAndEnrich(toplevels, clients); + } + + function filterCurrentDisplay(toplevels, screenOrName) { + const screenName = _screenName(screenOrName); + if (!toplevels || toplevels.length === 0 || !screenName) + return toplevels; + + if (toplevels.length > 0 && toplevels[0].mangoMonitor !== undefined) + return toplevels.filter(t => t.mangoMonitor === screenName); + + const clients = (windows || []).filter(c => c.monitor === screenName); + return _matchAndEnrich(toplevels, clients); + } + + // ── Commands (mango verb IPC: mmsg dispatch ,) ───────────── + + function reloadConfig() { + Proc.runCommand("mango-reload", ["mmsg", "dispatch", "reload_config"], (output, exitCode) => { + if (exitCode !== 0) + log.warn("mmsg reload_config failed:", output); + }); + } + + function quit() { + Quickshell.execDetached(["mmsg", "dispatch", "quit"]); + } + + // mango tag dispatches act on the focused monitor; tagIndex is 0-based + // (dwl model), mango `view`/`toggleview` take a 1-based tag number. + function switchToTag(outputName, tagIndex) { + Quickshell.execDetached(["mmsg", "dispatch", "view," + (tagIndex + 1)]); + } + + function toggleTag(outputName, tagIndex) { + Quickshell.execDetached(["mmsg", "dispatch", "toggleview," + (tagIndex + 1)]); + } + + // mango's tiling layouts are a fixed compiled-in set the IPC doesn't expose, + // so mirror it here in mango's layouts[] order (layout_index aligns). The + // parallel name list exists because `setlayout` dispatches by name, not index. + readonly property var layouts: ["T", "S", "G", "M", "K", "CT", "RT", "VS", "VT", "VG", "VK", "DW", "F", "VF"] + readonly property var _layoutNames: ["tile", "scroller", "grid", "monocle", "deck", "center_tile", "right_tile", "vertical_scroller", "vertical_tile", "vertical_grid", "vertical_deck", "dwindle", "fair", "vertical_fair"] + + function setLayout(outputName, index) { + const name = _layoutNames[index]; + if (name) + Quickshell.execDetached(["mmsg", "dispatch", "setlayout," + name]); + } + + function cycleKeyboardLayout() { + Quickshell.execDetached(["mmsg", "dispatch", "switch_keyboard_layout"]); + } + + function powerOffMonitors() { + const screens = Quickshell.screens || []; + for (let i = 0; i < screens.length; i++) { + if (screens[i] && screens[i].name) + Quickshell.execDetached(["mmsg", "dispatch", "disable_monitor," + screens[i].name]); + } + } + + function powerOnMonitors() { + const screens = Quickshell.screens || []; + for (let i = 0; i < screens.length; i++) { + if (screens[i] && screens[i].name) + Quickshell.execDetached(["mmsg", "dispatch", "enable_monitor," + screens[i].name]); + } + } + + // ── Config generation (mango config fragments under ~/.config/mango/dms) ─ + + Connections { + target: SettingsData + function onBarConfigsChanged() { + if (!CompositorService.isMango) + return; + const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)); + if (newGaps === root._lastGapValue) + return; + root._lastGapValue = newGaps; + generateLayoutConfig(); + } + } + + Connections { + target: CompositorService + function onIsMangoChanged() { + if (CompositorService.isMango) + generateLayoutConfig(); + } + } + + function transformToMango(transform) { + switch (transform) { + case "Normal": + return 0; + case "90": + return 1; + case "180": + return 2; + case "270": + return 3; + case "Flipped": + return 4; + case "Flipped90": + return 5; + case "Flipped180": + return 6; + case "Flipped270": + return 7; + default: + return 0; + } + } + + function generateOutputsConfig(outputsData, callback) { + if (!outputsData || Object.keys(outputsData).length === 0) { + if (callback) + callback(false); + return; + } + let lines = ["# Auto-generated by DMS - do not edit manually", ""]; + + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (!output) + continue; + let width = 1920; + let height = 1080; + let refreshRate = 60; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) { + width = mode.width || 1920; + height = mode.height || 1080; + refreshRate = Math.round((mode.refresh_rate || 60000) / 1000); + } + } + + const x = output.logical?.x ?? 0; + const y = output.logical?.y ?? 0; + const scale = output.logical?.scale ?? 1.0; + const transform = transformToMango(output.logical?.transform ?? "Normal"); + const vrr = output.vrr_enabled ? 1 : 0; + + // Anchor the name regex: mango matches `name:` unanchored (first-match + // wins), so a bare "DP-1" would also match "eDP-1" and collapse outputs. + const rule = ["name:^" + outputName + "$", "width:" + width, "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(","); + + lines.push("monitorrule=" + rule); + } + + lines.push(""); + + const content = lines.join("\n"); + + Proc.runCommand("mango-write-outputs", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { + if (exitCode !== 0) { + log.warn("Failed to write outputs config:", output); + if (callback) + callback(false); + return; + } + log.info("Generated outputs config at", outputsPath); + if (CompositorService.isMango) + reloadConfig(); + if (callback) + callback(true); + }); + } + + function generateLayoutConfig() { + if (!CompositorService.isMango) + return; + + const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; + const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; + const defaultBorderSize = 2; + + const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutRadiusOverride >= 0) ? SettingsData.mangoLayoutRadiusOverride : defaultRadius; + const gaps = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutGapsOverride >= 0) ? SettingsData.mangoLayoutGapsOverride : defaultGaps; + const borderSize = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutBorderSize >= 0) ? SettingsData.mangoLayoutBorderSize : defaultBorderSize; + + let content = `# Auto-generated by DMS - do not edit manually +border_radius=${cornerRadius} +gappih=${gaps} +gappiv=${gaps} +gappoh=${gaps} +gappov=${gaps} +borderpx=${borderSize} +`; + + Proc.runCommand("mango-write-layout", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { + if (exitCode !== 0) { + log.warn("Failed to write layout config:", output); + return; + } + log.info("Generated layout config at", layoutPath); + reloadConfig(); + }); + } + + function generateCursorConfig() { + if (!CompositorService.isMango) + return; + + const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null; + if (!settings) { + 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 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; + } + + let content = `# Auto-generated by DMS - do not edit manually +cursor_size=${size}`; + + if (themeName) + content += `\ncursor_theme=${themeName}`; + + if (hideTimeout > 0) + content += `\ncursor_hide_timeout=${hideTimeout}`; + + content += `\n`; + + Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${cursorPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { + if (exitCode !== 0) { + log.warn("Failed to write cursor config:", output); + return; + } + log.info("Generated cursor config at", cursorPath); + reloadConfig(); + }); + } +} diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index a0d364ca..b29b42fc 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -319,6 +319,11 @@ Singleton { return; } + if (CompositorService.isMango) { + MangoService.quit(); + return; + } + if (CompositorService.isLabwc) { LabwcService.quit(); return; diff --git a/quickshell/Services/SettingsSearchService.qml b/quickshell/Services/SettingsSearchService.qml index e5b8991d..bf6bc248 100644 --- a/quickshell/Services/SettingsSearchService.qml +++ b/quickshell/Services/SettingsSearchService.qml @@ -36,6 +36,7 @@ Singleton { "isNiri": () => CompositorService.isNiri, "isHyprland": () => CompositorService.isHyprland, "isDwl": () => CompositorService.isDwl, + "isMango": () => CompositorService.isMango, "keybindsAvailable": () => KeybindsService.available, "soundsAvailable": () => AudioService.soundsAvailable, "cupsAvailable": () => CupsService.cupsAvailable, diff --git a/quickshell/matugen/configs/mangowc.toml b/quickshell/matugen/configs/mangowc.toml index 8853142c..fd51da21 100644 --- a/quickshell/matugen/configs/mangowc.toml +++ b/quickshell/matugen/configs/mangowc.toml @@ -1,4 +1,4 @@ [templates.dmsmango] input_path = 'SHELL_DIR/matugen/templates/mango-colors.conf' output_path = 'CONFIG_DIR/mango/dms/colors.conf' -post_hook = 'sh -c "mmsg -d reload_config 2>&1 || true"' +post_hook = 'sh -c "mmsg dispatch reload_config 2>&1 || true"'