detached a9ec34a

fix(keys): keep kitty text, lock bits, and split reads exact when normalizing

Hold a trailing ESC and long associated-text sequences across reads,
send generated text whatever its modifiers, and keep lock bits on keys
that do not produce text. Release a held Ctrl on suspension, rank only
the entries Ctrl+1..9 reach, and bound MRU text dimming by normal-text
contrast.
commit
a9ec34af86dd601fb9808cc73e514ce7b4ba154f
author
brice · · 2026-10-15 03:31 UTC
parent
5bdb1f2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +144 -25
M internal/usecase/daemon/attachment_suspension.go +3 -0
@@ -51,6 +51,9 @@ func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol.
5151 ac.lifecycle.activity = attachmentSuspended
5252 ac.lifecycle.retained = target
5353 ac.lifecycle.mu.Unlock()
54+ // A suspended attachment drops modifier reports; its activation starts
55+ // from a released Ctrl and the client reports a hold afresh.
56+ ac.ctrlHeld.Store(false)
5457 sess.mu.Unlock()
5558 d.mu.Unlock()
5659 cleanup.finish()
@@ -51,6 +51,9 @@ func (d *Daemon) suspendAttachment(token attachmentCapability, request protocol.
51 ac.lifecycle.activity = attachmentSuspended51 ac.lifecycle.activity = attachmentSuspended
52 ac.lifecycle.retained = target52 ac.lifecycle.retained = target
53 ac.lifecycle.mu.Unlock()53 ac.lifecycle.mu.Unlock()
54+ // A suspended attachment drops modifier reports; its activation starts
55+ // from a released Ctrl and the client reports a hold afresh.
56+ ac.ctrlHeld.Store(false)
54 sess.mu.Unlock()57 sess.mu.Unlock()
55 d.mu.Unlock()58 d.mu.Unlock()
56 cleanup.finish()59 cleanup.finish()
M internal/usecase/daemon/modifier_state_test.go +14 -0
@@ -49,3 +49,17 @@ func renderTestStatusRow(state barState) string {
4949 drawStatusBarState(row, state, resolveStyles(nil))
5050 return rowText(row)
5151 }
52+
53+// TestSuspensionReleasesHeldCtrl pins that a suspended attachment, which
54+// drops modifier reports, never activates with ranks stuck on.
55+func TestSuspensionReleasesHeldCtrl(t *testing.T) {
56+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
57+ d.attachCoordinator(sess, nil, ac, true)
58+ token := sess.captureAttachmentCapability(ac, ac.transport())
59+ ac.installTestAttachmentCapability(token)
60+ ac.ctrlHeld.Store(true)
61+
62+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
63+
64+ require.False(t, ac.ctrlHeld.Load())
65+}
@@ -49,3 +49,17 @@ func renderTestStatusRow(state barState) string {
49 drawStatusBarState(row, state, resolveStyles(nil))49 drawStatusBarState(row, state, resolveStyles(nil))
50 return rowText(row)50 return rowText(row)
51 }51 }
52+
53+// TestSuspensionReleasesHeldCtrl pins that a suspended attachment, which
54+// drops modifier reports, never activates with ranks stuck on.
55+func TestSuspensionReleasesHeldCtrl(t *testing.T) {
56+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
57+ d.attachCoordinator(sess, nil, ac, true)
58+ token := sess.captureAttachmentCapability(ac, ac.transport())
59+ ac.installTestAttachmentCapability(token)
60+ ac.ctrlHeld.Store(true)
61+
62+ require.NoError(t, d.suspendAttachment(token, protocol.SuspendAttachment{RequestID: 1}))
63+
64+ require.False(t, ac.ctrlHeld.Load())
65+}
M internal/usecase/daemon/status.go +5 -4
@@ -16,6 +16,7 @@ import (
1616 )
1717
1818 const (
19+ // maxMRUSessions is how many history entries Ctrl+1..9 reach.
1920 maxMRUSessions = 9
2021 pulseFrameCount = 30
2122 pulseFrameInterval = 120 * time.Millisecond
@@ -92,7 +93,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
9293 fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
9394 for i, sess := range fittedMRU {
9495 style := styles.MRUStyle(i, len(fittedMRU))
95- if state.showRanks {
96+ if state.showRanks && i < maxMRUSessions {
9697 drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
9798 continue
9899 }
@@ -458,12 +459,12 @@ func rankPrefixWidth(rank int) int {
458459 return statusTextWidth(strconv.Itoa(rank) + ":")
459460 }
460461
461-// fitMRU keeps the leading entries that fit. ranked adds each entry's "N:"
462-// rank prefix to its width.
462+// fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix
463+// of each entry Ctrl+1..9 reaches to its width.
463464 func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
464465 cost := func(i int, entry recentRouteDisplay) int {
465466 width := recentRouteDisplayWidth(entry)
466- if ranked {
467+ if ranked && i < maxMRUSessions {
467468 width += rankPrefixWidth(i + 1)
468469 }
469470 return width
@@ -16,6 +16,7 @@ import (
16 )16 )
17 17
18 const (18 const (
19+ // maxMRUSessions is how many history entries Ctrl+1..9 reach.
19 maxMRUSessions = 920 maxMRUSessions = 9
20 pulseFrameCount = 3021 pulseFrameCount = 30
21 pulseFrameInterval = 120 * time.Millisecond22 pulseFrameInterval = 120 * time.Millisecond
@@ -92,7 +93,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
92 fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)93 fittedMRU := fitMRU(state.mru, len(row), x, rightText, state.showRanks)
93 for i, sess := range fittedMRU {94 for i, sess := range fittedMRU {
94 style := styles.MRUStyle(i, len(fittedMRU))95 style := styles.MRUStyle(i, len(fittedMRU))
95- if state.showRanks {96+ if state.showRanks && i < maxMRUSessions {
96 drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)97 drawRankedStatusSessionEntry(row, &x, rankedRecent{rank: i + 1, name: sess.name, attention: sess.attention}, style, state.attentionFrame, styles.Reduced)
97 continue98 continue
98 }99 }
@@ -458,12 +459,12 @@ func rankPrefixWidth(rank int) int {
458 return statusTextWidth(strconv.Itoa(rank) + ":")459 return statusTextWidth(strconv.Itoa(rank) + ":")
459 }460 }
460 461
461-// fitMRU keeps the leading entries that fit. ranked adds each entry's "N:"462+// fitMRU keeps the leading entries that fit. ranked adds the "N:" rank prefix
462-// rank prefix to its width.463+// of each entry Ctrl+1..9 reaches to its width.
463 func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {464 func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string, ranked bool) []recentRouteDisplay {
464 cost := func(i int, entry recentRouteDisplay) int {465 cost := func(i int, entry recentRouteDisplay) int {
465 width := recentRouteDisplayWidth(entry)466 width := recentRouteDisplayWidth(entry)
466- if ranked {467+ if ranked && i < maxMRUSessions {
467 width += rankPrefixWidth(i + 1)468 width += rankPrefixWidth(i + 1)
468 }469 }
469 return width470 return width
M internal/usecase/daemon/status_test.go +11 -1
@@ -1092,8 +1092,13 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
10921092
10931093 func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
10941094 mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}}
1095+ var tenEntries []recentRouteDisplay
1096+ for _, name := range "abcdefghij" {
1097+ tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)})
1098+ }
10951099 tests := []struct {
10961100 name string
1101+ mru []recentRouteDisplay
10971102 showRanks bool
10981103 width int
10991104 want string
@@ -1101,10 +1106,15 @@ func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
11011106 {name: "names only", width: 44, want: " cur local neferwl@arch old "},
11021107 {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},
11031108 {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},
1109+ {name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur 1:a 2:b 3:c 4:d 5:e 6:f 7:g 8:h 9:i j "},
11041110 }
11051111 for _, tt := range tests {
11061112 t.Run(tt.name, func(t *testing.T) {
1107- state := barState{status: statusSnapshot{session: "cur"}, mru: mru, showRanks: tt.showRanks}
1113+ entries := mru
1114+ if tt.mru != nil {
1115+ entries = tt.mru
1116+ }
1117+ state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks}
11081118 row := make([]renderer.Cell, tt.width)
11091119
11101120 drawStatusBarState(row, state, resolveStyles(nil))
@@ -1092,8 +1092,13 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1092 1092
1093 func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {1093 func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
1094 mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}}1094 mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}}
1095+ var tenEntries []recentRouteDisplay
1096+ for _, name := range "abcdefghij" {
1097+ tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)})
1098+ }
1095 tests := []struct {1099 tests := []struct {
1096 name string1100 name string
1101+ mru []recentRouteDisplay
1097 showRanks bool1102 showRanks bool
1098 width int1103 width int
1099 want string1104 want string
@@ -1101,10 +1106,15 @@ func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
1101 {name: "names only", width: 44, want: " cur local neferwl@arch old "},1106 {name: "names only", width: 44, want: " cur local neferwl@arch old "},
1102 {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},1107 {name: "ranks while held", showRanks: true, width: 50, want: " cur 1:local 2:neferwl@arch 3:old "},
1103 {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},1108 {name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur 1:local 2:neferwl@arch "},
1109+ {name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur 1:a 2:b 3:c 4:d 5:e 6:f 7:g 8:h 9:i j "},
1104 }1110 }
1105 for _, tt := range tests {1111 for _, tt := range tests {
1106 t.Run(tt.name, func(t *testing.T) {1112 t.Run(tt.name, func(t *testing.T) {
1107- state := barState{status: statusSnapshot{session: "cur"}, mru: mru, showRanks: tt.showRanks}1113+ entries := mru
1114+ if tt.mru != nil {
1115+ entries = tt.mru
1116+ }
1117+ state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks}
1108 row := make([]renderer.Cell, tt.width)1118 row := make([]renderer.Cell, tt.width)
1109 1119
1110 drawStatusBarState(row, state, resolveStyles(nil))1120 drawStatusBarState(row, state, resolveStyles(nil))
M internal/usecase/keys/kittykey/kittykey.go +7 -4
@@ -117,6 +117,8 @@ func Parse(data []byte) (ev Event, n int, ok, partial bool) {
117117 // fullKey is one CSI u key event with the fields Event leaves out.
118118 type fullKey struct {
119119 ev Event
120+ // locks holds the Caps Lock and Num Lock bits Event.Mods leaves out.
121+ locks int
120122 // text holds the associated text (FlagText), control codes removed.
121123 text []rune
122124 }
@@ -156,7 +158,8 @@ func parseParams(params []byte) (fullKey, bool) {
156158 if !ok {
157159 return fullKey{}, false
158160 }
159- ev.Mods = mods
161+ ev.Mods = mods &^ modLocks
162+ key.locks = mods & modLocks
160163 ev.Release = event == eventRelease
161164 }
162165 // A third field carries associated text (FlagText). Only a Normalizer
@@ -181,8 +184,8 @@ const (
181184 eventRelease = 3
182185 )
183186
184-// parseMods decodes a "mods[:event]" field: the modifier bits with the lock
185-// bits removed, and the event type (press when absent).
187+// parseMods decodes a "mods[:event]" field: the modifier bits, lock bits
188+// included, and the event type (press when absent).
186189 func parseMods(field []byte) (mods, event int, ok bool) {
187190 sub := splitByte(field, ':')
188191 if len(sub) > 2 {
@@ -199,7 +202,7 @@ func parseMods(field []byte) (mods, event int, ok bool) {
199202 return 0, 0, false
200203 }
201204 }
202- return (encoded - 1) &^ modLocks, event, true
205+ return encoded - 1, event, true
203206 }
204207
205208 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
@@ -117,6 +117,8 @@ func Parse(data []byte) (ev Event, n int, ok, partial bool) {
117 // fullKey is one CSI u key event with the fields Event leaves out.117 // fullKey is one CSI u key event with the fields Event leaves out.
118 type fullKey struct {118 type fullKey struct {
119 ev Event119 ev Event
120+ // locks holds the Caps Lock and Num Lock bits Event.Mods leaves out.
121+ locks int
120 // text holds the associated text (FlagText), control codes removed.122 // text holds the associated text (FlagText), control codes removed.
121 text []rune123 text []rune
122 }124 }
@@ -156,7 +158,8 @@ func parseParams(params []byte) (fullKey, bool) {
156 if !ok {158 if !ok {
157 return fullKey{}, false159 return fullKey{}, false
158 }160 }
159- ev.Mods = mods161+ ev.Mods = mods &^ modLocks
162+ key.locks = mods & modLocks
160 ev.Release = event == eventRelease163 ev.Release = event == eventRelease
161 }164 }
162 // A third field carries associated text (FlagText). Only a Normalizer165 // A third field carries associated text (FlagText). Only a Normalizer
@@ -181,8 +184,8 @@ const (
181 eventRelease = 3184 eventRelease = 3
182 )185 )
183 186
184-// parseMods decodes a "mods[:event]" field: the modifier bits with the lock187+// parseMods decodes a "mods[:event]" field: the modifier bits, lock bits
185-// bits removed, and the event type (press when absent).188+// included, and the event type (press when absent).
186 func parseMods(field []byte) (mods, event int, ok bool) {189 func parseMods(field []byte) (mods, event int, ok bool) {
187 sub := splitByte(field, ':')190 sub := splitByte(field, ':')
188 if len(sub) > 2 {191 if len(sub) > 2 {
@@ -199,7 +202,7 @@ func parseMods(field []byte) (mods, event int, ok bool) {
199 return 0, 0, false202 return 0, 0, false
200 }203 }
201 }204 }
202- return (encoded - 1) &^ modLocks, event, true205+ return encoded - 1, event, true
203 }206 }
204 207
205 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in208 // Translate rewrites every complete CSI u key sequence and kitty F1-F4 form in
M internal/usecase/keys/kittykey/normalize.go +22 -9
@@ -6,6 +6,10 @@ import (
66 "unicode/utf8"
77 )
88
9+// maxNormalizedSeq bounds one held key sequence. Associated text makes a key
10+// event as long as the text an IME commits at once.
11+const maxNormalizedSeq = 4096
12+
913 // Kitty functional key codes of the modifier and lock keys. RequestFlags
1014 // reports them; OuterFlags never does.
1115 const (
@@ -24,8 +28,9 @@ const (
2428 //
2529 // Input already in the OuterFlags or legacy encoding passes through
2630 // unchanged, so enabling a Normalizer before the terminal switches flags is
27-// safe. A trailing incomplete CSI sequence is held until the next call; a
28-// lone trailing ESC is not, because the key router owns its timing.
31+// safe. A trailing incomplete escape sequence, a lone ESC included, is held
32+// until the next call: with FlagDisambiguate the Escape key itself arrives as
33+// CSI 27 u, so a lone ESC only ends a read split inside a sequence.
2934 // A Normalizer is not safe for concurrent use.
3035 type Normalizer struct {
3136 pending []byte
@@ -115,17 +120,20 @@ func partialSuffix(data, marker []byte) int {
115120 // only digits, ';' and ':'. size is 0 when data starts no such sequence;
116121 // partial reports a strict prefix that may still complete.
117122 func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {
118- if len(data) < 2 || data[1] != '[' {
123+ if len(data) == 1 {
124+ return nil, 0, 0, true
125+ }
126+ if data[1] != '[' {
119127 return nil, 0, 0, false
120128 }
121- for i := 2; i < len(data) && i < maxSeq; i++ {
129+ for i := 2; i < len(data) && i < maxNormalizedSeq; i++ {
122130 b := data[i]
123131 if (b >= '0' && b <= '9') || b == ';' || b == ':' {
124132 continue
125133 }
126134 return data[2:i], b, i + 1, false
127135 }
128- return nil, 0, 0, len(data) < maxSeq
136+ return nil, 0, 0, len(data) < maxNormalizedSeq
129137 }
130138
131139 // rewrite returns the OuterFlags form of one key sequence, or false when the
@@ -160,10 +168,11 @@ func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool)
160168 if !ok {
161169 return nil, false
162170 }
163- n.track(Event{Mods: mods, Release: event == eventRelease}, false)
171+ n.track(Event{Mods: mods &^ modLocks, Release: event == eventRelease}, false)
164172 if event == eventRelease {
165173 return nil, true
166174 }
175+ // Functional keys keep their lock bits under OuterFlags.
167176 out := []byte("\x1b[")
168177 if mods != 0 || (final == '~' || string(fields[0]) != "1") {
169178 out = append(out, fields[0]...)
@@ -207,14 +216,18 @@ func encodeOuter(key fullKey) []byte {
207216 if ev.Release || isModifierOrLock(ev.Code) {
208217 return nil
209218 }
210- plain := ev.Mods&^ModShift == 0
211219 switch {
212- case ev.Code == 0 || (plain && len(key.text) > 0):
220+ case len(key.text) > 0 || ev.Code == 0:
221+ // A terminal sends the text a key generates as is, whatever the
222+ // modifiers that produced it.
213223 return appendRunes(nil, key.text)
214224 case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):
215225 return []byte{byte(ev.Code)}
216- case plain && isTextKey(ev.Code):
226+ case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):
217227 return legacyText(ev)
228+ case !isTextKey(ev.Code):
229+ // Lock bits are reported for keys that do not produce text.
230+ ev.Mods |= key.locks
218231 }
219232 return encodeCSIu(ev, OuterFlags)
220233 }
@@ -6,6 +6,10 @@ import (
6 "unicode/utf8"6 "unicode/utf8"
7 )7 )
8 8
9+// maxNormalizedSeq bounds one held key sequence. Associated text makes a key
10+// event as long as the text an IME commits at once.
11+const maxNormalizedSeq = 4096
12+
9 // Kitty functional key codes of the modifier and lock keys. RequestFlags13 // Kitty functional key codes of the modifier and lock keys. RequestFlags
10 // reports them; OuterFlags never does.14 // reports them; OuterFlags never does.
11 const (15 const (
@@ -24,8 +28,9 @@ const (
24 //28 //
25 // Input already in the OuterFlags or legacy encoding passes through29 // Input already in the OuterFlags or legacy encoding passes through
26 // unchanged, so enabling a Normalizer before the terminal switches flags is30 // unchanged, so enabling a Normalizer before the terminal switches flags is
27-// safe. A trailing incomplete CSI sequence is held until the next call; a31+// safe. A trailing incomplete escape sequence, a lone ESC included, is held
28-// lone trailing ESC is not, because the key router owns its timing.32+// until the next call: with FlagDisambiguate the Escape key itself arrives as
33+// CSI 27 u, so a lone ESC only ends a read split inside a sequence.
29 // A Normalizer is not safe for concurrent use.34 // A Normalizer is not safe for concurrent use.
30 type Normalizer struct {35 type Normalizer struct {
31 pending []byte36 pending []byte
@@ -115,17 +120,20 @@ func partialSuffix(data, marker []byte) int {
115 // only digits, ';' and ':'. size is 0 when data starts no such sequence;120 // only digits, ';' and ':'. size is 0 when data starts no such sequence;
116 // partial reports a strict prefix that may still complete.121 // partial reports a strict prefix that may still complete.
117 func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {122 func scanCSI(data []byte) (params []byte, final byte, size int, partial bool) {
118- if len(data) < 2 || data[1] != '[' {123+ if len(data) == 1 {
124+ return nil, 0, 0, true
125+ }
126+ if data[1] != '[' {
119 return nil, 0, 0, false127 return nil, 0, 0, false
120 }128 }
121- for i := 2; i < len(data) && i < maxSeq; i++ {129+ for i := 2; i < len(data) && i < maxNormalizedSeq; i++ {
122 b := data[i]130 b := data[i]
123 if (b >= '0' && b <= '9') || b == ';' || b == ':' {131 if (b >= '0' && b <= '9') || b == ';' || b == ':' {
124 continue132 continue
125 }133 }
126 return data[2:i], b, i + 1, false134 return data[2:i], b, i + 1, false
127 }135 }
128- return nil, 0, 0, len(data) < maxSeq136+ return nil, 0, 0, len(data) < maxNormalizedSeq
129 }137 }
130 138
131 // rewrite returns the OuterFlags form of one key sequence, or false when the139 // rewrite returns the OuterFlags form of one key sequence, or false when the
@@ -160,10 +168,11 @@ func (n *Normalizer) rewriteFunctional(params []byte, final byte) ([]byte, bool)
160 if !ok {168 if !ok {
161 return nil, false169 return nil, false
162 }170 }
163- n.track(Event{Mods: mods, Release: event == eventRelease}, false)171+ n.track(Event{Mods: mods &^ modLocks, Release: event == eventRelease}, false)
164 if event == eventRelease {172 if event == eventRelease {
165 return nil, true173 return nil, true
166 }174 }
175+ // Functional keys keep their lock bits under OuterFlags.
167 out := []byte("\x1b[")176 out := []byte("\x1b[")
168 if mods != 0 || (final == '~' || string(fields[0]) != "1") {177 if mods != 0 || (final == '~' || string(fields[0]) != "1") {
169 out = append(out, fields[0]...)178 out = append(out, fields[0]...)
@@ -207,14 +216,18 @@ func encodeOuter(key fullKey) []byte {
207 if ev.Release || isModifierOrLock(ev.Code) {216 if ev.Release || isModifierOrLock(ev.Code) {
208 return nil217 return nil
209 }218 }
210- plain := ev.Mods&^ModShift == 0
211 switch {219 switch {
212- case ev.Code == 0 || (plain && len(key.text) > 0):220+ case len(key.text) > 0 || ev.Code == 0:
221+ // A terminal sends the text a key generates as is, whatever the
222+ // modifiers that produced it.
213 return appendRunes(nil, key.text)223 return appendRunes(nil, key.text)
214 case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):224 case ev.Mods == 0 && (ev.Code == KeyEnter || ev.Code == KeyTab || ev.Code == KeyBackspace):
215 return []byte{byte(ev.Code)}225 return []byte{byte(ev.Code)}
216- case plain && isTextKey(ev.Code):226+ case ev.Mods&^ModShift == 0 && isTextKey(ev.Code):
217 return legacyText(ev)227 return legacyText(ev)
228+ case !isTextKey(ev.Code):
229+ // Lock bits are reported for keys that do not produce text.
230+ ev.Mods |= key.locks
218 }231 }
219 return encodeCSIu(ev, OuterFlags)232 return encodeCSIu(ev, OuterFlags)
220 }233 }
M internal/usecase/keys/kittykey/normalize_test.go +33 -2
@@ -1,6 +1,7 @@
11 package kittykey
22
33 import (
4+ "strings"
45 "testing"
56
67 "github.com/stretchr/testify/require"
@@ -31,7 +32,15 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
3132 {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},
3233 {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},
3334 {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},
34- {name: "alt letter ignores text", in: "\x1b[97;3;97u", want: "\x1b[97;3u"},
35+ {name: "alt letter without text", in: "\x1b[97;3u", want: "\x1b[97;3u"},
36+ {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},
37+ {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},
38+ {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},
39+ {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;5u"},
40+ {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},
41+ {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},
42+ {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},
43+ {name: "caps lock delete", in: "\x1b[3;65~", want: "\x1b[3;65~"},
3544 {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},
3645 {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},
3746 {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},
@@ -48,7 +57,7 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
4857 {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
4958 {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
5059 {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},
51- {name: "lone esc passes", in: "x\x1b", want: "x\x1b"},
60+ {name: "lone esc held", in: "x\x1b", want: "x"},
5261 {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
5362 {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
5463 }
@@ -69,6 +78,7 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
6978 {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
7079 {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
7180 {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
81+ {name: "split after esc", reads: []string{"\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"", "", "", "a"}},
7282 {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},
7383 }
7484 for _, tt := range tests {
@@ -81,6 +91,27 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
8191 }
8292 }
8393
94+// TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte
95+// and requires the same output and Ctrl state as one read.
96+func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
97+ inputs := []string{
98+ "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",
99+ "a\x1b[97;;97u\x1b[57442;1:3u",
100+ "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[1;65:2A",
101+ "\x1b[0;;19968:19968u\x1b[27u",
102+ }
103+ for _, in := range inputs {
104+ var whole Normalizer
105+ want := string(whole.Normalize([]byte(in)))
106+ for cut := 1; cut < len(in); cut++ {
107+ var n Normalizer
108+ got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))
109+ require.Equal(t, want, got, "input %q cut %d", in, cut)
110+ require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)
111+ }
112+ }
113+}
114+
84115 func TestNormalizerTracksCtrl(t *testing.T) {
85116 tests := []struct {
86117 name string
@@ -1,6 +1,7 @@
1 package kittykey1 package kittykey
2 2
3 import (3 import (
4+ "strings"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
@@ -31,7 +32,15 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
31 {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},32 {name: "escape", in: "\x1b[27u", want: "\x1b[27u"},
32 {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},33 {name: "ctrl letter", in: "\x1b[99;5u", want: "\x1b[99;5u"},
33 {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},34 {name: "ctrl digit with base", in: "\x1b[38::49;5u", want: "\x1b[38::49;5u"},
34- {name: "alt letter ignores text", in: "\x1b[97;3;97u", want: "\x1b[97;3u"},35+ {name: "alt letter without text", in: "\x1b[97;3u", want: "\x1b[97;3u"},
36+ {name: "altgr text wins over modifiers", in: "\x1b[113;7;64u", want: "@"},
37+ {name: "long ime commit", in: "\x1b[0;;" + strings.Repeat("19968:", 11) + "19968u", want: strings.Repeat("一", 12)},
38+ {name: "caps lock dropped from text key", in: "\x1b[97;65;65u", want: "A"},
39+ {name: "caps lock kept on ctrl letter", in: "\x1b[97;69u", want: "\x1b[97;5u"},
40+ {name: "num lock kept on escape", in: "\x1b[27;129u", want: "\x1b[27;129u"},
41+ {name: "caps lock kept on arrow", in: "\x1b[1;65A", want: "\x1b[1;65A"},
42+ {name: "caps lock arrow repeat", in: "\x1b[1;65:2A", want: "\x1b[1;65A"},
43+ {name: "caps lock delete", in: "\x1b[3;65~", want: "\x1b[3;65~"},
35 {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},44 {name: "ctrl press dropped", in: "\x1b[57442;5u", want: ""},
36 {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},45 {name: "ctrl release dropped", in: "\x1b[57442;1:3u", want: ""},
37 {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},46 {name: "shift key dropped", in: "\x1b[57441;2u", want: ""},
@@ -48,7 +57,7 @@ func TestNormalizerRewritesRequestFlagsToOuterFlags(t *testing.T) {
48 {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},57 {name: "keyboard reply untouched", in: "\x1b[?5u", want: "\x1b[?5u"},
49 {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},58 {name: "cursor report untouched", in: "\x1b[12;40R", want: "\x1b[12;40R"},
50 {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},59 {name: "paste body untouched", in: "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[97;1:3u", want: "\x1b[200~\x1b[97;1:3u\x1b[201~"},
51- {name: "lone esc passes", in: "x\x1b", want: "x\x1b"},60+ {name: "lone esc held", in: "x\x1b", want: "x"},
52 {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},61 {name: "trailing garbage", in: "\x1b[97;;97u\x1b[zz", want: "a\x1b[zz"},
53 {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},62 {name: "bad event type kept", in: "\x1b[97;1:4u", want: "\x1b[97;1:4u"},
54 }63 }
@@ -69,6 +78,7 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
69 {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},78 {name: "split csi u", reads: []string{"a\x1b[97;", ";98u"}, want: []string{"a", "b"}},
70 {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},79 {name: "split csi prefix", reads: []string{"\x1b[", "57442;1:3u"}, want: []string{"", ""}},
71 {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},80 {name: "split paste close", reads: []string{"\x1b[200~x\x1b[20", "1~\x1b[97;;97u"}, want: []string{"\x1b[200~x", "\x1b[201~a"}},
81+ {name: "split after esc", reads: []string{"\x1b", "[57442;5u", "\x1b", "[97;;97u"}, want: []string{"", "", "", "a"}},
72 {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},82 {name: "split paste open", reads: []string{"\x1b[20", "0~\x1b[97;1:3u\x1b[201~"}, want: []string{"", "\x1b[200~\x1b[97;1:3u\x1b[201~"}},
73 }83 }
74 for _, tt := range tests {84 for _, tt := range tests {
@@ -81,6 +91,27 @@ func TestNormalizerHoldsSplitSequences(t *testing.T) {
81 }91 }
82 }92 }
83 93
94+// TestNormalizerEverySplitMatchesOneRead feeds each input split at every byte
95+// and requires the same output and Ctrl state as one read.
96+func TestNormalizerEverySplitMatchesOneRead(t *testing.T) {
97+ inputs := []string{
98+ "\x1b[57442;5u\x1b[49;5u\x1b[49;5:3u",
99+ "a\x1b[97;;97u\x1b[57442;1:3u",
100+ "\x1b[200~\x1b[97;1:3u\x1b[201~\x1b[1;65:2A",
101+ "\x1b[0;;19968:19968u\x1b[27u",
102+ }
103+ for _, in := range inputs {
104+ var whole Normalizer
105+ want := string(whole.Normalize([]byte(in)))
106+ for cut := 1; cut < len(in); cut++ {
107+ var n Normalizer
108+ got := string(n.Normalize([]byte(in[:cut]))) + string(n.Normalize([]byte(in[cut:])))
109+ require.Equal(t, want, got, "input %q cut %d", in, cut)
110+ require.Equal(t, whole.CtrlHeld(), n.CtrlHeld(), "input %q cut %d", in, cut)
111+ }
112+ }
113+}
114+
84 func TestNormalizerTracksCtrl(t *testing.T) {115 func TestNormalizerTracksCtrl(t *testing.T) {
85 tests := []struct {116 tests := []struct {
86 name string117 name string
M internal/usecase/theme/ramp.go +20 -4
@@ -1,6 +1,10 @@
11 package theme
22
3-import renderer "github.com/bnema/vev-vt"
3+import (
4+ "math"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
48
59 const (
610 normalTextContrast = 4.5
@@ -199,9 +203,22 @@ func mruTextDim(index, count int) float64 {
199203 return mruMaxTextDim * float64(index) / float64(count-1)
200204 }
201205
206+// dimText fades text toward its surface by up to weight, never below
207+// normal-text contrast unless the undimmed text was already below it.
208+func dimText(text, surface renderer.RGB, weight float64) renderer.RGB {
209+ for step := int(math.Round(weight * 100)); step > 0; step-- {
210+ candidate := okLabLerp(text, surface, float64(step)/100)
211+ if ContrastRatio(candidate, surface) >= normalTextContrast {
212+ return candidate
213+ }
214+ }
215+ return text
216+}
217+
202218 // MRUStyle places history entries by mruWeight between the bar and the active
203219 // surface. The background is never moved for contrast; the text picks the
204-// better of the theme foreground and background, then fades by mruTextDim.
220+// better of the theme foreground and background, then fades by mruTextDim
221+// within normal-text contrast.
205222 func MRUStyle(ramp Ramp, index, count int) renderer.Style {
206223 if !ramp.rgb || count <= 0 {
207224 return ramp.SurfaceRecent
@@ -210,6 +227,5 @@ func MRUStyle(ramp Ramp, index, count int) renderer.Style {
210227 weight := mruWeight(index, count)
211228 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
212229 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
213- foreground = okLabLerp(foreground, background, mruTextDim(index, count))
214- return rgbSurface(foreground, background)
230+ return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background)
215231 }
@@ -1,6 +1,10 @@
1 package theme1 package theme
2 2
3-import renderer "github.com/bnema/vev-vt"3+import (
4+ "math"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
4 8
5 const (9 const (
6 normalTextContrast = 4.510 normalTextContrast = 4.5
@@ -199,9 +203,22 @@ func mruTextDim(index, count int) float64 {
199 return mruMaxTextDim * float64(index) / float64(count-1)203 return mruMaxTextDim * float64(index) / float64(count-1)
200 }204 }
201 205
206+// dimText fades text toward its surface by up to weight, never below
207+// normal-text contrast unless the undimmed text was already below it.
208+func dimText(text, surface renderer.RGB, weight float64) renderer.RGB {
209+ for step := int(math.Round(weight * 100)); step > 0; step-- {
210+ candidate := okLabLerp(text, surface, float64(step)/100)
211+ if ContrastRatio(candidate, surface) >= normalTextContrast {
212+ return candidate
213+ }
214+ }
215+ return text
216+}
217+
202 // MRUStyle places history entries by mruWeight between the bar and the active218 // MRUStyle places history entries by mruWeight between the bar and the active
203 // surface. The background is never moved for contrast; the text picks the219 // surface. The background is never moved for contrast; the text picks the
204-// better of the theme foreground and background, then fades by mruTextDim.220+// better of the theme foreground and background, then fades by mruTextDim
221+// within normal-text contrast.
205 func MRUStyle(ramp Ramp, index, count int) renderer.Style {222 func MRUStyle(ramp Ramp, index, count int) renderer.Style {
206 if !ramp.rgb || count <= 0 {223 if !ramp.rgb || count <= 0 {
207 return ramp.SurfaceRecent224 return ramp.SurfaceRecent
@@ -210,6 +227,5 @@ func MRUStyle(ramp Ramp, index, count int) renderer.Style {
210 weight := mruWeight(index, count)227 weight := mruWeight(index, count)
211 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)228 background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
212 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)229 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
213- foreground = okLabLerp(foreground, background, mruTextDim(index, count))230+ return rgbSurface(dimText(foreground, background, mruTextDim(index, count)), background)
214- return rgbSurface(foreground, background)
215 }231 }
M internal/usecase/theme/ramp_test.go +29 -1
@@ -160,7 +160,10 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
160160 style := MRUStyle(ramp, index, count)
161161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
162162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
163- require.Equal(t, okLabLerp(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
163+ require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
164+ if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast {
165+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index)
166+ }
164167 }
165168 }
166169 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
@@ -185,6 +188,31 @@ func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) {
185188 }
186189 }
187190
191+func TestDimTextStopsAtNormalTextContrast(t *testing.T) {
192+ black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80}
193+ tests := []struct {
194+ name string
195+ text renderer.RGB
196+ surface renderer.RGB
197+ want renderer.RGB
198+ }{
199+ {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)},
200+ {name: "marginal contrast fades only to the floor", text: grey, surface: black},
201+ {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey},
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ got := dimText(tt.text, tt.surface, 0.3)
206+ if tt.want != (renderer.RGB{}) {
207+ require.Equal(t, tt.want, got)
208+ }
209+ if ContrastRatio(tt.text, tt.surface) >= normalTextContrast {
210+ require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast)
211+ }
212+ })
213+ }
214+}
215+
188216 func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
189217 ramp := neutralRamp(rampTheme(false))
190218 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
@@ -160,7 +160,10 @@ func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
160 style := MRUStyle(ramp, index, count)160 style := MRUStyle(ramp, index, count)
161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)161 require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)162 foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
163- require.Equal(t, okLabLerp(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)163+ require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
164+ if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast {
165+ require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index)
166+ }
164 }167 }
165 }168 }
166 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")169 require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
@@ -185,6 +188,31 @@ func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) {
185 }188 }
186 }189 }
187 190
191+func TestDimTextStopsAtNormalTextContrast(t *testing.T) {
192+ black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80}
193+ tests := []struct {
194+ name string
195+ text renderer.RGB
196+ surface renderer.RGB
197+ want renderer.RGB
198+ }{
199+ {name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)},
200+ {name: "marginal contrast fades only to the floor", text: grey, surface: black},
201+ {name: "low contrast stays undimmed", text: grey, surface: grey, want: grey},
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ got := dimText(tt.text, tt.surface, 0.3)
206+ if tt.want != (renderer.RGB{}) {
207+ require.Equal(t, tt.want, got)
208+ }
209+ if ContrastRatio(tt.text, tt.surface) >= normalTextContrast {
210+ require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast)
211+ }
212+ })
213+ }
214+}
215+
188 func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {216 func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
189 ramp := neutralRamp(rampTheme(false))217 ramp := neutralRamp(rampTheme(false))
190 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))218 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))

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 12

Tags 11

↑↓ moveenter openesc close