From d32fdbaab926a28a71d40cac366fdd074ff61b65 Mon Sep 17 00:00:00 2001 From: "G@rv!t" Date: Tue, 1 Aug 2023 03:26:33 +0530 Subject: [PATCH] - Fixed description model size - Restructured list selector model to support generic options - Added custom input workflow in main model - Added feature to move task to a new stack --- entities/task.go | 5 + tui/keys.go | 6 + tui/main_model.go | 223 ++++++++++++------ tui/messages.go | 8 +- tui/model_delete_confirmation.go | 7 +- tui/model_input_form.go | 11 +- ...lect_options.go => model_list_selector.go} | 58 +++-- tui/model_text_area.go | 2 +- tui/table_utils.go | 4 + 9 files changed, 225 insertions(+), 99 deletions(-) rename tui/{model_select_options.go => model_list_selector.go} (56%) diff --git a/entities/task.go b/entities/task.go index 6e4086e..fb9eec2 100644 --- a/entities/task.go +++ b/entities/task.go @@ -47,3 +47,8 @@ func (t Task) LatestRecurTask() (RecurTask, int64) { func (t Task) RemoveFutureRecurTasks() { DB.Unscoped().Where("deadline >= DATE('now', 'start of day') AND task_id = ?", t.ID).Delete(&RecurTask{}) } + +func (t Task) FetchAllRecurTasks() []RecurTask { + DB.Preload("RecurChildren").Find(&t) + return t.RecurChildren +} diff --git a/tui/keys.go b/tui/keys.go index 5ab682d..1fc6948 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -13,6 +13,7 @@ type keyMap struct { New key.Binding NewRecur key.Binding Edit key.Binding + Move key.Binding Enter key.Binding Save key.Binding Toggle key.Binding @@ -64,6 +65,10 @@ var Keys = keyMap{ key.WithKeys("e"), key.WithHelp("'e'", "edit"), ), + Move: key.NewBinding( + key.WithKeys("m"), + key.WithHelp("'m'", "move"), + ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("'enter'", "enter"), @@ -107,6 +112,7 @@ func (k keyMap) ShortHelp() []key.Binding { k.Enter, k.Save, k.Delete, + k.Move, k.Return, k.Up, k.Down, diff --git a/tui/main_model.go b/tui/main_model.go index 7f5a9e1..a19d322 100644 --- a/tui/main_model.go +++ b/tui/main_model.go @@ -12,22 +12,23 @@ import ( ) type model struct { - data []entities.Stack - stackTable table.Model - taskTable table.Model - taskDetails detailsBox - help helpModel - input inputForm - showTasks bool - showDetails bool - showInput bool - showHelp bool - deleteConfirmation tea.Model - showDelete bool - navigationKeys keyMap - preInputFocus string //useful for reverting back when input box is closed - firstRender bool - prevState preserveState + data []entities.Stack + stackTable table.Model + taskTable table.Model + taskDetails detailsBox + help helpModel + input inputForm + showTasks bool + showDetails bool + showInput bool + showHelp bool + customInput tea.Model + customInputType string + showCustomInput bool + navigationKeys keyMap + preInputFocus string //useful for reverting back when input box is closed + firstRender bool + prevState preserveState } type preserveState struct { @@ -63,13 +64,14 @@ func (m *model) Init() tea.Cmd { func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //Transfer control to inputForm's Update method if m.showInput { + switch msg := msg.(type) { case goToMainMsg: m.input = inputForm{} m.showInput = false - if msg.value == "refresh" { + if msg.value.(string) == "refresh" { m.preserveState() m.refreshData() } @@ -97,62 +99,121 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - //Transfer control to delete confirmation model - if m.showDelete { - switch msg := msg.(type) { + if m.showCustomInput { + switch m.customInputType { + //Transfer control to delete confirmation model + case "delete": + switch msg := msg.(type) { - case goToMainMsg: - m.showDelete = false + case goToMainMsg: + m.showCustomInput = false - if msg.value == "y" { - switch m.preInputFocus { - case "stack": - stackIndex := m.stackTable.Cursor() - currStack := m.data[stackIndex] + if msg.value.(string) == "y" { + switch m.preInputFocus { + case "stack": + stackIndex := m.stackTable.Cursor() + currStack := m.data[stackIndex] - if stackIndex == len(m.stackTable.Rows())-1 { - m.stackTable.SetCursor(stackIndex - 1) - } + if stackIndex == len(m.stackTable.Rows())-1 { + m.stackTable.SetCursor(stackIndex - 1) + } - currStack.Delete() - m.showTasks = false - m.showDetails = false - m.refreshData() - return m, nil + currStack.Delete() + m.showTasks = false + m.showDetails = false + m.refreshData() + return m, nil - case "task": - stackIndex := m.stackTable.Cursor() - taskIndex := m.taskTable.Cursor() + case "task": + stackIndex := m.stackTable.Cursor() + taskIndex := m.taskTable.Cursor() - var currTask entities.Task - if len(m.data[stackIndex].Tasks) > 0 { - currTask = m.data[stackIndex].Tasks[taskIndex] + var currTask entities.Task + if len(m.data[stackIndex].Tasks) > 0 { + currTask = m.data[stackIndex].Tasks[taskIndex] - if currTask.IsRecurring { + if currTask.IsRecurring { - } else { - if !currTask.IsFinished { - stack := m.data[stackIndex] - stack.PendingTaskCount-- - stack.Save() + } else { + if !currTask.IsFinished { + stack := m.data[stackIndex] + stack.PendingTaskCount-- + stack.Save() + } } + if taskIndex == len(m.taskTable.Rows())-1 { + m.taskTable.SetCursor(taskIndex - 1) + } + currTask.Delete() + m.refreshData() + return m, nil } - if taskIndex == len(m.taskTable.Rows())-1 { - m.taskTable.SetCursor(taskIndex - 1) - } - currTask.Delete() - m.refreshData() - return m, nil } } + + default: + inp, cmd := m.customInput.Update(msg) + t, _ := inp.(deleteConfirmation) + m.customInput = t + + return m, cmd } - default: - inp, cmd := m.deleteConfirmation.Update(msg) - t, _ := inp.(deleteConfirmation) - m.deleteConfirmation = t + case "move": + switch msg := msg.(type) { - return m, cmd + case goToMainMsg: + m.showCustomInput = false + + response := msg.value.(keyVal) + + if response.val == "" { + return m, nil + } + + newStackID := response.key + + stackIndex := m.stackTable.Cursor() + taskIndex := m.taskTable.Cursor() + + currStack := m.data[stackIndex] + currTask := currStack.Tasks[taskIndex] + + if currTask.StackID == newStackID { + return m, nil + } + + if currTask.IsRecurring { + for _, child := range currTask.FetchAllRecurTasks() { + child.StackID = newStackID + child.Save() + } + } else { + //Moving recurring tasks wouldn't have any effect on the stack pending task count + + //Decrease pending task count for old stack + if !currTask.IsFinished { + currStack.PendingTaskCount-- + currStack.Save() + } + + //Increase pending task count for new stack + entities.IncPendingCount(newStackID) + } + + currTask.StackID = newStackID + currTask.Save() + + m.refreshData() + return m, nil + + default: + inp, cmd := m.customInput.Update(msg) + t, _ := inp.(listSelector) + m.customInput = t + + return m, cmd + } } } @@ -356,7 +417,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { IsRecurring: true, StartTime: time.Now(), Deadline: time.Now(), - RecurrenceInterval: 7, + RecurrenceInterval: 1, } m.input = initializeInput("task", newTask, 0) @@ -404,8 +465,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, Keys.Delete): if m.stackTable.Focused() { m.preInputFocus = "stack" - m.showDelete = true - m.deleteConfirmation = initializeDeleteConfirmation() + m.showCustomInput = true + m.customInputType = "delete" + m.customInput = initializeDeleteConfirmation() return m, nil @@ -414,8 +476,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.data[stackIndex].Tasks) > 0 { m.preInputFocus = "task" - m.showDelete = true - m.deleteConfirmation = initializeDeleteConfirmation() + m.showCustomInput = true + m.customInputType = "delete" + m.customInput = initializeDeleteConfirmation() return m, nil } @@ -462,10 +525,29 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // case key.Matches(msg, Keys.CalendarToggle): - // m.isCalenderView = !m.isCalenderView - // return m, nil + case key.Matches(msg, Keys.Move): + if m.taskTable.Focused() { + stackIndex := m.stackTable.Cursor() + + if len(m.data[stackIndex].Tasks) > 0 { + m.preInputFocus = "task" + m.showCustomInput = true + m.customInputType = "move" + + opts := []keyVal{} + for _, stack := range m.data { + entry := keyVal{ + key: stack.ID, + val: stack.Title, + } + opts = append(opts, entry) + } + m.customInput = initializeListSelector(opts, "", goToMainWithVal) + m.help = initializeHelp(listSelectorKeys) + return m, nil + } + } case key.Matches(msg, Keys.Help): m.showHelp = !m.showHelp return m, nil @@ -539,8 +621,11 @@ func (m *model) View() string { tablesView := lipgloss.JoinHorizontal(lipgloss.Center, viewArr...) - if m.showDelete { - return lipgloss.JoinVertical(lipgloss.Left, tablesView, m.deleteConfirmation.View()) + if m.showCustomInput { + tablesView = lipgloss.JoinVertical(lipgloss.Left, + tablesView, + getInputFormStyle().Render(m.customInput.View()), + ) } if m.showInput { @@ -553,7 +638,7 @@ func (m *model) View() string { } if m.showHelp { - if !m.showInput { + if !m.showInput && !m.showCustomInput { navigationHelp := initializeHelp(m.navigationKeys) return lipgloss.JoinVertical(lipgloss.Left, tablesView, m.help.View(), navigationHelp.View()) } diff --git a/tui/messages.go b/tui/messages.go index f324e76..e96bf06 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -3,14 +3,16 @@ package tui import tea "github.com/charmbracelet/bubbletea" type goToMainMsg struct { - value string + value interface{} } func goToMainCmd() tea.Msg { - return goToMainMsg{} + return goToMainMsg{ + value: "", + } } -func goToMainWithVal(value string) tea.Cmd { +func goToMainWithVal(value interface{}) tea.Cmd { return func() tea.Msg { return goToMainMsg{value: value} } diff --git a/tui/model_delete_confirmation.go b/tui/model_delete_confirmation.go index 02d5b4e..baf4eeb 100644 --- a/tui/model_delete_confirmation.go +++ b/tui/model_delete_confirmation.go @@ -9,10 +9,13 @@ import ( // textinput.Model doesn't implement tea.Model interface type deleteConfirmation struct { + customInputType string } func initializeDeleteConfirmation() tea.Model { - m := deleteConfirmation{} + m := deleteConfirmation{ + customInputType: "delete", + } return m } @@ -46,5 +49,5 @@ func (m deleteConfirmation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m deleteConfirmation) View() string { // Can't just render textinput.Value(), otherwise cursor blinking wouldn't work - return lipgloss.NewStyle().Foreground(highlightedBackgroundColor).PaddingTop(1).Render("Do you wish to proceed with deletion? (y/n): ") + return lipgloss.NewStyle().Foreground(highlightedBackgroundColor).Padding(1, 0).Render("Do you wish to proceed with deletion? (y/n): ") } diff --git a/tui/model_input_form.go b/tui/model_input_form.go index 9a8f37a..d043d2e 100644 --- a/tui/model_input_form.go +++ b/tui/model_input_form.go @@ -67,7 +67,7 @@ var ( 3: { name: "Priority", prompt: "Task Priority", - helpKeys: selectOptionKeys, + helpKeys: listSelectorKeys, }, 4: { name: "Deadline", @@ -143,7 +143,12 @@ func initializeInput(selectedTable string, data entities.Entity, fieldIndex int) case 2: targetField.model = initializeStepsEditor(task.Steps, task.ID) case 3: - targetField.model = initialSelectModel([]string{"0", "1", "2"}, strconv.Itoa(task.Priority)) + opts := []keyVal{ + {val: "0"}, + {val: "1"}, + {val: "2"}, + } + targetField.model = initializeListSelector(opts, strconv.Itoa(task.Priority), goToFormWithVal) case 4: if task.Deadline.IsZero() { currDate := time.Now().String()[0:10] @@ -226,7 +231,7 @@ func (m inputForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // We save tasks independently (in steps-editor itself) & not as task associations return m, goToMainWithVal("refresh") case 3: - task.Priority, _ = strconv.Atoi(selectedValue.(string)) + task.Priority, _ = strconv.Atoi(selectedValue.(keyVal).val) case 4: oldDeadline := task.Deadline task.Deadline = selectedValue.(time.Time) diff --git a/tui/model_select_options.go b/tui/model_list_selector.go similarity index 56% rename from tui/model_select_options.go rename to tui/model_list_selector.go index 42dabfe..b5be09c 100644 --- a/tui/model_select_options.go +++ b/tui/model_list_selector.go @@ -1,20 +1,24 @@ package tui import ( - "strings" - "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type selectModel struct { - options []string +type listSelector struct { + options []keyVal focusIndex int maxIndex int + responder func(interface{}) tea.Cmd +} + +type keyVal struct { + key uint + val string } -var selectOptionKeys = keyMap{ +var listSelectorKeys = keyMap{ Up: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("'โ†‘/k'", "up"), @@ -33,42 +37,53 @@ var selectOptionKeys = keyMap{ ), } -func (m selectModel) Init() tea.Cmd { +func (m listSelector) Init() tea.Cmd { return nil } -func initialSelectModel(options []string, selectedVal string) tea.Model { +func initializeListSelector(options []keyVal, selectedVal string, responder func(interface{}) tea.Cmd) tea.Model { // Takes care of default case where index should be 0 var selectedIndex int - for i, val := range options { - if val == selectedVal { + for i, item := range options { + if item.val == selectedVal { selectedIndex = i break } } - m := selectModel{focusIndex: selectedIndex, maxIndex: len(options) - 1, options: options} + m := listSelector{ + focusIndex: selectedIndex, + maxIndex: len(options) - 1, + options: options, + responder: responder, + } return m } -func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m listSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch strings.ToLower(msg.String()) { - case "q", "ctrl+c": + switch { + case key.Matches(msg, Keys.Return): + return m, goToMainWithVal(keyVal{}) + + case key.Matches(msg, Keys.Quit): return m, tea.Quit - case "enter": - return m, goToFormWithVal(m.options[m.focusIndex]) - case "up", "k": + + case key.Matches(msg, Keys.Enter): + return m, m.responder(m.options[m.focusIndex]) + + case key.Matches(msg, Keys.Up): if m.focusIndex > 0 { m.focusIndex-- } else { m.focusIndex = m.maxIndex return m, nil } - case "down", "j": + + case key.Matches(msg, Keys.Down): if m.focusIndex < m.maxIndex { m.focusIndex++ } else { @@ -76,20 +91,21 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + } return m, nil } -func (m selectModel) View() string { +func (m listSelector) View() string { var res []string - for i, val := range m.options { + for i, item := range m.options { var value string if i == m.focusIndex { - value = lipgloss.NewStyle().Foreground(inputFormColor).Bold(true).Render("ยป " + val) + value = lipgloss.NewStyle().Foreground(inputFormColor).Bold(true).Render("ยป " + item.val) } else { - value = lipgloss.NewStyle().Foreground(inputFormColor).Bold(true).Render(" " + val) + value = lipgloss.NewStyle().Foreground(inputFormColor).Bold(true).Render(" " + item.val) } res = append(res, value) diff --git a/tui/model_text_area.go b/tui/model_text_area.go index bc517ec..e9e53b8 100644 --- a/tui/model_text_area.go +++ b/tui/model_text_area.go @@ -30,7 +30,7 @@ func initializeTextArea(value string) tea.Model { t := textarea.New() t.SetValue(value) t.SetWidth(getInputFormStyle().GetWidth() - 2) - t.SetHeight(5) + t.SetHeight(4) t.CharLimit = 500 t.Placeholder = "Enter task description" t.ShowLineNumbers = false diff --git a/tui/table_utils.go b/tui/table_utils.go index 6f646ec..7b9943c 100644 --- a/tui/table_utils.go +++ b/tui/table_utils.go @@ -49,6 +49,10 @@ var taskKeys = keyMap{ key.WithKeys("x"), key.WithHelp("'x'", "delete ๐Ÿ—‘"), ), + Move: key.NewBinding( + key.WithKeys("m"), + key.WithHelp("'m'", "move ๐Ÿ“ค"), + ), } var tableNavigationKeys = keyMap{