Add things

This commit is contained in:
Martin Pander
2026-02-04 13:13:04 +01:00
parent 474bb3dc07
commit 02fa2e503a
15 changed files with 1214 additions and 126 deletions

465
pages/projectTaskPicker.go Normal file
View File

@ -0,0 +1,465 @@
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
}