detached 9e2fc29

fix(visual): keep lock screen content inside its box

Cap masked characters to the field width, shorten the PIN fallback hint
so it fits one line, derive field centering margins from named geometry,
offset PIN padding for letter-spacing, and poll the wall-clock minute so
the date refreshes promptly after suspend.
commit
9e2fc294934e0a6a10c33708b8dfe228abb459a6
author
brice · · 2026-10-01 13:30 UTC
committer
brice · · 2026-10-01 16:10 UTC
parent
0fa4cbf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +104 -47
M internal/adapters/visual/lock.go +1 -1
@@ -215,7 +215,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun {
215215 return f
216216 }
217217
218-// startLock runs RunLock with the V1 view and a private auth controller. The
218+// startLock runs RunLock with the lock view and a private auth controller. The
219219 // lock context is detached from process signals: it ends only with RunLock.
220220 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
221221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
@@ -215,7 +215,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun {
215 return f215 return f
216 }216 }
217 217
218-// startLock runs RunLock with the V1 view and a private auth controller. The218+// startLock runs RunLock with the lock view and a private auth controller. The
219 // lock context is detached from process signals: it ends only with RunLock.219 // lock context is detached from process signals: it ends only with RunLock.
220 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {220 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
M internal/adapters/visual/view.go +63 -29
@@ -1,6 +1,7 @@
11 package visual
22
33 import (
4+ "fmt"
45 "strings"
56 "time"
67
@@ -12,25 +13,37 @@ import (
1213 // equal-height spacer below it balance each other, and an empty spacer inside
1314 // the box balances the status line under the input. The view sees only the
1415 // typed-character count, never the secret.
16+//
17+// NeferGUI's flex layout does not center a fixed-width child across the
18+// column here, so each field gets an explicit left margin derived from the
19+// geometry below.
1520 const (
21+ frameW = 360 // shared width: the date line ends at the box edge
22+ boxBorder = 2
23+ boxPadding = 24
24+ fieldPadX = 8
25+ fieldEdge = 2 // relief width (bottom and right)
26+ passwordW = 280
27+ pinW = 160
28+ pinSpacing = 6 // letter-spacing between PIN dots
29+
30+ // boxContentW is the width available to the field inside the box.
31+ boxContentW = frameW - 2*boxBorder - 2*boxPadding
32+
33+ // Monospace advance at 18px is about 10.8px: 28 characters fit the
34+ // box content, 24 bullets plus the cursor fit the password field, and
35+ // 7 dots at 24px plus spacing fit the PIN field.
36+ maxHintRunes = 28
37+ maxPasswordMask = 24
38+ maxPINMask = 7
39+
1640 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
1741 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
18- // cssFrame fixes the shared width so the date line ends at the box edge.
19- cssFrame = "display:flex;flex-direction:column;align-items:stretch;width:360px"
2042 cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"
2143 // cssBalance mirrors cssClock below the box.
2244 cssBalance = "height:24px;margin-top:8px"
23- cssBox = "display:flex;flex-direction:column;align-items:stretch;border:2px solid #bfbfbf;" +
24- "padding:24px;background-color:#000000"
25- // The box content is 360-2*2-2*24 = 308px wide; each field is centered by
26- // an explicit left margin: (308 - (width + 16 padding + 2 border)) / 2.
27- // The input field: an inverted relief, bottom and right edges at 50% white.
28- cssMask = "color:#ffffff;height:32px;line-height:32px;width:280px;padding:4px 8px;margin-left:5px;" +
29- "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
30- // cssMaskPIN: a short field with large, centered bullets.
31- cssMaskPIN = "color:#ffffff;height:32px;line-height:32px;width:160px;padding:4px 8px;margin-left:65px;" +
32- "font-size:24px;letter-spacing:6px;text-align:center;" +
33- "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
45+ // cssRelief is the inverted relief: bottom and right edges at 50% white.
46+ cssRelief = "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
3447 // cssHint and cssHintBalance have the same outer height.
3548 cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"
3649 cssHintBalance = "margin-bottom:12px;height:22px"
@@ -38,6 +51,24 @@ const (
3851 clockLayout = "Mon 02 Jan 2006 15:04"
3952 )
4053
54+var (
55+ cssFrame = fmt.Sprintf("display:flex;flex-direction:column;align-items:stretch;width:%dpx", frameW)
56+ cssBox = fmt.Sprintf("display:flex;flex-direction:column;align-items:stretch;"+
57+ "border:%dpx solid #bfbfbf;padding:%dpx;background-color:#000000", boxBorder, boxPadding)
58+ cssMask = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
59+ "padding:4px %dpx;margin-left:%dpx;%s", passwordW, fieldPadX, fieldMargin(passwordW), cssRelief)
60+ // The PIN field shifts its padding by half the trailing letter-spacing
61+ // so the dots stay optically centered.
62+ cssMaskPIN = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
63+ "padding:4px %dpx 4px %dpx;margin-left:%dpx;font-size:24px;letter-spacing:%dpx;text-align:center;%s",
64+ pinW, fieldPadX-pinSpacing/2, fieldPadX+pinSpacing/2, fieldMargin(pinW), pinSpacing, cssRelief)
65+)
66+
67+// fieldMargin centers a field of content width w inside the box content.
68+func fieldMargin(w int) int {
69+ return (boxContentW - (w + 2*fieldPadX + fieldEdge)) / 2
70+}
71+
4172 // hintFor is the line under the input: the attempt status first, then a note
4273 // for prompts the bare field cannot convey. It never carries typed input.
4374 func hintFor(s nefergui.LockStatus, p promptKind) string {
@@ -49,7 +80,7 @@ func hintFor(s nefergui.LockStatus, p promptKind) string {
4980 }
5081 switch p {
5182 case promptFallback:
52- return "PIN unavailable, use password"
83+ return "PIN off, use password"
5384 case promptUnavailable:
5485 return "Authentication unavailable"
5586 case promptMore:
@@ -59,12 +90,12 @@ func hintFor(s nefergui.LockStatus, p promptKind) string {
5990 }
6091
6192 // maskFor is the field content and style: large dots for a PIN, bullets
62-// plus a cursor for a password.
93+// plus a cursor for a password. The count is capped to the field width.
6394 func maskFor(s nefergui.LockState, p promptKind) (string, string) {
6495 if p == promptPIN {
65- return strings.Repeat("●", s.Mask), cssMaskPIN
96+ return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN
6697 }
67- return s.Bullets() + "_", cssMask
98+ return strings.Repeat("•", min(s.Mask, maxPasswordMask)) + "_", cssMask
6899 }
69100
70101 // UnlockBox draws the date line, the box and the balancing spacer.
@@ -84,25 +115,28 @@ func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Ti
84115 UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
85116 }
86117
87-// clockWake requests a redraw at every minute boundary until ctx ends, so
88-// the date line stays current. A pending request is enough: sends never block.
118+// clockPoll is short because the monotonic timer stops during suspend: a
119+// lock held across suspend must show the current minute soon after resume.
120+const clockPoll = 5 * time.Second
121+
122+// clockWake requests a redraw whenever the wall-clock minute changes, until
123+// done closes. Sends never block: a pending request is enough.
89124 func clockWake(done <-chan struct{}, wake chan<- struct{}) {
90- t := time.NewTimer(untilNextMinute(time.Now()))
125+ t := time.NewTicker(clockPoll)
91126 defer t.Stop()
127+ shown := time.Now().Truncate(time.Minute)
92128 for {
93129 select {
94130 case <-done:
95131 return
96- case now := <-t.C:
97- select {
98- case wake <- struct{}{}:
99- default:
132+ case <-t.C:
133+ if m := time.Now().Truncate(time.Minute); !m.Equal(shown) {
134+ shown = m
135+ select {
136+ case wake <- struct{}{}:
137+ default:
138+ }
100139 }
101- t.Reset(untilNextMinute(now))
102140 }
103141 }
104142 }
105-
106-func untilNextMinute(now time.Time) time.Duration {
107- return now.Truncate(time.Minute).Add(time.Minute).Sub(now)
108-}
@@ -1,6 +1,7 @@
1 package visual1 package visual
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 "time"6 "time"
6 7
@@ -12,25 +13,37 @@ import (
12 // equal-height spacer below it balance each other, and an empty spacer inside13 // equal-height spacer below it balance each other, and an empty spacer inside
13 // the box balances the status line under the input. The view sees only the14 // the box balances the status line under the input. The view sees only the
14 // typed-character count, never the secret.15 // typed-character count, never the secret.
16+//
17+// NeferGUI's flex layout does not center a fixed-width child across the
18+// column here, so each field gets an explicit left margin derived from the
19+// geometry below.
15 const (20 const (
21+ frameW = 360 // shared width: the date line ends at the box edge
22+ boxBorder = 2
23+ boxPadding = 24
24+ fieldPadX = 8
25+ fieldEdge = 2 // relief width (bottom and right)
26+ passwordW = 280
27+ pinW = 160
28+ pinSpacing = 6 // letter-spacing between PIN dots
29+
30+ // boxContentW is the width available to the field inside the box.
31+ boxContentW = frameW - 2*boxBorder - 2*boxPadding
32+
33+ // Monospace advance at 18px is about 10.8px: 28 characters fit the
34+ // box content, 24 bullets plus the cursor fit the password field, and
35+ // 7 dots at 24px plus spacing fit the PIN field.
36+ maxHintRunes = 28
37+ maxPasswordMask = 24
38+ maxPINMask = 7
39+
16 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +40 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
17 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"41 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
18- // cssFrame fixes the shared width so the date line ends at the box edge.
19- cssFrame = "display:flex;flex-direction:column;align-items:stretch;width:360px"
20 cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"42 cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"
21 // cssBalance mirrors cssClock below the box.43 // cssBalance mirrors cssClock below the box.
22 cssBalance = "height:24px;margin-top:8px"44 cssBalance = "height:24px;margin-top:8px"
23- cssBox = "display:flex;flex-direction:column;align-items:stretch;border:2px solid #bfbfbf;" +45+ // cssRelief is the inverted relief: bottom and right edges at 50% white.
24- "padding:24px;background-color:#000000"46+ cssRelief = "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
25- // The box content is 360-2*2-2*24 = 308px wide; each field is centered by
26- // an explicit left margin: (308 - (width + 16 padding + 2 border)) / 2.
27- // The input field: an inverted relief, bottom and right edges at 50% white.
28- cssMask = "color:#ffffff;height:32px;line-height:32px;width:280px;padding:4px 8px;margin-left:5px;" +
29- "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
30- // cssMaskPIN: a short field with large, centered bullets.
31- cssMaskPIN = "color:#ffffff;height:32px;line-height:32px;width:160px;padding:4px 8px;margin-left:65px;" +
32- "font-size:24px;letter-spacing:6px;text-align:center;" +
33- "border-bottom:2px solid rgba(255,255,255,0.5);border-right:2px solid rgba(255,255,255,0.5)"
34 // cssHint and cssHintBalance have the same outer height.47 // cssHint and cssHintBalance have the same outer height.
35 cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"48 cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"
36 cssHintBalance = "margin-bottom:12px;height:22px"49 cssHintBalance = "margin-bottom:12px;height:22px"
@@ -38,6 +51,24 @@ const (
38 clockLayout = "Mon 02 Jan 2006 15:04"51 clockLayout = "Mon 02 Jan 2006 15:04"
39 )52 )
40 53
54+var (
55+ cssFrame = fmt.Sprintf("display:flex;flex-direction:column;align-items:stretch;width:%dpx", frameW)
56+ cssBox = fmt.Sprintf("display:flex;flex-direction:column;align-items:stretch;"+
57+ "border:%dpx solid #bfbfbf;padding:%dpx;background-color:#000000", boxBorder, boxPadding)
58+ cssMask = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
59+ "padding:4px %dpx;margin-left:%dpx;%s", passwordW, fieldPadX, fieldMargin(passwordW), cssRelief)
60+ // The PIN field shifts its padding by half the trailing letter-spacing
61+ // so the dots stay optically centered.
62+ cssMaskPIN = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
63+ "padding:4px %dpx 4px %dpx;margin-left:%dpx;font-size:24px;letter-spacing:%dpx;text-align:center;%s",
64+ pinW, fieldPadX-pinSpacing/2, fieldPadX+pinSpacing/2, fieldMargin(pinW), pinSpacing, cssRelief)
65+)
66+
67+// fieldMargin centers a field of content width w inside the box content.
68+func fieldMargin(w int) int {
69+ return (boxContentW - (w + 2*fieldPadX + fieldEdge)) / 2
70+}
71+
41 // hintFor is the line under the input: the attempt status first, then a note72 // hintFor is the line under the input: the attempt status first, then a note
42 // for prompts the bare field cannot convey. It never carries typed input.73 // for prompts the bare field cannot convey. It never carries typed input.
43 func hintFor(s nefergui.LockStatus, p promptKind) string {74 func hintFor(s nefergui.LockStatus, p promptKind) string {
@@ -49,7 +80,7 @@ func hintFor(s nefergui.LockStatus, p promptKind) string {
49 }80 }
50 switch p {81 switch p {
51 case promptFallback:82 case promptFallback:
52- return "PIN unavailable, use password"83+ return "PIN off, use password"
53 case promptUnavailable:84 case promptUnavailable:
54 return "Authentication unavailable"85 return "Authentication unavailable"
55 case promptMore:86 case promptMore:
@@ -59,12 +90,12 @@ func hintFor(s nefergui.LockStatus, p promptKind) string {
59 }90 }
60 91
61 // maskFor is the field content and style: large dots for a PIN, bullets92 // maskFor is the field content and style: large dots for a PIN, bullets
62-// plus a cursor for a password.93+// plus a cursor for a password. The count is capped to the field width.
63 func maskFor(s nefergui.LockState, p promptKind) (string, string) {94 func maskFor(s nefergui.LockState, p promptKind) (string, string) {
64 if p == promptPIN {95 if p == promptPIN {
65- return strings.Repeat("●", s.Mask), cssMaskPIN96+ return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN
66 }97 }
67- return s.Bullets() + "_", cssMask98+ return strings.Repeat("•", min(s.Mask, maxPasswordMask)) + "_", cssMask
68 }99 }
69 100
70 // UnlockBox draws the date line, the box and the balancing spacer.101 // UnlockBox draws the date line, the box and the balancing spacer.
@@ -84,25 +115,28 @@ func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Ti
84 UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)115 UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
85 }116 }
86 117
87-// clockWake requests a redraw at every minute boundary until ctx ends, so118+// clockPoll is short because the monotonic timer stops during suspend: a
88-// the date line stays current. A pending request is enough: sends never block.119+// lock held across suspend must show the current minute soon after resume.
120+const clockPoll = 5 * time.Second
121+
122+// clockWake requests a redraw whenever the wall-clock minute changes, until
123+// done closes. Sends never block: a pending request is enough.
89 func clockWake(done <-chan struct{}, wake chan<- struct{}) {124 func clockWake(done <-chan struct{}, wake chan<- struct{}) {
90- t := time.NewTimer(untilNextMinute(time.Now()))125+ t := time.NewTicker(clockPoll)
91 defer t.Stop()126 defer t.Stop()
127+ shown := time.Now().Truncate(time.Minute)
92 for {128 for {
93 select {129 select {
94 case <-done:130 case <-done:
95 return131 return
96- case now := <-t.C:132+ case <-t.C:
97- select {133+ if m := time.Now().Truncate(time.Minute); !m.Equal(shown) {
98- case wake <- struct{}{}:134+ shown = m
99- default:135+ select {
136+ case wake <- struct{}{}:
137+ default:
138+ }
100 }139 }
101- t.Reset(untilNextMinute(now))
102 }140 }
103 }141 }
104 }142 }
105-
106-func untilNextMinute(now time.Time) time.Duration {
107- return now.Truncate(time.Minute).Add(time.Minute).Sub(now)
108-}
M internal/adapters/visual/view_test.go +40 -17
@@ -3,6 +3,7 @@ package visual
33 import (
44 "testing"
55 "time"
6+ "unicode/utf8"
67
78 "github.com/bnema/nefergui"
89 "github.com/stretchr/testify/require"
@@ -53,27 +54,49 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
5354 require.Equal(t, all, l.Anchors)
5455 }
5556
56-func TestHintsShowStatusThenPromptNotes(t *testing.T) {
57- require.Equal(t, "Denied", hintFor(nefergui.LockFailed, promptFallback))
58- require.Equal(t, "...", hintFor(nefergui.LockBusy, promptPIN))
59- require.Equal(t, "PIN unavailable, use password", hintFor(nefergui.LockIdle, promptFallback))
60- require.Equal(t, "Authentication unavailable", hintFor(nefergui.LockIdle, promptUnavailable))
61- require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPassword))
62- require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPIN))
57+func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
58+ for _, tc := range []struct {
59+ s nefergui.LockStatus
60+ p promptKind
61+ want string
62+ }{
63+ {nefergui.LockFailed, promptFallback, "Denied"},
64+ {nefergui.LockFailed, promptMore, "Denied"},
65+ {nefergui.LockBusy, promptPIN, "..."},
66+ {nefergui.LockIdle, promptFallback, "PIN off, use password"},
67+ {nefergui.LockIdle, promptUnavailable, "Authentication unavailable"},
68+ {nefergui.LockIdle, promptMore, "Enter again"},
69+ {nefergui.LockIdle, promptPassword, " "},
70+ {nefergui.LockIdle, promptPIN, " "},
71+ } {
72+ got := hintFor(tc.s, tc.p)
73+ require.Equal(t, tc.want, got)
74+ require.LessOrEqual(t, utf8.RuneCountInString(got), maxHintRunes, got)
75+ }
6376 }
6477
65-func TestMaskUsesCompactFieldForPIN(t *testing.T) {
66- s := nefergui.LockState{Mask: 3}
67- mask, css := maskFor(s, promptPIN)
78+func TestMaskIsCappedToTheField(t *testing.T) {
79+ mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN)
6880 require.Equal(t, "●●●", mask)
69- require.Equal(t, cssMaskPIN, css)
70- mask, css = maskFor(s, promptFallback)
81+ mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback)
7182 require.Equal(t, "•••_", mask)
72- require.Equal(t, cssMask, css)
83+ mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPIN)
84+ require.Equal(t, maxPINMask, utf8.RuneCountInString(mask))
85+ mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPassword)
86+ require.Equal(t, maxPasswordMask+1, utf8.RuneCountInString(mask))
7387 }
7488
75-func TestUntilNextMinute(t *testing.T) {
76- now := time.Date(2026, 10, 1, 15, 7, 45, 0, time.UTC)
77- require.Equal(t, 15*time.Second, untilNextMinute(now))
78- require.Equal(t, time.Minute, untilNextMinute(now.Truncate(time.Minute)))
89+func TestFieldsAreCenteredInTheBox(t *testing.T) {
90+ for _, w := range []int{passwordW, pinW} {
91+ outer := w + 2*fieldPadX + fieldEdge
92+ require.Equal(t, boxContentW-outer-fieldMargin(w), fieldMargin(w), w)
93+ }
94+}
95+
96+func TestClockWakeStopsWhenDone(t *testing.T) {
97+ done := make(chan struct{})
98+ stopped := make(chan struct{})
99+ go func() { defer close(stopped); clockWake(done, make(chan struct{})) }()
100+ close(done)
101+ <-stopped
79102 }
@@ -3,6 +3,7 @@ package visual
3 import (3 import (
4 "testing"4 "testing"
5 "time"5 "time"
6+ "unicode/utf8"
6 7
7 "github.com/bnema/nefergui"8 "github.com/bnema/nefergui"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -53,27 +54,49 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
53 require.Equal(t, all, l.Anchors)54 require.Equal(t, all, l.Anchors)
54 }55 }
55 56
56-func TestHintsShowStatusThenPromptNotes(t *testing.T) {57+func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
57- require.Equal(t, "Denied", hintFor(nefergui.LockFailed, promptFallback))58+ for _, tc := range []struct {
58- require.Equal(t, "...", hintFor(nefergui.LockBusy, promptPIN))59+ s nefergui.LockStatus
59- require.Equal(t, "PIN unavailable, use password", hintFor(nefergui.LockIdle, promptFallback))60+ p promptKind
60- require.Equal(t, "Authentication unavailable", hintFor(nefergui.LockIdle, promptUnavailable))61+ want string
61- require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPassword))62+ }{
62- require.Equal(t, " ", hintFor(nefergui.LockIdle, promptPIN))63+ {nefergui.LockFailed, promptFallback, "Denied"},
64+ {nefergui.LockFailed, promptMore, "Denied"},
65+ {nefergui.LockBusy, promptPIN, "..."},
66+ {nefergui.LockIdle, promptFallback, "PIN off, use password"},
67+ {nefergui.LockIdle, promptUnavailable, "Authentication unavailable"},
68+ {nefergui.LockIdle, promptMore, "Enter again"},
69+ {nefergui.LockIdle, promptPassword, " "},
70+ {nefergui.LockIdle, promptPIN, " "},
71+ } {
72+ got := hintFor(tc.s, tc.p)
73+ require.Equal(t, tc.want, got)
74+ require.LessOrEqual(t, utf8.RuneCountInString(got), maxHintRunes, got)
75+ }
63 }76 }
64 77
65-func TestMaskUsesCompactFieldForPIN(t *testing.T) {78+func TestMaskIsCappedToTheField(t *testing.T) {
66- s := nefergui.LockState{Mask: 3}79+ mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN)
67- mask, css := maskFor(s, promptPIN)
68 require.Equal(t, "●●●", mask)80 require.Equal(t, "●●●", mask)
69- require.Equal(t, cssMaskPIN, css)81+ mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback)
70- mask, css = maskFor(s, promptFallback)
71 require.Equal(t, "•••_", mask)82 require.Equal(t, "•••_", mask)
72- require.Equal(t, cssMask, css)83+ mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPIN)
84+ require.Equal(t, maxPINMask, utf8.RuneCountInString(mask))
85+ mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPassword)
86+ require.Equal(t, maxPasswordMask+1, utf8.RuneCountInString(mask))
73 }87 }
74 88
75-func TestUntilNextMinute(t *testing.T) {89+func TestFieldsAreCenteredInTheBox(t *testing.T) {
76- now := time.Date(2026, 10, 1, 15, 7, 45, 0, time.UTC)90+ for _, w := range []int{passwordW, pinW} {
77- require.Equal(t, 15*time.Second, untilNextMinute(now))91+ outer := w + 2*fieldPadX + fieldEdge
78- require.Equal(t, time.Minute, untilNextMinute(now.Truncate(time.Minute)))92+ require.Equal(t, boxContentW-outer-fieldMargin(w), fieldMargin(w), w)
93+ }
94+}
95+
96+func TestClockWakeStopsWhenDone(t *testing.T) {
97+ done := make(chan struct{})
98+ stopped := make(chan struct{})
99+ go func() { defer close(stopped); clockWake(done, make(chan struct{})) }()
100+ close(done)
101+ <-stopped
79 }102 }

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 2

Tags 9

↑↓ moveenter openesc close