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
133 m.SaveTasks()
134
135 case key.Matches(msg, m.keys.OrderUp):
136 newCursor := m.cursor - 1
137 if newCursor < 0 {
138 newCursor += len(m.tasks)
139 }
140 focusedTask := m.tasks[m.cursor]
141 m.tasks[m.cursor] = m.tasks[newCursor]
142 m.tasks[newCursor] = focusedTask
143 m.cursor = newCursor
144 m.SaveTasks()
145
146 case key.Matches(msg, m.keys.OrderDown):
147 newCursor := (m.cursor + 1) % len(m.tasks)
148 focusedTask := m.tasks[m.cursor]
149 m.tasks[m.cursor] = m.tasks[newCursor]
150 m.tasks[newCursor] = focusedTask
151 m.cursor = newCursor
152 m.SaveTasks()
153
154 case key.Matches(msg, m.keys.SortByPriority):
155 m.tasks.Sort(todo.SortCompletedDateAsc, todo.SortPriorityAsc)
156 m.selected = make(map[int]struct{})
157 for i, t := range m.tasks {
158 if t.Completed {
159 m.selected[i] = struct{}{}
160 }
161 }
162
163 case key.Matches(msg, m.keys.SortByDate):
164 m.tasks.Sort(todo.SortCompletedDateAsc, todo.SortDueDateAsc)
165 m.selected = make(map[int]struct{})
166 for i, t := range m.tasks {
167 if t.Completed {
168 m.selected[i] = struct{}{}
169 }
170 }
171
172 case key.Matches(msg, m.keys.Clean):
173 m.selected = make(map[int]struct{})
174 doneTasks := m.tasks.Filter(todo.FilterCompleted)
175 doneFile, err := os.OpenFile(m.config.doneFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
176 if err != nil {
177 log.Fatal("Can't write done tasks to " + m.config.doneFilePath)
178 }
179 defer doneFile.Close()
180 if writeErr := doneTasks.WriteToFile(doneFile); writeErr != nil {
181 log.Fatal(writeErr)
182 }
183 m.SaveTasks()
184 m.tasks = m.tasks.Filter(todo.FilterNotCompleted)
185 m.selected = make(map[int]struct{})
186 for i, t := range m.tasks {
187 if t.Completed {
188 m.selected[i] = struct{}{}
189 }
190 }
191
192 // Interface
193 case key.Matches(msg, m.keys.Add):
194 m.interfaceState = Add
195
196 case key.Matches(msg, m.keys.Edit):
197 m.textInput.SetValue(m.tasks[m.cursor].Original)
198 m.interfaceState = Edit
199
200 case key.Matches(msg, m.keys.Quit):
201 m.SaveTasks()
202 return m, tea.Quit
203
204 case key.Matches(msg, m.keys.Help):
205 m.help.ShowAll = !m.help.ShowAll
206
207 }
208
209 return m, nil
210
211 case Add:
212 switch msg.Type {
213 case tea.KeyCtrlC, tea.KeyEsc:
214 m.interfaceState = List
215 case tea.KeyEnter:
216 inputValue := m.textInput.Value()
217 if inputValue != "" {
218 newTask, _ := todo.ParseTask(inputValue)
219 m.tasks.AddTask(newTask)
220 m.SaveTasks()
221 }
222 m.textInput.Reset()
223 m.interfaceState = List
224 }
225
226 m.textInput, cmd = m.textInput.Update(msg)
227
228 return m, cmd
229
230 case Edit:
231 switch msg.Type {
232 case tea.KeyCtrlC, tea.KeyEsc:
233 m.interfaceState = List
234
235 case tea.KeyEnter:
236 inputValue := m.textInput.Value()
237 if inputValue != "" {
238 editedTask, _ := todo.ParseTask(inputValue)
239 m.tasks[m.cursor] = *editedTask
240 }
241 m.textInput.Reset()
242 m.interfaceState = List
243 }
244
245 m.textInput, cmd = m.textInput.Update(msg)
246
247 return m, cmd
248 }
249
250 }
251
252 return m, nil
253}
254
255func (m model) View() string {
256 output := "\n"
257
258 switch m.interfaceState {
259
260 case List:
261 if len(m.tasks) > 0 {
262 for i, task := range m.tasks {
263
264 cursor := " "
265 if m.cursor == i {
266 cursor = "→"
267 }
268
269 // Render the row
270 _, checked := m.selected[i]
271 styles := NewTextStyle()
272 output += fmt.Sprintf("%s %s\n", cursor, styles.getTaskStyle(task, checked))
273 }
274 } else {
275 output += "No tasks in file"
276 }
277
278 output += "\n"
279 output += m.help.View(m.keys)
280 case Add:
281 output += fmt.Sprintf("New task:\n\n%s", m.textInput.View())
282 output += "\n\n Press ESC to go back.\n"
283
284 case Edit:
285 output += fmt.Sprintf("Edit task:\n\n%s", m.textInput.View())
286 output += "\n\n Press ESC to go back.\n"
287 }
288
289 return output
290}
291
292func main() {
293 config := NewConfig()
294 p := tea.NewProgram(initialModel(config), tea.WithAltScreen())
295 if _, err := p.Run(); err != nil {
296 fmt.Printf("Alas, there's been an error: %v", err)
297 os.Exit(1)
298 }
299}