diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b5e28ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.github +README.md +LICENSE +images +neonlift \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b2925bc --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,62 @@ +name: Docker + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: latest=auto + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..8d64f14 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,18 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - main + pull_request: +permissions: + contents: read +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: golangci-lint + uses: golangci/golangci-lint-action@v2 \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..bfa3d91 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,20 @@ +on: + release: + types: [created] + +jobs: + releases-matrix: + name: Release Go Binary + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, windows, darwin] + goarch: [amd64, arm64] + steps: + - uses: actions/checkout@v2 + - uses: wangyoucao577/go-release-action@v1.22 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + sha256sum: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..defa4fb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +neonlift \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2562068 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o neonlift . + +# Final stage +FROM scratch +WORKDIR /root/ +COPY --from=builder /app/neonlift . +ENTRYPOINT ["./neonlift"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c034982 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Anthony Scotti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..66e6f86 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Neon Lift - Stand and Sit Timer + +

+ A wizard standing at a standing desk, using a computer +

+ +Neon Lift is a simple command-line application to remind you to alternate between standing and sitting positions while working at a standing desk. In addition to its functionality, this was created to help learn more about [Bubble Tea](https://github.com/charmbracelet/bubbletea) and [Lip Gloss](https://github.com/charmbracelet/lipgloss). + +[![asciicast](https://asciinema.org/a/VQ065W0QL9xH2bBDE6I55RN2b.svg)](https://asciinema.org/a/VQ065W0QL9xH2bBDE6I55RN2b) + +## Features + +- Customizable standing and sitting durations. +- Visual progress bar. +- Audible alert when it's time to change positions. +- Pause and resume functionality. + +## Usage + +### Running with Go + +To run the application, use the following command: + +```sh +go run main.go +``` + +You can specify the duration for standing and sitting using the `-stand` and `-sit` flags, respectively: + +```sh +go run main.go -stand=45m -sit=15m +``` + +Press 'Enter' to start, 'Space' to pause, and 'Q' to quit the application. + + +### Running with Docker + +You can build and run the application using Docker. First, build the Docker image: + +```sh +docker build -t neonlift . +``` + +Then, run the application in a Docker container: + +```sh +docker run --rm neonlift +``` + +You can also pass the `-stand` and `-sit` flags to customize the durations: + +```sh +docker run --rm neonlift -stand=45m -sit=15m +``` + +## TODO / Improvements + +- Restructure code. +- Add unit test +- Options for notification other than beeping. + +I'm always looking to improve Neon Lift, so if you're keen to contribute, have at it! Fork the repo, work your magic, and send a pull request my way. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..022381a --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/amscotti/neonlift + +go 1.21.5 + +require ( + github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/gen2brain/beeep v0.0.0-20230907135156-1a38885a97fc +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/term v0.6.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dd5d02a --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= +github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/gen2brain/beeep v0.0.0-20230907135156-1a38885a97fc h1:NNgdMgPX3j33uEAoVVxNxillDPnxT0xbGv8uh4CKIAo= +github.com/gen2brain/beeep v0.0.0-20230907135156-1a38885a97fc/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/images/wizard_standing.jpg b/images/wizard_standing.jpg new file mode 100644 index 0000000..5e4566d Binary files /dev/null and b/images/wizard_standing.jpg differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..d482ad2 --- /dev/null +++ b/main.go @@ -0,0 +1,222 @@ +package main + +import ( + "flag" + "fmt" + "log" + "time" + + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/lipgloss" + + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gen2brain/beeep" +) + +var ( + standingDuration = flag.Duration("stand", 30*time.Minute, "Duration for standing") + sittingDuration = flag.Duration("sit", 1*time.Hour, "Duration for sitting") +) + +// formatTime takes a duration and returns it as a formatted string. +func formatTime(d time.Duration) string { + minutes := d / time.Minute + seconds := (d - minutes*time.Minute) / time.Second + return fmt.Sprintf("%02d:%02d", minutes, seconds) +} + +type State int + +const ( + Sitting State = iota + Standing + Waiting + Start +) + +// tickMsg is sent on every tick +type tickMsg time.Time + +type model struct { + state State + previousState State + cycleCount uint8 + timer time.Duration + paused bool + progress progress.Model +} + +func initialModel() model { + return model{ + state: Start, + previousState: Sitting, + cycleCount: 0, + timer: *standingDuration, + progress: progress.New(progress.WithWidth(60), progress.WithDefaultGradient(), progress.WithoutPercentage()), + } +} + +func (m model) Init() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +const neonPink = lipgloss.Color("#FF00FF") +const neonBlue = lipgloss.Color("#00FFFF") +const darkBackground = lipgloss.Color("#120052") + +var appStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(neonPink). + Background(darkBackground). + Width(80) // Set a minimum width for the app window + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Underline(true). + Foreground(neonPink). + Background(darkBackground). + Width(80). + Padding(2, 0). + Align(lipgloss.Center) + + countStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(neonBlue). + Background(darkBackground). + Width(80). + Padding(0, 0). + Align(lipgloss.Center) + + timerStyle = lipgloss.NewStyle(). + Foreground(neonBlue). + Background(darkBackground). + Width(80). + Align(lipgloss.Center). + Padding(1, 0) + + progressStyle = lipgloss.NewStyle(). + Foreground(neonBlue). + Background(darkBackground). + Width(80). + Align(lipgloss.Center). + Padding(1, 0) + + instructionStyle = lipgloss.NewStyle(). + Foreground(neonPink). + Width(80). + Align(lipgloss.Center) +) + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + return m, tea.Batch(tea.Quit) + case " ": + if m.state == Standing || m.state == Sitting { + if m.paused { + m.paused = false + return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) + } else { + m.paused = true + return m, nil + } + } + return m, nil + + case "enter": + if m.state == Waiting || m.state == Start { + // Transition to the opposite of the previous state + if m.previousState == Sitting { + m.state = Standing + m.timer = *standingDuration // Use the standing duration from the command-line option + } else { + m.state = Sitting + m.timer = *sittingDuration // Use the sitting duration from the command-line option + } + return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) + } + } + case tickMsg: + if (m.state == Standing || m.state == Sitting) && m.timer > 0 { + if !m.paused { + m.timer -= time.Second + initialDuration := *standingDuration + if m.state == Sitting { + initialDuration = *sittingDuration + } + elapsedTime := initialDuration - m.timer + progressPercent := float64(elapsedTime) / float64(initialDuration) + return m, tea.Batch(tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }), m.progress.SetPercent(progressPercent)) + } + } else if m.timer <= 0 { + err := beeep.Beep(beeep.DefaultFreq, beeep.DefaultDuration) + if err != nil { + panic(err) + } + + m.previousState = m.state + m.state = Waiting + m.cycleCount++ + return m, m.progress.SetPercent(0.0) + } + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + return m, cmd + } + + return m, nil +} + +func (m model) View() string { + var fullView string + title := titleStyle.Render("Neon Lift - Escape the Pixel Slump") + instructions := instructionStyle.Render("Press 'Enter' to start, 'Space' to pause, 'Q' to quit") + progress := progressStyle.Render(m.progress.View()) + + count := "" + for i := uint8(0); i < m.cycleCount; i++ { + if i%2 == 0 { + count += "● " + } else { + count += "○ " + } + } + count = strings.TrimSpace(count) + + // Content based on state + switch m.state { + case Sitting: + timer := timerStyle.Render(fmt.Sprintf("Sitting down! Time left: %s", formatTime(m.timer))) + fullView = lipgloss.JoinVertical(lipgloss.Left, title, countStyle.Render(count), timer, progress, instructions) + case Standing: + timer := timerStyle.Render(fmt.Sprintf("Standing up! Time left: %s", formatTime(m.timer))) + fullView = lipgloss.JoinVertical(lipgloss.Left, title, countStyle.Render(count), timer, progress, instructions) + case Waiting: + waiting := timerStyle.Render("Please change your position") + fullView = lipgloss.JoinVertical(lipgloss.Left, title, countStyle.Render(count), waiting, progressStyle.Render(), instructions) + case Start: + waiting := timerStyle.Render("Welcome, please begin standing") + fullView = lipgloss.JoinVertical(lipgloss.Left, title, countStyle.Render(count), waiting, progressStyle.Render(), instructions) + default: + unknown := timerStyle.Render("Unknown state") + fullView = lipgloss.JoinVertical(lipgloss.Left, title, countStyle.Render(count), unknown, progressStyle.Render(), instructions) + } + + // Apply app-wide styling + return appStyle.Render(fullView) +} + +func main() { + flag.Parse() + + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +}