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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 215 | 215 | return f | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 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 | |
| 219 | 219 | // lock context is detached from process signals: it ends only with RunLock. | |
| 220 | 220 | func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun { | |
| 221 | 221 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| @@ -215,7 +215,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun { | |||||
| 215 | return f | 215 | return f | ||
| 216 | } | 216 | } | ||
| 217 | 217 | ||||
| 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 |
| 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 @@ | |||
| 1 | 1 | package visual | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -12,25 +13,37 @@ import ( | |||
| 12 | 13 | // equal-height spacer below it balance each other, and an empty spacer inside | |
| 13 | 14 | // the box balances the status line under the input. The view sees only the | |
| 14 | 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 | 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 | 40 | cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" + | |
| 17 | 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 | 42 | cssClock = "color:#ffffff;text-align:right;height:24px;margin-bottom:8px" | |
| 21 | 43 | // cssBalance mirrors cssClock below the box. | |
| 22 | 44 | 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)" | |
| 34 | 47 | // cssHint and cssHintBalance have the same outer height. | |
| 35 | 48 | cssHint = "color:#ffffff;margin-top:12px;height:22px;text-align:center" | |
| 36 | 49 | cssHintBalance = "margin-bottom:12px;height:22px" | |
| @@ -38,6 +51,24 @@ const ( | |||
| 38 | 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 | 72 | // hintFor is the line under the input: the attempt status first, then a note | |
| 42 | 73 | // for prompts the bare field cannot convey. It never carries typed input. | |
| 43 | 74 | func hintFor(s nefergui.LockStatus, p promptKind) string { | |
| @@ -49,7 +80,7 @@ func hintFor(s nefergui.LockStatus, p promptKind) string { | |||
| 49 | 80 | } | |
| 50 | 81 | switch p { | |
| 51 | 82 | case promptFallback: | |
| 52 | - | return "PIN unavailable, use password" | |
| 83 | + | return "PIN off, use password" | |
| 53 | 84 | case promptUnavailable: | |
| 54 | 85 | return "Authentication unavailable" | |
| 55 | 86 | case promptMore: | |
| @@ -59,12 +90,12 @@ func hintFor(s nefergui.LockStatus, p promptKind) string { | |||
| 59 | 90 | } | |
| 60 | 91 | ||
| 61 | 92 | // 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 | 94 | func maskFor(s nefergui.LockState, p promptKind) (string, string) { | |
| 64 | 95 | if p == promptPIN { | |
| 65 | - | return strings.Repeat("●", s.Mask), cssMaskPIN | |
| 96 | + | return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN | |
| 66 | 97 | } | |
| 67 | - | return s.Bullets() + "_", cssMask | |
| 98 | + | return strings.Repeat("•", min(s.Mask, maxPasswordMask)) + "_", cssMask | |
| 68 | 99 | } | |
| 69 | 100 | ||
| 70 | 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 | 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, 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. | |
| 89 | 124 | func clockWake(done <-chan struct{}, wake chan<- struct{}) { | |
| 90 | - | t := time.NewTimer(untilNextMinute(time.Now())) | |
| 125 | + | t := time.NewTicker(clockPoll) | |
| 91 | 126 | defer t.Stop() | |
| 127 | + | shown := time.Now().Truncate(time.Minute) | |
| 92 | 128 | for { | |
| 93 | 129 | select { | |
| 94 | 130 | case <-done: | |
| 95 | 131 | 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 | + | } | |
| 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 | - | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package visual | 1 | 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 inside | 13 | // 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 the | 14 | // 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 note | 72 | // 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, bullets | 92 | // 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), cssMaskPIN | 96 | + | return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN |
| 66 | } | 97 | } | ||
| 67 | - | return s.Bullets() + "_", cssMask | 98 | + | 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, so | 118 | + | // 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 | return | 131 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | "time" | |
| 6 | + | "unicode/utf8" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/nefergui" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| @@ -53,27 +54,49 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | |||
| 53 | 54 | require.Equal(t, all, l.Anchors) | |
| 54 | 55 | } | |
| 55 | 56 | ||
| 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 | + | } | |
| 63 | 76 | } | |
| 64 | 77 | ||
| 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) | |
| 68 | 80 | require.Equal(t, "●●●", mask) | |
| 69 | - | require.Equal(t, cssMaskPIN, css) | |
| 70 | - | mask, css = maskFor(s, promptFallback) | |
| 81 | + | mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback) | |
| 71 | 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) { | |
| 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 | |
| 79 | 102 | } | |
| @@ -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 | } | ||