1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-07 19:59:14 -04:00
Files
DankMaterialShell/core/internal/keybinds/providers/hyprland.go
T
purian23 3118e7b9c3 fix(Hyprland): correct Lua keybind writes
- Write titles as Lua description metadata
- Use hl.dispatch for custom dispatcher actions
- Preserve legacy trailing comment titles on rewrite
- Update option edits before saving keybinds
2026-05-31 15:35:13 -04:00

917 lines
26 KiB
Go

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 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 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 "killactive":
return `hl.dsp.window.kill()`, true
case "closewindow":
if params == "" {
return `hl.dsp.window.close()`, true
}
return fmt.Sprintf(`hl.dsp.window.close(%s)`, strconv.Quote(params)), true
case "killwindow":
if params == "" {
return `hl.dsp.window.kill()`, true
}
return fmt.Sprintf(`hl.dsp.window.kill(%s)`, strconv.Quote(params)), true
case "togglefloating":
return `hl.dsp.window.float({ action = "toggle" })`, true
case "setfloating":
return `hl.dsp.window.float({ action = "set" })`, true
case "settiled":
return `hl.dsp.window.float({ action = "unset" })`, 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
}
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 "pin":
if params == "" {
return `hl.dsp.window.pin()`, 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 "resizeactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.resize", params); expr != "" {
return expr, true
}
case "moveactive":
if expr := dispatcherActiveMoveResize("hl.dsp.window.move", params); expr != "" {
return expr, 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 "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 "focuscurrentorlast":
return `hl.dsp.focus({ last = true })`, true
case "focusurgentorlast":
return `hl.dsp.focus({ urgent_or_last = true })`, true
case "layoutmsg":
if params != "" {
return fmt.Sprintf(`hl.dsp.layout(%s)`, strconv.Quote(params)), 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 "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 "togglegroup":
return `hl.dsp.group.toggle()`, true
}
return "", false
}
func luaActionStringFromHyprlangAction(action string) string {
action = strings.TrimSpace(action)
if expr, ok := luaActionStringFromKnownHyprlandAction(action); ok {
return expr
}
return fmt.Sprintf(`hl.dispatch(%s)`, strconv.Quote(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
}