diff --git a/todo/display.go b/todo/display.go index 8757bad4..91a8193b 100644 --- a/todo/display.go +++ b/todo/display.go @@ -3,6 +3,7 @@ package todo import ( "fmt" + "github.com/rivo/tview" "github.com/senorprogrammer/wtf/wtf" ) @@ -10,38 +11,35 @@ const checkWidth = 4 func (widget *Widget) display() { str := "" - newList := List{selected: -1} - - selectedItem := widget.list.Selected() - maxLineLen := widget.list.LongestLine() + newList := wtf.NewChecklist() for _, item := range widget.list.UncheckedItems() { - str = str + widget.formattedItemLine(item, selectedItem, maxLineLen) + str = str + widget.formattedItemLine(item, widget.list.SelectedItem(), widget.list.LongestLine()) newList.Items = append(newList.Items, item) } for _, item := range widget.list.CheckedItems() { - str = str + widget.formattedItemLine(item, selectedItem, maxLineLen) + str = str + widget.formattedItemLine(item, widget.list.SelectedItem(), widget.list.LongestLine()) newList.Items = append(newList.Items, item) } - newList.SetSelectedByItem(widget.list.Selected()) - widget.SetList(&newList) + newList.SetSelectedByItem(widget.list.SelectedItem()) + widget.SetList(newList) widget.View.Clear() widget.View.SetText(str) } -func (widget *Widget) formattedItemLine(item *Item, selectedItem *Item, maxLen int) string { +func (widget *Widget) formattedItemLine(item *wtf.ChecklistItem, selectedItem *wtf.ChecklistItem, maxLen int) string { foreColor, backColor := "white", wtf.Config.UString("wtf.colors.background", "black") if item.Checked { - foreColor = wtf.Config.UString("wtf.mods.todo.colors.checked", "white") + foreColor = wtf.Config.UString("wtf.colors.checked", "white") } if widget.View.HasFocus() && (item == selectedItem) { - foreColor = wtf.Config.UString("wtf.mods.todo.colors.highlight.fore", "black") - backColor = wtf.Config.UString("wtf.mods.todo.colors.highlight.back", "white") + foreColor = wtf.Config.UString("wtf.colors.highlight.fore", "black") + backColor = wtf.Config.UString("wtf.colors.highlight.back", "orange") } str := fmt.Sprintf( @@ -49,10 +47,8 @@ func (widget *Widget) formattedItemLine(item *Item, selectedItem *Item, maxLen i foreColor, backColor, item.CheckMark(), - item.Text, + tview.Escape(item.Text), ) - str = str + wtf.PadRow((checkWidth+len(item.Text)), (checkWidth+maxLen)) + "\n" - - return str + return str + wtf.PadRow((checkWidth+len(item.Text)), (checkWidth+maxLen+1)) + "\n" } diff --git a/todo/item.go b/todo/item.go deleted file mode 100644 index f546b124..00000000 --- a/todo/item.go +++ /dev/null @@ -1,22 +0,0 @@ -package todo - -import ( - "github.com/senorprogrammer/wtf/wtf" -) - -type Item struct { - Checked bool - Text string -} - -func (item *Item) CheckMark() string { - if item.Checked { - return wtf.Config.UString("wtf.mods.todo.checkedIcon", "x") - } else { - return " " - } -} - -func (item *Item) Toggle() { - item.Checked = !item.Checked -} diff --git a/todo/list.go b/todo/list.go deleted file mode 100644 index 0e852f6c..00000000 --- a/todo/list.go +++ /dev/null @@ -1,165 +0,0 @@ -package todo - -type List struct { - Items []*Item - - selected int -} - -/* -------------------- Exported Functions -------------------- */ - -func (list *List) Add(text string) { - item := Item{ - Checked: false, - Text: text, - } - - list.Items = append([]*Item{&item}, list.Items...) -} - -func (list *List) CheckedItems() []*Item { - items := []*Item{} - - for _, item := range list.Items { - if item.Checked { - items = append(items, item) - } - } - - return items -} - -func (list *List) Delete() { - list.Items = append(list.Items[:list.selected], list.Items[list.selected+1:]...) - list.Prev() -} - -func (list *List) Demote() { - if list.isUnselectable() { - return - } - - j := list.selected + 1 - if j >= len(list.Items) { - j = 0 - } - - list.Swap(list.selected, j) - list.selected = j -} - -func (list *List) Next() { - list.selected = list.selected + 1 - if list.selected >= len(list.Items) { - list.selected = 0 - } -} - -func (list *List) LongestLine() int { - maxLen := 0 - - for _, item := range list.Items { - if len(item.Text) > maxLen { - maxLen = len(item.Text) - } - } - - return maxLen -} - -func (list *List) Prev() { - list.selected = list.selected - 1 - if list.selected < 0 { - list.selected = len(list.Items) - 1 - } -} - -func (list *List) Promote() { - if list.isUnselectable() { - return - } - - j := list.selected - 1 - if j < 0 { - j = len(list.Items) - 1 - } - - list.Swap(list.selected, j) - list.selected = j -} - -func (list *List) Selected() *Item { - if list.isUnselectable() { - return nil - } - - return list.Items[list.selected] -} - -func (list *List) SetSelectedByItem(selectableItem *Item) { - for idx, item := range list.Items { - if item == selectableItem { - list.selected = idx - break - } - } -} - -// Toggle switches the checked state of the currently-selected item -func (list *List) Toggle() { - if list.isUnselectable() { - return - } - - list.Selected().Toggle() -} - -func (list *List) UncheckedItems() []*Item { - items := []*Item{} - - for _, item := range list.Items { - if !item.Checked { - items = append(items, item) - } - } - - return items -} - -func (list *List) Unselect() { - list.selected = -1 -} - -func (list *List) Update(text string) { - item := list.Selected() - - if item == nil { - return - } - - item.Text = text -} - -/* -------------------- Sort Interface -------------------- */ - -func (list *List) Len() int { - return len(list.Items) -} - -func (list *List) Less(i, j int) bool { - return list.Items[i].Text < list.Items[j].Text -} - -func (list *List) Swap(i, j int) { - list.Items[i], list.Items[j] = list.Items[j], list.Items[i] -} - -/* -------------------- Unexported Functions -------------------- */ - -func (list *List) isSelectable() bool { - return list.selected >= 0 && list.selected < len(list.Items) -} - -func (list *List) isUnselectable() bool { - return !list.isSelectable() -} diff --git a/todo/widget.go b/todo/widget.go index 6bebc605..c0d9bef1 100644 --- a/todo/widget.go +++ b/todo/widget.go @@ -39,7 +39,7 @@ type Widget struct { app *tview.Application filePath string - list *List + list wtf.Checklist pages *tview.Pages } @@ -49,7 +49,7 @@ func NewWidget(app *tview.Application, pages *tview.Pages) *Widget { app: app, filePath: wtf.Config.UString("wtf.mods.todo.filename"), - list: &List{selected: -1}, + list: wtf.NewChecklist(), pages: pages, } @@ -67,7 +67,7 @@ func (widget *Widget) Refresh() { widget.display() } -func (widget *Widget) SetList(newList *List) { +func (widget *Widget) SetList(newList wtf.Checklist) { widget.list = newList } @@ -75,11 +75,11 @@ func (widget *Widget) SetList(newList *List) { // edit opens a modal dialog that permits editing the text of the currently-selected item func (widget *Widget) editItem() { - if widget.list.Selected() == nil { + if widget.list.SelectedItem() == nil { return } - form := widget.modalForm("Edit:", widget.list.Selected().Text) + form := widget.modalForm("Edit:", widget.list.SelectedItem().Text) saveFctn := func() { text := form.GetFormItem(0).(*tview.InputField).GetText() @@ -191,7 +191,7 @@ func (widget *Widget) newItem() { saveFctn := func() { text := form.GetFormItem(0).(*tview.InputField).GetText() - widget.list.Add(text) + widget.list.Add(false, text) widget.persist() widget.pages.RemovePage("modal") widget.app.SetFocus(widget.View) diff --git a/todoist/display.go b/todoist/display.go index 8cbfa858..c6004c22 100644 --- a/todoist/display.go +++ b/todoist/display.go @@ -3,62 +3,42 @@ package todoist import ( "fmt" - "github.com/gdamore/tcell" "github.com/rivo/tview" "github.com/senorprogrammer/wtf/wtf" ) +const checkWidth = 4 + func (w *Widget) display() { - if len(w.list) == 0 { + proj := w.CurrentProject() + + if proj == nil { return } - list := w.list[w.idx] - w.View.SetTitle(fmt.Sprintf("%s- [green]%s[white] ", w.Name, list.Project.Name)) - str := wtf.SigilStr(len(w.list), w.idx, w.View) + "\n" + w.View.SetTitle(fmt.Sprintf("%s- [green]%s[white] ", w.Name, proj.Project.Name)) + str := wtf.SigilStr(len(w.projects), w.idx, w.View) + "\n" - for index, item := range list.items { - if index == list.index { - str = str + fmt.Sprintf("[%s]", wtf.Config.UString("wtf.colors.border.focused", "grey")) + maxLen := proj.LongestLine() + + for index, item := range proj.tasks { + foreColor, backColor := "white", wtf.Config.UString("wtf.colors.background", "black") + + if index == proj.index { + foreColor = wtf.Config.UString("wtf.colors.highlight.fore", "black") + backColor = wtf.Config.UString("wtf.colors.highlight.back", "orange") } - str = str + fmt.Sprintf("| | %s[white]\n", tview.Escape(item.Content)) + + row := fmt.Sprintf( + "[%s:%s]| | %s[white]", + foreColor, + backColor, + tview.Escape(item.Content), + ) + + str = str + row + wtf.PadRow((checkWidth+len(item.Content)), (checkWidth+maxLen+1)) + "\n" } w.View.Clear() w.View.SetText(str) } - -func (w *Widget) keyboardIntercept(event *tcell.EventKey) *tcell.EventKey { - if len(w.list) == 0 { - return event - } - - switch string(event.Rune()) { - case "r": - w.Refresh() - return nil - case "d": - w.Delete() - return nil - case "c": - w.Close() - return nil - } - - switch fromVim(event) { - case tcell.KeyLeft: - w.Prev() - return nil - case tcell.KeyRight: - w.Next() - return nil - case tcell.KeyUp: - w.UP() - return nil - case tcell.KeyDown: - w.Down() - return nil - } - - return event -} diff --git a/todoist/list.go b/todoist/list.go deleted file mode 100644 index 81b9d55e..00000000 --- a/todoist/list.go +++ /dev/null @@ -1,79 +0,0 @@ -package todoist - -import ( - "fmt" - - "github.com/darkSasori/todoist" -) - -type List struct { - todoist.Project - items []todoist.Task - index int -} - -func NewList(id int) *List { - project, err := todoist.GetProject(id) - if err != nil { - panic(err) - } - - list := &List{ - Project: project, - index: -1, - } - list.loadItems() - return list -} - -func (l List) isFirst() bool { - return l.index == 0 -} - -func (l List) isLast() bool { - return l.index >= len(l.items)-1 -} - -func (l *List) up() { - l.index = l.index - 1 - if l.index < 0 { - l.index = len(l.items) - 1 - } -} - -func (l *List) down() { - if l.index == -1 { - l.index = 0 - return - } - - l.index = l.index + 1 - if l.index >= len(l.items) { - l.index = 0 - } -} - -func (l *List) loadItems() { - tasks, err := todoist.ListTask(todoist.QueryParam{"project_id": fmt.Sprintf("%d", l.ID)}) - if err != nil { - panic(err) - } - - l.items = tasks -} - -func (l *List) close() { - if err := l.items[l.index].Close(); err != nil { - panic(err) - } - - l.loadItems() -} - -func (l *List) delete() { - if err := l.items[l.index].Delete(); err != nil { - panic(err) - } - - l.loadItems() -} diff --git a/todoist/project.go b/todoist/project.go new file mode 100644 index 00000000..ce1a31ec --- /dev/null +++ b/todoist/project.go @@ -0,0 +1,109 @@ +package todoist + +import ( + "fmt" + + "github.com/darkSasori/todoist" +) + +type Project struct { + todoist.Project + + index int + tasks []todoist.Task +} + +func NewProject(id int) *Project { + project, err := todoist.GetProject(id) + if err != nil { + panic(err) + } + + proj := &Project{ + Project: project, + index: -1, + } + + proj.loadTasks() + + return proj +} + +func (proj *Project) isFirst() bool { + return proj.index == 0 +} + +func (proj *Project) isLast() bool { + return proj.index >= len(proj.tasks)-1 +} + +func (proj *Project) up() { + proj.index = proj.index - 1 + + if proj.index < 0 { + proj.index = len(proj.tasks) - 1 + } +} + +func (proj *Project) down() { + if proj.index == -1 { + proj.index = 0 + return + } + + proj.index = proj.index + 1 + if proj.index >= len(proj.tasks) { + proj.index = 0 + } +} + +func (proj *Project) loadTasks() { + tasks, err := todoist.ListTask(todoist.QueryParam{"project_id": fmt.Sprintf("%d", proj.ID)}) + if err == nil { + proj.tasks = tasks + } +} + +func (proj *Project) LongestLine() int { + maxLen := 0 + + for _, task := range proj.tasks { + if len(task.Content) > maxLen { + maxLen = len(task.Content) + } + } + + return maxLen +} + +func (proj *Project) currentTask() *todoist.Task { + if proj.index < 0 { + return nil + } + + return &proj.tasks[proj.index] +} + +func (proj *Project) closeSelectedTask() { + currTask := proj.currentTask() + + if currTask != nil { + if err := currTask.Close(); err != nil { + return + } + + proj.loadTasks() + } +} + +func (proj *Project) deleteSelectedTask() { + currTask := proj.currentTask() + + if currTask != nil { + if err := currTask.Delete(); err != nil { + return + } + + proj.loadTasks() + } +} diff --git a/todoist/widget.go b/todoist/widget.go index e40089fa..cc9635ab 100644 --- a/todoist/widget.go +++ b/todoist/widget.go @@ -12,10 +12,10 @@ import ( type Widget struct { wtf.TextWidget - app *tview.Application - pages *tview.Pages - list []*List - idx int + app *tview.Application + pages *tview.Pages + projects []*Project + idx int } func NewWidget(app *tview.Application, pages *tview.Pages) *Widget { @@ -27,14 +27,46 @@ func NewWidget(app *tview.Application, pages *tview.Pages) *Widget { } todoist.Token = os.Getenv("WTF_TODOIST_TOKEN") - widget.list = loadProjects() + widget.projects = loadProjects() widget.View.SetInputCapture(widget.keyboardIntercept) return &widget } +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) CurrentProject() *Project { + return widget.ProjectAt(widget.idx) +} + +func (widget *Widget) ProjectAt(idx int) *Project { + if len(widget.projects) == 0 { + return nil + } + + return widget.projects[idx] +} + +func (w *Widget) NextProject() { + w.idx = w.idx + 1 + if w.idx == len(w.projects) { + w.idx = 0 + } + + w.display() +} + +func (w *Widget) PreviousProject() { + w.idx = w.idx - 1 + if w.idx < 0 { + w.idx = len(w.projects) - 1 + } + + w.display() +} + func (w *Widget) Refresh() { - if w.Disabled() || len(w.list) == 0 { + if w.Disabled() || w.CurrentProject() == nil { return } @@ -42,63 +74,94 @@ func (w *Widget) Refresh() { w.display() } -func (w *Widget) Next() { - w.idx = w.idx + 1 - if w.idx == len(w.list) { - w.idx = 0 - } - - w.display() -} - -func (w *Widget) Prev() { - w.idx = w.idx - 1 - if w.idx < 0 { - w.idx = len(w.list) - 1 - } - - w.display() -} +/* -------------------- Keyboard Movement -------------------- */ +// Down selects the next item in the list func (w *Widget) Down() { - w.list[w.idx].down() + w.CurrentProject().down() w.display() } -func (w *Widget) UP() { - w.list[w.idx].up() +// Up selects the previous item in the list +func (w *Widget) Up() { + w.CurrentProject().up() w.display() } +// Close closes the currently-selected task in the currently-selected project func (w *Widget) Close() { - w.list[w.idx].close() - if w.list[w.idx].isLast() { - w.UP() + w.CurrentProject().closeSelectedTask() + + if w.CurrentProject().isLast() { + w.Up() return } + w.Down() } +// Delete deletes the currently-selected task in the currently-selected project func (w *Widget) Delete() { - w.list[w.idx].close() - if w.list[w.idx].isLast() { - w.UP() + w.CurrentProject().deleteSelectedTask() + + if w.CurrentProject().isLast() { + w.Up() return } + w.Down() } -func loadProjects() []*List { - lists := []*List{} - for _, id := range wtf.Config.UList("wtf.mods.todoist.projects") { - list := NewList(id.(int)) - lists = append(lists, list) +/* -------------------- Unexported Functions -------------------- */ + +func (w *Widget) keyboardIntercept(event *tcell.EventKey) *tcell.EventKey { + if len(w.projects) == 0 { + return event } - return lists + switch string(event.Rune()) { + case "r": + w.Refresh() + return nil + case "d": + w.Delete() + return nil + case "c": + w.Close() + return nil + } + + switch w.vimBindings(event) { + case tcell.KeyLeft: + w.PreviousProject() + return nil + case tcell.KeyRight: + w.NextProject() + return nil + case tcell.KeyUp: + w.Up() + return nil + case tcell.KeyDown: + w.Down() + return nil + } + + return event } -func fromVim(event *tcell.EventKey) tcell.Key { +// TODO: Rename this List to Projects so the internal can be Checklist +func loadProjects() []*Project { + projects := []*Project{} + + for _, id := range wtf.Config.UList("wtf.mods.todoist.projects") { + proj := NewProject(id.(int)) + projects = append(projects, proj) + } + + return projects +} + +func (w *Widget) vimBindings(event *tcell.EventKey) tcell.Key { switch string(event.Rune()) { case "h": return tcell.KeyLeft diff --git a/wtf/checklist.go b/wtf/checklist.go new file mode 100644 index 00000000..4aa60c75 --- /dev/null +++ b/wtf/checklist.go @@ -0,0 +1,187 @@ +package wtf + +// Checklist is a module for creating generic checklist implementations +// See 'Todo' for an implementation example +type Checklist struct { + Selected int + + Items []*ChecklistItem +} + +func NewChecklist() Checklist { + list := Checklist{ + Selected: -1, + } + + return list +} + +/* -------------------- Exported Functions -------------------- */ + +// Add creates a new item in the checklist +func (list *Checklist) Add(checked bool, text string) { + item := ChecklistItem{ + Checked: checked, + Text: text, + } + + list.Items = append([]*ChecklistItem{&item}, list.Items...) +} + +// CheckedItems returns a slice of all the checked items +func (list *Checklist) CheckedItems() []*ChecklistItem { + items := []*ChecklistItem{} + + for _, item := range list.Items { + if item.Checked { + items = append(items, item) + } + } + + return items +} + +// Delete removes the selected item from the checklist +func (list *Checklist) Delete() { + list.Items = append(list.Items[:list.Selected], list.Items[list.Selected+1:]...) + list.Prev() +} + +// Demote moves the selected item down in the checklist +func (list *Checklist) Demote() { + if list.IsUnselectable() { + return + } + + j := list.Selected + 1 + if j >= len(list.Items) { + j = 0 + } + + list.Swap(list.Selected, j) + list.Selected = j +} + +// IsSelectable returns true if the checklist has selectable items, false if it does not +func (list *Checklist) IsSelectable() bool { + return list.Selected >= 0 && list.Selected < len(list.Items) +} + +// IsUnselectable returns true if the checklist has no selectable items, false if it does +func (list *Checklist) IsUnselectable() bool { + return !list.IsSelectable() +} + +// Next selects the next item in the checklist +func (list *Checklist) Next() { + list.Selected = list.Selected + 1 + if list.Selected >= len(list.Items) { + list.Selected = 0 + } +} + +// LongestLine returns the length of the longest checklist item's text +func (list *Checklist) LongestLine() int { + maxLen := 0 + + for _, item := range list.Items { + if len(item.Text) > maxLen { + maxLen = len(item.Text) + } + } + + return maxLen +} + +// Prev selects the previous item in the checklist +func (list *Checklist) Prev() { + list.Selected = list.Selected - 1 + if list.Selected < 0 { + list.Selected = len(list.Items) - 1 + } +} + +// Promote moves the selected item up in the checklist +func (list *Checklist) Promote() { + if list.IsUnselectable() { + return + } + + j := list.Selected - 1 + if j < 0 { + j = len(list.Items) - 1 + } + + list.Swap(list.Selected, j) + list.Selected = j +} + +// SelectedItem returns the currently-selected checklist item or nil if no item is selected +func (list *Checklist) SelectedItem() *ChecklistItem { + if list.IsUnselectable() { + return nil + } + + return list.Items[list.Selected] +} + +func (list *Checklist) SetSelectedByItem(selectableItem *ChecklistItem) { + for idx, item := range list.Items { + if item == selectableItem { + list.Selected = idx + break + } + } +} + +// Toggle switches the checked state of the currently-selected item +func (list *Checklist) Toggle() { + if list.IsUnselectable() { + return + } + + list.SelectedItem().Toggle() +} + +// UncheckedItems returns a slice of all the unchecked items +func (list *Checklist) UncheckedItems() []*ChecklistItem { + items := []*ChecklistItem{} + + for _, item := range list.Items { + if !item.Checked { + items = append(items, item) + } + } + + return items +} + +// Unselect removes the current select such that no item is selected +func (list *Checklist) Unselect() { + list.Selected = -1 +} + +// Update sets the text of the currently-selected item to the provided text +func (list *Checklist) Update(text string) { + item := list.SelectedItem() + + if item == nil { + return + } + + item.Text = text +} + +/* -------------------- Sort Interface -------------------- */ + +func (list *Checklist) Len() int { + return len(list.Items) +} + +func (list *Checklist) Less(i, j int) bool { + return list.Items[i].Text < list.Items[j].Text +} + +func (list *Checklist) Swap(i, j int) { + list.Items[i], list.Items[j] = list.Items[j], list.Items[i] +} diff --git a/wtf/checklist_item.go b/wtf/checklist_item.go new file mode 100644 index 00000000..514ef714 --- /dev/null +++ b/wtf/checklist_item.go @@ -0,0 +1,23 @@ +package wtf + +// ChecklistItem is a module for creating generic checklist implementations +// See 'Todo' for an implementation example +type ChecklistItem struct { + Checked bool + Text string +} + +// CheckMark returns the string used to indicate a ChecklistItem is checked or unchecked +func (item *ChecklistItem) CheckMark() string { + if item.Checked { + return Config.UString("wtf.mods.todo.checkedIcon", "x") + } else { + return " " + } +} + +// Toggle changes the checked state of the ChecklistItem +// If checked, it is unchecked. If unchecked, it is checked +func (item *ChecklistItem) Toggle() { + item.Checked = !item.Checked +} diff --git a/wtftests/checklist_item_test.go b/wtftests/checklist_item_test.go new file mode 100644 index 00000000..799f4f55 --- /dev/null +++ b/wtftests/checklist_item_test.go @@ -0,0 +1,42 @@ +package wtftests + +import ( + "testing" + + "github.com/olebedev/config" + . "github.com/senorprogrammer/wtf/wtf" + . "github.com/stretchr/testify/assert" +) + +/* -------------------- CheckMark -------------------- */ + +func TestCheckMark(t *testing.T) { + loadConfig() + + item := ChecklistItem{} + Equal(t, " ", item.CheckMark()) + + item = ChecklistItem{Checked: true} + Equal(t, "x", item.CheckMark()) +} + +/* -------------------- Toggle -------------------- */ + +func TestToggle(t *testing.T) { + loadConfig() + + item := ChecklistItem{} + Equal(t, false, item.Checked) + + item.Toggle() + Equal(t, true, item.Checked) + + item.Toggle() + Equal(t, false, item.Checked) +} + +/* -------------------- helpers -------------------- */ + +func loadConfig() { + Config, _ = config.ParseYamlFile("../_sample_configs/simple_config.yml") +}