refactor(mango): deprecate pre-mango v18.0 binds & legacy management

(cherry picked from commit d8734bbe1a)
This commit is contained in:
purian23
2026-10-22 23:02:56 -04:00
parent f0826a953f
commit 0ae0b96885
30 changed files with 246 additions and 544 deletions
-24
View File
@@ -55,32 +55,8 @@ var resolveIncludeCmd = &cobra.Command{
Run: runResolveInclude,
}
var mangoMigrateMain bool
var mangoMigrateCmd = &cobra.Command{
Use: "mango-migrate",
Short: "Respell Mango config keys for the installed Mango",
Long: "Detects whether the installed Mango uses legacy or snake_case config keys, rewrites the DMS fragments in mango/dms to match, and prints the dialect on the last line. --main also rewrites config.conf after a backup.",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
dialect := mangoconf.Detect()
changed, err := dialect.Migrate(mangoconf.Dir(), mangoMigrateMain)
for _, path := range changed {
fmt.Fprintf(os.Stderr, "migrated %s\n", path)
}
// Printed before any error: the shell needs the dialect even when a fragment failed.
fmt.Println(dialect)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func init() {
configCmd.AddCommand(resolveIncludeCmd)
mangoMigrateCmd.Flags().BoolVar(&mangoMigrateMain, "main", false, "also rewrite config.conf (a backup is kept)")
configCmd.AddCommand(mangoMigrateCmd)
}
type IncludeResult struct {
+65 -16
View File
@@ -858,6 +858,14 @@ func checkWindowManagers() []checkResult {
if c.name == "Hyprland" {
results = append(results, checkHyprlandConfigFormat(filepath.Join(utils.XDGConfigHome(), "hypr"))...)
}
if c.name == "mangowc" && mangoLacksSnakeCase(ver) {
results = append(results, checkResult{
catCompositor, "Mango config format", statusError,
fmt.Sprintf("Mango %s is older than 0.18", ver),
"DMS writes Mango config with the snake_case keys of Mango 0.18 and later, which this release rejects; upgrade Mango",
doctorDocsURL + "#compositor-checks",
})
}
}
if !foundAny {
@@ -878,7 +886,7 @@ func checkWindowManagers() []checkResult {
}
if os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" {
results = append(results, checkMangoConfig(mangoconf.Dir(), mangoconf.Detect())...)
results = append(results, checkMangoConfig(mangoconf.Dir())...)
}
results = append(results, checkCompositorBlurSupport())
@@ -886,33 +894,70 @@ func checkWindowManagers() []checkResult {
return results
}
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
var (
mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
mangoUnknownKeyword = regexp.MustCompile(`Unknown keyword:\s*(\S+)`)
ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
)
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult {
// Mango's parser follows source= and always exits 0; only its output tells.
var mangoParseConfig = func(path string) (string, error) {
if _, err := exec.LookPath("mango"); err != nil {
return "", err
}
out, _ := exec.Command("mango", "-p", "-c", path).CombinedOutput()
return string(out), nil
}
// Each "Unknown keyword" line is followed by a location line naming the file.
func mangoUnknownKeywords(output string) []string {
var found []string
seen := map[string]bool{}
key := ""
for line := range strings.SplitSeq(ansiEscape.ReplaceAllString(output, ""), "\n") {
if m := mangoUnknownKeyword.FindStringSubmatch(line); m != nil {
key = m[1]
continue
}
slash := strings.LastIndex(line, "/")
if key == "" || slash < 0 {
continue
}
file, _, _ := strings.Cut(line[slash+1:], ":")
entry := key + " (" + file + ")"
if !seen[entry] {
seen[entry] = true
found = append(found, entry)
}
key = ""
}
return found
}
func checkMangoConfig(mangoDir string) []checkResult {
url := doctorDocsURL + "#compositor-checks"
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
paths = append([]string{filepath.Join(mangoDir, "config.conf")}, paths...)
mainPath := filepath.Join(mangoDir, "config.conf")
var results []checkResult
if output, err := mangoParseConfig(mainPath); err == nil {
if unknown := mangoUnknownKeywords(output); len(unknown) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
"Unknown keys: " + strings.Join(unknown, ", "),
"Mango 0.18 renamed its config keys to snake_case. Run migrate-config-keys.sh from Mango's 0.18.0 release over ~/.config/mango, or rename the keys by hand.", url})
}
}
var stale, overviewBinds []string
paths, _ := filepath.Glob(filepath.Join(mangoDir, "dms", "*.conf"))
paths = append([]string{mainPath}, paths...)
var overviewBinds []string
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
if dialect.Translate(string(data)) != string(data) {
stale = append(stale, filepath.Base(path))
}
if mangoLegacyOverviewBinds.Match(data) {
overviewBinds = append(overviewBinds, filepath.Base(path))
}
}
var results []checkResult
if len(stale) > 0 {
results = append(results, checkResult{catCompositor, "Mango config keys", statusError,
fmt.Sprintf("Not %s keys: %s", dialect, strings.Join(stale, ", ")),
"This Mango rejects those lines as unknown keywords. Run `dms config mango-migrate --main` (config.conf is backed up).", url})
}
if len(overviewBinds) > 0 {
results = append(results, checkResult{catCompositor, "Mango mouse binds", statusWarn,
"Obsolete overview binds in " + strings.Join(overviewBinds, ", "),
@@ -967,6 +1012,10 @@ func hyprlandLacksLua(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.55.0") < 0
}
func mangoLacksSnakeCase(ver string) bool {
return ver != "" && version.CompareVersions(ver, "0.18.0") < 0
}
func firstSubmatch(re *regexp.Regexp) func(string) string {
return func(s string) string {
if m := re.FindStringSubmatch(s); len(m) > 1 {
+28 -8
View File
@@ -8,8 +8,6 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
)
func TestQuickshellVersionFailureDetails(t *testing.T) {
@@ -385,19 +383,41 @@ func TestCheckXDGMenuFile(t *testing.T) {
}
}
func TestCheckMangoConfigFlagsWrongDialectAndOverviewBinds(t *testing.T) {
func TestCheckMangoConfigReportsUnknownKeysAndOverviewBinds(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "dms"), 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(dir, "config.conf"), []byte("exec-once=dms run\nmousebind=NONE,btn_left,toggleoverview,1\n"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "dms", "layout.conf"), []byte("border_px=2\n"), 0o644)
// Shape of `mango -p` 0.18.2 output, colours included; the same key twice collapses.
parsed := "\x1b[1m\x1b[31m[ERROR]:\x1b[0m Unknown keyword: \x1b[1m\x1b[31mexec-once\x1b[0m\n" +
"\x1b[1;31m╰─\x1b[1;33m[Index]\x1b[0m \x1b[1;36m" + dir + "/config.conf\x1b[0m:\x1b[1;35m1\x1b[0m\n ╰─exec-once=dms run\n\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:10\n ╰─overviewgappo=30\n" +
"[ERROR]: Unknown keyword: overviewgappo\n╰─[Index] " + dir + "/dms/layout.conf:12\n"
prev := mangoParseConfig
mangoParseConfig = func(string) (string, error) { return parsed, nil }
t.Cleanup(func() { mangoParseConfig = prev })
if got := checkMangoConfig(dir, mangoconf.Legacy); len(got) != 2 || got[0].message != "Not legacy keys: layout.conf" {
t.Fatalf("legacy: %+v", got)
got := checkMangoConfig(dir)
if len(got) != 2 || got[0].message != "Unknown keys: exec-once (config.conf), overviewgappo (layout.conf)" || got[1].name != "Mango mouse binds" {
t.Fatalf("got %+v", got)
}
if got := checkMangoConfig(dir, mangoconf.Snake); len(got) != 2 || got[0].message != "Not snake keys: config.conf" {
t.Fatalf("snake: %+v", got)
mangoParseConfig = func(string) (string, error) { return "", os.ErrNotExist }
if got := checkMangoConfig(dir); len(got) != 1 {
t.Fatalf("without mango on PATH only the bind check should run, got %+v", got)
}
}
func TestMangoLacksSnakeCase(t *testing.T) {
for ver, want := range map[string]bool{
"0.17.5": true,
"0.18.0": false,
"0.18.2": false,
"": false,
} {
if got := mangoLacksSnakeCase(ver); got != want {
t.Errorf("mangoLacksSnakeCase(%q) = %v, want %v", ver, got, want)
}
}
}
+2 -2
View File
@@ -130,7 +130,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "layout.conf",
niriContent: func(_ string) string { return config.NiriLayoutConfig },
hyprContent: func(_ string) string { return config.DMSLayoutLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoLayoutConfig) },
mangoContent: func(_ string) string { return config.MangoLayoutConfig },
},
"colors": {
niriFile: "colors.kdl",
@@ -138,7 +138,7 @@ var dmsConfigSpecs = map[string]dmsConfigSpec{
mangoFile: "colors.conf",
niriContent: func(_ string) string { return config.NiriColorsConfig },
hyprContent: func(_ string) string { return config.DMSColorsLuaConfig },
mangoContent: func(_ string) string { return mangoconf.Detect().Translate(config.MangoColorsConfig) },
mangoContent: func(_ string) string { return config.MangoColorsConfig },
},
"alttab": {
niriFile: "alttab.kdl",
@@ -1,6 +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
border_color = 0x595959ff
focus_color = 0x8ab4f8ff
urgent_color = 0xff5555ff
@@ -1,8 +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
gap_inner_horizontal=5
gap_inner_vertical=5
gap_outer_horizontal=5
gap_outer_vertical=5
border_px=2
+1 -1
View File
@@ -7,7 +7,7 @@ env=XDG_SESSION_TYPE,wayland
# Starts the shell when DMS is not a systemd service. Never exec=: Mango re-runs
# exec= on every config reload, and DMS reloads the config.
exec-once=dms run
exec_once=dms run
source=./dms/colors.conf
source=./dms/layout.conf
+10 -12
View File
@@ -41,14 +41,13 @@ func nonEmptyFile(path string) (string, bool) {
}
var (
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|keymode|key_mode)\s*=`)
mangoMonitorLine = regexp.MustCompile(`^\s*(monitorrule|monitor_rule)\s*=`)
mangoWindowLine = regexp.MustCompile(`^\s*(windowrule|window_rule|windowrule-once|window_rule_once)\s*=`)
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\n(exec-once|exec_once)=dms run\n`)
mangoBindLine = regexp.MustCompile(`^\s*(bind[a-z]*|mousebind|axisbind|gesturebind|switchbind|key_mode)\s*=`)
mangoMonitorLine = regexp.MustCompile(`^\s*monitor_rule\s*=`)
mangoWindowLine = regexp.MustCompile(`^\s*window_rule(_once)?\s*=`)
mangoExecOnceDMS = regexp.MustCompile(`(?m)^# Starts the shell when DMS.*\n#.*\nexec_once=dms run\n`)
)
var (
mangoDialect = mangoconf.Detect
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
)
@@ -110,17 +109,16 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath))
}
dialect := mangoDialect()
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
}
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil {
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, dialect, existing, timestamp); err != nil {
if err := cd.deployMangoDmsConfigs(dmsDir, terminalCommand, existing, timestamp); err != nil {
result.Error = fmt.Errorf("failed to deploy dms configs: %w", err)
return result, result.Error
}
@@ -132,7 +130,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
// Like niri, fragments the user or the shell already filled are left alone; the
// replaced config.conf hands its binds, monitor and window rules to empty ones.
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string, dialect mangoconf.Dialect, oldMain, timestamp string) error {
func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand, oldMain, timestamp string) error {
stockBinds := strings.ReplaceAll(MangoBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
bindsPath := filepath.Join(dmsDir, "binds.conf")
if data, err := os.ReadFile(bindsPath); err == nil && len(data) > 0 {
@@ -142,7 +140,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
}
cd.log(fmt.Sprintf("Backed up existing binds to %s", backup))
if cd.replaceBinds {
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(stockBinds)), 0o644); err != nil {
if err := os.WriteFile(bindsPath, []byte(stockBinds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err)
}
cd.log("Deployed DMS stock binds.conf")
@@ -160,7 +158,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
binds = mangoconf.BindsMovedHeader + "\n" + old
cd.log("Moved existing binds from config.conf to binds.conf")
}
if err := os.WriteFile(bindsPath, []byte(dialect.Translate(binds)), 0o644); err != nil {
if err := os.WriteFile(bindsPath, []byte(binds), 0o644); err != nil {
return fmt.Errorf("failed to write binds.conf: %w", err)
}
}
@@ -182,7 +180,7 @@ func (cd *ConfigDeployer) deployMangoDmsConfigs(dmsDir, terminalCommand string,
cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name))
continue
}
if err := os.WriteFile(path, []byte(dialect.Translate(cfg.content)), 0o644); err != nil {
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))
+17 -17
View File
@@ -13,12 +13,11 @@ import (
"github.com/stretchr/testify/require"
)
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int {
func stubMango(t *testing.T, target bool, addWants error) *int {
t.Helper()
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled
mangoDialect = func() mangoconf.Dialect { return dialect }
prevTarget := mangoSessionTargetInstalled
mangoSessionTargetInstalled = func() bool { return target }
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget })
t.Cleanup(func() { mangoSessionTargetInstalled = prevTarget })
return stubAddWants(t, addWants)
}
@@ -31,16 +30,16 @@ func readMango(t *testing.T, home, rel string) string {
const oldMangoConfig = `exec-once=waybar
bind=SUPER,Return,spawn,foot
keymode=resize
key_mode=resize
bind=NONE,h,resizewin,-10,0
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
windowrule=appid:pavucontrol,isfloating:1
monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:144,x:0,y:0,scale:1,rr:0,vrr:1,hdr:1
window_rule=app_id:pavucontrol,is_floating:1
`
func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil)
stubMango(t, false, nil)
require.NoError(t, os.MkdirAll(filepath.Join(home, ".config", "mango"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(home, ".config", "mango", "config.conf"), []byte(oldMangoConfig), 0o644))
@@ -48,25 +47,26 @@ func TestMangoDeployMovesUserRulesAndKeepsBindsByDefault(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, result.BackupPath)
assert.Contains(t, readMango(t, home, "config.conf"), "exec-once=dms run")
assert.Contains(t, readMango(t, home, "config.conf"), "exec_once=dms run")
assert.Contains(t, readMango(t, home, "config.conf"), "source=./dms/input.conf")
assert.Equal(t, "", readMango(t, home, "dms/input.conf"))
binds := readMango(t, home, "dms/binds.conf")
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkeymode=resize\nbind=NONE,h,resizewin,-10,0")
assert.Contains(t, binds, "bind=SUPER,Return,spawn,foot\nkey_mode=resize\nbind=NONE,h,resizewin,-10,0")
assert.NotContains(t, binds, "{{TERMINAL_COMMAND}}")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "monitor_rule=name:^DP-1$")
assert.Contains(t, readMango(t, home, "dms/outputs.conf"), "hdr:1")
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "windowrule=appid:pavucontrol,isfloating:1")
assert.Contains(t, readMango(t, home, "dms/windowrules.conf"), "window_rule=app_id:pavucontrol,is_floating:1")
}
func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
for _, replace := range []bool{false, true} {
home := t.TempDir()
t.Setenv("HOME", home)
stubMango(t, mangoconf.Legacy, false, nil)
stubMango(t, false, nil)
dms := filepath.Join(home, ".config", "mango", "dms")
require.NoError(t, os.MkdirAll(dms, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("windowrule=appid:keep,isglobal:1\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dms, "binds.conf"), []byte("bind=SUPER,x,killclient\nkey_mode=resize\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dms, "windowrules.conf"), []byte("window_rule=app_id:keep,is_global:1\n"), 0o644))
cd := NewConfigDeployer(nil)
cd.SetReplaceBinds(replace)
@@ -76,8 +76,8 @@ func TestMangoDeployBindsChoiceAlwaysBacksUp(t *testing.T) {
backups, _ := filepath.Glob(filepath.Join(dms, "binds.conf.backup.*"))
assert.Len(t, backups, 1, "replace=%v", replace)
binds := readMango(t, home, "dms/binds.conf")
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\n", "replace=%v", replace)
assert.Equal(t, "windowrule=appid:keep,isglobal:1\n", readMango(t, home, "dms/windowrules.conf"))
assert.Equal(t, !replace, binds == mangoconf.BindsKeptHeader+"\nbind=SUPER,x,killclient\nkey_mode=resize\n", "replace=%v", replace)
assert.Equal(t, "window_rule=app_id:keep,is_global:1\n", readMango(t, home, "dms/windowrules.conf"), "kept fragments are left alone")
}
}
@@ -97,7 +97,7 @@ func TestMangoDeploySystemdAutostart(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := stubMango(t, mangoconf.Snake, tc.target, tc.addWants)
calls := stubMango(t, tc.target, tc.addWants)
_, err := NewConfigDeployer(nil).DeployCompositor(deps.WindowManagerMango, "kitty", true)
require.NoError(t, err)
main := readMango(t, home, "config.conf")
+1 -1
View File
@@ -43,7 +43,7 @@ func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
func TestEnableSessionMangoWithoutTarget(t *testing.T) {
t.Setenv("HOME", t.TempDir())
calls := stubMango(t, mangoconf.Snake, false, nil)
calls := stubMango(t, false, nil)
unit, err := EnableSession(deps.WindowManagerMango)
require.Error(t, err)
assert.Empty(t, unit)
+19
View File
@@ -0,0 +1,19 @@
// Package mangoconf holds what DMS knows about Mango's config files: where they
// live, the session target, and the markers DMS leaves in the binds fragment.
package mangoconf
import (
"fmt"
"strings"
)
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-216
View File
@@ -1,216 +0,0 @@
// Package mangoconf maps Mango config keys between the run-together names of
// Mango <= 0.17.5 and the snake_case names of upstream 8adc6bc3 (mangowm/mango#1491).
// Neither parser accepts the other's names.
package mangoconf
import (
"bytes"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
)
type Dialect int
const (
Legacy Dialect = iota
Snake
)
// Upstream migration order: where two legacy names share a snake name, the
// window-rule spelling comes first and wins the reverse lookup.
var renames = [][2]string{
{"windowrule-once", "window_rule_once"},
{"windowrule", "window_rule"},
{"monitorrule", "monitor_rule"},
{"tagrule", "tag_rule"},
{"layerrule", "layer_rule"},
{"devicerule", "device_rule"},
{"source-optional", "source_optional"},
{"exec-once", "exec_once"},
{"keymode", "key_mode"},
{"isnoborder", "no_border"},
{"isnoshadow", "no_shadow"},
{"isnoradius", "no_radius"},
{"isnoanimation", "no_animation"},
{"isnosizehint", "no_size_hint"},
{"noblur", "no_blur"},
{"noshadow", "no_shadow"},
{"noanim", "no_animation"},
{"nofocus", "no_focus"},
{"nofadein", "no_fade_in"},
{"nofadeout", "no_fade_out"},
{"noswallow", "no_swallow"},
{"special_gappih", "special_gap_inner_horizontal"},
{"special_gappiv", "special_gap_inner_vertical"},
{"special_gappoh", "special_gap_outer_horizontal"},
{"special_gappov", "special_gap_outer_vertical"},
{"gappih", "gap_inner_horizontal"},
{"gappiv", "gap_inner_vertical"},
{"gappoh", "gap_outer_horizontal"},
{"gappov", "gap_outer_vertical"},
{"overviewgappi", "overview_gap_inner"},
{"overviewgappo", "overview_gap_outer"},
{"default_mfact", "default_master_factor"},
{"default_nmaster", "default_master_count"},
{"animation_curve_opafadein", "animation_curve_opacity_fade_in"},
{"animation_curve_opafadeout", "animation_curve_opacity_fade_out"},
{"dwindle_hsplit", "dwindle_horizontal_split"},
{"dwindle_vsplit", "dwindle_vertical_split"},
{"fadein_begin_opacity", "fade_in_begin_opacity"},
{"fadeout_begin_opacity", "fade_out_begin_opacity"},
{"focusdir_only_zone_overlap", "focus_direction_only_zone_overlap"},
{"force_fakemaximize", "force_fake_maximize"},
{"idleinhibit_ignore_visible", "idle_inhibit_ignore_visible"},
{"idleinhibit_when_focus", "idle_inhibit_when_focus"},
{"idleinhibit_when_fullscreen", "idle_inhibit_when_fullscreen"},
{"syncobj_enable", "sync_obj_enable"},
{"isfakefullscreen", "is_fake_fullscreen"},
{"isfloating", "is_floating"},
{"isfullscreen", "is_fullscreen"},
{"isunglobal", "is_unmanaged_global"},
{"isglobal", "is_global"},
{"isnamedscratchpad", "is_named_scratchpad"},
{"isopensilent", "is_open_silent"},
{"isoverlay", "is_overlay"},
{"istagsilent", "is_tag_silent"},
{"isterm", "is_term"},
{"appid", "app_id"},
{"bordercolor", "border_color"},
{"borderpx", "border_px"},
{"dropcolor", "drop_color"},
{"focuscolor", "focus_color"},
{"globalcolor", "global_color"},
{"globalkeybinding", "global_key_binding"},
{"maximizescreencolor", "maximized_screen_color"},
{"mfact", "master_factor"},
{"nmaster", "master_count"},
{"numlockon", "numlock_on"},
{"offsetx", "offset_x"},
{"offsety", "offset_y"},
{"overlaycolor", "overlay_color"},
{"rootcolor", "root_color"},
{"scratchpadcolor", "scratchpad_color"},
{"shadowscolor", "shadows_color"},
{"sloppyfocus", "sloppy_focus"},
{"smartgaps", "smart_gaps"},
{"splitcolor", "split_color"},
{"urgentcolor", "urgent_color"},
{"warpcursor", "warp_cursor"},
}
var toSnake, toLegacy = func() (map[string]string, map[string]string) {
fwd := make(map[string]string, len(renames))
rev := make(map[string]string, len(renames))
for _, r := range renames {
fwd[r[0]] = r[1]
if _, ok := rev[r[1]]; !ok {
rev[r[1]] = r[0]
}
}
return fwd, rev
}()
// Normalize returns the snake_case name for a key in either dialect.
func Normalize(key string) string {
if s, ok := toSnake[key]; ok {
return s
}
return key
}
// Key spells a snake_case key in this dialect.
func (d Dialect) Key(snake string) string {
if d == Snake {
return snake
}
if l, ok := toLegacy[snake]; ok {
return l
}
return snake
}
var keyLine = regexp.MustCompile(`^(\s*)([A-Za-z][A-Za-z_-]*)(\s*=\s*)(.*)$`)
// Translate respells every line key, and the field keys of rule lines, in this dialect.
func (d Dialect) Translate(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
m := keyLine.FindStringSubmatch(line)
if m == nil {
continue
}
key := Normalize(m[2])
value := m[4]
switch key {
case "window_rule", "window_rule_once", "tag_rule":
value = d.translateFields(value, nil)
case "layer_rule":
value = d.translateFields(value, legacyLayerFields)
}
lines[i] = m[1] + d.Key(key) + m[3] + value
}
return strings.Join(lines, "\n")
}
// Layer rules spell these without the window rules' "is" prefix.
var legacyLayerFields = map[string]string{"no_shadow": "noshadow", "no_animation": "noanim"}
func (d Dialect) translateFields(value string, legacyOverride map[string]string) string {
parts := strings.Split(value, ",")
for i, part := range parts {
k, v, ok := strings.Cut(part, ":")
if !ok {
continue
}
trimmed := strings.TrimSpace(k)
key := d.Key(Normalize(trimmed))
if l, ok := legacyOverride[Normalize(trimmed)]; ok && d == Legacy {
key = l
}
parts[i] = strings.Replace(k, trimmed, key, 1) + ":" + v
}
return strings.Join(parts, ",")
}
// Legacy builds carry exec_once as a symbol name, so it cannot tell the dialects apart.
var snakeMarker = []byte("gap_inner_horizontal")
var (
detectOnce sync.Once
detected Dialect
)
// Detect reads the mango binary: snake_case shipped on master builds still reporting 0.17.5, before the 0.18.0 release.
func Detect() Dialect {
detectOnce.Do(func() {
path, err := exec.LookPath("mango")
if err != nil {
return
}
detected = DetectBinary(path)
})
return detected
}
func DetectBinary(path string) Dialect {
data, err := os.ReadFile(path)
if err != nil || !bytes.Contains(data, snakeMarker) {
return Legacy
}
return Snake
}
// Mango reads each line into a 512-byte buffer and the value into 255 bytes; longer is cut silently.
const maxValueLen = 255
func CheckLine(line string) error {
_, value, ok := strings.Cut(line, "=")
if !ok || len(strings.TrimSpace(value)) <= maxValueLen {
return nil
}
return fmt.Errorf("line is %d characters after '='; Mango truncates past %d", len(strings.TrimSpace(value)), maxValueLen)
}
-68
View File
@@ -1,68 +0,0 @@
package mangoconf
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTranslateRespellsKeysBothWays(t *testing.T) {
legacy := "exec-once=dms run\nsource=./dms/colors.conf\ngappih = 5\nbordercolor = 0x595959ff\nwindowrule=appid:foo,isfloating:1,isnoanimation:1\nlayerrule=noanim:1,noshadow:1,layer_name:rofi\n# exec-once=kept\n"
snake := "exec_once=dms run\nsource=./dms/colors.conf\ngap_inner_horizontal = 5\nborder_color = 0x595959ff\nwindow_rule=app_id:foo,is_floating:1,no_animation:1\nlayer_rule=no_animation:1,no_shadow:1,layer_name:rofi\n# exec-once=kept\n"
assert.Equal(t, snake, Snake.Translate(legacy))
assert.Equal(t, legacy, Legacy.Translate(snake))
assert.Equal(t, legacy, Legacy.Translate(legacy))
assert.Equal(t, "tag_rule=id:1,master_factor:0.6,master_count:2\n", Snake.Translate("tagrule=id:1,mfact:0.6,nmaster:2\n"))
}
func TestDetectBinary(t *testing.T) {
dir := t.TempDir()
old := filepath.Join(dir, "old")
cur := filepath.Join(dir, "new")
require.NoError(t, os.WriteFile(old, []byte("\x00run_exec_once\x00gappih\x00"), 0o755))
require.NoError(t, os.WriteFile(cur, []byte("\x00exec_once\x00gap_inner_horizontal\x00"), 0o755))
assert.Equal(t, Legacy, DetectBinary(old))
assert.Equal(t, Snake, DetectBinary(cur))
assert.Equal(t, Legacy, DetectBinary(filepath.Join(dir, "missing")))
}
func TestMigrateLeavesUserConfigUnlessAsked(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
main := filepath.Join(dir, "config.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
input := filepath.Join(dir, "dms", "input.conf")
require.NoError(t, os.WriteFile(main, []byte("exec-once=dms run\n"), 0o644))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
require.NoError(t, os.WriteFile(input, []byte("numlockon=1\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
require.NoError(t, err)
assert.Equal(t, []string{layout, input}, changed)
data, _ := os.ReadFile(main)
assert.Equal(t, "exec-once=dms run\n", string(data))
data, _ = os.ReadFile(input)
assert.Equal(t, "numlock_on=1\n", string(data))
changed, err = Snake.Migrate(dir, true)
require.NoError(t, err)
assert.Equal(t, []string{main}, changed)
backups, _ := filepath.Glob(main + ".backup.*")
assert.Len(t, backups, 1)
}
func TestMigrateReportsUnwritableFragmentAndContinues(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dms"), 0o755))
binds := filepath.Join(dir, "dms", "binds.conf")
layout := filepath.Join(dir, "dms", "layout.conf")
require.NoError(t, os.WriteFile(binds, []byte("exec-once=foo\n"), 0o444))
require.NoError(t, os.WriteFile(layout, []byte("borderpx=2\n"), 0o644))
changed, err := Snake.Migrate(dir, false)
assert.ErrorContains(t, err, "binds.conf")
assert.Equal(t, []string{layout}, changed)
}
-57
View File
@@ -1,57 +0,0 @@
package mangoconf
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// DMS-owned fragments under mango/dms; config.conf is the user's and only
// migrated on request.
var fragments = []string{"binds.conf", "colors.conf", "layout.conf", "cursor.conf", "input.conf", "outputs.conf", "windowrules.conf"}
// Migrate respells the dms fragments, and config.conf (after a backup) when includeMain is set.
// It returns the files rewritten; an unwritable file is reported without stopping the others.
func (d Dialect) Migrate(mangoDir string, includeMain bool) ([]string, error) {
var changed []string
var errs []error
paths := make([]string, 0, len(fragments)+1)
for _, f := range fragments {
paths = append(paths, filepath.Join(mangoDir, "dms", f))
}
if includeMain {
paths = append(paths, filepath.Join(mangoDir, "config.conf"))
}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
continue
}
translated := d.Translate(string(data))
if translated == string(data) {
continue
}
if filepath.Base(path) == "config.conf" {
backup := path + ".backup." + time.Now().Format("2006-01-02_15-04-05")
if err := os.WriteFile(backup, data, 0o644); err != nil {
errs = append(errs, fmt.Errorf("backup %s: %w", path, err))
continue
}
}
if err := os.WriteFile(path, []byte(translated), 0o644); err != nil {
errs = append(errs, fmt.Errorf("write %s: %w", path, err))
continue
}
changed = append(changed, path)
}
return changed, errors.Join(errs...)
}
func (d Dialect) String() string {
if d == Snake {
return "snake"
}
return "legacy"
}
+1 -9
View File
@@ -20,7 +20,6 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/dank16"
"github.com/duxlabs-dev/dux-shell/core/internal/log"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
"github.com/godbus/dbus/v5"
"github.com/lucasb-eyer/go-colorful"
@@ -44,7 +43,6 @@ const (
TemplateKindGTK
TemplateKindVSCode
TemplateKindEmacs
TemplateKindMango
)
type TemplateDef struct {
@@ -63,7 +61,7 @@ var templateRegistry = []TemplateDef{
{ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
{ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
{ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE", Kind: TemplateKindMango},
{ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml", RequiredEnv: "MANGO_INSTANCE_SIGNATURE"},
{ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
{ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
{ID: "fcitx5", Commands: []string{"fcitx5"}, ConfigDirs: []string{"fcitx5"}, ConfigFile: "fcitx5.toml"},
@@ -725,12 +723,6 @@ output_path = '%s'
for _, editor := range vscodeEditors {
appendVSCodeConfig(cfgFile, editor.name, editor.extensionsDir(homeDir), opts.ShellDir)
}
case TemplateKindMango:
configFile := tmpl.ConfigFile
if mangoconf.Detect() == mangoconf.Snake {
configFile = "mangowc-snake.toml"
}
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, configFile)
case TemplateKindEmacs:
if utils.EmacsConfigDir() != "" {
appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigDirs, tmpl.ConfigFile)
+26 -1
View File
@@ -511,7 +511,7 @@ func TestBuildMergedConfigColorsOnly(t *testing.T) {
assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
}
func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
func TestBuildMergedConfigMangoSession(t *testing.T) {
t.Setenv("MANGO_INSTANCE_SIGNATURE", "")
tempDir := t.TempDir()
@@ -555,6 +555,31 @@ func TestBuildMergedConfigSkipsMangowcWithoutActiveSession(t *testing.T) {
t.Fatalf("failed to read merged config: %v", err)
}
assert.NotContains(t, string(output), "[templates.dmsmango]")
socket := filepath.Join(tempDir, "mango.sock")
if err := os.WriteFile(socket, nil, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MANGO_INSTANCE_SIGNATURE", socket)
checker := mocks_utils.NewMockAppChecker(t)
checker.EXPECT().AnyCommandExists("mango").Return(true)
opts.AppChecker = checker
cfgFile, err = os.CreateTemp(tempDir, "merged-active-*.toml")
if err != nil {
t.Fatal(err)
}
defer cfgFile.Close()
if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
t.Fatal(err)
}
if err := cfgFile.Close(); err != nil {
t.Fatal(err)
}
output, err = os.ReadFile(cfgFile.Name())
if err != nil {
t.Fatal(err)
}
assert.Contains(t, string(output), mangowcConfig)
}
func TestAppendConfigConfigDirExists(t *testing.T) {
+1 -1
View File
@@ -179,7 +179,7 @@ func (m Model) configReplacementNote() string {
if m.useSystemdConfig() && mangoconf.SessionTargetInstalled() {
return "Replacing Mango writes the DMS Mango template and starts the user systemd dms service with mango-session.target. Existing binds, monitor and window rules move into the dms/ files."
}
return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with " + mangoconf.Detect().Key("exec_once") + "=dms run. Existing binds, monitor and window rules move into the dms/ files."
return "Replacing Mango writes the DMS Mango template and starts DMS from Mango with exec_once=dms run. Existing binds, monitor and window rules move into the dms/ files."
case config.BindsConfigType:
return "Keep uses your binds as they are; replace starts from the DMS stock binds. A timestamped backup is kept either way."
case "Ghostty":
+2 -2
View File
@@ -197,7 +197,7 @@ func (m Model) troubleshootingHints() (autostart, logs string) {
case deps.WindowManagerHyprland:
autostart = `remove hl.exec_cmd("dms run") from ~/.config/hypr/hyprland.lua`
default:
autostart = "remove '" + mangoconf.Detect().Key("exec_once") + "=dms run' from ~/.config/mango/config.conf"
autostart = "remove 'exec_once=dms run' from ~/.config/mango/config.conf"
}
return autostart, logs
}
@@ -206,7 +206,7 @@ func (m Model) troubleshootingHints() (autostart, logs string) {
if mangoconf.SessionTargetInstalled() {
return "rm ~/.config/systemd/user/mango-session.target.wants/dms.service", "journalctl --user -u dms"
}
return "remove '" + mangoconf.Detect().Key("exec_once") + "=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log"
return "remove 'exec_once=dms run' from ~/.config/mango/config.conf", "qs -p ~/.config/quickshell/dms log"
}
return "systemctl --user disable dms", "journalctl --user -u dms"
}
@@ -11,19 +11,17 @@ import (
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
)
// Mango window rules are flat `windowrule=key:value,...` lines (`window_rule=`
// with snake_case fields after the upstream rename; both are read). DMS-managed rules
// live in dms/windowrules.conf (sourced from config.conf), each preceded by an
// `# @id=<id> @name=<name>` comment so they round-trip.
// DMS-managed rules live in dms/windowrules.conf (sourced from config.conf),
// each preceded by an `# @id=<id> @name=<name>` comment so they round-trip.
type MangoWindowRule struct {
Source string
Fields map[string]string
}
var mangoWindowRuleRegex = regexp.MustCompile(`^(?:windowrule|window_rule)\s*=\s*(.+)$`)
var mangoWindowRuleRegex = regexp.MustCompile(`^window_rule\s*=\s*(.+)$`)
var mangoMetaCommentRegex = regexp.MustCompile(`^#\s*@id=(\S*)\s*@name=(.*)$`)
var mangoOnceRuleRegex = regexp.MustCompile(`^(?:windowrule-once|window_rule_once)\s*=`)
var mangoOnceRuleRegex = regexp.MustCompile(`^window_rule_once\s*=`)
// Fields the editor models; anything else on a DMS rule line is carried through saves.
var mangoModeledFields = map[string]bool{
@@ -42,7 +40,7 @@ func mangoUnmodeledFields(value string) [][2]string {
if !ok {
continue
}
key := mangoconf.Normalize(strings.TrimSpace(k))
key := strings.TrimSpace(k)
sized := (key == "width" || key == "height") && (!hasWidth || !hasHeight)
if key != "" && (!mangoModeledFields[key] || sized) {
extra = append(extra, [2]string{key, strings.TrimSpace(v)})
@@ -62,7 +60,7 @@ func parseMangoWindowRuleLine(value string) map[string]string {
if !ok {
continue
}
key := mangoconf.Normalize(strings.TrimSpace(before))
key := strings.TrimSpace(before)
val := strings.TrimSpace(after)
if key != "" {
fields[key] = val
@@ -89,7 +87,7 @@ func mangoOverridePath(configDir string) string {
return filepath.Join(configDir, "dms", "windowrules.conf")
}
// parseMangoRulesFile reads a config file and returns its windowrule= lines.
// parseMangoRulesFile reads a config file and returns its window_rule= lines.
func parseMangoRulesFile(path, source string) []MangoWindowRule {
data, err := os.ReadFile(path)
if err != nil {
@@ -215,11 +213,11 @@ func ConvertMangoRulesToWindowRules(mangoRules []MangoWindowRule) []windowrules.
return result
}
func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, extra ...[2]string) string {
func formatMangoRule(rule windowrules.WindowRule, extra ...[2]string) string {
var parts []string
add := func(k, v string) {
if v != "" {
parts = append(parts, dialect.Key(k)+":"+v)
parts = append(parts, k+":"+v)
}
}
@@ -235,7 +233,7 @@ func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, ext
addBool := func(k string, b *bool) {
if b != nil {
parts = append(parts, dialect.Key(k)+":"+mangoBoolStr(b))
parts = append(parts, k+":"+mangoBoolStr(b))
}
}
addBool("is_floating", rule.Actions.OpenFloating)
@@ -249,19 +247,18 @@ func formatMangoRule(rule windowrules.WindowRule, dialect mangoconf.Dialect, ext
add(kv[0], kv[1])
}
return dialect.Key("window_rule") + "=" + strings.Join(parts, ",")
return "window_rule=" + strings.Join(parts, ",")
}
type MangoWritableProvider struct {
configDir string
dialect mangoconf.Dialect
// Filled by LoadDMSRules so the following write keeps what the editor cannot show.
extraFields map[string][][2]string
onceLines []string
}
func NewMangoWritableProvider(configDir string) *MangoWritableProvider {
return &MangoWritableProvider{configDir: configDir, dialect: mangoconf.Detect()}
return &MangoWritableProvider{configDir: configDir}
}
func (p *MangoWritableProvider) Name() string { return "mango" }
@@ -360,7 +357,7 @@ func (p *MangoWritableProvider) WriteDMSRules(rules []windowrules.WindowRule) er
if id == "" {
id = fmt.Sprintf("rule_%d", i)
}
line := formatMangoRule(r, p.dialect, p.extraFields[r.ID]...)
line := formatMangoRule(r, p.extraFields[r.ID]...)
if err := mangoconf.CheckLine(line); err != nil {
return fmt.Errorf("rule %q: %w", r.Name, err)
}
@@ -369,7 +366,7 @@ func (p *MangoWritableProvider) WriteDMSRules(rules []windowrules.WindowRule) er
sb.WriteString("\n\n")
}
for _, line := range p.onceLines {
sb.WriteString(p.dialect.Translate(line))
sb.WriteString(line)
sb.WriteString("\n")
}
@@ -6,14 +6,13 @@ import (
"strings"
"testing"
"github.com/duxlabs-dev/dux-shell/core/internal/mangoconf"
"github.com/duxlabs-dev/dux-shell/core/internal/windowrules"
)
func TestParseMangoWindowRuleLineReadsBothDialects(t *testing.T) {
func TestParseMangoWindowRuleLineSplitsFields(t *testing.T) {
for _, line := range []string{
"appid:firefox,title:Gmail,isfloating:1,tags:2,monitor:HDMI-A-1",
"app_id:firefox,title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
"app_id: firefox , title:Gmail,is_floating:1,tags:2,monitor:HDMI-A-1",
} {
fields := parseMangoWindowRuleLine(line)
want := map[string]string{"app_id": "firefox", "title": "Gmail", "is_floating": "1", "tags": "2", "monitor": "HDMI-A-1"}
@@ -25,13 +24,10 @@ func TestParseMangoWindowRuleLineReadsBothDialects(t *testing.T) {
}
}
func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
func TestMangoRulesWrittenInSnakeCase(t *testing.T) {
floating := true
rule := windowrules.WindowRule{MatchCriteria: windowrules.MatchCriteria{AppID: "discord"}, Actions: windowrules.Actions{OpenFloating: &floating, NoAnim: &floating}}
if got, want := formatMangoRule(rule, mangoconf.Legacy), "windowrule=appid:discord,isfloating:1,isnoanimation:1"; got != want {
t.Errorf("legacy = %q, want %q", got, want)
}
if got, want := formatMangoRule(rule, mangoconf.Snake), "window_rule=app_id:discord,is_floating:1,no_animation:1"; got != want {
if got, want := formatMangoRule(rule), "window_rule=app_id:discord,is_floating:1,no_animation:1"; got != want {
t.Errorf("snake = %q, want %q", got, want)
}
@@ -39,7 +35,7 @@ func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
rulesPath := filepath.Join(dir, "dms", "windowrules.conf")
_ = os.MkdirAll(filepath.Dir(rulesPath), 0o755)
_ = os.WriteFile(rulesPath, []byte("# @id=a @name=A\nwindow_rule=app_id:discord,is_floating:1\n"), 0o644)
loaded, err := (&MangoWritableProvider{configDir: dir, dialect: mangoconf.Snake}).LoadDMSRules()
loaded, err := (&MangoWritableProvider{configDir: dir}).LoadDMSRules()
if err != nil || len(loaded) != 1 || loaded[0].MatchCriteria.AppID != "discord" || loaded[0].Actions.OpenFloating == nil {
t.Fatalf("snake rule not loaded: %+v, %v", loaded, err)
}
@@ -47,7 +43,7 @@ func TestMangoRulesWrittenInInstalledDialect(t *testing.T) {
func TestConvertMangoRulesToWindowRules(t *testing.T) {
mangoRules := []MangoWindowRule{
{Source: "config.conf", Fields: parseMangoWindowRuleLine("appid:discord,tags:9,isfloating:1,noblur:1")},
{Source: "config.conf", Fields: parseMangoWindowRuleLine("app_id:discord,tags:9,is_floating:1,no_blur:1")},
}
rules := ConvertMangoRulesToWindowRules(mangoRules)
if len(rules) != 1 {
@@ -141,9 +137,9 @@ func TestMangoRuleSaveKeepsUnmodeledFieldsAndOnceRules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dms", "windowrules.conf")
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte("# @id=a @name=A\nwindowrule=appid:mpv,isglobal:1,offsetx:20,width:0.5\nwindowrule-once=appid:firefox,tags:2\n"), 0o644)
_ = os.WriteFile(path, []byte("# @id=a @name=A\nwindow_rule=app_id:mpv,is_global:1,offset_x:20,width:0.5\nwindow_rule_once=app_id:firefox,tags:2\n"), 0o644)
p := &MangoWritableProvider{configDir: dir, dialect: mangoconf.Legacy}
p := &MangoWritableProvider{configDir: dir}
floating := true
if err := p.SetRule(windowrules.WindowRule{ID: "b", Name: "B", MatchCriteria: windowrules.MatchCriteria{AppID: "foot"}, Actions: windowrules.Actions{OpenFloating: &floating}}); err != nil {
t.Fatal(err)
@@ -151,9 +147,9 @@ func TestMangoRuleSaveKeepsUnmodeledFieldsAndOnceRules(t *testing.T) {
data, _ := os.ReadFile(path)
got := string(data)
for _, want := range []string{
"windowrule=appid:mpv,isglobal:1,offsetx:20,width:0.5\n",
"windowrule=appid:foot,isfloating:1\n",
"windowrule-once=appid:firefox,tags:2\n",
"window_rule=app_id:mpv,is_global:1,offset_x:20,width:0.5\n",
"window_rule=app_id:foot,is_floating:1\n",
"window_rule_once=app_id:firefox,tags:2\n",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
+4 -4
View File
@@ -420,7 +420,7 @@ function parseMangoOutputs(content) {
const result = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
const match = trimmed.match(/^(?:monitorrule|monitor_rule)\s*=\s*(.*)$/);
const match = trimmed.match(/^monitor_rule\s*=\s*(.*)$/);
if (!match)
continue;
const entry = parseMangoRule(match[1]);
@@ -445,10 +445,10 @@ function mangoRuleFields(rule) {
// Mango applies only the first matching monitor rule and resets hdr/icc/disable a rule omits,
// so unmodeled fields of every existing rule for an output are carried into DMS's rule, first one winning.
function mangoMonitorRuleLines(outputsData, existingText, snakeKeys) {
function mangoMonitorRuleLines(outputsData, existingText) {
const carried = {};
for (const line of (existingText || "").split("\n")) {
const match = line.trim().match(/^(?:monitorrule|monitor_rule)\s*=\s*(.*)$/);
const match = line.trim().match(/^monitor_rule\s*=\s*(.*)$/);
if (!match)
continue;
const fields = mangoRuleFields(match[1]);
@@ -496,7 +496,7 @@ function mangoMonitorRuleLines(outputsData, existingText, snakeKeys) {
}
if (output.enabled === false)
fields.push("disable:1");
lines.push((snakeKeys ? "monitor_rule=" : "monitorrule=") + fields.join(","));
lines.push("monitor_rule=" + fields.join(","));
}
return lines;
}
@@ -739,7 +739,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`;
}
SettingsSliderRow {
tags: ["mangowc", "mango", "gaps", "override", "inner", "gappih", "gappiv"]
tags: ["mangowc", "mango", "gaps", "override", "inner", "gap_inner_horizontal", "gap_inner_vertical", "gappih", "gappiv"]
settingKey: "mangoLayoutGapsOverride"
resetKeys: []
text: I18n.tr("Inner gaps")
@@ -752,7 +752,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`;
}
SettingsSliderRow {
tags: ["mangowc", "mango", "gaps", "override", "outer", "edge", "gappoh", "gappov"]
tags: ["mangowc", "mango", "gaps", "override", "outer", "edge", "gap_outer_horizontal", "gap_outer_vertical", "gappoh", "gappov"]
settingKey: "mangoLayoutGapsOutOverride"
text: I18n.tr("Outer gaps")
visible: SettingsData.mangoLayoutGapsOverride >= 0
@@ -772,7 +772,7 @@ awk '$1 == "xray" { print FILENAME ":" FNR; exit }' $files 2>/dev/null`;
}
SettingsToggleSliderRow {
tags: ["mangowc", "mango", "border", "override", "borderpx"]
tags: ["mangowc", "mango", "border", "override", "border_px", "borderpx"]
settingKey: "mangoLayoutBorderSizeEnabled"
text: I18n.tr("Override border size")
checked: SettingsData.mangoLayoutBorderSize >= 0
+9 -32
View File
@@ -31,9 +31,6 @@ Singleton {
readonly property string inputPath: mangoDmsDir + "/input.conf"
readonly property string windowRulesPath: mangoDmsDir + "/windowrules.conf"
// Mango after 0.17.5 renamed every config key to snake_case with no aliases.
property bool snakeKeys: false
property bool dialectReady: false
property bool inputGenerationPending: false
property bool inputGenerationRunning: false
property int _lastGapValue: -1
@@ -587,21 +584,11 @@ Singleton {
Connections {
target: CompositorService
function onIsMangoChanged() {
if (CompositorService.isMango)
detectDialect();
}
}
// Also respells the DMS fragments for the installed Mango; an older dms keeps legacy keys.
function detectDialect() {
Proc.runCommand("mango-dialect", [Proc.dmsBin, "config", "mango-migrate"], (output, exitCode) => {
if (exitCode !== 0)
log.warn("mango-migrate exited", exitCode, "- some fragments may keep the old key spelling");
root.snakeKeys = output.trim().split("\n").pop() === "snake";
root.dialectReady = true;
if (!CompositorService.isMango)
return;
generateLayoutConfig();
generateInputConfig();
});
}
}
function generateOutputsConfig(outputsData, callback, skipReload) {
@@ -612,7 +599,7 @@ Singleton {
}
// outputs.conf is sourced first, so its rules win over config.conf's; read both for carried fields.
Proc.runCommand("mango-read-outputs", ["sh", "-c", `cat "${outputsPath}" "${configPath}" 2>/dev/null`], existing => {
const lines = ["# Auto-generated by DMS - do not edit manually", ""].concat(OutputModel.mangoMonitorRuleLines(outputsData, existing, root.snakeKeys), [""]);
const lines = ["# Auto-generated by DMS - do not edit manually", ""].concat(OutputModel.mangoMonitorRuleLines(outputsData, existing), [""]);
const content = lines.join("\n");
suppressWatchedConfigReloads(1500);
@@ -646,21 +633,16 @@ Singleton {
const gapsOut = (gapsOverride >= 0 && SettingsData.mangoLayoutGapsOutOverride >= 0) ? SettingsData.mangoLayoutGapsOutOverride : gapsIn;
const borderSize = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutBorderSize >= 0) ? SettingsData.mangoLayoutBorderSize : defaultBorderSize;
const snake = root.snakeKeys;
let content = `# Auto-generated by DMS - do not edit manually
border_radius=${cornerRadius}
${snake ? "border_px" : "borderpx"}=${borderSize}
border_px=${borderSize}
`;
if (manageGaps)
content += snake ? `gap_inner_horizontal=${gapsIn}
content += `gap_inner_horizontal=${gapsIn}
gap_inner_vertical=${gapsIn}
gap_outer_horizontal=${gapsOut}
gap_outer_vertical=${gapsOut}
` : `gappih=${gapsIn}
gappiv=${gapsIn}
gappoh=${gapsOut}
gappov=${gapsOut}
`;
suppressWatchedConfigReloads(1500);
@@ -727,14 +709,9 @@ cursor_size=${size}`;
inputGenerationPending = false;
if (!CompositorService.isMango)
return;
// numlock is spelled per dialect, so the first write waits for mango-migrate
if (!dialectReady) {
inputGenerationPending = true;
return;
}
inputGenerationRunning = true;
const content = ["# Auto-generated by DMS - do not edit manually"].concat(mangoInputLines(SettingsData, snakeKeys), [""]).join("\n");
const content = ["# Auto-generated by DMS - do not edit manually"].concat(mangoInputLines(SettingsData), [""]).join("\n");
// Exit 3 = unchanged, so a shell start does not reload Mango for nothing
const script = `mkdir -p "${mangoDmsDir}" && cat > "${inputPath}.tmp" << 'EOF'\n${content}EOF\nif cmp -s "${inputPath}.tmp" "${inputPath}"; then rm -f "${inputPath}.tmp"; exit 3; fi\nmv -f "${inputPath}.tmp" "${inputPath}"`;
suppressWatchedConfigReloads(1500);
@@ -757,7 +734,7 @@ cursor_size=${size}`;
}
// Mouse and trackpad keys are the split names Mango has had since 0.16.1; libinput enums are numeric
function mangoInputLines(s, snake) {
function mangoInputLines(s) {
const flag = value => value ? 1 : 0;
const profile = name => name === "flat" ? 1 : name === "adaptive" ? 2 : -1;
const scrollMethod = name => ({
@@ -806,7 +783,7 @@ cursor_size=${size}`;
add("repeat_delay", s.keyboardRepeatDelay);
if (s.keyboardRepeatRate > 0)
add("repeat_rate", s.keyboardRepeatRate);
add(snake ? "numlock_on" : "numlockon", flag(s.keyboardNumlock));
add("numlock_on", flag(s.keyboardNumlock));
return lines;
}
}
@@ -1,4 +0,0 @@
[templates.dmsmango]
input_path = 'SHELL_DIR/matugen/templates/mango-colors-snake.conf'
output_path = 'CONFIG_DIR/mango/dms/colors.conf'
post_hook = 'sh -c "[ -n \"$MANGO_INSTANCE_SIGNATURE\" ] && mmsg dispatch reload_config || true"'
@@ -1,6 +0,0 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
border_color = 0x{{colors.outline.default.hex_stripped}}ff
focus_color = 0x{{colors.primary.default.hex_stripped}}ff
urgent_color = 0x{{colors.error.default.hex_stripped}}ff
@@ -1,6 +1,6 @@
# ! Auto-generated file. Do not edit directly.
# Remove source = ./dms/colors.conf from your config to override.
bordercolor = 0x{{colors.outline.default.hex_stripped}}ff
focuscolor = 0x{{colors.primary.default.hex_stripped}}ff
urgentcolor = 0x{{colors.error.default.hex_stripped}}ff
border_color = 0x{{colors.outline.default.hex_stripped}}ff
focus_color = 0x{{colors.primary.default.hex_stripped}}ff
urgent_color = 0x{{colors.error.default.hex_stripped}}ff
+4 -4
View File
@@ -54,7 +54,7 @@ test("repair preserves config and fragments, backs up, and adds live includes on
}
});
test("mango input fragment maps choices onto libinput numbers and spells numlock per dialect", () => {
test("mango input fragment maps choices onto libinput numbers and spells numlock in snake_case", () => {
const SPEC = loadScript(new URL("../Common/settings/SettingsSpec.js", import.meta.url)).SPEC;
const source = readFileSync(new URL("../Services/MangoService.qml", import.meta.url), "utf8");
const found = source.match(/^ function mangoInputLines\([^)]*\) \{\n[\s\S]*?^ \}/m);
@@ -62,7 +62,7 @@ test("mango input fragment maps choices onto libinput numbers and spells numlock
const scope = vm.createContext({});
vm.runInContext(found[0], scope);
const defaults = Object.fromEntries(Object.keys(SPEC).filter(key => /^(mouse|touchpad|keyboard)[A-Z]|^focusFollowsMouse$/.test(key)).map(key => [key, SPEC[key].def]));
const lines = (overrides, snake = false) => [...scope.mangoInputLines(Object.assign({}, defaults, overrides), snake)];
const lines = overrides => [...scope.mangoInputLines(Object.assign({}, defaults, overrides))];
const value = (list, key) => list.find(line => line.startsWith(key + "="))?.slice(key.length + 1);
const stock = lines({});
@@ -79,8 +79,8 @@ test("mango input fragment maps choices onto libinput numbers and spells numlock
assert.equal(value(chosen, "xkb_rules_layout"), "us,de");
assert.equal(value(chosen, "xkb_rules_variant"), undefined, "empty fields stay out");
assert.equal(value(chosen, "repeat_delay"), "300");
assert.equal(value(chosen, "numlockon"), "0");
assert.equal(value(lines({ keyboardNumlock: true }, true), "numlock_on"), "1");
assert.equal(value(chosen, "numlock_on"), "0");
assert.equal(value(lines({ keyboardNumlock: true }), "numlock_on"), "1");
assert.equal(value(stock, "sloppy_focus"), undefined, "untouched focus mode stays Mango's");
for (const mode of [0, 1])
+5 -5
View File
@@ -1,8 +1,8 @@
# Auto-generated by DMS - do not edit manually
monitorrule=name:^DP-1$,width:2560,height:1440,refresh:143.998,x:0,y:0,scale:1.25,rr:1,vrr:1
monitorrule=name:^eDP-1$,x:0,y:1440,scale:1,rr:0,vrr:0
monitorrule=name:HDMI-A-1,width:1920,height:1080,refresh:60,x:2048,y:0,scale:1,rr:5,vrr:0
monitorrule=width:1920,height:1080
monitorrule=name:^DP-2$,width:1920,height:1080,refresh:59.95,x:-1920,y:0,scale:1.333,rr:7,vrr:1
monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:143.998,x:0,y:0,scale:1.25,rr:1,vrr:1
monitor_rule=name:^eDP-1$,x:0,y:1440,scale:1,rr:0,vrr:0
monitor_rule=name:HDMI-A-1,width:1920,height:1080,refresh:60,x:2048,y:0,scale:1,rr:5,vrr:0
monitor_rule=width:1920,height:1080
monitor_rule=name:^DP-2$,width:1920,height:1080,refresh:59.95,x:-1920,y:0,scale:1.333,rr:7,vrr:1
monitor_rule=name:^DP-3$,rr:9
+7 -8
View File
@@ -79,7 +79,7 @@ test("hyprland lua parser: quoted and long-bracket strings, hdr fields, mode-les
assert.equal(model.parseHyprlandLuaMonitorLine("hl.monitor({ scale = 1 })"), null);
});
test("mango parser: both key spellings, anchors stripped, defaults filled, nameless rule skipped, out-of-range rotation is Normal", () => {
test("mango parser: anchors stripped, defaults filled, nameless rule skipped, out-of-range rotation is Normal", () => {
const parsed = plain(model.parseMangoOutputs(text("mango-monitors.conf")));
assert.deepEqual(Object.keys(parsed), ["DP-1", "eDP-1", "HDMI-A-1", "DP-2", "DP-3"]);
assert.deepEqual(parsed["DP-1"], { name: "DP-1", logical: { x: 0, y: 0, scale: 1.25, transform: "90" }, modes: [{ width: 2560, height: 1440, refresh_rate: 143998 }], current_mode: 0, vrr_enabled: true, vrr_supported: true });
@@ -306,21 +306,20 @@ test("modeAlreadyCurrent tolerates the refresh tolerance, restoreModeValue keeps
assert.equal(model.niriCurrentMode({ current_mode: 0 }), null);
});
test("mango rule writer carries hdr/icc/disable from existing rules, keeps fractional refresh, honours the dialect", () => {
test("mango rule writer carries hdr/icc/disable from existing rules, keeps fractional refresh, writes snake_case", () => {
const outputs = {
"DP-1": { configured_mode: "[email protected]", logical: { x: 0, y: 0, scale: 1, transform: "Normal" }, vrr_enabled: true, enabled: true },
"HDMI-A-1": { modes: [{ width: 1920, height: 1080, refresh_rate: 60000 }], current_mode: 0, logical: { x: 2560, y: 0, scale: 1, transform: "90" }, enabled: false }
};
const existing = [
"monitorrule=name:^DP-1$,width:2560,height:1440,refresh:60,x:0,y:0,scale:1,rr:0,vrr:0,icc:/p.icc,disable:1",
"monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:60,x:0,y:0,scale:1,rr:0,vrr:0,icc:/p.icc,disable:1",
"monitor_rule=name:DP-1,hdr:1,icc:/ignored.icc",
"monitorrule=name:^HDMI-A-1$,primary:1"
"monitor_rule=name:^HDMI-A-1$,primary:1"
].join("\n");
assert.deepEqual(model.mangoMonitorRuleLines(outputs, existing, false), [
"monitorrule=name:^DP-1$,width:2560,height:1440,refresh:59.951,x:0,y:0,scale:1,rr:0,vrr:1,icc:/p.icc,hdr:1",
"monitorrule=name:^HDMI-A-1$,width:1920,height:1080,refresh:60,x:2560,y:0,scale:1,rr:1,vrr:0,primary:1,disable:1"
assert.deepEqual(model.mangoMonitorRuleLines(outputs, existing), [
"monitor_rule=name:^DP-1$,width:2560,height:1440,refresh:59.951,x:0,y:0,scale:1,rr:0,vrr:1,icc:/p.icc,hdr:1",
"monitor_rule=name:^HDMI-A-1$,width:1920,height:1080,refresh:60,x:2560,y:0,scale:1,rr:1,vrr:0,primary:1,disable:1"
]);
assert.ok(model.mangoMonitorRuleLines(outputs, "", true).every(line => line.startsWith("monitor_rule=")));
});
test("hyprland hardwareDetails gate vrr and color modes like Hyprland does, and gate nothing when absent", () => {
@@ -12684,6 +12684,8 @@
"tabIndex": 37,
"category": "System & integrations",
"keywords": [
"gap_inner_horizontal",
"gap_inner_vertical",
"gappih",
"gappiv",
"gaps",
@@ -12951,6 +12953,8 @@
"category": "System & integrations",
"keywords": [
"edge",
"gap_outer_horizontal",
"gap_outer_vertical",
"gappoh",
"gappov",
"gaps",
@@ -12986,6 +12990,7 @@
"category": "System & integrations",
"keywords": [
"border",
"border_px",
"borderpx",
"integrations",
"mango",