[WIP] Layout

This commit is contained in:
Martin Pander
2024-05-22 16:20:57 +02:00
parent a23b76c3c9
commit 14dbfc406d
15 changed files with 378 additions and 160 deletions

View File

@ -4,24 +4,47 @@ import (
"context"
"tasksquire/taskwarrior"
tea "github.com/charmbracelet/bubbletea"
)
type Common struct {
Ctx context.Context
PageStack *Stack[tea.Model]
TW taskwarrior.TaskWarrior
Keymap *Keymap
Styles *Styles
pageStack *Stack[Component]
width int
height int
}
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common {
return &Common{
Ctx: ctx,
PageStack: NewStack[tea.Model](),
TW: tw,
Keymap: NewKeymap(),
Styles: NewStyles(tw.GetConfig()),
pageStack: NewStack[Component](),
}
}
func (c *Common) SetSize(width, height int) {
c.width = width
c.height = height
}
func (c *Common) Width() int {
return c.width
}
func (c *Common) Height() int {
return c.height
}
func (c *Common) PushPage(page Component) {
c.pageStack.Push(page)
}
func (c *Common) PopPage() (Component, error) {
return c.pageStack.Pop()
}

9
common/component.go Normal file
View File

@ -0,0 +1,9 @@
package common
import tea "github.com/charmbracelet/bubbletea"
type Component interface {
tea.Model
//help.KeyMap
SetSize(width int, height int)
}

View File

@ -8,12 +8,19 @@ import (
type Keymap struct {
Quit key.Binding
Back key.Binding
Ok key.Binding
Input key.Binding
Add key.Binding
Edit key.Binding
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
SetReport key.Binding
SetContext key.Binding
SetProject key.Binding
Select key.Binding
Insert key.Binding
}
// NewKeymap creates a new Keymap.
@ -29,6 +36,16 @@ func NewKeymap() *Keymap {
key.WithHelp("esc", "Back"),
),
Ok: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "Ok"),
),
Input: key.NewBinding(
key.WithKeys(":"),
key.WithHelp(":", "Input"),
),
Add: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "Add new task"),
@ -39,6 +56,26 @@ func NewKeymap() *Keymap {
key.WithHelp("e", "Edit task"),
),
Up: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("↑/k", "Up"),
),
Down: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("↓/j", "Down"),
),
Left: key.NewBinding(
key.WithKeys("h", "left"),
key.WithHelp("←/h", "Left"),
),
Right: key.NewBinding(
key.WithKeys("l", "right"),
key.WithHelp("→/l", "Right"),
),
SetReport: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "Set report"),
@ -58,5 +95,10 @@ func NewKeymap() *Keymap {
key.WithKeys("enter"),
key.WithHelp("enter", "Select"),
),
Insert: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("insert", "Insert mode"),
),
}
}

View File

@ -72,7 +72,6 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles.Main = lipgloss.NewStyle()
formTheme := huh.ThemeBase()
formTheme.Focused.Card = formTheme.Focused.Card.BorderStyle(lipgloss.RoundedBorder()).BorderBottom(true).BorderTop(true)
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ")
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)

View File

@ -6,7 +6,7 @@ import (
"os"
"tasksquire/common"
"tasksquire/model"
"tasksquire/pages"
"tasksquire/taskwarrior"
tea "github.com/charmbracelet/bubbletea"
@ -32,7 +32,7 @@ func main() {
// slog.Error("Uh oh:", err)
// os.Exit(1)
// }
m := model.NewMainModel(common)
m := pages.NewMainPage(common)
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
fmt.Println("Error running program:", err)

View File

@ -1,53 +0,0 @@
package model
import (
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
"tasksquire/pages"
"tasksquire/taskwarrior"
)
type MainModel struct {
common *common.Common
selectedPage tea.Model
selectedTask *taskwarrior.Task
selectedReport *taskwarrior.Report
selectedContext *taskwarrior.Context
}
func NewMainModel(common *common.Common) *MainModel {
m := &MainModel{
common: common,
selectedReport: common.TW.GetReport("next"),
selectedContext: common.TW.GetActiveContext(),
}
m.selectedPage = pages.NewReportPage(common, m.selectedReport)
return m
}
func (m MainModel) Init() tea.Cmd {
return m.selectedPage.Init()
}
func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, m.common.Keymap.Add):
// case key.Matches(msg, m.common.Keymap.Edit):
// }
// }
m.selectedPage, cmd = m.selectedPage.Update(msg)
return m, cmd
}
func (m MainModel) View() string {
return m.selectedPage.View()
}

View File

@ -23,15 +23,6 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
contexts: common.TW.GetContexts(),
}
// if allowAdd {
// fields = append(fields, huh.NewInput().
// Key("input").
// Title("Input").
// Prompt(fmt.Sprintf("Enter a new %s", header)).
// Inline(false),
// )
// }
selected := common.TW.GetActiveContext().Name
options := make([]string, 0)
for _, c := range p.contexts {
@ -59,6 +50,10 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
return p
}
func (p *ContextPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *ContextPickerPage) Init() tea.Cmd {
return p.form.Init()
}
@ -67,10 +62,12 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
@ -87,7 +84,7 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateContextCmd)
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit

45
pages/main.go Normal file
View File

@ -0,0 +1,45 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
)
type MainPage struct {
common *common.Common
activePage common.Component
}
func NewMainPage(common *common.Common) *MainPage {
m := &MainPage{
common: common,
}
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
return m
}
func (m *MainPage) Init() tea.Cmd {
return m.activePage.Init()
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.common.SetSize(msg.Width, msg.Height)
}
activePage, cmd := m.activePage.Update(msg)
m.activePage = activePage.(common.Component)
return m, cmd
}
func (m *MainPage) View() string {
return m.activePage.View()
}

View File

@ -46,6 +46,10 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
return p
}
func (p *ProjectPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *ProjectPickerPage) Init() tea.Cmd {
return p.form.Init()
}
@ -54,10 +58,12 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
@ -74,7 +80,7 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateProjectCmd)
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit

View File

@ -80,16 +80,22 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
}
}
func (p ReportPage) Init() tea.Cmd {
func (p *ReportPage) SetSize(width int, height int) {
p.common.SetSize(width, height)
p.taskTable.SetWidth(width - 2)
p.taskTable.SetHeight(height - 4)
}
func (p *ReportPage) Init() tea.Cmd {
return p.getTasks()
}
func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.taskTable.SetWidth(msg.Width - 2)
p.taskTable.SetHeight(msg.Height - 4)
p.SetSize(msg.Width, msg.Height)
case BackMsg:
p.subpageActive = false
case TaskMsg:
@ -105,9 +111,7 @@ func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case UpdateProjectMsg:
p.activeProject = string(msg)
cmds = append(cmds, p.getTasks())
case AddedTaskMsg:
cmds = append(cmds, p.getTasks())
case EditedTaskMsg:
case UpdatedTasksMsg:
cmds = append(cmds, p.getTasks())
case tea.KeyMsg:
switch {
@ -117,31 +121,31 @@ func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.subpage = NewReportPickerPage(p.common, p.activeReport)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.SetContext):
p.subpage = NewContextPickerPage(p.common)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Add):
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Edit):
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.SetProject):
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
p.common.PushPage(p)
return p.subpage, nil
}
}
@ -150,15 +154,21 @@ func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
if p.tasks != nil {
if p.tasks != nil && len(p.tasks) > 0 {
p.selectedTask = (*taskwarrior.Task)(p.tasks[p.taskTable.Cursor()])
} else {
p.selectedTask = nil
}
return p, tea.Batch(cmds...)
}
func (p ReportPage) View() string {
return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
func (p *ReportPage) View() string {
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
if p.tasks == nil || len(p.tasks) == 0 {
return p.common.Styles.Main.Render("No tasks found")
}
return p.common.Styles.Main.Render(p.taskTable.View())
}
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {

View File

@ -47,6 +47,10 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
return p
}
func (p *ReportPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *ReportPickerPage) Init() tea.Cmd {
return p.form.Init()
}
@ -55,10 +59,12 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
@ -75,7 +81,7 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if p.form.State == huh.StateCompleted {
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit

View File

@ -8,15 +8,27 @@ import (
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type Mode int
const (
ModeNormal Mode = iota
ModeInsert
ModeAddTag
ModeAddProject
)
type TaskEditorPage struct {
common *common.Common
task taskwarrior.Task
form *huh.Form
edit bool
mode Mode
statusline tea.Model
}
type TaskEditorKeys struct {
@ -31,10 +43,7 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor
p := &TaskEditorPage{
common: common,
task: task,
}
if task.Uuid != "" {
p.edit = true
mode: ModeInsert,
}
if p.task.Priority == "" {
@ -53,6 +62,12 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor
huh.NewInput().
Title("Task").
Value(&p.task.Description).
Validate(func(desc string) error {
if desc == "" {
return fmt.Errorf("task description is required")
}
return nil
}).
Inline(true),
huh.NewSelect[string]().
@ -90,12 +105,21 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor
),
).
WithShowHelp(false).
WithShowErrors(false).
WithShowErrors(true).
// use styles from common
WithHeight(30).
WithWidth(50).
WithTheme(p.common.Styles.Form)
p.statusline = NewStatusLine(common, p.mode)
return p
}
func (p *TaskEditorPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *TaskEditorPage) Init() tea.Cmd {
return p.form.Init()
}
@ -103,16 +127,44 @@ func (p *TaskEditorPage) Init() tea.Cmd {
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case SwitchModeMsg:
switch Mode(msg) {
case ModeNormal:
p.mode = ModeNormal
case ModeInsert:
p.mode = ModeInsert
}
}
switch p.mode {
case ModeNormal:
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
case key.Matches(msg, p.common.Keymap.Insert):
return p, p.switchModeCmd(ModeInsert)
case key.Matches(msg, p.common.Keymap.Ok):
p.form.State = huh.StateCompleted
case key.Matches(msg, p.common.Keymap.Down):
p.form.NextField()
case key.Matches(msg, p.common.Keymap.Up):
p.form.PrevField()
}
}
case ModeInsert:
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
return p, p.switchModeCmd(ModeNormal)
}
}
@ -122,13 +174,15 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
if p.edit {
cmds = append(cmds, p.editTaskCmd)
} else {
cmds = append(cmds, p.addTaskCmd)
}
model, err := p.common.PageStack.Pop()
var cmd tea.Cmd
p.statusline, cmd = p.statusline.Update(msg)
cmds = append(cmds, cmd)
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateTasksCmd)
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
@ -140,22 +194,87 @@ func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (p *TaskEditorPage) View() string {
return p.common.Styles.Main.Render(p.form.View())
return lipgloss.JoinVertical(
lipgloss.Left,
// lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceBackground(p.common.Styles.Warning.GetForeground())),
lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceChars(".")),
p.statusline.View(),
)
}
func (p *TaskEditorPage) addTaskCmd() tea.Msg {
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
p.common.TW.AddTask(&p.task)
return AddedTaskMsg{}
return UpdatedTasksMsg{}
}
func (p *TaskEditorPage) editTaskCmd() tea.Msg {
p.common.TW.ModifyTask(&p.task)
return EditedTaskMsg{}
func (p *TaskEditorPage) switchModeCmd(mode Mode) tea.Cmd {
return func() tea.Msg {
return SwitchModeMsg(mode)
}
}
type AddedTaskMsg struct{}
type UpdatedTasksMsg struct{}
type SwitchModeMsg Mode
type EditedTaskMsg struct{}
type StatusLine struct {
common *common.Common
mode Mode
input textinput.Model
}
func NewStatusLine(common *common.Common, mode Mode) *StatusLine {
input := textinput.New()
input.Placeholder = ""
input.Prompt = ""
input.Blur()
return &StatusLine{
input: textinput.New(),
common: common,
mode: mode,
}
}
func (s *StatusLine) Init() tea.Cmd {
s.input.Blur()
return nil
}
func (s *StatusLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case SwitchModeMsg:
s.mode = Mode(msg)
switch s.mode {
case ModeNormal:
s.input.Blur()
case ModeInsert:
s.input.Focus()
}
case tea.KeyMsg:
switch {
case key.Matches(msg, s.common.Keymap.Back):
s.input.Blur()
case key.Matches(msg, s.common.Keymap.Input):
s.input.Focus()
}
}
s.input, cmd = s.input.Update(msg)
return s, cmd
}
func (s *StatusLine) View() string {
var mode string
switch s.mode {
case ModeNormal:
mode = s.common.Styles.Main.Render("NORMAL")
case ModeInsert:
mode = s.common.Styles.Active.Inline(true).Render("INSERT")
}
return lipgloss.JoinHorizontal(lipgloss.Left, mode, s.input.View())
}
// TODO: move this to taskwarrior; add missing date formats
func validateDate(s string) error {

View File

@ -10,9 +10,23 @@ type TWConfig struct {
config map[string]string
}
var (
defaultConfig = map[string]string{
"uda.tasksquire.report.default": "next",
}
)
func NewConfig(config []string) *TWConfig {
cfg := parseConfig(config)
for key, value := range defaultConfig {
if _, ok := cfg[key]; !ok {
cfg[key] = value
}
}
return &TWConfig{
config: parseConfig(config),
config: cfg,
}
}

View File

@ -10,25 +10,25 @@ import (
)
type Task struct {
Id int64 `json:"id"`
Uuid string `json:"uuid"`
Description string `json:"description"`
Project string `json:"project"`
Priority string `json:"priority"`
Status string `json:"status"`
Tags []string `json:"tags"`
Depends []string `json:"depends"`
Urgency float32 `json:"urgency"`
Parent string `json:"parent"`
Due string `json:"due"`
Wait string `json:"wait"`
Scheduled string `json:"scheduled"`
Until string `json:"until"`
Start string `json:"start"`
End string `json:"end"`
Entry string `json:"entry"`
Modified string `json:"modified"`
Recur string `json:"recur"`
Id int64 `json:"id,omitempty"`
Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"`
Project string `json:"project,omitempty"`
Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags,omitempty"`
Depends []string `json:"depends,omitempty"`
Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"`
Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"`
Until string `json:"until,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Entry string `json:"entry,omitempty"`
Modified string `json:"modified,omitempty"`
Recur string `json:"recur,omitempty"`
}
func (t *Task) GetString(fieldWFormat string) string {

View File

@ -1,6 +1,7 @@
package taskwarrior
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
@ -87,7 +88,7 @@ type TaskWarrior interface {
GetTasks(report *Report, filter ...string) Tasks
AddTask(task *Task) error
ModifyTask(task *Task)
ImportTask(task *Task)
}
type TaskSquire struct {
@ -325,20 +326,20 @@ func (ts *TaskSquire) AddTask(task *Task) error {
}
// TODO error handling
func (ts *TaskSquire) ModifyTask(task *Task) {
func (ts *TaskSquire) ImportTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
jsonStr, err := json.Marshal(Tasks{task})
tasks, err := json.Marshal(Tasks{task})
if err != nil {
slog.Error("Failed marshalling task:", err)
}
cmd := exec.Command(twBinary, append([]string{"echo", string(jsonStr), "|"}, append(ts.defaultArgs, []string{"import", "-"}...)...)...)
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
cmd.Stdin = bytes.NewBuffer(tasks)
out, err := cmd.CombinedOutput()
strOut := string(out)
if err != nil {
slog.Error("Failed modifying task:", err, strOut)
slog.Error("Failed modifying task:", err, string(out))
}
}