Compare commits

3 Commits

Author SHA1 Message Date
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
2b31d9bc2b Add autocompletion 2026-02-03 07:39:26 +01:00
2 changed files with 229 additions and 43 deletions

View File

@ -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

View File

@ -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
}