Merge pull request #147 from bnema/chore/final-polish-release
Release polish: tagline, AUR install, keycap hints, fast demo pipeline
7 files · +120 -33
M Makefile +20 -12
| @@ -30,24 +30,32 @@ demo: | |||
| 30 | 30 | ./scripts/demo/run.sh demo.tape | |
| 31 | 31 | # vhs cannot emit a transparent margin, so cut the window out after the | |
| 32 | 32 | # fact: everything outside the rounded window rect becomes GIF | |
| 33 | - | # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin | |
| 34 | - | # 20, border radius 10); keep them in sync. | |
| 33 | + | # transparency, plus a 2px edge stroked along the window. The geometry | |
| 34 | + | # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep | |
| 35 | + | # them in sync. ffmpeg streams the clip frame by frame, so peak memory | |
| 36 | + | # stays flat; the previous imagemagick -coalesce pipeline needed the whole | |
| 37 | + | # ~22GiB uncompressed clip at once and either crawled for an hour on its | |
| 38 | + | # disk cache or got OOM-killed — do not go back to it. | |
| 39 | + | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | |
| 40 | + | -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \ | |
| 41 | + | -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png | |
| 35 | 42 | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | |
| 36 | 43 | -size 1200x750 xc:black -fill white \ | |
| 37 | 44 | -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png | |
| 38 | - | # The 2px edge is stroked before the mask is applied so the mask clips the | |
| 39 | - | # stroke's antialiased overflow; drawing it after would leave partly | |
| 40 | - | # transparent pixels, which a GIF's 1-bit alpha cannot represent. | |
| 41 | - | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | |
| 42 | - | -limit memory 8GiB -limit map 12GiB /out/demo.gif -coalesce \ | |
| 43 | - | -fill none -stroke "#30363D" -strokewidth 2 \ | |
| 44 | - | -draw "roundrectangle 21,21 1178,728 9,9" \ | |
| 45 | - | null: \( /out/mask.png -alpha off \) -compose CopyOpacity \ | |
| 46 | - | -layers composite /out/demo-masked.gif | |
| 45 | + | # alphamerge applies the mask after the border overlay so the mask clips | |
| 46 | + | # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot | |
| 47 | + | # represent; paletteuse's alpha_threshold folds the rest into that 1-bit | |
| 48 | + | # alpha (palettegen reserves the transparent slot). | |
| 49 | + | docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | |
| 50 | + | -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \ | |
| 51 | + | -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \ | |
| 52 | + | /out/demo-masked.gif | |
| 47 | 53 | # --lossy above 30 smears the near-identical darks of the window chrome | |
| 48 | 54 | # (window bar vs terminal background) into visible banding, and the | |
| 49 | 55 | # conserve-memory fallback for huge inputs roughly doubles the output. | |
| 50 | 56 | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif | |
| 51 | - | rm scripts/demo/out/demo-masked.gif scripts/demo/out/mask.png | |
| 57 | + | # Temp files are owned by the container's subuid, so remove them from | |
| 58 | + | # inside a container rather than the host. | |
| 59 | + | docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png' | |
| 52 | 60 | mkdir -p docs/assets | |
| 53 | 61 | cp scripts/demo/out/demo.gif docs/assets/demo.gif | |
| @@ -30,24 +30,32 @@ demo: | |||||
| 30 | ./scripts/demo/run.sh demo.tape | 30 | ./scripts/demo/run.sh demo.tape | ||
| 31 | # vhs cannot emit a transparent margin, so cut the window out after the | 31 | # vhs cannot emit a transparent margin, so cut the window out after the | ||
| 32 | # fact: everything outside the rounded window rect becomes GIF | 32 | # fact: everything outside the rounded window rect becomes GIF | ||
| 33 | - | # transparency. The geometry mirrors demo.tape (1200x750 canvas, margin | 33 | + | # transparency, plus a 2px edge stroked along the window. The geometry |
| 34 | - | # 20, border radius 10); keep them in sync. | 34 | + | # mirrors demo.tape (1200x750 canvas, margin 20, border radius 10); keep |
| 35 | + | # them in sync. ffmpeg streams the clip frame by frame, so peak memory | |||
| 36 | + | # stays flat; the previous imagemagick -coalesce pipeline needed the whole | |||
| 37 | + | # ~22GiB uncompressed clip at once and either crawled for an hour on its | |||
| 38 | + | # disk cache or got OOM-killed — do not go back to it. | |||
| 39 | + | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | |||
| 40 | + | -size 1200x750 xc:none -fill none -stroke "#30363D" -strokewidth 2 \ | |||
| 41 | + | -draw "roundrectangle 21,21 1178,728 9,9" /out/border.png | |||
| 35 | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | 42 | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | ||
| 36 | -size 1200x750 xc:black -fill white \ | 43 | -size 1200x750 xc:black -fill white \ | ||
| 37 | -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png | 44 | -draw "roundrectangle 20,20 1179,729 10,10" /out/mask.png | ||
| 38 | - | # The 2px edge is stroked before the mask is applied so the mask clips the | 45 | + | # alphamerge applies the mask after the border overlay so the mask clips |
| 39 | - | # stroke's antialiased overflow; drawing it after would leave partly | 46 | + | # the stroke's antialiased overflow, which a GIF's 1-bit alpha cannot |
| 40 | - | # transparent pixels, which a GIF's 1-bit alpha cannot represent. | 47 | + | # represent; paletteuse's alpha_threshold folds the rest into that 1-bit |
| 41 | - | docker run --rm --entrypoint magick -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ | 48 | + | # alpha (palettegen reserves the transparent slot). |
| 42 | - | -limit memory 8GiB -limit map 12GiB /out/demo.gif -coalesce \ | 49 | + | docker run --rm --entrypoint ffmpeg -v "$(CURDIR)/scripts/demo/out:/out" vev-demo \ |
| 43 | - | -fill none -stroke "#30363D" -strokewidth 2 \ | 50 | + | -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \ |
| 44 | - | -draw "roundrectangle 21,21 1178,728 9,9" \ | 51 | + | -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \ |
| 45 | - | null: \( /out/mask.png -alpha off \) -compose CopyOpacity \ | 52 | + | /out/demo-masked.gif |
| 46 | - | -layers composite /out/demo-masked.gif | |||
| 47 | # --lossy above 30 smears the near-identical darks of the window chrome | 53 | # --lossy above 30 smears the near-identical darks of the window chrome | ||
| 48 | # (window bar vs terminal background) into visible banding, and the | 54 | # (window bar vs terminal background) into visible banding, and the | ||
| 49 | # conserve-memory fallback for huge inputs roughly doubles the output. | 55 | # conserve-memory fallback for huge inputs roughly doubles the output. | ||
| 50 | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif | 56 | docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif | ||
| 51 | - | rm scripts/demo/out/demo-masked.gif scripts/demo/out/mask.png | 57 | + | # Temp files are owned by the container's subuid, so remove them from |
| 58 | + | # inside a container rather than the host. | |||
| 59 | + | docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png' | |||
| 52 | mkdir -p docs/assets | 60 | mkdir -p docs/assets | ||
| 53 | cp scripts/demo/out/demo.gif docs/assets/demo.gif | 61 | cp scripts/demo/out/demo.gif docs/assets/demo.gif | ||
M README.md +9 -1
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | <p align="center"><em>Norwegian: to weave</em></p> | |
| 4 | 4 | ||
| 5 | 5 | <p align="center">A terminal multiplexer for Linux and macOS.<br> | |
| 6 | - | One Go binary, zero runtime dependencies, remote attach that survives bad networks.</p> | |
| 6 | + | Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p> | |
| 7 | 7 | ||
| 8 | 8 | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p> | |
| 9 | 9 | ||
| @@ -17,6 +17,14 @@ Install a pre-built release on Linux x86_64/arm64 or macOS Apple silicon (arm64) | |||
| 17 | 17 | curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh | |
| 18 | 18 | ``` | |
| 19 | 19 | ||
| 20 | + | On Arch Linux, install [`vev-bin`](https://aur.archlinux.org/packages/vev-bin) from the AUR: | |
| 21 | + | ||
| 22 | + | ```sh | |
| 23 | + | yay -S vev-bin | |
| 24 | + | # or | |
| 25 | + | paru -S vev-bin | |
| 26 | + | ``` | |
| 27 | + | ||
| 20 | 28 | Alternatively, build and install the binary with Go: | |
| 21 | 29 | ||
| 22 | 30 | ```sh | |
| @@ -3,7 +3,7 @@ | |||||
| 3 | <p align="center"><em>Norwegian: to weave</em></p> | 3 | <p align="center"><em>Norwegian: to weave</em></p> | ||
| 4 | 4 | ||||
| 5 | <p align="center">A terminal multiplexer for Linux and macOS.<br> | 5 | <p align="center">A terminal multiplexer for Linux and macOS.<br> | ||
| 6 | - | One Go binary, zero runtime dependencies, remote attach that survives bad networks.</p> | 6 | + | Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p> |
| 7 | 7 | ||||
| 8 | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p> | 8 | <p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p> | ||
| 9 | 9 | ||||
| @@ -17,6 +17,14 @@ Install a pre-built release on Linux x86_64/arm64 or macOS Apple silicon (arm64) | |||||
| 17 | curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh | 17 | curl -fsSL https://raw.githubusercontent.com/bnema/vev/main/install.sh | sh | ||
| 18 | ``` | 18 | ``` | ||
| 19 | 19 | ||||
| 20 | + | On Arch Linux, install [`vev-bin`](https://aur.archlinux.org/packages/vev-bin) from the AUR: | |||
| 21 | + | ||||
| 22 | + | ```sh | |||
| 23 | + | yay -S vev-bin | |||
| 24 | + | # or | |||
| 25 | + | paru -S vev-bin | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 20 | Alternatively, build and install the binary with Go: | 28 | Alternatively, build and install the binary with Go: | ||
| 21 | 29 | ||||
| 22 | ```sh | 30 | ```sh | ||
M docs/assets/demo.gif +0 -0 binary file: no text diff
binary file: no text diff
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 +39 -7
| @@ -17,9 +17,11 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | func TestSnatchedPanelFrame(t *testing.T) { | |
| 19 | 19 | styles := themeui.Styles{ | |
| 20 | - | PickerBase: renderer.Style{Foreground: 1}, | |
| 21 | - | PickerSelection: renderer.Style{Foreground: 2}, | |
| 22 | - | BorderMuted: renderer.Style{Foreground: 3}, | |
| 20 | + | PickerBase: renderer.Style{Foreground: 1}, | |
| 21 | + | PickerSelection: renderer.Style{Foreground: 2}, | |
| 22 | + | BorderMuted: renderer.Style{Foreground: 3}, | |
| 23 | + | HintKey: renderer.Style{Foreground: 4}, | |
| 24 | + | PickerDescription: renderer.Style{Foreground: 5}, | |
| 23 | 25 | } | |
| 24 | 26 | tests := []struct { | |
| 25 | 27 | name string | |
| @@ -27,6 +29,8 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 27 | 29 | feedback string | |
| 28 | 30 | wantSize domain.Size | |
| 29 | 31 | want []string | |
| 32 | + | wantKeys string | |
| 33 | + | wantSep string | |
| 30 | 34 | wantBounds domain.Rect | |
| 31 | 35 | compact bool | |
| 32 | 36 | }{ | |
| @@ -34,14 +38,17 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 34 | 38 | name: "centered bordered panel", | |
| 35 | 39 | size: domain.Size{Cols: 80, Rows: 24}, | |
| 36 | 40 | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 37 | - | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 41 | + | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | |
| 42 | + | wantKeys: " r q Esc ", | |
| 43 | + | wantSep: " / ", | |
| 38 | 44 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 39 | 45 | }, | |
| 40 | 46 | { | |
| 41 | 47 | name: "compact fallback", | |
| 42 | 48 | size: domain.Size{Cols: 24, Rows: 4}, | |
| 43 | 49 | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 44 | - | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 50 | + | want: []string{"Session snatched", "r Resume", "q Quit"}, | |
| 51 | + | wantKeys: " r q ", | |
| 45 | 52 | compact: true, | |
| 46 | 53 | }, | |
| 47 | 54 | { | |
| @@ -49,7 +56,8 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 49 | 56 | size: domain.Size{Cols: 24, Rows: 4}, | |
| 50 | 57 | feedback: "Session is no longer available.", | |
| 51 | 58 | wantSize: domain.Size{Cols: 24, Rows: 4}, | |
| 52 | - | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 59 | + | want: []string{"Session unavailable", "r Resume", "q Quit"}, | |
| 60 | + | wantKeys: " r q ", | |
| 53 | 61 | compact: true, | |
| 54 | 62 | }, | |
| 55 | 63 | { | |
| @@ -57,7 +65,9 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 57 | 65 | size: domain.Size{Cols: 80, Rows: 24}, | |
| 58 | 66 | feedback: "Session is no longer available.", | |
| 59 | 67 | wantSize: domain.Size{Cols: 80, Rows: 24}, | |
| 60 | - | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 68 | + | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | |
| 69 | + | wantKeys: " r q Esc ", | |
| 70 | + | wantSep: " / ", | |
| 61 | 71 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | |
| 62 | 72 | }, | |
| 63 | 73 | { | |
| @@ -85,6 +95,8 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 85 | 95 | for _, want := range tt.want { | |
| 86 | 96 | require.Contains(t, text, want) | |
| 87 | 97 | } | |
| 98 | + | require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style") | |
| 99 | + | require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style") | |
| 88 | 100 | ||
| 89 | 101 | if tt.compact { | |
| 90 | 102 | require.NotContains(t, text, "┌") | |
| @@ -237,6 +249,26 @@ func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel do | |||
| 237 | 249 | } | |
| 238 | 250 | } | |
| 239 | 251 | ||
| 252 | + | // snatchedStyledText concatenates, in reading order, the runes of every cell | |
| 253 | + | // drawn with exactly the given style, so tests can assert which text runs a | |
| 254 | + | // style covers without depending on layout coordinates. | |
| 255 | + | func snatchedStyledText(frame renderer.Frame, style renderer.Style) string { | |
| 256 | + | var out strings.Builder | |
| 257 | + | for y := 0; y < frame.Height; y++ { | |
| 258 | + | for _, cell := range frame.Row(y) { | |
| 259 | + | if cell.Continuation || !cell.Style.Equal(style) { | |
| 260 | + | continue | |
| 261 | + | } | |
| 262 | + | r := cell.Rune | |
| 263 | + | if r == 0 { | |
| 264 | + | r = ' ' | |
| 265 | + | } | |
| 266 | + | out.WriteRune(r) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | return out.String() | |
| 270 | + | } | |
| 271 | + | ||
| 240 | 272 | func snatchedFrameText(frame renderer.Frame) string { | |
| 241 | 273 | var out strings.Builder | |
| 242 | 274 | for y := 0; y < frame.Height; y++ { | |
| @@ -17,9 +17,11 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | func TestSnatchedPanelFrame(t *testing.T) { | 18 | func TestSnatchedPanelFrame(t *testing.T) { | ||
| 19 | styles := themeui.Styles{ | 19 | styles := themeui.Styles{ | ||
| 20 | - | PickerBase: renderer.Style{Foreground: 1}, | 20 | + | PickerBase: renderer.Style{Foreground: 1}, |
| 21 | - | PickerSelection: renderer.Style{Foreground: 2}, | 21 | + | PickerSelection: renderer.Style{Foreground: 2}, |
| 22 | - | BorderMuted: renderer.Style{Foreground: 3}, | 22 | + | BorderMuted: renderer.Style{Foreground: 3}, |
| 23 | + | HintKey: renderer.Style{Foreground: 4}, | |||
| 24 | + | PickerDescription: renderer.Style{Foreground: 5}, | |||
| 23 | } | 25 | } | ||
| 24 | tests := []struct { | 26 | tests := []struct { | ||
| 25 | name string | 27 | name string | ||
| @@ -27,6 +29,8 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 27 | feedback string | 29 | feedback string | ||
| 28 | wantSize domain.Size | 30 | wantSize domain.Size | ||
| 29 | want []string | 31 | want []string | ||
| 32 | + | wantKeys string | |||
| 33 | + | wantSep string | |||
| 30 | wantBounds domain.Rect | 34 | wantBounds domain.Rect | ||
| 31 | compact bool | 35 | compact bool | ||
| 32 | }{ | 36 | }{ | ||
| @@ -34,14 +38,17 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 34 | name: "centered bordered panel", | 38 | name: "centered bordered panel", | ||
| 35 | size: domain.Size{Cols: 80, Rows: 24}, | 39 | size: domain.Size{Cols: 80, Rows: 24}, | ||
| 36 | wantSize: domain.Size{Cols: 80, Rows: 24}, | 40 | wantSize: domain.Size{Cols: 80, Rows: 24}, | ||
| 37 | - | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, | 41 | + | want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"}, |
| 42 | + | wantKeys: " r q Esc ", | |||
| 43 | + | wantSep: " / ", | |||
| 38 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | 44 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | ||
| 39 | }, | 45 | }, | ||
| 40 | { | 46 | { | ||
| 41 | name: "compact fallback", | 47 | name: "compact fallback", | ||
| 42 | size: domain.Size{Cols: 24, Rows: 4}, | 48 | size: domain.Size{Cols: 24, Rows: 4}, | ||
| 43 | wantSize: domain.Size{Cols: 24, Rows: 4}, | 49 | wantSize: domain.Size{Cols: 24, Rows: 4}, | ||
| 44 | - | want: []string{"Session snatched", "r Resume", "q Quit"}, | 50 | + | want: []string{"Session snatched", "r Resume", "q Quit"}, |
| 51 | + | wantKeys: " r q ", | |||
| 45 | compact: true, | 52 | compact: true, | ||
| 46 | }, | 53 | }, | ||
| 47 | { | 54 | { | ||
| @@ -49,7 +56,8 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 49 | size: domain.Size{Cols: 24, Rows: 4}, | 56 | size: domain.Size{Cols: 24, Rows: 4}, | ||
| 50 | feedback: "Session is no longer available.", | 57 | feedback: "Session is no longer available.", | ||
| 51 | wantSize: domain.Size{Cols: 24, Rows: 4}, | 58 | wantSize: domain.Size{Cols: 24, Rows: 4}, | ||
| 52 | - | want: []string{"Session unavailable", "r Resume", "q Quit"}, | 59 | + | want: []string{"Session unavailable", "r Resume", "q Quit"}, |
| 60 | + | wantKeys: " r q ", | |||
| 53 | compact: true, | 61 | compact: true, | ||
| 54 | }, | 62 | }, | ||
| 55 | { | 63 | { | ||
| @@ -57,7 +65,9 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 57 | size: domain.Size{Cols: 80, Rows: 24}, | 65 | size: domain.Size{Cols: 80, Rows: 24}, | ||
| 58 | feedback: "Session is no longer available.", | 66 | feedback: "Session is no longer available.", | ||
| 59 | wantSize: domain.Size{Cols: 80, Rows: 24}, | 67 | wantSize: domain.Size{Cols: 80, Rows: 24}, | ||
| 60 | - | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, | 68 | + | want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"}, |
| 69 | + | wantKeys: " r q Esc ", | |||
| 70 | + | wantSep: " / ", | |||
| 61 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | 71 | wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7}, | ||
| 62 | }, | 72 | }, | ||
| 63 | { | 73 | { | ||
| @@ -85,6 +95,8 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 85 | for _, want := range tt.want { | 95 | for _, want := range tt.want { | ||
| 86 | require.Contains(t, text, want) | 96 | require.Contains(t, text, want) | ||
| 87 | } | 97 | } | ||
| 98 | + | require.Equal(t, tt.wantKeys, snatchedStyledText(frame, styles.HintKey), "keycap badges must use the HintKey style") | |||
| 99 | + | require.Equal(t, tt.wantSep, snatchedStyledText(frame, styles.PickerDescription), "hint separator must use the muted description style") | |||
| 88 | 100 | ||||
| 89 | if tt.compact { | 101 | if tt.compact { | ||
| 90 | require.NotContains(t, text, "┌") | 102 | require.NotContains(t, text, "┌") | ||
| @@ -237,6 +249,26 @@ func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel do | |||||
| 237 | } | 249 | } | ||
| 238 | } | 250 | } | ||
| 239 | 251 | ||||
| 252 | + | // snatchedStyledText concatenates, in reading order, the runes of every cell | |||
| 253 | + | // drawn with exactly the given style, so tests can assert which text runs a | |||
| 254 | + | // style covers without depending on layout coordinates. | |||
| 255 | + | func snatchedStyledText(frame renderer.Frame, style renderer.Style) string { | |||
| 256 | + | var out strings.Builder | |||
| 257 | + | for y := 0; y < frame.Height; y++ { | |||
| 258 | + | for _, cell := range frame.Row(y) { | |||
| 259 | + | if cell.Continuation || !cell.Style.Equal(style) { | |||
| 260 | + | continue | |||
| 261 | + | } | |||
| 262 | + | r := cell.Rune | |||
| 263 | + | if r == 0 { | |||
| 264 | + | r = ' ' | |||
| 265 | + | } | |||
| 266 | + | out.WriteRune(r) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | return out.String() | |||
| 270 | + | } | |||
| 271 | + | ||||
| 240 | func snatchedFrameText(frame renderer.Frame) string { | 272 | func snatchedFrameText(frame renderer.Frame) string { | ||
| 241 | var out strings.Builder | 273 | var out strings.Builder | ||
| 242 | for y := 0; y < frame.Height; y++ { | 274 | for y := 0; y < frame.Height; y++ { | ||
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 | ||