diff --git a/components/autocomplete/autocomplete.go b/components/autocomplete/autocomplete.go index f459b2b..248e74a 100644 --- a/components/autocomplete/autocomplete.go +++ b/components/autocomplete/autocomplete.go @@ -83,6 +83,12 @@ func (a *Autocomplete) SetMinChars(min int) { a.minChars = min } +// SetSuggestions updates the available suggestions +func (a *Autocomplete) SetSuggestions(suggestions []string) { + a.allSuggestions = suggestions + a.updateFilteredSuggestions() +} + // Init initializes the autocomplete func (a *Autocomplete) Init() tea.Cmd { return textinput.Blink diff --git a/pages/timeEditor.go b/pages/timeEditor.go index c573f6b..03f90ae 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -5,10 +5,12 @@ import ( "strings" "tasksquire/common" "tasksquire/components/autocomplete" + "tasksquire/components/picker" "tasksquire/components/timestampeditor" "tasksquire/timewarrior" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -18,17 +20,65 @@ type TimeEditorPage struct { interval *timewarrior.Interval // Fields - startEditor *timestampeditor.TimestampEditor - endEditor *timestampeditor.TimestampEditor - tagsInput *autocomplete.Autocomplete - adjust bool + projectPicker *picker.Picker + startEditor *timestampeditor.TimestampEditor + endEditor *timestampeditor.TimestampEditor + tagsInput *autocomplete.Autocomplete + adjust bool // State - currentField int - totalFields int + selectedProject string + currentField int + totalFields int +} + +type timeEditorProjectSelectedMsg struct { + project string } func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { + // Extract project from tags if it exists + projects := com.TW.GetProjects() + selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects) + if selectedProject == "" && len(projects) > 0 { + selectedProject = projects[0] // Default to first project (required) + } + + // Create project picker with onCreate support for new projects + projectItemProvider := func() []list.Item { + projects := com.TW.GetProjects() + items := make([]list.Item, len(projects)) + for i, proj := range projects { + items[i] = picker.NewItem(proj) + } + return items + } + + projectOnSelect := func(item list.Item) tea.Cmd { + return func() tea.Msg { + return timeEditorProjectSelectedMsg{project: item.FilterValue()} + } + } + + projectOnCreate := func(name string) tea.Cmd { + return func() tea.Msg { + return timeEditorProjectSelectedMsg{project: name} + } + } + + projectPicker := picker.New( + com, + "Project", + projectItemProvider, + projectOnSelect, + picker.WithOnCreate(projectOnCreate), + picker.WithFilterByDefault(true), + ) + projectPicker.SetSize(50, 10) // Compact size for inline use + if selectedProject != "" { + projectPicker.SelectItemByFilterValue(selectedProject) + } + // Create start timestamp editor startEditor := timestampeditor.New(com). Title("Start"). @@ -39,38 +89,50 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time Title("End"). ValueFromString(interval.End) - // Create tags autocomplete with combinations from past intervals - tagCombinations := com.TimeW.GetTagCombinations() + // Get tag combinations filtered by selected project + tagCombinations := filterTagCombinationsByProject( + com.TimeW.GetTagCombinations(), + selectedProject, + ) + tagsInput := autocomplete.New(tagCombinations, 3, 10) tagsInput.SetPlaceholder("Space separated, use \"\" for tags with spaces") - tagsInput.SetValue(formatTags(interval.Tags)) + tagsInput.SetValue(formatTags(remainingTags)) // Use remaining tags (without project) tagsInput.SetWidth(50) p := &TimeEditorPage{ - common: com, - interval: interval, - startEditor: startEditor, - endEditor: endEditor, - tagsInput: tagsInput, - adjust: true, // Enable :adjust by default - currentField: 0, - totalFields: 4, // Updated to include adjust field + common: com, + interval: interval, + projectPicker: projectPicker, + startEditor: startEditor, + endEditor: endEditor, + tagsInput: tagsInput, + adjust: true, // Enable :adjust by default + selectedProject: selectedProject, + currentField: 0, + totalFields: 5, // Now 5 fields: project, tags, start, end, adjust } return p } func (p *TimeEditorPage) Init() tea.Cmd { - // Focus the first field (tags) + // Focus the first field (project picker) p.currentField = 0 - p.tagsInput.Focus() - return p.tagsInput.Init() + return p.projectPicker.Init() } func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case timeEditorProjectSelectedMsg: + // Update selected project + p.selectedProject = msg.project + // Refresh tag autocomplete with filtered combinations + cmds = append(cmds, p.updateTagSuggestions()) + return p, tea.Batch(cmds...) + case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): @@ -82,14 +144,18 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return model, BackCmd case key.Matches(msg, p.common.Keymap.Ok): - // Save and exit - p.saveInterval() - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit + // Don't save if the project picker is focused - let it handle Enter + if p.currentField != 0 { + // Save and exit + p.saveInterval() + model, err := p.common.PopPage() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) } - return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) + // If picker is focused, let it handle the key below case key.Matches(msg, p.common.Keymap.Next): // Move to next field @@ -111,25 +177,31 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update the currently focused field var cmd tea.Cmd switch p.currentField { - case 0: + case 0: // Project picker + var model tea.Model + model, cmd = p.projectPicker.Update(msg) + if pk, ok := model.(*picker.Picker); ok { + p.projectPicker = pk + } + case 1: // Tags (was 0) var model tea.Model model, cmd = p.tagsInput.Update(msg) if ac, ok := model.(*autocomplete.Autocomplete); ok { p.tagsInput = ac } - case 1: + case 2: // Start (was 1) var model tea.Model model, cmd = p.startEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.startEditor = editor } - case 2: + case 3: // End (was 2) var model tea.Model model, cmd = p.endEditor.Update(msg) if editor, ok := model.(*timestampeditor.TimestampEditor); ok { p.endEditor = editor } - case 3: + case 4: // Adjust (was 3) // Handle adjust toggle with space/enter if msg, ok := msg.(tea.KeyMsg); ok { if msg.String() == " " || msg.String() == "enter" { @@ -145,13 +217,15 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p *TimeEditorPage) focusCurrentField() tea.Cmd { switch p.currentField { case 0: + return p.projectPicker.Init() // Picker doesn't have explicit Focus() + case 1: p.tagsInput.Focus() return p.tagsInput.Init() - case 1: - return p.startEditor.Focus() case 2: - return p.endEditor.Focus() + return p.startEditor.Focus() case 3: + return p.endEditor.Focus() + case 4: // Adjust checkbox doesn't need focus action return nil } @@ -161,12 +235,14 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd { func (p *TimeEditorPage) blurCurrentField() { switch p.currentField { case 0: - p.tagsInput.Blur() + // Picker doesn't have explicit Blur(), state handled by Update case 1: - p.startEditor.Blur() + p.tagsInput.Blur() case 2: - p.endEditor.Blur() + p.startEditor.Blur() case 3: + p.endEditor.Blur() + case 4: // Adjust checkbox doesn't need blur action } } @@ -179,10 +255,22 @@ func (p *TimeEditorPage) View() string { sections = append(sections, titleStyle.Render("Edit Time Interval")) sections = append(sections, "") - // Tags input (now first) + // Project picker (field 0) + if p.currentField == 0 { + sections = append(sections, p.projectPicker.View()) + } else { + blurredLabelStyle := p.common.Styles.Form.Blurred.Title + sections = append(sections, blurredLabelStyle.Render("Project")) + sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedProject)) + } + + sections = append(sections, "") + sections = append(sections, "") + + // Tags input (now field 1, was first) tagsLabelStyle := p.common.Styles.Form.Focused.Title tagsLabel := tagsLabelStyle.Render("Tags") - if p.currentField == 0 { + if p.currentField == 1 { // Changed from 0 sections = append(sections, tagsLabel) sections = append(sections, p.tagsInput.View()) descStyle := p.common.Styles.Form.Focused.Description @@ -204,7 +292,7 @@ func (p *TimeEditorPage) View() string { sections = append(sections, p.endEditor.View()) sections = append(sections, "") - // Adjust checkbox + // Adjust checkbox (now field 4, was 3) adjustLabelStyle := p.common.Styles.Form.Focused.Title adjustLabel := adjustLabelStyle.Render("Adjust overlaps") @@ -215,7 +303,7 @@ func (p *TimeEditorPage) View() string { checkbox = "[ ]" } - if p.currentField == 3 { + if p.currentField == 4 { // Changed from 3 focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) sections = append(sections, adjustLabel) sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals")) @@ -254,8 +342,24 @@ func (p *TimeEditorPage) saveInterval() { p.interval.Start = p.startEditor.GetValueString() p.interval.End = p.endEditor.GetValueString() - // Parse tags - p.interval.Tags = parseTags(p.tagsInput.GetValue()) + // Parse tags from input + tags := parseTags(p.tagsInput.GetValue()) + + // Add project to tags if not already present + if p.selectedProject != "" { + projectExists := false + for _, tag := range tags { + if tag == p.selectedProject { + projectExists = true + break + } + } + if !projectExists { + tags = append([]string{p.selectedProject}, tags...) // Prepend project + } + } + + p.interval.Tags = tags err := p.common.TimeW.ModifyInterval(p.interval, p.adjust) if err != nil { @@ -298,3 +402,79 @@ func formatTags(tags []string) string { } return strings.Join(formatted, " ") } + +// extractProjectFromTags finds and removes the first tag that matches a known project +// Returns the found project (or empty string) and the remaining tags +func extractProjectFromTags(tags []string, projects []string) (string, []string) { + projectSet := make(map[string]bool) + for _, p := range projects { + projectSet[p] = true + } + + var foundProject string + var remaining []string + + for _, tag := range tags { + if foundProject == "" && projectSet[tag] { + foundProject = tag // First matching project + } else { + remaining = append(remaining, tag) + } + } + + return foundProject, remaining +} + +// filterTagCombinationsByProject filters tag combinations to only show those +// containing the exact project tag, and removes the project from the displayed combination +func filterTagCombinationsByProject(combinations []string, project string) []string { + if project == "" { + return combinations + } + + var filtered []string + for _, combo := range combinations { + // Parse the combination into individual tags + tags := parseTags(combo) + + // Check if project exists in this combination + for _, tag := range tags { + if tag == project { + // Found the project - now remove it from display + var displayTags []string + for _, t := range tags { + if t != project { + displayTags = append(displayTags, t) + } + } + if len(displayTags) > 0 { + filtered = append(filtered, formatTags(displayTags)) + } + break + } + } + } + + return filtered +} + +// updateTagSuggestions refreshes the tag autocomplete with filtered combinations +func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd { + combinations := filterTagCombinationsByProject( + p.common.TimeW.GetTagCombinations(), + p.selectedProject, + ) + + // Update autocomplete suggestions + currentValue := p.tagsInput.GetValue() + p.tagsInput.SetSuggestions(combinations) + p.tagsInput.SetValue(currentValue) + + // If tags field is focused, refocus it + if p.currentField == 1 { + p.tagsInput.Focus() + return p.tagsInput.Init() + } + + return nil +}