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) 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"). 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 // 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): 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): // 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) } // If picker is focused, let it handle the key below 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 != "" { 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 { 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 { 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 }