package providers import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) type HyprlandProvider struct { configPath string dmsBindsIncluded bool parsed bool } func NewHyprlandProvider(configPath string) *HyprlandProvider { if configPath == "" { configPath = defaultHyprlandConfigDir() } return &HyprlandProvider{ configPath: configPath, } } func defaultHyprlandConfigDir() string { configDir, err := os.UserConfigDir() if err != nil { return "" } return filepath.Join(configDir, "hypr") } func (h *HyprlandProvider) Name() string { return "hyprland" } func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { result, err := ParseHyprlandKeysWithDMS(h.configPath) if err != nil { return nil, fmt.Errorf("failed to parse hyprland config: %w", err) } h.dmsBindsIncluded = result.DMSBindsIncluded h.parsed = true categorizedBinds := make(map[string][]keybinds.Keybind) h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs, result.DefaultDMSKeys) sheet := &keybinds.CheatSheet{ Title: "Hyprland Keybinds", Provider: h.Name(), Binds: categorizedBinds, DMSBindsIncluded: result.DMSBindsIncluded, } if result.DMSStatus != nil { sheet.DMSStatus = &keybinds.DMSBindsStatus{ Exists: result.DMSStatus.Exists, Included: result.DMSStatus.Included, IncludePosition: result.DMSStatus.IncludePosition, TotalIncludes: result.DMSStatus.TotalIncludes, BindsAfterDMS: result.DMSStatus.BindsAfterDMS, Effective: result.DMSStatus.Effective, OverriddenBy: result.DMSStatus.OverriddenBy, StatusMessage: result.DMSStatus.StatusMessage, ConfigFormat: result.DMSStatus.ConfigFormat, ReadOnly: result.DMSStatus.ReadOnly, } } return sheet, nil } func (h *HyprlandProvider) HasDMSBindsIncluded() bool { if h.parsed { return h.dmsBindsIncluded } result, err := ParseHyprlandKeysWithDMS(h.configPath) if err != nil { return false } h.dmsBindsIncluded = result.DMSBindsIncluded h.parsed = true return h.dmsBindsIncluded } func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) { currentSubcat := subcategory if section.Name != "" { currentSubcat = section.Name } for _, kb := range section.Keybinds { category := h.categorizeByDispatcher(kb.Dispatcher) bind := h.convertKeybind(&kb, currentSubcat, conflicts, defaultKeys) categorizedBinds[category] = append(categorizedBinds[category], bind) } for _, child := range section.Children { h.convertSection(&child, currentSubcat, categorizedBinds, conflicts, defaultKeys) } } func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { switch { case strings.Contains(dispatcher, "workspace"): return "Workspace" case strings.Contains(dispatcher, "monitor"): return "Monitor" case strings.Contains(dispatcher, "window") || strings.Contains(dispatcher, "focus") || strings.Contains(dispatcher, "move") || strings.Contains(dispatcher, "swap") || strings.Contains(dispatcher, "resize") || dispatcher == "killactive" || dispatcher == "fullscreen" || dispatcher == "togglefloating" || dispatcher == "pin" || dispatcher == "fakefullscreen" || dispatcher == "splitratio" || dispatcher == "resizeactive": return "Window" case dispatcher == "exec": return "Execute" case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"): return "System" default: return "Other" } } func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding, defaultKeys map[string]bool) keybinds.Keybind { keyStr := h.formatKey(kb) rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) desc := kb.Comment if desc == "" { desc = rawAction } source := "config" if isDMSBindsUserOverridePath(kb.Source) { source = "dms" } else if isDMSBindsPrimarySourcePath(kb.Source) { source = "dms-default" } hasDefault := false if source == "dms" && defaultKeys != nil { hasDefault = defaultKeys[strings.ToLower(keyStr)] } bind := keybinds.Keybind{ Key: keyStr, Description: desc, Action: rawAction, Subcategory: subcategory, Source: source, Flags: kb.Flags, HasDefault: hasDefault, } if (source == "dms" || source == "dms-default") && conflicts != nil { normalizedKey := strings.ToLower(keyStr) if conflictKb, ok := conflicts[normalizedKey]; ok { bind.Conflict = &keybinds.Keybind{ Key: keyStr, Description: conflictKb.Comment, Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params), Source: "config", } } } return bind } func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { if params != "" { return dispatcher + " " + params } return dispatcher } func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string { parts := make([]string, 0, len(kb.Mods)+1) parts = append(parts, kb.Mods...) parts = append(parts, kb.Key) return strings.Join(parts, "+") } func (h *HyprlandProvider) GetOverridePath() string { expanded, err := utils.ExpandPath(h.configPath) if err != nil { return filepath.Join(h.configPath, "dms", "binds-user.lua") } return filepath.Join(expanded, "dms", "binds-user.lua") } func (h *HyprlandProvider) validateAction(action string) error { action = strings.TrimSpace(action) switch { case action == "": return fmt.Errorf("action cannot be empty") case action == "exec" || action == "exec ": return fmt.Errorf("exec dispatcher requires arguments") case strings.HasPrefix(action, "exec "): rest := strings.TrimSpace(strings.TrimPrefix(action, "exec ")) if rest == "" { return fmt.Errorf("exec dispatcher requires arguments") } } return nil } func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { if err := h.ensureWritableConfig(); err != nil { return err } if err := h.validateAction(action); err != nil { return err } overridePath := h.GetOverridePath() if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { return fmt.Errorf("failed to create dms directory: %w", err) } existingBinds, err := h.loadOverrideBinds() if err != nil { existingBinds = make(map[string]*hyprlandOverrideBind) } // Extract flags from options var flags string if options != nil { if f, ok := options["flags"].(string); ok { flags = f } } canonicalKey := canonicalHyprlandOverrideKey(key) normalizedKey := hyprlandOverrideMapKey(canonicalKey) existingBinds[normalizedKey] = &hyprlandOverrideBind{ Key: canonicalKey, Action: action, Description: description, Flags: flags, Options: options, } return h.writeOverrideBinds(existingBinds) } func (h *HyprlandProvider) RemoveBind(key string) error { if err := h.ensureWritableConfig(); err != nil { return err } existingBinds, err := h.loadOverrideBinds() if err != nil { return nil } canonicalKey := canonicalHyprlandOverrideKey(key) normalizedKey := hyprlandOverrideMapKey(canonicalKey) existingBinds[normalizedKey] = &hyprlandOverrideBind{Key: canonicalKey, Unbind: true} return h.writeOverrideBinds(existingBinds) } func (h *HyprlandProvider) ResetBind(key string) error { if err := h.ensureWritableConfig(); err != nil { return err } existingBinds, err := h.loadOverrideBinds() if err != nil { return nil } normalizedKey := hyprlandOverrideMapKey(key) delete(existingBinds, normalizedKey) return h.writeOverrideBinds(existingBinds) } type hyprlandOverrideBind struct { Key string Action string Description string Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press Options map[string]any // Unbind: negative override (hl.unbind only, no rebind). Unbind bool } func (h *HyprlandProvider) ensureWritableConfig() error { if h.isLegacyConfigReadOnly() { return fmt.Errorf("hyprland legacy conf configs are read-only; run dms setup to migrate to Lua before editing keybinds") } return nil } func (h *HyprlandProvider) isLegacyConfigReadOnly() bool { expanded, err := utils.ExpandPath(h.configPath) if err != nil { expanded = h.configPath } luaPath := filepath.Join(expanded, "hyprland.lua") if st, err := os.Stat(luaPath); err == nil && st.Mode().IsRegular() { return false } confPath := filepath.Join(expanded, "hyprland.conf") if st, err := os.Stat(confPath); err == nil && st.Mode().IsRegular() { return true } return false } func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { return readLuaOrHyprlangOverride(h.GetOverridePath()) } func canonicalHyprlandOverrideKey(key string) string { trimmed := strings.TrimSpace(key) normalized := luaKeyComboToInternalKey(trimmed) if normalized == "" { return trimmed } return normalized } func hyprlandOverrideMapKey(key string) string { return strings.ToLower(canonicalHyprlandOverrideKey(key)) } func (h *HyprlandProvider) getBindSortPriority(action string) int { switch { case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): return 0 case strings.Contains(action, "workspace"): return 1 case strings.Contains(action, "window") || strings.Contains(action, "focus") || strings.Contains(action, "move") || strings.Contains(action, "swap") || strings.Contains(action, "resize"): return 2 case strings.Contains(action, "monitor"): return 3 case strings.HasPrefix(action, "exec"): return 4 case action == "exit" || strings.Contains(action, "dpms"): return 5 default: return 6 } } func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error { overridePath := h.GetOverridePath() content := h.generateBindsContent(binds) return os.WriteFile(overridePath, []byte(content), 0o644) } func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string { if len(binds) == 0 { return "" } bindList := make([]*hyprlandOverrideBind, 0, len(binds)) for _, bind := range binds { bindList = append(bindList, bind) } sort.Slice(bindList, func(i, j int) bool { pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action) if pi != pj { return pi < pj } return bindList[i].Key < bindList[j].Key }) var sb strings.Builder sb.WriteString("-- DMS user keybind overrides (edit via Control Center or dms; do not remove this header)\n\n") for _, bind := range bindList { writeLuaBindLine(&sb, bind) } return sb.String() } func formatLuaBindKey(internalKey string) string { internalKey = strings.TrimSpace(internalKey) parts := strings.Split(internalKey, "+") for i := range parts { parts[i] = normalizeLuaBindKeyPart(strings.TrimSpace(parts[i])) } return strings.Join(parts, " + ") } func normalizeLuaBindKeyPart(part string) string { switch strings.ToLower(part) { case "super", "mod4", "mainmod": return "SUPER" case "ctrl", "control": return "CTRL" case "shift": return "SHIFT" case "alt", "mod1": return "ALT" } if len(part) == 1 { return strings.ToUpper(part) } return part } type luaField struct { name string value string } func luaDispatcherTableCall(funcName string, fields ...luaField) string { parts := make([]string, 0, len(fields)) for _, field := range fields { if field.name == "" || field.value == "" { continue } parts = append(parts, field.name+" = "+field.value) } return fmt.Sprintf(`%s({ %s })`, funcName, strings.Join(parts, ", ")) } func luaStringField(name, value string) luaField { return luaField{name: name, value: strconv.Quote(strings.TrimSpace(value))} } func luaBoolField(name string, value bool) luaField { if value { return luaField{name: name, value: "true"} } return luaField{name: name, value: "false"} } func luaNumberOrStringField(name, value string) luaField { value = strings.TrimSpace(value) if isBareLuaNumber(value) { return luaField{name: name, value: value} } return luaStringField(name, value) } func isBareLuaNumber(value string) bool { if value == "" || strings.HasPrefix(value, "+") { return false } if value[0] == '-' { value = value[1:] } if value == "" { return false } digitsBeforeDot := 0 i := 0 for i < len(value) && value[i] >= '0' && value[i] <= '9' { digitsBeforeDot++ i++ } digitsAfterDot := 0 if i < len(value) && value[i] == '.' { i++ for i < len(value) && value[i] >= '0' && value[i] <= '9' { digitsAfterDot++ i++ } } return i == len(value) && (digitsBeforeDot > 0 || digitsAfterDot > 0) } func splitHyprlandAction(action string) (dispatcher, params string) { action = strings.TrimSpace(action) if action == "" { return "", "" } idx := strings.IndexFunc(action, func(r rune) bool { return r == ' ' || r == '\t' || r == '\r' || r == '\n' }) if idx < 0 { return strings.ToLower(action), "" } return strings.ToLower(strings.TrimSpace(action[:idx])), strings.TrimSpace(action[idx+1:]) } func isKnownHyprlandDispatcher(dispatcher string) bool { switch dispatcher { case "exec", "execr", "spawn", "killactive", "forcekillactive", "closewindow", "killwindow", "signal", "signalwindow", "togglefloating", "setfloating", "settiled", "workspace", "renameworkspace", "fullscreen", "fullscreenstate", "fakefullscreen", "movetoworkspace", "movetoworkspacesilent", "pseudo", "movefocus", "movewindow", "swapwindow", "centerwindow", "togglegroup", "changegroupactive", "movegroupwindow", "focusmonitor", "movecursortocorner", "movecursor", "workspaceopt", "exit", "movecurrentworkspacetomonitor", "focusworkspaceoncurrentmonitor", "moveworkspacetomonitor", "togglespecialworkspace", "forcerendererreload", "resizeactive", "moveactive", "cyclenext", "focuswindowbyclass", "focuswindow", "tagwindow", "toggleswallow", "submap", "pass", "sendshortcut", "sendkeystate", "layoutmsg", "splitratio", "dpms", "movewindowpixel", "resizewindowpixel", "swapnext", "swapactiveworkspaces", "pin", "mouse", "bringactivetotop", "alterzorder", "focusurgentorlast", "focuscurrentorlast", "lockgroups", "lockactivegroup", "moveintogroup", "moveoutofgroup", "movewindoworgroup", "moveintoorcreategroup", "setignoregrouplock", "denywindowfromgroup", "event", "global", "setprop", "forceidle": return true default: return false } } func firstParam(params string) (head, rest string) { params = strings.TrimSpace(params) if params == "" { return "", "" } fields := strings.Fields(params) if len(fields) == 0 { return "", "" } head = fields[0] rest = strings.TrimSpace(strings.TrimPrefix(params, head)) return head, rest } func xyParams(params string) (x, y string, relative bool, ok bool) { fields := strings.Fields(params) if len(fields) > 0 && strings.EqualFold(fields[0], "exact") { relative = false fields = fields[1:] } else { relative = true } if len(fields) < 2 { return "", "", relative, false } return fields[0], fields[1], relative, true } func dispatcherWorkspaceMove(params string, follow *bool) string { workspace, window := firstParam(params) if workspace == "" { return "" } fields := []luaField{luaStringField("workspace", workspace)} if follow != nil { fields = append(fields, luaBoolField("follow", *follow)) } if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.window.move", fields...) } func dispatcherActiveMoveResize(funcName, params string) string { x, y, relative, ok := xyParams(params) if !ok { return "" } if !isBareLuaNumber(x) || !isBareLuaNumber(y) { return "" } return luaDispatcherTableCall(funcName, luaNumberOrStringField("x", x), luaNumberOrStringField("y", y), luaBoolField("relative", relative), ) } func dispatcherWindowMoveResize(funcName, params string) string { geometry, window := splitCommaParams(params) x, y, relative, ok := xyParams(geometry) if !ok { return "" } if !isBareLuaNumber(x) || !isBareLuaNumber(y) { return "" } fields := []luaField{ luaNumberOrStringField("x", x), luaNumberOrStringField("y", y), luaBoolField("relative", relative), } if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall(funcName, fields...) } func splitCommaParams(params string) (left, right string) { left = strings.TrimSpace(params) if idx := strings.Index(left, ","); idx >= 0 { right = strings.TrimSpace(left[idx+1:]) left = strings.TrimSpace(left[:idx]) } return left, right } func luaHyprctlDispatchFunction(action string) string { return fmt.Sprintf(`function() hl.exec_cmd(%s) end`, strconv.Quote("hyprctl dispatch "+strings.TrimSpace(action))) } func luaToggleActionValue(params string) string { switch strings.ToLower(strings.TrimSpace(params)) { case "on", "enable", "enabled", "set", "lock": return "on" case "off", "disable", "disabled", "unset", "unlock": return "off" default: return "toggle" } } func dispatcherToggleTableCall(funcName, params string) string { return luaDispatcherTableCall(funcName, luaStringField("action", luaToggleActionValue(params))) } func dispatcherCycleNext(params string) string { params = strings.TrimSpace(strings.ToLower(params)) if params == "" { return `hl.dsp.window.cycle_next()` } fields := []luaField{} for _, field := range strings.Fields(params) { switch field { case "prev", "previous", "b": fields = append(fields, luaBoolField("next", false)) case "next", "f": fields = append(fields, luaBoolField("next", true)) case "tiled": fields = append(fields, luaBoolField("tiled", true)) case "floating": fields = append(fields, luaBoolField("floating", true)) } } if len(fields) == 0 { return "" } return luaDispatcherTableCall("hl.dsp.window.cycle_next", fields...) } func dispatcherSwapNext(params string) string { switch strings.ToLower(strings.TrimSpace(params)) { case "prev", "previous", "b": return `hl.dsp.window.swap({ prev = true })` default: return `hl.dsp.window.swap({ next = true })` } } func dispatcherGroupActive(params string) string { switch strings.ToLower(strings.TrimSpace(params)) { case "f", "next", "forward": return `hl.dsp.group.next()` case "b", "prev", "previous", "backward": return `hl.dsp.group.prev()` } if isBareLuaNumber(params) { return luaDispatcherTableCall("hl.dsp.group.active", luaNumberOrStringField("index", params)) } return "" } func dispatcherMoveGroupWindow(params string) string { switch strings.ToLower(strings.TrimSpace(params)) { case "b", "prev", "previous", "backward": return `hl.dsp.group.move_window({ forward = false })` default: return `hl.dsp.group.move_window({ forward = true })` } } func dispatcherCursorMove(params string) string { x, y, _, ok := xyParams(params) if !ok || !isBareLuaNumber(x) || !isBareLuaNumber(y) { return "" } return luaDispatcherTableCall("hl.dsp.cursor.move", luaNumberOrStringField("x", x), luaNumberOrStringField("y", y)) } func dispatcherSignal(params string) string { signal, window := firstParam(params) if signal == "" || !isBareLuaNumber(signal) { return "" } fields := []luaField{luaNumberOrStringField("signal", signal)} if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.window.signal", fields...) } func dispatcherSignalWindow(params string) string { window, rest := firstParam(params) signal, _ := firstParam(rest) if signal == "" || !isBareLuaNumber(signal) { return "" } fields := []luaField{luaNumberOrStringField("signal", signal)} if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.window.signal", fields...) } func dispatcherTagWindow(params string) string { tag, window := firstParam(params) if tag == "" { return "" } fields := []luaField{luaStringField("tag", tag)} if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.window.tag", fields...) } func luaActionStringFromKnownHyprlandAction(action string) (string, bool) { dispatcher, params := splitHyprlandAction(action) switch dispatcher { case "spawn", "exec": return fmt.Sprintf(`hl.dsp.exec_cmd(%s)`, strconv.Quote(params)), true case "execr": return fmt.Sprintf(`hl.dsp.exec_raw(%s)`, strconv.Quote(params)), true case "killactive": return `hl.dsp.window.close()`, true case "forcekillactive": return `hl.dsp.window.kill()`, true case "closewindow": if params == "" { return `hl.dsp.window.close()`, true } return luaDispatcherTableCall("hl.dsp.window.close", luaStringField("window", params)), true case "killwindow": if params == "" { return `hl.dsp.window.kill()`, true } return luaDispatcherTableCall("hl.dsp.window.kill", luaStringField("window", params)), true case "togglefloating": return dispatcherToggleTableCall("hl.dsp.window.float", "toggle"), true case "setfloating": return dispatcherToggleTableCall("hl.dsp.window.float", "on"), true case "settiled": return dispatcherToggleTableCall("hl.dsp.window.float", "off"), true case "fullscreen": mode := strings.TrimSpace(params) switch mode { case "", "0": return `hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })`, true case "1": return `hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" })`, true } return luaHyprctlDispatchFunction(action), true case "fullscreenstate": internal, rest := firstParam(params) client, _ := firstParam(rest) if internal != "" && client != "" { return luaDispatcherTableCall("hl.dsp.window.fullscreen_state", luaNumberOrStringField("internal", internal), luaNumberOrStringField("client", client), ), true } case "fakefullscreen": return luaHyprctlDispatchFunction(action), true case "pin": if params == "" { return `hl.dsp.window.pin()`, true } return dispatcherToggleTableCall("hl.dsp.window.pin", params), true case "pseudo": return dispatcherToggleTableCall("hl.dsp.window.pseudo", params), true case "centerwindow": return `hl.dsp.window.center()`, true case "resizewindow": return `hl.dsp.window.resize()`, true case "movewindow": if params == "" { return `hl.dsp.window.drag()`, true } if monitor, ok := strings.CutPrefix(params, "mon:"); ok { return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("monitor", monitor)), true } return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params)), true case "swapwindow": if params == "" { return "", false } return luaDispatcherTableCall("hl.dsp.window.swap", luaStringField("direction", params)), true case "swapnext": return dispatcherSwapNext(params), true case "resizeactive": if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" { return expr, true } return luaHyprctlDispatchFunction(action), true case "moveactive": if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" { return expr, true } return luaHyprctlDispatchFunction(action), true case "resizewindowpixel": if expr := dispatcherWindowMoveResize("hl.dsp.window.resize", params); expr != "" { return expr, true } return luaHyprctlDispatchFunction(action), true case "movewindowpixel": if expr := dispatcherWindowMoveResize("hl.dsp.window.move", params); expr != "" { return expr, true } return luaHyprctlDispatchFunction(action), true case "workspace": if params == "" { return "", false } return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params)), true case "focusworkspaceoncurrentmonitor": if params == "" { return "", false } return luaDispatcherTableCall("hl.dsp.focus", luaStringField("workspace", params), luaBoolField("on_current_monitor", true)), true case "movetoworkspace": if expr := dispatcherWorkspaceMove(params, nil); expr != "" { return expr, true } case "movetoworkspacesilent": follow := false if expr := dispatcherWorkspaceMove(params, &follow); expr != "" { return expr, true } case "togglespecialworkspace": if params == "" { return `hl.dsp.workspace.toggle_special()`, true } return fmt.Sprintf(`hl.dsp.workspace.toggle_special(%s)`, strconv.Quote(params)), true case "renameworkspace": workspace, name := firstParam(params) if workspace != "" { fields := []luaField{luaStringField("workspace", workspace)} if name != "" { fields = append(fields, luaStringField("name", name)) } return luaDispatcherTableCall("hl.dsp.workspace.rename", fields...), true } case "movecurrentworkspacetomonitor": if params != "" { return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("monitor", params)), true } case "moveworkspacetomonitor": workspace, monitor := firstParam(params) if workspace != "" && monitor != "" { return luaDispatcherTableCall("hl.dsp.workspace.move", luaStringField("workspace", workspace), luaStringField("monitor", monitor)), true } case "workspaceopt": return luaHyprctlDispatchFunction(action), true case "swapactiveworkspaces": monitor1, rest := firstParam(params) monitor2, _ := firstParam(rest) if monitor1 != "" && monitor2 != "" { return luaDispatcherTableCall("hl.dsp.workspace.swap_monitors", luaStringField("monitor1", monitor1), luaStringField("monitor2", monitor2)), true } case "movefocus": if params != "" { return luaDispatcherTableCall("hl.dsp.focus", luaStringField("direction", params)), true } case "focusmonitor": if params != "" { return luaDispatcherTableCall("hl.dsp.focus", luaStringField("monitor", params)), true } case "focuswindow": if params != "" { return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", params)), true } case "focuswindowbyclass": if params != "" { return luaDispatcherTableCall("hl.dsp.focus", luaStringField("window", "class:"+params)), true } case "focuscurrentorlast": return `hl.dsp.focus({ last = true })`, true case "focusurgentorlast": return `hl.dsp.focus({ urgent_or_last = true })`, true case "cyclenext": if expr := dispatcherCycleNext(params); expr != "" { return expr, true } return luaHyprctlDispatchFunction(action), true case "layoutmsg": if params != "" { return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), true } case "splitratio": return luaHyprctlDispatchFunction(action), true case "alterzorder": mode, window := firstParam(params) if mode != "" { fields := []luaField{luaStringField("mode", mode)} if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.window.alter_zorder", fields...), true } case "setprop": window, rest := firstParam(params) prop, value := firstParam(rest) if window != "" && prop != "" && value != "" { return luaDispatcherTableCall("hl.dsp.window.set_prop", luaStringField("window", window), luaStringField("prop", prop), luaStringField("value", value), ), true } case "bringactivetotop": return `hl.dsp.window.bring_to_top()`, true case "toggleswallow": return `hl.dsp.window.toggle_swallow()`, true case "signal": if expr := dispatcherSignal(params); expr != "" { return expr, true } case "signalwindow": if expr := dispatcherSignalWindow(params); expr != "" { return expr, true } case "tagwindow": if expr := dispatcherTagWindow(params); expr != "" { return expr, true } case "dpms": dpmsAction := strings.TrimSpace(params) switch dpmsAction { case "on": dpmsAction = "enable" case "off": dpmsAction = "disable" } if dpmsAction == "" { return `hl.dsp.dpms({})`, true } return luaDispatcherTableCall("hl.dsp.dpms", luaStringField("action", dpmsAction)), true case "exit": return `hl.dsp.exit()`, true case "submap": return fmt.Sprintf(`hl.dsp.submap(%s)`, strconv.Quote(params)), true case "global": return fmt.Sprintf(`hl.dsp.global(%s)`, strconv.Quote(params)), true case "event": return fmt.Sprintf(`hl.dsp.event(%s)`, strconv.Quote(params)), true case "pass": if params == "" { return `hl.dsp.pass({})`, true } return luaDispatcherTableCall("hl.dsp.pass", luaStringField("window", params)), true case "sendshortcut": mod, rest := firstParam(params) key, window := firstParam(rest) if mod != "" && key != "" { fields := []luaField{luaStringField("mods", mod), luaStringField("key", key)} if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.send_shortcut", fields...), true } case "sendkeystate": mod, rest := firstParam(params) key, rest := firstParam(rest) state, window := firstParam(rest) if mod != "" && key != "" && state != "" { fields := []luaField{luaStringField("mods", mod), luaStringField("key", key), luaStringField("state", state)} if window != "" { fields = append(fields, luaStringField("window", window)) } return luaDispatcherTableCall("hl.dsp.send_key_state", fields...), true } case "movecursortocorner": if params != "" && isBareLuaNumber(params) { return luaDispatcherTableCall("hl.dsp.cursor.move_to_corner", luaNumberOrStringField("corner", params)), true } case "movecursor": if expr := dispatcherCursorMove(params); expr != "" { return expr, true } case "togglegroup": return `hl.dsp.group.toggle()`, true case "changegroupactive": if expr := dispatcherGroupActive(params); expr != "" { return expr, true } return luaHyprctlDispatchFunction(action), true case "movegroupwindow": return dispatcherMoveGroupWindow(params), true case "moveintogroup": if params != "" { return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_group", params)), true } case "moveintoorcreategroup": if params != "" { return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("into_or_create_group", params)), true } case "moveoutofgroup": if params != "" { return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("out_of_group", params)), true } return luaDispatcherTableCall("hl.dsp.window.move", luaBoolField("out_of_group", true)), true case "movewindoworgroup": if params != "" { return luaDispatcherTableCall("hl.dsp.window.move", luaStringField("direction", params), luaBoolField("group_aware", true)), true } case "lockgroups": return dispatcherToggleTableCall("hl.dsp.group.lock", params), true case "lockactivegroup": return dispatcherToggleTableCall("hl.dsp.group.lock_active", params), true case "denywindowfromgroup": return dispatcherToggleTableCall("hl.dsp.window.deny_from_group", params), true case "setignoregrouplock": return luaHyprctlDispatchFunction(action), true case "forcerendererreload": return `hl.dsp.force_renderer_reload()`, true case "forceidle": if params != "" && isBareLuaNumber(params) { return fmt.Sprintf(`hl.dsp.force_idle(%s)`, params), true } } if isKnownHyprlandDispatcher(dispatcher) { return luaHyprctlDispatchFunction(action), true } return "", false } func luaActionStringFromHyprlangAction(action string) string { action = strings.TrimSpace(action) if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok { return expr } return action } func luaExprToInternalAction(expr string) string { d, p := luaExprToDispatcherParams(expr) if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") { return "exec " + p } if p != "" { return d + " " + p } return d } func luaBindOptions(bind *hyprlandOverrideBind) []string { var opts []string if strings.Contains(bind.Flags, "l") { opts = append(opts, "locked = true") } if strings.Contains(bind.Flags, "e") { opts = append(opts, "repeating = true") } if bind.Description != "" { opts = append(opts, fmt.Sprintf("description = %s", strconv.Quote(bind.Description))) } return opts } func writeLuaBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { key := formatLuaBindKey(bind.Key) if bind.Unbind { fmt.Fprintf(sb, `hl.unbind("%s")`, key) sb.WriteByte('\n') return } expr := luaActionStringFromHyprlangAction(bind.Action) opts := luaBindOptions(bind) fmt.Fprintf(sb, `hl.unbind("%s")`, key) sb.WriteByte('\n') if len(opts) > 0 { fmt.Fprintf(sb, `hl.bind("%s", %s, { %s })`, key, expr, strings.Join(opts, ", ")) } else { fmt.Fprintf(sb, `hl.bind("%s", %s)`, key, expr) } sb.WriteByte('\n') } func parseLuaBindOverrideLine(line string) (*hyprlandOverrideBind, bool) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "--") { return nil, false } kbc, actionExpr, optSuffix, ok := parseLuaBindInvocation(line) if !ok { return nil, false } internalKey := luaKeyComboToInternalKey(kbc) action := luaExprToInternalAction(actionExpr) flags := luaBindOptFlags(optSuffix) description := luaBindOptDescription(optSuffix) if description == "" { description = luaLineTrailingComment(line) } return &hyprlandOverrideBind{ Key: internalKey, Action: action, Description: description, Flags: flags, }, true } func parseLuaUnbindLine(line string) (string, bool) { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "hl.unbind") { return "", false } rest := strings.TrimSpace(line[len("hl.unbind"):]) if !strings.HasPrefix(rest, "(") { return "", false } rest = rest[1:] combo, _, ok := parseLuaStringLiteral(rest, 0) if !ok { return "", false } return luaKeyComboToInternalKey(combo), true } func luaKeyComboToInternalKey(combo string) string { parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(combo, "+", " "), " ", " ")) return strings.Join(parts, "+") } func readLuaOrHyprlangOverride(path string) (map[string]*hyprlandOverrideBind, error) { binds := make(map[string]*hyprlandOverrideBind) data, err := os.ReadFile(path) if os.IsNotExist(err) { return binds, nil } if err != nil { return nil, err } lines := strings.Split(string(data), "\n") parser := NewHyprlandParser("") pendingUnbinds := make(map[string]string) for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "--") { continue } if key, ok := parseLuaUnbindLine(line); ok { pendingUnbinds[hyprlandOverrideMapKey(key)] = canonicalHyprlandOverrideKey(key) continue } if kb, ok := parseLuaBindOverrideLine(line); ok { kb.Key = canonicalHyprlandOverrideKey(kb.Key) normalizedKey := hyprlandOverrideMapKey(kb.Key) binds[normalizedKey] = kb delete(pendingUnbinds, normalizedKey) continue } if !strings.HasPrefix(line, "bind") { continue } kb := parser.parseBindLine(line) if kb == nil { continue } keyStr := parser.formatBindKey(kb) action := kb.Dispatcher if kb.Params != "" { action = kb.Dispatcher + " " + kb.Params } flags := kb.Flags keyStr = canonicalHyprlandOverrideKey(keyStr) normalizedKey := hyprlandOverrideMapKey(keyStr) binds[normalizedKey] = &hyprlandOverrideBind{ Key: keyStr, Action: action, Description: kb.Comment, Flags: flags, } delete(pendingUnbinds, normalizedKey) } for normKey, origKey := range pendingUnbinds { binds[normKey] = &hyprlandOverrideBind{Key: origKey, Unbind: true} } return binds, nil }