Merge pull request #7 from bnema/feat/lock-screen-style
feat(visual): restyle lock screen around a centered minimal input
3 files · +164 -42
M internal/adapters/visual/lock.go +4 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "io" | |
| 7 | 7 | "os" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/neferafk/internal/adapters/visualproc" | |
| 10 | 11 | "github.com/bnema/neferafk/internal/ports" | |
| @@ -214,7 +215,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun { | |||
| 214 | 215 | return f | |
| 215 | 216 | } | |
| 216 | 217 | ||
| 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 | |
| 218 | 219 | // lock context is detached from process signals: it ends only with RunLock. | |
| 219 | 220 | func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun { | |
| 220 | 221 | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| @@ -224,6 +225,7 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun { | |||
| 224 | 225 | done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1), | |
| 225 | 226 | } | |
| 226 | 227 | go func() { defer close(l.authDone); ctl.run(lctx) }() | |
| 228 | + | go clockWake(lctx.Done(), ctl.wake) | |
| 227 | 229 | go func() { | |
| 228 | 230 | secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret) | |
| 229 | 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 | 242 | return nefergui.LockConfig{ | |
| 241 | 243 | Secret: secret, | |
| 242 | 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 | 247 | OnLocked: func() { | |
| 246 | 248 | 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 f | 215 | return f | ||
| 215 | } | 216 | } | ||
| 216 | 217 | ||||
| 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 |
| 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 @@ | |||
| 1 | 1 | package visual | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 4 | 8 | "github.com/bnema/nefergui" | |
| 5 | 9 | ) | |
| 6 | 10 | ||
| 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. | |
| 10 | 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 | 39 | cssScreen = "display:flex;flex-direction:column;align-items:center;justify-content:center;" + | |
| 12 | 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;" + | |
| 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" | |
| 20 | 49 | ) | |
| 21 | 50 | ||
| 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 | |
| 37 | 68 | } | |
| 38 | 69 | ||
| 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 { | |
| 41 | 73 | switch s { | |
| 42 | 74 | case nefergui.LockBusy: | |
| 43 | 75 | return "..." | |
| 44 | 76 | case nefergui.LockFailed: | |
| 45 | 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 | 87 | return " " | |
| 48 | 88 | } | |
| 49 | 89 | ||
| 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)) | |
| 56 | 109 | } | |
| 57 | 110 | ||
| 58 | 111 | // 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 | + | } | |
| 61 | 140 | } | |
| @@ -1,61 +1,140 @@ | |||||
| 1 | package visual | 1 | 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 only | 12 | + | // 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 | |||
| 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,9 +54,49 @@ func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) { | |||
| 53 | 54 | require.Equal(t, all, l.Anchors) | |
| 54 | 55 | } | |
| 55 | 56 | ||
| 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 | |
| 61 | 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,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 | } | ||