├── .gitignore ├── example.gif ├── .github └── FUNDING.yml ├── go.mod ├── go.sum ├── LICENSE ├── ttimer.go ├── .goreleaser.yml ├── README.md ├── parse └── parse.go └── agent └── agent.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | ttimer -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drgrib/ttimer/HEAD/example.gif -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: drgrib 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/drgrib/ttimer 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/0xAX/notificator v0.0.0-20191016112426-3962a5ea8da1 7 | github.com/gizak/termui/v3 v3.1.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/0xAX/notificator v0.0.0-20191016112426-3962a5ea8da1 h1:j9HaafapDbPbGRDku6e/HRs6KBMcKHiWcm1/9Sbxnl4= 2 | github.com/0xAX/notificator v0.0.0-20191016112426-3962a5ea8da1/go.mod h1:NtXa9WwQsukMHZpjNakTTz0LArxvGYdPA9CjIcUSZ6s= 3 | github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= 4 | github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= 5 | github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= 6 | github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 7 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= 8 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 9 | github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= 10 | github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Chris Redford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /ttimer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/drgrib/ttimer/agent" 8 | "github.com/drgrib/ttimer/parse" 9 | ) 10 | 11 | ////////////////////////////////////////////// 12 | /// flags 13 | ////////////////////////////////////////////// 14 | 15 | var args struct { 16 | t string 17 | q bool 18 | } 19 | 20 | func init() { 21 | switch len(os.Args) { 22 | case 3: 23 | if os.Args[1] == "-q" { 24 | args.q = true 25 | args.t = os.Args[2] 26 | } 27 | if os.Args[2] == "-q" { 28 | args.q = true 29 | args.t = os.Args[1] 30 | } 31 | case 2: 32 | args.t = os.Args[1] 33 | default: 34 | args.t = "1m" 35 | } 36 | } 37 | 38 | ////////////////////////////////////////////// 39 | /// main 40 | ////////////////////////////////////////////// 41 | 42 | func main() { 43 | // parse 44 | d, title, err := parse.Args(args.t) 45 | if err != nil { 46 | fmt.Println(err.Error()) 47 | fmt.Println("\nPlease refer to https://github.com/drgrib/ttimer for usage instructions.") 48 | return 49 | } 50 | 51 | // start timer 52 | t := agent.Timer{Title: title} 53 | t.AutoQuit = args.q 54 | t.Start(d) 55 | 56 | // run UI 57 | t.CountDown() 58 | } 59 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: ttimer 2 | release: 3 | github: 4 | owner: drgrib 5 | name: ttimer 6 | name_template: '{{.Tag}}' 7 | brew: 8 | commit_author: 9 | name: goreleaserbot 10 | email: goreleaser@carlosbecker.com 11 | install: bin.install "ttimer" 12 | builds: 13 | - goos: 14 | - linux 15 | - darwin 16 | goarch: 17 | - amd64 18 | - "386" 19 | goarm: 20 | - "6" 21 | main: . 22 | ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} 23 | binary: ttimer 24 | archive: 25 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm 26 | }}v{{ .Arm }}{{ end }}' 27 | format: tar.gz 28 | files: 29 | - licence* 30 | - LICENCE* 31 | - license* 32 | - LICENSE* 33 | - readme* 34 | - README* 35 | - changelog* 36 | - CHANGELOG* 37 | fpm: 38 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm 39 | }}v{{ .Arm }}{{ end }}' 40 | bindir: /usr/local/bin 41 | snapcraft: 42 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm 43 | }}v{{ .Arm }}{{ end }}' 44 | snapshot: 45 | name_template: SNAPSHOT-{{ .Commit }} 46 | checksum: 47 | name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt' 48 | dist: dist 49 | sign: 50 | cmd: gpg 51 | args: 52 | - --output 53 | - $signature 54 | - --detach-sig 55 | - $artifact 56 | signature: ${artifact}.sig 57 | artifacts: none 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ttimer - Terminal Timer 2 | 3 | `ttimer` is a simple timer that counts down time left in a terminal window. If run on Mac, Windows, or desktop Linux, it will send silent system notifications at 90% and 100% completion. 4 | 5 | demo 6 | 7 | ## Installing 8 | 9 | ### macOS 10 | 11 | ``` 12 | brew install drgrib/tap/ttimer 13 | ``` 14 | 15 | #### Upgrading 16 | 17 | To get the latest version after installing with `brew`, use: 18 | 19 | ``` 20 | brew upgrade ttimer 21 | ``` 22 | 23 | ### go install 24 | 25 | ``` 26 | go install github.com/drgrib/ttimer@latest 27 | ``` 28 | 29 | To make it accessible on the command line as `ttimer`, ensure you've added `$GOPATH/bin` to your `$PATH`. 30 | 31 | ## Duration Timing 32 | 33 | Lets say you want a timer for 3 minutes. Simply enter: 34 | 35 | ``` 36 | ttimer 3 37 | ``` 38 | 39 | This will start a timer count down like so: 40 | 41 | ``` 42 | == 3m Timer == 43 | 2m55s 44 | ``` 45 | 46 | Or if you want a very specific duration, you can specify it using: 47 | 48 | ``` 49 | ttimer 3h2m5s 50 | ``` 51 | 52 | Or if you want a very short time: 53 | 54 | ``` 55 | ttimer 30s 56 | ``` 57 | 58 | ## End Time Timing 59 | 60 | Let's say you need to leave for the bus by *8:12 am*, which is coming up in the next hour. You could simply enter: 61 | 62 | ``` 63 | ttimer 812 64 | ``` 65 | 66 | And `ttimer` will automatically infer the next occurrence of `8:12`, which is `am`: 67 | 68 | ``` 69 | == 812a Timer == 70 | 23m29s 71 | ``` 72 | 73 | If you want to force it to set a timer for *8:12 __pm__*, you could use: 74 | 75 | ``` 76 | ttimer 812p 77 | ``` 78 | 79 | Resulting in something like: 80 | 81 | ``` 82 | == 812p Timer == 83 | 12h22m25s 84 | ``` 85 | 86 | If you want a timer for *3:00 pm*, you could simply enter: 87 | 88 | ``` 89 | ttimer 3p 90 | ``` 91 | 92 | All end time timers are set to align to zero seconds on the minute so they will change over to new minutes with the system clock. 93 | 94 | ## Parsing Rules 95 | 96 | * Integers less than `100` and floats will be interpreted as minutes. 97 | * Strings fitting a call to [`time.ParseDuration`](https://golang.org/pkg/time/#ParseDuration) will be interpreted as that duration. E.g. `1m30s` or `2h`. 98 | * Strings ending in `a`, `p`, `am`, or `pm` will be interpreted as times. E.g. `1p` or `930a`. 99 | * Integers greater than or equal to `100` will be interpreted as times. E.g. `242` will be interpreted as the next occurrence of `2:42` and set to `am` or `pm`, whichever is soonest. 100 | 101 | ## Exiting 102 | 103 | To exit the timer at any time, simply press `q`. 104 | 105 | ## Auto Exit 106 | 107 | To make the timer automatically exit after finishing, pass the `-q` argument like this 108 | 109 | ``` 110 | ttimer -q 30s 111 | ``` 112 | -------------------------------------------------------------------------------- /parse/parse.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | . "fmt" 5 | "math" 6 | "regexp" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | ////////////////////////////////////////////// 12 | /// parseArgs 13 | ////////////////////////////////////////////// 14 | 15 | func parseClock(clock string) (int, int, error) { 16 | if len(clock) >= 3 { 17 | // hour 18 | hourStr := clock[:len(clock)-2] 19 | hour, err := strconv.Atoi(hourStr) 20 | if err != nil { 21 | return 0, 0, Errorf( 22 | "Couldn't parse hourStr %#v", hourStr) 23 | } 24 | // min 25 | minStr := clock[len(clock)-2:] 26 | min, err := strconv.Atoi(minStr) 27 | if err != nil { 28 | return 0, 0, Errorf( 29 | "Couldn't parse minStr %#v", minStr) 30 | 31 | } 32 | return hour, min, nil 33 | } 34 | hour, err := strconv.Atoi(clock) 35 | if err != nil { 36 | return 0, 0, Errorf( 37 | "Couldn't parse as hour %#v", clock) 38 | } 39 | return hour, 0, nil 40 | } 41 | 42 | func parseTime(t string) (time.Duration, string, error) { 43 | // parameterized location due to not all platforms supporting local detection 44 | zero := time.Duration(0) 45 | now := time.Now() 46 | // track period 47 | pattern := `(\d+)(a|p)?` 48 | r := regexp.MustCompile(pattern) 49 | m := r.FindStringSubmatch(t) 50 | if len(m) < 3 { 51 | return zero, "", Errorf("Could not parse as Time: %#v", t) 52 | } 53 | clock := m[1] 54 | period := m[2] 55 | // handle minute case 56 | if period == "" && len(clock) <= 2 { 57 | return zero, "", Errorf("No period, assuming minutes, not Time: %#v", clock) 58 | } 59 | // handle clock 60 | hour, min, err := parseClock(clock) 61 | if err != nil { 62 | return zero, "", err 63 | } 64 | // estimate endTime 65 | endTime := time.Date( 66 | now.Year(), now.Month(), now.Day(), 67 | hour, min, 68 | 0, 0, now.Location()) 69 | // increment by 12 hours until after now 70 | for endTime.Before(now) { 71 | endTime = endTime.Add(12 * time.Hour) 72 | } 73 | // final increment if wrong period 74 | if period == "a" && endTime.Hour() >= 12 { 75 | endTime = endTime.Add(12 * time.Hour) 76 | } 77 | if period == "p" && endTime.Hour() < 12 { 78 | endTime = endTime.Add(12 * time.Hour) 79 | } 80 | // calculate the duration 81 | d := endTime.Sub(now) 82 | // format the title 83 | layout := "304pm" 84 | if min == 0 { 85 | // further truncate 86 | layout = "3pm" 87 | } 88 | formatted := endTime.Format(layout) 89 | // truncate period 90 | formatted = formatted[:len(formatted)-1] 91 | title := Sprintf("%v Timer", formatted) 92 | return d, title, nil 93 | } 94 | 95 | func Args(t string) (time.Duration, string, error) { 96 | f, err := strconv.ParseFloat(t, 64) 97 | switch { 98 | case err == nil: 99 | floatMinutes := math.Floor(f) 100 | seconds := int64(math.Floor((f - floatMinutes) * 60)) 101 | minutes := int64(floatMinutes) 102 | 103 | if seconds == 0 && len(t) > 1 { 104 | // parse as time 105 | d, title, err := parseTime(t) 106 | if err == nil { 107 | return d, title, nil 108 | } 109 | } 110 | 111 | d := time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second 112 | title := Sprintf("%vm Timer", f) 113 | return d, title, nil 114 | case len(t) == 1: 115 | // simple minute timer 116 | minutes, err := strconv.Atoi(t) 117 | if err != nil { 118 | return 0, "", err 119 | } 120 | 121 | d := time.Duration(minutes) * time.Minute 122 | title := Sprintf("%vm Timer", t) 123 | return d, title, nil 124 | default: 125 | // parse as duration 126 | d, err := time.ParseDuration(t) 127 | if err == nil { 128 | title := Sprintf("%v Timer", t) 129 | return d, title, nil 130 | } 131 | // parse as time 132 | d, title, err := parseTime(t) 133 | if err == nil { 134 | return d, title, nil 135 | } 136 | // if not time, parse as minute 137 | minutes, err := strconv.Atoi(t) 138 | if err != nil { 139 | return 0, "", err 140 | } 141 | d = time.Duration(minutes) * time.Minute 142 | title = Sprintf("%vm Timer", t) 143 | return d, title, nil 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "fmt" 5 | . "fmt" 6 | "math" 7 | "strings" 8 | "time" 9 | 10 | "github.com/0xAX/notificator" 11 | ui "github.com/gizak/termui/v3" 12 | "github.com/gizak/termui/v3/widgets" 13 | ) 14 | 15 | const ( 16 | termX = 1 17 | termY = 0 18 | ) 19 | 20 | ////////////////////////////////////////////// 21 | /// util 22 | ////////////////////////////////////////////// 23 | 24 | func mustBeNil(err error) { 25 | if err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | ////////////////////////////////////////////// 31 | /// AfterWallClock 32 | ////////////////////////////////////////////// 33 | 34 | func AfterWallClock(d time.Duration) <-chan time.Time { 35 | c := make(chan time.Time, 1) 36 | go func() { 37 | end := time.Now().Add(d) 38 | // clear monotonic clock 39 | end = end.Round(0) 40 | for !time.Now().After(end) { 41 | time.Sleep(100 * time.Millisecond) 42 | } 43 | c <- time.Now() 44 | }() 45 | return c 46 | } 47 | 48 | ////////////////////////////////////////////// 49 | /// Timer 50 | ////////////////////////////////////////////// 51 | 52 | type Timer struct { 53 | Title string 54 | AutoQuit bool 55 | Debug bool 56 | duration time.Duration 57 | end time.Time 58 | left time.Duration 59 | status string 60 | finished bool 61 | } 62 | 63 | func (t *Timer) Start(d time.Duration) { 64 | t.duration = d 65 | if t.Title == "" { 66 | t.Title = Sprintf("%v Timer", d) 67 | } 68 | // strip monotonic time to account for system changes 69 | t.end = time.Now().Add(t.duration).Round(0) 70 | 71 | // init notificator 72 | notify := notificator.New(notificator.Options{ 73 | AppName: t.Title, 74 | }) 75 | 76 | // set and execute pre-notify 77 | seconds := t.duration.Seconds() 78 | if seconds > 10 { 79 | go func() { 80 | almostSec := math.Floor(seconds * .9) 81 | almostDur := time.Duration(almostSec) * time.Second 82 | <-AfterWallClock(almostDur) 83 | message := Sprintf("%v left", t.left) 84 | _ = notify.Push( 85 | "", message, "", notificator.UR_CRITICAL) 86 | }() 87 | } 88 | // set and execute notify 89 | go func() { 90 | <-AfterWallClock(t.duration) 91 | _ = notify.Push( 92 | "", "Finished", "", notificator.UR_CRITICAL) 93 | t.finished = true 94 | }() 95 | } 96 | 97 | func shortTimeString(t time.Time) string { 98 | hour := t.Hour() 99 | min := t.Minute() 100 | period := "a" 101 | if hour >= 12 { 102 | period = "p" 103 | } 104 | outHour := hour 105 | if period == "p" && hour > 12 { 106 | outHour -= 12 107 | } 108 | if outHour == 0 { 109 | outHour = 12 110 | } 111 | if min == 0 { 112 | return fmt.Sprintf("%d%s", outHour, period) 113 | } 114 | return fmt.Sprintf("%d%02d%s", outHour, min, period) 115 | } 116 | 117 | func (t *Timer) update() { 118 | if !t.AutoQuit { 119 | t.status = "Finished\n\n[r]estart\n[q]uit" 120 | } 121 | now := time.Now() 122 | if !now.After(t.end) { 123 | exactLeft := t.end.Sub(now) 124 | floorSeconds := math.Floor(exactLeft.Seconds()) 125 | t.left = time.Duration(floorSeconds) * time.Second 126 | endTime := time.Now().Add(t.left) 127 | t.status = Sprintf("%v", t.left) 128 | // Don't duplicate the title if this is already an end time based timer 129 | if !(strings.Contains(t.Title, "a") || strings.Contains(t.Title, "p")) { 130 | t.status += " " + shortTimeString(endTime) 131 | } 132 | if t.Debug { 133 | t.status += "\n" 134 | t.status += Sprintf("\nnow: %v", now) 135 | t.status += Sprintf("\nexactLeft: %v", exactLeft) 136 | t.status += Sprintf("\nt.end: %v", t.end) 137 | t.status += Sprintf("\nt.end.Sub(now): %v", t.end.Sub(now)) 138 | } 139 | } 140 | } 141 | 142 | type countdownParams struct { 143 | eventHandler func(string) 144 | } 145 | 146 | type countdownOption func(opts *countdownParams) 147 | 148 | func WithEventHandler(eventHandler func(string)) countdownOption { 149 | return func(opts *countdownParams) { 150 | opts.eventHandler = eventHandler 151 | } 152 | } 153 | 154 | func (t *Timer) CountDown(opts ...countdownOption) { 155 | var params countdownParams 156 | for _, o := range opts { 157 | o(¶ms) 158 | } 159 | 160 | // init and close 161 | err := ui.Init() 162 | mustBeNil(err) 163 | defer ui.Close() 164 | 165 | p := widgets.NewParagraph() 166 | termWidth, termHeight := ui.TerminalDimensions() 167 | p.SetRect(termX, termY, termWidth, termHeight) 168 | p.TextStyle.Fg = ui.ColorClear 169 | p.Border = false 170 | 171 | // draw 172 | banner := Sprintf("== %s ==", t.Title) 173 | draw := func(tick int) { 174 | t.update() 175 | // render 176 | p.Text = Sprintf("%s\n%v", 177 | banner, 178 | t.status) 179 | ui.Render(p) 180 | } 181 | 182 | tickerCount := 1 183 | draw(tickerCount) 184 | tickerCount++ 185 | ticker := time.NewTicker(100 * time.Millisecond).C 186 | 187 | uiEvents := ui.PollEvents() 188 | for { 189 | select { 190 | case e := <-uiEvents: 191 | switch e.ID { 192 | case "q", "", "": 193 | return 194 | case "r": 195 | if time.Now().After(t.end) { 196 | t.Start(t.duration) 197 | } 198 | case "": 199 | resize := e.Payload.(ui.Resize) 200 | p.SetRect(termX, termY, resize.Width, resize.Height) 201 | } 202 | if params.eventHandler != nil { 203 | params.eventHandler(e.ID) 204 | } 205 | case <-ticker: 206 | draw(tickerCount) 207 | tickerCount++ 208 | if t.AutoQuit && t.finished { 209 | return 210 | } 211 | } 212 | } 213 | } 214 | --------------------------------------------------------------------------------