diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..49b34aa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true + +[*.go] +indent_style = tab +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..518ec0c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up env + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "VERSION=$VERSION" >> $GITHUB_ENV + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.22 + cache: true + - name: Build + run: make builds + - name: Archive + run: make archives + - name: Release + run: make release VERSION=$VERSION + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c691ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.zip +*.tar.gz +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc19184 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 mistweaver.co + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e04ecc --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +BIN_NAME=countup + +build-windows-64: + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w -X 'main.VERSION=$(VERSION)'" -o dist/windows/$(BIN_NAME).exe cmd/main.go +build-linux-64: + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w -X 'main.VERSION=$(VERSION)'" -o dist/linux/$(BIN_NAME) cmd/main.go +build-macos-arm64: + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "-s -w -X 'main.VERSION=$(VERSION)'" -o dist/macos/$(BIN_NAME) cmd/main.go + +builds: build-linux-64 build-macos-arm64 build-windows-64 + +archives: + zip --junk-paths -r dist/countup.sh-$(VERSION)-windows.zip dist/windows + tar -czvf dist/countup.sh-$(VERSION)-linux.tar.gz -C dist/linux . + tar -czvf dist/countup.sh-$(VERSION)-macos.tar.gz -C dist/macos . + +release: + gh release create --generate-notes v$(VERSION) dist/countup.sh-$(VERSION)-linux.tar.gz dist/countup.sh-$(VERSION)-macos.tar.gz dist/countup.sh-$(VERSION)-windows.zip + +run: + go run -ldflags "-X 'main.VERSION=development'" cmd/main.go "Dummy Timer" diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..b2bcee6 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "os" + + "github.com/charmbracelet/log" + "github.com/mistweaverco/countup.sh/internal/counter" +) + +var VERSION string + +func main() { + log.Info("Starting countup.sh ⏰", "version", VERSION) + + if len(os.Args) < 2 { + log.Error("No name for the timer provided 💀") + os.Exit(1) + } + timerName := os.Args[1] + + log.Info("Timer started", "name", timerName) + + counter.Start(timerName) +} \ No newline at end of file diff --git a/cmd/styles.go b/cmd/styles.go new file mode 100644 index 0000000..7bc1297 --- /dev/null +++ b/cmd/styles.go @@ -0,0 +1,11 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +var style = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#7D56F4")). + PaddingTop(2). + PaddingLeft(4). + Width(22) \ No newline at end of file diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..79deef5 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,35 @@ +package main + +import ( + "encoding/json" + "log" + "os" +) + +var ps = string(os.PathSeparator) + +func getCurrentWorkingDirectory() string { + dir, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + return dir +} + +func getJsonRoot() JsonRoot { + currentDir := getCurrentWorkingDirectory() + file, err := os.Open(currentDir + ps + "packages.json") + if err != nil { + log.Fatal(err) + } + defer file.Close() + + decoder := json.NewDecoder(file) + var jsonRoot JsonRoot + err = decoder.Decode(&jsonRoot) + if err != nil { + log.Fatal(err) + } + + return jsonRoot +} \ No newline at end of file diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..e58f24a --- /dev/null +++ b/demo.tape @@ -0,0 +1,15 @@ +Output demo.gif + +Require make +Require go +Require zsh + +Set Shell "zsh" +Set FontSize 12 +Set Width 480 +Set Height 320 + +Type "make run" Enter + +Sleep 10s + diff --git a/dist/.gitignore b/dist/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/dist/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/dist/linux/countup b/dist/linux/countup new file mode 100755 index 0000000..db74b0f Binary files /dev/null and b/dist/linux/countup differ diff --git a/dist/macos/countup b/dist/macos/countup new file mode 100755 index 0000000..38c91ae Binary files /dev/null and b/dist/macos/countup differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6f2d4d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/mistweaverco/countup.sh + +go 1.22 + +require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.10.0 + github.com/charmbracelet/log v0.4.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // 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-20230316100256-276c6243b2f6 // 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/rivo/uniseg v0.4.7 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..373b824 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +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.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/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-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/counter/counter.go b/internal/counter/counter.go new file mode 100644 index 0000000..68e99bf --- /dev/null +++ b/internal/counter/counter.go @@ -0,0 +1,130 @@ +package counter + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/stopwatch" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + timerNameColor = "#2EF8BB" + stopwatchColor = "#FF5F87" +) + +var ( + timerNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(timerNameColor)).MarginRight(1) + stopwatchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(stopwatchColor)).MarginRight(1) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginTop(2) +) + +var baseTimerStyle = lipgloss.NewStyle().Padding(1, 2) + +var ( + lastID int + idMtx sync.Mutex +) + +type model struct { + timerName string + stopwatch stopwatch.Model + keymap keymap + help help.Model + quitting bool +} + +type keymap struct { + start key.Binding + stop key.Binding + reset key.Binding + quit key.Binding +} + +func (m model) Init() tea.Cmd { + return m.stopwatch.Init() +} + +func getFormattedTimeString(d time.Duration) string { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) +} + +func (m model) View() string { + s := "" + sw := getFormattedTimeString(m.stopwatch.Elapsed()) + "\n" + if !m.quitting { + s = timerNameStyle.Render(m.timerName) + " " + sw + s += m.helpView() + } + return s +} + +func (m model) helpView() string { + return "\n" + m.help.ShortHelpView([]key.Binding{ + m.keymap.start, + m.keymap.stop, + m.keymap.reset, + m.keymap.quit, + }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keymap.quit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keymap.reset): + return m, m.stopwatch.Reset() + case key.Matches(msg, m.keymap.start, m.keymap.stop): + m.keymap.stop.SetEnabled(!m.stopwatch.Running()) + m.keymap.start.SetEnabled(m.stopwatch.Running()) + return m, m.stopwatch.Toggle() + } + } + var cmd tea.Cmd + m.stopwatch, cmd = m.stopwatch.Update(msg) + return m, cmd +} + +func Start(timerName string) { + m := model{ + timerName: timerName, + stopwatch: stopwatch.NewWithInterval(time.Second), + keymap: keymap{ + start: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "start"), + ), + stop: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "stop"), + ), + reset: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reset"), + ), + quit: key.NewBinding( + key.WithKeys("ctrl+c", "q"), + key.WithHelp("q", "quit"), + ), + }, + help: help.New(), + } + + m.keymap.start.SetEnabled(false) + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Oh no, it didn't work:", err) + os.Exit(1) + } +} \ No newline at end of file