all repos — todoxt @ main

TUI for todo.txt

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}