package pages import ( "log/slog" "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" ) type TimeEditorPage struct { common *common.Common interval *timewarrior.Interval // Fields projectPicker *picker.Picker startEditor *timestampeditor.TimestampEditor endEditor *timestampeditor.TimestampEditor tagsInput *autocomplete.Autocomplete adjust bool // State 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) // 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} } } opts := []picker.PickerOption{ picker.WithOnCreate(projectOnCreate), } if selectedProject != "" { opts = append(opts, picker.WithDefaultValue(selectedProject)) } else { opts = append(opts, picker.WithFilterByDefault(true)) } projectPicker := picker.New( com, "Project", projectItemProvider, projectOnSelect, opts..., ) projectPicker.SetSize(50, 10) // Compact size for inline use // Create start timestamp editor startEditor := timestampeditor.New(com). Title("Start"). ValueFromString(interval.Start) // Create end timestamp editor endEditor := timestampeditor.New(com). Title("End"). ValueFromString(interval.End) // 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(remainingTags)) // Use remaining tags (without project) tagsInput.SetWidth(50) p := &TimeEditorPage{ 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 (project picker) p.currentField = 0 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 // Blur current field (project picker) p.blurCurrentField() // Advance to tags field p.currentField = 1 // Refresh tag autocomplete with filtered combinations cmds = append(cmds, p.updateTagSuggestions()) // Focus tags input cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): model, err := p.common.PopPage() if err != nil { slog.Error("page stack empty") return nil, tea.Quit } return model, BackCmd case key.Matches(msg, p.common.Keymap.Ok): // Handle Enter based on current field if p.currentField == 0 { // Project picker - let it handle Enter (will trigger projectSelectedMsg) break } if p.currentField == 1 { // Tags field if p.tagsInput.HasSuggestions() { // Let autocomplete handle suggestion selection break } // Tags confirmed without suggestions - advance to start timestamp p.blurCurrentField() p.currentField = 2 cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) } // For all other fields (2-4: start, end, adjust), 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) case key.Matches(msg, p.common.Keymap.Next): // Move to next field p.blurCurrentField() p.currentField = (p.currentField + 1) % p.totalFields cmds = append(cmds, p.focusCurrentField()) case key.Matches(msg, p.common.Keymap.Prev): // Move to previous field p.blurCurrentField() p.currentField = (p.currentField - 1 + p.totalFields) % p.totalFields cmds = append(cmds, p.focusCurrentField()) } case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) } // Update the currently focused field var cmd tea.Cmd switch p.currentField { 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 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 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 4: // Adjust (was 3) // Handle adjust toggle with space/enter if msg, ok := msg.(tea.KeyMsg); ok { if msg.String() == " " || msg.String() == "enter" { p.adjust = !p.adjust } } } cmds = append(cmds, cmd) return p, tea.Batch(cmds...) } 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 2: return p.startEditor.Focus() case 3: return p.endEditor.Focus() case 4: // Adjust checkbox doesn't need focus action return nil } return nil } func (p *TimeEditorPage) blurCurrentField() { switch p.currentField { case 0: // Picker doesn't have explicit Blur(), state handled by Update case 1: p.tagsInput.Blur() case 2: p.startEditor.Blur() case 3: p.endEditor.Blur() case 4: // Adjust checkbox doesn't need blur action } } func (p *TimeEditorPage) View() string { var sections []string // Title titleStyle := p.common.Styles.Form.Focused.Title sections = append(sections, titleStyle.Render("Edit Time Interval")) sections = append(sections, "") // 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 == 1 { // Changed from 0 sections = append(sections, tagsLabel) sections = append(sections, p.tagsInput.View()) descStyle := p.common.Styles.Form.Focused.Description sections = append(sections, descStyle.Render("Space separated, use \"\" for tags with spaces")) } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Tags")) sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.tagsInput.GetValue())) } sections = append(sections, "") sections = append(sections, "") // Start editor sections = append(sections, p.startEditor.View()) sections = append(sections, "") // End editor sections = append(sections, p.endEditor.View()) sections = append(sections, "") // Adjust checkbox (now field 4, was 3) adjustLabelStyle := p.common.Styles.Form.Focused.Title adjustLabel := adjustLabelStyle.Render("Adjust overlaps") var checkbox string if p.adjust { checkbox = "[X]" } else { checkbox = "[ ]" } 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")) descStyle := p.common.Styles.Form.Focused.Description sections = append(sections, descStyle.Render("Press space to toggle")) } else { blurredLabelStyle := p.common.Styles.Form.Blurred.Title sections = append(sections, blurredLabelStyle.Render("Adjust overlaps")) sections = append(sections, lipgloss.NewStyle().Faint(true).Render(checkbox+" Auto-adjust overlapping intervals")) } sections = append(sections, "") sections = append(sections, "") // Help text helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel")) return lipgloss.JoinVertical(lipgloss.Left, sections...) } func (p *TimeEditorPage) SetSize(width, height int) { p.common.SetSize(width, height) } func (p *TimeEditorPage) saveInterval() { // If it's an existing interval (has ID), delete it first so we can replace it with the modified version if p.interval.ID != 0 { err := p.common.TimeW.DeleteInterval(p.interval.ID) if err != nil { slog.Error("Failed to delete old interval during edit", "err", err) // Proceeding to import anyway, attempting to save user data } } p.interval.Start = p.startEditor.GetValueString() p.interval.End = p.endEditor.GetValueString() // Parse tags from input tags := parseTags(p.tagsInput.GetValue()) // Add project to tags if not already present if p.selectedProject != "" { projectTag := "project:" + p.selectedProject projectExists := false for _, tag := range tags { if tag == projectTag { projectExists = true break } } if !projectExists { tags = append([]string{projectTag}, tags...) // Prepend project tag } } p.interval.Tags = tags err := p.common.TimeW.ModifyInterval(p.interval, p.adjust) if err != nil { slog.Error("Failed to modify interval", "err", err) } } func parseTags(tagsStr string) []string { var tags []string var current strings.Builder inQuotes := false for _, r := range tagsStr { switch { case r == '"': inQuotes = !inQuotes case r == ' ' && !inQuotes: if current.Len() > 0 { tags = append(tags, current.String()) current.Reset() } default: current.WriteRune(r) } } if current.Len() > 0 { tags = append(tags, current.String()) } return tags } func formatTags(tags []string) string { var formatted []string for _, t := range tags { if strings.Contains(t, " ") { formatted = append(formatted, "\""+t+"\"") } else { formatted = append(formatted, t) } } 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 { // Check if this tag is a project tag (format: "project:projectname") if strings.HasPrefix(tag, "project:") { projectName := strings.TrimPrefix(tag, "project:") if foundProject == "" && projectSet[projectName] { foundProject = projectName // First matching project continue // Don't add to remaining tags } } 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 } projectTag := "project:" + project 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 == projectTag { // Found the project - now remove it from display var displayTags []string for _, t := range tags { if t != projectTag { 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 }