detached 0a8834d

Merge pull request #7 from bnema/feat/lock-screen-style

feat(visual): restyle lock screen around a centered minimal input
commit
0a8834dda6040ce762aff34ced11ac67f1bdae98
author
bnema · · 2026-10-01 16:11 UTC
committer
GitHub · · 2026-10-01 16:11 UTC
merge of
565bd35 + 1fd2245 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.0
3 files · +164 -42
M internal/adapters/visual/lock.go +4 -2
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "io"
77 "os"
8+ "time"
89
910 "github.com/bnema/neferafk/internal/adapters/visualproc"
1011 "github.com/bnema/neferafk/internal/ports"
@@ -214,7 +215,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun {
214215 return f
215216 }
216217
217-// 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
218219 // lock context is detached from process signals: it ends only with RunLock.
219220 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
220221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
@@ -224,6 +225,7 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
224225 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),
225226 }
226227 go func() { defer close(l.authDone); ctl.run(lctx) }()
228+ go clockWake(lctx.Done(), ctl.wake)
227229 go func() {
228230 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
229231 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
@@ -240,7 +242,7 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre
240242 return nefergui.LockConfig{
241243 Secret: secret,
242244 View: func(f *nefergui.Frame, st nefergui.LockState) {
243- lockView(f, st, promptKind(ctl.prompt.Load()))
245+ lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
244246 },
245247 OnLocked: func() {
246248 select {
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "io"6 "io"
7 "os"7 "os"
8+ "time"
8 9
9 "github.com/bnema/neferafk/internal/adapters/visualproc"10 "github.com/bnema/neferafk/internal/adapters/visualproc"
10 "github.com/bnema/neferafk/internal/ports"11 "github.com/bnema/neferafk/internal/ports"
@@ -214,7 +215,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun {
214 return f215 return f
215 }216 }
216 217
217-// 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
218 // 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.
219 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {220 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
220 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
@@ -224,6 +225,7 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
224 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),225 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),
225 }226 }
226 go func() { defer close(l.authDone); ctl.run(lctx) }()227 go func() { defer close(l.authDone); ctl.run(lctx) }()
228+ go clockWake(lctx.Done(), ctl.wake)
227 go func() {229 go func() {
228 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)230 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
229 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))231 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
@@ -240,7 +242,7 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre
240 return nefergui.LockConfig{242 return nefergui.LockConfig{
241 Secret: secret,243 Secret: secret,
242 View: func(f *nefergui.Frame, st nefergui.LockState) {244 View: func(f *nefergui.Frame, st nefergui.LockState) {
243- lockView(f, st, promptKind(ctl.prompt.Load()))245+ lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
244 },246 },
245 OnLocked: func() {247 OnLocked: func() {
246 select {248 select {
M internal/adapters/visual/view.go +114 -35
@@ -1,61 +1,140 @@
11 package visual
22
33 import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
48 "github.com/bnema/nefergui"
59 )
610
7-// V1 lock look: black screen, one centered monospace box with a white border,
8-// masked input (bullets only). No clock, avatar or widgets. The view sees only
9-// the typed-character count, never the secret.
11+// Lock look: black screen, one monospace box with a grey border. The masked
12+// input sits at the exact screen center: the date line above the box and an
13+// equal-height spacer below it balance each other, and an empty spacer inside
14+// the box balances the status line under the input. The view sees only the
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.
1020 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+
29+ // boxContentW is the width available to the field inside the box.
30+ boxContentW = frameW - 2*boxBorder - 2*boxPadding
31+
32+ // Monospace advance at 18px is about 10.8px: 28 characters fit the
33+ // box content, 24 bullets plus the cursor fit the password field, and
34+ // 7 dots at 24px plus spacing fit the PIN field.
35+ maxHintRunes = 28
36+ maxPasswordMask = 24
37+ maxPINMask = 7
38+
1139 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
1240 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
13- cssBox = "display:flex;flex-direction:column;align-items:center;border:2px solid #ffffff;" +
14- "padding:24px;min-width:320px;background-color:#000000"
15- cssLabel = "color:#ffffff"
16- // The input field: a visible white-underlined line, bullets plus a cursor.
17- cssMask = "color:#ffffff;min-height:28px;min-width:280px;margin-top:12px;padding:4px 8px;" +
18- "border-bottom:2px solid #ffffff"
19- cssHint = "color:#ffffff;margin-top:12px;min-height:22px"
41+ cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"
42+ // cssBalance mirrors cssClock below the box.
43+ cssBalance = "height:24px;margin-top:8px"
44+ // cssHint and cssHintBalance have the same outer height.
45+ cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"
46+ cssHintBalance = "margin-bottom:12px;height:22px"
47+
48+ clockLayout = "Mon 02 Jan 2006 15:04"
2049 )
2150
22-// labelFor is the fixed, non-secret caption for the requested credential.
23-func labelFor(p promptKind) string {
24- switch p {
25- case promptPIN:
26- return "PIN"
27- case promptFallback:
28- return "Password (PIN unavailable)"
29- case promptUnavailable:
30- return "Authentication unavailable"
31- case promptMore:
32- return "Password (again)"
33- case promptPassword:
34- return "Password"
35- }
36- return "Locked"
51+var (
52+ // cssRelief is the inverted relief: bottom and right edges at 50% white.
53+ cssRelief = fmt.Sprintf("border-bottom:%[1]dpx solid rgba(255,255,255,0.5);"+
54+ "border-right:%[1]dpx solid rgba(255,255,255,0.5)", fieldEdge)
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+ cssMaskPIN = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
61+ "padding:4px %dpx;margin-left:%dpx;font-size:24px;letter-spacing:6px;text-align:center;%s",
62+ pinW, fieldPadX, fieldMargin(pinW), cssRelief)
63+)
64+
65+// fieldMargin centers a field of content width w inside the box content.
66+func fieldMargin(w int) int {
67+ return (boxContentW - (w + 2*fieldPadX + fieldEdge)) / 2
3768 }
3869
39-// hintFor is the minimal failure/busy line.
40-func hintFor(s nefergui.LockStatus) string {
70+// hintFor is the line under the input: the attempt status first, then a note
71+// for prompts the bare field cannot convey. It never carries typed input.
72+func hintFor(s nefergui.LockStatus, p promptKind) string {
4173 switch s {
4274 case nefergui.LockBusy:
4375 return "..."
4476 case nefergui.LockFailed:
4577 return "Denied"
4678 }
79+ switch p {
80+ case promptFallback:
81+ return "PIN off, use password"
82+ case promptUnavailable:
83+ return "Authentication unavailable"
84+ case promptMore:
85+ return "Enter again"
86+ }
4787 return " "
4888 }
4989
50-// UnlockBox draws the centered box.
51-func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind) {
52- box := root.Column(nefergui.Class("box"), nefergui.Inline(cssBox))
53- box.Text(labelFor(p), nefergui.Key("label"), nefergui.Inline(cssLabel))
54- box.Text(s.Bullets()+"_", nefergui.Key("mask"), nefergui.Inline(cssMask))
55- box.Text(hintFor(s.Status), nefergui.Key("hint"), nefergui.Inline(cssHint))
90+// maskFor is the field content and style: large dots for a PIN, bullets
91+// plus a cursor for a password. The count is capped to the field width.
92+func maskFor(s nefergui.LockState, p promptKind) (string, string) {
93+ if p == promptPIN {
94+ return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN
95+ }
96+ return strings.Repeat("•", min(s.Mask, maxPasswordMask)) + "_", cssMask
97+}
98+
99+// UnlockBox draws the date line, the box and the balancing spacer.
100+func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.Time) {
101+ frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))
102+ frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))
103+ box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))
104+ box.Spacer(nefergui.Key("hint-balance"), nefergui.Inline(cssHintBalance))
105+ mask, css := maskFor(s, p)
106+ box.Text(mask, nefergui.Key("mask"), nefergui.Inline(css))
107+ box.Text(hintFor(s.Status, p), nefergui.Key("hint"), nefergui.Inline(cssHint))
108+ frame.Spacer(nefergui.Key("balance"), nefergui.Inline(cssBalance))
56109 }
57110
58111 // lockView builds the whole lock screen.
59-func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind) {
60- UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p)
112+func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Time) {
113+ UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
114+}
115+
116+// clockPoll is short because the monotonic timer stops during suspend: a
117+// lock held across suspend must show the current minute soon after resume.
118+const clockPoll = 5 * time.Second
119+
120+// clockWake requests a redraw whenever the wall-clock minute changes, until
121+// done closes. Sends never block: a pending request is enough.
122+func clockWake(done <-chan struct{}, wake chan<- struct{}) {
123+ t := time.NewTicker(clockPoll)
124+ defer t.Stop()
125+ shown := time.Now().Truncate(time.Minute)
126+ for {
127+ select {
128+ case <-done:
129+ return
130+ case <-t.C:
131+ if m := time.Now().Truncate(time.Minute); !m.Equal(shown) {
132+ shown = m
133+ select {
134+ case wake <- struct{}{}:
135+ default:
136+ }
137+ }
138+ }
139+ }
61140 }
@@ -1,61 +1,140 @@
1 package visual1 package visual
2 2
3 import (3 import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
4 "github.com/bnema/nefergui"8 "github.com/bnema/nefergui"
5 )9 )
6 10
7-// V1 lock look: black screen, one centered monospace box with a white border,11+// Lock look: black screen, one monospace box with a grey border. The masked
8-// masked input (bullets only). No clock, avatar or widgets. The view sees only12+// input sits at the exact screen center: the date line above the box and an
9-// the typed-character count, never the secret.13+// equal-height spacer below it balance each other, and an empty spacer inside
14+// the box balances the status line under the input. The view sees only the
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.
10 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+
29+ // boxContentW is the width available to the field inside the box.
30+ boxContentW = frameW - 2*boxBorder - 2*boxPadding
31+
32+ // Monospace advance at 18px is about 10.8px: 28 characters fit the
33+ // box content, 24 bullets plus the cursor fit the password field, and
34+ // 7 dots at 24px plus spacing fit the PIN field.
35+ maxHintRunes = 28
36+ maxPasswordMask = 24
37+ maxPINMask = 7
38+
11 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +39 cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" +
12 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"40 "width:100%;height:100%;background-color:#000000;color:#ffffff;font-family:monospace;font-size:18px"
13- cssBox = "display:flex;flex-direction:column;align-items:center;border:2px solid #ffffff;" +41+ cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px"
14- "padding:24px;min-width:320px;background-color:#000000"42+ // cssBalance mirrors cssClock below the box.
15- cssLabel = "color:#ffffff"43+ cssBalance = "height:24px;margin-top:8px"
16- // The input field: a visible white-underlined line, bullets plus a cursor.44+ // cssHint and cssHintBalance have the same outer height.
17- cssMask = "color:#ffffff;min-height:28px;min-width:280px;margin-top:12px;padding:4px 8px;" +45+ cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center"
18- "border-bottom:2px solid #ffffff"46+ cssHintBalance = "margin-bottom:12px;height:22px"
19- cssHint = "color:#ffffff;margin-top:12px;min-height:22px"47+
48+ clockLayout = "Mon 02 Jan 2006 15:04"
20 )49 )
21 50
22-// labelFor is the fixed, non-secret caption for the requested credential.51+var (
23-func labelFor(p promptKind) string {52+ // cssRelief is the inverted relief: bottom and right edges at 50% white.
24- switch p {53+ cssRelief = fmt.Sprintf("border-bottom:%[1]dpx solid rgba(255,255,255,0.5);"+
25- case promptPIN:54+ "border-right:%[1]dpx solid rgba(255,255,255,0.5)", fieldEdge)
26- return "PIN"55+ cssFrame = fmt.Sprintf("display:flex;flex-direction:column;align-items:stretch;width:%dpx", frameW)
27- case promptFallback:56+ cssBox = fmt.Sprintf("display:flex;flex-direction:column;align-items:stretch;"+
28- return "Password (PIN unavailable)"57+ "border:%dpx solid #bfbfbf;padding:%dpx;background-color:#000000", boxBorder, boxPadding)
29- case promptUnavailable:58+ cssMask = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
30- return "Authentication unavailable"59+ "padding:4px %dpx;margin-left:%dpx;%s", passwordW, fieldPadX, fieldMargin(passwordW), cssRelief)
31- case promptMore:60+ cssMaskPIN = fmt.Sprintf("color:#ffffff;height:32px;line-height:32px;width:%dpx;"+
32- return "Password (again)"61+ "padding:4px %dpx;margin-left:%dpx;font-size:24px;letter-spacing:6px;text-align:center;%s",
33- case promptPassword:62+ pinW, fieldPadX, fieldMargin(pinW), cssRelief)
34- return "Password"63+)
35- }64+
36- return "Locked"65+// fieldMargin centers a field of content width w inside the box content.
66+func fieldMargin(w int) int {
67+ return (boxContentW - (w + 2*fieldPadX + fieldEdge)) / 2
37 }68 }
38 69
39-// hintFor is the minimal failure/busy line.70+// hintFor is the line under the input: the attempt status first, then a note
40-func hintFor(s nefergui.LockStatus) string {71+// for prompts the bare field cannot convey. It never carries typed input.
72+func hintFor(s nefergui.LockStatus, p promptKind) string {
41 switch s {73 switch s {
42 case nefergui.LockBusy:74 case nefergui.LockBusy:
43 return "..."75 return "..."
44 case nefergui.LockFailed:76 case nefergui.LockFailed:
45 return "Denied"77 return "Denied"
46 }78 }
79+ switch p {
80+ case promptFallback:
81+ return "PIN off, use password"
82+ case promptUnavailable:
83+ return "Authentication unavailable"
84+ case promptMore:
85+ return "Enter again"
86+ }
47 return " "87 return " "
48 }88 }
49 89
50-// UnlockBox draws the centered box.90+// maskFor is the field content and style: large dots for a PIN, bullets
51-func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind) {91+// plus a cursor for a password. The count is capped to the field width.
52- box := root.Column(nefergui.Class("box"), nefergui.Inline(cssBox))92+func maskFor(s nefergui.LockState, p promptKind) (string, string) {
53- box.Text(labelFor(p), nefergui.Key("label"), nefergui.Inline(cssLabel))93+ if p == promptPIN {
54- box.Text(s.Bullets()+"_", nefergui.Key("mask"), nefergui.Inline(cssMask))94+ return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN
55- box.Text(hintFor(s.Status), nefergui.Key("hint"), nefergui.Inline(cssHint))95+ }
96+ return strings.Repeat("•", min(s.Mask, maxPasswordMask)) + "_", cssMask
97+}
98+
99+// UnlockBox draws the date line, the box and the balancing spacer.
100+func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.Time) {
101+ frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))
102+ frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))
103+ box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))
104+ box.Spacer(nefergui.Key("hint-balance"), nefergui.Inline(cssHintBalance))
105+ mask, css := maskFor(s, p)
106+ box.Text(mask, nefergui.Key("mask"), nefergui.Inline(css))
107+ box.Text(hintFor(s.Status, p), nefergui.Key("hint"), nefergui.Inline(cssHint))
108+ frame.Spacer(nefergui.Key("balance"), nefergui.Inline(cssBalance))
56 }109 }
57 110
58 // lockView builds the whole lock screen.111 // lockView builds the whole lock screen.
59-func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind) {112+func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Time) {
60- UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p)113+ UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
114+}
115+
116+// clockPoll is short because the monotonic timer stops during suspend: a
117+// lock held across suspend must show the current minute soon after resume.
118+const clockPoll = 5 * time.Second
119+
120+// clockWake requests a redraw whenever the wall-clock minute changes, until
121+// done closes. Sends never block: a pending request is enough.
122+func clockWake(done <-chan struct{}, wake chan<- struct{}) {
123+ t := time.NewTicker(clockPoll)
124+ defer t.Stop()
125+ shown := time.Now().Truncate(time.Minute)
126+ for {
127+ select {
128+ case <-done:
129+ return
130+ case <-t.C:
131+ if m := time.Now().Truncate(time.Minute); !m.Equal(shown) {
132+ shown = m
133+ select {
134+ case wake <- struct{}{}:
135+ default:
136+ }
137+ }
138+ }
139+ }
61 }140 }
M internal/adapters/visual/view_test.go +46 -5
@@ -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,9 +54,49 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
5354 require.Equal(t, all, l.Anchors)
5455 }
5556
56-func TestLabelsAndHintsNeverCarryInput(t *testing.T) {
57- require.Equal(t, "Locked", labelFor(promptNone))
58- require.Equal(t, "PIN", labelFor(promptPIN))
59- require.Equal(t, "Denied", hintFor(nefergui.LockFailed))
60- require.Equal(t, "...", hintFor(nefergui.LockBusy))
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+ }
76+}
77+
78+func TestMaskIsCappedToTheField(t *testing.T) {
79+ mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN)
80+ require.Equal(t, "●●●", mask)
81+ mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback)
82+ require.Equal(t, "•••_", mask)
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))
87+}
88+
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
61102 }
@@ -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,9 +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 TestLabelsAndHintsNeverCarryInput(t *testing.T) {57+func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
57- require.Equal(t, "Locked", labelFor(promptNone))58+ for _, tc := range []struct {
58- require.Equal(t, "PIN", labelFor(promptPIN))59+ s nefergui.LockStatus
59- require.Equal(t, "Denied", hintFor(nefergui.LockFailed))60+ p promptKind
60- require.Equal(t, "...", hintFor(nefergui.LockBusy))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+ }
76+}
77+
78+func TestMaskIsCappedToTheField(t *testing.T) {
79+ mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN)
80+ require.Equal(t, "●●●", mask)
81+ mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback)
82+ require.Equal(t, "•••_", mask)
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))
87+}
88+
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
61 }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