feat(ui): render snatched-modal key hints as keycap badges
Replace the fully accent-highlighted hint line with per-key badges: the key gets an accent background over ' key ' padding via the new theme style HintKey, labels stay plain, and the q/Esc pair renders as two badges with a muted slash. Applies to both the bordered modal and the compact fallback.
- commit
- 50c5c14f2044dcc4e856ae87e94397223263a1cc
- author
- brice · · 2026-07-29 13:50 UTC
- parent
- fc46d25
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +56 -17
M internal/usecase/daemon/snatched_render.go +45 -11
| @@ -30,7 +30,15 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string | |||
| 30 | 30 | message = feedback | |
| 31 | 31 | } | |
| 32 | 32 | drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase) | |
| 33 | - | drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection) | |
| 33 | + | drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{ | |
| 34 | + | {" r ", styles.HintKey}, | |
| 35 | + | {" Resume here", styles.PickerBase}, | |
| 36 | + | {" ", styles.PickerBase}, | |
| 37 | + | {" q ", styles.HintKey}, | |
| 38 | + | {" / ", styles.PickerDescription}, | |
| 39 | + | {" Esc ", styles.HintKey}, | |
| 40 | + | {" Quit", styles.PickerBase}, | |
| 41 | + | }) | |
| 34 | 42 | return frame | |
| 35 | 43 | } | |
| 36 | 44 | ||
| @@ -39,20 +47,46 @@ func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback s | |||
| 39 | 47 | if feedback != "" { | |
| 40 | 48 | status = "Session unavailable" | |
| 41 | 49 | } | |
| 42 | - | lines := []string{status, "r Resume", "q Quit"} | |
| 43 | - | startY := max((frame.Height-len(lines))/2, 0) | |
| 44 | - | for i, line := range lines { | |
| 45 | - | drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase) | |
| 46 | - | } | |
| 50 | + | bounds := domain.Rect{Width: frame.Width, Height: frame.Height} | |
| 51 | + | startY := max((frame.Height-3)/2, 0) | |
| 52 | + | drawCenteredLine(frame, bounds, startY, status, styles.PickerBase) | |
| 53 | + | drawCenteredSegments(frame, bounds, startY+1, []textSegment{ | |
| 54 | + | {" r ", styles.HintKey}, | |
| 55 | + | {" Resume", styles.PickerBase}, | |
| 56 | + | }) | |
| 57 | + | drawCenteredSegments(frame, bounds, startY+2, []textSegment{ | |
| 58 | + | {" q ", styles.HintKey}, | |
| 59 | + | {" Quit", styles.PickerBase}, | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // textSegment is one differently-styled run within a centered hint line. | |
| 64 | + | type textSegment struct { | |
| 65 | + | text string | |
| 66 | + | style renderer.Style | |
| 47 | 67 | } | |
| 48 | 68 | ||
| 49 | 69 | func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) { | |
| 50 | - | width := 0 | |
| 51 | - | for _, r := range text { | |
| 52 | - | width += renderer.RuneWidth(r) | |
| 70 | + | drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}}) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // drawCenteredSegments draws consecutive differently-styled segments as one | |
| 74 | + | // logical line, centered as a whole within bounds. | |
| 75 | + | func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) { | |
| 76 | + | total := 0 | |
| 77 | + | for _, seg := range segments { | |
| 78 | + | for _, r := range seg.text { | |
| 79 | + | total += renderer.RuneWidth(r) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | x := bounds.X + max((bounds.Width-total)/2, 0) | |
| 83 | + | limit := bounds.X + bounds.Width | |
| 84 | + | for _, seg := range segments { | |
| 85 | + | ui.DrawText(frame, x, y, limit, seg.text, seg.style) | |
| 86 | + | for _, r := range seg.text { | |
| 87 | + | x += renderer.RuneWidth(r) | |
| 88 | + | } | |
| 53 | 89 | } | |
| 54 | - | x := bounds.X + max((bounds.Width-width)/2, 0) | |
| 55 | - | ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style) | |
| 56 | 90 | } | |
| 57 | 91 | ||
| 58 | 92 | var errSnatchedOutputStale = errors.New("snatched output role changed") | |
| @@ -30,7 +30,15 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string | |||||
| 30 | message = feedback | 30 | message = feedback | ||
| 31 | } | 31 | } | ||
| 32 | drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase) | 32 | drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase) | ||
| 33 | - | drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection) | 33 | + | drawCenteredSegments(frame, inner, inner.Y+3, []textSegment{ |
| 34 | + | {" r ", styles.HintKey}, | |||
| 35 | + | {" Resume here", styles.PickerBase}, | |||
| 36 | + | {" ", styles.PickerBase}, | |||
| 37 | + | {" q ", styles.HintKey}, | |||
| 38 | + | {" / ", styles.PickerDescription}, | |||
| 39 | + | {" Esc ", styles.HintKey}, | |||
| 40 | + | {" Quit", styles.PickerBase}, | |||
| 41 | + | }) | |||
| 34 | return frame | 42 | return frame | ||
| 35 | } | 43 | } | ||
| 36 | 44 | ||||
| @@ -39,20 +47,46 @@ func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback s | |||||
| 39 | if feedback != "" { | 47 | if feedback != "" { | ||
| 40 | status = "Session unavailable" | 48 | status = "Session unavailable" | ||
| 41 | } | 49 | } | ||
| 42 | - | lines := []string{status, "r Resume", "q Quit"} | 50 | + | bounds := domain.Rect{Width: frame.Width, Height: frame.Height} |
| 43 | - | startY := max((frame.Height-len(lines))/2, 0) | 51 | + | startY := max((frame.Height-3)/2, 0) |
| 44 | - | for i, line := range lines { | 52 | + | drawCenteredLine(frame, bounds, startY, status, styles.PickerBase) |
| 45 | - | drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase) | 53 | + | drawCenteredSegments(frame, bounds, startY+1, []textSegment{ |
| 46 | - | } | 54 | + | {" r ", styles.HintKey}, |
| 55 | + | {" Resume", styles.PickerBase}, | |||
| 56 | + | }) | |||
| 57 | + | drawCenteredSegments(frame, bounds, startY+2, []textSegment{ | |||
| 58 | + | {" q ", styles.HintKey}, | |||
| 59 | + | {" Quit", styles.PickerBase}, | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // textSegment is one differently-styled run within a centered hint line. | |||
| 64 | + | type textSegment struct { | |||
| 65 | + | text string | |||
| 66 | + | style renderer.Style | |||
| 47 | } | 67 | } | ||
| 48 | 68 | ||||
| 49 | func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) { | 69 | func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) { | ||
| 50 | - | width := 0 | 70 | + | drawCenteredSegments(frame, bounds, y, []textSegment{{text, style}}) |
| 51 | - | for _, r := range text { | 71 | + | } |
| 52 | - | width += renderer.RuneWidth(r) | 72 | + | |
| 73 | + | // drawCenteredSegments draws consecutive differently-styled segments as one | |||
| 74 | + | // logical line, centered as a whole within bounds. | |||
| 75 | + | func drawCenteredSegments(frame renderer.Frame, bounds domain.Rect, y int, segments []textSegment) { | |||
| 76 | + | total := 0 | |||
| 77 | + | for _, seg := range segments { | |||
| 78 | + | for _, r := range seg.text { | |||
| 79 | + | total += renderer.RuneWidth(r) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | x := bounds.X + max((bounds.Width-total)/2, 0) | |||
| 83 | + | limit := bounds.X + bounds.Width | |||
| 84 | + | for _, seg := range segments { | |||
| 85 | + | ui.DrawText(frame, x, y, limit, seg.text, seg.style) | |||
| 86 | + | for _, r := range seg.text { | |||
| 87 | + | x += renderer.RuneWidth(r) | |||
| 88 | + | } | |||
| 53 | } | 89 | } | ||
| 54 | - | x := bounds.X + max((bounds.Width-width)/2, 0) | |||
| 55 | - | ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style) | |||
| 56 | } | 90 | } | ||
| 57 | 91 | ||||
| 58 | var errSnatchedOutputStale = errors.New("snatched output role changed") | 92 | var errSnatchedOutputStale = errors.New("snatched output role changed") | ||
M internal/usecase/daemon/snatched_render_test.go +4 -4
| @@ -34,14 +34,14 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 34 | 34 | name: "centered bordered panel", | |
| 35 | 35 | size: domain.Size{Cols: 80, Rows: 24}, | |
| 36 | 36 | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 37 | - | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 37 | + | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 38 | 38 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 39 | 39 | }, | |
| 40 | 40 | { | |
| 41 | 41 | name: "compact fallback", | |
| 42 | 42 | size: domain.Size{Cols: 24, Rows: 4}, | |
| 43 | 43 | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 44 | - | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 44 | + | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 45 | 45 | compact: true, | |
| 46 | 46 | }, | |
| 47 | 47 | { | |
| @@ -49,7 +49,7 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 49 | 49 | size: domain.Size{Cols: 24, Rows: 4}, | |
| 50 | 50 | feedback: "Session is no longer available.", | |
| 51 | 51 | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 52 | - | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 52 | + | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 53 | 53 | compact: true, | |
| 54 | 54 | }, | |
| 55 | 55 | { | |
| @@ -57,7 +57,7 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 57 | 57 | size: domain.Size{Cols: 80, Rows: 24}, | |
| 58 | 58 | feedback: "Session is no longer available.", | |
| 59 | 59 | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 60 | - | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 60 | + | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 61 | 61 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 62 | 62 | }, | |
| 63 | 63 | { | |
| @@ -34,14 +34,14 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 34 | name: "centered bordered panel", | 34 | name: "centered bordered panel", | ||
| 35 | size: domain.Size{Cols: 80, Rows: 24}, | 35 | size: domain.Size{Cols: 80, Rows: 24}, | ||
| 36 | wantSize: domain.Size{Cols: 80, Rows: 24}, | 36 | wantSize: domain.Size{Cols: 80, Rows: 24}, | ||
| 37 | - | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | 37 | + | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, |
| 38 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | 38 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | ||
| 39 | }, | 39 | }, | ||
| 40 | { | 40 | { | ||
| 41 | name: "compact fallback", | 41 | name: "compact fallback", | ||
| 42 | size: domain.Size{Cols: 24, Rows: 4}, | 42 | size: domain.Size{Cols: 24, Rows: 4}, | ||
| 43 | wantSize: domain.Size{Cols: 24, Rows: 4}, | 43 | wantSize: domain.Size{Cols: 24, Rows: 4}, | ||
| 44 | - | want: []string{"Session snatched", "r Resume", "q Quit"}, | 44 | + | want: []string{"Session snatched", "r Resume", "q Quit"}, |
| 45 | compact: true, | 45 | compact: true, | ||
| 46 | }, | 46 | }, | ||
| 47 | { | 47 | { | ||
| @@ -49,7 +49,7 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 49 | size: domain.Size{Cols: 24, Rows: 4}, | 49 | size: domain.Size{Cols: 24, Rows: 4}, | ||
| 50 | feedback: "Session is no longer available.", | 50 | feedback: "Session is no longer available.", | ||
| 51 | wantSize: domain.Size{Cols: 24, Rows: 4}, | 51 | wantSize: domain.Size{Cols: 24, Rows: 4}, | ||
| 52 | - | want: []string{"Session unavailable", "r Resume", "q Quit"}, | 52 | + | want: []string{"Session unavailable", "r Resume", "q Quit"}, |
| 53 | compact: true, | 53 | compact: true, | ||
| 54 | }, | 54 | }, | ||
| 55 | { | 55 | { | ||
| @@ -57,7 +57,7 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 57 | size: domain.Size{Cols: 80, Rows: 24}, | 57 | size: domain.Size{Cols: 80, Rows: 24}, | ||
| 58 | feedback: "Session is no longer available.", | 58 | feedback: "Session is no longer available.", | ||
| 59 | wantSize: domain.Size{Cols: 80, Rows: 24}, | 59 | wantSize: domain.Size{Cols: 80, Rows: 24}, | ||
| 60 | - | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | 60 | + | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, |
| 61 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | 61 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | ||
| 62 | }, | 62 | }, | ||
| 63 | { | 63 | { | ||
M internal/usecase/theme/resolved_styles.go +2 -0
| @@ -55,6 +55,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 55 | 55 | ||
| 56 | 56 | PickerBase: renderer.DefaultStyle(), | |
| 57 | 57 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 58 | + | HintKey: EmphasisStyle(ramp.SurfaceActive, t), | |
| 58 | 59 | PickerDescription: overlayDescription, | |
| 59 | 60 | // Separators are secondary text on the terminal background, not | |
| 60 | 61 | // non-text borders; they therefore require the normal 4.5:1 contrast. | |
| @@ -148,6 +149,7 @@ func neutralStyles(t Theme) Styles { | |||
| 148 | 149 | ||
| 149 | 150 | PickerBase: renderer.DefaultStyle(), | |
| 150 | 151 | PickerSelection: EmphasisStyle(accent, t), | |
| 152 | + | HintKey: EmphasisStyle(accent, t), | |
| 151 | 153 | PickerDescription: overlayDescription, | |
| 152 | 154 | PickerSeparator: overlayDescription, | |
| 153 | 155 | PromptBase: renderer.DefaultStyle(), | |
| @@ -55,6 +55,7 @@ func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||||
| 55 | 55 | ||||
| 56 | PickerBase: renderer.DefaultStyle(), | 56 | PickerBase: renderer.DefaultStyle(), | ||
| 57 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | 57 | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | ||
| 58 | + | HintKey: EmphasisStyle(ramp.SurfaceActive, t), | |||
| 58 | PickerDescription: overlayDescription, | 59 | PickerDescription: overlayDescription, | ||
| 59 | // Separators are secondary text on the terminal background, not | 60 | // Separators are secondary text on the terminal background, not | ||
| 60 | // non-text borders; they therefore require the normal 4.5:1 contrast. | 61 | // non-text borders; they therefore require the normal 4.5:1 contrast. | ||
| @@ -148,6 +149,7 @@ func neutralStyles(t Theme) Styles { | |||||
| 148 | 149 | ||||
| 149 | PickerBase: renderer.DefaultStyle(), | 150 | PickerBase: renderer.DefaultStyle(), | ||
| 150 | PickerSelection: EmphasisStyle(accent, t), | 151 | PickerSelection: EmphasisStyle(accent, t), | ||
| 152 | + | HintKey: EmphasisStyle(accent, t), | |||
| 151 | PickerDescription: overlayDescription, | 153 | PickerDescription: overlayDescription, | ||
| 152 | PickerSeparator: overlayDescription, | 154 | PickerSeparator: overlayDescription, | ||
| 153 | PromptBase: renderer.DefaultStyle(), | 155 | PromptBase: renderer.DefaultStyle(), | ||
M internal/usecase/theme/styles.go +5 -2
| @@ -89,8 +89,11 @@ type Styles struct { | |||
| 89 | 89 | TabActiveTitle renderer.Style | |
| 90 | 90 | MRURecent renderer.Style | |
| 91 | 91 | ||
| 92 | - | PickerBase renderer.Style | |
| 93 | - | PickerSelection renderer.Style | |
| 92 | + | PickerBase renderer.Style | |
| 93 | + | PickerSelection renderer.Style | |
| 94 | + | // HintKey is the keycap badge style for key hints: an accent background | |
| 95 | + | // over " key " padding, distinct from PickerSelection's full-line emphasis. | |
| 96 | + | HintKey renderer.Style | |
| 94 | 97 | PickerDescription renderer.Style | |
| 95 | 98 | PickerSeparator renderer.Style | |
| 96 | 99 | PromptBase renderer.Style | |
| @@ -89,8 +89,11 @@ type Styles struct { | |||||
| 89 | TabActiveTitle renderer.Style | 89 | TabActiveTitle renderer.Style | ||
| 90 | MRURecent renderer.Style | 90 | MRURecent renderer.Style | ||
| 91 | 91 | ||||
| 92 | - | PickerBase renderer.Style | 92 | + | PickerBase renderer.Style |
| 93 | - | PickerSelection renderer.Style | 93 | + | PickerSelection renderer.Style |
| 94 | + | // HintKey is the keycap badge style for key hints: an accent background | |||
| 95 | + | // over " key " padding, distinct from PickerSelection's full-line emphasis. | |||
| 96 | + | HintKey renderer.Style | |||
| 94 | PickerDescription renderer.Style | 97 | PickerDescription renderer.Style | ||
| 95 | PickerSeparator renderer.Style | 98 | PickerSeparator renderer.Style | ||
| 96 | PromptBase renderer.Style | 99 | PromptBase renderer.Style | ||