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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ commit-sprout status # streak, last commit, days until it wilts
commit-sprout prompt # compact glyph for your shell prompt / tmux status
commit-sprout --prompt # same thing, as a flag (handy inside a prompt string)
commit-sprout water # buy a grace day before wilting (weekends / PTO)
commit-sprout watch # live full-screen view; the plant gently sways
commit-sprout garden # a row of plants, one per repo (a windowsill)
commit-sprout --species cactus # pick your plant's species (remembered as default)
```
Expand Down Expand Up @@ -77,6 +78,21 @@ ever faking *progress*:
- **Health only** — grace never changes your growth stage or streak count; it
just keeps a watered plant perky through a planned break.

### Watch mode (windowsill)

```bash
commit-sprout watch
commit-sprout watch --interval 2s # re-read git activity more often
```

`watch` opens a minimal full-screen view and lets the plant live: it **sways
gently** and **re-reads your git activity on an interval** (default every 5s),
so a fresh commit sprouts without you restarting anything. Press **q** or
**Ctrl-C** to exit — the terminal (alternate screen, cursor, raw mode) is always
restored on the way out. It stays cheap: frames are throttled and only redraw on
change, so a still plant costs almost nothing. `watch` is read-only (it never
persists state) and refuses to start when stdout is not a TTY.

`prompt` prints a single glyph + stage (e.g. `ل leafy`). It is built for the
hot path: **read-only by default** (a prompt renders on every command, so it
skips the state write unless you pass `--save`) and it **fails silently outside
Expand Down Expand Up @@ -278,6 +294,8 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+
with a saveable repo set and per-repo author identity.
- **Species selection** (`commit-sprout --species`) — fern / cactus / bonsai / sunflower,
each with its own art set; the cactus survives droughts longer as a gameplay twist.
- **Animated watch mode** (`commit-sprout watch`) — a live full-screen view where the
plant gently sways and re-reads git activity on an interval.

## License

Expand Down
239 changes: 239 additions & 0 deletions cmd/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// Watch subcommand: a live, full-screen TUI that renders the plant and lets it
// gently sway. Where the root command prints one frame and exits, `watch` keeps
// a minimal alternate-screen loop running, re-reading git activity on an
// interval so the plant reflects new commits as they land, and animating an
// idle sway between reads so the creature feels alive on a windowsill.
//
// Design goals (see issue #10):
//
// - Minimal full-screen TUI on the alternate screen buffer.
// - Idle sway animation; git activity is re-read on a bounded interval.
// - Clean exit on q / Q / Ctrl-C, always restoring the terminal.
// - Low CPU when idle: frames are throttled and we only redraw on change.
//
// The animation is deliberately tiny and dependency-free: raw terminal mode via
// golang.org/x/sys plus a handful of ANSI escapes, so it degrades to nothing
// extra in the binary and stays fast to start.
package cmd

import (
"bufio"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/rwrife/commit-sprout/internal/gitstat"
"github.com/rwrife/commit-sprout/internal/render"
"github.com/spf13/cobra"
"golang.org/x/sys/unix"
)

// watchInterval is how often watch re-reads git activity. Commits do not land
// every second, so a slow poll keeps the plant fresh without churning git.
var watchInterval time.Duration

// swayPeriod bounds the idle sway animation. A slow step is plenty for a gentle
// sway and keeps CPU near-idle; combined with redraw-on-change it means a
// perfectly still plant costs almost nothing.
const (
swaySteps = 4 // frames in one sway cycle
swayPeriod = 700 * time.Millisecond // wall-clock time per sway step
)

// watchCmd implements `commit-sprout watch`.
var watchCmd = &cobra.Command{
Use: "watch",
Short: "Live full-screen view of the plant, gently swaying",
Long: `Open a minimal full-screen view of the plant and let it live: it sways
gently and re-reads your git activity on an interval, so a fresh commit sprouts
without you restarting anything.

Press q or Ctrl-C to exit; the terminal is always restored on the way out.

When stdout is not a terminal (piped or redirected) watch refuses to start,
since there is nothing to animate.`,
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runWatch(cmd)
},
}

// runWatch drives the watch loop. It sets up raw mode and the alternate screen,
// installs a signal handler, and runs an event loop that redraws on animation
// ticks and on git re-reads, exiting cleanly on q / Ctrl-C or SIGINT/SIGTERM.
func runWatch(cmd *cobra.Command) error {
out := cmd.OutOrStdout()

f, ok := out.(*os.File)
if !ok || !isTerminalFile(f) {
return errors.New("watch requires an interactive terminal (stdout is not a TTY)")
}
if watchInterval <= 0 {
return errors.New("watch: --interval must be positive")
}

// Enter raw mode so single keypresses (q, Ctrl-C) are delivered without
// line buffering, and restore it no matter how we leave.
restore, err := enterRaw(f)
if err != nil {
return fmt.Errorf("watch: could not set up terminal: %w", err)
}
defer restore()

// Alternate screen + hidden cursor for a clean canvas; always undo both.
fmt.Fprint(out, altScreenEnter+cursorHide)
defer fmt.Fprint(out, cursorShow+altScreenLeave)

// Translate SIGINT/SIGTERM into a graceful quit so the deferred restores
// still run (a bare signal would skip them and wreck the terminal).
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)

// Keypresses arrive on their own goroutine so the render loop never blocks
// on input; a closed channel signals EOF/read error.
keyCh := make(chan byte, 8)
go readKeys(f, keyCh)

// Prime the first git read immediately, then refresh on the interval.
frame := readFrame()
ticker := time.NewTicker(swayPeriod)
defer ticker.Stop()
refresh := time.NewTicker(watchInterval)
defer refresh.Stop()

phase := 0
var last string // last drawn frame, to skip redundant redraws (low CPU idle)

draw := func() {
screen := composeScreen(frame, phase)
if screen == last {
return
}
last = screen
fmt.Fprint(out, clearScreen+home+screen)
}
draw()

for {
select {
case <-sigCh:
return nil
case b, open := <-keyCh:
if !open {
return nil // input closed; leave gracefully
}
if b == 'q' || b == 'Q' || b == 3 /* Ctrl-C */ {
return nil
}
case <-ticker.C:
phase = (phase + 1) % swaySteps
draw()
case <-refresh.C:
frame = readFrame()
draw()
}
}
}

// readFrame reads git activity for the current repo and renders one plain-ASCII\n// plant frame, degrading gracefully: outside a repo (or on any read error) it
// returns a friendly message instead of an error so the loop keeps running.
// It never persists state — watch is a read-only view.
func readFrame() string {
r, err := runPipeline()
if err != nil {
if errors.Is(err, gitstat.ErrNotARepo) {
return "Not a git repository — nothing growing here yet."
}
return "commit-sprout: " + err.Error()
}
return render.Frame(r.plant, render.Options{
Color: false, // the sway is the animation; plain ASCII keeps redraw cheap
Now: r.now,
LastCommit: r.activity.LastCommit,
Species: r.species,
})
}

// composeScreen applies the sway to a rendered frame and appends the quit
// footer, producing the exact bytes drawn for a given animation phase.
func composeScreen(frame string, phase int) string {
return swayIndent(frame, phase) + "\r\n\r\n q / Ctrl-C to quit"
}

// swayIndent shifts every line right by a small, phase-driven amount to fake a
// gentle breeze. The offsets form a smooth triangle wave (0,1,2,1) so the plant
// leans one way and back rather than jumping. Lines are joined with \r\n so raw
// mode (no ONLCR translation) still returns the cursor to column 0.
func swayIndent(frame string, phase int) string {
offsets := []int{0, 1, 2, 1}
pad := strings.Repeat(" ", offsets[phase%len(offsets)])
lines := strings.Split(frame, "\n")
for i, ln := range lines {
lines[i] = pad + ln
}
return strings.Join(lines, "\r\n")
}

// readKeys forwards single bytes from the terminal to keyCh until read fails
// (EOF / error), then closes the channel to signal the loop to exit.
func readKeys(f *os.File, keyCh chan<- byte) {
defer close(keyCh)
r := bufio.NewReader(f)
for {
b, err := r.ReadByte()
if err != nil {
return // EOF or read error: signal exit by closing the channel
}
keyCh <- b
}
}

// --- terminal plumbing -----------------------------------------------------

const (
altScreenEnter = "\x1b[?1049h"
altScreenLeave = "\x1b[?1049l"
cursorHide = "\x1b[?25l"
cursorShow = "\x1b[?25h"
clearScreen = "\x1b[2J"
home = "\x1b[H"
)

// isTerminalFile reports whether f is a real terminal via a TCGETS ioctl.
func isTerminalFile(f *os.File) bool {
_, err := unix.IoctlGetTermios(int(f.Fd()), unix.TCGETS)
return err == nil
}

// enterRaw puts the terminal into byte-at-a-time raw mode (no echo, no
// canonical line buffering, no signal generation on Ctrl-C so we can handle it
// ourselves) and returns a function that restores the original settings exactly.
func enterRaw(f *os.File) (func(), error) {
fd := int(f.Fd())
orig, err := unix.IoctlGetTermios(fd, unix.TCGETS)
if err != nil {
return nil, err
}
raw := *orig
raw.Lflag &^= unix.ECHO | unix.ICANON | unix.ISIG
raw.Iflag &^= unix.IXON | unix.ICRNL
raw.Cc[unix.VMIN] = 1
raw.Cc[unix.VTIME] = 0
if err := unix.IoctlSetTermios(fd, unix.TCSETS, &raw); err != nil {
return nil, err
}
return func() { _ = unix.IoctlSetTermios(fd, unix.TCSETS, orig) }, nil
}

func init() {
watchCmd.Flags().DurationVar(&watchInterval, "interval", 5*time.Second,
"how often to re-read git activity (e.g. 2s, 1m)")
rootCmd.AddCommand(watchCmd)
}
41 changes: 41 additions & 0 deletions cmd/watch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cmd

import (
"strings"
"testing"
)

// TestSwayIndent verifies the sway offsets form the expected triangle wave and
// that every line is padded (not just the first), joined with \r\n for raw mode.
func TestSwayIndent(t *testing.T) {
frame := "abc\ndef"
cases := []struct {
phase int
pad string
}{
{0, ""},
{1, " "},
{2, " "},
{3, " "},
{4, ""}, // wraps
}
for _, c := range cases {
got := swayIndent(frame, c.phase)
want := c.pad + "abc\r\n" + c.pad + "def"
if got != want {
t.Errorf("phase %d: got %q, want %q", c.phase, got, want)
}
}
}

// TestComposeScreenFooter ensures the quit footer is always present so the user
// is never left guessing how to exit the full-screen view.
func TestComposeScreenFooter(t *testing.T) {
screen := composeScreen("plant", 0)
if !strings.Contains(screen, "q / Ctrl-C to quit") {
t.Errorf("footer missing from watch screen: %q", screen)
}
if !strings.HasPrefix(screen, "plant") {
t.Errorf("plant body missing/misplaced: %q", screen)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/charmbracelet/lipgloss v0.13.0
github.com/mattn/go-isatty v0.0.20
github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.19.0
)

require (
Expand All @@ -17,5 +18,4 @@ require (
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.19.0 // indirect
)
Loading