├── .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 |
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 |
--------------------------------------------------------------------------------