detached e368cc0

feat(config): add hint mode keys

commit
e368cc0eff0c2ae55984613dd1118b10ab106392
author
brice · · 2026-10-19 16:03 UTC
committer
brice · · 2026-10-19 17:36 UTC
parent
2082615
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +166 -0
M internal/adapters/config/config.go +74 -0
@@ -114,6 +114,7 @@ var actions = map[string]bool{
114114 "move-workspace-prev": true,
115115 "move-workspace-next": true,
116116 "toggle-floating": true,
117+ "hint": true,
117118 }
118119 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119120 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
@@ -163,6 +164,9 @@ func Defaults() ports.Config {
163164 c.Terminal.Command = []string{"foot"}
164165 }
165166 c.Terminal.AutoOpen = "first"
167+ // Hint mode is experimental: off unless the config turns it on.
168+ c.Hint.Enable = false
169+ c.Hint.TextClick = ports.ModCtrl | ports.ModShift
166170 c.Xwayland = "xwayland-satellite"
167171 c.Background.Color = "#111111"
168172 c.Floating.Dim = 0.3
@@ -847,6 +851,46 @@ func set(c *ports.Config, key, v string) error {
847851 return err
848852 }
849853 c.Focus.Flash = b
854+ case "hint.enable":
855+ b, err := onOff(v)
856+ if err != nil {
857+ return err
858+ }
859+ c.Hint.Enable = b
860+ case "hint.size":
861+ if v == "auto" {
862+ c.Hint.Size = 0
863+ break
864+ }
865+ n, err := strconv.ParseFloat(v, 64)
866+ if err != nil || math.IsNaN(n) || n < 6 || n > 72 {
867+ return fmt.Errorf("must be auto or a number from 6 to 72 (logical pixels)")
868+ }
869+ c.Hint.Size = n
870+ case "hint.background", "hint.foreground":
871+ if v != "" && !color.MatchString(v) {
872+ return fmt.Errorf("must be #rrggbb or empty (derived)")
873+ }
874+ if key == "hint.background" {
875+ c.Hint.Background = v
876+ } else {
877+ c.Hint.Foreground = v
878+ }
879+ case "hint.targets":
880+ switch v {
881+ case "clickable":
882+ c.Hint.LinksOnly = false
883+ case "links":
884+ c.Hint.LinksOnly = true
885+ default:
886+ return fmt.Errorf("must be clickable or links")
887+ }
888+ case "hint.text-click":
889+ m, err := parseTextClick(v)
890+ if err != nil {
891+ return err
892+ }
893+ c.Hint.TextClick = m
850894 case "focus.flash-width":
851895 n, err := strconv.Atoi(v)
852896 if err != nil || n < 1 || n > 8 {
@@ -1270,6 +1314,36 @@ func checkAction(v string) error {
12701314 return nil
12711315 }
12721316
1317+// parseTextClick parses hint.text-click: `+`-separated modifier names as in
1318+// binds, or none. cmd is an alias of super here (it is not resolved through
1319+// keyboard.cmd, so the result does not depend on key order).
1320+func parseTextClick(v string) (ports.Mods, error) {
1321+ if strings.ToLower(v) == "none" {
1322+ return 0, nil
1323+ }
1324+ var mods ports.Mods
1325+ for _, p := range strings.Split(v, "+") {
1326+ var m ports.Mods
1327+ switch strings.ToLower(strings.TrimSpace(p)) {
1328+ case "shift":
1329+ m = ports.ModShift
1330+ case "ctrl":
1331+ m = ports.ModCtrl
1332+ case "alt":
1333+ m = ports.ModAlt
1334+ case "super", "cmd":
1335+ m = ports.ModSuper
1336+ default:
1337+ return 0, fmt.Errorf("unknown modifier %q (use shift, ctrl, alt, super or cmd joined by +, or none)", p)
1338+ }
1339+ if mods&m != 0 {
1340+ return 0, fmt.Errorf("duplicate modifier %q", p)
1341+ }
1342+ mods |= m
1343+ }
1344+ return mods, nil
1345+}
1346+
12731347 // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
12741348 // modifiers sorted, the key as an xkb keysym name.
12751349 func parseCombo(s string) (string, error) {
@@ -114,6 +114,7 @@ var actions = map[string]bool{
114 "move-workspace-prev": true,114 "move-workspace-prev": true,
115 "move-workspace-next": true,115 "move-workspace-next": true,
116 "toggle-floating": true,116 "toggle-floating": true,
117+ "hint": true,
117 }118 }
118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}119 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)120 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
@@ -163,6 +164,9 @@ func Defaults() ports.Config {
163 c.Terminal.Command = []string{"foot"}164 c.Terminal.Command = []string{"foot"}
164 }165 }
165 c.Terminal.AutoOpen = "first"166 c.Terminal.AutoOpen = "first"
167+ // Hint mode is experimental: off unless the config turns it on.
168+ c.Hint.Enable = false
169+ c.Hint.TextClick = ports.ModCtrl | ports.ModShift
166 c.Xwayland = "xwayland-satellite"170 c.Xwayland = "xwayland-satellite"
167 c.Background.Color = "#111111"171 c.Background.Color = "#111111"
168 c.Floating.Dim = 0.3172 c.Floating.Dim = 0.3
@@ -847,6 +851,46 @@ func set(c *ports.Config, key, v string) error {
847 return err851 return err
848 }852 }
849 c.Focus.Flash = b853 c.Focus.Flash = b
854+ case "hint.enable":
855+ b, err := onOff(v)
856+ if err != nil {
857+ return err
858+ }
859+ c.Hint.Enable = b
860+ case "hint.size":
861+ if v == "auto" {
862+ c.Hint.Size = 0
863+ break
864+ }
865+ n, err := strconv.ParseFloat(v, 64)
866+ if err != nil || math.IsNaN(n) || n < 6 || n > 72 {
867+ return fmt.Errorf("must be auto or a number from 6 to 72 (logical pixels)")
868+ }
869+ c.Hint.Size = n
870+ case "hint.background", "hint.foreground":
871+ if v != "" && !color.MatchString(v) {
872+ return fmt.Errorf("must be #rrggbb or empty (derived)")
873+ }
874+ if key == "hint.background" {
875+ c.Hint.Background = v
876+ } else {
877+ c.Hint.Foreground = v
878+ }
879+ case "hint.targets":
880+ switch v {
881+ case "clickable":
882+ c.Hint.LinksOnly = false
883+ case "links":
884+ c.Hint.LinksOnly = true
885+ default:
886+ return fmt.Errorf("must be clickable or links")
887+ }
888+ case "hint.text-click":
889+ m, err := parseTextClick(v)
890+ if err != nil {
891+ return err
892+ }
893+ c.Hint.TextClick = m
850 case "focus.flash-width":894 case "focus.flash-width":
851 n, err := strconv.Atoi(v)895 n, err := strconv.Atoi(v)
852 if err != nil || n < 1 || n > 8 {896 if err != nil || n < 1 || n > 8 {
@@ -1270,6 +1314,36 @@ func checkAction(v string) error {
1270 return nil1314 return nil
1271 }1315 }
1272 1316
1317+// parseTextClick parses hint.text-click: `+`-separated modifier names as in
1318+// binds, or none. cmd is an alias of super here (it is not resolved through
1319+// keyboard.cmd, so the result does not depend on key order).
1320+func parseTextClick(v string) (ports.Mods, error) {
1321+ if strings.ToLower(v) == "none" {
1322+ return 0, nil
1323+ }
1324+ var mods ports.Mods
1325+ for _, p := range strings.Split(v, "+") {
1326+ var m ports.Mods
1327+ switch strings.ToLower(strings.TrimSpace(p)) {
1328+ case "shift":
1329+ m = ports.ModShift
1330+ case "ctrl":
1331+ m = ports.ModCtrl
1332+ case "alt":
1333+ m = ports.ModAlt
1334+ case "super", "cmd":
1335+ m = ports.ModSuper
1336+ default:
1337+ return 0, fmt.Errorf("unknown modifier %q (use shift, ctrl, alt, super or cmd joined by +, or none)", p)
1338+ }
1339+ if mods&m != 0 {
1340+ return 0, fmt.Errorf("duplicate modifier %q", p)
1341+ }
1342+ mods |= m
1343+ }
1344+ return mods, nil
1345+}
1346+
1273 // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:1347 // parseCombo turns `ctrl+cmd+é` into the canonical `Cmd+Ctrl+eacute`:
1274 // modifiers sorted, the key as an xkb keysym name.1348 // modifiers sorted, the key as an xkb keysym name.
1275 func parseCombo(s string) (string, error) {1349 func parseCombo(s string) (string, error) {
M internal/adapters/config/config_test.go +92 -0
@@ -953,3 +953,95 @@ func TestLoadRejectsFIFO(t *testing.T) {
953953 t.Fatal("Load blocked on a FIFO")
954954 }
955955 }
956+
957+func TestHintTargets(t *testing.T) {
958+ if Defaults().Hint.LinksOnly {
959+ t.Fatal("hint targets default must be clickable")
960+ }
961+ c, w := parseString(t, "hint.targets = links\n")
962+ if !c.Hint.LinksOnly || len(w) != 0 {
963+ t.Fatal(c.Hint, w)
964+ }
965+ c, w = parseString(t, "hint.targets = clickable\n")
966+ if c.Hint.LinksOnly || len(w) != 0 {
967+ t.Fatal(c.Hint, w)
968+ }
969+ // An invalid value warns and keeps the default.
970+ c, w = parseString(t, "hint.targets = buttons\n")
971+ if c.Hint.LinksOnly || len(w) != 1 {
972+ t.Fatal(c.Hint, w)
973+ }
974+ c, w = parseString(t, "bind.cmd+g = hint\n")
975+ if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976+ t.Fatal(c.Binds["Cmd+g"], w)
977+ }
978+}
979+
980+func TestHintStyleConfig(t *testing.T) {
981+ c, warnings := parseString(t, "hint.size = 17.5\nhint.background = #89b4fa\nhint.foreground = #111111\n")
982+ if len(warnings) != 0 || c.Hint.Size != 17.5 || c.Hint.Background != "#89b4fa" || c.Hint.Foreground != "#111111" {
983+ t.Fatalf("style: %+v, warnings: %v", c.Hint, warnings)
984+ }
985+ c, warnings = parseString(t, "hint.size = auto\nhint.background =\nhint.foreground =\n")
986+ if len(warnings) != 0 || c.Hint.Size != 0 || c.Hint.Background != "" || c.Hint.Foreground != "" {
987+ t.Fatalf("automatic: %+v %v", c.Hint, warnings)
988+ }
989+ for _, line := range []string{"hint.size = 0", "hint.size = -1", "hint.size = NaN", "hint.size = Inf", "hint.size = huge", "hint.size = 5.9", "hint.size = 72.1", "hint.size = 1e9", "hint.background = red", "hint.foreground = #fff"} {
990+ _, warnings := parseString(t, line+"\n")
991+ if len(warnings) == 0 {
992+ t.Errorf("expected validate-config warning for %s", line)
993+ }
994+ }
995+}
996+
997+func TestHintSizeBounds(t *testing.T) {
998+ for _, v := range []string{"6", "72", "13"} {
999+ c, w := parseString(t, "hint.size = "+v+"\n")
1000+ if len(w) != 0 || c.Hint.Size == 0 {
1001+ t.Errorf("hint.size = %s: %+v %v", v, c.Hint, w)
1002+ }
1003+ }
1004+}
1005+
1006+func TestHintTextClick(t *testing.T) {
1007+ if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift {
1008+ t.Fatalf("default %d", got)
1009+ }
1010+ for in, want := range map[string]ports.Mods{
1011+ "ctrl+shift": ports.ModCtrl | ports.ModShift,
1012+ "Alt": ports.ModAlt,
1013+ "super": ports.ModSuper,
1014+ "cmd+shift": ports.ModSuper | ports.ModShift,
1015+ "none": 0,
1016+ "shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt,
1017+ } {
1018+ c, w := parseString(t, "hint.text-click = "+in+"\n")
1019+ if c.Hint.TextClick != want || len(w) != 0 {
1020+ t.Errorf("%q: %d %v", in, c.Hint.TextClick, w)
1021+ }
1022+ }
1023+ for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} {
1024+ c, w := parseString(t, "hint.text-click = "+bad+"\n")
1025+ if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift {
1026+ t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w)
1027+ }
1028+ }
1029+}
1030+
1031+func TestHintEnable(t *testing.T) {
1032+ if Defaults().Hint.Enable {
1033+ t.Fatal("hint mode must be off by default")
1034+ }
1035+ c, w := parseString(t, "hint.enable = on\n")
1036+ if !c.Hint.Enable || len(w) != 0 {
1037+ t.Fatal(c.Hint, w)
1038+ }
1039+ c, w = parseString(t, "hint.enable = off\n")
1040+ if c.Hint.Enable || len(w) != 0 {
1041+ t.Fatal(c.Hint, w)
1042+ }
1043+ c, w = parseString(t, "hint.enable = maybe\n")
1044+ if c.Hint.Enable || len(w) != 1 {
1045+ t.Fatal(c.Hint, w)
1046+ }
1047+}
@@ -953,3 +953,95 @@ func TestLoadRejectsFIFO(t *testing.T) {
953 t.Fatal("Load blocked on a FIFO")953 t.Fatal("Load blocked on a FIFO")
954 }954 }
955 }955 }
956+
957+func TestHintTargets(t *testing.T) {
958+ if Defaults().Hint.LinksOnly {
959+ t.Fatal("hint targets default must be clickable")
960+ }
961+ c, w := parseString(t, "hint.targets = links\n")
962+ if !c.Hint.LinksOnly || len(w) != 0 {
963+ t.Fatal(c.Hint, w)
964+ }
965+ c, w = parseString(t, "hint.targets = clickable\n")
966+ if c.Hint.LinksOnly || len(w) != 0 {
967+ t.Fatal(c.Hint, w)
968+ }
969+ // An invalid value warns and keeps the default.
970+ c, w = parseString(t, "hint.targets = buttons\n")
971+ if c.Hint.LinksOnly || len(w) != 1 {
972+ t.Fatal(c.Hint, w)
973+ }
974+ c, w = parseString(t, "bind.cmd+g = hint\n")
975+ if len(w) != 0 || c.Binds["Cmd+g"] != "hint" {
976+ t.Fatal(c.Binds["Cmd+g"], w)
977+ }
978+}
979+
980+func TestHintStyleConfig(t *testing.T) {
981+ c, warnings := parseString(t, "hint.size = 17.5\nhint.background = #89b4fa\nhint.foreground = #111111\n")
982+ if len(warnings) != 0 || c.Hint.Size != 17.5 || c.Hint.Background != "#89b4fa" || c.Hint.Foreground != "#111111" {
983+ t.Fatalf("style: %+v, warnings: %v", c.Hint, warnings)
984+ }
985+ c, warnings = parseString(t, "hint.size = auto\nhint.background =\nhint.foreground =\n")
986+ if len(warnings) != 0 || c.Hint.Size != 0 || c.Hint.Background != "" || c.Hint.Foreground != "" {
987+ t.Fatalf("automatic: %+v %v", c.Hint, warnings)
988+ }
989+ for _, line := range []string{"hint.size = 0", "hint.size = -1", "hint.size = NaN", "hint.size = Inf", "hint.size = huge", "hint.size = 5.9", "hint.size = 72.1", "hint.size = 1e9", "hint.background = red", "hint.foreground = #fff"} {
990+ _, warnings := parseString(t, line+"\n")
991+ if len(warnings) == 0 {
992+ t.Errorf("expected validate-config warning for %s", line)
993+ }
994+ }
995+}
996+
997+func TestHintSizeBounds(t *testing.T) {
998+ for _, v := range []string{"6", "72", "13"} {
999+ c, w := parseString(t, "hint.size = "+v+"\n")
1000+ if len(w) != 0 || c.Hint.Size == 0 {
1001+ t.Errorf("hint.size = %s: %+v %v", v, c.Hint, w)
1002+ }
1003+ }
1004+}
1005+
1006+func TestHintTextClick(t *testing.T) {
1007+ if got := Defaults().Hint.TextClick; got != ports.ModCtrl|ports.ModShift {
1008+ t.Fatalf("default %d", got)
1009+ }
1010+ for in, want := range map[string]ports.Mods{
1011+ "ctrl+shift": ports.ModCtrl | ports.ModShift,
1012+ "Alt": ports.ModAlt,
1013+ "super": ports.ModSuper,
1014+ "cmd+shift": ports.ModSuper | ports.ModShift,
1015+ "none": 0,
1016+ "shift+ctrl+alt": ports.ModShift | ports.ModCtrl | ports.ModAlt,
1017+ } {
1018+ c, w := parseString(t, "hint.text-click = "+in+"\n")
1019+ if c.Hint.TextClick != want || len(w) != 0 {
1020+ t.Errorf("%q: %d %v", in, c.Hint.TextClick, w)
1021+ }
1022+ }
1023+ for _, bad := range []string{"hyper", "ctrl+", "ctrl+ctrl", "ctrl+none", ""} {
1024+ c, w := parseString(t, "hint.text-click = "+bad+"\n")
1025+ if len(w) != 1 || c.Hint.TextClick != ports.ModCtrl|ports.ModShift {
1026+ t.Errorf("%q: %d %v", bad, c.Hint.TextClick, w)
1027+ }
1028+ }
1029+}
1030+
1031+func TestHintEnable(t *testing.T) {
1032+ if Defaults().Hint.Enable {
1033+ t.Fatal("hint mode must be off by default")
1034+ }
1035+ c, w := parseString(t, "hint.enable = on\n")
1036+ if !c.Hint.Enable || len(w) != 0 {
1037+ t.Fatal(c.Hint, w)
1038+ }
1039+ c, w = parseString(t, "hint.enable = off\n")
1040+ if c.Hint.Enable || len(w) != 0 {
1041+ t.Fatal(c.Hint, w)
1042+ }
1043+ c, w = parseString(t, "hint.enable = maybe\n")
1044+ if c.Hint.Enable || len(w) != 1 {
1045+ t.Fatal(c.Hint, w)
1046+ }
1047+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close