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 }