hyprland: add binds command to display keybindings

parent 0888c0d2
package hyprland
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"unicode"
"unicode/utf8"
"ximperconf/config"
"ximperconf/locale"
"ximperconf/ui"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
type HyprBind struct {
Mouse bool `json:"mouse"`
HasDescription bool `json:"has_description"`
Modmask int `json:"modmask"`
Submap string `json:"submap"`
Key string `json:"key"`
Description string `json:"description"`
Dispatcher string `json:"dispatcher"`
Arg string `json:"arg"`
}
type bindEntry struct {
Submap string
Key string
Action string
Dispatcher string
Arg string
IsDescription bool
}
type BindJSON struct {
Submap string `json:"submap"`
Key string `json:"key"`
Dispatcher string `json:"dispatcher"`
Arg string `json:"arg"`
Description string `json:"description"`
}
func (m *HyprlandManager) GetBinds() ([]HyprBind, error) {
out, err := exec.Command("hyprctl", "binds", "-j").Output()
if err != nil {
return nil, fmt.Errorf(locale.T("failed to get binds: %s"), err)
}
var binds []HyprBind
if err := json.Unmarshal(out, &binds); err != nil {
return nil, fmt.Errorf(locale.T("failed to parse binds: %s"), err)
}
return binds, nil
}
const (
modShift = 1
modCtrl = 4
modAlt = 8
modSuper = 64
)
func formatModmask(mask int) string {
var mods []string
if mask&modSuper != 0 {
mods = append(mods, "SUPER")
}
if mask&modAlt != 0 {
mods = append(mods, "ALT")
}
if mask&modCtrl != 0 {
mods = append(mods, "CTRL")
}
if mask&modShift != 0 {
mods = append(mods, "SHIFT")
}
return strings.Join(mods, " + ")
}
var mouseButtonNames = map[string]string{
"mouse:272": "ЛКМ",
"mouse:273": "ПКМ",
"mouse:274": "СКМ",
}
func formatBindKey(mods string, key string) string {
if name, ok := mouseButtonNames[key]; ok {
key = name
} else {
key = strings.ToUpper(key)
}
if mods == "" {
return key
}
return mods + " + " + key
}
func formatAction(b HyprBind) (string, bool) {
if b.HasDescription && b.Description != "" {
return b.Description, true
}
if b.Arg != "" {
return b.Dispatcher + " " + b.Arg, false
}
return b.Dispatcher, false
}
type groupKey struct {
Modmask int
Submap string
Dispatcher string
}
func isDigitKey(key string) bool {
return len(key) == 1 && unicode.IsDigit(rune(key[0]))
}
func replaceFirstDigitSeq(s string, digit string) string {
idx := strings.Index(s, digit)
if idx < 0 {
return s
}
start := idx
for start > 0 && unicode.IsDigit(rune(s[start-1])) {
start--
}
end := idx + len(digit)
for end < len(s) && unicode.IsDigit(rune(s[end])) {
end++
}
return s[:start] + "<NUMBER>" + s[end:]
}
func bindToEntry(b HyprBind) bindEntry {
mods := formatModmask(b.Modmask)
action, isDesc := formatAction(b)
return bindEntry{
Submap: b.Submap,
Key: formatBindKey(mods, b.Key),
Action: action,
Dispatcher: b.Dispatcher,
Arg: b.Arg,
IsDescription: isDesc,
}
}
func isXF86Key(key string) bool {
return strings.HasPrefix(strings.ToUpper(key), "XF86")
}
func deduplicateBinds(binds []HyprBind) []HyprBind {
type dedupKey struct {
Modmask int
Submap string
Key string
Description string
}
seen := make(map[dedupKey]bool)
var result []HyprBind
for _, b := range binds {
if !b.HasDescription || b.Description == "" {
result = append(result, b)
continue
}
dk := dedupKey{b.Modmask, b.Submap, b.Key, b.Description}
if !seen[dk] {
seen[dk] = true
result = append(result, b)
}
}
return result
}
func groupBinds(binds []HyprBind, showAll bool) []bindEntry {
binds = deduplicateBinds(binds)
groups := make(map[groupKey][]HyprBind)
var order []groupKey
var nonDigit []HyprBind
for _, b := range binds {
if !showAll && isXF86Key(b.Key) {
continue
}
if !isDigitKey(b.Key) {
nonDigit = append(nonDigit, b)
continue
}
gk := groupKey{b.Modmask, b.Submap, b.Dispatcher}
if _, ok := groups[gk]; !ok {
order = append(order, gk)
}
groups[gk] = append(groups[gk], b)
}
var entries []bindEntry
for _, gk := range order {
group := groups[gk]
if len(group) >= 3 {
mods := formatModmask(gk.Modmask)
key := formatBindKey(mods, "<NUMBER>")
var action string
isDesc := false
for _, b := range group {
if b.HasDescription && b.Description != "" {
action = replaceFirstDigitSeq(b.Description, b.Key)
isDesc = true
break
}
}
if action == "" {
sample := group[0]
if sample.Arg != "" {
action = sample.Dispatcher + " " + replaceFirstDigitSeq(sample.Arg, sample.Key)
} else {
action = sample.Dispatcher
}
}
entries = append(entries, bindEntry{
Submap: gk.Submap,
Key: key,
Action: action,
Dispatcher: gk.Dispatcher,
Arg: "<NUMBER>",
IsDescription: isDesc,
})
} else {
for _, b := range group {
entries = append(entries, bindToEntry(b))
}
}
}
for _, b := range nonDigit {
entries = append(entries, bindToEntry(b))
}
return mergeByAction(entries)
}
func mergeByAction(entries []bindEntry) []bindEntry {
type actionKey struct {
Submap string
Dispatcher string
Arg string
}
seen := make(map[actionKey]int)
var result []bindEntry
for _, e := range entries {
ak := actionKey{e.Submap, e.Dispatcher, e.Arg}
if idx, ok := seen[ak]; ok {
result[idx].Key += " / " + e.Key
if e.IsDescription && !result[idx].IsDescription {
result[idx].Action = e.Action
result[idx].IsDescription = true
}
} else {
seen[ak] = len(result)
result = append(result, e)
}
}
return result
}
// Форматирование с цветами
var (
cMod = color.New(color.FgCyan).SprintFunc()
cKey = color.New(color.Bold).SprintFunc()
cPlus = color.New(color.Faint).SprintFunc()
cDesc = color.New(color.FgGreen).SprintFunc()
cDisp = color.New(color.FgYellow).SprintFunc()
cHeader = color.New(color.FgCyan, color.Bold).SprintFunc()
cLine = color.New(color.Faint).SprintFunc()
)
func colorizeKey(key string) string {
// Обработка объединённых ключей через " / "
variants := strings.Split(key, " / ")
colored := make([]string, len(variants))
for i, v := range variants {
colored[i] = colorizeSingleKey(v)
}
return strings.Join(colored, cPlus(" / "))
}
var modNames = map[string]bool{"SUPER": true, "ALT": true, "CTRL": true, "SHIFT": true}
func colorizeSingleKey(key string) string {
parts := strings.Split(key, " + ")
colored := make([]string, len(parts))
for i, p := range parts {
if modNames[p] {
colored[i] = cMod(p)
} else {
colored[i] = cKey(p)
}
}
return strings.Join(colored, cPlus(" + "))
}
func colorizeAction(action string, isDesc bool) string {
if isDesc {
return cDesc(action)
}
parts := strings.SplitN(action, " ", 2)
disp := cDisp(parts[0])
if len(parts) > 1 {
return disp + " " + parts[1]
}
return disp
}
func formatHeader(title string) string {
line := "── " + cHeader(title) + " "
pad := 50 - 4 - utf8.RuneCountInString(title)
if pad > 0 {
line += cLine(strings.Repeat("─", pad))
}
return line
}
func HyprlandBindsCommand(ctx context.Context, cmd *cli.Command) error {
manager, err := GetHyprlandManager(ctx)
if err != nil {
return err
}
binds, err := manager.GetBinds()
if err != nil {
return err
}
entries := groupBinds(binds, cmd.Bool("all"))
if config.IsJSON(cmd) {
jsonItems := make([]BindJSON, len(entries))
for i, e := range entries {
jsonItems[i] = BindJSON{
Submap: e.Submap,
Key: e.Key,
Dispatcher: e.Dispatcher,
Arg: e.Arg,
}
if e.IsDescription {
jsonItems[i].Description = e.Action
}
}
return ui.PrintJSON(jsonItems)
}
// Группируем по submap
type submapData struct {
name string
entries []bindEntry
}
var submaps []submapData
submapIdx := make(map[string]int)
for _, e := range entries {
if idx, ok := submapIdx[e.Submap]; ok {
submaps[idx].entries = append(submaps[idx].entries, e)
} else {
submapIdx[e.Submap] = len(submaps)
submaps = append(submaps, submapData{name: e.Submap, entries: []bindEntry{e}})
}
}
for i, sg := range submaps {
if i > 0 {
fmt.Println()
}
title := "DEFAULT"
if sg.name != "" {
title = strings.ToUpper(sg.name)
}
fmt.Println(formatHeader(title))
maxKeyLen := 0
for _, e := range sg.entries {
if n := utf8.RuneCountInString(e.Key); n > maxKeyLen {
maxKeyLen = n
}
}
for _, e := range sg.entries {
padding := maxKeyLen - utf8.RuneCountInString(e.Key)
fmt.Printf(" %s%*s %s\n",
colorizeKey(e.Key),
padding, "",
colorizeAction(e.Action, e.IsDescription),
)
}
}
return nil
}
...@@ -49,6 +49,19 @@ func CommandList() *cli.Command { ...@@ -49,6 +49,19 @@ func CommandList() *cli.Command {
Action: HyprlandFixConfigCommand, Action: HyprlandFixConfigCommand,
}, },
{ {
Name: "binds",
Usage: locale.T("Show keybindings"),
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "all",
Usage: locale.T("Show all binds including hardware keys (XF86)"),
Aliases: []string{"a"},
},
config.FormatFlag,
},
Action: HyprlandBindsCommand,
},
{
Name: "sync-xkb-layouts", Name: "sync-xkb-layouts",
Usage: locale.T("Sync layouts with xkb"), Usage: locale.T("Sync layouts with xkb"),
Action: HyprlandSyncSystemLayouts, Action: HyprlandSyncSystemLayouts,
......
...@@ -2,7 +2,7 @@ package ui ...@@ -2,7 +2,7 @@ package ui
import ( import (
"encoding/json" "encoding/json"
"fmt" "os"
) )
type JSONItem struct { type JSONItem struct {
...@@ -25,10 +25,8 @@ func TreeItemsToJSON(items []TreeItem) []JSONItem { ...@@ -25,10 +25,8 @@ func TreeItemsToJSON(items []TreeItem) []JSONItem {
} }
func PrintJSON(data interface{}) error { func PrintJSON(data interface{}) error {
output, err := json.MarshalIndent(data, "", " ") enc := json.NewEncoder(os.Stdout)
if err != nil { enc.SetIndent("", " ")
return err enc.SetEscapeHTML(false)
} return enc.Encode(data)
fmt.Println(string(output))
return nil
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment