1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-01 19:18:28 -04:00
Files
DankMaterialShell/core/internal/keybinds/providers/hyprland.go
T
2026-07-30 17:08:59 -04:00

1261 lines
37 KiB
Go

package providers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"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 {
key := kb.Key
if canonical, ok := hyprlandScrollToCanonical(key); ok {
key = canonical
}
parts := make([]string, 0, len(kb.Mods)+1)
parts = append(parts, kb.Mods...)
parts = append(parts, 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
}
var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`)
// isRawLuaActionText reports that action is a Lua expression to re-emit
// verbatim rather than freeform dispatcher text to wrap for hyprctl. The
// balance check keeps malformed input from corrupting the generated file.
func isRawLuaActionText(action string) bool {
if !luaExprActionPattern.MatchString(action) {
return false
}
depth := 0
var quote byte
escaped := false
for i := 0; i < len(action); i++ {
c := action[i]
switch {
case escaped:
escaped = false
case quote != 0:
switch c {
case '\\':
escaped = true
case quote:
quote = 0
}
case c == '"' || c == '\'':
quote = c
case c == '(':
depth++
case c == ')':
depth--
if depth < 0 {
return false
}
}
}
return depth == 0 && quote == 0 && !escaped
}
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,
RawLuaAction: isRawLuaActionText(action),
}
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
// RawLuaAction: Action is a custom hl.* Lua expression round-tripped from an
// existing Lua override; re-emit it verbatim instead of quoting it.
RawLuaAction 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 native, ok := hyprlandScrollToNative(part); ok {
return native
}
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
}
// Unrecognized dispatchers are freeform text, not Lua; forward them to
// hyprctl quoted so a stray `"` can't produce broken Lua output.
return luaHyprctlDispatchFunction(action)
}
// luaExprToInternalAction converts a parsed Lua bind expression back into
// "dispatcher params" text. isRawLua reports that expr matched no known hl.*
// shape and must be re-emitted verbatim as Lua on write-back.
func luaExprToInternalAction(expr string) (action string, isRawLua bool) {
d, p := luaExprToDispatcherParams(expr)
if d == expr && p == "" {
return expr, true
}
if d == "exec" && p != "" && !strings.HasPrefix(p, "hyprctl dispatch lua:") {
return "exec " + p, false
}
if p != "" {
return d + " " + p, false
}
return d, false
}
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 := strconv.Quote(formatLuaBindKey(bind.Key))
if bind.Unbind {
fmt.Fprintf(sb, `hl.unbind(%s)`, key)
sb.WriteByte('\n')
return
}
var expr string
if bind.RawLuaAction {
expr = bind.Action
} else {
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, isRawLua := luaExprToInternalAction(actionExpr)
flags := luaBindOptFlags(optSuffix)
description := luaBindOptDescription(optSuffix)
if description == "" {
description = luaLineTrailingComment(line)
}
return &hyprlandOverrideBind{
Key: internalKey,
Action: action,
Description: description,
Flags: flags,
RawLuaAction: isRawLua,
}, 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, "+", " "), " ", " "))
for i, part := range parts {
if canonical, ok := hyprlandScrollToCanonical(part); ok {
parts[i] = canonical
}
}
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 := expandLuaConfigLines(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
}