├── LICENSE ├── README.md ├── go.mod ├── go.sum └── main.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vernon Miller 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # countdown 2 | Countdown is terminal based multi-event countdown timer. It uses the [Bubble Tea](https://github.com/charmbracelet/bubbletea) TUI framework from [Charm_](https://charm.sh/). 3 | 4 | 5 | 6 | https://user-images.githubusercontent.com/96601789/182011443-15b35466-3969-490c-9f74-b30dcbd29a41.mp4 7 | 8 | 9 | 10 | ## Installation 11 | Just clone and build. 12 | ``` 13 | git clone https://github.com/aldernero/countdown.git 14 | cd countdown 15 | go build -o countdown main.go 16 | ``` 17 | When you launch it for the first time an `events.json` file will be created in the current directory, and you'll see a single event: 18 | 19 | ![Screenshot_20220730_230038](https://user-images.githubusercontent.com/96601789/182010935-492b513e-4df4-48f8-8efb-28c1767ce2cb.png) 20 | 21 | As you add and remove events, the `events.json` file will be updated. 22 | 23 | ## Usage 24 | 25 | The controls are 26 | - "+" to add an event 27 | - "-" to remove an event 28 | - "/" to filter events 29 | 30 | The rest of the controls are what you would expect, up/down to traverse the list, tab to move between fields in the event input form. 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aldernero/countdown 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.13.0 7 | github.com/charmbracelet/bubbletea v0.22.0 8 | github.com/charmbracelet/lipgloss v0.5.0 9 | ) 10 | 11 | require ( 12 | github.com/atotto/clipboard v0.1.4 // indirect 13 | github.com/containerd/console v1.0.3 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-isatty v0.0.14 // indirect 16 | github.com/mattn/go-runewidth v0.0.13 // indirect 17 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 18 | github.com/muesli/cancelreader v0.2.1 // indirect 19 | github.com/muesli/reflow v0.3.0 // indirect 20 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect 21 | github.com/rivo/uniseg v0.2.0 // indirect 22 | github.com/sahilm/fuzzy v0.1.0 // indirect 23 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect 24 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w= 4 | github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= 5 | github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= 6 | github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc= 7 | github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs= 8 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 9 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 10 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 11 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 12 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 13 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 14 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 15 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 16 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 17 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 18 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 19 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 20 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 21 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 22 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 23 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 24 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 25 | github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 26 | github.com/muesli/cancelreader v0.2.1 h1:Xzd1B4U5bWQOuSKuN398MyynIGTNT89dxzpEDsalXZs= 27 | github.com/muesli/cancelreader v0.2.1/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 28 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 29 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 30 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 31 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 32 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 33 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 34 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 35 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 36 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 38 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 39 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= 44 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 46 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 47 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "github.com/charmbracelet/bubbles/key" 8 | "github.com/charmbracelet/bubbles/list" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | "github.com/charmbracelet/bubbles/timer" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "io/ioutil" 14 | "os" 15 | "strconv" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | const ( 21 | secondsPerYear = 31557600 22 | secondsPerDay = 86400 23 | secondsPerHour = 3600 24 | secondsPerMinute = 60 25 | timeout = 365 * 24 * time.Hour 26 | defaultListWidth = 28 27 | defaultListHeight = 40 28 | defaultDetailWidth = 45 29 | defaultInputWidth = 22 30 | defaultHelpHeight = 4 31 | eventsFile = "events.json" 32 | inputTimeFormShort = "2006-01-02" 33 | inputTimeFormLong = "2006-01-02 15:04:05" 34 | cError = "#CF002E" 35 | cItemTitleDark = "#F5EB6D" 36 | cItemTitleLight = "#F3B512" 37 | cItemDescDark = "#9E9742" 38 | cItemDescLight = "#FFD975" 39 | cTitle = "#2389D3" 40 | cDetailTitle = "#D32389" 41 | cPromptBorder = "#D32389" 42 | cDimmedTitleDark = "#DDDDDD" 43 | cDimmedTitleLight = "#222222" 44 | cDimmedDescDark = "#999999" 45 | cDimmedDescLight = "#555555" 46 | cTextLightGray = "#FFFDF5" 47 | ) 48 | 49 | var AppStyle = lipgloss.NewStyle().Margin(0, 1) 50 | var TitleStyle = lipgloss.NewStyle(). 51 | Foreground(lipgloss.Color(cTextLightGray)). 52 | Background(lipgloss.Color(cTitle)). 53 | Padding(0, 1) 54 | var DetailTitleStyle = lipgloss.NewStyle(). 55 | Width(defaultDetailWidth). 56 | Foreground(lipgloss.Color(cTextLightGray)). 57 | Background(lipgloss.Color(cDetailTitle)). 58 | Padding(0, 1). 59 | Align(lipgloss.Center) 60 | var InputTitleStyle = lipgloss.NewStyle(). 61 | Width(defaultInputWidth). 62 | Foreground(lipgloss.Color(cTextLightGray)). 63 | Background(lipgloss.Color(cDetailTitle)). 64 | Padding(0, 1). 65 | Align(lipgloss.Center) 66 | var SelectedTitle = lipgloss.NewStyle(). 67 | Border(lipgloss.NormalBorder(), false, false, false, true). 68 | BorderForeground(lipgloss.AdaptiveColor{Light: cItemTitleLight, Dark: cItemTitleDark}). 69 | Foreground(lipgloss.AdaptiveColor{Light: cItemTitleLight, Dark: cItemTitleDark}). 70 | Padding(0, 0, 0, 1) 71 | var SelectedDesc = SelectedTitle.Copy(). 72 | Foreground(lipgloss.AdaptiveColor{Light: cItemDescLight, Dark: cItemDescDark}) 73 | var DimmedTitle = lipgloss.NewStyle(). 74 | Foreground(lipgloss.AdaptiveColor{Light: cDimmedTitleLight, Dark: cDimmedTitleDark}). 75 | Padding(0, 0, 0, 2) 76 | var DimmedDesc = DimmedTitle.Copy(). 77 | Foreground(lipgloss.AdaptiveColor{Light: cDimmedDescDark, Dark: cDimmedDescLight}) 78 | var InputStyle = lipgloss.NewStyle(). 79 | Margin(1, 1). 80 | Padding(1, 2). 81 | Border(lipgloss.RoundedBorder(), true, true, true, true). 82 | BorderForeground(lipgloss.Color(cPromptBorder)). 83 | Render 84 | var DetailStyle = lipgloss.NewStyle(). 85 | Padding(1, 2). 86 | Border(lipgloss.ThickBorder(), false, false, false, true). 87 | BorderForeground(lipgloss.AdaptiveColor{Light: cItemTitleLight, Dark: cItemTitleDark}). 88 | Render 89 | var ErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cError)).Render 90 | var NoStyle = lipgloss.NewStyle() 91 | var FocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(cPromptBorder)) 92 | var BlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) 93 | var BrightTextStyle = lipgloss.NewStyle(). 94 | Foreground(lipgloss.AdaptiveColor{Light: cDimmedTitleLight, Dark: cDimmedTitleDark}).Render 95 | var NormalTextStyle = lipgloss.NewStyle(). 96 | Foreground(lipgloss.AdaptiveColor{Light: cDimmedDescLight, Dark: cDimmedDescDark}).Render 97 | var SpecialTextStyle = lipgloss.NewStyle(). 98 | Width(defaultDetailWidth). 99 | Margin(0, 0, 1, 0). 100 | Foreground(lipgloss.AdaptiveColor{Light: cItemTitleLight, Dark: cItemTitleDark}). 101 | Align(lipgloss.Center).Render 102 | var DetailsBlockLeft = lipgloss.NewStyle(). 103 | Width(defaultDetailWidth / 2). 104 | Foreground(lipgloss.AdaptiveColor{Light: cDimmedTitleLight, Dark: cDimmedTitleDark}). 105 | Align(lipgloss.Right). 106 | Render 107 | var DetailsBlockRight = lipgloss.NewStyle(). 108 | Width(defaultDetailWidth / 2). 109 | Foreground(lipgloss.AdaptiveColor{Light: cDimmedDescLight, Dark: cDimmedDescDark}). 110 | Align(lipgloss.Left). 111 | Render 112 | var HelpStyle = list.DefaultStyles().HelpStyle.Width(defaultListWidth).Height(5) 113 | 114 | type keymap struct { 115 | Add key.Binding 116 | Remove key.Binding 117 | Next key.Binding 118 | Prev key.Binding 119 | Enter key.Binding 120 | Back key.Binding 121 | Quit key.Binding 122 | } 123 | 124 | // Keymap reusable key mappings shared across models 125 | var Keymap = keymap{ 126 | Add: key.NewBinding( 127 | key.WithKeys("+"), 128 | key.WithHelp("+", "add"), 129 | ), 130 | Remove: key.NewBinding( 131 | key.WithKeys("-"), 132 | key.WithHelp("-", "remove"), 133 | ), 134 | Next: key.NewBinding( 135 | key.WithKeys("tab"), 136 | ), 137 | Prev: key.NewBinding( 138 | key.WithKeys("shift+tab"), 139 | ), 140 | Enter: key.NewBinding( 141 | key.WithKeys("enter"), 142 | ), 143 | Back: key.NewBinding( 144 | key.WithKeys("esc"), 145 | key.WithHelp("esc", "back"), 146 | ), 147 | Quit: key.NewBinding( 148 | key.WithKeys("ctlr+c", "q"), 149 | key.WithHelp("q", "back"), 150 | ), 151 | } 152 | 153 | type sessionState int 154 | 155 | const ( 156 | showEvents sessionState = iota 157 | showInput 158 | noEvents 159 | ) 160 | 161 | type inputFields int 162 | 163 | const ( 164 | inputNameField inputFields = iota 165 | inputTimeField 166 | inputCancelButton 167 | inputSubmitButton 168 | ) 169 | 170 | type Event struct { 171 | Name string `json:"name"` 172 | Time int64 `json:"ts"` 173 | } 174 | 175 | func (e Event) ToBasicString() string { 176 | return time.Unix(e.Time, 0).String() 177 | } 178 | 179 | func (e Event) Title() string { return e.Name } 180 | func (e Event) Description() string { return countdownParser(e.Time) } 181 | func (e Event) FilterValue() string { return e.Name } 182 | 183 | type MainModel struct { 184 | state sessionState 185 | focus int 186 | events list.Model 187 | inputs []textinput.Model 188 | timer timer.Model 189 | inputStatus string 190 | } 191 | 192 | func NewMainModel() MainModel { 193 | m := MainModel{ 194 | state: showEvents, 195 | timer: timer.NewWithInterval(timeout, time.Second), 196 | } 197 | events, err := readEventsFile() 198 | if err != nil { 199 | panic(err) 200 | } 201 | items := make([]list.Item, len(events)) 202 | for i := range events { 203 | items[i] = events[i] 204 | } 205 | m.inputs = make([]textinput.Model, 2) 206 | var t textinput.Model 207 | for i := range m.inputs { 208 | t = textinput.New() 209 | t.CharLimit = 30 210 | switch i { 211 | case 0: 212 | t.Placeholder = "Event Name" 213 | t.Focus() 214 | t.PromptStyle = FocusedStyle 215 | t.TextStyle = FocusedStyle 216 | case 1: 217 | t.Placeholder = "YYYY-MM-DD hh:mm:ss" 218 | t.CharLimit = 19 219 | } 220 | m.inputs[i] = t 221 | } 222 | delegate := list.NewDefaultDelegate() 223 | delegate.Styles.SelectedTitle = SelectedTitle 224 | delegate.Styles.SelectedDesc = SelectedDesc 225 | delegate.Styles.DimmedTitle = DimmedTitle 226 | delegate.Styles.DimmedDesc = DimmedDesc 227 | delegate.ShortHelpFunc = func() []key.Binding { return []key.Binding{Keymap.Add, Keymap.Remove} } 228 | delegate.FullHelpFunc = func() [][]key.Binding { return [][]key.Binding{{Keymap.Add, Keymap.Remove}} } 229 | m.events = list.New(items, delegate, defaultListWidth, defaultListHeight) 230 | m.events.Title = "Events" 231 | m.events.Styles.Title = TitleStyle 232 | m.events.Styles.HelpStyle = HelpStyle 233 | m.events.SetShowPagination(true) 234 | if len(m.events.Items()) == 0 { 235 | m.state = noEvents 236 | } 237 | return m 238 | } 239 | 240 | func (m MainModel) Init() tea.Cmd { 241 | return m.timer.Init() 242 | } 243 | 244 | func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 245 | var cmd tea.Cmd 246 | var cmds []tea.Cmd 247 | switch m.state { 248 | case noEvents: 249 | switch msg := msg.(type) { 250 | case tea.KeyMsg: 251 | switch { 252 | case key.Matches(msg, Keymap.Add): 253 | m.state = showInput 254 | } 255 | } 256 | case showEvents: 257 | switch msg := msg.(type) { 258 | case tea.WindowSizeMsg: 259 | _, v := AppStyle.GetFrameSize() 260 | m.events.SetSize(defaultListWidth, msg.Height-v) 261 | case tea.KeyMsg: 262 | switch { 263 | case key.Matches(msg, Keymap.Quit): 264 | return m, tea.Quit 265 | case key.Matches(msg, Keymap.Add): 266 | m.state = showInput 267 | case key.Matches(msg, Keymap.Remove): 268 | m.events.RemoveItem(m.events.Index()) 269 | if err := m.saveEventsToFile(); err != nil { 270 | panic(err) 271 | } 272 | if len(m.events.Items()) == 0 { 273 | m.state = noEvents 274 | } 275 | } 276 | } 277 | newEvents, newCmd := m.events.Update(msg) 278 | m.events = newEvents 279 | cmd = newCmd 280 | case showInput: 281 | switch msg := msg.(type) { 282 | case tea.KeyMsg: 283 | switch { 284 | case key.Matches(msg, Keymap.Back): 285 | m.resetInputs() 286 | m.state = showEvents 287 | case key.Matches(msg, Keymap.Next): 288 | m.focus++ 289 | if m.focus > int(inputSubmitButton) { 290 | m.focus = int(inputNameField) 291 | } 292 | case key.Matches(msg, Keymap.Prev): 293 | m.focus-- 294 | if m.focus < int(inputNameField) { 295 | m.focus = int(inputSubmitButton) 296 | } 297 | case key.Matches(msg, Keymap.Enter): 298 | switch inputFields(m.focus) { 299 | case inputNameField, inputTimeField: 300 | m.focus++ 301 | case inputCancelButton: 302 | m.resetInputs() 303 | m.state = showEvents 304 | case inputSubmitButton: 305 | e, err := m.validateInputs() 306 | if err != nil { 307 | m.inputs[inputNameField].Reset() 308 | m.inputs[inputTimeField].Reset() 309 | m.focus = 0 310 | m.inputStatus = fmt.Sprintf("Error: %v", err) 311 | break 312 | } 313 | if len(m.events.Items()) == 0 { 314 | m.events.InsertItem(0, e) 315 | } else { 316 | index := 0 317 | for _, item := range m.events.Items() { 318 | if e.Time >= item.(Event).Time { 319 | index++ 320 | } 321 | } 322 | m.events.InsertItem(index, e) 323 | if err := m.saveEventsToFile(); err != nil { 324 | panic(err) 325 | } 326 | } 327 | newEvents, newCmd := m.events.Update(msg) 328 | m.events = newEvents 329 | cmd = newCmd 330 | m.resetInputs() 331 | m.state = showEvents 332 | } 333 | } 334 | } 335 | cmds = append(cmds, m.updateInputs()...) 336 | for i := 0; i < len(m.inputs); i++ { 337 | newModel, cmd := m.inputs[i].Update(msg) 338 | m.inputs[i] = newModel 339 | cmds = append(cmds, cmd) 340 | } 341 | } 342 | timerModel, timerCmd := m.timer.Update(msg) 343 | m.timer = timerModel 344 | cmds = append(cmds, timerCmd) 345 | cmds = append(cmds, cmd) 346 | return m, tea.Batch(cmds...) 347 | } 348 | 349 | func (m MainModel) View() string { 350 | switch m.state { 351 | case noEvents: 352 | return InputStyle("No events, add one with '+'") 353 | case showInput: 354 | return m.inputView() 355 | default: 356 | listStr := AppStyle.Render(m.events.View()) 357 | detailStr := m.detailsString() 358 | return lipgloss.JoinHorizontal(0.05, listStr, detailStr) 359 | } 360 | } 361 | 362 | func main() { 363 | p := tea.NewProgram(NewMainModel(), tea.WithAltScreen()) 364 | if err := p.Start(); err != nil { 365 | fmt.Printf("There was an error: %v", err) 366 | os.Exit(1) 367 | } 368 | } 369 | 370 | func (m MainModel) detailsString() string { 371 | var b strings.Builder 372 | event := m.events.SelectedItem().(Event) 373 | b.WriteString(DetailTitleStyle.Render(event.Name) + "\n") 374 | ts := time.Unix(event.Time, 0) 375 | rfc1123 := ts.Format(time.RFC1123) 376 | b.WriteString(NormalTextStyle("When (RFC1123): ")) 377 | b.WriteString(BrightTextStyle(rfc1123) + "\n") 378 | b.WriteString(NormalTextStyle(" When (ISO): ")) 379 | b.WriteString(BrightTextStyle(event.ToBasicString()) + "\n") 380 | b.WriteString("\n\n" + DetailTitleStyle.Render("Countdown") + "\n") 381 | b.WriteString(SpecialTextStyle(countdownParser(event.Time)) + "\n") 382 | diff := time.Until(ts).Seconds() 383 | seconds := int64(diff) 384 | minutes := diff / float64(secondsPerMinute) 385 | hours := diff / float64(secondsPerHour) 386 | days := diff / float64(secondsPerDay) 387 | years := diff / float64(secondsPerYear) 388 | var left strings.Builder 389 | left.WriteString(strconv.FormatInt(seconds, 10) + "\n") 390 | left.WriteString(strconv.FormatFloat(minutes, 'f', 3, 64) + "\n") 391 | left.WriteString(strconv.FormatFloat(hours, 'f', 4, 64) + "\n") 392 | left.WriteString(strconv.FormatFloat(days, 'f', 5, 64) + "\n") 393 | left.WriteString(strconv.FormatFloat(years, 'f', 7, 64)) 394 | right := " seconds\n minutes\n hours\n days\n years" 395 | return DetailStyle(b.String() + 396 | lipgloss.JoinHorizontal(lipgloss.Bottom, DetailsBlockLeft(left.String()), DetailsBlockRight(right))) 397 | } 398 | 399 | func (m MainModel) eventsView() string { 400 | return AppStyle.Render(m.events.View()) 401 | } 402 | 403 | func countdownParser(ts int64) string { 404 | t := time.Unix(ts, 0) 405 | diff := int(time.Until(t).Seconds()) 406 | years := diff / secondsPerYear 407 | days := (diff - years*secondsPerYear) / secondsPerDay 408 | hours := (diff - years*secondsPerYear - days*secondsPerDay) / secondsPerHour 409 | minutes := (diff - years*secondsPerYear - days*secondsPerDay - hours*secondsPerHour) / secondsPerMinute 410 | seconds := diff - years*secondsPerYear - days*secondsPerDay - hours*secondsPerHour - minutes*secondsPerMinute 411 | var result string 412 | if years > 0 { 413 | result = fmt.Sprintf("%dy %dd %dh %dm %ds", years, days, hours, minutes, seconds) 414 | } 415 | if years == 0 && days > 0 { 416 | result = fmt.Sprintf("%dd %dh %dm %ds", days, hours, minutes, seconds) 417 | } 418 | if years == 0 && days == 0 && hours > 0 { 419 | result = fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds) 420 | } 421 | if years == 0 && days == 0 && hours == 0 && minutes > 0 { 422 | result = fmt.Sprintf("%dm %ds", minutes, seconds) 423 | } 424 | if years == 0 && days == 0 && hours == 0 && minutes == 0 { 425 | result = fmt.Sprintf("%ds", seconds) 426 | } 427 | if diff < 0 { 428 | result = ErrStyle("Expired") 429 | } 430 | return result 431 | } 432 | 433 | func readEventsFile() ([]Event, error) { 434 | var events []Event 435 | if _, err := os.Stat(eventsFile); errors.Is(err, os.ErrNotExist) { 436 | // create file 437 | _, err := os.Create(eventsFile) 438 | if err != nil { 439 | return events, err 440 | } 441 | event := nextGolangAnniversary() 442 | events = append(events, event) 443 | bytes, err := json.MarshalIndent(events, "", " ") 444 | if err != nil { 445 | return events, err 446 | } 447 | err = ioutil.WriteFile(eventsFile, bytes, 0644) 448 | return events, err 449 | } 450 | bytes, err := ioutil.ReadFile(eventsFile) 451 | if err != nil { 452 | return events, err 453 | } 454 | err = json.Unmarshal(bytes, &events) 455 | if err != nil { 456 | return events, err 457 | } 458 | return events, nil 459 | } 460 | 461 | func (m MainModel) saveEventsToFile() error { 462 | items := m.events.Items() 463 | events := make([]Event, len(items)) 464 | for i := range items { 465 | events[i] = items[i].(Event) 466 | } 467 | bytes, err := json.MarshalIndent(events, "", " ") 468 | if err != nil { 469 | return err 470 | } 471 | err = ioutil.WriteFile(eventsFile, bytes, 0644) 472 | return err 473 | } 474 | 475 | func (m MainModel) inputView() string { 476 | var b strings.Builder 477 | b.WriteString(InputTitleStyle.Render("New Event") + "\n") 478 | for i := range m.inputs { 479 | b.WriteString(m.inputs[i].View()) 480 | if i < len(m.inputs)-1 { 481 | b.WriteRune('\n') 482 | } 483 | } 484 | 485 | cancelButton := &BlurredStyle 486 | if m.focus == len(m.inputs) { 487 | cancelButton = &FocusedStyle 488 | } 489 | submitButton := &BlurredStyle 490 | if m.focus == len(m.inputs)+1 { 491 | submitButton = &FocusedStyle 492 | } 493 | _, err := fmt.Fprintf( 494 | &b, 495 | "\n\n%s %s\n\n%s", 496 | cancelButton.Render("[ Cancel ]"), 497 | submitButton.Render("[ Submit ]"), 498 | ErrStyle(m.inputStatus), 499 | ) 500 | if err != nil { 501 | fmt.Printf("Error formatting input string: %v\n", err) 502 | os.Exit(1) 503 | } 504 | 505 | return InputStyle(b.String()) 506 | } 507 | 508 | func (m *MainModel) updateInputs() []tea.Cmd { 509 | cmds := make([]tea.Cmd, len(m.inputs)) 510 | for i := 0; i <= len(m.inputs)-1; i++ { 511 | if i == m.focus { 512 | // Set focused state 513 | cmds[i] = m.inputs[i].Focus() 514 | m.inputs[i].PromptStyle = FocusedStyle 515 | m.inputs[i].TextStyle = FocusedStyle 516 | continue 517 | } 518 | // Remove focused state 519 | m.inputs[i].Blur() 520 | m.inputs[i].PromptStyle = NoStyle 521 | m.inputs[i].TextStyle = NoStyle 522 | } 523 | return cmds 524 | } 525 | 526 | func (m MainModel) resetInputs() { 527 | m.inputs[inputNameField].Reset() 528 | m.inputs[inputTimeField].Reset() 529 | m.focus = 0 530 | m.inputStatus = "" 531 | } 532 | 533 | func (m MainModel) validateInputs() (Event, error) { 534 | var event Event 535 | name := m.inputs[0].Value() 536 | t := m.inputs[1].Value() 537 | if name == "" && t == "" { 538 | return event, fmt.Errorf("empty fields") 539 | } 540 | timeFormat := inputTimeFormLong 541 | if len(t) < len(inputTimeFormLong) { 542 | timeFormat = inputTimeFormShort 543 | } 544 | ts, err := time.ParseInLocation(timeFormat, t, time.Local) 545 | if err != nil { 546 | return event, err 547 | } 548 | if ts.Before(time.Now()) { 549 | return event, fmt.Errorf("event time is in the past") 550 | } 551 | event = Event{Name: name, Time: ts.Unix()} 552 | return event, nil 553 | } 554 | 555 | func nextGolangAnniversary() Event { 556 | nameStr := "Golang's Birthday" 557 | now := time.Now() 558 | year := now.Year() 559 | thisYear := time.Date(year, 11, 10, 0, 0, 0, 0, time.Local) 560 | nextYear := time.Date(year+1, 11, 10, 0, 0, 0, 0, time.Local) 561 | if now.Before(thisYear) { 562 | return Event{nameStr, thisYear.Unix()} 563 | } 564 | return Event{nameStr, nextYear.Unix()} 565 | } 566 | --------------------------------------------------------------------------------