diff --git a/README.md b/README.md index dad409b..ea557ac 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ A terminal countdown timer with animated spinners. 🌙 Liftoff in 99 ``` +Includes an option to render big numbers. + +![Screen recording of big numbers](https://vhs.charm.sh/vhs-3UPyKQ0dKOOUpwHoBVW678.gif) + ## Installation (in development) ### Homebrew (macOS/Linux) @@ -74,6 +78,9 @@ countdown --spinner.foreground 201 --title.foreground 39 # With padding countdown --padding "1 2" + +# Big ASCII art numbers +countdown -b -r 10..0 ``` ### Flags @@ -88,6 +95,7 @@ countdown --padding "1 2" | `-t, --time-interval` | `1` | Seconds between each tick | | `-d, --decrement` | `1` | Amount to change count each tick | | `-f, --final-phase` | `5` | Threshold for final phase styling (number or percentage like `10%`) | +| `-b, --big` | `false` | Display numbers using large ASCII art digits | ### Style Flags @@ -119,7 +127,7 @@ countdown --padding "1 2" ### Environment Variables -All flags can be set via environment variables: +Some flags can be set via environment variables: | Variable | Flag | |----------|------| @@ -188,6 +196,11 @@ Optionally fetch changes with `jj` to see the new tag on `main`. ```nushell docker run --rm -v ($env.PWD):/vhs ghcr.io/charmbracelet/vhs vhs/basic.tape ``` +Or with custom Dockerfile (installs `go` for building the binary dynamically): + +```nushell +docker run --rm -v ($env.PWD):/vhs (docker build -q /vhs) vhs/big.tape +``` ## License diff --git a/internal/countdown/model.go b/internal/countdown/model.go index 8d7d0a1..f0a9925 100644 --- a/internal/countdown/model.go +++ b/internal/countdown/model.go @@ -15,6 +15,98 @@ import ( "github.com/charmbracelet/lipgloss" ) +// bigDigits contains ASCII art representations of digits 0-9 and colon. +var bigDigits = map[rune][]string{ + '0': { + "╭───────╮", + "│ ╭───╮ │", + "│ │ │ │", + "│ │ │ │", + "│ ╰───╯ │", + "╰───────╯", + }, + '1': { + "╭───╮", + "╰─╮ │", + " │ │", + " │ │", + " │ │", + " ╰─╯", + }, + '2': { + "╭───────╮", + "╰─────╮ │", + "╭─────╯ │", + "│ ╭─────╯", + "│ ╰─────╮", + "╰───────╯", + }, + '3': { + "╭───────╮", + "╰─────╮ │", + "╭─────╯ │", + "╰─────╮ │", + "╭─────╯ │", + "╰───────╯", + }, + '4': { + "╭─╮ ╭─╮", + "│ │ │ │", + "│ ╰──╯ │", + "╰────╮ │", + " │ │", + " ╰─╯", + }, + '5': { + "╭───────╮", + "│ ╭─────╯", + "│ ╰─────╮", + "╰─────╮ │", + "╭─────╯ │", + "╰───────╯", + }, + '6': { + "╭───────╮", + "│ ╭─────╯", + "│ ╰─────╮", + "│ ╭───╮ │", + "│ ╰───╯ │", + "╰───────╯", + }, + '7': { + "╭─────╮", + "╰───╮ │", + " │ │", + " │ │", + " │ │", + " ╰─╯", + }, + '8': { + "╭───────╮", + "│ ╭───╮ │", + "│ ╰───╯ │", + "│ ╭───╮ │", + "│ ╰───╯ │", + "╰───────╯", + }, + '9': { + "╭───────╮", + "│ ╭───╮ │", + "│ ╰───╯ │", + "╰─────╮ │", + "╭─────╯ │", + "╰───────╯", + }, + ':': { + " ", + "╭─╮", + "╰─╯", + "╭─╮", + "╰─╯", + " ", + }, +} + // Config holds the countdown configuration. type Config struct { SpinnerType string @@ -30,6 +122,7 @@ type Config struct { TitleBackground string PaddingVertical int PaddingHorizontal int + Big bool } // Model represents the Bubbletea model for the countdown. @@ -170,9 +263,52 @@ func (m Model) View() string { if m.killed { titleStr += "(killed) " } - countStr := strconv.Itoa(m.current) + var titleView string var countView string + + if m.config.Big { + // Render big ASCII art numbers + bigNumStr := renderBigNumber(m.current) + titleView = m.titleStyle.Render(titleStr) + + if inFinalPhase && m.current%2 == 1 { + // Final phase: foreground becomes background, text is high-contrast + finalStyle := lipgloss.NewStyle() + + // Determine the foreground color to use as background + fgColor := m.config.SpinnerForeground // Default foreground + if m.config.TitleForeground != "" { + fgColor = m.config.TitleForeground + } + + // Set the original foreground as the new background + if fgColor != "" { + finalStyle = finalStyle.Background(parseColor(fgColor)) + } else { + // Use default spinner color (212) as background + fgColor = "212" + finalStyle = finalStyle.Background(parseColor(fgColor)) + } + + // Calculate high-contrast foreground for readability + finalStyle = finalStyle.Foreground(highContrastColor(fgColor)) + finalStyle = finalStyle.Bold(true) + + countView = finalStyle.Render(bigNumStr) + } else { + countView = m.countStyle.Render(bigNumStr) + } + + // For big numbers, render title and number on separate lines + content := fmt.Sprintf("%s %s\n%s", spinnerView, titleView, countView) + return m.containerStyle.Render(content) + } + + // Regular number rendering + countStr := strconv.Itoa(m.current) + titleView = m.titleStyle.Render(titleStr) + if inFinalPhase && m.current%2 == 1 { // Final phase: foreground becomes background, text is high-contrast finalStyle := lipgloss.NewStyle() @@ -196,10 +332,8 @@ func (m Model) View() string { finalStyle = finalStyle.Foreground(highContrastColor(fgColor)) finalStyle = finalStyle.Bold(true) - titleView = finalStyle.Render(titleStr) countView = finalStyle.Render(countStr) } else { - titleView = m.titleStyle.Render(titleStr) countView = m.countStyle.Render(countStr) } @@ -360,6 +494,32 @@ func calcLuminance(r, g, b uint8) float64 { return 0.2126*rLin + 0.7152*gLin + 0.0722*bLin } +// renderBigNumber renders a number as large ASCII art digits. +func renderBigNumber(num int) string { + numStr := strconv.Itoa(num) + lines := make([][]string, 6) + for i := range lines { + lines[i] = make([]string, 0) + } + + for _, char := range numStr { + if digit, ok := bigDigits[char]; ok { + for i, line := range digit { + lines[i] = append(lines[i], line) + } + } + } + + var result strings.Builder + for _, line := range lines { + if len(line) > 0 { + result.WriteString(strings.Join(line, "")) + result.WriteString("\n") + } + } + return strings.TrimRight(result.String(), "\n") +} + // Run starts the countdown application. func Run(cfg Config) error { p := tea.NewProgram(NewModel(cfg)) diff --git a/internal/countdown/model_test.go b/internal/countdown/model_test.go index 04a03de..f2ba8b4 100644 --- a/internal/countdown/model_test.go +++ b/internal/countdown/model_test.go @@ -229,3 +229,77 @@ func TestHexToRGB(t *testing.T) { }) } } + +func TestRenderBigNumber(t *testing.T) { + tests := []struct { + name string + input int + contains []string // Substrings that should be in the output + }{ + {"zero", 0, []string{"╭───────╮", "│ ╭───╮ │", "│ ╰───╯ │", "╰───────╯"}}, + {"one", 1, []string{"╭───╮", "╰─╮ │", " │ │", " ╰─╯"}}, + {"two", 2, []string{"╭───────╮", "╰─────╮ │", "╭─────╯ │", "╰───────╯"}}, + {"three", 3, []string{"╭───────╮", "╰─────╮ │", "╭─────╯ │", "╰───────╯"}}, + {"multi-digit", 123, []string{"╭───╮", "╰─╮ │", " │ │"}}, // Should contain parts of 1, 2, 3 + {"negative", -5, []string{"╭───────╮", "│ ╭─────╯", "╰───────╯"}}, // Should render the 5 part + {"large number", 9876543210, []string{"╭───────╮"}}, // Should render all digits + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := renderBigNumber(tt.input) + assert.NotEmpty(t, result, "renderBigNumber should not return empty string") + for _, substr := range tt.contains { + assert.Contains(t, result, substr, "renderBigNumber output should contain expected substring") + } + // Verify it's multi-line (has newlines) + assert.Contains(t, result, "\n", "renderBigNumber should return multi-line output") + }) + } +} + +func TestModelViewWithBig(t *testing.T) { + cfg := Config{ + SpinnerType: "none", + Title: "Test", + Start: 10, + End: 0, + TimeInterval: 1, + Decrement: 1, + FinalPhase: 2, + Big: true, + } + + m := NewModel(cfg) + view := m.View() + + assert.NotEmpty(t, view, "View() should not return empty string when not done") + // When Big is enabled, the view should contain big number ASCII art + assert.Contains(t, view, "╭", "View() with Big enabled should contain ASCII art characters") + assert.Contains(t, view, "│", "View() with Big enabled should contain ASCII art characters") + // Should contain the title + assert.Contains(t, view, "Test", "View() should contain the title") +} + +func TestModelViewWithBigDisabled(t *testing.T) { + cfg := Config{ + SpinnerType: "none", + Title: "Test", + Start: 10, + End: 0, + TimeInterval: 1, + Decrement: 1, + FinalPhase: 2, + Big: false, + } + + m := NewModel(cfg) + view := m.View() + + assert.NotEmpty(t, view, "View() should not return empty string when not done") + // When Big is disabled, should contain regular number (not ASCII art) + assert.Contains(t, view, "10", "View() with Big disabled should contain regular number") + // Should not contain big number ASCII art characters in the number part + // (though spinner might have them, so we check for the specific pattern) + assert.Contains(t, view, "Test", "View() should contain the title") +} diff --git a/main.go b/main.go index 9a48e8a..28b9c28 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ type CLI struct { TimeInterval int `short:"t" default:"1" help:"Number of seconds between each iteration"` Decrement int `short:"d" default:"1" help:"Number subtracted from current count at each iteration"` FinalPhase string `short:"f" default:"5" help:"Number at which the final phase starts. At this number, the foreground and background colors are swapped. Can be a number such as '5' or a percentage such as '10%'"` + Big bool `short:"b" help:"Display numbers using large ASCII art digits"` SpinnerStyle SpinnerStyle `embed:"" prefix:"spinner."` TitleStyle TitleStyle `embed:"" prefix:"title."` @@ -84,6 +85,7 @@ func main() { TitleBackground: cli.TitleStyle.Background, PaddingVertical: padV, PaddingHorizontal: padH, + Big: cli.Big, } if err := countdown.Run(config); err != nil { diff --git a/main_test.go b/main_test.go index 4031cdf..78cb197 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,10 @@ package main import ( + "os" "testing" + "github.com/alecthomas/kong" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -100,3 +102,45 @@ func TestParsePadding(t *testing.T) { }) } } + +func TestCLIBigFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantBig bool + wantErr bool + }{ + {"big flag short", []string{"-b"}, true, false}, + {"big flag long", []string{"--big"}, true, false}, + {"big flag with range", []string{"-b", "-r", "10..0"}, true, false}, + {"no big flag", []string{"-r", "10..0"}, false, false}, + {"big flag with other options", []string{"-b", "-s", "dot", "-r", "5..0"}, true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original args + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + // Set up test args + os.Args = append([]string{"countdown"}, tt.args...) + + var cli CLI + parser, err := kong.New(&cli, + kong.Name("countdown"), + kong.Description("Display spinner while displaying a number which counts downward"), + kong.UsageOnError(), + ) + require.NoError(t, err, "kong.New should not error") + + _, err = parser.Parse(tt.args) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantBig, cli.Big, "Big flag should match expected value") + } + }) + } +} diff --git a/vhs/Dockerfile b/vhs/Dockerfile new file mode 100644 index 0000000..74f1cb8 --- /dev/null +++ b/vhs/Dockerfile @@ -0,0 +1,21 @@ +FROM ghcr.io/charmbracelet/vhs + +# Install Dependencies +RUN apt-get update --allow-releaseinfo-change || true +RUN apt-get -y install build-essential procps curl file git + +# Prepare Homebrew directory and install as vhs user +RUN mkdir -p /home/linuxbrew/.linuxbrew /home/vhs/.cache && chown -R 1976:1976 /home/linuxbrew /home/vhs +RUN NONINTERACTIVE=1 HOME=/vhs su vhs -s /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +RUN echo >> /vhs/.bashrc +RUN echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv bash)"' >> /vhs/.bashrc + +# Ensure vhs user can use homebrew +RUN chown -R 1976:1976 /home/linuxbrew/.linuxbrew + +ENV PATH="/home/linuxbrew/.linuxbrew/bin:${PATH}" + +# Install packages +RUN su vhs -s /bin/bash -c "/home/linuxbrew/.linuxbrew/bin/brew install go" + diff --git a/vhs/basic.tape b/vhs/basic.tape index cf29ede..ac78e97 100644 --- a/vhs/basic.tape +++ b/vhs/basic.tape @@ -2,8 +2,6 @@ Output vhs/basic.gif -Require countdown - Set Shell "bash" Set FontSize 32 Set Width 1200 @@ -11,9 +9,19 @@ Set Height 300 Env PATH "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/vhs" +# Setup +Hide +Type 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv bash)"' +Enter +Type "go build -o countdown ." +Enter +Wait +Type "clear" +Enter +Show + # Script for recording -# Type './countdown --spinner "bomb" --range "10..0" --title "Implosion in"' Sleep 1s Type './countdown --spinner "bomb" --range "10..0" \' Enter Type ' --title "Implosion in"' Sleep 1s diff --git a/vhs/big.tape b/vhs/big.tape new file mode 100644 index 0000000..92b1fa5 --- /dev/null +++ b/vhs/big.tape @@ -0,0 +1,26 @@ +# Configuration + +Output vhs/big.gif + +Set Shell "bash" +Set FontSize 32 +Set Width 1200 +Set Height 500 + +Env PATH "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/vhs" + +# Setup +Hide +Type 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv bash)"' +Enter +Type "go build -o countdown ." +Enter +Wait +Type "clear" +Enter +Show + +# Script for recording + +Type './countdown --big --range 10..0 --spinner="bomb"' Sleep 1s +Enter Sleep 14s