Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 |
|----------|------|
Expand Down Expand Up @@ -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

Expand Down
166 changes: 163 additions & 3 deletions internal/countdown/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +122,7 @@ type Config struct {
TitleBackground string
PaddingVertical int
PaddingHorizontal int
Big bool
}

// Model represents the Bubbletea model for the countdown.
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}

Expand Down Expand Up @@ -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))
Expand Down
74 changes: 74 additions & 0 deletions internal/countdown/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down Expand Up @@ -84,6 +85,7 @@ func main() {
TitleBackground: cli.TitleStyle.Background,
PaddingVertical: padV,
PaddingHorizontal: padH,
Big: cli.Big,
}

if err := countdown.Run(config); err != nil {
Expand Down
Loading