466 lines
12 KiB
Go
466 lines
12 KiB
Go
package pages
|
|
|
|
import (
|
|
"fmt"
|
|
"tasksquire/common"
|
|
"tasksquire/components/picker"
|
|
"tasksquire/taskwarrior"
|
|
"tasksquire/timewarrior"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type ProjectTaskPickerPage struct {
|
|
common *common.Common
|
|
|
|
// Both pickers visible simultaneously
|
|
projectPicker *picker.Picker
|
|
taskPicker *picker.Picker
|
|
selectedProject string
|
|
selectedTask *taskwarrior.Task
|
|
|
|
// Focus tracking: 0 = project picker, 1 = task picker
|
|
focusedPicker int
|
|
}
|
|
|
|
type projectTaskPickerProjectSelectedMsg struct {
|
|
project string
|
|
}
|
|
|
|
type projectTaskPickerTaskSelectedMsg struct {
|
|
task *taskwarrior.Task
|
|
}
|
|
|
|
func NewProjectTaskPickerPage(com *common.Common) *ProjectTaskPickerPage {
|
|
p := &ProjectTaskPickerPage{
|
|
common: com,
|
|
focusedPicker: 0,
|
|
}
|
|
|
|
// Create project picker
|
|
projectItemProvider := func() []list.Item {
|
|
projects := com.TW.GetProjects()
|
|
items := make([]list.Item, 0, len(projects))
|
|
|
|
for _, proj := range projects {
|
|
items = append(items, picker.NewItem(proj))
|
|
}
|
|
return items
|
|
}
|
|
|
|
projectOnSelect := func(item list.Item) tea.Cmd {
|
|
return func() tea.Msg {
|
|
return projectTaskPickerProjectSelectedMsg{project: item.FilterValue()}
|
|
}
|
|
}
|
|
|
|
p.projectPicker = picker.New(
|
|
com,
|
|
"Projects",
|
|
projectItemProvider,
|
|
projectOnSelect,
|
|
)
|
|
|
|
// Initialize with the first project's tasks
|
|
projects := com.TW.GetProjects()
|
|
if len(projects) > 0 {
|
|
p.selectedProject = projects[0]
|
|
p.createTaskPicker(projects[0])
|
|
} else {
|
|
// No projects - create empty task picker
|
|
p.createTaskPicker("")
|
|
}
|
|
|
|
p.SetSize(com.Width(), com.Height())
|
|
|
|
return p
|
|
}
|
|
|
|
func (p *ProjectTaskPickerPage) createTaskPicker(project string) {
|
|
// Build filters for tasks
|
|
filters := []string{"+track", "status:pending"}
|
|
|
|
if project != "" {
|
|
// Tasks in the selected project
|
|
filters = append(filters, "project:"+project)
|
|
}
|
|
|
|
taskItemProvider := func() []list.Item {
|
|
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
items := make([]list.Item, 0, len(tasks))
|
|
|
|
for i := range tasks {
|
|
// Just use the description as the item text
|
|
// picker.NewItem creates a simple item with title and filter value
|
|
items = append(items, picker.NewItem(tasks[i].Description))
|
|
}
|
|
return items
|
|
}
|
|
|
|
taskOnSelect := func(item list.Item) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Find the task by description
|
|
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
for _, task := range tasks {
|
|
if task.Description == item.FilterValue() {
|
|
// tasks is already []*Task, so task is already *Task
|
|
return projectTaskPickerTaskSelectedMsg{task: task}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
title := "Tasks with +track"
|
|
if project != "" {
|
|
title = fmt.Sprintf("Tasks: %s", project)
|
|
}
|
|
|
|
p.taskPicker = picker.New(
|
|
p.common,
|
|
title,
|
|
taskItemProvider,
|
|
taskOnSelect,
|
|
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
|
|
)
|
|
}
|
|
|
|
func (p *ProjectTaskPickerPage) Init() tea.Cmd {
|
|
// Focus the project picker initially
|
|
p.projectPicker.Focus()
|
|
return tea.Batch(p.projectPicker.Init(), p.taskPicker.Init())
|
|
}
|
|
|
|
func (p *ProjectTaskPickerPage) 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 projectTaskPickerProjectSelectedMsg:
|
|
// Project selected - update task picker
|
|
p.selectedProject = msg.project
|
|
p.createTaskPicker(msg.project)
|
|
// Move focus to task picker
|
|
p.projectPicker.Blur()
|
|
p.taskPicker.Focus()
|
|
p.focusedPicker = 1
|
|
p.SetSize(p.common.Width(), p.common.Height())
|
|
return p, p.taskPicker.Init()
|
|
|
|
case projectTaskPickerTaskSelectedMsg:
|
|
// Task selected - emit TaskPickedMsg and return to parent
|
|
p.selectedTask = msg.task
|
|
model, err := p.common.PopPage()
|
|
if err != nil {
|
|
return p, tea.Quit
|
|
}
|
|
return model, func() tea.Msg {
|
|
return TaskPickedMsg{Task: p.selectedTask}
|
|
}
|
|
|
|
case UpdatedTasksMsg:
|
|
// Task was edited - refresh the task list and recreate the task picker
|
|
if p.selectedProject != "" {
|
|
p.createTaskPicker(p.selectedProject)
|
|
p.SetSize(p.common.Width(), p.common.Height())
|
|
// Keep the task picker focused
|
|
p.taskPicker.Focus()
|
|
p.focusedPicker = 1
|
|
return p, p.taskPicker.Init()
|
|
}
|
|
return p, nil
|
|
|
|
case tea.KeyMsg:
|
|
// Check if the focused picker is in filtering mode BEFORE handling any keys
|
|
var focusedPickerFiltering bool
|
|
if p.focusedPicker == 0 {
|
|
focusedPickerFiltering = p.projectPicker.IsFiltering()
|
|
} else {
|
|
focusedPickerFiltering = p.taskPicker.IsFiltering()
|
|
}
|
|
|
|
switch {
|
|
case key.Matches(msg, p.common.Keymap.Back):
|
|
// If the focused picker is filtering, let it handle the escape key to dismiss the filter
|
|
// and don't exit the page
|
|
if focusedPickerFiltering {
|
|
// Don't handle the Back key - let it fall through to the picker
|
|
break
|
|
}
|
|
// Exit picker completely
|
|
model, err := p.common.PopPage()
|
|
if err != nil {
|
|
return p, tea.Quit
|
|
}
|
|
return model, BackCmd
|
|
|
|
case key.Matches(msg, p.common.Keymap.Add):
|
|
// Don't handle 'a' if focused picker is filtering - let the picker handle it for typing
|
|
if focusedPickerFiltering {
|
|
break
|
|
}
|
|
// Create new task with selected project and track tag pre-filled
|
|
newTask := taskwarrior.NewTask()
|
|
newTask.Project = p.selectedProject
|
|
newTask.Tags = []string{"track"}
|
|
|
|
// Open task editor with pre-populated task
|
|
taskEditor := NewTaskEditorPage(p.common, newTask)
|
|
p.common.PushPage(p)
|
|
return taskEditor, taskEditor.Init()
|
|
|
|
case key.Matches(msg, p.common.Keymap.Edit):
|
|
// Don't handle 'e' if focused picker is filtering - let the picker handle it for typing
|
|
if focusedPickerFiltering {
|
|
break
|
|
}
|
|
// Edit task when task picker is focused and a task is selected
|
|
if p.focusedPicker == 1 && p.selectedProject != "" {
|
|
// Get the currently highlighted task
|
|
selectedItemText := p.taskPicker.GetValue()
|
|
if selectedItemText != "" {
|
|
// Find the task by description
|
|
filters := []string{"+track", "status:pending"}
|
|
filters = append(filters, "project:"+p.selectedProject)
|
|
|
|
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
for _, task := range tasks {
|
|
if task.Description == selectedItemText {
|
|
// Found the task - open editor
|
|
p.selectedTask = task
|
|
taskEditor := NewTaskEditorPage(p.common, *task)
|
|
p.common.PushPage(p)
|
|
return taskEditor, taskEditor.Init()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return p, nil
|
|
|
|
case key.Matches(msg, p.common.Keymap.Tag):
|
|
// Don't handle 't' if focused picker is filtering - let the picker handle it for typing
|
|
if focusedPickerFiltering {
|
|
break
|
|
}
|
|
// Open time editor with task pre-filled when task picker is focused
|
|
if p.focusedPicker == 1 && p.selectedProject != "" {
|
|
// Get the currently highlighted task
|
|
selectedItemText := p.taskPicker.GetValue()
|
|
if selectedItemText != "" {
|
|
// Find the task by description
|
|
filters := []string{"+track", "status:pending"}
|
|
filters = append(filters, "project:"+p.selectedProject)
|
|
|
|
tasks := p.common.TW.GetTasks(nil, filters...)
|
|
for _, task := range tasks {
|
|
if task.Description == selectedItemText {
|
|
// Found the task - create new interval with task pre-filled
|
|
interval := createIntervalFromTask(task)
|
|
|
|
// Open time editor with pre-populated interval
|
|
timeEditor := NewTimeEditorPage(p.common, interval)
|
|
p.common.PushPage(p)
|
|
return timeEditor, timeEditor.Init()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return p, nil
|
|
|
|
case key.Matches(msg, p.common.Keymap.Next):
|
|
// Tab: switch focus between pickers
|
|
if p.focusedPicker == 0 {
|
|
p.projectPicker.Blur()
|
|
p.taskPicker.Focus()
|
|
p.focusedPicker = 1
|
|
} else {
|
|
p.taskPicker.Blur()
|
|
p.projectPicker.Focus()
|
|
p.focusedPicker = 0
|
|
}
|
|
return p, nil
|
|
|
|
case key.Matches(msg, p.common.Keymap.Prev):
|
|
// Shift+Tab: switch focus between pickers (reverse)
|
|
if p.focusedPicker == 1 {
|
|
p.taskPicker.Blur()
|
|
p.projectPicker.Focus()
|
|
p.focusedPicker = 0
|
|
} else {
|
|
p.projectPicker.Blur()
|
|
p.taskPicker.Focus()
|
|
p.focusedPicker = 1
|
|
}
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
// Update the focused picker
|
|
var cmd tea.Cmd
|
|
if p.focusedPicker == 0 {
|
|
// Track the previous project selection
|
|
previousProject := p.selectedProject
|
|
|
|
_, cmd = p.projectPicker.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// Check if the highlighted project changed
|
|
currentProject := p.projectPicker.GetValue()
|
|
if currentProject != previousProject && currentProject != "" {
|
|
// Update the selected project and refresh task picker
|
|
p.selectedProject = currentProject
|
|
p.createTaskPicker(currentProject)
|
|
p.SetSize(p.common.Width(), p.common.Height())
|
|
cmds = append(cmds, p.taskPicker.Init())
|
|
}
|
|
} else {
|
|
_, cmd = p.taskPicker.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
return p, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (p *ProjectTaskPickerPage) View() string {
|
|
// Render both pickers (they handle their own focused/blurred styling)
|
|
projectView := p.projectPicker.View()
|
|
taskView := p.taskPicker.View()
|
|
|
|
// Create distinct styling for focused vs blurred pickers
|
|
var projectStyled, taskStyled string
|
|
|
|
if p.focusedPicker == 0 {
|
|
// Project picker is focused
|
|
projectStyled = lipgloss.NewStyle().
|
|
Border(lipgloss.ThickBorder()).
|
|
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
|
Padding(0, 1).
|
|
Render(projectView)
|
|
|
|
taskStyled = lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
|
Padding(0, 1).
|
|
Render(taskView)
|
|
} else {
|
|
// Task picker is focused
|
|
projectStyled = lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder()).
|
|
BorderForeground(lipgloss.Color("240")). // Gray for blurred
|
|
Padding(0, 1).
|
|
Render(projectView)
|
|
|
|
taskStyled = lipgloss.NewStyle().
|
|
Border(lipgloss.ThickBorder()).
|
|
BorderForeground(lipgloss.Color("6")). // Cyan for focused
|
|
Padding(0, 1).
|
|
Render(taskView)
|
|
}
|
|
|
|
// Layout side by side if width permits, otherwise stack vertically
|
|
var content string
|
|
if p.common.Width() >= 100 {
|
|
// Side by side layout
|
|
content = lipgloss.JoinHorizontal(lipgloss.Top, projectStyled, " ", taskStyled)
|
|
} else {
|
|
// Vertical stack layout
|
|
content = lipgloss.JoinVertical(lipgloss.Left, projectStyled, "", taskStyled)
|
|
}
|
|
|
|
// Add help text
|
|
helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel"
|
|
helpStyled := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("241")).
|
|
Italic(true).
|
|
Render(helpText)
|
|
|
|
fullContent := lipgloss.JoinVertical(lipgloss.Left, content, "", helpStyled)
|
|
|
|
return lipgloss.Place(
|
|
p.common.Width(),
|
|
p.common.Height(),
|
|
lipgloss.Center,
|
|
lipgloss.Center,
|
|
fullContent,
|
|
)
|
|
}
|
|
|
|
func (p *ProjectTaskPickerPage) SetSize(width, height int) {
|
|
p.common.SetSize(width, height)
|
|
|
|
// Calculate sizes based on layout
|
|
var projectWidth, taskWidth, listHeight int
|
|
|
|
if width >= 100 {
|
|
// Side by side layout
|
|
projectWidth = 30
|
|
taskWidth = width - projectWidth - 10 // Account for margins and padding
|
|
if taskWidth > 60 {
|
|
taskWidth = 60
|
|
}
|
|
} else {
|
|
// Vertical stack layout
|
|
projectWidth = width - 8
|
|
taskWidth = width - 8
|
|
if projectWidth > 60 {
|
|
projectWidth = 60
|
|
}
|
|
if taskWidth > 60 {
|
|
taskWidth = 60
|
|
}
|
|
}
|
|
|
|
// Height for each picker
|
|
listHeight = height - 10 // Account for help text and padding
|
|
if listHeight > 25 {
|
|
listHeight = 25
|
|
}
|
|
if listHeight < 10 {
|
|
listHeight = 10
|
|
}
|
|
|
|
if p.projectPicker != nil {
|
|
p.projectPicker.SetSize(projectWidth, listHeight)
|
|
}
|
|
|
|
if p.taskPicker != nil {
|
|
p.taskPicker.SetSize(taskWidth, listHeight)
|
|
}
|
|
}
|
|
|
|
// createIntervalFromTask creates a new time interval pre-filled with task metadata
|
|
func createIntervalFromTask(task *taskwarrior.Task) *timewarrior.Interval {
|
|
interval := timewarrior.NewInterval()
|
|
|
|
// Set start time to now (UTC format)
|
|
interval.Start = time.Now().UTC().Format("20060102T150405Z")
|
|
// Leave End empty for active tracking
|
|
interval.End = ""
|
|
|
|
// Build tags from task metadata
|
|
tags := []string{}
|
|
|
|
// Add UUID tag for task linking
|
|
if task.Uuid != "" {
|
|
tags = append(tags, "uuid:"+task.Uuid)
|
|
}
|
|
|
|
// Add project tag
|
|
if task.Project != "" {
|
|
tags = append(tags, "project:"+task.Project)
|
|
}
|
|
|
|
// Add existing task tags (excluding virtual tags)
|
|
tags = append(tags, task.Tags...)
|
|
|
|
interval.Tags = tags
|
|
|
|
return interval
|
|
}
|