├── .gitignore ├── gifs ├── Editing.gif └── Navigation.gif ├── entities ├── step.go ├── recur_task.go ├── entity.go ├── stack.go └── task.go ├── main.go ├── tui ├── messages.go ├── model_help.go ├── model_delete_confirmation.go ├── model_text_area.go ├── model_text_input.go ├── model_list_selector.go ├── keys.go ├── model_steps_editor.go ├── model_calendar.go ├── styles.go ├── table_utils.go ├── model_time_picker.go ├── model_input_form.go ├── model_details_box.go └── main_model.go ├── .github └── workflows │ └── go.yml ├── LICENSE ├── go.mod ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | 3 | dist/ 4 | -------------------------------------------------------------------------------- /gifs/Editing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BOTbkcd/mayhem/HEAD/gifs/Editing.gif -------------------------------------------------------------------------------- /gifs/Navigation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BOTbkcd/mayhem/HEAD/gifs/Navigation.gif -------------------------------------------------------------------------------- /entities/step.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | type Step struct { 8 | gorm.Model 9 | Title string 10 | IsFinished bool 11 | TaskID uint 12 | } 13 | 14 | func (s Step) Save() Step { 15 | DB.Save(&s) 16 | return s 17 | } 18 | 19 | func (s Step) Delete() { 20 | DB.Delete(&s) 21 | } 22 | -------------------------------------------------------------------------------- /entities/recur_task.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type RecurTask struct { 10 | gorm.Model 11 | Deadline time.Time `gorm:"index:idx_member"` 12 | IsFinished bool 13 | StackID uint `gorm:"index:idx_member"` 14 | TaskID uint 15 | } 16 | 17 | func (r RecurTask) Save() { 18 | DB.Save(&r) 19 | } 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | entities "github.com/BOTbkcd/mayhem/entities" 7 | tui "github.com/BOTbkcd/mayhem/tui" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | func main() { 13 | entities.InitializeDB() 14 | 15 | model := tui.InitializeMainModel() 16 | p := tea.NewProgram(model, tea.WithAltScreen()) 17 | 18 | // f, err := tea.LogToFile("debug.log", "debug") 19 | // if err != nil { 20 | // fmt.Println("fatal:", err) 21 | // os.Exit(1) 22 | // } 23 | // defer f.Close() 24 | 25 | if _, err := p.Run(); err != nil { 26 | log.Fatal("Error encountered while running the program:", err) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tui/messages.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | type goToMainMsg struct { 6 | value interface{} 7 | } 8 | 9 | func goToMainCmd() tea.Msg { 10 | return goToMainMsg{ 11 | value: "", 12 | } 13 | } 14 | 15 | func goToMainWithVal(value interface{}) tea.Cmd { 16 | return func() tea.Msg { 17 | return goToMainMsg{value: value} 18 | } 19 | } 20 | 21 | type goToFormMsg struct { 22 | value interface{} 23 | } 24 | 25 | func goToFormWithVal(value interface{}) tea.Cmd { 26 | return func() tea.Msg { 27 | return goToFormMsg{value: value} 28 | } 29 | } 30 | 31 | type goToStepsMsg struct { 32 | value interface{} 33 | } 34 | 35 | func goToStepsWithVal(value interface{}) tea.Cmd { 36 | return func() tea.Msg { 37 | return goToStepsMsg{value: value} 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /entities/entity.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | //Using pure-go implementation of GORM driver to avoid CGO issues during cross-compilation 5 | 6 | "log" 7 | "os" 8 | 9 | "github.com/glebarez/sqlite" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/logger" 12 | ) 13 | 14 | type Entity interface { 15 | Save() Entity 16 | Delete() 17 | } 18 | 19 | var DB *gorm.DB 20 | 21 | func InitializeDB() { 22 | dirname, err := os.UserHomeDir() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | db, err := gorm.Open(sqlite.Open(dirname+string(os.PathSeparator)+".todo.db"), &gorm.Config{ 28 | //Silent mode ensures that errors logs don't interfere with the view 29 | Logger: logger.Default.LogMode(logger.Silent), 30 | }) 31 | if err != nil { 32 | panic(err) 33 | } 34 | db.AutoMigrate(&Stack{}, &Task{}, &Step{}, &RecurTask{}) 35 | 36 | DB = db 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Generate binary assets 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | CGO_ENABLED: 0 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | releases-matrix: 15 | name: Release binaries 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | goos: [linux, darwin, windows] 20 | goarch: [amd64, arm64] 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: wangyoucao577/go-release-action@v1.38 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | goos: ${{ matrix.goos }} 27 | goarch: ${{ matrix.goarch }} 28 | goversion: "1.20" 29 | asset_name: mayhem-${{ matrix.goos }}-${{ matrix.goarch }} 30 | project_path: "." 31 | md5sum: false 32 | extra_files: LICENSE README.md 33 | retry: 3 34 | -------------------------------------------------------------------------------- /tui/model_help.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | type helpModel struct { 10 | help help.Model 11 | keys keyMap 12 | } 13 | 14 | func initializeHelp(keys keyMap) helpModel { 15 | return helpModel{ 16 | keys: keys, 17 | help: help.New(), 18 | } 19 | } 20 | 21 | func (m helpModel) Init() tea.Cmd { 22 | return nil 23 | } 24 | 25 | func (m helpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 26 | switch msg := msg.(type) { 27 | case tea.WindowSizeMsg: 28 | // If we set a width on the help menu it can it can gracefully truncate 29 | // its view as needed. 30 | m.help.Width = msg.Width 31 | } 32 | 33 | return m, nil 34 | } 35 | 36 | func (m helpModel) View() string { 37 | style := lipgloss.NewStyle().MarginTop(1) 38 | return style.Render(m.help.View(m.keys)) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 BOT_bkcd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tui/model_delete_confirmation.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | // textinput.Model doesn't implement tea.Model interface 11 | type deleteConfirmation struct { 12 | customInputType string 13 | } 14 | 15 | func initializeDeleteConfirmation() tea.Model { 16 | m := deleteConfirmation{ 17 | customInputType: "delete", 18 | } 19 | 20 | return m 21 | } 22 | 23 | func (m deleteConfirmation) Init() tea.Cmd { 24 | return textinput.Blink 25 | } 26 | 27 | func (m deleteConfirmation) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 28 | switch msg := msg.(type) { 29 | 30 | case tea.KeyMsg: 31 | switch { 32 | 33 | case key.Matches(msg, Keys.Return): 34 | return m, goToMainCmd 35 | 36 | case key.Matches(msg, Keys.Quit): 37 | return m, tea.Quit 38 | 39 | default: 40 | if msg.String() == "y" || msg.String() == "Y" { 41 | return m, goToMainWithVal("y") 42 | } else { 43 | return m, goToMainWithVal("") 44 | } 45 | } 46 | } 47 | return m, nil 48 | } 49 | 50 | func (m deleteConfirmation) View() string { 51 | // Can't just render textinput.Value(), otherwise cursor blinking wouldn't work 52 | return lipgloss.NewStyle().Foreground(highlightedBackgroundColor).Padding(1, 0).Render("Do you wish to proceed with deletion? (y/n): ") 53 | } 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BOTbkcd/mayhem 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.16.1 7 | github.com/charmbracelet/bubbletea v0.24.2 8 | github.com/charmbracelet/lipgloss v0.7.1 9 | github.com/glebarez/sqlite v1.9.0 10 | gorm.io/gorm v1.25.2 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 17 | github.com/dustin/go-humanize v1.0.1 // indirect 18 | github.com/glebarez/go-sqlite v1.21.2 // indirect 19 | github.com/google/uuid v1.3.0 // indirect 20 | github.com/jinzhu/inflection v1.0.0 // indirect 21 | github.com/jinzhu/now v1.1.5 // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-isatty v0.0.18 // indirect 24 | github.com/mattn/go-localereader v0.0.1 // indirect 25 | github.com/mattn/go-runewidth v0.0.14 // indirect 26 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 27 | github.com/muesli/cancelreader v0.2.2 // indirect 28 | github.com/muesli/reflow v0.3.0 // indirect 29 | github.com/muesli/termenv v0.15.1 // indirect 30 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 31 | github.com/rivo/uniseg v0.2.0 // indirect 32 | golang.org/x/sync v0.1.0 // indirect 33 | golang.org/x/sys v0.7.0 // indirect 34 | golang.org/x/term v0.6.0 // indirect 35 | golang.org/x/text v0.3.8 // indirect 36 | modernc.org/libc v1.22.5 // indirect 37 | modernc.org/mathutil v1.5.0 // indirect 38 | modernc.org/memory v1.5.0 // indirect 39 | modernc.org/sqlite v1.23.1 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /entities/stack.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | "gorm.io/gorm/clause" 6 | ) 7 | 8 | // Skipping priority field, just sort them alphabetically 9 | type Stack struct { 10 | gorm.Model 11 | Title string `gorm:"notnull"` 12 | PendingTaskCount int 13 | Tasks []Task 14 | } 15 | 16 | func InitializeStacks() (Stack, error) { 17 | stack := Stack{Title: "New Stack"} 18 | result := DB.Create(&stack) 19 | return stack, result.Error 20 | } 21 | 22 | func FetchAllStacks() ([]Stack, error) { 23 | var stacks []Stack 24 | result := DB.Model(&Stack{}).Preload("Tasks").Preload("Tasks.Steps").Find(&stacks) 25 | 26 | if len(stacks) == 0 { 27 | stack, err := InitializeStacks() 28 | return []Stack{stack}, err 29 | } 30 | 31 | return stacks, result.Error 32 | } 33 | 34 | func IncPendingCount(id uint) { 35 | stack := Stack{} 36 | DB.Find(&stack, id) 37 | stack.PendingTaskCount++ 38 | stack.Save() 39 | } 40 | 41 | func (s Stack) PendingRecurringCount() int { 42 | recurTasks := []RecurTask{} 43 | //localtime modifier has to be added to DATE other wise UTC time would be used 44 | result := DB.Find(&recurTasks, "deadline >= DATE('now', 'localtime', 'start of day') AND deadline < DATE('now', 'localtime', 'start of day', '+1 day') AND stack_id = ? AND is_finished = false", s.ID) 45 | return int(result.RowsAffected) 46 | } 47 | 48 | func (s Stack) Save() Entity { 49 | DB.Save(&s) 50 | return s 51 | } 52 | 53 | func (s Stack) Delete() { 54 | //Unscoped() is used to ensure hard delete, where stack will be removed from db instead of being just marked as "deleted" 55 | // DB.Unscoped().Delete(&s) 56 | DB.Unscoped().Select(clause.Associations).Delete(&s) 57 | } 58 | -------------------------------------------------------------------------------- /entities/task.go: -------------------------------------------------------------------------------- 1 | package entities 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/clause" 8 | ) 9 | 10 | type Task struct { 11 | gorm.Model 12 | Title string `gorm:"notnull"` 13 | Description string 14 | Steps []Step 15 | Deadline time.Time 16 | Priority int //3: High, 2: Mid, 1: Low, 0: No Priority 17 | IsFinished bool 18 | IsRecurring bool 19 | StartTime time.Time //Applicable only for recurring tasks 20 | RecurrenceInterval int // in days 21 | RecurChildren []RecurTask 22 | StackID uint 23 | } 24 | 25 | func (t Task) Save() Entity { 26 | DB.Save(&t) 27 | return t 28 | } 29 | 30 | func (t Task) Delete() { 31 | //Unscoped() is used to ensure hard delete, where task will be removed from db instead of being just marked as "deleted" 32 | DB.Unscoped().Select(clause.Associations).Delete(&t) 33 | } 34 | 35 | func (t Task) LatestRecurTask() (RecurTask, int64) { 36 | recurTask := RecurTask{} 37 | //localtime modifier has to be added to DATE other wise UTC time would be used 38 | result := DB.Last(&recurTask, "task_id = ? AND deadline < DATE('now', 'localtime', 'start of day', '+1 day')", t.ID) 39 | 40 | // if t.IsFinished != recurTask.IsFinished { 41 | // t.IsFinished = recurTask.IsFinished 42 | // t.Save() 43 | // } 44 | return recurTask, result.RowsAffected 45 | } 46 | 47 | func (t Task) RemoveFutureRecurTasks() { 48 | DB.Unscoped().Where("deadline >= DATE('now', 'start of day') AND task_id = ?", t.ID).Delete(&RecurTask{}) 49 | } 50 | 51 | func (t Task) FetchAllRecurTasks() []RecurTask { 52 | DB.Preload("RecurChildren").Find(&t) 53 | return t.RecurChildren 54 | } 55 | -------------------------------------------------------------------------------- /tui/model_text_area.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/textarea" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // textarea.Model doesn't implement tea.Model interface 10 | type textArea struct { 11 | input textarea.Model 12 | } 13 | 14 | var textAreaKeys = keyMap{ 15 | Enter: key.NewBinding( 16 | key.WithKeys("enter"), 17 | key.WithHelp("'enter'", "new line"), 18 | ), 19 | Save: key.NewBinding( 20 | key.WithKeys("ctrl+s"), 21 | key.WithHelp("'ctrl+s'", "save"), 22 | ), 23 | Return: key.NewBinding( 24 | key.WithKeys("esc"), 25 | key.WithHelp("'esc'", "return"), 26 | ), 27 | } 28 | 29 | func initializeTextArea(value string) tea.Model { 30 | t := textarea.New() 31 | t.SetValue(value) 32 | t.SetWidth(getInputFormStyle().GetWidth() - 2) 33 | t.SetHeight(4) 34 | t.CharLimit = 500 35 | t.Placeholder = "Enter task description" 36 | t.ShowLineNumbers = false 37 | //We only deal with textarea in focused state, so blurred style is redundant 38 | t.FocusedStyle = textarea.Style{Placeholder: placeHolderStyle, Text: textInputStyle} 39 | t.Focus() 40 | 41 | m := textArea{ 42 | input: t, 43 | } 44 | 45 | return m 46 | } 47 | 48 | func (m textArea) Init() tea.Cmd { 49 | return textarea.Blink 50 | } 51 | 52 | func (m textArea) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 53 | switch msg := msg.(type) { 54 | 55 | case tea.KeyMsg: 56 | switch msg.String() { 57 | case "ctrl+s": 58 | return m, goToFormWithVal(m.input.Value()) 59 | } 60 | } 61 | 62 | // Placing it outside KeyMsg case is required, otherwise messages like textarea's Blink will be lost 63 | var cmd tea.Cmd 64 | m.input, cmd = m.input.Update(msg) 65 | return m, cmd 66 | } 67 | 68 | func (m textArea) View() string { 69 | // Can't just render textarea.Value(), otherwise cursor blinking wouldn't work 70 | return m.input.View() 71 | } 72 | -------------------------------------------------------------------------------- /tui/model_text_input.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/textinput" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // textinput.Model doesn't implement tea.Model interface 10 | type textInput struct { 11 | input textinput.Model 12 | //Since textinput field can be used in multiple places, 13 | //responder is required to determine the receiver of the message emitted by textinput field 14 | responder func(interface{}) tea.Cmd 15 | } 16 | 17 | var textInputKeys = keyMap{ 18 | Enter: key.NewBinding( 19 | key.WithKeys("enter"), 20 | key.WithHelp("'enter'", "save"), 21 | ), 22 | Return: key.NewBinding( 23 | key.WithKeys("esc"), 24 | key.WithHelp("'esc'", "return"), 25 | ), 26 | } 27 | 28 | func initializeTextInput(value string, placeholder string, charLimit int, responder func(interface{}) tea.Cmd) tea.Model { 29 | t := textinput.New() 30 | t.SetValue(value) 31 | 32 | t.Cursor.Style = textInputStyle 33 | t.CharLimit = charLimit 34 | t.Focus() 35 | t.PromptStyle = textInputStyle 36 | t.TextStyle = textInputStyle 37 | t.Placeholder = placeholder 38 | 39 | m := textInput{ 40 | input: t, 41 | responder: responder, 42 | } 43 | 44 | return m 45 | } 46 | 47 | func (m textInput) Init() tea.Cmd { 48 | return textinput.Blink 49 | } 50 | 51 | func (m textInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 52 | switch msg := msg.(type) { 53 | 54 | case tea.KeyMsg: 55 | switch { 56 | case key.Matches(msg, Keys.Enter): 57 | return m, m.responder(m.input.Value()) 58 | } 59 | } 60 | 61 | // Placing it outside KeyMsg case is required, otherwise messages like textinput's Blink will be lost 62 | var cmd tea.Cmd 63 | m.input, cmd = m.input.Update(msg) 64 | return m, cmd 65 | } 66 | 67 | func (m textInput) View() string { 68 | // Can't just render textinput.Value(), otherwise cursor blinking wouldn't work 69 | return m.input.View() 70 | } 71 | -------------------------------------------------------------------------------- /tui/model_list_selector.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | type listSelector struct { 10 | options []keyVal 11 | focusIndex int 12 | maxIndex int 13 | responder func(interface{}) tea.Cmd 14 | } 15 | 16 | type keyVal struct { 17 | key uint 18 | val string 19 | } 20 | 21 | var listSelectorKeys = keyMap{ 22 | Up: key.NewBinding( 23 | key.WithKeys("up", "k"), 24 | key.WithHelp("'↑/k'", "up"), 25 | ), 26 | Down: key.NewBinding( 27 | key.WithKeys("down", "j"), 28 | key.WithHelp("'↓/j'", "down"), 29 | ), 30 | Enter: key.NewBinding( 31 | key.WithKeys("enter"), 32 | key.WithHelp("'enter'", "save"), 33 | ), 34 | Return: key.NewBinding( 35 | key.WithKeys("esc"), 36 | key.WithHelp("'esc'", "return"), 37 | ), 38 | } 39 | 40 | func (m listSelector) Init() tea.Cmd { 41 | return nil 42 | } 43 | 44 | func initializeListSelector(options []keyVal, selectedVal string, responder func(interface{}) tea.Cmd) tea.Model { 45 | // Takes care of default case where index should be 0 46 | var selectedIndex int 47 | 48 | for i, item := range options { 49 | if item.val == selectedVal { 50 | selectedIndex = i 51 | break 52 | } 53 | } 54 | 55 | m := listSelector{ 56 | focusIndex: selectedIndex, 57 | maxIndex: len(options) - 1, 58 | options: options, 59 | responder: responder, 60 | } 61 | 62 | return m 63 | } 64 | 65 | func (m listSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 66 | switch msg := msg.(type) { 67 | case tea.KeyMsg: 68 | switch { 69 | case key.Matches(msg, Keys.Return): 70 | return m, goToMainWithVal(keyVal{}) 71 | 72 | case key.Matches(msg, Keys.Quit, Keys.Exit): 73 | return m, tea.Quit 74 | 75 | case key.Matches(msg, Keys.Enter): 76 | return m, m.responder(m.options[m.focusIndex]) 77 | 78 | case key.Matches(msg, Keys.Up): 79 | if m.focusIndex > 0 { 80 | m.focusIndex-- 81 | } else { 82 | m.focusIndex = m.maxIndex 83 | return m, nil 84 | } 85 | 86 | case key.Matches(msg, Keys.Down): 87 | if m.focusIndex < m.maxIndex { 88 | m.focusIndex++ 89 | } else { 90 | m.focusIndex = 0 91 | return m, nil 92 | } 93 | } 94 | 95 | } 96 | return m, nil 97 | } 98 | 99 | func (m listSelector) View() string { 100 | var res []string 101 | 102 | for i, item := range m.options { 103 | var value string 104 | 105 | if i == m.focusIndex { 106 | value = lipgloss.NewStyle().Foreground(inputFormColor).Bold(true).Render("» " + item.val) 107 | } else { 108 | value = lipgloss.NewStyle().Foreground(inputFormColor).Bold(true).Render(" " + item.val) 109 | } 110 | 111 | res = append(res, value) 112 | } 113 | 114 | return lipgloss.JoinVertical(lipgloss.Left, res...) 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mayhem 📝 2 | 3 | A minimal TUI based task tracker 4 | 5 | 6 | 7 | 8 | 9 |

10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ## Installation 19 | 20 | - You can download a pre-compiled binary for your system for the [latest release](https://github.com/BOTbkcd/mayhem/releases/latest) 21 | - Or if you have go installed on your system you can use the following command to install this package: 22 | 23 | ``` 24 | go install github.com/BOTbkcd/mayhem@latest 25 | ``` 26 | 27 | *SQLite is a dependency for this tool, make sure it is installed beforehand (it is fairly ubiquitous & should already be present on your system).* 28 | 29 | 30 | 31 | 32 | ## Features 33 | 34 | - Three pane responsive layout, auto adjusts when terminal is resized 35 | 36 | - Vim key bindings for navigation 37 | 38 | - Tasks: 39 | 40 | - Completion Status: 41 | - Tasks can be marked finished/unfinished using `Tab` key 42 | - Each stack has a label which denotes the number of unfinished tasks in that stack 43 | - A task can be broken down into associated *steps* 44 | - Individual steps can be marked as finished as progress is made 45 | 46 | - Task can be moved to a new stack after creation without any loss of data 47 | 48 | - Recurring tasks: 49 | - A recurring task will begin from the specified start time & repeat after the recurrence interval until the deadline is reached 50 | - A recurring task can only be temporarily marked as finished. It will resurface after the recurrence interval. 51 | - The deadline can be extended as per requirement 52 | - They are marked in task table using `📌` icon 53 | 54 | - Sorting: 55 | 56 | - Stacks are sorted alphabetically by default 57 | - Tasks are sorted by completion status, then deadline, then priority & then by title 58 | - Unscheduled tasks have less precedence than scheduled tasks 59 | 60 | - Pane Footer: each pane has a footer which your relative position in the pane 61 | 62 | - Dynamic help section at the bottom shows the relevant key bindings available at a given instance 63 | 64 | 65 | 66 | ## Navigation 67 | 68 | | Key | Description | 69 | | --------------------- | ---------------------------------- | 70 | | k or up | Move up | 71 | | j or down | Move down | 72 | | l or right | Switch focus to the pane on right | 73 | | h or left | Switch focus to the pane on left | 74 | | g | Jump to top of the pane | 75 | | G | Jump to bottom of the pane | 76 | | e | Edit | 77 | | tab | Toggle task/step completion status | 78 | | esc | Return | 79 | | m | Move task to new stack | 80 | | ? | Toggle Help | 81 | | q / ctrl+c | Quit | 82 | 83 | -------------------------------------------------------------------------------- /tui/keys.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | type keyMap struct { 6 | CalendarToggle key.Binding 7 | Up key.Binding 8 | Down key.Binding 9 | GotoTop key.Binding 10 | GotoBottom key.Binding 11 | Left key.Binding 12 | Right key.Binding 13 | New key.Binding 14 | NewRecur key.Binding 15 | Edit key.Binding 16 | Move key.Binding 17 | Enter key.Binding 18 | Save key.Binding 19 | Toggle key.Binding 20 | ReverseToggle key.Binding 21 | Delete key.Binding 22 | Return key.Binding 23 | Help key.Binding 24 | Quit key.Binding 25 | Exit key.Binding 26 | } 27 | 28 | var Keys = keyMap{ 29 | CalendarToggle: key.NewBinding( 30 | key.WithKeys("c"), 31 | key.WithHelp("'c'", "calendar view"), 32 | ), 33 | Up: key.NewBinding( 34 | key.WithKeys("up", "k"), 35 | key.WithHelp("'↑/k'", "move up"), 36 | ), 37 | Down: key.NewBinding( 38 | key.WithKeys("down", "j"), 39 | key.WithHelp("'↓/j'", "move down"), 40 | ), 41 | GotoTop: key.NewBinding( 42 | key.WithKeys("g"), 43 | key.WithHelp("'g'", "go to top"), 44 | ), 45 | GotoBottom: key.NewBinding( 46 | key.WithKeys("G"), 47 | key.WithHelp("'G'", "go to bottom"), 48 | ), 49 | Left: key.NewBinding( 50 | key.WithKeys("left", "h"), 51 | key.WithHelp("'←/h'", "move left"), 52 | ), 53 | Right: key.NewBinding( 54 | key.WithKeys("right", "l"), 55 | key.WithHelp("'→/l'", "move right"), 56 | ), 57 | New: key.NewBinding( 58 | key.WithKeys("n"), 59 | key.WithHelp("'n'", "new"), 60 | ), 61 | NewRecur: key.NewBinding( 62 | key.WithKeys("r"), 63 | key.WithHelp("'r'", "new recurring"), 64 | ), 65 | Edit: key.NewBinding( 66 | key.WithKeys("e"), 67 | key.WithHelp("'e'", "edit"), 68 | ), 69 | Move: key.NewBinding( 70 | key.WithKeys("m"), 71 | key.WithHelp("'m'", "move"), 72 | ), 73 | Enter: key.NewBinding( 74 | key.WithKeys("enter"), 75 | key.WithHelp("'enter'", "enter"), 76 | ), 77 | Toggle: key.NewBinding( 78 | key.WithKeys("tab"), 79 | key.WithHelp("'tab'", "toggle"), 80 | ), 81 | ReverseToggle: key.NewBinding( 82 | key.WithKeys("shift+tab"), 83 | key.WithHelp("'shift+tab'", "toggle"), 84 | ), 85 | Delete: key.NewBinding( 86 | key.WithKeys("x"), 87 | key.WithHelp("'x'", "delete 🗑"), 88 | ), 89 | Return: key.NewBinding( 90 | key.WithKeys("esc"), 91 | key.WithHelp("'esc'", "return"), 92 | ), 93 | Help: key.NewBinding( 94 | key.WithKeys("?"), 95 | key.WithHelp("'?'", "toggle help"), 96 | ), 97 | Quit: key.NewBinding( 98 | key.WithKeys("q"), 99 | key.WithHelp("'q'", "quit"), 100 | ), 101 | Exit: key.NewBinding( 102 | key.WithKeys("ctrl+c"), 103 | key.WithHelp("'ctrl+c'", "exit"), 104 | ), 105 | } 106 | 107 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 108 | // of the key.Map interface. 109 | func (k keyMap) ShortHelp() []key.Binding { 110 | return []key.Binding{ 111 | k.CalendarToggle, 112 | k.Toggle, 113 | k.ReverseToggle, 114 | k.New, 115 | k.NewRecur, 116 | k.Edit, 117 | k.Enter, 118 | k.Save, 119 | k.Delete, 120 | k.Move, 121 | k.Return, 122 | k.Up, 123 | k.Down, 124 | k.GotoTop, 125 | k.GotoBottom, 126 | k.Left, 127 | k.Right, 128 | k.Help, 129 | k.Quit, 130 | } 131 | } 132 | 133 | // FullHelp returns keybindings for the expanded help view. It's part of the 134 | // key.Map interface. 135 | func (k keyMap) FullHelp() [][]key.Binding { 136 | return [][]key.Binding{k.ShortHelp()} 137 | } 138 | -------------------------------------------------------------------------------- /tui/model_steps_editor.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/BOTbkcd/mayhem/entities" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | // textinput.Model doesn't implement tea.Model interface 12 | type stepsEditor struct { 13 | taskID uint 14 | steps []entities.Step 15 | textInput tea.Model 16 | focusIndex int 17 | isEditMode bool 18 | } 19 | 20 | var stepsEditorKeys = keyMap{ 21 | Up: key.NewBinding( 22 | key.WithKeys("up", "k"), 23 | key.WithHelp("'↑/k'", "up"), 24 | ), 25 | Down: key.NewBinding( 26 | key.WithKeys("down", "j"), 27 | key.WithHelp("'↓/j'", "down"), 28 | ), 29 | New: key.NewBinding( 30 | key.WithKeys("n"), 31 | key.WithHelp("'n'", "new"), 32 | ), 33 | Edit: key.NewBinding( 34 | key.WithKeys("e"), 35 | key.WithHelp("'e'", "edit"), 36 | ), 37 | Enter: key.NewBinding( 38 | key.WithKeys("enter"), 39 | key.WithHelp("'enter'", "save"), 40 | ), 41 | Toggle: key.NewBinding( 42 | key.WithKeys("tab"), 43 | key.WithHelp("'tab'", "toggle status"), 44 | ), 45 | Delete: key.NewBinding( 46 | key.WithKeys("x"), 47 | key.WithHelp("'x'", "delete"), 48 | ), 49 | Return: key.NewBinding( 50 | key.WithKeys("esc"), 51 | key.WithHelp("'esc'", "return"), 52 | ), 53 | } 54 | 55 | var ( 56 | selectedStepColor = lipgloss.Color("#FFFF00") 57 | 58 | unselectedStepColor = lipgloss.Color("#999999") 59 | ) 60 | 61 | var ( 62 | newStepPlaceholder = "Enter Step Description" 63 | ) 64 | 65 | func initializeStepsEditor(steps []entities.Step, taskID uint) tea.Model { 66 | t := stepsEditor{ 67 | taskID: taskID, 68 | steps: steps, 69 | focusIndex: 0, 70 | } 71 | 72 | return t 73 | } 74 | 75 | func (m stepsEditor) Init() tea.Cmd { 76 | return nil 77 | } 78 | 79 | func (m stepsEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 80 | if m.isEditMode { 81 | switch msg := msg.(type) { 82 | case goToStepsMsg: 83 | currStep := m.steps[m.focusIndex] 84 | currStep.Title = msg.value.(string) 85 | m.steps[m.focusIndex] = currStep.Save() 86 | m.isEditMode = false 87 | return m, nil 88 | } 89 | 90 | // Placing it outside KeyMsg case is required, otherwise messages like textinput's Blink will be lost 91 | var cmd tea.Cmd 92 | m.textInput, cmd = m.textInput.Update(msg) 93 | return m, cmd 94 | } 95 | 96 | switch msg := msg.(type) { 97 | 98 | case tea.KeyMsg: 99 | switch { 100 | 101 | case key.Matches(msg, Keys.Up): 102 | if m.focusIndex > 0 { 103 | m.focusIndex-- 104 | } 105 | 106 | case key.Matches(msg, Keys.Down): 107 | if m.focusIndex < len(m.steps)-1 { 108 | m.focusIndex++ 109 | } 110 | 111 | case key.Matches(msg, Keys.New): 112 | newStep := entities.Step{ 113 | TaskID: m.taskID, 114 | Title: "", 115 | IsFinished: false, 116 | } 117 | 118 | if len(m.steps) == 0 { 119 | m.steps = []entities.Step{newStep} 120 | } else { 121 | m.steps = append(m.steps[:m.focusIndex+1], m.steps[m.focusIndex:]...) 122 | m.steps[m.focusIndex+1] = newStep 123 | m.focusIndex++ 124 | } 125 | m.isEditMode = true 126 | m.textInput = initializeTextInput("", newStepPlaceholder, 60, goToStepsWithVal) 127 | 128 | case key.Matches(msg, Keys.Delete): 129 | if len(m.steps) > 0 { 130 | step := m.steps[m.focusIndex] 131 | step.Delete() 132 | m.steps = append(m.steps[:m.focusIndex], m.steps[m.focusIndex+1:]...) 133 | 134 | if m.focusIndex > 0 { 135 | m.focusIndex-- 136 | } 137 | } 138 | 139 | case key.Matches(msg, Keys.Toggle): 140 | if len(m.steps) > 0 { 141 | currStep := m.steps[m.focusIndex] 142 | currStep.IsFinished = !currStep.IsFinished 143 | currStep.Save() 144 | m.steps[m.focusIndex] = currStep 145 | } 146 | 147 | case key.Matches(msg, Keys.Edit): 148 | if len(m.steps) > 0 { 149 | m.isEditMode = true 150 | m.textInput = initializeTextInput(m.steps[m.focusIndex].Title, newStepPlaceholder, 60, goToStepsWithVal) 151 | } 152 | 153 | case key.Matches(msg, Keys.Enter): 154 | return m, goToFormWithVal("") 155 | } 156 | } 157 | return m, nil 158 | } 159 | 160 | func (m stepsEditor) View() string { 161 | var res []string 162 | 163 | for i, val := range m.steps { 164 | var value string 165 | 166 | if i == m.focusIndex && m.isEditMode { 167 | res = append(res, m.textInput.View()) 168 | continue 169 | } else if i == m.focusIndex { 170 | value = "» " 171 | } else { 172 | value = " " 173 | } 174 | 175 | if val.IsFinished { 176 | value += lipgloss.NewStyle().Strikethrough(true).Render(val.Title) 177 | res = append(res, value) 178 | } else { 179 | value += val.Title 180 | res = append(res, value) 181 | } 182 | } 183 | 184 | return lipgloss.JoinVertical(lipgloss.Left, res...) 185 | } 186 | -------------------------------------------------------------------------------- /tui/model_calendar.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | var ( 14 | weekDays = map[int]string{ 15 | 0: "Mo", 16 | 1: "Tu", 17 | 2: "We", 18 | 3: "Th", 19 | 4: "Fr", 20 | 5: "Sa", 21 | 6: "Su", 22 | } 23 | ) 24 | 25 | type calendar struct { 26 | selectedDate time.Time 27 | totalDays int 28 | startOffset int 29 | } 30 | 31 | func initializeCalender(selectedDate time.Time) calendar { 32 | c := calendar{} 33 | c.selectedDate = selectedDate 34 | 35 | //As per time package Sunday has 0 index, but in our arrangement Sunday appears at the end of the row with 7 index 36 | offset := int(c.selectedDate.AddDate(0, 0, -c.selectedDate.Day()+1).Weekday()) - int(time.Monday) 37 | 38 | if offset == -1 { 39 | c.startOffset = 6 40 | } else { 41 | c.startOffset = offset 42 | } 43 | 44 | c.totalDays = c.selectedDate.AddDate(0, 1, -c.selectedDate.Day()).Day() 45 | 46 | return c 47 | } 48 | 49 | func (c calendar) Init() tea.Cmd { 50 | return nil 51 | } 52 | 53 | func (c calendar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | switch msg := msg.(type) { 55 | 56 | case tea.KeyMsg: 57 | switch { 58 | case key.Matches(msg, Keys.Right): 59 | newDate := c.selectedDate.AddDate(0, 0, 1) 60 | return initializeCalender(newDate), nil 61 | 62 | case key.Matches(msg, Keys.Left): 63 | newDate := c.selectedDate.AddDate(0, 0, -1) 64 | return initializeCalender(newDate), nil 65 | 66 | case key.Matches(msg, Keys.Up): 67 | newDate := c.selectedDate.AddDate(0, 0, -7) 68 | return initializeCalender(newDate), nil 69 | 70 | case key.Matches(msg, Keys.Down): 71 | newDate := c.selectedDate.AddDate(0, 0, 7) 72 | return initializeCalender(newDate), nil 73 | 74 | } 75 | } 76 | return c, nil 77 | } 78 | 79 | func (c calendar) View() string { 80 | //Add month + year row 81 | monthRow := lipgloss.NewStyle().Padding(1, 0).Bold(true).Render(c.renderCalendarMonth()) 82 | 83 | //Add weekday row 84 | weekdayRow := lipgloss.NewStyle().Padding(0, 1).Render(c.renderWeekDays()) 85 | 86 | return lipgloss.JoinVertical(lipgloss.Center, monthRow, weekdayRow, c.renderCalender()) 87 | } 88 | 89 | func (c calendar) renderCalender() string { 90 | var output []string 91 | 92 | //Calculate ceiling 93 | rowCount := (c.totalDays + c.startOffset + (7 - 1)) / 7 94 | 95 | for rowIndex := 0; rowIndex < rowCount; rowIndex++ { 96 | renderedRow := c.renderCalendarRow(rowIndex) 97 | output = append(output, lipgloss.NewStyle().Padding(0, 1).Render(renderedRow)) 98 | } 99 | 100 | return lipgloss.JoinVertical(lipgloss.Top, output...) 101 | } 102 | 103 | func (c calendar) renderCalendarRow(rowIndex int) string { 104 | rowString := make([]string, 7) 105 | 106 | for colIndex := 1; colIndex <= 7; colIndex++ { 107 | value := c.getBoxValue(rowIndex, colIndex) 108 | 109 | if c.isCurrentDay(value) { 110 | rowString = append(rowString, c.renderBox(value, timeFocusColor, true)) 111 | } else { 112 | color := whiteColor 113 | if colIndex == 7 { 114 | color = unfocusedColor 115 | } 116 | 117 | rowString = append(rowString, c.renderBox(value, color, false)) 118 | } 119 | } 120 | 121 | return lipgloss.JoinHorizontal(lipgloss.Left, rowString...) 122 | } 123 | 124 | func (c calendar) renderWeekDays() string { 125 | rowString := make([]string, 7) 126 | 127 | for i := 0; i < 7; i++ { 128 | value := weekDays[i] 129 | color := whiteColor 130 | if i == 6 { 131 | color = unfocusedColor 132 | } 133 | 134 | rowString = append(rowString, c.renderBox(value, color, true)) 135 | } 136 | 137 | return lipgloss.JoinHorizontal(lipgloss.Left, rowString...) 138 | } 139 | 140 | func (c calendar) renderBox(val string, color lipgloss.Color, border bool) string { 141 | style := lipgloss.NewStyle(). 142 | Foreground(color). 143 | BorderForeground(color). 144 | Padding(0, 2) 145 | 146 | if border { 147 | style = style.Border(lipgloss.RoundedBorder()) 148 | } else { 149 | style = style.Border(lipgloss.HiddenBorder()) 150 | } 151 | 152 | return style.Render(val) 153 | } 154 | 155 | func (c calendar) getBoxValue(rowIndex int, colIndex int) string { 156 | value := colIndex + 7*rowIndex - c.startOffset 157 | if value <= 0 || value > c.totalDays { 158 | return " " 159 | } else { 160 | return fmt.Sprintf("%02d", value) 161 | } 162 | } 163 | 164 | func (c calendar) renderCalendarMonth() string { 165 | month := c.selectedDate.Month().String() 166 | year := strconv.Itoa(c.selectedDate.Year()) 167 | 168 | return month + " - " + year 169 | } 170 | 171 | func (c calendar) isCurrentDay(boxValue string) bool { 172 | selectedDay := c.selectedDate.Day() 173 | boxDay, _ := strconv.Atoi(boxValue) 174 | 175 | if selectedDay == boxDay { 176 | return true 177 | } else { 178 | return false 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tui/styles.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/table" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | // func FontColor(str, color string) string { 9 | // var term = termenv.ColorProfile() 10 | // return termenv.String(str).Foreground(term.Color(color)).Bold().String() 11 | // } 12 | 13 | var ( 14 | screenWidth int 15 | screenHeight int 16 | tableViewHeight = 25 17 | stackTableWidth = 26 //22: column width + 2*2: column padding 18 | taskTableWidth = 67 //59: column widths + 2*4: column paddings 19 | dash = "–" 20 | ) 21 | 22 | var ( 23 | stackBorderColor = lipgloss.Color("#019187") 24 | taskBorderColor = lipgloss.Color("#f1b44c") 25 | detailsBorderColor = lipgloss.Color("#6192bc") 26 | inputFormBorderColor = lipgloss.Color("#325b84") 27 | 28 | stackSelectionColor = lipgloss.Color("#019187") 29 | taskSelectionColor = lipgloss.Color("#f1b44c") 30 | detailsSelectionColor = lipgloss.Color("#333c4d") 31 | 32 | highlightedBackgroundColor = lipgloss.Color("#f97171") 33 | highlightedTextColor = lipgloss.Color("#4e4e4e") 34 | inputFormColor = lipgloss.Color("#5ac7c7") 35 | timeFocusColor = lipgloss.Color("#FFFF00") 36 | unfocusedColor = lipgloss.Color("#898989") 37 | whiteColor = lipgloss.Color("#ffffff") 38 | ) 39 | var ( 40 | selectedBoxStyle = lipgloss.NewStyle(). 41 | BorderStyle(lipgloss.ThickBorder()) 42 | 43 | selectedStackBoxStyle = selectedBoxStyle.Copy(). 44 | BorderForeground(stackBorderColor) 45 | 46 | selectedTaskBoxStyle = selectedBoxStyle.Copy(). 47 | BorderForeground(taskBorderColor) 48 | 49 | selectedDetailsBoxStyle = selectedBoxStyle.Copy(). 50 | BorderForeground(detailsBorderColor) 51 | 52 | unselectedBoxStyle = lipgloss.NewStyle(). 53 | BorderStyle(lipgloss.RoundedBorder()). 54 | BorderForeground(unfocusedColor) 55 | 56 | columnHeaderStyle = table.DefaultStyles().Header. 57 | BorderStyle(lipgloss.NormalBorder()). 58 | BorderForeground(unfocusedColor). 59 | BorderBottom(true). 60 | Bold(true) 61 | 62 | stackSelectedRowStyle = table.DefaultStyles().Selected. 63 | Foreground(highlightedTextColor). 64 | Background(stackSelectionColor). 65 | Bold(false) 66 | 67 | taskSelectedRowStyle = stackSelectedRowStyle.Copy(). 68 | Background(taskSelectionColor) 69 | 70 | footerInfoStyle = lipgloss.NewStyle(). 71 | Padding(0, 1). 72 | Background(lipgloss.Color("#1c2c4c")) 73 | 74 | footerContainerStyle = lipgloss.NewStyle(). 75 | Align(lipgloss.Center). 76 | Background(lipgloss.Color("#3e424b")) 77 | 78 | highlightedTextStyle = lipgloss.NewStyle(). 79 | Bold(true). 80 | Italic(true). 81 | Foreground(highlightedTextColor). 82 | Background(highlightedBackgroundColor). 83 | Padding(0, 1). 84 | MarginTop(1) 85 | 86 | inputFormStyle = lipgloss.NewStyle(). 87 | BorderStyle(lipgloss.ThickBorder()). 88 | BorderForeground(inputFormBorderColor). 89 | Padding(0, 1) 90 | 91 | textInputStyle = lipgloss.NewStyle().Foreground(inputFormColor) 92 | placeHolderStyle = lipgloss.NewStyle().Foreground(unfocusedColor) 93 | ) 94 | 95 | // Since width is dynamic, we have to append it to the style before usage 96 | 97 | func getInputFormStyle() lipgloss.Style { 98 | //Subtract 2 for padding on each side 99 | return inputFormStyle.Width(screenWidth - 2) 100 | } 101 | 102 | func getTableStyle(tableType string) table.Styles { 103 | s := table.DefaultStyles() 104 | s.Header = columnHeaderStyle 105 | 106 | switch tableType { 107 | case "stack": 108 | s.Selected = stackSelectedRowStyle 109 | case "task": 110 | s.Selected = taskSelectedRowStyle 111 | } 112 | 113 | return s 114 | } 115 | 116 | func getEmptyTaskStyle() lipgloss.Style { 117 | return lipgloss.NewStyle(). 118 | AlignHorizontal(lipgloss.Center). 119 | AlignVertical(lipgloss.Center). 120 | Width(60). 121 | Height(tableViewHeight + 3) //3 is added to account for header & footer height 122 | } 123 | 124 | func getEmptyDetailsStyle() lipgloss.Style { 125 | return getDetailsBoxStyle(). 126 | Height(tableViewHeight + 3). 127 | AlignHorizontal(lipgloss.Center). 128 | AlignVertical(lipgloss.Center) 129 | } 130 | 131 | func getDetailsBoxWidth() int { 132 | return screenWidth - (stackTableWidth + taskTableWidth + 3*2) //each of the 3 boxes have left & right borders 133 | } 134 | func getDetailsBoxHeight() int { 135 | return tableViewHeight 136 | } 137 | 138 | func getDetailsBoxStyle() lipgloss.Style { 139 | return lipgloss.NewStyle(). 140 | Width(getDetailsBoxWidth()). 141 | Height(tableViewHeight + 2) 142 | } 143 | 144 | func getDetailsItemStyle(isSelected bool) lipgloss.Style { 145 | style := lipgloss.NewStyle(). 146 | Padding(0, 0, 1, 0). 147 | Width(getDetailsBoxWidth() - 2) 148 | 149 | if isSelected { 150 | style.Background(detailsSelectionColor) 151 | } 152 | 153 | return style 154 | } 155 | 156 | // Applying padding (0,1) to detail items causes issue with description text alignment 157 | // To avoid that an additional container is used for detail items 158 | func getItemContainerStyle(isSelected bool) lipgloss.Style { 159 | style := lipgloss.NewStyle(). 160 | Padding(0, 1). 161 | Width(getDetailsBoxWidth()) 162 | 163 | if isSelected { 164 | style.Background(detailsSelectionColor) 165 | } 166 | 167 | return style 168 | } 169 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 6 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 7 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 8 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 9 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 10 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 11 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 12 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 14 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 15 | github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 16 | github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 17 | github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= 18 | github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= 19 | github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= 20 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 21 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 22 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 23 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 24 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 25 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 26 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 27 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 28 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 29 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 31 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 32 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 33 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 34 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 35 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 36 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 37 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 38 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 39 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 40 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 41 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 42 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 43 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 44 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 45 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 46 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 47 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 48 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 49 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 50 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 51 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= 54 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 56 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 57 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 58 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 59 | gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= 60 | gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 61 | modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= 62 | modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 63 | modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= 64 | modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 65 | modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= 66 | modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 67 | modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= 68 | modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= 69 | -------------------------------------------------------------------------------- /tui/table_utils.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/BOTbkcd/mayhem/entities" 11 | 12 | "github.com/charmbracelet/bubbles/key" 13 | "github.com/charmbracelet/bubbles/table" 14 | ) 15 | 16 | var stackKeys = keyMap{ 17 | New: key.NewBinding( 18 | key.WithKeys("n"), 19 | key.WithHelp("'n'", "new stack 🌟"), 20 | ), 21 | Edit: key.NewBinding( 22 | key.WithKeys("e"), 23 | key.WithHelp("'e'", "edit 📝"), 24 | ), 25 | Delete: key.NewBinding( 26 | key.WithKeys("x"), 27 | key.WithHelp("'x'", "delete 🗑"), 28 | ), 29 | } 30 | 31 | var taskKeys = keyMap{ 32 | Toggle: key.NewBinding( 33 | key.WithKeys("tab"), 34 | key.WithHelp("'tab'", "check/uncheck 🔄"), 35 | ), 36 | New: key.NewBinding( 37 | key.WithKeys("n"), 38 | key.WithHelp("'n'", "new task 🌟"), 39 | ), 40 | NewRecur: key.NewBinding( 41 | key.WithKeys("r"), 42 | key.WithHelp("'r'", "new recurring task 🌟"), 43 | ), 44 | Edit: key.NewBinding( 45 | key.WithKeys("e"), 46 | key.WithHelp("'e'", "edit 📝"), 47 | ), 48 | Delete: key.NewBinding( 49 | key.WithKeys("x"), 50 | key.WithHelp("'x'", "delete 🗑"), 51 | ), 52 | Move: key.NewBinding( 53 | key.WithKeys("m"), 54 | key.WithHelp("'m'", "change stack 📤"), 55 | ), 56 | } 57 | 58 | var tableNavigationKeys = keyMap{ 59 | Up: key.NewBinding( 60 | key.WithKeys("up", "k"), 61 | key.WithHelp("'↑/k'", "up"), 62 | ), 63 | Down: key.NewBinding( 64 | key.WithKeys("down", "j"), 65 | key.WithHelp("'↓/j'", "down"), 66 | ), 67 | GotoTop: key.NewBinding( 68 | key.WithKeys("g"), 69 | key.WithHelp("'g'", "jump to top"), 70 | ), 71 | GotoBottom: key.NewBinding( 72 | key.WithKeys("G"), 73 | key.WithHelp("'G'", "jump to bottom"), 74 | ), 75 | Left: key.NewBinding( 76 | key.WithKeys("left", "h"), 77 | key.WithHelp("'←/h'", "left"), 78 | ), 79 | Right: key.NewBinding( 80 | key.WithKeys("right", "l"), 81 | key.WithHelp("'→/l'", "right"), 82 | ), 83 | Help: key.NewBinding( 84 | key.WithKeys("?"), 85 | key.WithHelp("'?'", "toggle help"), 86 | ), 87 | Quit: key.NewBinding( 88 | key.WithKeys("q"), 89 | key.WithHelp("'q'", "quit"), 90 | ), 91 | } 92 | 93 | var taskFinishStatus = map[uint]bool{} 94 | var recurDeadlines = map[uint]time.Time{} 95 | 96 | func stackColumns() []table.Column { 97 | return []table.Column{ 98 | {Title: " Stacks 🗃", Width: 20}, 99 | {Title: "", Width: 2}, 100 | } 101 | } 102 | 103 | func taskColumns() []table.Column { 104 | return []table.Column{ 105 | {Title: "", Width: 1}, 106 | {Title: " Tasks 📄", Width: 30}, 107 | {Title: " Deadline 🕑", Width: 20}, 108 | {Title: "Priority", Width: 8}, 109 | } 110 | } 111 | 112 | func stackRows(stacks []entities.Stack) []table.Row { 113 | rows := make([]table.Row, len(stacks)) 114 | 115 | sortStacks(stacks) 116 | 117 | for i, val := range stacks { 118 | row := []string{ 119 | val.Title, 120 | incompleteTaskTag(val.PendingTaskCount + val.PendingRecurringCount()), 121 | } 122 | rows[i] = row 123 | } 124 | return rows 125 | } 126 | 127 | func taskRows(tasks []entities.Task) []table.Row { 128 | rows := make([]table.Row, len(tasks)) 129 | 130 | // We perform this step earlier since we need the deadline & finish status data before sorting 131 | for _, val := range tasks { 132 | if val.IsRecurring { 133 | r, count := val.LatestRecurTask() 134 | if count > 0 { 135 | recurDeadlines[val.ID] = r.Deadline 136 | taskFinishStatus[val.ID] = r.IsFinished 137 | } 138 | } else { 139 | taskFinishStatus[val.ID] = val.IsFinished 140 | } 141 | } 142 | 143 | sortTasks(tasks) 144 | 145 | var prefix string 146 | var deadline string 147 | 148 | for i, val := range tasks { 149 | if val.IsRecurring { 150 | deadline = formatTime(recurDeadlines[val.ID], true) 151 | // prefix = "𝑹" 152 | // prefix = "🅁 Ⓡ 🄬" 153 | } else { 154 | deadline = formatTime(val.Deadline, true) 155 | } 156 | 157 | if taskFinishStatus[val.ID] { 158 | prefix = "✘" 159 | } else { 160 | prefix = "▢" 161 | } 162 | 163 | row := []string{ 164 | prefix, 165 | val.Title, 166 | deadline, 167 | " " + strconv.Itoa(val.Priority), 168 | } 169 | 170 | if val.IsRecurring { 171 | row[3] = row[3] + " 📌" 172 | } 173 | 174 | rows[i] = row 175 | } 176 | 177 | return rows 178 | } 179 | 180 | func sortStacks(s []entities.Stack) { 181 | //Alphabetically sort by stack title 182 | sort.Slice(s, func(i, j int) bool { 183 | return strings.ToLower(s[i].Title) < strings.ToLower(s[j].Title) 184 | }) 185 | } 186 | 187 | func sortTasks(t []entities.Task) { 188 | //Sort by finish status, then deadline, then priority, then title 189 | sort.Slice(t, func(i, j int) bool { 190 | if taskFinishStatus[t[i].ID] == taskFinishStatus[t[j].ID] { 191 | var deadline_i time.Time 192 | if t[i].IsRecurring { 193 | deadline_i = recurDeadlines[t[i].ID] 194 | } else { 195 | deadline_i = t[i].Deadline 196 | } 197 | 198 | var deadline_j time.Time 199 | if t[j].IsRecurring { 200 | deadline_j = recurDeadlines[t[j].ID] 201 | } else { 202 | deadline_j = t[j].Deadline 203 | } 204 | 205 | if deadline_i.Equal(deadline_j) { 206 | if t[i].Priority == t[j].Priority { 207 | return strings.ToLower(t[i].Title) < strings.ToLower(t[j].Title) 208 | } 209 | return t[i].Priority > t[j].Priority 210 | } 211 | 212 | if deadline_i.IsZero() { 213 | return false 214 | } 215 | 216 | if deadline_j.IsZero() { 217 | return true 218 | } 219 | 220 | return deadline_i.Before(deadline_j) 221 | } else { 222 | return !taskFinishStatus[t[i].ID] 223 | } 224 | }) 225 | } 226 | 227 | func buildTable(columns []table.Column, tableType string) table.Model { 228 | t := table.New( 229 | table.WithHeight(tableViewHeight), 230 | table.WithColumns(columns), 231 | table.WithKeyMap(table.DefaultKeyMap()), 232 | // table.WithFocused(true), 233 | ) 234 | 235 | s := getTableStyle(tableType) 236 | t.SetStyles(s) 237 | 238 | return t 239 | } 240 | 241 | func formatTime(time time.Time, fullDate bool) string { 242 | if time.IsZero() { 243 | return fmt.Sprintf("%10s", dash) 244 | } 245 | 246 | year := fmt.Sprintf("%04d", time.Year()) 247 | month := fmt.Sprintf("%02d", int(time.Month())) 248 | days := fmt.Sprintf("%02d", time.Day()) 249 | hours := fmt.Sprintf("%02d", formatHour(time.Hour())) 250 | minutes := fmt.Sprintf("%02d", time.Minute()) 251 | midDayInfo := renderMidDayInfo(time.Hour()) 252 | 253 | if fullDate { 254 | return days + "-" + month + "-" + year + " " + hours + ":" + minutes + " " + midDayInfo 255 | } else { 256 | return hours + ":" + minutes + " " + midDayInfo 257 | } 258 | 259 | } 260 | 261 | func getEmptyTaskView() string { 262 | return getEmptyTaskStyle().Render("Press either '→' or 'l' key to explore this stack") 263 | } 264 | 265 | func getEmptyDetailsView() string { 266 | return getEmptyDetailsStyle().Render("Press either '→' or 'l' key to see task details") 267 | } 268 | 269 | func incompleteTaskTag(count int) string { 270 | if count > 0 && count <= 10 { 271 | return " " + string(rune('➊'+count-1)) 272 | } else if count > 10 { 273 | return "+➓" 274 | } else { 275 | return "" 276 | } 277 | } 278 | 279 | func min(x int, y int) int { 280 | if x < y { 281 | return x 282 | } else { 283 | return y 284 | } 285 | } 286 | 287 | func findStackIndex(arr []entities.Stack, id uint) int { 288 | for i, val := range arr { 289 | if val.ID == id { 290 | return i 291 | } 292 | } 293 | return -1 294 | } 295 | 296 | func findTaskIndex(arr []entities.Task, id uint) int { 297 | for i, val := range arr { 298 | if val.ID == id { 299 | return i 300 | } 301 | } 302 | return -1 303 | } 304 | -------------------------------------------------------------------------------- /tui/model_time_picker.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/key" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | // textinput.Model doesn't implement tea.Model interface 13 | type timePicker struct { 14 | currTime time.Time 15 | focusIndex int 16 | isDurationPicker bool //Show all fields, else only show days 17 | isMomentPicker bool //Show only min+hr fields 18 | dayCount int //Used in duration picker mode 19 | } 20 | 21 | type timeUnit struct { 22 | title string 23 | tag string 24 | charWidth int 25 | } 26 | 27 | var timePickerKeys = keyMap{ 28 | Up: key.NewBinding( 29 | key.WithKeys("up", "k"), 30 | key.WithHelp("'↑/k'", "increase"), 31 | ), 32 | Down: key.NewBinding( 33 | key.WithKeys("down", "j"), 34 | key.WithHelp("'↓/j'", "decrease"), 35 | ), 36 | Left: key.NewBinding( 37 | key.WithKeys("left", "h"), 38 | key.WithHelp("'←/h'", "move left"), 39 | ), 40 | Right: key.NewBinding( 41 | key.WithKeys("right", "l"), 42 | key.WithHelp("'→/l'", "move right"), 43 | ), 44 | Enter: key.NewBinding( 45 | key.WithKeys("enter"), 46 | key.WithHelp("'enter'", "save"), 47 | ), 48 | Return: key.NewBinding( 49 | key.WithKeys("esc"), 50 | key.WithHelp("'esc'", "return"), 51 | ), 52 | } 53 | 54 | var timeUnitMap = map[int]timeUnit{ 55 | 0: { 56 | title: "Hour", 57 | tag: "hh", 58 | charWidth: 2, 59 | }, 60 | 1: { 61 | title: "Minute", 62 | tag: "mm", 63 | charWidth: 2, 64 | }, 65 | 2: { 66 | title: "Day", 67 | tag: "DD", 68 | charWidth: 2, 69 | }, 70 | 3: { 71 | title: "Month", 72 | tag: "MM", 73 | charWidth: 2, 74 | }, 75 | 4: { 76 | title: "Year", 77 | tag: "YYYY", 78 | charWidth: 4, 79 | }, 80 | } 81 | 82 | func initializeTimePicker(currTime time.Time) tea.Model { 83 | t := timePicker{ 84 | currTime: currTime, 85 | } 86 | 87 | return t 88 | } 89 | 90 | func initializeDurationPicker(recurrenceInterval int) tea.Model { 91 | t := timePicker{ 92 | dayCount: recurrenceInterval, 93 | isDurationPicker: true, 94 | focusIndex: 2, 95 | } 96 | 97 | return t 98 | } 99 | 100 | func initializeMomentPicker(currTime time.Time) tea.Model { 101 | t := timePicker{ 102 | currTime: currTime, 103 | isMomentPicker: true, 104 | } 105 | 106 | return t 107 | } 108 | 109 | func (m timePicker) Init() tea.Cmd { 110 | return nil 111 | } 112 | 113 | func (m timePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 114 | switch msg := msg.(type) { 115 | 116 | case tea.KeyMsg: 117 | switch { 118 | 119 | case key.Matches(msg, Keys.Up): 120 | if m.isDurationPicker { 121 | m.dayCount++ 122 | } else { 123 | switch m.focusIndex { 124 | case 0: 125 | hourDuration, _ := time.ParseDuration("60m") 126 | m.currTime = m.currTime.Add(hourDuration) 127 | case 1: 128 | minuteDuration, _ := time.ParseDuration("1m") 129 | m.currTime = m.currTime.Add(minuteDuration) 130 | case 2: 131 | m.currTime = m.currTime.AddDate(0, 0, 1) 132 | case 3: 133 | m.currTime = m.currTime.AddDate(0, 1, 0) 134 | case 4: 135 | m.currTime = m.currTime.AddDate(1, 0, 0) 136 | } 137 | } 138 | return m, nil 139 | 140 | case key.Matches(msg, Keys.Down): 141 | if m.isDurationPicker { 142 | if m.dayCount > 1 { 143 | m.dayCount-- 144 | } 145 | } else { 146 | switch m.focusIndex { 147 | case 0: 148 | hourDuration, _ := time.ParseDuration("60m") 149 | m.currTime = m.currTime.Add(-hourDuration) 150 | case 1: 151 | minuteDuration, _ := time.ParseDuration("1m") 152 | m.currTime = m.currTime.Add(-minuteDuration) 153 | case 2: 154 | m.currTime = m.currTime.AddDate(0, 0, -1) 155 | case 3: 156 | m.currTime = m.currTime.AddDate(0, -1, 0) 157 | case 4: 158 | m.currTime = m.currTime.AddDate(-1, 0, 0) 159 | } 160 | } 161 | return m, nil 162 | 163 | case key.Matches(msg, Keys.Right): 164 | if !m.isDurationPicker { 165 | if m.focusIndex < len(timeUnitMap)-1 { 166 | m.focusIndex++ 167 | } 168 | return m, nil 169 | } 170 | 171 | case key.Matches(msg, Keys.Left): 172 | if !m.isDurationPicker { 173 | if m.focusIndex > 0 { 174 | m.focusIndex-- 175 | } 176 | return m, nil 177 | } 178 | case key.Matches(msg, Keys.Enter): 179 | if m.isDurationPicker { 180 | return m, goToFormWithVal(m.dayCount) 181 | } else { 182 | return m, goToFormWithVal(m.currTime) 183 | } 184 | } 185 | } 186 | return m, nil 187 | } 188 | 189 | func (m timePicker) View() string { 190 | var timeUnitLabel string 191 | var timeValue string 192 | 193 | if m.isDurationPicker { 194 | return lipgloss.JoinHorizontal(lipgloss.Center, m.renderUnitCol(2, m.dayCount), " Day(s)") 195 | } else if m.isMomentPicker { 196 | timeUnitLabel = lipgloss.JoinHorizontal(lipgloss.Center, 197 | m.renderUnitTag(0), 198 | " ", 199 | m.renderUnitTag(1), 200 | " ", 201 | " ", 202 | ) 203 | 204 | timeValue = lipgloss.JoinHorizontal(lipgloss.Center, 205 | m.renderUnitCol(0, formatHour(m.currTime.Hour())), 206 | ":", 207 | m.renderUnitCol(1, m.currTime.Minute()), 208 | " ", 209 | renderMidDayInfo(m.currTime.Hour()), 210 | ) 211 | 212 | return lipgloss.JoinVertical(lipgloss.Center, 213 | timeValue, 214 | timeUnitLabel, 215 | ) 216 | 217 | } else { 218 | //Empty spaces are added to align the label and value rows 219 | timeUnitLabel = lipgloss.JoinHorizontal(lipgloss.Center, 220 | m.renderUnitTag(0), 221 | " ", 222 | m.renderUnitTag(1), 223 | " ", 224 | " ", 225 | " ", 226 | m.renderUnitTag(2), 227 | " ", 228 | m.renderUnitTag(3), 229 | " ", 230 | m.renderUnitTag(4), 231 | ) 232 | 233 | timeValue = lipgloss.JoinHorizontal(lipgloss.Center, 234 | m.renderUnitCol(0, formatHour(m.currTime.Hour())), 235 | ":", 236 | m.renderUnitCol(1, m.currTime.Minute()), 237 | " ", 238 | renderMidDayInfo(m.currTime.Hour()), 239 | " ", 240 | m.renderUnitCol(2, m.currTime.Day()), 241 | "-", 242 | m.renderUnitCol(3, int(m.currTime.Month())), 243 | "-", 244 | m.renderUnitCol(4, m.currTime.Year())) 245 | 246 | return lipgloss.JoinVertical(lipgloss.Center, 247 | timeValue, 248 | timeUnitLabel, 249 | ) 250 | } 251 | 252 | } 253 | 254 | func (m timePicker) renderUnitCol(index int, val int) string { 255 | value := fmt.Sprintf("%0*d", timeUnitMap[index].charWidth, val) 256 | 257 | var color lipgloss.Color 258 | if m.focusIndex == index { 259 | color = timeFocusColor 260 | } else { 261 | color = unfocusedColor 262 | } 263 | 264 | style := lipgloss.NewStyle(). 265 | Foreground(color). 266 | BorderForeground(color). 267 | Border(lipgloss.RoundedBorder()). 268 | Padding(0, 1) 269 | 270 | return style.Render(value) 271 | } 272 | 273 | func (m timePicker) renderUnitTag(index int) string { 274 | value := timeUnitMap[index].tag 275 | 276 | var color lipgloss.Color 277 | if m.focusIndex == index { 278 | color = timeFocusColor 279 | } else { 280 | color = unfocusedColor 281 | } 282 | 283 | style := lipgloss.NewStyle(). 284 | Foreground(color). 285 | Padding(0, 2) 286 | 287 | return style.Render(value) 288 | } 289 | 290 | func renderMidDayInfo(hours int) string { 291 | if isBeforeMidDay(hours) { 292 | return "am" 293 | } else { 294 | return "pm" 295 | } 296 | } 297 | 298 | // Adjust Hour value to 12 hour clock format 299 | func formatHour(value int) int { 300 | if value > 12 { 301 | return value - 12 302 | } else { 303 | return value 304 | } 305 | } 306 | 307 | func isBeforeMidDay(value int) bool { 308 | if value >= 12 { 309 | return false 310 | } else { 311 | return true 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /tui/model_input_form.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/BOTbkcd/mayhem/entities" 9 | 10 | "github.com/charmbracelet/bubbles/key" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | type inputForm struct { 16 | focusIndex int 17 | data entities.Entity 18 | dataType string 19 | fieldMap map[int]field 20 | isInvalid bool 21 | invalidPrompt string 22 | isNewTask bool 23 | helpKeys keyMap 24 | } 25 | 26 | type field struct { 27 | name string 28 | prompt string 29 | model tea.Model 30 | isRequired bool 31 | nilValue string 32 | validationPrompt string 33 | helpKeys keyMap 34 | } 35 | 36 | var ( 37 | stackFields map[int]field = map[int]field{ 38 | 0: { 39 | name: "Title", 40 | prompt: "Stack Title", 41 | isRequired: true, 42 | nilValue: "", 43 | helpKeys: textInputKeys, 44 | validationPrompt: "Stack title field can not be empty❗", 45 | }, 46 | } 47 | 48 | taskFields map[int]field = map[int]field{ 49 | 0: { 50 | name: "Title", 51 | prompt: "Task Title", 52 | isRequired: true, 53 | nilValue: "", 54 | helpKeys: textInputKeys, 55 | validationPrompt: "Task title field can not be empty❗", 56 | }, 57 | 1: { 58 | name: "Description", 59 | prompt: "Task Description", 60 | helpKeys: textAreaKeys, 61 | }, 62 | 2: { 63 | name: "Steps", 64 | prompt: "Task Steps", 65 | helpKeys: stepsEditorKeys, 66 | }, 67 | 3: { 68 | name: "Priority", 69 | prompt: "Task Priority", 70 | helpKeys: listSelectorKeys, 71 | }, 72 | 4: { 73 | name: "Deadline", 74 | prompt: "Task Deadline", 75 | helpKeys: timePickerKeys, 76 | }, 77 | 5: { 78 | name: "StartAt", 79 | prompt: "Task Start Time", 80 | helpKeys: timePickerKeys, 81 | }, 82 | 6: { 83 | name: "RecurrenceInterval", 84 | prompt: "Task Recurrence Interval", 85 | helpKeys: timePickerKeys, 86 | }, 87 | } 88 | ) 89 | 90 | var ( 91 | StackFieldIndex map[string]int = map[string]int{ 92 | "Title": 0, 93 | } 94 | 95 | TaskFieldIndex map[string]int = map[string]int{ 96 | "Title": 0, 97 | "Description": 1, 98 | "Steps": 2, 99 | "Priority": 3, 100 | "Deadline": 4, 101 | "StartAt": 5, 102 | "RecurrenceInterval": 6, 103 | } 104 | ) 105 | 106 | func initializeInput(selectedTable string, data entities.Entity, fieldIndex int) inputForm { 107 | var m inputForm 108 | if selectedTable == "stack" { 109 | m = inputForm{ 110 | data: data, 111 | focusIndex: fieldIndex, 112 | dataType: "stack", 113 | fieldMap: stackFields, 114 | } 115 | 116 | targetField := m.fieldMap[fieldIndex] 117 | stack := data.(entities.Stack) 118 | 119 | switch fieldIndex { 120 | case 0: 121 | targetField.model = initializeTextInput(stack.Title, "", 20, goToFormWithVal) 122 | } 123 | 124 | m.helpKeys = targetField.helpKeys 125 | m.fieldMap[fieldIndex] = targetField 126 | 127 | } else { 128 | m = inputForm{ 129 | data: data, 130 | focusIndex: fieldIndex, 131 | fieldMap: taskFields, 132 | dataType: "task", 133 | } 134 | 135 | targetField := m.fieldMap[fieldIndex] 136 | task := data.(entities.Task) 137 | 138 | switch fieldIndex { 139 | case 0: 140 | targetField.model = initializeTextInput(task.Title, "", 60, goToFormWithVal) 141 | case 1: 142 | targetField.model = initializeTextArea(task.Description) 143 | case 2: 144 | targetField.model = initializeStepsEditor(task.Steps, task.ID) 145 | case 3: 146 | opts := []keyVal{ 147 | {val: "0"}, 148 | {val: "1"}, 149 | {val: "2"}, 150 | } 151 | targetField.model = initializeListSelector(opts, strconv.Itoa(task.Priority), goToFormWithVal) 152 | case 4: 153 | if task.Deadline.IsZero() { 154 | currDate := time.Now().String()[0:10] 155 | startOfToday, _ := time.Parse(time.DateOnly, currDate) 156 | targetField.model = initializeTimePicker(startOfToday) 157 | } else { 158 | targetField.model = initializeTimePicker(task.Deadline) 159 | } 160 | case 5: 161 | targetField.model = initializeMomentPicker(task.StartTime) 162 | case 6: 163 | targetField.model = initializeDurationPicker(task.RecurrenceInterval) 164 | } 165 | m.helpKeys = targetField.helpKeys 166 | m.fieldMap[fieldIndex] = targetField 167 | } 168 | 169 | return m 170 | } 171 | 172 | func (m inputForm) Init() tea.Cmd { 173 | return nil 174 | } 175 | 176 | func (m inputForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 177 | 178 | //Transfer control to selectModel's Update method 179 | switch msg := msg.(type) { 180 | 181 | case tea.KeyMsg: 182 | switch { 183 | case key.Matches(msg, Keys.Return): 184 | if m.focusIndex == TaskFieldIndex["Steps"] { 185 | //In case of steps editor the steps are saved at the time of editing itself, 186 | //so returning from steps editor should update the data 187 | return m, goToMainWithVal("refresh") 188 | } else { 189 | return m, goToMainCmd 190 | } 191 | 192 | case key.Matches(msg, Keys.Exit): 193 | return m, tea.Quit 194 | } 195 | 196 | case goToFormMsg: 197 | selectedValue := msg.value 198 | 199 | if (m.fieldMap[m.focusIndex].isRequired) && (selectedValue == m.fieldMap[m.focusIndex].nilValue) { 200 | m.isInvalid = true 201 | m.invalidPrompt = m.fieldMap[m.focusIndex].validationPrompt 202 | return m, nil 203 | } else { 204 | m.isInvalid = false 205 | } 206 | 207 | if m.dataType == "stack" { 208 | stack := m.data.(entities.Stack) 209 | 210 | switch m.focusIndex { 211 | case 0: 212 | stack.Title = selectedValue.(string) 213 | } 214 | 215 | stack.Save() 216 | 217 | } else if m.dataType == "task" { 218 | task := m.data.(entities.Task) 219 | 220 | switch m.focusIndex { 221 | case 0: 222 | task.Title = selectedValue.(string) 223 | 224 | if task.CreatedAt.IsZero() { 225 | m.isNewTask = true 226 | } 227 | 228 | case 1: 229 | task.Description = selectedValue.(string) 230 | case 2: 231 | // We save tasks independently (in steps-editor itself) & not as task associations 232 | return m, goToMainWithVal("refresh") 233 | case 3: 234 | task.Priority, _ = strconv.Atoi(selectedValue.(keyVal).val) 235 | case 4: 236 | oldDeadline := task.Deadline 237 | task.Deadline = selectedValue.(time.Time) 238 | if task.IsRecurring { 239 | spawnRecurTasks(task, oldDeadline) 240 | } 241 | 242 | case 5: 243 | prevTime := task.StartTime 244 | newTime := selectedValue.(time.Time) 245 | task.StartTime = time.Date(prevTime.Year(), prevTime.Month(), prevTime.Day(), newTime.Hour(), newTime.Minute(), 0, 0, prevTime.Location()) 246 | spawnRecurTasks(task, task.Deadline) 247 | 248 | case 6: 249 | task.RecurrenceInterval = selectedValue.(int) 250 | spawnRecurTasks(task, task.Deadline) 251 | } 252 | 253 | task = task.Save().(entities.Task) 254 | 255 | if m.isNewTask { 256 | if task.IsRecurring { 257 | recurTask := entities.RecurTask{ 258 | StackID: task.StackID, 259 | TaskID: task.ID, 260 | Deadline: task.StartTime, 261 | } 262 | recurTask.Save() 263 | } else { 264 | entities.IncPendingCount(task.StackID) 265 | } 266 | } 267 | } 268 | 269 | return m, goToMainWithVal("refresh") 270 | } 271 | 272 | // Placing it outside KeyMsg case is required, otherwise messages like textinput's Blink will be lost 273 | var cmd tea.Cmd 274 | inputField := m.fieldMap[m.focusIndex] 275 | inputField.model, cmd = m.fieldMap[m.focusIndex].model.Update(msg) 276 | m.fieldMap[m.focusIndex] = inputField 277 | 278 | return m, cmd 279 | } 280 | 281 | func (m inputForm) View() string { 282 | var b strings.Builder 283 | 284 | //ADD changes for invalid input case 285 | 286 | b.WriteString(highlightedTextStyle.Render(m.fieldMap[m.focusIndex].prompt)) 287 | 288 | if m.isInvalid { 289 | b.WriteString(lipgloss.NewStyle().Foreground(highlightedBackgroundColor).Render(" **" + m.invalidPrompt)) 290 | } 291 | 292 | b.WriteRune('\n') 293 | b.WriteRune('\n') 294 | 295 | b.WriteString(m.fieldMap[m.focusIndex].model.View()) 296 | b.WriteRune('\n') 297 | 298 | // blurredButton := fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) 299 | // focusedButton := focusedStyle.Copy().Render("[ Submit ]") 300 | 301 | // var button *string 302 | // if m.focusIndex == len(m.fieldMap) { 303 | // button = &focusedButton 304 | // } else { 305 | // button = &blurredButton 306 | // } 307 | 308 | // fmt.Fprintf(&b, "\n\n%s\n\n", *button) 309 | 310 | return b.String() 311 | } 312 | 313 | func spawnRecurTasks(task entities.Task, oldDeadline time.Time) { 314 | if task.Deadline.Before(time.Now()) { 315 | return 316 | } 317 | 318 | r, _ := task.LatestRecurTask() 319 | 320 | //Delete all recur tasks from now 321 | task.RemoveFutureRecurTasks() 322 | 323 | var startTime time.Time 324 | t := time.Now() 325 | 326 | if t.Before(oldDeadline) { 327 | startTime = time.Date(r.Deadline.Year(), r.Deadline.Month(), r.Deadline.Day(), task.StartTime.Hour(), task.StartTime.Minute(), 0, 0, task.StartTime.Location()) 328 | } else { 329 | startTime = time.Date(t.Year(), t.Month(), t.Day(), task.StartTime.Hour(), task.StartTime.Minute(), 0, 0, task.StartTime.Location()) 330 | } 331 | 332 | for startTime.Compare(task.Deadline) <= 0 { 333 | recurTask := entities.RecurTask{ 334 | TaskID: task.ID, 335 | StackID: task.StackID, 336 | IsFinished: false, 337 | Deadline: startTime, 338 | } 339 | recurTask.Save() 340 | 341 | startTime = startTime.AddDate(0, 0, task.RecurrenceInterval) 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /tui/model_details_box.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/BOTbkcd/mayhem/entities" 9 | 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/viewport" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | type detailsBox struct { 17 | taskData entities.Task 18 | viewport viewport.Model 19 | preserveOffset bool 20 | oldViewportOffset int 21 | focusIndex int 22 | isBoxFocused bool 23 | isRecurrenceDuration bool 24 | scrollData scrollData 25 | } 26 | 27 | type scrollData struct { 28 | title int 29 | description int 30 | steps int 31 | priority int 32 | deadline int 33 | startTime int 34 | recurrenceInterval int 35 | } 36 | 37 | var taskDetailsKeys = keyMap{ 38 | Edit: key.NewBinding( 39 | key.WithKeys("e"), 40 | key.WithHelp("'e'", "edit field 📝"), 41 | ), 42 | // Toggle: key.NewBinding( 43 | // key.WithKeys("tab"), 44 | // key.WithHelp("'tab'", "next 🔽"), 45 | // ), 46 | // ReverseToggle: key.NewBinding( 47 | // key.WithKeys("shift+tab"), 48 | // key.WithHelp("'shift+tab'", "previous 🔼"), 49 | // ), 50 | } 51 | 52 | var detailsNavigationKeys = keyMap{ 53 | Up: key.NewBinding( 54 | key.WithKeys("up", "k"), 55 | key.WithHelp("'↑/k'", "up"), 56 | ), 57 | Down: key.NewBinding( 58 | key.WithKeys("down", "j"), 59 | key.WithHelp("'↓/j'", "down"), 60 | ), 61 | GotoTop: key.NewBinding( 62 | key.WithKeys("g"), 63 | key.WithHelp("'g'", "jump to top"), 64 | ), 65 | GotoBottom: key.NewBinding( 66 | key.WithKeys("G"), 67 | key.WithHelp("'G'", "jump to bottom"), 68 | ), 69 | Help: key.NewBinding( 70 | key.WithKeys("?"), 71 | key.WithHelp("'?'", "toggle help"), 72 | ), 73 | Quit: key.NewBinding( 74 | key.WithKeys("q"), 75 | key.WithHelp("'q'", "quit"), 76 | ), 77 | } 78 | 79 | func (m *detailsBox) buildDetailsBox(data entities.Task, preserveOffset bool) { 80 | m.taskData = data 81 | 82 | //We want to preserve offset when we return to same details view after editing any field 83 | //But when going from one task to another, we want to reset the view 84 | m.preserveOffset = preserveOffset 85 | m.oldViewportOffset = m.viewport.YOffset 86 | m.viewport = viewport.New(getDetailsBoxWidth(), tableViewHeight) 87 | m.renderContent() 88 | } 89 | 90 | func (m detailsBox) Init() tea.Cmd { 91 | return nil 92 | } 93 | 94 | func (m detailsBox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 95 | if !m.isBoxFocused { 96 | return m, nil 97 | } 98 | 99 | m.viewport.Width = getDetailsBoxWidth() 100 | 101 | switch msg := msg.(type) { 102 | 103 | case tea.KeyMsg: 104 | switch { 105 | 106 | case key.Matches(msg, Keys.Up): 107 | var scrollDistance int 108 | switch m.focusIndex { 109 | case 0: 110 | m.viewport.GotoBottom() 111 | m.End() 112 | return m, nil 113 | case 1: 114 | scrollDistance = m.scrollData.description 115 | m.Previous() 116 | case 2: 117 | scrollDistance = m.scrollData.steps 118 | m.Previous() 119 | case 3: 120 | scrollDistance = m.scrollData.priority 121 | m.Previous() 122 | case 4: 123 | if m.taskData.IsRecurring { 124 | scrollDistance = m.scrollData.deadline 125 | } 126 | m.Previous() 127 | case 5: 128 | scrollDistance = m.scrollData.startTime 129 | m.Previous() 130 | case 6: 131 | // scrollDistance = m.scrollData.recurrenceInterval 132 | m.Previous() 133 | } 134 | 135 | m.viewport.LineUp(scrollDistance) 136 | 137 | case key.Matches(msg, Keys.Down): 138 | var scrollDistance int 139 | switch m.focusIndex { 140 | case 0: 141 | // scrollDistance = m.scrollData.title 142 | m.Next() 143 | case 1: 144 | scrollDistance = m.scrollData.description 145 | m.Next() 146 | case 2: 147 | scrollDistance = m.scrollData.steps 148 | m.Next() 149 | case 3: 150 | scrollDistance = m.scrollData.priority 151 | m.Next() 152 | case 4: 153 | scrollDistance = m.scrollData.deadline 154 | if m.taskData.IsRecurring { 155 | m.Next() 156 | } else { 157 | m.viewport.GotoTop() 158 | m.Start() 159 | return m, nil 160 | } 161 | case 5: 162 | scrollDistance = m.scrollData.startTime 163 | m.Next() 164 | case 6: 165 | m.viewport.GotoTop() 166 | m.Start() 167 | return m, nil 168 | } 169 | 170 | m.viewport.LineDown(scrollDistance) 171 | 172 | case key.Matches(msg, Keys.GotoTop): 173 | m.viewport.GotoTop() 174 | m.Start() 175 | 176 | case key.Matches(msg, Keys.GotoBottom): 177 | m.viewport.GotoBottom() 178 | m.End() 179 | 180 | // case key.Matches(msg, Keys.Toggle): 181 | // m.Next() 182 | 183 | // case key.Matches(msg, Keys.ReverseToggle): 184 | // m.Previous() 185 | } 186 | } 187 | return m, nil 188 | } 189 | 190 | func (m detailsBox) View() string { 191 | return lipgloss.JoinVertical(lipgloss.Center, getDetailsBoxStyle().Render(m.viewport.View()), m.footerView()) 192 | } 193 | 194 | func (m *detailsBox) Focus() { 195 | m.isBoxFocused = true 196 | } 197 | 198 | func (m *detailsBox) Blur() { 199 | m.isBoxFocused = false 200 | } 201 | 202 | func (m detailsBox) Focused() bool { 203 | return m.isBoxFocused 204 | } 205 | 206 | func (m *detailsBox) Next() { 207 | var length int 208 | if m.taskData.IsRecurring { 209 | length = 7 210 | } else { 211 | length = 5 212 | } 213 | m.focusIndex = (m.focusIndex + 1) % length 214 | m.renderContent() 215 | } 216 | 217 | func (m *detailsBox) End() { 218 | if m.taskData.IsRecurring { 219 | m.focusIndex = 6 220 | } else { 221 | m.focusIndex = 4 222 | } 223 | m.renderContent() 224 | } 225 | 226 | func (m *detailsBox) Previous() { 227 | var length int 228 | if m.taskData.IsRecurring { 229 | length = 7 230 | } else { 231 | length = 5 232 | } 233 | val := (m.focusIndex - 1) % length 234 | if val < 0 { 235 | val = val + length 236 | } 237 | m.focusIndex = val 238 | m.renderContent() 239 | } 240 | 241 | func (m *detailsBox) Start() { 242 | m.focusIndex = 0 243 | m.renderContent() 244 | } 245 | 246 | func (m *detailsBox) renderContent() { 247 | var content []string 248 | 249 | if m.taskData.IsRecurring { 250 | content = []string{ 251 | m.titleBlock(), 252 | m.descriptionBlock(), 253 | m.stepsBlock(), 254 | m.priorityBlock(), 255 | m.deadlineBlock(), 256 | m.startTimeBlock(), 257 | m.recurrenceIntervalBlock(), 258 | } 259 | } else { 260 | content = []string{ 261 | m.titleBlock(), 262 | m.descriptionBlock(), 263 | m.stepsBlock(), 264 | m.priorityBlock(), 265 | m.deadlineBlock(), 266 | } 267 | } 268 | 269 | view := lipgloss.JoinVertical(lipgloss.Left, content...) 270 | m.viewport.SetContent(view) 271 | if m.preserveOffset { 272 | m.viewport.SetYOffset(m.oldViewportOffset) 273 | m.preserveOffset = false 274 | } 275 | } 276 | func (m *detailsBox) titleBlock() string { 277 | var b strings.Builder 278 | 279 | b.WriteString(highlightedTextStyle.Render("Title:")) 280 | b.WriteString("\n\n") 281 | b.WriteString(m.taskData.Title) 282 | 283 | isFocused := (m.focusIndex == 0) 284 | 285 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).PaddingTop(0).Render(b.String())) 286 | m.scrollData.title = lipgloss.Height(data) 287 | return data 288 | } 289 | 290 | func (m *detailsBox) descriptionBlock() string { 291 | var b strings.Builder 292 | 293 | b.WriteString(highlightedTextStyle.Render("Description:")) 294 | b.WriteString("\n\n") 295 | if m.taskData.Description == "" { 296 | b.WriteString(dash) 297 | } else { 298 | b.WriteString(m.taskData.Description) 299 | } 300 | 301 | isFocused := (m.focusIndex == 1) 302 | 303 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).Render(b.String())) 304 | m.scrollData.description = lipgloss.Height(data) 305 | return data 306 | } 307 | 308 | func (m *detailsBox) stepsBlock() string { 309 | var b strings.Builder 310 | 311 | b.WriteString(highlightedTextStyle.Render("Steps:")) 312 | b.WriteString("\n\n") 313 | b.WriteString(renderSteps(m.taskData.Steps)) 314 | 315 | isFocused := (m.focusIndex == 2) 316 | 317 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).Render(b.String())) 318 | m.scrollData.steps = lipgloss.Height(data) 319 | return data 320 | } 321 | 322 | func (m *detailsBox) priorityBlock() string { 323 | var b strings.Builder 324 | 325 | b.WriteString(highlightedTextStyle.Render("Priority:")) 326 | b.WriteString("\n\n") 327 | b.WriteString(strconv.Itoa(m.taskData.Priority)) 328 | 329 | isFocused := (m.focusIndex == 3) 330 | 331 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).Render(b.String())) 332 | m.scrollData.priority = lipgloss.Height(data) 333 | return data 334 | } 335 | 336 | func (m *detailsBox) deadlineBlock() string { 337 | var b strings.Builder 338 | 339 | b.WriteString(highlightedTextStyle.Render("Deadline:")) 340 | b.WriteString("\n\n") 341 | if m.taskData.Deadline.IsZero() { 342 | b.WriteString("Not Scheduled") 343 | } else { 344 | b.WriteString(formatTime(m.taskData.Deadline, true)) 345 | } 346 | 347 | isFocused := (m.focusIndex == 4) 348 | 349 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).Render(b.String())) 350 | m.scrollData.deadline = lipgloss.Height(data) 351 | return data 352 | } 353 | 354 | func (m *detailsBox) startTimeBlock() string { 355 | var b strings.Builder 356 | 357 | b.WriteString(highlightedTextStyle.Render("Due Time:")) 358 | b.WriteString("\n\n") 359 | b.WriteString(formatTime(m.taskData.StartTime, false)) 360 | 361 | isFocused := (m.focusIndex == 5) 362 | 363 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).Render(b.String())) 364 | m.scrollData.startTime = lipgloss.Height(data) 365 | return data 366 | } 367 | 368 | func (m *detailsBox) recurrenceIntervalBlock() string { 369 | var b strings.Builder 370 | 371 | b.WriteString(highlightedTextStyle.Render("Recurrence Interval:")) 372 | b.WriteString("\n\n") 373 | b.WriteString(strconv.Itoa(m.taskData.RecurrenceInterval) + " day(s)") 374 | 375 | isFocused := (m.focusIndex == 6) 376 | 377 | data := getItemContainerStyle(isFocused).Render(getDetailsItemStyle(isFocused).Render(b.String())) 378 | m.scrollData.recurrenceInterval = lipgloss.Height(data) 379 | return data 380 | } 381 | 382 | func (m *detailsBox) footerView() string { 383 | scrollInfoStyle := footerContainerStyle.Copy(). 384 | Width(m.viewport.Width). 385 | Align(lipgloss.Right) 386 | 387 | info := footerInfoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 388 | return scrollInfoStyle.Render(info) 389 | } 390 | 391 | func renderSteps(steps []entities.Step) string { 392 | var res []string 393 | 394 | if len(steps) == 0 { 395 | return dash 396 | } 397 | for _, val := range steps { 398 | if val.IsFinished { 399 | value := lipgloss.JoinHorizontal( 400 | lipgloss.Center, 401 | boxedValue("✘"), 402 | " ", 403 | val.Title, 404 | ) 405 | res = append(res, value) 406 | } else { 407 | value := lipgloss.JoinHorizontal( 408 | lipgloss.Center, 409 | boxedValue(" "), 410 | " ", 411 | val.Title, 412 | ) 413 | res = append(res, value) 414 | } 415 | } 416 | 417 | return lipgloss.JoinVertical(lipgloss.Left, res...) 418 | } 419 | 420 | func boxedValue(value string) string { 421 | style := lipgloss.NewStyle(). 422 | Border(lipgloss.RoundedBorder()). 423 | Padding(0, 1). 424 | Render(value) 425 | 426 | return style 427 | } 428 | -------------------------------------------------------------------------------- /tui/main_model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/BOTbkcd/mayhem/entities" 8 | "github.com/charmbracelet/bubbles/key" 9 | "github.com/charmbracelet/bubbles/table" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | type model struct { 15 | data []entities.Stack 16 | stackTable table.Model 17 | taskTable table.Model 18 | taskDetails detailsBox 19 | help helpModel 20 | input inputForm 21 | showTasks bool 22 | showDetails bool 23 | showInput bool 24 | showHelp bool 25 | customInput tea.Model 26 | customInputType string 27 | showCustomInput bool 28 | navigationKeys keyMap 29 | preInputFocus string //useful for reverting back when input box is closed 30 | firstRender bool 31 | prevState preserveState 32 | } 33 | 34 | type preserveState struct { 35 | retainState bool 36 | stackID uint 37 | taskID uint 38 | } 39 | 40 | func InitializeMainModel() *model { 41 | stacks, _ := entities.FetchAllStacks() 42 | 43 | m := &model{ 44 | stackTable: buildTable(stackColumns(), "stack"), 45 | taskTable: buildTable(taskColumns(), "task"), 46 | taskDetails: detailsBox{}, // we can't build the details box at this stage since we need both stack & task indices for that 47 | data: stacks, 48 | help: initializeHelp(stackKeys), 49 | navigationKeys: tableNavigationKeys, 50 | showHelp: true, 51 | } 52 | 53 | m.stackTable.Focus() 54 | m.taskTable.Blur() 55 | m.taskDetails.Blur() 56 | return m 57 | } 58 | 59 | func (m *model) Init() tea.Cmd { 60 | m.firstRender = true 61 | return nil 62 | } 63 | 64 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 65 | //Transfer control to inputForm's Update method 66 | if m.showInput { 67 | 68 | switch msg := msg.(type) { 69 | 70 | case goToMainMsg: 71 | m.input = inputForm{} 72 | m.showInput = false 73 | 74 | if msg.value.(string) == "refresh" { 75 | m.preserveState() 76 | m.refreshData() 77 | } 78 | 79 | if m.preInputFocus == "stack" { 80 | m.stackTable.Focus() 81 | m.help = initializeHelp(stackKeys) 82 | m.navigationKeys = tableNavigationKeys 83 | } else if m.preInputFocus == "task" { 84 | m.taskTable.Focus() 85 | m.help = initializeHelp(taskKeys) 86 | m.navigationKeys = tableNavigationKeys 87 | } else if m.preInputFocus == "detail" { 88 | m.taskDetails.Focus() 89 | m.navigationKeys = detailsNavigationKeys 90 | } 91 | 92 | m.updateViewDimensions(10) 93 | 94 | return m, nil 95 | 96 | case tea.WindowSizeMsg: 97 | screenWidth = msg.Width 98 | screenHeight = msg.Height 99 | m.updateViewDimensions(14) 100 | return m, nil 101 | 102 | default: 103 | inp, cmd := m.input.Update(msg) 104 | t, _ := inp.(inputForm) 105 | m.input = t 106 | 107 | return m, cmd 108 | } 109 | } 110 | 111 | if m.showCustomInput { 112 | switch msg := msg.(type) { 113 | case tea.WindowSizeMsg: 114 | screenWidth = msg.Width 115 | screenHeight = msg.Height 116 | m.updateViewDimensions(14) 117 | return m, nil 118 | } 119 | 120 | switch m.customInputType { 121 | //Transfer control to delete confirmation model 122 | case "delete": 123 | switch msg := msg.(type) { 124 | 125 | case goToMainMsg: 126 | m.showCustomInput = false 127 | 128 | if m.preInputFocus == "stack" { 129 | m.stackTable.Focus() 130 | m.help = initializeHelp(stackKeys) 131 | } else if m.preInputFocus == "task" { 132 | m.taskTable.Focus() 133 | m.help = initializeHelp(taskKeys) 134 | } 135 | 136 | if msg.value.(string) == "y" { 137 | switch m.preInputFocus { 138 | case "stack": 139 | stackIndex := m.stackTable.Cursor() 140 | currStack := m.data[stackIndex] 141 | 142 | if stackIndex == len(m.stackTable.Rows())-1 { 143 | m.stackTable.SetCursor(stackIndex - 1) 144 | } 145 | 146 | currStack.Delete() 147 | m.showTasks = false 148 | m.showDetails = false 149 | m.refreshData() 150 | return m, nil 151 | 152 | case "task": 153 | stackIndex := m.stackTable.Cursor() 154 | taskIndex := m.taskTable.Cursor() 155 | 156 | var currTask entities.Task 157 | if len(m.data[stackIndex].Tasks) > 0 { 158 | currTask = m.data[stackIndex].Tasks[taskIndex] 159 | 160 | if currTask.IsRecurring { 161 | 162 | } else { 163 | if !currTask.IsFinished { 164 | stack := m.data[stackIndex] 165 | stack.PendingTaskCount-- 166 | stack.Save() 167 | } 168 | } 169 | if taskIndex == len(m.taskTable.Rows())-1 { 170 | m.taskTable.SetCursor(taskIndex - 1) 171 | } 172 | currTask.Delete() 173 | m.refreshData() 174 | return m, nil 175 | } 176 | } 177 | } 178 | 179 | default: 180 | inp, cmd := m.customInput.Update(msg) 181 | t, _ := inp.(deleteConfirmation) 182 | m.customInput = t 183 | 184 | return m, cmd 185 | } 186 | 187 | case "move": 188 | switch msg := msg.(type) { 189 | 190 | case goToMainMsg: 191 | m.showCustomInput = false 192 | m.taskTable.Focus() 193 | m.help = initializeHelp(taskKeys) 194 | 195 | response := msg.value.(keyVal) 196 | 197 | if response.val == "" { 198 | return m, nil 199 | } 200 | 201 | newStackID := response.key 202 | 203 | stackIndex := m.stackTable.Cursor() 204 | taskIndex := m.taskTable.Cursor() 205 | 206 | currStack := m.data[stackIndex] 207 | currTask := currStack.Tasks[taskIndex] 208 | 209 | if currTask.StackID == newStackID { 210 | return m, nil 211 | } 212 | 213 | if currTask.IsRecurring { 214 | for _, child := range currTask.FetchAllRecurTasks() { 215 | child.StackID = newStackID 216 | child.Save() 217 | } 218 | } else { 219 | //Moving recurring tasks wouldn't have any effect on the stack pending task count 220 | 221 | //Decrease pending task count for old stack 222 | if !currTask.IsFinished { 223 | currStack.PendingTaskCount-- 224 | currStack.Save() 225 | } 226 | 227 | //Increase pending task count for new stack 228 | entities.IncPendingCount(newStackID) 229 | } 230 | 231 | currTask.StackID = newStackID 232 | currTask.Save() 233 | 234 | if taskIndex == len(m.taskTable.Rows())-1 { 235 | m.taskTable.SetCursor(taskIndex - 1) 236 | } 237 | m.refreshData() 238 | return m, nil 239 | 240 | default: 241 | inp, cmd := m.customInput.Update(msg) 242 | t, _ := inp.(listSelector) 243 | m.customInput = t 244 | 245 | return m, cmd 246 | } 247 | } 248 | } 249 | 250 | var cmd tea.Cmd 251 | 252 | switch msg := msg.(type) { 253 | 254 | case tea.KeyMsg: 255 | switch { 256 | // Inter-table navigation 257 | case key.Matches(msg, Keys.Left): 258 | if m.stackTable.Focused() { 259 | if m.showDetails { 260 | m.stackTable.Blur() 261 | m.taskTable.Blur() 262 | m.taskDetails.Focus() 263 | m.help = initializeHelp(taskDetailsKeys) 264 | m.navigationKeys = detailsNavigationKeys 265 | 266 | } 267 | } else if m.taskTable.Focused() { 268 | m.stackTable.Focus() 269 | m.taskTable.Blur() 270 | m.taskDetails.Blur() 271 | m.help = initializeHelp(stackKeys) 272 | m.navigationKeys = tableNavigationKeys 273 | 274 | } else if m.taskDetails.Focused() { 275 | m.stackTable.Blur() 276 | m.taskTable.Focus() 277 | m.taskDetails.Blur() 278 | m.help = initializeHelp(taskKeys) 279 | m.navigationKeys = tableNavigationKeys 280 | 281 | } 282 | return m, nil 283 | 284 | case key.Matches(msg, Keys.Right): 285 | if m.stackTable.Focused() { 286 | if len(m.stackTable.Rows()) > 0 { 287 | m.showTasks = true 288 | m.stackTable.Blur() 289 | m.taskTable.Focus() 290 | m.taskDetails.Blur() 291 | m.help = initializeHelp(taskKeys) 292 | m.navigationKeys = tableNavigationKeys 293 | return m, nil 294 | } 295 | } else if m.taskTable.Focused() { 296 | if len(m.taskTable.Rows()) > 0 { 297 | m.showDetails = true 298 | m.stackTable.Blur() 299 | m.taskTable.Blur() 300 | m.taskDetails.Focus() 301 | m.help = initializeHelp(taskDetailsKeys) 302 | m.navigationKeys = detailsNavigationKeys 303 | return m, nil 304 | } 305 | } else if m.taskDetails.Focused() { 306 | m.stackTable.Focus() 307 | m.taskTable.Blur() 308 | m.taskDetails.Blur() 309 | m.help = initializeHelp(stackKeys) 310 | m.navigationKeys = tableNavigationKeys 311 | return m, nil 312 | } 313 | 314 | // Intra-table navigation 315 | 316 | // When we switch to a new stack: 317 | // - Empty task box is shown 318 | // - Details box is hidden 319 | 320 | // When we switch to a new task: 321 | // - Empty details box is shown 322 | case key.Matches(msg, Keys.Up): 323 | if m.stackTable.Focused() { 324 | m.stackTable.MoveUp(1) 325 | m.taskTable.SetCursor(0) 326 | m.taskDetails.focusIndex = 0 327 | m.showTasks = false 328 | m.showDetails = false 329 | m.updateSelectionData("tasks") 330 | return m, nil 331 | 332 | } else if m.taskTable.Focused() { 333 | m.taskTable.MoveUp(1) 334 | m.taskDetails.focusIndex = 0 335 | m.showDetails = false 336 | m.updateSelectionData("details") 337 | return m, nil 338 | 339 | } else if m.taskDetails.Focused() { 340 | var t tea.Model 341 | t, cmd = m.taskDetails.Update(msg) 342 | m.taskDetails = t.(detailsBox) 343 | return m, cmd 344 | } 345 | 346 | case key.Matches(msg, Keys.Down): 347 | if m.stackTable.Focused() { 348 | m.stackTable.MoveDown(1) 349 | m.taskTable.SetCursor(0) 350 | m.taskDetails.focusIndex = 0 351 | m.showTasks = false 352 | m.showDetails = false 353 | m.updateSelectionData("tasks") 354 | return m, nil 355 | 356 | } else if m.taskTable.Focused() { 357 | m.taskTable.MoveDown(1) 358 | m.taskDetails.focusIndex = 0 359 | m.showDetails = false 360 | m.updateSelectionData("details") 361 | return m, nil 362 | 363 | } else if m.taskDetails.Focused() { 364 | var t tea.Model 365 | t, cmd = m.taskDetails.Update(msg) 366 | m.taskDetails = t.(detailsBox) 367 | return m, cmd 368 | } 369 | 370 | case key.Matches(msg, Keys.GotoTop): 371 | if m.stackTable.Focused() { 372 | m.stackTable.GotoTop() 373 | m.taskTable.SetCursor(0) 374 | m.taskDetails.focusIndex = 0 375 | m.showTasks = false 376 | m.showDetails = false 377 | m.updateSelectionData("tasks") 378 | return m, nil 379 | 380 | } else if m.taskTable.Focused() { 381 | m.taskTable.GotoTop() 382 | m.taskDetails.focusIndex = 0 383 | m.showDetails = false 384 | m.updateSelectionData("details") 385 | return m, nil 386 | 387 | } else if m.taskDetails.Focused() { 388 | var t tea.Model 389 | t, cmd = m.taskDetails.Update(msg) 390 | m.taskDetails = t.(detailsBox) 391 | return m, cmd 392 | } 393 | 394 | case key.Matches(msg, Keys.GotoBottom): 395 | if m.stackTable.Focused() { 396 | m.stackTable.GotoBottom() 397 | m.taskTable.SetCursor(0) 398 | m.taskDetails.focusIndex = 0 399 | m.showTasks = false 400 | m.showDetails = false 401 | m.updateSelectionData("tasks") 402 | return m, nil 403 | 404 | } else if m.taskTable.Focused() { 405 | m.taskTable.GotoBottom() 406 | m.taskDetails.focusIndex = 0 407 | m.showDetails = false 408 | m.updateSelectionData("details") 409 | return m, nil 410 | 411 | } else if m.taskDetails.Focused() { 412 | var t tea.Model 413 | t, cmd = m.taskDetails.Update(msg) 414 | m.taskDetails = t.(detailsBox) 415 | return m, cmd 416 | } 417 | 418 | case key.Matches(msg, Keys.New): 419 | if m.stackTable.Focused() { 420 | m.preInputFocus = "stack" 421 | m.input = initializeInput("stack", entities.Stack{}, 0) 422 | 423 | } else if m.taskTable.Focused() { 424 | m.preInputFocus = "task" 425 | newTask := entities.Task{ 426 | StackID: m.data[m.stackTable.Cursor()].ID, 427 | } 428 | m.input = initializeInput("task", newTask, 0) 429 | 430 | } else if m.taskDetails.Focused() { 431 | return m, nil 432 | } 433 | 434 | m.stackTable.Blur() 435 | m.taskTable.Blur() 436 | m.taskDetails.Blur() 437 | 438 | m.updateViewDimensions(14) 439 | 440 | m.showInput = true 441 | 442 | return m, nil 443 | 444 | case key.Matches(msg, Keys.NewRecur): 445 | if m.taskTable.Focused() { 446 | m.preInputFocus = "task" 447 | newTask := entities.Task{ 448 | StackID: m.data[m.stackTable.Cursor()].ID, 449 | IsRecurring: true, 450 | StartTime: time.Now(), 451 | Deadline: time.Now(), 452 | RecurrenceInterval: 1, 453 | } 454 | m.input = initializeInput("task", newTask, 0) 455 | 456 | m.stackTable.Blur() 457 | m.taskTable.Blur() 458 | m.taskDetails.Blur() 459 | 460 | m.updateViewDimensions(14) 461 | 462 | m.showInput = true 463 | 464 | return m, nil 465 | } 466 | 467 | case key.Matches(msg, Keys.Edit): 468 | if m.stackTable.Focused() { 469 | if len(m.stackTable.Rows()) == 0 { 470 | return m, nil 471 | } 472 | m.preInputFocus = "stack" 473 | m.input = initializeInput("stack", m.data[m.stackTable.Cursor()], 0) 474 | } else if m.taskTable.Focused() { 475 | if len(m.taskTable.Rows()) > 0 { 476 | m.showDetails = true 477 | m.stackTable.Blur() 478 | m.taskTable.Blur() 479 | m.taskDetails.Focus() 480 | m.help = initializeHelp(taskDetailsKeys) 481 | m.navigationKeys = detailsNavigationKeys 482 | } 483 | return m, nil 484 | } else if m.taskDetails.Focused() { 485 | m.preInputFocus = "detail" 486 | m.input = initializeInput("task", m.data[m.stackTable.Cursor()].Tasks[m.taskTable.Cursor()], m.taskDetails.focusIndex) 487 | } 488 | 489 | m.stackTable.Blur() 490 | m.taskTable.Blur() 491 | m.taskDetails.Blur() 492 | 493 | m.updateViewDimensions(14) 494 | 495 | m.showInput = true 496 | 497 | return m, nil 498 | 499 | //Actual delete operation happens in showDelete conditional at the start of Update() method 500 | //Here we just trigger the delete confirmation step 501 | case key.Matches(msg, Keys.Delete): 502 | if m.stackTable.Focused() { 503 | m.preInputFocus = "stack" 504 | m.showCustomInput = true 505 | m.customInputType = "delete" 506 | m.customInput = initializeDeleteConfirmation() 507 | m.stackTable.Blur() 508 | m.help = helpModel{} 509 | 510 | return m, nil 511 | 512 | } else if m.taskTable.Focused() { 513 | stackIndex := m.stackTable.Cursor() 514 | 515 | if len(m.data[stackIndex].Tasks) > 0 { 516 | m.preInputFocus = "task" 517 | m.showCustomInput = true 518 | m.customInputType = "delete" 519 | m.customInput = initializeDeleteConfirmation() 520 | m.taskTable.Blur() 521 | m.help = helpModel{} 522 | 523 | return m, nil 524 | } 525 | } 526 | 527 | case key.Matches(msg, Keys.Toggle): 528 | //Toggle task finish status 529 | if m.taskTable.Focused() { 530 | stackIndex := m.stackTable.Cursor() 531 | taskIndex := m.taskTable.Cursor() 532 | 533 | var currTask entities.Task 534 | if len(m.data[stackIndex].Tasks) > 0 { 535 | stack := m.data[stackIndex] 536 | currTask = stack.Tasks[taskIndex] 537 | 538 | //For recurring tasks we toggle the status of latest recur task entry 539 | if currTask.IsRecurring { 540 | r, count := currTask.LatestRecurTask() 541 | if count > 0 { 542 | r.IsFinished = !r.IsFinished 543 | r.Save() 544 | } 545 | } else { 546 | currTask.IsFinished = !currTask.IsFinished 547 | currTask.Save() 548 | 549 | if currTask.IsFinished { 550 | stack.PendingTaskCount-- 551 | stack.Save() 552 | } else { 553 | stack.PendingTaskCount++ 554 | stack.Save() 555 | } 556 | 557 | stack.Tasks[taskIndex] = currTask 558 | m.data[stackIndex] = stack 559 | } 560 | 561 | //Changing finish status will lead to reordering, so state has to be preserved 562 | m.preserveState() 563 | m.updateSelectionData("stacks") 564 | return m, nil 565 | } 566 | } 567 | 568 | case key.Matches(msg, Keys.Move): 569 | if m.taskTable.Focused() { 570 | stackIndex := m.stackTable.Cursor() 571 | 572 | if len(m.data[stackIndex].Tasks) > 0 { 573 | m.preInputFocus = "task" 574 | m.showCustomInput = true 575 | m.customInputType = "move" 576 | m.taskTable.Blur() 577 | 578 | opts := []keyVal{} 579 | for _, stack := range m.data { 580 | entry := keyVal{ 581 | key: stack.ID, 582 | val: stack.Title, 583 | } 584 | opts = append(opts, entry) 585 | } 586 | m.customInput = initializeListSelector(opts, "", goToMainWithVal) 587 | 588 | m.help = initializeHelp(listSelectorKeys) 589 | return m, nil 590 | } 591 | } 592 | case key.Matches(msg, Keys.Help): 593 | m.showHelp = !m.showHelp 594 | return m, nil 595 | 596 | case key.Matches(msg, Keys.Quit, Keys.Exit): 597 | return m, tea.Quit 598 | } 599 | 600 | case tea.WindowSizeMsg: 601 | screenWidth = msg.Width 602 | screenHeight = msg.Height 603 | m.updateViewDimensions(10) 604 | 605 | if m.firstRender { 606 | //updateSelectionData() is called here instead of being called from Init() 607 | //since details box rendering requires screen dimensions, which aren't set at the time of Init() 608 | m.updateSelectionData("stacks") 609 | m.firstRender = false 610 | } 611 | } 612 | 613 | return m, cmd 614 | } 615 | 616 | func (m *model) View() string { 617 | var stackView, taskView, detailView string 618 | 619 | if m.stackTable.Focused() { 620 | stackView = selectedStackBoxStyle.Render(m.stackView()) 621 | taskView = unselectedBoxStyle.Render(m.taskView()) 622 | detailView = unselectedBoxStyle.Render(m.taskDetails.View()) 623 | } else if m.taskTable.Focused() { 624 | stackView = unselectedBoxStyle.Render(m.stackView()) 625 | taskView = selectedTaskBoxStyle.Render(m.taskView()) 626 | detailView = unselectedBoxStyle.Render(m.taskDetails.View()) 627 | } else if m.taskDetails.Focused() { 628 | stackView = unselectedBoxStyle.Render(m.stackView()) 629 | taskView = unselectedBoxStyle.Render(m.taskView()) 630 | detailView = selectedDetailsBoxStyle.Render(m.taskDetails.View()) 631 | } else { 632 | stackView = unselectedBoxStyle.Render(m.stackView()) 633 | taskView = unselectedBoxStyle.Render(m.taskView()) 634 | detailView = unselectedBoxStyle.Render(m.taskDetails.View()) 635 | } 636 | 637 | // if m.isCalenderView { 638 | // return lipgloss.PlaceHorizontal(screenWidth, lipgloss.Left, initializeCalender(time.Now()).View()) 639 | // } 640 | viewArr := []string{stackView} 641 | if m.showTasks { 642 | viewArr = append(viewArr, taskView) 643 | 644 | if m.showDetails { 645 | viewArr = append(viewArr, detailView) 646 | } else if len(m.taskTable.Rows()) > 0 { 647 | viewArr = append(viewArr, unselectedBoxStyle.Render(getEmptyDetailsView())) 648 | } 649 | } else { 650 | viewArr = append(viewArr, unselectedBoxStyle.Render(getEmptyTaskView())) 651 | } 652 | 653 | tablesView := lipgloss.JoinHorizontal(lipgloss.Center, viewArr...) 654 | 655 | if m.showCustomInput { 656 | tablesView = lipgloss.JoinVertical(lipgloss.Left, 657 | tablesView, 658 | getInputFormStyle().Render(m.customInput.View()), 659 | ) 660 | } 661 | 662 | if m.showInput { 663 | inputFormView := getInputFormStyle().Render(m.input.View()) 664 | tablesView = lipgloss.JoinVertical(lipgloss.Left, 665 | tablesView, 666 | inputFormView, 667 | ) 668 | m.help = initializeHelp(m.input.helpKeys) 669 | } 670 | 671 | if m.showHelp { 672 | if !m.showInput && !m.showCustomInput { 673 | navigationHelp := initializeHelp(m.navigationKeys) 674 | return lipgloss.JoinVertical(lipgloss.Left, tablesView, m.help.View(), navigationHelp.View()) 675 | } 676 | return lipgloss.JoinVertical(lipgloss.Left, tablesView, m.help.View()) 677 | } else { 678 | return tablesView 679 | } 680 | } 681 | 682 | func (m *model) stackView() string { 683 | m.stackTable.SetHeight(tableViewHeight) 684 | return lipgloss.JoinVertical(lipgloss.Center, m.stackTable.View(), m.stackFooter()) 685 | } 686 | 687 | func (m *model) stackFooter() string { 688 | stackFooterStyle := footerContainerStyle.Copy(). 689 | Width(stackTableWidth) 690 | 691 | info := footerInfoStyle.Render(fmt.Sprintf("%d/%d", m.stackTable.Cursor()+1, len(m.stackTable.Rows()))) 692 | 693 | return stackFooterStyle.Render(info) 694 | } 695 | 696 | func (m *model) taskView() string { 697 | m.taskTable.SetHeight(tableViewHeight) 698 | return lipgloss.JoinVertical(lipgloss.Center, m.taskTable.View(), m.taskFooter()) 699 | } 700 | 701 | func (m *model) taskFooter() string { 702 | taskFooterStyle := footerContainerStyle.Copy(). 703 | Width(taskTableWidth) 704 | 705 | if len(m.taskTable.Rows()) == 0 { 706 | return taskFooterStyle.Render("Press 'n' to create a new task") 707 | } else { 708 | info := footerInfoStyle.Render(fmt.Sprintf("%d/%d", m.taskTable.Cursor()+1, len(m.taskTable.Rows()))) 709 | return taskFooterStyle.Render(info) 710 | } 711 | } 712 | 713 | // Pull new data from database 714 | func (m *model) refreshData() { 715 | stacks, _ := entities.FetchAllStacks() 716 | m.data = stacks 717 | m.updateSelectionData("stacks") 718 | } 719 | 720 | // Efficiently update only the required pane 721 | func (m *model) updateSelectionData(category string) { 722 | var retainIndex bool 723 | if m.prevState.retainState { 724 | retainIndex = true 725 | m.prevState.retainState = false 726 | } 727 | 728 | switch category { 729 | case "stacks": 730 | m.updateStackTableData(retainIndex) 731 | m.updateTaskTableData(retainIndex) 732 | m.updateDetailsBoxData(true) 733 | case "tasks": 734 | m.updateTaskTableData(retainIndex) 735 | m.updateDetailsBoxData(false) 736 | case "details": 737 | m.updateDetailsBoxData(false) 738 | default: 739 | m.updateStackTableData(retainIndex) 740 | m.updateTaskTableData(retainIndex) 741 | m.updateDetailsBoxData(true) 742 | } 743 | } 744 | 745 | func (m *model) updateStackTableData(retainIndex bool) { 746 | //Set stack view data 747 | //We pass a slice to stackRows, so the changes (like sorting) that happen there will be reflected in original slice 748 | m.stackTable.SetRows(stackRows(m.data)) 749 | 750 | if retainIndex { 751 | newIndex := findStackIndex(m.data, m.prevState.stackID) 752 | 753 | if newIndex != -1 { 754 | m.stackTable.SetCursor(newIndex) 755 | } 756 | } 757 | } 758 | 759 | func (m *model) updateTaskTableData(retainIndex bool) { 760 | //Set task view data for selected stack 761 | stackIndex := m.stackTable.Cursor() 762 | currStack := m.data[stackIndex] 763 | 764 | //We pass a slice to taskRows, so the changes (like sorting) that happen there will be reflected in original slice 765 | m.taskTable.SetRows(taskRows(currStack.Tasks)) 766 | 767 | if retainIndex { 768 | newIndex := findTaskIndex(m.data[stackIndex].Tasks, m.prevState.taskID) 769 | if newIndex != -1 { 770 | m.taskTable.SetCursor(newIndex) 771 | } 772 | } 773 | } 774 | 775 | func (m *model) updateDetailsBoxData(preserveOffset bool) { 776 | stackIndex := m.stackTable.Cursor() 777 | taskIndex := m.taskTable.Cursor() 778 | if taskIndex == -1 { 779 | taskIndex = 0 780 | m.taskTable.SetCursor(0) 781 | } 782 | 783 | var currTask entities.Task 784 | if len(m.data[stackIndex].Tasks) > 0 { 785 | currTask = m.data[stackIndex].Tasks[taskIndex] 786 | } else { 787 | currTask = entities.Task{} 788 | } 789 | 790 | m.taskDetails.buildDetailsBox(currTask, preserveOffset) 791 | } 792 | 793 | // Changing title, deadline, priority or finish status will lead to table reordering 794 | // preserveState() is used to maintain focus on the stack/task that was being edited 795 | func (m *model) preserveState() { 796 | m.prevState.retainState = true 797 | stackIndex := m.stackTable.Cursor() 798 | taskIndex := m.taskTable.Cursor() 799 | 800 | m.prevState.stackID = m.data[m.stackTable.Cursor()].ID 801 | if len(m.data[stackIndex].Tasks) > 0 { 802 | m.prevState.taskID = m.data[stackIndex].Tasks[taskIndex].ID 803 | } 804 | } 805 | 806 | func (m *model) updateViewDimensions(offset int) { 807 | tableViewHeight = screenHeight - offset 808 | 809 | //Details box viewport dimensions & section width are set at the time of box creation, 810 | //after that they have to be manually adjusted 811 | m.taskDetails.viewport.Width = getDetailsBoxWidth() 812 | m.taskDetails.viewport.Height = getDetailsBoxHeight() 813 | m.updateDetailsBoxData(true) 814 | } 815 | --------------------------------------------------------------------------------