├── .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 |
--------------------------------------------------------------------------------