main.go (view raw)
1package main
2
3import (
4 "fmt"
5 "log"
6 "os"
7 "strings"
8
9 todo "github.com/1set/todotxt"
10 "github.com/charmbracelet/bubbles/help"
11 "github.com/charmbracelet/bubbles/key"
12 "github.com/charmbracelet/bubbles/textinput"
13 tea "github.com/charmbracelet/bubbletea"
14)
15
16type InterfaceState int
17
18const (
19 List InterfaceState = iota
20 Add
21 Edit
22)
23
24type model struct {
25 interfaceState InterfaceState
26 config Config
27 keys keyMap
28 help help.Model
29
30 tasks todo.TaskList
31 cursor int // which to-do list item our cursor is pointing at
32 selected map[int]struct{} // which to-do items are selected
33
34 textInput textinput.Model
35}
36
37func initialModel(config Config) model {
38 // New task input
39 ti := textinput.New()
40 ti.Focus()
41 ti.CharLimit = 200
42 ti.Width = 200
43
44 if tasklist, err := todo.LoadFromPath(config.taskFilePath); err != nil {
45 log.Fatal(err)
46 return model{}
47 } else {
48 tasks := tasklist
49 tasks.Sort(todo.SortCompletedDateAsc, todo.SortDueDateAsc)
50 selected := make(map[int]struct{})
51
52 interfaceState := List
53 if len(tasks) == 0 {
54 interfaceState = Add
55 }
56
57 for i, t := range tasks {
58 if t.Completed {
59 selected[i] = struct{}{}
60 }
61 }
62
63 return model{
64 interfaceState: interfaceState,
65 config: config,
66 tasks: tasks,
67 selected: selected,
68 textInput: ti,
69 keys: keys,
70 help: help.New(),
71 }
72
73 }
74}
75
76func (m model) Init() tea.Cmd {
77 return textinput.Blink
78}
79
80func (m model) SaveTasks() {
81 m.tasks.WriteToPath(m.config.taskFilePath)
82}
83
84func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85 var cmd tea.Cmd
86
87 switch msg := msg.(type) {
88
89 case tea.WindowSizeMsg:
90 // If we set a width on the help menu it can gracefully truncate
91 // its view as needed.
92 m.help.Width = msg.Width
93
94 // Is it a key press?
95 case tea.KeyMsg:
96
97 switch m.interfaceState {
98
99 case List:
100
101 switch {
102 // Navigation
103 case key.Matches(msg, m.keys.Up):
104 newCursor := (m.cursor - 1) % len(m.tasks)
105 if newCursor < 0 {
106 newCursor += len(m.tasks)
107 }
108 m.cursor = newCursor
109
110 case key.Matches(msg, m.keys.Down):
111 m.cursor = (m.cursor + 1) % len(m.tasks)
112
113 // Tasks management
114 case key.Matches(msg, m.keys.Priority):
115 m.tasks[m.cursor].Priority = strings.ToUpper(msg.String())
116 m.SaveTasks()
117
118 case key.Matches(msg, m.keys.ClearPriority):
119 m.tasks[m.cursor].Priority = ""
120 m.SaveTasks()
121
122 case key.Matches(msg, m.keys.Check):
123 _, ok := m.selected[m.cursor]
124
125 if ok {
126 delete(m.selected, m.cursor)
127 m.tasks[m.cursor].Reopen()
128 } else {
129 m.selected[m.cursor] = struct{}{}
130 m.tasks[m.cursor].Complete()
131
132 recurringTask := handleRecurringTask(m.tasks[m.cursor])
133 if recurringTask.Original != "" {
134 m.tasks.AddTask(recurringTask)
135 }
136 }
137
138 m.SaveTasks()
139
140 case key.Matches(msg, m.keys.OrderUp):
141 newCursor := m.cursor - 1
142 if newCursor < 0 {
143 newCursor += len(m.tasks)
144 }
145 focusedTask := m.tasks[m.cursor]
146 m.tasks[m.cursor] = m.tasks[newCursor]
147 m.tasks[newCursor] = focusedTask
148 m.cursor = newCursor
149 m.SaveTasks()
150
151 case key.Matches(msg, m.keys.OrderDown):
152 newCursor := (m.cursor + 1) % len(m.tasks)
153 focusedTask := m.tasks[m.cursor]
154 m.tasks[m.cursor] = m.tasks[newCursor]
155 m.tasks[newCursor] = focusedTask
156 m.cursor = newCursor
157 m.SaveTasks()
158
159 case key.Matches(msg, m.keys.SortByPriority):
160 m.tasks.Sort(todo.SortCompletedDateAsc, todo.SortPriorityAsc)
161 m.selected = make(map[int]struct{})
162 for i, t := range m.tasks {
163 if t.Completed {
164 m.selected[i] = struct{}{}
165 }
166 }
167
168 case key.Matches(msg, m.keys.SortByDate):
169 m.tasks.Sort(todo.SortCompletedDateAsc, todo.SortDueDateAsc)
170 m.selected = make(map[int]struct{})
171 for i, t := range m.tasks {
172 if t.Completed {
173 m.selected[i] = struct{}{}
174 }
175 }
176
177 case key.Matches(msg, m.keys.Clean):
178 m.selected = make(map[int]struct{})
179 doneTasks := m.tasks.Filter(todo.FilterCompleted)
180 doneFile, err := os.OpenFile(m.config.doneFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
181 if err != nil {
182 log.Fatal("Can't write done tasks to " + m.config.doneFilePath)
183 }
184 defer doneFile.Close()
185 if writeErr := doneTasks.WriteToFile(doneFile); writeErr != nil {
186 log.Fatal(writeErr)
187 }
188 m.SaveTasks()
189 m.tasks = m.tasks.Filter(todo.FilterNotCompleted)
190 m.selected = make(map[int]struct{})
191 for i, t := range m.tasks {
192 if t.Completed {
193 m.selected[i] = struct{}{}
194 }
195 }
196
197 case key.Matches(msg, m.keys.Delete):
198 focusedTask := m.tasks[m.cursor]
199 m.tasks.RemoveTaskByID(focusedTask.ID)
200
201 // Interface
202 case key.Matches(msg, m.keys.Add):
203 m.interfaceState = Add
204
205 case key.Matches(msg, m.keys.Edit):
206 m.textInput.SetValue(m.tasks[m.cursor].Original)
207 m.interfaceState = Edit
208
209 case key.Matches(msg, m.keys.Quit):
210 m.SaveTasks()
211 return m, tea.Quit
212
213 case key.Matches(msg, m.keys.Help):
214 m.help.ShowAll = !m.help.ShowAll
215
216 }
217
218 return m, nil
219
220 case Add:
221 switch msg.Type {
222 case tea.KeyCtrlC, tea.KeyEsc:
223 m.interfaceState = List
224 case tea.KeyEnter:
225 inputValue := m.textInput.Value()
226 if inputValue != "" {
227 newTask, _ := todo.ParseTask(inputValue)
228 m.tasks.AddTask(newTask)
229 m.SaveTasks()
230 }
231 m.textInput.Reset()
232 m.interfaceState = List
233 }
234
235 m.textInput, cmd = m.textInput.Update(msg)
236
237 return m, cmd
238
239 case Edit:
240 switch msg.Type {
241 case tea.KeyCtrlC, tea.KeyEsc:
242 m.interfaceState = List
243
244 case tea.KeyEnter:
245 inputValue := m.textInput.Value()
246 if inputValue != "" {
247 editedTask, _ := todo.ParseTask(inputValue)
248 m.tasks[m.cursor] = *editedTask
249 }
250 m.textInput.Reset()
251 m.interfaceState = List
252 }
253
254 m.textInput, cmd = m.textInput.Update(msg)
255
256 return m, cmd
257 }
258
259 }
260
261 return m, nil
262}
263
264func (m model) View() string {
265 output := "\n"
266
267 switch m.interfaceState {
268
269 case List:
270 if len(m.tasks) > 0 {
271 for i, task := range m.tasks {
272
273 cursor := " "
274 if m.cursor == i {
275 cursor = "→"
276 }
277
278 // Render the row
279 _, checked := m.selected[i]
280 styles := NewTextStyle()
281 output += fmt.Sprintf("%s %s\n", cursor, styles.getTaskStyle(task, checked))
282 }
283 } else {
284 output += "No tasks in file"
285 }
286
287 output += "\n"
288 output += m.help.View(m.keys)
289 case Add:
290 output += fmt.Sprintf("New task:\n\n%s", m.textInput.View())
291 output += "\n\n Press ESC to go back.\n"
292
293 case Edit:
294 output += fmt.Sprintf("Edit task:\n\n%s", m.textInput.View())
295 output += "\n\n Press ESC to go back.\n"
296 }
297
298 return output
299}
300
301func main() {
302 config := NewConfig()
303 p := tea.NewProgram(initialModel(config), tea.WithAltScreen())
304 if _, err := p.Run(); err != nil {
305 fmt.Printf("Alas, there's been an error: %v", err)
306 os.Exit(1)
307 }
308}