diff --git a/components/autocomplete/autocomplete.go b/components/autocomplete/autocomplete.go deleted file mode 100644 index 6c1d6f8..0000000 --- a/components/autocomplete/autocomplete.go +++ /dev/null @@ -1,293 +0,0 @@ -package autocomplete - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/sahilm/fuzzy" -) - -type Autocomplete struct { - input textinput.Model - allSuggestions []string // All available suggestions (newest first) - filteredSuggestions []string // Currently matching suggestions - matchedIndexes [][]int // Matched character positions for each suggestion - selectedIndex int // -1 = input focused, 0+ = suggestion selected - showSuggestions bool // Whether to display suggestion box - maxVisible int // Max suggestions to show - minChars int // Min chars before showing suggestions - focused bool - width int - placeholder string -} - -// New creates a new autocomplete component -func New(suggestions []string, minChars int, maxVisible int) *Autocomplete { - ti := textinput.New() - ti.Width = 50 - - return &Autocomplete{ - input: ti, - allSuggestions: suggestions, - selectedIndex: -1, - maxVisible: maxVisible, - minChars: minChars, - width: 50, - } -} - -// SetValue sets the input value -func (a *Autocomplete) SetValue(value string) { - a.input.SetValue(value) - a.updateFilteredSuggestions() -} - -// GetValue returns the current input value -func (a *Autocomplete) GetValue() string { - return a.input.Value() -} - -// Focus focuses the autocomplete input -func (a *Autocomplete) Focus() { - a.focused = true - a.input.Focus() -} - -// Blur blurs the autocomplete input -func (a *Autocomplete) Blur() { - a.focused = false - a.input.Blur() - a.showSuggestions = false -} - -// SetPlaceholder sets the placeholder text -func (a *Autocomplete) SetPlaceholder(placeholder string) { - a.placeholder = placeholder - a.input.Placeholder = placeholder -} - -// SetWidth sets the width of the autocomplete -func (a *Autocomplete) SetWidth(width int) { - a.width = width - a.input.Width = width -} - -// SetMaxVisible sets the maximum number of visible suggestions -func (a *Autocomplete) SetMaxVisible(max int) { - a.maxVisible = max -} - -// SetMinChars sets the minimum characters required before showing suggestions -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() -} - -// HasSuggestions returns true if the autocomplete is currently showing suggestions -func (a *Autocomplete) HasSuggestions() bool { - return a.showSuggestions && len(a.filteredSuggestions) > 0 -} - -// Init initializes the autocomplete -func (a *Autocomplete) Init() tea.Cmd { - return textinput.Blink -} - -// Update handles messages for the autocomplete -func (a *Autocomplete) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if !a.focused { - return a, nil - } - - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, key.NewBinding(key.WithKeys("down"))): - if a.showSuggestions && len(a.filteredSuggestions) > 0 { - a.selectedIndex++ - if a.selectedIndex >= len(a.filteredSuggestions) { - a.selectedIndex = 0 - } - return a, nil - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("up"))): - if a.showSuggestions && len(a.filteredSuggestions) > 0 { - a.selectedIndex-- - if a.selectedIndex < 0 { - a.selectedIndex = len(a.filteredSuggestions) - 1 - } - return a, nil - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): - if a.showSuggestions && a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) { - // Accept selected suggestion - a.input.SetValue(a.filteredSuggestions[a.selectedIndex]) - a.showSuggestions = false - a.selectedIndex = -1 - return a, nil - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): - if a.showSuggestions && len(a.filteredSuggestions) > 0 { - // Accept first or selected suggestion - if a.selectedIndex >= 0 && a.selectedIndex < len(a.filteredSuggestions) { - a.input.SetValue(a.filteredSuggestions[a.selectedIndex]) - } else { - a.input.SetValue(a.filteredSuggestions[0]) - } - a.showSuggestions = false - a.selectedIndex = -1 - return a, nil - } - - case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): - if a.showSuggestions { - a.showSuggestions = false - a.selectedIndex = -1 - return a, nil - } - - default: - // Handle regular text input - prevValue := a.input.Value() - a.input, cmd = a.input.Update(msg) - - // Update suggestions if value changed - if a.input.Value() != prevValue { - a.updateFilteredSuggestions() - } - - return a, cmd - } - } - - a.input, cmd = a.input.Update(msg) - return a, cmd -} - -// View renders the autocomplete -func (a *Autocomplete) View() string { - // Input field - inputView := a.input.View() - - if !a.showSuggestions || len(a.filteredSuggestions) == 0 { - return inputView - } - - // Suggestion box - var suggestionViews []string - for i, suggestion := range a.filteredSuggestions { - if i >= a.maxVisible { - break - } - - prefix := " " - baseStyle := lipgloss.NewStyle().Padding(0, 1) - - if i == a.selectedIndex { - // Highlight selected suggestion - baseStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("12")) - prefix = "→ " - } - - // Build suggestion with highlighted matched characters - var rendered string - if i < len(a.matchedIndexes) { - rendered = a.renderWithHighlights(suggestion, a.matchedIndexes[i], i == a.selectedIndex) - } else { - rendered = suggestion - } - - suggestionViews = append(suggestionViews, baseStyle.Render(prefix+rendered)) - } - - // Box style - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). - Width(a.width) - - suggestionsBox := boxStyle.Render( - lipgloss.JoinVertical(lipgloss.Left, suggestionViews...), - ) - - return lipgloss.JoinVertical(lipgloss.Left, inputView, suggestionsBox) -} - -// renderWithHighlights renders a suggestion with matched characters highlighted -func (a *Autocomplete) renderWithHighlights(str string, matchedIndexes []int, isSelected bool) string { - if len(matchedIndexes) == 0 { - return str - } - - // Create a map for quick lookup - matchedMap := make(map[int]bool) - for _, idx := range matchedIndexes { - matchedMap[idx] = true - } - - // Choose highlight style based on selection state - var highlightStyle lipgloss.Style - if isSelected { - // When selected, use underline to distinguish from selection bold - highlightStyle = lipgloss.NewStyle().Underline(true) - } else { - // When not selected, use bold and accent color - highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) - } - - // Build the string with highlights - var result string - runes := []rune(str) - for i, r := range runes { - if matchedMap[i] { - result += highlightStyle.Render(string(r)) - } else { - result += string(r) - } - } - - return result -} - -// updateFilteredSuggestions filters suggestions based on current input -func (a *Autocomplete) updateFilteredSuggestions() { - value := a.input.Value() - - // Only show if >= minChars - if len(value) < a.minChars { - a.showSuggestions = false - a.filteredSuggestions = nil - a.matchedIndexes = nil - a.selectedIndex = -1 - return - } - - // Fuzzy match using sahilm/fuzzy - matches := fuzzy.Find(value, a.allSuggestions) - - var filtered []string - var indexes [][]int - for _, match := range matches { - filtered = append(filtered, match.Str) - indexes = append(indexes, match.MatchedIndexes) - if len(filtered) >= a.maxVisible { - break - } - } - - a.filteredSuggestions = filtered - a.matchedIndexes = indexes - a.showSuggestions = len(filtered) > 0 && a.focused - a.selectedIndex = -1 // Reset to input -} diff --git a/components/detailsviewer/detailsviewer.go b/components/detailsviewer/detailsviewer.go deleted file mode 100644 index db37870..0000000 --- a/components/detailsviewer/detailsviewer.go +++ /dev/null @@ -1,174 +0,0 @@ -package detailsviewer - -import ( - "log/slog" - "tasksquire/common" - "tasksquire/taskwarrior" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" -) - -// DetailsViewer is a reusable component for displaying task details -type DetailsViewer struct { - common *common.Common - viewport viewport.Model - task *taskwarrior.Task - focused bool - width int - height int -} - -// New creates a new DetailsViewer component -func New(com *common.Common) *DetailsViewer { - return &DetailsViewer{ - common: com, - viewport: viewport.New(0, 0), - focused: false, - } -} - -// SetTask updates the task to display -func (d *DetailsViewer) SetTask(task *taskwarrior.Task) { - d.task = task - d.updateContent() -} - -// Focus sets the component to focused state (for future interactivity) -func (d *DetailsViewer) Focus() { - d.focused = true -} - -// Blur sets the component to blurred state -func (d *DetailsViewer) Blur() { - d.focused = false -} - -// IsFocused returns whether the component is focused -func (d *DetailsViewer) IsFocused() bool { - return d.focused -} - -// SetSize implements common.Component -func (d *DetailsViewer) SetSize(width, height int) { - d.width = width - d.height = height - - // Account for border and padding (4 chars horizontal, 4 lines vertical) - d.viewport.Width = max(width-4, 0) - d.viewport.Height = max(height-4, 0) - - // Refresh content with new width - d.updateContent() -} - -// Init implements tea.Model -func (d *DetailsViewer) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model -func (d *DetailsViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - d.viewport, cmd = d.viewport.Update(msg) - return d, cmd -} - -// View implements tea.Model -func (d *DetailsViewer) View() string { - // Title bar - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(d.common.Styles.Palette.Text.GetForeground()) - - helpStyle := lipgloss.NewStyle(). - Foreground(d.common.Styles.Palette.Muted.GetForeground()) - - header := lipgloss.JoinHorizontal( - lipgloss.Left, - titleStyle.Render("Details"), - " ", - helpStyle.Render("(↑/↓ scroll, v/ESC close)"), - ) - - // Container style - containerStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(d.common.Styles.Palette.Border.GetForeground()). - Padding(0, 1). - Width(d.width). - Height(d.height) - - // Optional: highlight border when focused (for future interactivity) - if d.focused { - containerStyle = containerStyle. - BorderForeground(d.common.Styles.Palette.Accent.GetForeground()) - } - - content := lipgloss.JoinVertical( - lipgloss.Left, - header, - d.viewport.View(), - ) - - return containerStyle.Render(content) -} - -// updateContent refreshes the viewport content based on current task -func (d *DetailsViewer) updateContent() { - if d.task == nil { - d.viewport.SetContent("(No task selected)") - return - } - - detailsValue := "" - if details, ok := d.task.Udas["details"]; ok && details != nil { - detailsValue = details.(string) - } - - if detailsValue == "" { - d.viewport.SetContent("(No details for this task)") - return - } - - // Render markdown with glamour - renderer, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), - glamour.WithWordWrap(d.viewport.Width), - ) - if err != nil { - slog.Error("failed to create markdown renderer", "error", err) - // Fallback to plain text - wrapped := lipgloss.NewStyle(). - Width(d.viewport.Width). - Render(detailsValue) - d.viewport.SetContent(wrapped) - d.viewport.GotoTop() - return - } - - rendered, err := renderer.Render(detailsValue) - if err != nil { - slog.Error("failed to render markdown", "error", err) - // Fallback to plain text - wrapped := lipgloss.NewStyle(). - Width(d.viewport.Width). - Render(detailsValue) - d.viewport.SetContent(wrapped) - d.viewport.GotoTop() - return - } - - d.viewport.SetContent(rendered) - d.viewport.GotoTop() -} - -// Helper function -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/components/input/multiselect.go b/components/input/multiselect.go deleted file mode 100644 index 169707c..0000000 --- a/components/input/multiselect.go +++ /dev/null @@ -1,672 +0,0 @@ -package input - -import ( - "fmt" - "strings" - "tasksquire/common" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/huh/accessibility" - "github.com/charmbracelet/lipgloss" -) - -// MultiSelect is a form multi-select field. -type MultiSelect struct { - common *common.Common - - value *[]string - key string - - // customization - title string - description string - options []Option[string] - filterable bool - filteredOptions []Option[string] - limit int - height int - - // error handling - validate func([]string) error - err error - - // state - cursor int - focused bool - filtering bool - filter textinput.Model - viewport viewport.Model - - // options - width int - accessible bool - theme *huh.Theme - keymap huh.MultiSelectKeyMap - - // new - hasNewOption bool - newInput textinput.Model - newInputActive bool -} - -// NewMultiSelect returns a new multi-select field. -func NewMultiSelect(common *common.Common) *MultiSelect { - filter := textinput.New() - filter.Prompt = "/" - - newInput := textinput.New() - newInput.Prompt = "New: " - - return &MultiSelect{ - common: common, - options: []Option[string]{}, - value: new([]string), - validate: func([]string) error { return nil }, - filtering: false, - filter: filter, - newInput: newInput, - newInputActive: false, - } -} - -// Value sets the value of the multi-select field. -func (m *MultiSelect) Value(value *[]string) *MultiSelect { - m.value = value - for i, o := range m.options { - for _, v := range *value { - if o.Value == v { - m.options[i].selected = true - break - } - } - } - return m -} - -// Key sets the key of the select field which can be used to retrieve the value -// after submission. -func (m *MultiSelect) Key(key string) *MultiSelect { - m.key = key - return m -} - -// Title sets the title of the multi-select field. -func (m *MultiSelect) Title(title string) *MultiSelect { - m.title = title - return m -} - -// Description sets the description of the multi-select field. -func (m *MultiSelect) Description(description string) *MultiSelect { - m.description = description - return m -} - -// Options sets the options of the multi-select field. -func (m *MultiSelect) Options(hasNewOption bool, options ...Option[string]) *MultiSelect { - m.hasNewOption = hasNewOption - - if m.hasNewOption { - newOption := []Option[string]{ - {Key: "(new)", Value: ""}, - } - options = append(newOption, options...) - } - - if len(options) <= 0 { - return m - } - - for i, o := range options { - for _, v := range *m.value { - if o.Value == v { - options[i].selected = true - break - } - } - } - m.options = options - m.filteredOptions = options - m.updateViewportHeight() - return m -} - -// Filterable sets the multi-select field as filterable. -func (m *MultiSelect) Filterable(filterable bool) *MultiSelect { - m.filterable = filterable - return m -} - -// Limit sets the limit of the multi-select field. -func (m *MultiSelect) Limit(limit int) *MultiSelect { - m.limit = limit - return m -} - -// Height sets the height of the multi-select field. -func (m *MultiSelect) Height(height int) *MultiSelect { - // What we really want to do is set the height of the viewport, but we - // need a theme applied before we can calcualate its height. - m.height = height - m.updateViewportHeight() - return m -} - -// Validate sets the validation function of the multi-select field. -func (m *MultiSelect) Validate(validate func([]string) error) *MultiSelect { - m.validate = validate - return m -} - -// Error returns the error of the multi-select field. -func (m *MultiSelect) Error() error { - return m.err -} - -// Skip returns whether the multiselect should be skipped or should be blocking. -func (*MultiSelect) Skip() bool { - return false -} - -// Zoom returns whether the multiselect should be zoomed. -func (*MultiSelect) Zoom() bool { - return false -} - -// Focus focuses the multi-select field. -func (m *MultiSelect) Focus() tea.Cmd { - m.focused = true - return nil -} - -// Blur blurs the multi-select field. -func (m *MultiSelect) Blur() tea.Cmd { - m.focused = false - return nil -} - -// KeyBinds returns the help message for the multi-select field. -func (m *MultiSelect) KeyBinds() []key.Binding { - return []key.Binding{ - m.keymap.Toggle, - m.keymap.Up, - m.keymap.Down, - m.keymap.Filter, - m.keymap.SetFilter, - m.keymap.ClearFilter, - m.keymap.Prev, - m.keymap.Submit, - m.keymap.Next, - } -} - -// Init initializes the multi-select field. -func (m *MultiSelect) Init() tea.Cmd { - return nil -} - -// Update updates the multi-select field. -func (m *MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Enforce height on the viewport during update as we need themes to - // be applied before we can calculate the height. - m.updateViewportHeight() - - var cmd tea.Cmd - if m.filtering { - m.filter, cmd = m.filter.Update(msg) - } - - if m.newInputActive { - m.newInput, cmd = m.newInput.Update(msg) - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.common.Keymap.Ok): - newOptions := []Option[string]{} - for _, item := range strings.Split(m.newInput.Value(), " ") { - newOptions = append(newOptions, Option[string]{ - Key: item, - Value: item, - selected: true, - }) - } - m.options = append(m.options, newOptions...) - filteredNewOptions := []Option[string]{} - for _, item := range newOptions { - if m.filterFunc(item.Key) { - filteredNewOptions = append(filteredNewOptions, item) - } - } - m.filteredOptions = append(m.filteredOptions, filteredNewOptions...) - m.newInputActive = false - m.newInput.SetValue("") - m.newInput.Blur() - case key.Matches(msg, m.common.Keymap.Back): - m.newInputActive = false - m.newInput.Blur() - return m, SuppressBack() - } - } - } - - switch msg := msg.(type) { - case tea.KeyMsg: - - m.err = nil - - switch { - case key.Matches(msg, m.keymap.Filter): - m.setFilter(true) - return m, m.filter.Focus() - case key.Matches(msg, m.keymap.SetFilter) && m.filtering: - if len(m.filteredOptions) <= 0 { - m.filter.SetValue("") - m.filteredOptions = m.options - } - m.setFilter(false) - case key.Matches(msg, m.common.Keymap.Back) && m.filtering: - m.filter.SetValue("") - m.filteredOptions = m.options - m.setFilter(false) - case key.Matches(msg, m.keymap.ClearFilter): - m.filter.SetValue("") - m.filteredOptions = m.options - m.setFilter(false) - case key.Matches(msg, m.keymap.Up): - if m.filtering && msg.String() == "k" { - break - } - - m.cursor = max(m.cursor-1, 0) - if m.cursor < m.viewport.YOffset { - m.viewport.SetYOffset(m.cursor) - } - case key.Matches(msg, m.keymap.Down): - if m.filtering && msg.String() == "j" { - break - } - - m.cursor = min(m.cursor+1, len(m.filteredOptions)-1) - if m.cursor >= m.viewport.YOffset+m.viewport.Height { - m.viewport.LineDown(1) - } - case key.Matches(msg, m.keymap.GotoTop): - if m.filtering { - break - } - m.cursor = 0 - m.viewport.GotoTop() - case key.Matches(msg, m.keymap.GotoBottom): - if m.filtering { - break - } - m.cursor = len(m.filteredOptions) - 1 - m.viewport.GotoBottom() - case key.Matches(msg, m.keymap.HalfPageUp): - m.cursor = max(m.cursor-m.viewport.Height/2, 0) - m.viewport.HalfViewUp() - case key.Matches(msg, m.keymap.HalfPageDown): - m.cursor = min(m.cursor+m.viewport.Height/2, len(m.filteredOptions)-1) - m.viewport.HalfViewDown() - case key.Matches(msg, m.keymap.Toggle) && !m.filtering: - if m.hasNewOption && m.cursor == 0 { - m.newInputActive = true - m.newInput.Focus() - } else { - for i, option := range m.options { - if option.Key == m.filteredOptions[m.cursor].Key { - if !m.options[m.cursor].selected && m.limit > 0 && m.numSelected() >= m.limit { - break - } - selected := m.options[i].selected - m.options[i].selected = !selected - m.filteredOptions[m.cursor].selected = !selected - m.finalize() - } - } - } - case key.Matches(msg, m.keymap.Prev): - m.finalize() - if m.err != nil { - return m, nil - } - return m, huh.PrevField - case key.Matches(msg, m.keymap.Next, m.keymap.Submit): - m.finalize() - if m.err != nil { - return m, nil - } - return m, huh.NextField - } - - if m.filtering { - m.filteredOptions = m.options - if m.filter.Value() != "" { - m.filteredOptions = nil - for _, option := range m.options { - if m.filterFunc(option.Key) { - m.filteredOptions = append(m.filteredOptions, option) - } - } - } - if len(m.filteredOptions) > 0 { - m.cursor = min(m.cursor, len(m.filteredOptions)-1) - m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height)) - } - } - } - - return m, cmd -} - -// updateViewportHeight updates the viewport size according to the Height setting -// on this multi-select field. -func (m *MultiSelect) updateViewportHeight() { - // If no height is set size the viewport to the number of options. - if m.height <= 0 { - m.viewport.Height = len(m.options) - return - } - - const minHeight = 1 - m.viewport.Height = max(minHeight, m.height- - lipgloss.Height(m.titleView())- - lipgloss.Height(m.descriptionView())) -} - -func (m *MultiSelect) numSelected() int { - var count int - for _, o := range m.options { - if o.selected { - count++ - } - } - return count -} - -func (m *MultiSelect) finalize() { - *m.value = make([]string, 0) - for _, option := range m.options { - if option.selected { - *m.value = append(*m.value, option.Value) - } - } - m.err = m.validate(*m.value) -} - -func (m *MultiSelect) activeStyles() *huh.FieldStyles { - theme := m.theme - if theme == nil { - theme = huh.ThemeCharm() - } - if m.focused { - return &theme.Focused - } - return &theme.Blurred -} - -func (m *MultiSelect) titleView() string { - if m.title == "" { - return "" - } - var ( - styles = m.activeStyles() - sb = strings.Builder{} - ) - if m.filtering { - sb.WriteString(m.filter.View()) - } else if m.filter.Value() != "" { - sb.WriteString(styles.Title.Render(m.title) + styles.Description.Render("/"+m.filter.Value())) - } else { - sb.WriteString(styles.Title.Render(m.title)) - } - if m.err != nil { - sb.WriteString(styles.ErrorIndicator.String()) - } - return sb.String() -} - -func (m *MultiSelect) descriptionView() string { - return m.activeStyles().Description.Render(m.description) -} - -func (m *MultiSelect) choicesView() string { - var ( - styles = m.activeStyles() - c = styles.MultiSelectSelector.String() - sb strings.Builder - ) - for i, option := range m.filteredOptions { - if m.newInputActive && i == 0 { - sb.WriteString(c) - sb.WriteString(m.newInput.View()) - sb.WriteString("\n") - continue - } else if m.cursor == i { - sb.WriteString(c) - } else { - sb.WriteString(strings.Repeat(" ", lipgloss.Width(c))) - } - - if m.filteredOptions[i].selected { - sb.WriteString(styles.SelectedPrefix.String()) - sb.WriteString(styles.SelectedOption.Render(option.Key)) - } else { - sb.WriteString(styles.UnselectedPrefix.String()) - sb.WriteString(styles.UnselectedOption.Render(option.Key)) - } - if i < len(m.options)-1 { - sb.WriteString("\n") - } - } - - for i := len(m.filteredOptions); i < len(m.options)-1; i++ { - sb.WriteString("\n") - } - - return sb.String() -} - -// View renders the multi-select field. -func (m *MultiSelect) View() string { - styles := m.activeStyles() - m.viewport.SetContent(m.choicesView()) - - var sb strings.Builder - if m.title != "" { - sb.WriteString(m.titleView()) - sb.WriteString("\n") - } - if m.description != "" { - sb.WriteString(m.descriptionView() + "\n") - } - sb.WriteString(m.viewport.View()) - return styles.Base.Render(sb.String()) -} - -func (m *MultiSelect) printOptions() { - styles := m.activeStyles() - var sb strings.Builder - - sb.WriteString(styles.Title.Render(m.title)) - sb.WriteString("\n") - - for i, option := range m.options { - if option.selected { - sb.WriteString(styles.SelectedOption.Render(fmt.Sprintf("%d. %s %s", i+1, "✓", option.Key))) - } else { - sb.WriteString(fmt.Sprintf("%d. %s %s", i+1, " ", option.Key)) - } - sb.WriteString("\n") - } - - fmt.Println(sb.String()) -} - -// setFilter sets the filter of the select field. -func (m *MultiSelect) setFilter(filter bool) { - m.filtering = filter - m.keymap.SetFilter.SetEnabled(filter) - m.keymap.Filter.SetEnabled(!filter) - m.keymap.Next.SetEnabled(!filter) - m.keymap.Submit.SetEnabled(!filter) - m.keymap.Prev.SetEnabled(!filter) - m.keymap.ClearFilter.SetEnabled(!filter && m.filter.Value() != "") -} - -// filterFunc returns true if the option matches the filter. -func (m *MultiSelect) filterFunc(option string) bool { - // XXX: remove diacritics or allow customization of filter function. - return strings.Contains(strings.ToLower(option), strings.ToLower(m.filter.Value())) -} - -// Run runs the multi-select field. -func (m *MultiSelect) Run() error { - if m.accessible { - return m.runAccessible() - } - return huh.Run(m) -} - -// runAccessible() runs the multi-select field in accessible mode. -func (m *MultiSelect) runAccessible() error { - m.printOptions() - styles := m.activeStyles() - - var choice int - for { - fmt.Printf("Select up to %d options. 0 to continue.\n", m.limit) - - choice = accessibility.PromptInt("Select: ", 0, len(m.options)) - if choice == 0 { - m.finalize() - err := m.validate(*m.value) - if err != nil { - fmt.Println(err) - continue - } - break - } - - if !m.options[choice-1].selected && m.limit > 0 && m.numSelected() >= m.limit { - fmt.Printf("You can't select more than %d options.\n", m.limit) - continue - } - m.options[choice-1].selected = !m.options[choice-1].selected - if m.options[choice-1].selected { - fmt.Printf("Selected: %s\n\n", m.options[choice-1].Key) - } else { - fmt.Printf("Deselected: %s\n\n", m.options[choice-1].Key) - } - - m.printOptions() - } - - var values []string - - for _, option := range m.options { - if option.selected { - *m.value = append(*m.value, option.Value) - values = append(values, option.Key) - } - } - - fmt.Println(styles.SelectedOption.Render("Selected:", strings.Join(values, ", ")+"\n")) - return nil -} - -// WithTheme sets the theme of the multi-select field. -func (m *MultiSelect) WithTheme(theme *huh.Theme) huh.Field { - if m.theme != nil { - return m - } - m.theme = theme - m.filter.Cursor.Style = m.theme.Focused.TextInput.Cursor - m.filter.PromptStyle = m.theme.Focused.TextInput.Prompt - m.updateViewportHeight() - return m -} - -// WithKeyMap sets the keymap of the multi-select field. -func (m *MultiSelect) WithKeyMap(k *huh.KeyMap) huh.Field { - m.keymap = k.MultiSelect - return m -} - -// WithAccessible sets the accessible mode of the multi-select field. -func (m *MultiSelect) WithAccessible(accessible bool) huh.Field { - m.accessible = accessible - return m -} - -// WithWidth sets the width of the multi-select field. -func (m *MultiSelect) WithWidth(width int) huh.Field { - m.width = width - return m -} - -// WithHeight sets the height of the multi-select field. -func (m *MultiSelect) WithHeight(height int) huh.Field { - m.height = height - return m -} - -// WithPosition sets the position of the multi-select field. -func (m *MultiSelect) WithPosition(p huh.FieldPosition) huh.Field { - if m.filtering { - return m - } - m.keymap.Prev.SetEnabled(!p.IsFirst()) - m.keymap.Next.SetEnabled(!p.IsLast()) - m.keymap.Submit.SetEnabled(p.IsLast()) - return m -} - -// GetKey returns the multi-select's key. -func (m *MultiSelect) GetKey() string { - return m.key -} - -// GetValue returns the multi-select's value. -func (m *MultiSelect) GetValue() any { - return *m.value -} - -// IsFiltering returns true if the multi-select is currently filtering. -func (m *MultiSelect) IsFiltering() bool { - return m.filtering -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func clamp(n, low, high int) int { - if low > high { - low, high = high, low - } - return min(high, max(low, n)) -} - -type SuppressBackMsg struct{} - -func SuppressBack() tea.Cmd { - return func() tea.Msg { - return SuppressBackMsg{} - } -} diff --git a/components/input/option.go b/components/input/option.go deleted file mode 100644 index caf5be6..0000000 --- a/components/input/option.go +++ /dev/null @@ -1,38 +0,0 @@ -package input - -import "fmt" - -// Option is an option for select fields. -type Option[T comparable] struct { - Key string - Value T - selected bool -} - -// NewOptions returns new options from a list of values. -func NewOptions[T comparable](values ...T) []Option[T] { - options := make([]Option[T], len(values)) - for i, o := range values { - options[i] = Option[T]{ - Key: fmt.Sprint(o), - Value: o, - } - } - return options -} - -// NewOption returns a new select option. -func NewOption[T comparable](key string, value T) Option[T] { - return Option[T]{Key: key, Value: value} -} - -// Selected sets whether the option is currently selected. -func (o Option[T]) Selected(selected bool) Option[T] { - o.selected = selected - return o -} - -// String returns the key of the option. -func (o Option[T]) String() string { - return o.Key -} diff --git a/components/input/select.go b/components/input/select.go deleted file mode 100644 index 5685a34..0000000 --- a/components/input/select.go +++ /dev/null @@ -1,618 +0,0 @@ -package input - -import ( - "fmt" - "strings" - "tasksquire/common" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/huh/accessibility" - "github.com/charmbracelet/lipgloss" -) - -// Select is a form select field. -type Select struct { - common *common.Common - - value *string - key string - viewport viewport.Model - - // customization - title string - description string - options []Option[string] - filteredOptions []Option[string] - height int - - // error handling - validate func(string) error - err error - - // state - selected int - focused bool - filtering bool - filter textinput.Model - - // options - inline bool - width int - accessible bool - theme *huh.Theme - keymap huh.SelectKeyMap - - // new - hasNewOption bool - newInput textinput.Model - newInputActive bool -} - -// NewSelect returns a new select field. -func NewSelect(com *common.Common) *Select { - filter := textinput.New() - filter.Prompt = "/" - - newInput := textinput.New() - newInput.Prompt = "New: " - - return &Select{ - common: com, - options: []Option[string]{}, - value: new(string), - validate: func(string) error { return nil }, - filtering: false, - filter: filter, - newInput: newInput, - newInputActive: false, - } -} - -// Value sets the value of the select field. -func (s *Select) Value(value *string) *Select { - s.value = value - s.selectValue(*value) - return s -} - -func (s *Select) selectValue(value string) { - for i, o := range s.options { - if o.Value == value { - s.selected = i - break - } - } -} - -// Key sets the key of the select field which can be used to retrieve the value -// after submission. -func (s *Select) Key(key string) *Select { - s.key = key - return s -} - -// Title sets the title of the select field. -func (s *Select) Title(title string) *Select { - s.title = title - return s -} - -// Description sets the description of the select field. -func (s *Select) Description(description string) *Select { - s.description = description - return s -} - -// Options sets the options of the select field. -func (s *Select) Options(hasNewOption bool, options ...Option[string]) *Select { - s.hasNewOption = hasNewOption - - if s.hasNewOption { - newOption := []Option[string]{ - {Key: "(new)", Value: ""}, - } - options = append(newOption, options...) - } - - if len(options) <= 0 { - return s - } - s.options = options - s.filteredOptions = options - - // Set the cursor to the existing value or the last selected option. - for i, option := range options { - if option.Value == *s.value { - s.selected = i - break - } else if option.selected { - s.selected = i - } - } - - s.updateViewportHeight() - - return s -} - -// Inline sets whether the select input should be inline. -func (s *Select) Inline(v bool) *Select { - s.inline = v - if v { - s.Height(1) - } - s.keymap.Left.SetEnabled(v) - s.keymap.Right.SetEnabled(v) - s.keymap.Up.SetEnabled(!v) - s.keymap.Down.SetEnabled(!v) - return s -} - -// Height sets the height of the select field. If the number of options -// exceeds the height, the select field will become scrollable. -func (s *Select) Height(height int) *Select { - s.height = height - s.updateViewportHeight() - return s -} - -// Validate sets the validation function of the select field. -func (s *Select) Validate(validate func(string) error) *Select { - s.validate = validate - return s -} - -// Error returns the error of the select field. -func (s *Select) Error() error { - return s.err -} - -// Skip returns whether the select should be skipped or should be blocking. -func (*Select) Skip() bool { - return false -} - -// Zoom returns whether the input should be zoomed. -func (*Select) Zoom() bool { - return false -} - -// Focus focuses the select field. -func (s *Select) Focus() tea.Cmd { - s.focused = true - return nil -} - -// Blur blurs the select field. -func (s *Select) Blur() tea.Cmd { - value := *s.value - if s.inline { - s.clearFilter() - s.selectValue(value) - } - s.focused = false - s.err = s.validate(value) - return nil -} - -// KeyBinds returns the help keybindings for the select field. -func (s *Select) KeyBinds() []key.Binding { - return []key.Binding{ - s.keymap.Up, - s.keymap.Down, - s.keymap.Left, - s.keymap.Right, - s.keymap.Filter, - s.keymap.SetFilter, - s.keymap.ClearFilter, - s.keymap.Prev, - s.keymap.Next, - s.keymap.Submit, - } -} - -// Init initializes the select field. -func (s *Select) Init() tea.Cmd { - return nil -} - -// Update updates the select field. -func (s *Select) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - s.updateViewportHeight() - - var cmd tea.Cmd - if s.filtering { - s.filter, cmd = s.filter.Update(msg) - - // Keep the selected item in view. - if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height { - s.viewport.SetYOffset(s.selected) - } - } - - if s.newInputActive { - s.newInput, cmd = s.newInput.Update(msg) - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, s.common.Keymap.Ok): - if s.newInput.Value() != "" { - newOption := Option[string]{ - Key: s.newInput.Value(), - Value: s.newInput.Value(), - selected: true, - } - s.options = append(s.options, newOption) - if s.filterFunc(newOption.Key) { - s.filteredOptions = append(s.filteredOptions, newOption) - } - s.selected = len(s.options) - 1 - - value := newOption.Value - s.setFiltering(false) - s.err = s.validate(value) - if s.err != nil { - return s, nil - } - *s.value = value - } - s.newInputActive = false - s.newInput.SetValue("") - s.newInput.Blur() - return s, nil - case key.Matches(msg, s.common.Keymap.Back): - s.newInputActive = false - s.newInput.Blur() - return s, SuppressBack() - } - } - } - - switch msg := msg.(type) { - case tea.KeyMsg: - s.err = nil - switch { - case key.Matches(msg, s.keymap.Filter): - s.setFiltering(true) - return s, s.filter.Focus() - case key.Matches(msg, s.keymap.SetFilter): - if len(s.filteredOptions) <= 0 { - s.filter.SetValue("") - s.filteredOptions = s.options - } - s.setFiltering(false) - case key.Matches(msg, s.keymap.ClearFilter): - s.clearFilter() - case key.Matches(msg, s.keymap.Up, s.keymap.Left): - // When filtering we should ignore j/k keybindings - // - // XXX: Currently, the below check doesn't account for keymap - // changes. When making this fix it's worth considering ignoring - // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 - // may not be enough when international keyboards are considered. - if s.filtering && (msg.String() == "k" || msg.String() == "h") { - break - } - s.selected = max(s.selected-1, 0) - if s.selected < s.viewport.YOffset { - s.viewport.SetYOffset(s.selected) - } - case key.Matches(msg, s.keymap.GotoTop): - if s.filtering { - break - } - s.selected = 0 - s.viewport.GotoTop() - case key.Matches(msg, s.keymap.GotoBottom): - if s.filtering { - break - } - s.selected = len(s.filteredOptions) - 1 - s.viewport.GotoBottom() - case key.Matches(msg, s.keymap.HalfPageUp): - s.selected = max(s.selected-s.viewport.Height/2, 0) - s.viewport.HalfViewUp() - case key.Matches(msg, s.keymap.HalfPageDown): - s.selected = min(s.selected+s.viewport.Height/2, len(s.filteredOptions)-1) - s.viewport.HalfViewDown() - case key.Matches(msg, s.keymap.Down, s.keymap.Right): - // When filtering we should ignore j/k keybindings - // - // XXX: See note in the previous case match. - if s.filtering && (msg.String() == "j" || msg.String() == "l") { - break - } - s.selected = min(s.selected+1, len(s.filteredOptions)-1) - if s.selected >= s.viewport.YOffset+s.viewport.Height { - s.viewport.LineDown(1) - } - case key.Matches(msg, s.keymap.Prev): - if s.selected >= len(s.filteredOptions) { - break - } - value := s.filteredOptions[s.selected].Value - s.err = s.validate(value) - if s.err != nil { - return s, nil - } - *s.value = value - return s, huh.PrevField - case key.Matches(msg, s.common.Keymap.Select): - if s.hasNewOption && s.selected == 0 { - s.newInputActive = true - s.newInput.Focus() - return s, nil - } - case key.Matches(msg, s.keymap.Next, s.keymap.Submit): - if s.selected >= len(s.filteredOptions) { - break - } - value := s.filteredOptions[s.selected].Value - s.setFiltering(false) - s.err = s.validate(value) - if s.err != nil { - return s, nil - } - *s.value = value - return s, huh.NextField - } - - if s.filtering { - s.filteredOptions = s.options - if s.filter.Value() != "" { - s.filteredOptions = nil - for _, option := range s.options { - if s.filterFunc(option.Key) { - s.filteredOptions = append(s.filteredOptions, option) - } - } - } - if len(s.filteredOptions) > 0 { - s.selected = min(s.selected, len(s.filteredOptions)-1) - s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height)) - } - } - } - - return s, cmd -} - -// updateViewportHeight updates the viewport size according to the Height setting -// on this select field. -func (s *Select) updateViewportHeight() { - // If no height is set size the viewport to the number of options. - if s.height <= 0 { - s.viewport.Height = len(s.options) - return - } - - const minHeight = 1 - s.viewport.Height = max(minHeight, s.height- - lipgloss.Height(s.titleView())- - lipgloss.Height(s.descriptionView())) -} - -func (s *Select) activeStyles() *huh.FieldStyles { - theme := s.theme - if theme == nil { - theme = huh.ThemeCharm() - } - if s.focused { - return &theme.Focused - } - return &theme.Blurred -} - -func (s *Select) titleView() string { - if s.title == "" { - return "" - } - var ( - styles = s.activeStyles() - sb = strings.Builder{} - ) - if s.filtering { - sb.WriteString(styles.Title.Render(s.filter.View())) - } else if s.filter.Value() != "" && !s.inline { - sb.WriteString(styles.Title.Render(s.title) + styles.Description.Render("/"+s.filter.Value())) - } else { - sb.WriteString(styles.Title.Render(s.title)) - } - if s.err != nil { - sb.WriteString(styles.ErrorIndicator.String()) - } - return sb.String() -} - -func (s *Select) descriptionView() string { - return s.activeStyles().Description.Render(s.description) -} - -func (s *Select) choicesView() string { - var ( - styles = s.activeStyles() - c = styles.SelectSelector.String() - sb strings.Builder - ) - - if s.inline { - sb.WriteString(styles.PrevIndicator.Faint(s.selected <= 0).String()) - if len(s.filteredOptions) > 0 { - sb.WriteString(styles.SelectedOption.Render(s.filteredOptions[s.selected].Key)) - } else { - sb.WriteString(styles.TextInput.Placeholder.Render("No matches")) - } - sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String()) - return sb.String() - } - - for i, option := range s.filteredOptions { - if s.newInputActive && i == 0 { - sb.WriteString(c) - sb.WriteString(s.newInput.View()) - sb.WriteString("\n") - continue - } else if s.selected == i { - sb.WriteString(c + styles.SelectedOption.Render(option.Key)) - } else { - sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.Option.Render(option.Key)) - } - if i < len(s.options)-1 { - sb.WriteString("\n") - } - } - - for i := len(s.filteredOptions); i < len(s.options)-1; i++ { - sb.WriteString("\n") - } - - return sb.String() -} - -// View renders the select field. -func (s *Select) View() string { - styles := s.activeStyles() - s.viewport.SetContent(s.choicesView()) - - var sb strings.Builder - if s.title != "" { - sb.WriteString(s.titleView()) - if !s.inline { - sb.WriteString("\n") - } - } - if s.description != "" { - sb.WriteString(s.descriptionView()) - if !s.inline { - sb.WriteString("\n") - } - } - sb.WriteString(s.viewport.View()) - return styles.Base.Render(sb.String()) -} - -// clearFilter clears the value of the filter. -func (s *Select) clearFilter() { - s.filter.SetValue("") - s.filteredOptions = s.options - s.setFiltering(false) -} - -// setFiltering sets the filter of the select field. -func (s *Select) setFiltering(filtering bool) { - if s.inline && filtering { - s.filter.Width = lipgloss.Width(s.titleView()) - 1 - 1 - } - s.filtering = filtering - s.keymap.SetFilter.SetEnabled(filtering) - s.keymap.Filter.SetEnabled(!filtering) - s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") -} - -// filterFunc returns true if the option matches the filter. -func (s *Select) filterFunc(option string) bool { - // XXX: remove diacritics or allow customization of filter function. - return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) -} - -// Run runs the select field. -func (s *Select) Run() error { - if s.accessible { - return s.runAccessible() - } - return huh.Run(s) -} - -// runAccessible runs an accessible select field. -func (s *Select) runAccessible() error { - var sb strings.Builder - styles := s.activeStyles() - - sb.WriteString(styles.Title.Render(s.title) + "\n") - - for i, option := range s.options { - sb.WriteString(fmt.Sprintf("%d. %s", i+1, option.Key)) - sb.WriteString("\n") - } - - fmt.Println(sb.String()) - - for { - choice := accessibility.PromptInt("Choose: ", 1, len(s.options)) - option := s.options[choice-1] - if err := s.validate(option.Value); err != nil { - fmt.Println(err.Error()) - continue - } - fmt.Println(styles.SelectedOption.Render("Chose: " + option.Key + "\n")) - *s.value = option.Value - break - } - - return nil -} - -// WithTheme sets the theme of the select field. -func (s *Select) WithTheme(theme *huh.Theme) huh.Field { - if s.theme != nil { - return s - } - s.theme = theme - s.filter.Cursor.Style = s.theme.Focused.TextInput.Cursor - s.filter.PromptStyle = s.theme.Focused.TextInput.Prompt - s.updateViewportHeight() - return s -} - -// WithKeyMap sets the keymap on a select field. -func (s *Select) WithKeyMap(k *huh.KeyMap) huh.Field { - s.keymap = k.Select - s.keymap.Left.SetEnabled(s.inline) - s.keymap.Right.SetEnabled(s.inline) - s.keymap.Up.SetEnabled(!s.inline) - s.keymap.Down.SetEnabled(!s.inline) - return s -} - -// WithAccessible sets the accessible mode of the select field. -func (s *Select) WithAccessible(accessible bool) huh.Field { - s.accessible = accessible - return s -} - -// WithWidth sets the width of the select field. -func (s *Select) WithWidth(width int) huh.Field { - s.width = width - return s -} - -// WithHeight sets the height of the select field. -func (s *Select) WithHeight(height int) huh.Field { - return s.Height(height) -} - -// WithPosition sets the position of the select field. -func (s *Select) WithPosition(p huh.FieldPosition) huh.Field { - if s.filtering { - return s - } - s.keymap.Prev.SetEnabled(!p.IsFirst()) - s.keymap.Next.SetEnabled(!p.IsLast()) - s.keymap.Submit.SetEnabled(p.IsLast()) - return s -} - -// GetKey returns the key of the field. -func (s *Select) GetKey() string { - return s.key -} - -// GetValue returns the value of the field. -func (s *Select) GetValue() any { - return *s.value -} diff --git a/components/picker/picker.go b/components/picker/picker.go deleted file mode 100644 index 200d3a5..0000000 --- a/components/picker/picker.go +++ /dev/null @@ -1,300 +0,0 @@ -package picker - -import ( - "tasksquire/common" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type Item struct { - text string -} - -func NewItem(text string) Item { return Item{text: text} } -func (i Item) Title() string { return i.text } -func (i Item) Description() string { return "" } -func (i Item) FilterValue() string { return i.text } - -// creationItem is a special item for creating new entries -type creationItem struct { - text string - filter string -} - -func (i creationItem) Title() string { return i.text } -func (i creationItem) Description() string { return "" } -func (i creationItem) FilterValue() string { return i.filter } - -type Picker struct { - common *common.Common - list list.Model - itemProvider func() []list.Item - onSelect func(list.Item) tea.Cmd - onCreate func(string) tea.Cmd - title string - filterByDefault bool - defaultValue string - baseItems []list.Item - focused bool -} - -type PickerOption func(*Picker) - -func WithFilterByDefault(enabled bool) PickerOption { - return func(p *Picker) { - p.filterByDefault = enabled - } -} - -func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption { - return func(p *Picker) { - p.onCreate = onCreate - } -} - -func WithDefaultValue(value string) PickerOption { - return func(p *Picker) { - p.defaultValue = value - } -} - -func (p *Picker) Focus() tea.Cmd { - p.focused = true - return nil -} - -func (p *Picker) Blur() tea.Cmd { - p.focused = false - return nil -} - -func (p *Picker) GetValue() string { - item := p.list.SelectedItem() - if item == nil { - return "" - } - return item.FilterValue() -} - -func New( - c *common.Common, - title string, - itemProvider func() []list.Item, - onSelect func(list.Item) tea.Cmd, - opts ...PickerOption, -) *Picker { - delegate := list.NewDefaultDelegate() - delegate.ShowDescription = false - delegate.SetSpacing(0) - - // Update Styles - delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground()) - delegate.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).Bold(true).PaddingLeft(2) - delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Muted.GetForeground()) - delegate.Styles.SelectedDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).PaddingLeft(2) - delegate.Styles.FilterMatch = lipgloss.NewStyle().Foreground(c.Styles.Palette.Secondary.GetForeground()).Underline(true) - - l := list.New([]list.Item{}, delegate, 0, 0) - l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()) - l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()) - - // Ensure the filter input text is readable (using Text color instead of potentially inheriting something else) - l.FilterInput.TextStyle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground()) - l.FilterInput.PromptStyle = l.Styles.FilterPrompt - l.FilterInput.CursorStyle = l.Styles.FilterCursor - - l.SetShowTitle(false) - l.SetShowHelp(false) - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality - - // Custom key for filtering (insert mode) - l.KeyMap.Filter = key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "filter"), - ) - - // Disable the quit key binding - we don't want Esc to quit the list - // Esc should only cancel filtering mode - l.KeyMap.Quit = key.NewBinding( - key.WithKeys(), // No keys bound - key.WithHelp("", ""), - ) - - // Also disable force quit - l.KeyMap.ForceQuit = key.NewBinding( - key.WithKeys(), // No keys bound - key.WithHelp("", ""), - ) - - p := &Picker{ - common: c, - list: l, - itemProvider: itemProvider, - onSelect: onSelect, - title: title, - focused: true, - } - - if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" { - p.filterByDefault = true - } - - for _, opt := range opts { - opt(p) - } - - // If a default value is provided, don't start in filter mode - if p.defaultValue != "" { - p.filterByDefault = false - } - - if p.filterByDefault { - // Manually trigger filter mode on the list so it doesn't require a global key press - p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - } - - // Refresh items after entering filter mode to ensure they're visible - p.Refresh() - - // If a default value is provided, select the corresponding item - if p.defaultValue != "" { - p.SelectItemByFilterValue(p.defaultValue) - } - - return p -} - -func (p *Picker) Refresh() tea.Cmd { - p.baseItems = p.itemProvider() - return p.updateListItems() -} - -func (p *Picker) updateListItems() tea.Cmd { - return p.updateListItemsWithFilter(p.list.FilterValue()) -} - -func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd { - items := make([]list.Item, 0, len(p.baseItems)+1) - - // First add all base items - items = append(items, p.baseItems...) - - if p.onCreate != nil && filterVal != "" { - // Add the creation item at the end (bottom of the list) - newItem := creationItem{ - text: "(new) " + filterVal, - filter: filterVal, - } - items = append(items, newItem) - } - - return p.list.SetItems(items) -} - -func (p *Picker) SetSize(width, height int) { - // We do NOT set common.SetSize here, as we are a sub-component. - - // Set list size. The parent is responsible for providing a reasonable size. - // If this component is intended to fill a page, width/height will be large. - // If it's a small embedded box, they will be small. - // We apply a small margin for the title if needed, but for now we just pass through - // minus a header gap if we render a title. - - headerHeight := 2 // Title + gap - p.list.SetSize(width, height-headerHeight) -} - -func (p *Picker) Init() tea.Cmd { - // Trigger list item update to ensure items are properly displayed, - // especially when in filter mode with an empty filter - return p.updateListItems() -} - -func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if !p.focused { - return p, nil - } - - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - // If filtering, update items with predicted filter before list processes the key - if p.list.FilterState() == list.Filtering { - currentFilter := p.list.FilterValue() - predictedFilter := currentFilter - - // Predict what the filter will be after this key - switch msg.Type { - case tea.KeyRunes: - predictedFilter = currentFilter + string(msg.Runes) - case tea.KeyBackspace: - if len(currentFilter) > 0 { - predictedFilter = currentFilter[:len(currentFilter)-1] - } - } - - // Update items with predicted filter before list processes the message - if predictedFilter != currentFilter { - preCmd := p.updateListItemsWithFilter(predictedFilter) - cmds = append(cmds, preCmd) - } - - break // Pass to list.Update - } - - switch { - case key.Matches(msg, p.common.Keymap.Ok): - selectedItem := p.list.SelectedItem() - if selectedItem == nil { - return p, nil - } - return p, p.handleSelect(selectedItem) - } - } - - p.list, cmd = p.list.Update(msg) - cmds = append(cmds, cmd) - - return p, tea.Batch(cmds...) -} - -func (p *Picker) handleSelect(item list.Item) tea.Cmd { - if cItem, ok := item.(creationItem); ok { - if p.onCreate != nil { - return p.onCreate(cItem.filter) - } - } - return p.onSelect(item) -} - -func (p *Picker) View() string { - var title string - if p.focused { - title = p.common.Styles.Form.Focused.Title.Render(p.title) - } else { - title = p.common.Styles.Form.Blurred.Title.Render(p.title) - } - return lipgloss.JoinVertical(lipgloss.Left, title, p.list.View()) -} - -func (p *Picker) IsFiltering() bool { - return p.list.FilterState() == list.Filtering -} - -// SelectItemByFilterValue selects the item with the given filter value -func (p *Picker) SelectItemByFilterValue(filterValue string) { - items := p.list.Items() - for i, item := range items { - if item.FilterValue() == filterValue { - p.list.Select(i) - break - } - } -} diff --git a/components/table/table.go b/components/table/table.go deleted file mode 100644 index f08a824..0000000 --- a/components/table/table.go +++ /dev/null @@ -1,696 +0,0 @@ -package table - -import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" - - "tasksquire/common" - "tasksquire/taskwarrior" -) - -// Model defines a state for the table widget. -type Model struct { - common *common.Common - KeyMap KeyMap - - cols []Column - rows taskwarrior.Tasks - rowStyles []lipgloss.Style - cursor int - focus bool - styles common.TableStyle - styleFunc StyleFunc - taskTree *taskwarrior.TaskTree - - viewport viewport.Model - start int - end int -} - -// Row represents one line in the table. -type Row *taskwarrior.Task - -// Column defines the table structure. -type Column struct { - Title string - Name string - Width int - MaxWidth int - ContentWidth int -} - -// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which -// is used to render the menu. -type KeyMap struct { - LineUp key.Binding - LineDown key.Binding - PageUp key.Binding - PageDown key.Binding - HalfPageUp key.Binding - HalfPageDown key.Binding - GotoTop key.Binding - GotoBottom key.Binding -} - -// ShortHelp implements the KeyMap interface. -func (km KeyMap) ShortHelp() []key.Binding { - return []key.Binding{km.LineUp, km.LineDown} -} - -// FullHelp implements the KeyMap interface. -func (km KeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom}, - {km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown}, - } -} - -// DefaultKeyMap returns a default set of keybindings. -func DefaultKeyMap() KeyMap { - const spacebar = " " - return KeyMap{ - LineUp: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - LineDown: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - PageUp: key.NewBinding( - key.WithKeys("b", "pgup"), - key.WithHelp("b/pgup", "page up"), - ), - PageDown: key.NewBinding( - key.WithKeys("f", "pgdown", spacebar), - key.WithHelp("f/pgdn", "page down"), - ), - HalfPageUp: key.NewBinding( - key.WithKeys("u", "ctrl+u"), - key.WithHelp("u", "½ page up"), - ), - HalfPageDown: key.NewBinding( - key.WithKeys("d", "ctrl+d"), - key.WithHelp("d", "½ page down"), - ), - GotoTop: key.NewBinding( - key.WithKeys("home", "g"), - key.WithHelp("g/home", "go to start"), - ), - GotoBottom: key.NewBinding( - key.WithKeys("end", "G"), - key.WithHelp("G/end", "go to end"), - ), - } -} - -// SetStyles sets the table styles. -func (m *Model) SetStyles(s common.TableStyle) { - m.styles = s - m.UpdateViewport() -} - -// Option is used to set options in New. For example: -// -// table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) -type Option func(*Model) - -// New creates a new model for the table widget. -func New(com *common.Common, opts ...Option) Model { - m := Model{ - common: com, - cursor: 0, - viewport: viewport.New(0, 20), - - KeyMap: DefaultKeyMap(), - } - - for _, opt := range opts { - opt(&m) - } - - m.cols = m.parseColumns(m.cols) - m.rowStyles = m.parseRowStyles(m.rows) - - m.UpdateViewport() - - return m -} - -// TODO: dynamically read rule.precedence.color -func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style { - styles := make([]lipgloss.Style, len(rows)) - if len(rows) == 0 { - return styles - } -taskstyle: - for i, task := range rows { - if task.Status == "deleted" { - if c, ok := m.common.Styles.Colors["deleted"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if task.Status == "completed" { - if c, ok := m.common.Styles.Colors["completed"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if task.Status == "pending" && task.Start != "" { - if c, ok := m.common.Styles.Colors["active"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - // TODO: implement keyword - // TODO: implement tag - if task.HasTag("next") { - if c, ok := m.common.Styles.Colors["tag.next"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - // TODO: implement project - if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) { - if c, ok := m.common.Styles.Colors["overdue"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if task.Scheduled != "" { - if c, ok := m.common.Styles.Colors["scheduled"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) { - if c, ok := m.common.Styles.Colors["due.today"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if task.Due != "" { - if c, ok := m.common.Styles.Colors["due"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if len(task.Depends) > 0 { - if c, ok := m.common.Styles.Colors["blocked"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - // TODO implement blocking - if task.Recur != "" { - if c, ok := m.common.Styles.Colors["recurring"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - // TODO: make styles optional and discard if empty - if len(task.Tags) > 0 { - if c, ok := m.common.Styles.Colors["tagged"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue - } - } - if len(m.common.Udas) > 0 { - for _, uda := range m.common.Udas { - if u, ok := task.Udas[uda.Name]; ok { - if c, ok := m.common.Styles.Colors[fmt.Sprintf("uda.%s.%s", uda.Name, u)]; ok { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - continue taskstyle - } - } - } - } - styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - } - return styles -} - -func (m *Model) parseColumns(cols []Column) []Column { - if len(cols) == 0 { - return cols - } - - // Calculate max tree depth for indentation - maxTreeWidth := 0 - if m.taskTree != nil { - for _, node := range m.taskTree.FlatList { - // Calculate indentation: depth * 2 spaces + tree characters (3 chars for "└─ ") - treeWidth := 0 - if node.Depth > 0 { - treeWidth = node.Depth*2 + 3 - } - // Add progress indicator width for parent tasks (e.g., " (3/5)" = 6 chars max) - if node.HasChildren() { - treeWidth += 7 - } - maxTreeWidth = max(maxTreeWidth, treeWidth) - } - } - - for i, col := range cols { - for _, task := range m.rows { - contentWidth := lipgloss.Width(task.GetString(col.Name)) - // Add tree width to description column - if strings.Contains(col.Name, "description") { - contentWidth += maxTreeWidth - } - col.ContentWidth = max(col.ContentWidth, contentWidth) - } - cols[i] = col - } - - combinedSize := 0 - nonZeroWidths := 0 - descIndex := -1 - for i, col := range cols { - if col.ContentWidth > 0 { - col.Width = max(col.ContentWidth, lipgloss.Width(col.Title)) - nonZeroWidths++ - } - - if !strings.Contains(col.Name, "description") { - combinedSize += col.Width - } else { - descIndex = i - } - - cols[i] = col - } - - if descIndex >= 0 { - cols[descIndex].Width = m.Width() - combinedSize - nonZeroWidths - } - - return cols -} - -// WithColumns sets the table columns (headers). -func WithColumns(cols []Column) Option { - return func(m *Model) { - m.cols = cols - } -} - -func WithReport(report *taskwarrior.Report) Option { - return func(m *Model) { - columns := make([]Column, len(report.Columns)) - for i, col := range report.Columns { - columns[i] = Column{ - Title: report.Labels[i], - Name: col, - Width: 0, - } - } - m.cols = columns - } -} - -// WithRows sets the table rows (data). -func WithRows(rows taskwarrior.Tasks) Option { - return func(m *Model) { - m.rows = rows - } -} - -// WithRows sets the table rows (data). -func WithTasks(rows taskwarrior.Tasks) Option { - return func(m *Model) { - m.rows = rows - } -} - -// WithHeight sets the height of the table. -func WithHeight(h int) Option { - return func(m *Model) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) - } -} - -// WithWidth sets the width of the table. -func WithWidth(w int) Option { - return func(m *Model) { - m.viewport.Width = w - } -} - -// WithFocused sets the focus state of the table. -func WithFocused(f bool) Option { - return func(m *Model) { - m.focus = f - } -} - -// WithStyles sets the table styles. -func WithStyles(s common.TableStyle) Option { - return func(m *Model) { - m.styles = s - } -} - -// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state. -func WithStyleFunc(f StyleFunc) Option { - return func(m *Model) { - m.styleFunc = f - } -} - -// WithKeyMap sets the key map. -func WithKeyMap(km KeyMap) Option { - return func(m *Model) { - m.KeyMap = km - } -} - -// WithTaskTree sets the task tree for hierarchical display. -func WithTaskTree(tree *taskwarrior.TaskTree) Option { - return func(m *Model) { - m.taskTree = tree - } -} - -// Update is the Bubble Tea update loop. -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - if !m.focus { - return m, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.KeyMap.LineUp): - m.MoveUp(1) - case key.Matches(msg, m.KeyMap.LineDown): - m.MoveDown(1) - case key.Matches(msg, m.KeyMap.PageUp): - m.MoveUp(m.viewport.Height) - case key.Matches(msg, m.KeyMap.PageDown): - m.MoveDown(m.viewport.Height) - case key.Matches(msg, m.KeyMap.HalfPageUp): - m.MoveUp(m.viewport.Height / 2) - case key.Matches(msg, m.KeyMap.HalfPageDown): - m.MoveDown(m.viewport.Height / 2) - case key.Matches(msg, m.KeyMap.LineDown): - m.MoveDown(1) - case key.Matches(msg, m.KeyMap.GotoTop): - m.GotoTop() - case key.Matches(msg, m.KeyMap.GotoBottom): - m.GotoBottom() - } - } - - return m, nil -} - -// Focused returns the focus state of the table. -func (m Model) Focused() bool { - return m.focus -} - -// Focus focuses the table, allowing the user to move around the rows and -// interact. -func (m *Model) Focus() { - m.focus = true - m.UpdateViewport() -} - -// Blur blurs the table, preventing selection or movement. -func (m *Model) Blur() { - m.focus = false - m.UpdateViewport() -} - -// View renders the component. -func (m Model) View() string { - return m.headersView() + "\n" + m.viewport.View() -} - -// UpdateViewport updates the list content based on the previously defined -// columns and rows. -func (m *Model) UpdateViewport() { - renderedRows := make([]string, 0, len(m.rows)) - - // Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height - // Constant runtime, independent of number of rows in a table. - // Limits the number of renderedRows to a maximum of 2*m.viewport.Height - if m.cursor >= 0 { - m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor) - } else { - m.start = 0 - } - m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows)) - for i := m.start; i < m.end; i++ { - renderedRows = append(renderedRows, m.renderRow(i)) - } - - m.viewport.SetContent( - lipgloss.JoinVertical(lipgloss.Left, renderedRows...), - ) -} - -// SelectedRow returns the selected row. -// You can cast it to your own implementation. -func (m Model) SelectedRow() Row { - if m.cursor < 0 || m.cursor >= len(m.rows) { - return nil - } - - return m.rows[m.cursor] -} - -// Rows returns the current rows. -func (m Model) Rows() taskwarrior.Tasks { - return m.rows -} - -// Columns returns the current columns. -func (m Model) Columns() []Column { - return m.cols -} - -// SetRows sets a new rows state. -func (m *Model) SetRows(r taskwarrior.Tasks) { - m.rows = r - m.UpdateViewport() -} - -// SetColumns sets a new columns state. -func (m *Model) SetColumns(c []Column) { - m.cols = c - m.UpdateViewport() -} - -// SetWidth sets the width of the viewport of the table. -func (m *Model) SetWidth(w int) { - m.viewport.Width = w - m.UpdateViewport() -} - -// SetHeight sets the height of the viewport of the table. -func (m *Model) SetHeight(h int) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) - m.UpdateViewport() -} - -// Height returns the viewport height of the table. -func (m Model) Height() int { - return m.viewport.Height -} - -// Width returns the viewport width of the table. -func (m Model) Width() int { - return m.viewport.Width -} - -// Cursor returns the index of the selected row. -func (m Model) Cursor() int { - return m.cursor -} - -// SetCursor sets the cursor position in the table. -func (m *Model) SetCursor(n int) { - m.cursor = clamp(n, 0, len(m.rows)-1) - m.UpdateViewport() -} - -// MoveUp moves the selection up by any number of rows. -// It can not go above the first row. -func (m *Model) MoveUp(n int) { - m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) - switch { - case m.start == 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) - case m.start < m.viewport.Height: - m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height)) - case m.viewport.YOffset >= 1: - m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height) - } - m.UpdateViewport() -} - -// MoveDown moves the selection down by any number of rows. -// It can not go below the last row. -func (m *Model) MoveDown(n int) { - m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) - m.UpdateViewport() - - switch { - case m.end == len(m.rows) && m.viewport.YOffset > 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height)) - case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor)) - case m.viewport.YOffset > 1: - case m.cursor > m.viewport.YOffset+m.viewport.Height-1: - m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1)) - } -} - -// GotoTop moves the selection to the first row. -func (m *Model) GotoTop() { - m.MoveUp(m.cursor) -} - -// GotoBottom moves the selection to the last row. -func (m *Model) GotoBottom() { - m.MoveDown(len(m.rows)) -} - -// FromValues create the table rows from a simple string. It uses `\n` by -// default for getting all the rows and the given separator for the fields on -// each row. -// func (m *Model) FromValues(value, separator string) { -// rows := []Row{} -// for _, line := range strings.Split(value, "\n") { -// r := Row{} -// for _, field := range strings.Split(line, separator) { -// r = append(r, field) -// } -// rows = append(rows, r) -// } - -// m.SetRows(rows) -// } - -// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index. -type StyleFunc func(row, col int, value string) lipgloss.Style - -func (m Model) headersView() string { - var s = make([]string, 0, len(m.cols)) - for _, col := range m.cols { - if col.Width <= 0 { - continue - } - style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) - renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) - s = append(s, m.styles.Header.Render(renderedCell)) - } - return lipgloss.JoinHorizontal(lipgloss.Left, s...) -} - -func (m *Model) renderRow(r int) string { - var s = make([]string, 0, len(m.cols)) - - // Extract tree metadata for this row - var depth int - var hasChildren bool - var progress string - if m.taskTree != nil && r < len(m.taskTree.FlatList) { - node := m.taskTree.FlatList[r] - depth = node.Depth - hasChildren = node.HasChildren() - if hasChildren { - completed, total := node.GetChildrenStatus() - progress = fmt.Sprintf(" (%d/%d)", completed, total) - } - } - - for i, col := range m.cols { - // for i, task := range m.rows[r] { - if m.cols[i].Width <= 0 { - continue - } - var cellStyle lipgloss.Style - // if m.styleFunc != nil { - // cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name)) - // if r == m.cursor { - // cellStyle.Inherit(m.styles.Selected) - // } - // } else { - cellStyle = m.rowStyles[r] - // } - if r == m.cursor { - cellStyle = cellStyle.Inherit(m.styles.Selected) - } - - // Render cell content with tree formatting for description column - var cellContent string - if strings.Contains(col.Name, "description") && m.taskTree != nil { - cellContent = m.renderTreeDescription(r, depth, hasChildren, progress) - } else { - cellContent = m.rows[r].GetString(col.Name) - } - - style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) - renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(cellContent, m.cols[i].Width, "…"))) - s = append(s, renderedCell) - } - - row := lipgloss.JoinHorizontal(lipgloss.Left, s...) - - if r == m.cursor { - return m.styles.Selected.Render(row) - } - - return row -} - -// renderTreeDescription renders the description column with tree indentation and progress -func (m *Model) renderTreeDescription(rowIdx int, depth int, hasChildren bool, progress string) string { - task := m.rows[rowIdx] - desc := task.Description - - // Build indentation and tree characters - prefix := "" - if depth > 0 { - prefix = strings.Repeat(" ", depth) + "└─ " - } - - // Add progress indicator for parent tasks - if hasChildren { - desc = desc + progress - } - - return prefix + desc -} - -func max(a, b int) int { - if a > b { - return a - } - - return b -} - -func min(a, b int) int { - if a < b { - return a - } - - return b -} - -func clamp(v, low, high int) int { - return min(max(v, low), high) -} diff --git a/components/timestampeditor/timestampeditor.go b/components/timestampeditor/timestampeditor.go deleted file mode 100644 index 1e6e436..0000000 --- a/components/timestampeditor/timestampeditor.go +++ /dev/null @@ -1,409 +0,0 @@ -package timestampeditor - -import ( - "log/slog" - "strings" - "tasksquire/common" - "time" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -const ( - timeFormat = "20060102T150405Z" // Timewarrior format -) - -// Field represents which field is currently focused -type Field int - -const ( - TimeField Field = iota - DateField -) - -// TimestampEditor is a component for editing timestamps with separate time and date fields -type TimestampEditor struct { - common *common.Common - - // Current timestamp value - timestamp time.Time - isEmpty bool // Track if timestamp is unset - - // UI state - focused bool - currentField Field - - // Dimensions - width int - height int - - // Title and description - title string - description string - - // Validation - validate func(time.Time) error - err error -} - -// New creates a new TimestampEditor with no initial timestamp -func New(com *common.Common) *TimestampEditor { - return &TimestampEditor{ - common: com, - timestamp: time.Time{}, // Zero time - isEmpty: true, // Start empty - focused: false, - currentField: TimeField, - validate: func(time.Time) error { return nil }, - } -} - -// Title sets the title of the timestamp editor -func (t *TimestampEditor) Title(title string) *TimestampEditor { - t.title = title - return t -} - -// Description sets the description of the timestamp editor -func (t *TimestampEditor) Description(description string) *TimestampEditor { - t.description = description - return t -} - -// Value sets the initial timestamp value -func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor { - t.timestamp = timestamp - t.isEmpty = timestamp.IsZero() - return t -} - -// ValueFromString sets the initial timestamp from a timewarrior format string -func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor { - if s == "" { - t.timestamp = time.Time{} - t.isEmpty = true - return t - } - - parsed, err := time.Parse(timeFormat, s) - if err != nil { - slog.Error("Failed to parse timestamp", "error", err) - t.timestamp = time.Time{} - t.isEmpty = true - return t - } - - t.timestamp = parsed.Local() - t.isEmpty = false - return t -} - -// GetValue returns the current timestamp -func (t *TimestampEditor) GetValue() time.Time { - return t.timestamp -} - -// GetValueString returns the timestamp in timewarrior format, or empty string if unset -func (t *TimestampEditor) GetValueString() string { - if t.isEmpty { - return "" - } - return t.timestamp.UTC().Format(timeFormat) -} - -// Validate sets the validation function -func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor { - t.validate = validate - return t -} - -// Error returns the validation error -func (t *TimestampEditor) Error() error { - return t.err -} - -// Focus focuses the timestamp editor -func (t *TimestampEditor) Focus() tea.Cmd { - t.focused = true - return nil -} - -// Blur blurs the timestamp editor -func (t *TimestampEditor) Blur() tea.Cmd { - t.focused = false - t.err = t.validate(t.timestamp) - return nil -} - -// SetSize sets the size of the timestamp editor -func (t *TimestampEditor) SetSize(width, height int) { - t.width = width - t.height = height -} - -// Init initializes the timestamp editor -func (t *TimestampEditor) Init() tea.Cmd { - return nil -} - -// Update handles messages for the timestamp editor -func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if !t.focused { - return t, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - t.err = nil - - switch msg.String() { - // Navigation between fields - case "h", "left": - t.currentField = TimeField - case "l", "right": - t.currentField = DateField - - // Time field adjustments (lowercase - 5 minutes) - case "j": - // Set current time on first edit if empty - if t.isEmpty { - t.setCurrentTime() - } - if t.currentField == TimeField { - t.adjustTime(-5) - } else { - t.adjustDate(-1) - } - case "k": - // Set current time on first edit if empty - if t.isEmpty { - t.setCurrentTime() - } - if t.currentField == TimeField { - t.adjustTime(5) - } else { - t.adjustDate(1) - } - - // Time field adjustments (uppercase - 60 minutes) or date adjustments (week) - case "J": - // Set current time on first edit if empty - if t.isEmpty { - t.setCurrentTime() - } - if t.currentField == TimeField { - t.adjustTime(-60) - } else { - t.adjustDate(-7) - } - case "K": - // Set current time on first edit if empty - if t.isEmpty { - t.setCurrentTime() - } - if t.currentField == TimeField { - t.adjustTime(60) - } else { - t.adjustDate(7) - } - - // Remove timestamp - case "d": - t.timestamp = time.Time{} - t.isEmpty = true - } - } - - return t, nil -} - -// setCurrentTime sets the timestamp to the current time and marks it as not empty -func (t *TimestampEditor) setCurrentTime() { - now := time.Now() - // Snap to nearest 5 minutes - minute := now.Minute() - remainder := minute % 5 - if remainder != 0 { - if remainder < 3 { - // Round down - now = now.Add(-time.Duration(remainder) * time.Minute) - } else { - // Round up - now = now.Add(time.Duration(5-remainder) * time.Minute) - } - } - // Zero out seconds and nanoseconds - t.timestamp = time.Date( - now.Year(), - now.Month(), - now.Day(), - now.Hour(), - now.Minute(), - 0, 0, - now.Location(), - ) - t.isEmpty = false -} - -// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes -func (t *TimestampEditor) adjustTime(minutes int) { - // Add the minutes - t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute) - - // Snap to nearest 5 minutes - minute := t.timestamp.Minute() - remainder := minute % 5 - if remainder != 0 { - if remainder < 3 { - // Round down - t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute) - } else { - // Round up - t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute) - } - } - - // Zero out seconds and nanoseconds - t.timestamp = time.Date( - t.timestamp.Year(), - t.timestamp.Month(), - t.timestamp.Day(), - t.timestamp.Hour(), - t.timestamp.Minute(), - 0, 0, - t.timestamp.Location(), - ) -} - -// adjustDate adjusts the date by the given number of days -func (t *TimestampEditor) adjustDate(days int) { - t.timestamp = t.timestamp.AddDate(0, 0, days) -} - -// View renders the timestamp editor -func (t *TimestampEditor) View() string { - var sb strings.Builder - - styles := t.getStyles() - - // Render title if present - if t.title != "" { - sb.WriteString(styles.title.Render(t.title)) - if t.err != nil { - sb.WriteString(styles.errorIndicator.String()) - } - sb.WriteString("\n") - } - - // Render description if present - if t.description != "" { - sb.WriteString(styles.description.Render(t.description)) - sb.WriteString("\n") - } - - // Render the time and date fields side by side - var timeStr, dateStr string - if t.isEmpty { - timeStr = "--:--" - dateStr = "--- ----------" - } else { - timeStr = t.timestamp.Format("15:04") - dateStr = t.timestamp.Format("Mon 2006-01-02") - } - - var timeField, dateField string - if t.currentField == TimeField { - timeField = styles.selectedField.Render(timeStr) - dateField = styles.unselectedField.Render(dateStr) - } else { - timeField = styles.unselectedField.Render(timeStr) - dateField = styles.selectedField.Render(dateStr) - } - - fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField) - sb.WriteString(fieldsRow) - - return styles.base.Render(sb.String()) -} - -// getHelpText returns the help text based on the current field -func (t *TimestampEditor) getHelpText() string { - if t.currentField == TimeField { - return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove" - } - return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove" -} - -// Styles for the timestamp editor -type timestampEditorStyles struct { - base lipgloss.Style - title lipgloss.Style - description lipgloss.Style - errorIndicator lipgloss.Style - selectedField lipgloss.Style - unselectedField lipgloss.Style - help lipgloss.Style -} - -// getStyles returns the styles for the timestamp editor -func (t *TimestampEditor) getStyles() timestampEditorStyles { - theme := t.common.Styles.Form - var styles timestampEditorStyles - - if t.focused { - styles.base = lipgloss.NewStyle() - styles.title = theme.Focused.Title - styles.description = theme.Focused.Description - styles.errorIndicator = theme.Focused.ErrorIndicator - styles.selectedField = lipgloss.NewStyle(). - Bold(true). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("12")) - styles.unselectedField = lipgloss.NewStyle(). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")) - styles.help = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")). - Italic(true) - } else { - styles.base = lipgloss.NewStyle() - styles.title = theme.Blurred.Title - styles.description = theme.Blurred.Description - styles.errorIndicator = theme.Blurred.ErrorIndicator - styles.selectedField = lipgloss.NewStyle(). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")) - styles.unselectedField = lipgloss.NewStyle(). - Padding(0, 2). - Border(lipgloss.HiddenBorder()) - styles.help = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")) - } - - return styles -} - -// Skip returns whether the timestamp editor should be skipped -func (t *TimestampEditor) Skip() bool { - return false -} - -// Zoom returns whether the timestamp editor should be zoomed -func (t *TimestampEditor) Zoom() bool { - return false -} - -// KeyBinds returns the key bindings for the timestamp editor -func (t *TimestampEditor) KeyBinds() []key.Binding { - return []key.Binding{ - t.common.Keymap.Left, - t.common.Keymap.Right, - t.common.Keymap.Up, - t.common.Keymap.Down, - } -} diff --git a/components/timetable/table.go b/components/timetable/table.go deleted file mode 100644 index a6bd283..0000000 --- a/components/timetable/table.go +++ /dev/null @@ -1,582 +0,0 @@ -package timetable - -import ( - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" - - "tasksquire/common" - "tasksquire/timewarrior" -) - -// Model defines a state for the table widget. -type Model struct { - common *common.Common - KeyMap KeyMap - - cols []Column - rows timewarrior.Intervals - rowStyles []lipgloss.Style - cursor int - focus bool - styles common.TableStyle - styleFunc StyleFunc - - viewport viewport.Model - start int - end int -} - -// Row represents one line in the table. -type Row *timewarrior.Interval - -// Column defines the table structure. -type Column struct { - Title string - Name string - Width int - MaxWidth int - ContentWidth int -} - -// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which -// is used to render the menu. -type KeyMap struct { - LineUp key.Binding - LineDown key.Binding - PageUp key.Binding - PageDown key.Binding - HalfPageUp key.Binding - HalfPageDown key.Binding - GotoTop key.Binding - GotoBottom key.Binding -} - -// ShortHelp implements the KeyMap interface. -func (km KeyMap) ShortHelp() []key.Binding { - return []key.Binding{km.LineUp, km.LineDown} -} - -// FullHelp implements the KeyMap interface. -func (km KeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom}, - {km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown}, - } -} - -// DefaultKeyMap returns a default set of keybindings. -func DefaultKeyMap() KeyMap { - const spacebar = " " - return KeyMap{ - LineUp: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - LineDown: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - PageUp: key.NewBinding( - key.WithKeys("b", "pgup"), - key.WithHelp("b/pgup", "page up"), - ), - PageDown: key.NewBinding( - key.WithKeys("f", "pgdown", spacebar), - key.WithHelp("f/pgdn", "page down"), - ), - HalfPageUp: key.NewBinding( - key.WithKeys("u", "ctrl+u"), - key.WithHelp("u", "½ page up"), - ), - HalfPageDown: key.NewBinding( - key.WithKeys("d", "ctrl+d"), - key.WithHelp("d", "½ page down"), - ), - GotoTop: key.NewBinding( - key.WithKeys("home", "g"), - key.WithHelp("g/home", "go to start"), - ), - GotoBottom: key.NewBinding( - key.WithKeys("end", "G"), - key.WithHelp("G/end", "go to end"), - ), - } -} - -// SetStyles sets the table styles. -func (m *Model) SetStyles(s common.TableStyle) { - m.styles = s - m.UpdateViewport() -} - -// Option is used to set options in New. For example: -// -// table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) -type Option func(*Model) - -// New creates a new model for the table widget. -func New(com *common.Common, opts ...Option) Model { - m := Model{ - common: com, - cursor: 0, - viewport: viewport.New(0, 20), - - KeyMap: DefaultKeyMap(), - } - - for _, opt := range opts { - opt(&m) - } - - m.cols = m.parseColumns(m.cols) - m.rowStyles = m.parseRowStyles(m.rows) - - m.UpdateViewport() - - return m -} - -func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style { - styles := make([]lipgloss.Style, len(rows)) - if len(rows) == 0 { - return styles - } - for i := range rows { - // Default style - styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - - // If active, maybe highlight? - if rows[i].IsActive() { - if c, ok := m.common.Styles.Colors["active"]; ok && c != nil { - styles[i] = c.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding()) - } - } - } - return styles -} - -func (m *Model) parseColumns(cols []Column) []Column { - if len(cols) == 0 { - return cols - } - - for i, col := range cols { - for _, interval := range m.rows { - col.ContentWidth = max(col.ContentWidth, lipgloss.Width(interval.GetString(col.Name))) - } - cols[i] = col - } - - combinedSize := 0 - nonZeroWidths := 0 - tagIndex := -1 - for i, col := range cols { - if col.ContentWidth > 0 { - col.Width = max(col.ContentWidth, lipgloss.Width(col.Title)) - nonZeroWidths++ - } - - if !strings.Contains(col.Name, "tags") { - combinedSize += col.Width - } else { - tagIndex = i - } - - cols[i] = col - } - - if tagIndex >= 0 { - cols[tagIndex].Width = m.Width() - combinedSize - nonZeroWidths - } - - return cols -} - -// WithColumns sets the table columns (headers). -func WithColumns(cols []Column) Option { - return func(m *Model) { - m.cols = cols - } -} - -// WithRows sets the table rows (data). -func WithIntervals(rows timewarrior.Intervals) Option { - return func(m *Model) { - m.rows = rows - } -} - -// WithHeight sets the height of the table. -func WithHeight(h int) Option { - return func(m *Model) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) - } -} - -// WithWidth sets the width of the table. -func WithWidth(w int) Option { - return func(m *Model) { - m.viewport.Width = w - } -} - -// WithFocused sets the focus state of the table. -func WithFocused(f bool) Option { - return func(m *Model) { - m.focus = f - } -} - -// WithStyles sets the table styles. -func WithStyles(s common.TableStyle) Option { - return func(m *Model) { - m.styles = s - } -} - -// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state. -func WithStyleFunc(f StyleFunc) Option { - return func(m *Model) { - m.styleFunc = f - } -} - -// WithKeyMap sets the key map. -func WithKeyMap(km KeyMap) Option { - return func(m *Model) { - m.KeyMap = km - } -} - -// Update is the Bubble Tea update loop. -func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - if !m.focus { - return m, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.KeyMap.LineUp): - m.MoveUp(1) - case key.Matches(msg, m.KeyMap.LineDown): - m.MoveDown(1) - case key.Matches(msg, m.KeyMap.PageUp): - m.MoveUp(m.viewport.Height) - case key.Matches(msg, m.KeyMap.PageDown): - m.MoveDown(m.viewport.Height) - case key.Matches(msg, m.KeyMap.HalfPageUp): - m.MoveUp(m.viewport.Height / 2) - case key.Matches(msg, m.KeyMap.HalfPageDown): - m.MoveDown(m.viewport.Height / 2) - case key.Matches(msg, m.KeyMap.LineDown): - m.MoveDown(1) - case key.Matches(msg, m.KeyMap.GotoTop): - m.GotoTop() - case key.Matches(msg, m.KeyMap.GotoBottom): - m.GotoBottom() - } - } - - return m, nil -} - -// Focused returns the focus state of the table. -func (m Model) Focused() bool { - return m.focus -} - -// Focus focuses the table, allowing the user to move around the rows and -// interact. -func (m *Model) Focus() { - m.focus = true - m.UpdateViewport() -} - -// Blur blurs the table, preventing selection or movement. -func (m *Model) Blur() { - m.focus = false - m.UpdateViewport() -} - -// View renders the component. -func (m Model) View() string { - return m.headersView() + "\n" + m.viewport.View() -} - -// UpdateViewport updates the list content based on the previously defined -// columns and rows. -func (m *Model) UpdateViewport() { - renderedRows := make([]string, 0, len(m.rows)) - - if m.cursor >= 0 { - m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor) - } else { - m.start = 0 - } - m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows)) - for i := m.start; i < m.end; i++ { - renderedRows = append(renderedRows, m.renderRow(i)) - } - - m.viewport.SetContent( - lipgloss.JoinVertical(lipgloss.Left, renderedRows...), - ) -} - -// SelectedRow returns the selected row. -// Returns nil if cursor is on a gap row or out of bounds. -func (m Model) SelectedRow() Row { - if m.cursor < 0 || m.cursor >= len(m.rows) { - return nil - } - - // Don't return gap rows as selected - if m.rows[m.cursor].IsGap { - return nil - } - - return m.rows[m.cursor] -} - -// Rows returns the current rows. -func (m Model) Rows() timewarrior.Intervals { - return m.rows -} - -// Columns returns the current columns. -func (m Model) Columns() []Column { - return m.cols -} - -// SetRows sets a new rows state. -func (m *Model) SetRows(r timewarrior.Intervals) { - m.rows = r - m.UpdateViewport() -} - -// SetColumns sets a new columns state. -func (m *Model) SetColumns(c []Column) { - m.cols = c - m.UpdateViewport() -} - -// SetWidth sets the width of the viewport of the table. -func (m *Model) SetWidth(w int) { - m.viewport.Width = w - m.UpdateViewport() -} - -// SetHeight sets the height of the viewport of the table. -func (m *Model) SetHeight(h int) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) - m.UpdateViewport() -} - -// Height returns the viewport height of the table. -func (m Model) Height() int { - return m.viewport.Height -} - -// Width returns the viewport width of the table. -func (m Model) Width() int { - return m.viewport.Width -} - -// Cursor returns the index of the selected row. -func (m Model) Cursor() int { - return m.cursor -} - -// SetCursor sets the cursor position in the table. -// Skips gap rows by moving to the nearest non-gap row. -func (m *Model) SetCursor(n int) { - m.cursor = clamp(n, 0, len(m.rows)-1) - - // Skip gap rows - try moving down first, then up - if m.cursor < len(m.rows) && m.rows[m.cursor].IsGap { - // Try moving down to find non-gap - found := false - for i := m.cursor; i < len(m.rows); i++ { - if !m.rows[i].IsGap { - m.cursor = i - found = true - break - } - } - // If not found down, try moving up - if !found { - for i := m.cursor; i >= 0; i-- { - if !m.rows[i].IsGap { - m.cursor = i - break - } - } - } - } - - m.UpdateViewport() -} - -// MoveUp moves the selection up by any number of rows. -// It can not go above the first row. Skips gap rows. -func (m *Model) MoveUp(n int) { - originalCursor := m.cursor - m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) - - // Skip gap rows - for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap { - m.cursor-- - } - - // If we went past the beginning, find the first non-gap row - if m.cursor < 0 { - for i := 0; i < len(m.rows); i++ { - if !m.rows[i].IsGap { - m.cursor = i - break - } - } - } - - // If no non-gap row found, restore original cursor - if m.cursor < 0 || (m.cursor < len(m.rows) && m.rows[m.cursor].IsGap) { - m.cursor = originalCursor - } - - switch { - case m.start == 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) - case m.start < m.viewport.Height: - m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height)) - case m.viewport.YOffset >= 1: - m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height) - } - m.UpdateViewport() -} - -// MoveDown moves the selection down by any number of rows. -// It can not go below the last row. Skips gap rows. -func (m *Model) MoveDown(n int) { - originalCursor := m.cursor - m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) - - // Skip gap rows - for m.cursor >= 0 && m.cursor < len(m.rows) && m.rows[m.cursor].IsGap { - m.cursor++ - } - - // If we went past the end, find the last non-gap row - if m.cursor >= len(m.rows) { - for i := len(m.rows) - 1; i >= 0; i-- { - if !m.rows[i].IsGap { - m.cursor = i - break - } - } - } - - // If no non-gap row found, restore original cursor - if m.cursor >= len(m.rows) || (m.cursor >= 0 && m.rows[m.cursor].IsGap) { - m.cursor = originalCursor - } - - m.UpdateViewport() - - switch { - case m.end == len(m.rows) && m.viewport.YOffset > 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height)) - case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0: - m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor)) - case m.viewport.YOffset > 1: - case m.cursor > m.viewport.YOffset+m.viewport.Height-1: - m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1)) - } -} - -// GotoTop moves the selection to the first row. -func (m *Model) GotoTop() { - m.MoveUp(m.cursor) -} - -// GotoBottom moves the selection to the last row. -func (m *Model) GotoBottom() { - m.MoveDown(len(m.rows)) -} - -// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index. -type StyleFunc func(row, col int, value string) lipgloss.Style - -func (m Model) headersView() string { - var s = make([]string, 0, len(m.cols)) - for _, col := range m.cols { - if col.Width <= 0 { - continue - } - style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) - renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) - s = append(s, m.styles.Header.Render(renderedCell)) - } - return lipgloss.JoinHorizontal(lipgloss.Left, s...) -} - -func (m *Model) renderRow(r int) string { - // Special rendering for gap rows - if m.rows[r].IsGap { - gapText := m.rows[r].GetString("gap_display") - gapStyle := lipgloss.NewStyle(). - Foreground(m.common.Styles.Palette.Muted.GetForeground()). - Align(lipgloss.Center). - Width(m.Width()) - return gapStyle.Render(gapText) - } - - var s = make([]string, 0, len(m.cols)) - for i, col := range m.cols { - if m.cols[i].Width <= 0 { - continue - } - var cellStyle lipgloss.Style - cellStyle = m.rowStyles[r] - if r == m.cursor { - cellStyle = cellStyle.Inherit(m.styles.Selected) - } - - style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) - renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…"))) - s = append(s, renderedCell) - } - - row := lipgloss.JoinHorizontal(lipgloss.Left, s...) - - if r == m.cursor { - return m.styles.Selected.Render(row) - } - - return row -} - -func max(a, b int) int { - if a > b { - return a - } - - return b -} - -func min(a, b int) int { - if a < b { - return a - } - - return b -} - -func clamp(v, low, high int) int { - return min(max(v, low), high) -} diff --git a/flake.lock b/flake.lock index f9ae013..8492a93 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", "owner": "nixos", "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e6cbd16..ef76e4d 100644 --- a/flake.nix +++ b/flake.nix @@ -38,7 +38,7 @@ devShells.default = pkgs.mkShell { inputsFrom = [ self'.packages.tasksquire ]; buildInputs = with pkgs; [ - go_1_24 + go gcc gotools golangci-lint diff --git a/go.mod b/go.mod index 4b533d6..0e54fea 100644 --- a/go.mod +++ b/go.mod @@ -1,51 +1,28 @@ module tasksquire -go 1.23.0 +go 1.25 -toolchain go1.24.12 +toolchain go1.25.7 require ( - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.26.4 - github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/huh v0.4.2 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/mattn/go-runewidth v0.0.16 - github.com/sahilm/fuzzy v0.1.1 - golang.org/x/term v0.31.0 -) - -require ( - github.com/alecthomas/chroma/v2 v2.14.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a // indirect - github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.0 // indirect + charm.land/lipgloss/v2 v2.0.0 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.8 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index de7290b..2c50afe 100644 --- a/go.sum +++ b/go.sum @@ -1,100 +1,50 @@ -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= -github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/huh v0.4.2 h1:5wLkwrA58XDAfEZsJzNQlfJ+K8N9+wYwvR5FOM7jXFM= -github.com/charmbracelet/huh v0.4.2/go.mod h1:g9OXBgtY3zRV4ahnVih9bZE+1yGYN+y2C9Q6L2P+WM0= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a h1:lOpqe2UvPmlln41DGoii7wlSZ/q8qGIon5JJ8Biu46I= -github.com/charmbracelet/x/exp/strings v0.0.0-20240524151031-ff83003bf67a/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7 h1:6Rw/MHf+Rm7wlUr+euFyaGw1fNKeZPKVh4gkFHUjFsY= -github.com/charmbracelet/x/exp/term v0.0.0-20240606154654-7c42867b53c7/go.mod h1:UZyEz89i6+jVx2oW3lgmIsVDNr7qzaKuvPVoSdwqDR0= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= +charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= +charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= diff --git a/common/common.go b/internal/common/common.go similarity index 94% rename from common/common.go rename to internal/common/common.go index 18cfacb..cce6508 100644 --- a/common/common.go +++ b/internal/common/common.go @@ -5,8 +5,8 @@ import ( "log/slog" "os" - "tasksquire/taskwarrior" - "tasksquire/timewarrior" + "tasksquire/internal/taskwarrior" + "tasksquire/internal/timewarrior" "golang.org/x/term" ) diff --git a/common/component.go b/internal/common/component.go similarity index 68% rename from common/component.go rename to internal/common/component.go index 1ec547b..f49b9f9 100644 --- a/common/component.go +++ b/internal/common/component.go @@ -1,6 +1,6 @@ package common -import tea "github.com/charmbracelet/bubbletea" +import tea "charm.land/bubbletea/v2" type Component interface { tea.Model diff --git a/common/keymap.go b/internal/common/keymap.go similarity index 98% rename from common/keymap.go rename to internal/common/keymap.go index c74f8d5..54e6284 100644 --- a/common/keymap.go +++ b/internal/common/keymap.go @@ -1,7 +1,7 @@ package common import ( - "github.com/charmbracelet/bubbles/key" + "charm.land/bubbles/v2/key" ) // Keymap is a collection of key bindings. diff --git a/common/stack.go b/internal/common/stack.go similarity index 100% rename from common/stack.go rename to internal/common/stack.go diff --git a/common/styles.go b/internal/common/styles.go similarity index 78% rename from common/styles.go rename to internal/common/styles.go index b766d5f..bec2f14 100644 --- a/common/styles.go +++ b/internal/common/styles.go @@ -4,13 +4,11 @@ import ( "log/slog" "strconv" "strings" + "image/color" - // "github.com/charmbracelet/bubbles/table" + "tasksquire/internal/taskwarrior" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - - "tasksquire/taskwarrior" + "charm.land/lipgloss/v2" ) type TableStyle struct { @@ -35,7 +33,7 @@ type Styles struct { Base lipgloss.Style - Form *huh.Theme + // Form *huh.Theme TableStyle TableStyle Tab lipgloss.Style @@ -90,21 +88,21 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles { Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()), } - formTheme := huh.ThemeBase() - formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground()) - formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground()) - formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground()) - formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground()) - formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground()) - formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ") - formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground()) - formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ") - formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true) - formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ") - formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ") - formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ") + // formTheme := huh.ThemeBase() + // formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground()) + // formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground()) + // formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground()) + // formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground()) + // formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground()) + // formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ") + // formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground()) + // formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ") + // formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true) + // formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ") + // formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ") + // formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ") - styles.Form = formTheme + // styles.Form = formTheme styles.Tab = lipgloss.NewStyle(). Padding(0, 1). @@ -164,7 +162,7 @@ func parseColorString(color string) *lipgloss.Style { return &style } -func parseColor(color string) lipgloss.Color { +func parseColor(color string) color.Color { if strings.HasPrefix(color, "rgb") { return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb"))) } diff --git a/common/sync.go b/internal/common/sync.go similarity index 96% rename from common/sync.go rename to internal/common/sync.go index 802fd16..d1591eb 100644 --- a/common/sync.go +++ b/internal/common/sync.go @@ -3,8 +3,8 @@ package common import ( "log/slog" - "tasksquire/taskwarrior" - "tasksquire/timewarrior" + "tasksquire/internal/taskwarrior" + "tasksquire/internal/timewarrior" ) // FindTaskByUUID queries Taskwarrior for a task with the given UUID. diff --git a/common/task.go b/internal/common/task.go similarity index 100% rename from common/task.go rename to internal/common/task.go diff --git a/internal/pages/main.go b/internal/pages/main.go new file mode 100644 index 0000000..9ebd9f5 --- /dev/null +++ b/internal/pages/main.go @@ -0,0 +1,112 @@ +package pages + +import ( + "tasksquire/internal/common" + + tea "charm.land/bubbletea/v2" + // "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +type MainPage struct { + common *common.Common + activePage common.Component + + taskPage common.Component + timePage common.Component + currentTab int + width int + height int +} + +func NewMainPage(common *common.Common) *MainPage { + m := &MainPage{ + common: common, + } + + // m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) + // m.timePage = NewTimePage(common) + // + // m.activePage = m.taskPage + m.currentTab = 0 + + return m +} + +func (m *MainPage) Init() tea.Cmd { + // return tea.Batch(m.taskPage.Init(), m.timePage.Init()) + return tea.Batch() +} + +func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // switch msg := msg.(type) { + // case tea.WindowSizeMsg: + // m.width = msg.Width + // m.height = msg.Height + // m.common.SetSize(msg.Width, msg.Height) + // + // tabHeight := lipgloss.Height(m.renderTabBar()) + // contentHeight := msg.Height - tabHeight + // if contentHeight < 0 { + // contentHeight = 0 + // } + // + // newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight} + // activePage, cmd := m.activePage.Update(newMsg) + // m.activePage = activePage.(common.Component) + // return m, cmd + // + // case tea.KeyMsg: + // // Only handle tab key for page switching when at the top level (no subpages active) + // if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() { + // if m.activePage == m.taskPage { + // m.activePage = m.timePage + // m.currentTab = 1 + // } else { + // m.activePage = m.taskPage + // m.currentTab = 0 + // } + // + // tabHeight := lipgloss.Height(m.renderTabBar()) + // contentHeight := m.height - tabHeight + // if contentHeight < 0 { + // contentHeight = 0 + // } + // m.activePage.SetSize(m.width, contentHeight) + // + // // Trigger a refresh/init on switch? Maybe not needed if we keep state. + // // But we might want to refresh data. + // return m, m.activePage.Init() + // } + // } + // + // activePage, cmd := m.activePage.Update(msg) + // m.activePage = activePage.(common.Component) + // + return m, cmd +} + +func (m *MainPage) renderTabBar() string { + var tabs []string + headers := []string{"Tasks", "Time"} + + for i, header := range headers { + style := m.common.Styles.Tab + if m.currentTab == i { + style = m.common.Styles.ActiveTab + } + tabs = append(tabs, style.Render(header)) + } + + row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + return m.common.Styles.TabBar.Width(m.common.Width()).Render(row) +} + +func (m *MainPage) View() tea.View { + v := tea.NewView("test") + v.AltScreen = true + return v + // return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View()) +} diff --git a/taskwarrior/config.go b/internal/taskwarrior/config.go similarity index 100% rename from taskwarrior/config.go rename to internal/taskwarrior/config.go diff --git a/taskwarrior/models.go b/internal/taskwarrior/models.go similarity index 100% rename from taskwarrior/models.go rename to internal/taskwarrior/models.go diff --git a/taskwarrior/models_test.go b/internal/taskwarrior/models_test.go similarity index 100% rename from taskwarrior/models_test.go rename to internal/taskwarrior/models_test.go diff --git a/taskwarrior/taskwarrior.go b/internal/taskwarrior/taskwarrior.go similarity index 88% rename from taskwarrior/taskwarrior.go rename to internal/taskwarrior/taskwarrior.go index d2dfea4..0a29582 100644 --- a/taskwarrior/taskwarrior.go +++ b/internal/taskwarrior/taskwarrior.go @@ -106,7 +106,7 @@ type TaskWarrior interface { Undo() } -type TaskSquire struct { +type TaskwarriorInterop struct { configLocation string defaultArgs []string config *TWConfig @@ -117,14 +117,14 @@ type TaskSquire struct { mutex sync.Mutex } -func NewTaskSquire(ctx context.Context, configLocation string) *TaskSquire { +func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop { if _, err := exec.LookPath(twBinary); err != nil { slog.Error("Taskwarrior not found") return nil } defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"} - ts := &TaskSquire{ + ts := &TaskwarriorInterop{ configLocation: configLocation, defaultArgs: defaultArgs, ctx: ctx, @@ -141,14 +141,14 @@ func NewTaskSquire(ctx context.Context, configLocation string) *TaskSquire { return ts } -func (ts *TaskSquire) GetConfig() *TWConfig { +func (ts *TaskwarriorInterop) GetConfig() *TWConfig { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.config } -func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks { +func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -190,7 +190,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks { } for _, task := range tasks { - if task.Depends != nil && len(task.Depends) > 0 { + if len(task.Depends) > 0 { ids := make([]string, len(task.Depends)) for i, dependUuid := range task.Depends { ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)}) @@ -203,7 +203,7 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks { return tasks } -func (ts *TaskSquire) getIds(filter []string) string { +func (ts *TaskwarriorInterop) getIds(filter []string) string { cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...) out, err := cmd.CombinedOutput() if err != nil { @@ -214,7 +214,7 @@ func (ts *TaskSquire) getIds(filter []string) string { return strings.TrimSpace(string(out)) } -func (ts *TaskSquire) GetContext(context string) *Context { +func (ts *TaskwarriorInterop) GetContext(context string) *Context { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -230,7 +230,7 @@ func (ts *TaskSquire) GetContext(context string) *Context { } } -func (ts *TaskSquire) GetActiveContext() *Context { +func (ts *TaskwarriorInterop) GetActiveContext() *Context { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -243,14 +243,14 @@ func (ts *TaskSquire) GetActiveContext() *Context { return ts.contexts["none"] } -func (ts *TaskSquire) GetContexts() Contexts { +func (ts *TaskwarriorInterop) GetContexts() Contexts { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.contexts } -func (ts *TaskSquire) GetProjects() []string { +func (ts *TaskwarriorInterop) GetProjects() []string { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -274,7 +274,7 @@ func (ts *TaskSquire) GetProjects() []string { return projects } -func (ts *TaskSquire) GetPriorities() []string { +func (ts *TaskwarriorInterop) GetPriorities() []string { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -288,7 +288,7 @@ func (ts *TaskSquire) GetPriorities() []string { return priorities } -func (ts *TaskSquire) GetTags() []string { +func (ts *TaskwarriorInterop) GetTags() []string { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -310,7 +310,7 @@ func (ts *TaskSquire) GetTags() []string { } } - for _, tag := range strings.Split(ts.config.Get("uda.tasksquire.tags.default"), ",") { + for _, tag := range strings.Split(ts.config.Get("uda.TaskwarriorInterop.tags.default"), ",") { if _, ok := tagSet[tag]; !ok { tags = append(tags, tag) } @@ -321,21 +321,21 @@ func (ts *TaskSquire) GetTags() []string { return tags } -func (ts *TaskSquire) GetReport(report string) *Report { +func (ts *TaskwarriorInterop) GetReport(report string) *Report { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.reports[report] } -func (ts *TaskSquire) GetReports() Reports { +func (ts *TaskwarriorInterop) GetReports() Reports { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.reports } -func (ts *TaskSquire) GetUdas() []Uda { +func (ts *TaskwarriorInterop) GetUdas() []Uda { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -377,7 +377,7 @@ func (ts *TaskSquire) GetUdas() []Uda { return udas } -func (ts *TaskSquire) SetContext(context *Context) error { +func (ts *TaskwarriorInterop) SetContext(context *Context) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -398,7 +398,7 @@ func (ts *TaskSquire) SetContext(context *Context) error { return nil } -// func (ts *TaskSquire) AddTask(task *Task) error { +// func (ts *TaskwarriorInterop) AddTask(task *Task) error { // ts.mutex.Lock() // defer ts.mutex.Unlock() @@ -436,7 +436,7 @@ func (ts *TaskSquire) SetContext(context *Context) error { // } // TODO error handling -func (ts *TaskSquire) ImportTask(task *Task) { +func (ts *TaskwarriorInterop) ImportTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -456,7 +456,7 @@ func (ts *TaskSquire) ImportTask(task *Task) { } } -func (ts *TaskSquire) SetTaskDone(task *Task) { +func (ts *TaskwarriorInterop) SetTaskDone(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -467,7 +467,7 @@ func (ts *TaskSquire) SetTaskDone(task *Task) { } } -func (ts *TaskSquire) DeleteTask(task *Task) { +func (ts *TaskwarriorInterop) DeleteTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -479,7 +479,7 @@ func (ts *TaskSquire) DeleteTask(task *Task) { } -func (ts *TaskSquire) Undo() { +func (ts *TaskwarriorInterop) Undo() { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -490,7 +490,7 @@ func (ts *TaskSquire) Undo() { } } -func (ts *TaskSquire) StartTask(task *Task) { +func (ts *TaskwarriorInterop) StartTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -501,7 +501,7 @@ func (ts *TaskSquire) StartTask(task *Task) { } } -func (ts *TaskSquire) StopTask(task *Task) { +func (ts *TaskwarriorInterop) StopTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -512,7 +512,7 @@ func (ts *TaskSquire) StopTask(task *Task) { } } -func (ts *TaskSquire) StopActiveTasks() { +func (ts *TaskwarriorInterop) StopActiveTasks() { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -542,7 +542,7 @@ func (ts *TaskSquire) StopActiveTasks() { } } -func (ts *TaskSquire) GetInformation(task *Task) string { +func (ts *TaskwarriorInterop) GetInformation(task *Task) string { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -559,7 +559,7 @@ func (ts *TaskSquire) GetInformation(task *Task) string { return string(output) } -func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) { +func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -570,7 +570,7 @@ func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) { } } -func (ts *TaskSquire) extractConfig() *TWConfig { +func (ts *TaskwarriorInterop) extractConfig() *TWConfig { cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...) output, err := cmd.CombinedOutput() if err != nil { @@ -584,7 +584,7 @@ func (ts *TaskSquire) extractConfig() *TWConfig { return NewConfig(strings.Split(string(output), "\n")) } -func (ts *TaskSquire) extractReports() Reports { +func (ts *TaskwarriorInterop) extractReports() Reports { cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...) output, err := cmd.CombinedOutput() if err != nil { @@ -630,7 +630,7 @@ func extractReports(config string) []string { return reports } -func (ts *TaskSquire) extractContexts() Contexts { +func (ts *TaskwarriorInterop) extractContexts() Contexts { cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...) output, err := cmd.CombinedOutput() diff --git a/taskwarrior/taskwarrior_test.go b/internal/taskwarrior/taskwarrior_test.go similarity index 100% rename from taskwarrior/taskwarrior_test.go rename to internal/taskwarrior/taskwarrior_test.go diff --git a/taskwarrior/tree.go b/internal/taskwarrior/tree.go similarity index 100% rename from taskwarrior/tree.go rename to internal/taskwarrior/tree.go diff --git a/taskwarrior/tree_test.go b/internal/taskwarrior/tree_test.go similarity index 100% rename from taskwarrior/tree_test.go rename to internal/taskwarrior/tree_test.go diff --git a/timewarrior/config.go b/internal/timewarrior/config.go similarity index 100% rename from timewarrior/config.go rename to internal/timewarrior/config.go diff --git a/timewarrior/models.go b/internal/timewarrior/models.go similarity index 100% rename from timewarrior/models.go rename to internal/timewarrior/models.go diff --git a/timewarrior/tags.go b/internal/timewarrior/tags.go similarity index 100% rename from timewarrior/tags.go rename to internal/timewarrior/tags.go diff --git a/timewarrior/timewarrior.go b/internal/timewarrior/timewarrior.go similarity index 87% rename from timewarrior/timewarrior.go rename to internal/timewarrior/timewarrior.go index 9fe87e0..5481ee7 100644 --- a/timewarrior/timewarrior.go +++ b/internal/timewarrior/timewarrior.go @@ -41,7 +41,7 @@ type TimeWarrior interface { Undo() } -type TimeSquire struct { +type TimewarriorInterop struct { configLocation string defaultArgs []string config *TWConfig @@ -50,13 +50,13 @@ type TimeSquire struct { mutex sync.Mutex } -func NewTimeSquire(ctx context.Context, configLocation string) *TimeSquire { +func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop { if _, err := exec.LookPath(twBinary); err != nil { slog.Error("Timewarrior not found") return nil } - ts := &TimeSquire{ + ts := &TimewarriorInterop{ configLocation: configLocation, defaultArgs: []string{}, ctx: ctx, @@ -67,14 +67,14 @@ func NewTimeSquire(ctx context.Context, configLocation string) *TimeSquire { return ts } -func (ts *TimeSquire) GetConfig() *TWConfig { +func (ts *TimewarriorInterop) GetConfig() *TWConfig { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.config } -func (ts *TimeSquire) GetTags() []string { +func (ts *TimewarriorInterop) GetTags() []string { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -108,7 +108,7 @@ func (ts *TimeSquire) GetTags() []string { // GetTagCombinations returns unique tag combinations from intervals, // ordered newest first (most recent intervals' tags appear first). // Returns formatted strings like "dev client-work meeting". -func (ts *TimeSquire) GetTagCombinations() []string { +func (ts *TimewarriorInterop) GetTagCombinations() []string { intervals := ts.GetIntervals() // Already sorted newest first // Track unique combinations while preserving order @@ -147,7 +147,7 @@ func formatTagsForCombination(tags []string) string { // getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only). // Caller must hold ts.mutex. -func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals { +func (ts *TimewarriorInterop) getIntervalsUnlocked(filter ...string) Intervals { args := append(ts.defaultArgs, "export") if filter != nil { args = append(args, filter...) @@ -182,14 +182,14 @@ func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals { } // GetIntervals fetches intervals with the given filter, acquiring mutex lock. -func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { +func (ts *TimewarriorInterop) GetIntervals(filter ...string) Intervals { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.getIntervalsUnlocked(filter...) } -func (ts *TimeSquire) StartTracking(tags []string) error { +func (ts *TimewarriorInterop) StartTracking(tags []string) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -209,7 +209,7 @@ func (ts *TimeSquire) StartTracking(tags []string) error { return nil } -func (ts *TimeSquire) StopTracking() error { +func (ts *TimewarriorInterop) StopTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -222,7 +222,7 @@ func (ts *TimeSquire) StopTracking() error { return nil } -func (ts *TimeSquire) ContinueTracking() error { +func (ts *TimewarriorInterop) ContinueTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -235,7 +235,7 @@ func (ts *TimeSquire) ContinueTracking() error { return nil } -func (ts *TimeSquire) ContinueInterval(id int) error { +func (ts *TimewarriorInterop) ContinueInterval(id int) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -248,7 +248,7 @@ func (ts *TimeSquire) ContinueInterval(id int) error { return nil } -func (ts *TimeSquire) CancelTracking() error { +func (ts *TimewarriorInterop) CancelTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -261,7 +261,7 @@ func (ts *TimeSquire) CancelTracking() error { return nil } -func (ts *TimeSquire) DeleteInterval(id int) error { +func (ts *TimewarriorInterop) DeleteInterval(id int) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -274,7 +274,7 @@ func (ts *TimeSquire) DeleteInterval(id int) error { return nil } -func (ts *TimeSquire) FillInterval(id int) error { +func (ts *TimewarriorInterop) FillInterval(id int) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -287,7 +287,7 @@ func (ts *TimeSquire) FillInterval(id int) error { return nil } -func (ts *TimeSquire) JoinInterval(id int) error { +func (ts *TimewarriorInterop) JoinInterval(id int) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -302,7 +302,7 @@ func (ts *TimeSquire) JoinInterval(id int) error { return nil } -func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error { +func (ts *TimewarriorInterop) ModifyInterval(interval *Interval, adjust bool) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -331,7 +331,7 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error { return nil } -func (ts *TimeSquire) GetSummary(filter ...string) string { +func (ts *TimewarriorInterop) GetSummary(filter ...string) string { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -350,7 +350,7 @@ func (ts *TimeSquire) GetSummary(filter ...string) string { return string(output) } -func (ts *TimeSquire) GetActive() *Interval { +func (ts *TimewarriorInterop) GetActive() *Interval { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -371,7 +371,7 @@ func (ts *TimeSquire) GetActive() *Interval { return nil } -func (ts *TimeSquire) Undo() { +func (ts *TimewarriorInterop) Undo() { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -382,7 +382,7 @@ func (ts *TimeSquire) Undo() { } } -func (ts *TimeSquire) extractConfig() *TWConfig { +func (ts *TimewarriorInterop) extractConfig() *TWConfig { cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...) output, err := cmd.CombinedOutput() if err != nil { diff --git a/main.go b/main.go deleted file mode 100644 index 060cb55..0000000 --- a/main.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "log/slog" - "os" - - "tasksquire/common" - "tasksquire/pages" - "tasksquire/taskwarrior" - "tasksquire/timewarrior" - - tea "github.com/charmbracelet/bubbletea" -) - -func main() { - var taskrcPath string - if taskrcEnv := os.Getenv("TASKRC"); taskrcEnv != "" { - taskrcPath = taskrcEnv - } else if _, err := os.Stat(os.Getenv("HOME") + "/.taskrc"); err == nil { - taskrcPath = os.Getenv("HOME") + "/.taskrc" - } else if _, err := os.Stat(os.Getenv("HOME") + "/.config/task/taskrc"); err == nil { - taskrcPath = os.Getenv("HOME") + "/.config/task/taskrc" - } else { - log.Fatal("Unable to find taskrc file") - } - - var timewConfigPath string - if timewConfigEnv := os.Getenv("TIMEWARRIORDB"); timewConfigEnv != "" { - timewConfigPath = timewConfigEnv - } else if _, err := os.Stat(os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg"); err == nil { - timewConfigPath = os.Getenv("HOME") + "/.timewarrior/timewarrior.cfg" - } else { - // Default to empty string if not found, let TimeSquire handle defaults or errors if necessary - // But TimeSquire seems to only take config location. - // Let's assume standard location if not found or pass empty if it auto-detects. - // Checking TimeSquire implementation: it calls `timew show` which usually works if timew is in path. - timewConfigPath = "" - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - ts := taskwarrior.NewTaskSquire(ctx, taskrcPath) - if ts == nil { - log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.") - } - - tws := timewarrior.NewTimeSquire(ctx, timewConfigPath) - common := common.NewCommon(ctx, ts, tws) - - file, err := os.OpenFile("/tmp/tasksquire.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Fatalf("failed to open log file: %v", err) - } - defer file.Close() - - // Create a new slog handler for the file - handler := slog.NewTextHandler(file, &slog.HandlerOptions{}) - - // Set the default logger to use the file handler - slog.SetDefault(slog.New(handler)) - - // form := huh.NewForm( - // huh.NewGroup( - // huh.NewSelect[string](). - // Options(huh.NewOptions(config.Reports...)...). - // Title("Report"). - // Description("Choose the report to display"). - // Value(&report), - // ), - // ) - - // err = form.Run() - // if err != nil { - // slog.Error("Uh oh:", err) - // os.Exit(1) - // } - m := pages.NewMainPage(common) - - if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { - fmt.Println("Error running program:", err) - os.Exit(1) - } - -} diff --git a/on-modify.timewarrior b/on-modify.timewarrior deleted file mode 100644 index 9a0f566..0000000 --- a/on-modify.timewarrior +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 - -############################################################################### -# -# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included -# in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -# https://www.opensource.org/licenses/mit-license.php -# -############################################################################### - -import json -import subprocess -import sys - -# Hook should extract all the following for use as Timewarrior tags: -# UUID -# Project -# Tags -# Description -# UDAs - -try: - input_stream = sys.stdin.buffer -except AttributeError: - input_stream = sys.stdin - - -def extract_tags_from(json_obj): - # Extract attributes for use as tags. - tags = [json_obj['description']] - - # Add UUID with prefix for reliable task linking - if 'uuid' in json_obj: - tags.append('uuid:' + json_obj['uuid']) - - # Add project with prefix for separate column display - if 'project' in json_obj: - tags.append('project:' + json_obj['project']) - - if 'tags' in json_obj: - if type(json_obj['tags']) is str: - # Usage of tasklib (e.g. in taskpirate) converts the tag list into a string - # If this is the case, convert it back into a list first - # See https://github.com/tbabej/taskpirate/issues/11 - task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next'] - tags.extend(task_tags) - else: - # Filter out the 'next' tag - task_tags = [tag for tag in json_obj['tags'] if tag != 'next'] - tags.extend(task_tags) - - return tags - - -def extract_annotation_from(json_obj): - - if 'annotations' not in json_obj: - return '\'\'' - - return json_obj['annotations'][0]['description'] - - -def main(old, new): - - start_or_stop = '' - - # Started task. - if 'start' in new and 'start' not in old: - start_or_stop = 'start' - - # Stopped task. - elif ('start' not in new or 'end' in new) and 'start' in old: - start_or_stop = 'stop' - - if start_or_stop: - tags = extract_tags_from(new) - - subprocess.call(['timew', start_or_stop] + tags + [':yes']) - - # Modifications to task other than start/stop - elif 'start' in new and 'start' in old: - old_tags = extract_tags_from(old) - new_tags = extract_tags_from(new) - - if old_tags != new_tags: - subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes']) - subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes']) - - old_annotation = extract_annotation_from(old) - new_annotation = extract_annotation_from(new) - - if old_annotation != new_annotation: - subprocess.call(['timew', 'annotate', '@1', new_annotation]) - - -if __name__ == "__main__": - old = json.loads(input_stream.readline().decode("utf-8", errors="replace")) - new = json.loads(input_stream.readline().decode("utf-8", errors="replace")) - print(json.dumps(new)) - main(old, new) diff --git a/pages/contextPicker.go b/pages/contextPicker.go deleted file mode 100644 index f0c764a..0000000 --- a/pages/contextPicker.go +++ /dev/null @@ -1,140 +0,0 @@ -package pages - -import ( - "log/slog" - "slices" - "tasksquire/common" - "tasksquire/components/picker" - "tasksquire/taskwarrior" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type ContextPickerPage struct { - common *common.Common - contexts taskwarrior.Contexts - picker *picker.Picker -} - -func NewContextPickerPage(common *common.Common) *ContextPickerPage { - p := &ContextPickerPage{ - common: common, - contexts: common.TW.GetContexts(), - } - - selected := common.TW.GetActiveContext().Name - - itemProvider := func() []list.Item { - contexts := common.TW.GetContexts() - options := make([]string, 0) - for _, c := range contexts { - if c.Name != "none" { - options = append(options, c.Name) - } - } - slices.Sort(options) - options = append([]string{"(none)"}, options...) - - items := []list.Item{} - for _, opt := range options { - items = append(items, picker.NewItem(opt)) - } - return items - } - - onSelect := func(item list.Item) tea.Cmd { - return func() tea.Msg { return contextSelectedMsg{item: item} } - } - - p.picker = picker.New(common, "Contexts", itemProvider, onSelect) - - // Set active context - if selected == "" { - selected = "(none)" - } - p.picker.SelectItemByFilterValue(selected) - - p.SetSize(common.Width(), common.Height()) - - return p -} - -func (p *ContextPickerPage) SetSize(width, height int) { - p.common.SetSize(width, height) - - // Use shared modal sizing logic - modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height) - p.picker.SetSize(modalWidth-2, modalHeight-2) -} - -func (p *ContextPickerPage) Init() tea.Cmd { - return p.picker.Init() -} - -type contextSelectedMsg struct { - item list.Item -} - -func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.SetSize(msg.Width, msg.Height) - case contextSelectedMsg: - name := msg.item.FilterValue() // Use FilterValue (which is the name/text) - if name == "(none)" { - name = "" - } - - ctx := p.common.TW.GetContext(name) - - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, func() tea.Msg { return UpdateContextMsg(ctx) } - case tea.KeyMsg: - if !p.picker.IsFiltering() { - 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 - } - } - } - - _, cmd = p.picker.Update(msg) - return p, cmd -} - -func (p *ContextPickerPage) View() string { - modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height()) - - content := p.picker.View() - styledContent := lipgloss.NewStyle(). - Width(modalWidth). - Height(modalHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(p.common.Styles.Palette.Border.GetForeground()). - Padding(0, 1). - Render(content) - - return lipgloss.Place( - p.common.Width(), - p.common.Height(), - lipgloss.Center, - lipgloss.Center, - styledContent, - ) -} - -type UpdateContextMsg *taskwarrior.Context diff --git a/pages/datePicker.go b/pages/datePicker.go deleted file mode 100644 index 1bfb703..0000000 --- a/pages/datePicker.go +++ /dev/null @@ -1,122 +0,0 @@ -package pages - -// import ( -// "tasksquire/common" - -// "github.com/charmbracelet/bubbles/textinput" -// tea "github.com/charmbracelet/bubbletea" -// "github.com/charmbracelet/lipgloss" -// datepicker "github.com/ethanefung/bubble-datepicker" -// ) - -// type Model struct { -// focus focus -// input textinput.Model -// datepicker datepicker.Model -// } - -// var inputStyles = lipgloss.NewStyle().Padding(1, 1, 0) - -// func initializeModel() tea.Model { -// dp := datepicker.New(time.Now()) - -// input := textinput.New() -// input.Placeholder = "YYYY-MM-DD (enter date)" -// input.Focus() -// input.Width = 20 - -// return Model{ -// focus: FocusInput, -// input: input, -// datepicker: dp, -// } -// } - -// func (m Model) Init() tea.Cmd { -// return textinput.Blink -// } - -// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { -// var cmd tea.Cmd - -// switch msg := msg.(type) { -// case tea.WindowSizeMsg: -// // TODO figure out how we want to size things -// // we'll probably want both bubbles to be vertically stacked -// // and to take as much room as the can -// return m, nil -// case tea.KeyMsg: -// switch msg.String() { -// case "ctrl+c", "q": -// return m, tea.Quit -// case "tab": -// if m.focus == FocusInput { -// m.focus = FocusDatePicker -// m.input.Blur() -// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly)) - -// m.datepicker.SelectDate() -// m.datepicker.SetFocus(datepicker.FocusHeaderMonth) -// m.datepicker = m.datepicker -// return m, nil - -// } -// case "shift+tab": -// if m.focus == FocusDatePicker && m.datepicker.Focused == datepicker.FocusHeaderMonth { -// m.focus = FocusInput -// m.datepicker.Blur() - -// m.input.Focus() -// return m, nil -// } -// } -// } - -// switch m.focus { -// case FocusInput: -// m.input, cmd = m.UpdateInput(msg) -// case FocusDatePicker: -// m.datepicker, cmd = m.UpdateDatepicker(msg) -// case FocusNone: -// // do nothing -// } - -// return m, cmd -// } - -// func (m Model) View() string { -// return lipgloss.JoinVertical(lipgloss.Left, inputStyles.Render(m.input.View()), m.datepicker.View()) -// } - -// func (m *Model) UpdateInput(msg tea.Msg) (textinput.Model, tea.Cmd) { -// var cmd tea.Cmd - -// m.input, cmd = m.input.Update(msg) - -// val := m.input.Value() -// t, err := time.Parse(time.DateOnly, strings.TrimSpace(val)) -// if err == nil { -// m.datepicker.SetTime(t) -// m.datepicker.SelectDate() -// m.datepicker.Blur() -// } -// if err != nil && m.datepicker.Selected { -// m.datepicker.UnselectDate() -// } - -// return m.input, cmd -// } - -// func (m *Model) UpdateDatepicker(msg tea.Msg) (datepicker.Model, tea.Cmd) { -// var cmd tea.Cmd - -// prev := m.datepicker.Time - -// m.datepicker, cmd = m.datepicker.Update(msg) - -// if prev != m.datepicker.Time { -// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly)) -// } - -// return m.datepicker, cmd -// } diff --git a/pages/main.go b/pages/main.go deleted file mode 100644 index dc4f637..0000000 --- a/pages/main.go +++ /dev/null @@ -1,108 +0,0 @@ -package pages - -import ( - "tasksquire/common" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type MainPage struct { - common *common.Common - activePage common.Component - - taskPage common.Component - timePage common.Component - currentTab int - width int - height int -} - -func NewMainPage(common *common.Common) *MainPage { - m := &MainPage{ - common: common, - } - - m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) - m.timePage = NewTimePage(common) - - m.activePage = m.taskPage - m.currentTab = 0 - - return m -} - -func (m *MainPage) Init() tea.Cmd { - return tea.Batch(m.taskPage.Init(), m.timePage.Init()) -} - -func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.common.SetSize(msg.Width, msg.Height) - - tabHeight := lipgloss.Height(m.renderTabBar()) - contentHeight := msg.Height - tabHeight - if contentHeight < 0 { - contentHeight = 0 - } - - newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight} - activePage, cmd := m.activePage.Update(newMsg) - m.activePage = activePage.(common.Component) - return m, cmd - - case tea.KeyMsg: - // Only handle tab key for page switching when at the top level (no subpages active) - if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() { - if m.activePage == m.taskPage { - m.activePage = m.timePage - m.currentTab = 1 - } else { - m.activePage = m.taskPage - m.currentTab = 0 - } - - tabHeight := lipgloss.Height(m.renderTabBar()) - contentHeight := m.height - tabHeight - if contentHeight < 0 { - contentHeight = 0 - } - m.activePage.SetSize(m.width, contentHeight) - - // Trigger a refresh/init on switch? Maybe not needed if we keep state. - // But we might want to refresh data. - return m, m.activePage.Init() - } - } - - activePage, cmd := m.activePage.Update(msg) - m.activePage = activePage.(common.Component) - - return m, cmd -} - -func (m *MainPage) renderTabBar() string { - var tabs []string - headers := []string{"Tasks", "Time"} - - for i, header := range headers { - style := m.common.Styles.Tab - if m.currentTab == i { - style = m.common.Styles.ActiveTab - } - tabs = append(tabs, style.Render(header)) - } - - row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) - return m.common.Styles.TabBar.Width(m.common.Width()).Render(row) -} - -func (m *MainPage) View() string { - return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View()) -} diff --git a/pages/messaging.go b/pages/messaging.go deleted file mode 100644 index e02b582..0000000 --- a/pages/messaging.go +++ /dev/null @@ -1,88 +0,0 @@ -package pages - -import ( - "tasksquire/taskwarrior" - "time" - - tea "github.com/charmbracelet/bubbletea" -) - -type UpdatedTasksMsg struct{} - -type nextColumnMsg struct{} - -func nextColumn() tea.Cmd { - return func() tea.Msg { - return nextColumnMsg{} - } -} - -type prevColumnMsg struct{} - -func prevColumn() tea.Cmd { - return func() tea.Msg { - return prevColumnMsg{} - } -} - -type nextFieldMsg struct{} - -func nextField() tea.Cmd { - return func() tea.Msg { - return nextFieldMsg{} - } -} - -type prevFieldMsg struct{} - -func prevField() tea.Cmd { - return func() tea.Msg { - return prevFieldMsg{} - } -} - -type nextAreaMsg struct{} - -func nextArea() tea.Cmd { - return func() tea.Msg { - return nextAreaMsg{} - } -} - -type prevAreaMsg struct{} - -func prevArea() tea.Cmd { - return func() tea.Msg { - return prevAreaMsg{} - } -} - -type changeAreaMsg int - -func changeArea(a int) tea.Cmd { - return func() tea.Msg { - return changeAreaMsg(a) - } -} - -func changeMode(mode mode) tea.Cmd { - return func() tea.Msg { - return changeModeMsg(mode) - } -} - -type changeModeMsg mode - -type taskMsg taskwarrior.Tasks - -type tickMsg time.Time - -func doTick() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -type TaskPickedMsg struct { - Task *taskwarrior.Task -} diff --git a/pages/page.go b/pages/page.go deleted file mode 100644 index 4515313..0000000 --- a/pages/page.go +++ /dev/null @@ -1,11 +0,0 @@ -package pages - -import ( - tea "github.com/charmbracelet/bubbletea" -) - -func BackCmd() tea.Msg { - return BackMsg{} -} - -type BackMsg struct{} diff --git a/pages/projectPicker.go b/pages/projectPicker.go deleted file mode 100644 index f8c6d01..0000000 --- a/pages/projectPicker.go +++ /dev/null @@ -1,132 +0,0 @@ -package pages - -import ( - "log/slog" - "tasksquire/common" - "tasksquire/components/picker" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type ProjectPickerPage struct { - common *common.Common - picker *picker.Picker -} - -func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage { - p := &ProjectPickerPage{ - common: common, - } - - itemProvider := func() []list.Item { - projects := common.TW.GetProjects() - items := []list.Item{picker.NewItem("(none)")} - for _, proj := range projects { - items = append(items, picker.NewItem(proj)) - } - return items - } - - onSelect := func(item list.Item) tea.Cmd { - return func() tea.Msg { return projectSelectedMsg{item: item} } - } - - // onCreate := func(name string) tea.Cmd { - // return func() tea.Msg { return projectSelectedMsg{item: picker.NewItem(name)} } - // } - - // p.picker = picker.New(common, "Projects", itemProvider, onSelect, picker.WithOnCreate(onCreate)) - p.picker = picker.New(common, "Projects", itemProvider, onSelect) - - // Set active project - if activeProject == "" { - activeProject = "(none)" - } - p.picker.SelectItemByFilterValue(activeProject) - - p.SetSize(common.Width(), common.Height()) - - return p -} - -func (p *ProjectPickerPage) SetSize(width, height int) { - p.common.SetSize(width, height) - - // Use shared modal sizing logic - modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height) - p.picker.SetSize(modalWidth-2, modalHeight-2) -} - -func (p *ProjectPickerPage) Init() tea.Cmd { - return p.picker.Init() -} - -type projectSelectedMsg struct { - item list.Item -} - -func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.SetSize(msg.Width, msg.Height) - case projectSelectedMsg: - proj := msg.item.FilterValue() // Use FilterValue (text) - if proj == "(none)" { - proj = "" - } - - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, func() tea.Msg { return UpdateProjectMsg(proj) } - case tea.KeyMsg: - if !p.picker.IsFiltering() { - 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 - } - } - } - - _, cmd = p.picker.Update(msg) - return p, cmd -} - -func (p *ProjectPickerPage) View() string { - modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height()) - - content := p.picker.View() - styledContent := lipgloss.NewStyle(). - Width(modalWidth). - Height(modalHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(p.common.Styles.Palette.Border.GetForeground()). - Padding(0, 1). - Render(content) - - return lipgloss.Place( - p.common.Width(), - p.common.Height(), - lipgloss.Center, - lipgloss.Center, - styledContent, - ) -} - -func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { - return nil -} - -type UpdateProjectMsg string diff --git a/pages/projectTaskPicker.go b/pages/projectTaskPicker.go deleted file mode 100644 index e652643..0000000 --- a/pages/projectTaskPicker.go +++ /dev/null @@ -1,468 +0,0 @@ -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 - - focusedBorder := p.common.Styles.Palette.Accent.GetForeground() - blurredBorder := p.common.Styles.Palette.Border.GetForeground() - - if p.focusedPicker == 0 { - // Project picker is focused - projectStyled = lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). - BorderForeground(focusedBorder). - Padding(0, 1). - Render(projectView) - - taskStyled = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(blurredBorder). - Padding(0, 1). - Render(taskView) - } else { - // Task picker is focused - projectStyled = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(blurredBorder). - Padding(0, 1). - Render(projectView) - - taskStyled = lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). - BorderForeground(focusedBorder). - 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(p.common.Styles.Palette.Muted.GetForeground()). - 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 -} diff --git a/pages/report.go b/pages/report.go deleted file mode 100644 index 19124b2..0000000 --- a/pages/report.go +++ /dev/null @@ -1,374 +0,0 @@ -// TODO: update table every second (to show correct relative time) -package pages - -import ( - "tasksquire/common" - "tasksquire/components/detailsviewer" - "tasksquire/components/table" - "tasksquire/taskwarrior" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type ReportPage struct { - common *common.Common - - activeReport *taskwarrior.Report - activeContext *taskwarrior.Context - activeProject string - selectedTask *taskwarrior.Task - taskCursor int - - tasks taskwarrior.Tasks - - taskTable table.Model - - // Details panel state - detailsPanelActive bool - detailsViewer *detailsviewer.DetailsViewer - - subpage common.Component -} - -func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { - // return &ReportPage{ - // common: com, - // activeReport: report, - // activeContext: com.TW.GetActiveContext(), - // activeProject: "", - // taskTable: table.New(com), - // } - - p := &ReportPage{ - common: com, - activeReport: report, - activeContext: com.TW.GetActiveContext(), - activeProject: "", - taskTable: table.New(com), - detailsPanelActive: false, - detailsViewer: detailsviewer.New(com), - } - - return p -} - -func (p *ReportPage) SetSize(width int, height int) { - p.common.SetSize(width, height) - - baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize() - baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize() - - var tableHeight int - if p.detailsPanelActive { - // Allocate 60% for table, 40% for details panel - // Minimum 5 lines for details, minimum 10 lines for table - detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5) - tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing - - // Set component size (component handles its own border/padding) - p.detailsViewer.SetSize(baseWidth, detailsHeight) - } else { - tableHeight = baseHeight - } - - p.taskTable.SetWidth(baseWidth) - p.taskTable.SetHeight(tableHeight) -} - -// Helper functions -func max(a, b int) int { - if a > b { - return a - } - return b -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func (p *ReportPage) Init() tea.Cmd { - return tea.Batch(p.getTasks(), doTick()) -} - -func (p *ReportPage) 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 BackMsg: - case tickMsg: - cmds = append(cmds, p.getTasks()) - cmds = append(cmds, doTick()) - return p, tea.Batch(cmds...) - case taskMsg: - p.tasks = taskwarrior.Tasks(msg) - p.populateTaskTable(p.tasks) - case UpdateReportMsg: - p.activeReport = msg - cmds = append(cmds, p.getTasks()) - case UpdateContextMsg: - p.activeContext = msg - p.common.TW.SetContext(msg) - cmds = append(cmds, p.getTasks()) - case UpdateProjectMsg: - p.activeProject = string(msg) - cmds = append(cmds, p.getTasks()) - case TaskPickedMsg: - if msg.Task != nil && msg.Task.Status == "pending" { - p.common.TW.StopActiveTasks() - p.common.TW.StartTask(msg.Task) - } - cmds = append(cmds, p.getTasks()) - case UpdatedTasksMsg: - cmds = append(cmds, p.getTasks()) - case tea.KeyMsg: - // Handle ESC when details panel is active - if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) { - p.detailsPanelActive = false - p.detailsViewer.Blur() - p.SetSize(p.common.Width(), p.common.Height()) - return p, nil - } - - switch { - case key.Matches(msg, p.common.Keymap.Quit): - return p, tea.Quit - case key.Matches(msg, p.common.Keymap.SetReport): - p.subpage = NewReportPickerPage(p.common, p.activeReport) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.SetContext): - p.subpage = NewContextPickerPage(p.common) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.Add): - p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask()) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.Edit): - p.subpage = NewTaskEditorPage(p.common, *p.selectedTask) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.Subtask): - if p.selectedTask != nil { - // Create new task inheriting parent's attributes - newTask := taskwarrior.NewTask() - - // Set parent relationship - newTask.Parent = p.selectedTask.Uuid - - // Copy parent's attributes - newTask.Project = p.selectedTask.Project - newTask.Priority = p.selectedTask.Priority - newTask.Tags = make([]string, len(p.selectedTask.Tags)) - copy(newTask.Tags, p.selectedTask.Tags) - - // Copy UDAs (except "details" which is task-specific) - if p.selectedTask.Udas != nil { - newTask.Udas = make(map[string]any) - for k, v := range p.selectedTask.Udas { - // Skip "details" UDA - it's specific to parent task - if k == "details" { - continue - } - // Deep copy other UDA values - newTask.Udas[k] = v - } - } - - // Open task editor with pre-populated task - p.subpage = NewTaskEditorPage(p.common, newTask) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - } - return p, nil - case key.Matches(msg, p.common.Keymap.Ok): - p.common.TW.SetTaskDone(p.selectedTask) - return p, p.getTasks() - case key.Matches(msg, p.common.Keymap.Delete): - p.common.TW.DeleteTask(p.selectedTask) - return p, p.getTasks() - case key.Matches(msg, p.common.Keymap.SetProject): - p.subpage = NewProjectPickerPage(p.common, p.activeProject) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.PickProjectTask): - p.subpage = NewProjectTaskPickerPage(p.common) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.Tag): - if p.selectedTask != nil { - tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default") - if p.selectedTask.HasTag(tag) { - p.selectedTask.RemoveTag(tag) - } else { - p.selectedTask.AddTag(tag) - } - p.common.TW.ImportTask(p.selectedTask) - return p, p.getTasks() - } - return p, nil - case key.Matches(msg, p.common.Keymap.Undo): - p.common.TW.Undo() - return p, p.getTasks() - case key.Matches(msg, p.common.Keymap.StartStop): - if p.selectedTask != nil && p.selectedTask.Status == "pending" { - if p.selectedTask.Start == "" { - p.common.TW.StopActiveTasks() - p.common.TW.StartTask(p.selectedTask) - } else { - p.common.TW.StopTask(p.selectedTask) - } - return p, p.getTasks() - } - case key.Matches(msg, p.common.Keymap.ViewDetails): - if p.selectedTask != nil { - // Toggle details panel - p.detailsPanelActive = !p.detailsPanelActive - if p.detailsPanelActive { - p.detailsViewer.SetTask(p.selectedTask) - p.detailsViewer.Focus() - } else { - p.detailsViewer.Blur() - } - p.SetSize(p.common.Width(), p.common.Height()) - return p, nil - } - } - } - - var cmd tea.Cmd - - // Route keyboard messages to details viewer when panel is active - if p.detailsPanelActive { - var viewerCmd tea.Cmd - var viewerModel tea.Model - viewerModel, viewerCmd = p.detailsViewer.Update(msg) - p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer) - cmds = append(cmds, viewerCmd) - } else { - // Route to table when details panel not active - p.taskTable, cmd = p.taskTable.Update(msg) - cmds = append(cmds, cmd) - - if p.tasks != nil && len(p.tasks) > 0 { - p.selectedTask = p.tasks[p.taskTable.Cursor()] - } else { - p.selectedTask = nil - } - } - - return p, tea.Batch(cmds...) -} - -func (p *ReportPage) View() string { - if p.tasks == nil || len(p.tasks) == 0 { - return p.common.Styles.Base.Render("No tasks found") - } - - tableView := p.taskTable.View() - - if !p.detailsPanelActive { - return tableView - } - - // Combine table and details panel vertically - return lipgloss.JoinVertical( - lipgloss.Left, - tableView, - p.detailsViewer.View(), - ) -} - -func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { - if len(tasks) == 0 { - return - } - - // Build task tree for hierarchical display - taskTree := taskwarrior.BuildTaskTree(tasks) - - // Use flattened tree list for display order - orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList)) - for i, node := range taskTree.FlatList { - orderedTasks[i] = node.Task - } - - selected := p.taskTable.Cursor() - - // Adjust cursor for tree ordering - if p.selectedTask != nil { - for i, task := range orderedTasks { - if task.Uuid == p.selectedTask.Uuid { - selected = i - break - } - } - } - if selected > len(orderedTasks)-1 { - selected = len(orderedTasks) - 1 - } - - // Calculate proper dimensions based on whether details panel is active - baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize() - baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize() - - var tableHeight int - if p.detailsPanelActive { - // Allocate 60% for table, 40% for details panel - // Minimum 5 lines for details, minimum 10 lines for table - detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5) - tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing - } else { - tableHeight = baseHeight - } - - p.taskTable = table.New( - p.common, - table.WithReport(p.activeReport), - table.WithTasks(orderedTasks), - table.WithTaskTree(taskTree), - table.WithFocused(true), - table.WithWidth(baseWidth), - table.WithHeight(tableHeight), - table.WithStyles(p.common.Styles.TableStyle), - ) - - if selected == 0 { - selected = p.taskTable.Cursor() - } - if selected < len(orderedTasks) { - p.taskTable.SetCursor(selected) - } else { - p.taskTable.SetCursor(len(p.tasks) - 1) - } - - // Refresh details content if panel is active - if p.detailsPanelActive && p.selectedTask != nil { - p.detailsViewer.SetTask(p.selectedTask) - } -} - -func (p *ReportPage) getTasks() tea.Cmd { - return func() tea.Msg { - filters := []string{} - if p.activeProject != "" { - filters = append(filters, "project:"+p.activeProject) - } - tasks := p.common.TW.GetTasks(p.activeReport, filters...) - return taskMsg(tasks) - } -} diff --git a/pages/reportPicker.go b/pages/reportPicker.go deleted file mode 100644 index 1eef0a5..0000000 --- a/pages/reportPicker.go +++ /dev/null @@ -1,128 +0,0 @@ -package pages - -import ( - "log/slog" - "slices" - "tasksquire/common" - "tasksquire/components/picker" - "tasksquire/taskwarrior" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type ReportPickerPage struct { - common *common.Common - reports taskwarrior.Reports - picker *picker.Picker -} - -func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage { - p := &ReportPickerPage{ - common: common, - reports: common.TW.GetReports(), - } - - itemProvider := func() []list.Item { - options := make([]string, 0) - for _, r := range p.reports { - options = append(options, r.Name) - } - slices.Sort(options) - - items := []list.Item{} - for _, opt := range options { - items = append(items, picker.NewItem(opt)) - } - return items - } - - onSelect := func(item list.Item) tea.Cmd { - return func() tea.Msg { return reportSelectedMsg{item: item} } - } - - p.picker = picker.New(common, "Reports", itemProvider, onSelect) - - if activeReport != nil { - p.picker.SelectItemByFilterValue(activeReport.Name) - } - - p.SetSize(common.Width(), common.Height()) - - return p -} - -func (p *ReportPickerPage) SetSize(width, height int) { - p.common.SetSize(width, height) - - // Use shared modal sizing logic - modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height) - p.picker.SetSize(modalWidth-2, modalHeight-2) -} - -func (p *ReportPickerPage) Init() tea.Cmd { - return p.picker.Init() -} - -type reportSelectedMsg struct { - item list.Item -} - -func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.SetSize(msg.Width, msg.Height) - case reportSelectedMsg: - reportName := msg.item.FilterValue() - report := p.common.TW.GetReport(reportName) - - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, func() tea.Msg { return UpdateReportMsg(report) } - case tea.KeyMsg: - if !p.picker.IsFiltering() { - 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 - } - } - } - - _, cmd = p.picker.Update(msg) - return p, cmd -} - -func (p *ReportPickerPage) View() string { - modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height()) - - content := p.picker.View() - styledContent := lipgloss.NewStyle(). - Width(modalWidth). - Height(modalHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(p.common.Styles.Palette.Border.GetForeground()). - Padding(0, 1). - Render(content) - - return lipgloss.Place( - p.common.Width(), - p.common.Height(), - lipgloss.Center, - lipgloss.Center, - styledContent, - ) -} - -type UpdateReportMsg *taskwarrior.Report diff --git a/pages/taskEditor.go b/pages/taskEditor.go deleted file mode 100644 index 1c6eed6..0000000 --- a/pages/taskEditor.go +++ /dev/null @@ -1,1043 +0,0 @@ -package pages - -import ( - "fmt" - "log/slog" - "tasksquire/common" - "time" - - "tasksquire/components/input" - "tasksquire/components/picker" - "tasksquire/components/timestampeditor" - "tasksquire/taskwarrior" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" -) - -type mode int - -const ( - modeNormal mode = iota - modeInsert -) - -type TaskEditorPage struct { - common *common.Common - task taskwarrior.Task - - colWidth int - colHeight int - - mode mode - - columnCursor int - - area int - areaPicker *areaPicker - areas []area - - infoViewport viewport.Model -} - -func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage { - p := TaskEditorPage{ - common: com, - task: task, - } - - if p.task.Project == "" { - p.task.Project = "(none)" - } - - tagOptions := p.common.TW.GetTags() - - p.areas = []area{ - NewTaskEdit(p.common, &p.task, p.task.Uuid == ""), - NewTagEdit(p.common, &p.task.Tags, tagOptions), - NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until), - NewDetailsEdit(p.common, &p.task), - } - - // p.areaList = NewAreaList(common, areaItems) - // p.selectedArea = areaTask - // p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...) - - p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"}) - - p.infoViewport = viewport.New(0, 0) - if p.task.Uuid != "" { - p.infoViewport.SetContent(p.common.TW.GetInformation(&p.task)) - } - - p.columnCursor = 1 - if p.task.Uuid == "" { - p.mode = modeInsert - } else { - p.mode = modeNormal - } - - p.SetSize(com.Width(), com.Height()) - - return &p -} - -func (p *TaskEditorPage) SetSize(width, height int) { - p.common.SetSize(width, height) - - if width >= 70 { - p.colWidth = 70 - p.common.Styles.ColumnFocused.GetVerticalFrameSize() - } else { - p.colWidth = width - p.common.Styles.ColumnFocused.GetVerticalFrameSize() - } - - if height >= 40 { - p.colHeight = 40 - p.common.Styles.ColumnFocused.GetVerticalFrameSize() - } else { - p.colHeight = height - p.common.Styles.ColumnFocused.GetVerticalFrameSize() - } - - p.infoViewport.Width = width - p.colWidth - p.common.Styles.ColumnFocused.GetHorizontalFrameSize()*2 - 5 - if p.infoViewport.Width < 0 { - p.infoViewport.Width = 0 - } - p.infoViewport.Height = p.colHeight -} - -func (p *TaskEditorPage) Init() tea.Cmd { - var cmds []tea.Cmd - for _, a := range p.areas { - cmds = append(cmds, a.Init()) - } - return tea.Batch(cmds...) -} - -func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.SetSize(msg.Width, msg.Height) - case changeAreaMsg: - p.area = int(msg) - case changeModeMsg: - p.mode = mode(msg) - case prevColumnMsg: - p.columnCursor-- - maxCols := 2 - if p.task.Uuid != "" { - maxCols = 3 - } - if p.columnCursor < 0 { - p.columnCursor = maxCols - 1 - } - case nextColumnMsg: - p.columnCursor++ - maxCols := 2 - if p.task.Uuid != "" { - maxCols = 3 - } - if p.columnCursor >= maxCols { - p.columnCursor = 0 - } - case prevAreaMsg: - p.area-- - if p.area < 0 { - p.area = len(p.areas) - 1 - } - p.areas[p.area].SetCursor(-1) - case nextAreaMsg: - p.area++ - if p.area > len(p.areas)-1 { - p.area = 0 - } - p.areas[p.area].SetCursor(0) - } - - switch p.mode { - case modeNormal: - switch msg := msg.(type) { - 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.Insert): - return p, changeMode(modeInsert) - case key.Matches(msg, p.common.Keymap.Ok): - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, p.updateTasksCmd - case key.Matches(msg, p.common.Keymap.PrevPage): - return p, prevArea() - case key.Matches(msg, p.common.Keymap.NextPage): - return p, nextArea() - case key.Matches(msg, p.common.Keymap.Left): - return p, prevColumn() - case key.Matches(msg, p.common.Keymap.Right): - return p, nextColumn() - case key.Matches(msg, p.common.Keymap.Up): - if p.columnCursor == 0 { - picker, cmd := p.areaPicker.Update(msg) - p.areaPicker = picker.(*areaPicker) - return p, cmd - } else if p.columnCursor == 1 { - model, cmd := p.areas[p.area].Update(prevFieldMsg{}) - p.areas[p.area] = model.(area) - return p, cmd - } else if p.columnCursor == 2 { - p.infoViewport.LineUp(1) - return p, nil - } - case key.Matches(msg, p.common.Keymap.Down): - if p.columnCursor == 0 { - picker, cmd := p.areaPicker.Update(msg) - p.areaPicker = picker.(*areaPicker) - return p, cmd - } else if p.columnCursor == 1 { - model, cmd := p.areas[p.area].Update(nextFieldMsg{}) - p.areas[p.area] = model.(area) - return p, cmd - } else if p.columnCursor == 2 { - p.infoViewport.LineDown(1) - return p, nil - } - } - } - - // var cmd tea.Cmd - // if p.columnCursor == 0 { - // p., cmd = p.areaList.Update(msg) - // p.selectedArea = p.areaList.(areaList).Area() - // cmds = append(cmds, cmd) - // } else { - // p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg) - // cmds = append(cmds, cmd) - // } - case modeInsert: - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, p.common.Keymap.Back): - model, cmd := p.areas[p.area].Update(msg) - p.areas[p.area] = model.(area) - if cmd != nil { - _, ok := cmd().(input.SuppressBackMsg) - if ok { - return p, nil - } - } - return p, changeMode(modeNormal) - case key.Matches(msg, p.common.Keymap.Prev): - if p.columnCursor == 0 { - picker, cmd := p.areaPicker.Update(msg) - p.areaPicker = picker.(*areaPicker) - return p, cmd - } else if p.columnCursor == 1 { - model, cmd := p.areas[p.area].Update(prevFieldMsg{}) - p.areas[p.area] = model.(area) - return p, cmd - } - return p, nil - case key.Matches(msg, p.common.Keymap.Next): - if p.columnCursor == 0 { - picker, cmd := p.areaPicker.Update(msg) - p.areaPicker = picker.(*areaPicker) - return p, cmd - } else if p.columnCursor == 1 { - model, cmd := p.areas[p.area].Update(nextFieldMsg{}) - p.areas[p.area] = model.(area) - return p, cmd - } - return p, nil - case key.Matches(msg, p.common.Keymap.Ok): - isFiltering := p.areas[p.area].IsFiltering() - model, cmd := p.areas[p.area].Update(msg) - if p.area != 3 { - p.areas[p.area] = model.(area) - if isFiltering { - return p, cmd - } - return p, tea.Batch(cmd, nextField()) - } - return p, cmd - } - } - - if p.columnCursor == 0 { - picker, cmd := p.areaPicker.Update(msg) - p.areaPicker = picker.(*areaPicker) - return p, cmd - } else if p.columnCursor == 2 { - var cmd tea.Cmd - p.infoViewport, cmd = p.infoViewport.Update(msg) - return p, cmd - } else { - model, cmd := p.areas[p.area].Update(msg) - p.areas[p.area] = model.(area) - return p, cmd - } - } - return p, nil -} - -func (p *TaskEditorPage) View() string { - var focusedStyle, blurredStyle lipgloss.Style - if p.mode == modeInsert { - focusedStyle = p.common.Styles.ColumnInsert - } else { - focusedStyle = p.common.Styles.ColumnFocused - } - blurredStyle = p.common.Styles.ColumnBlurred - - var area string - if p.columnCursor == 1 { - area = focusedStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View()) - } else { - area = blurredStyle.Copy().Width(p.colWidth).Height(p.colHeight).Render(p.areas[p.area].View()) - } - - if p.task.Uuid != "" { - var infoView string - if p.columnCursor == 2 { - infoView = focusedStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View()) - } else { - infoView = blurredStyle.Copy().Width(p.infoViewport.Width).Height(p.infoViewport.Height).Render(p.infoViewport.View()) - } - area = lipgloss.JoinHorizontal( - lipgloss.Top, - area, - infoView, - ) - } - - tabs := "" - for i, a := range p.areas { - style := p.common.Styles.Base - if i == p.area { - style = style.Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground()) - } else { - style = style.Foreground(p.common.Styles.Palette.Muted.GetForeground()) - } - tabs += style.Render(fmt.Sprintf(" %s ", a.GetName())) - } - - page := lipgloss.JoinVertical( - lipgloss.Left, - tabs, - area, - ) - - // return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, lipgloss.JoinHorizontal( - // lipgloss.Center, - // picker, - // area, - // )) - return lipgloss.Place(p.common.Width(), p.common.Height(), lipgloss.Center, lipgloss.Center, page) -} - -type area interface { - tea.Model - SetCursor(c int) - GetName() string - IsFiltering() bool -} - -type focusMsg struct{} - -type areaPicker struct { - common *common.Common - list list.Model -} - -type item string - -func (i item) Title() string { return string(i) } -func (i item) Description() string { return "test" } -func (i item) FilterValue() string { return "" } - -func NewAreaPicker(common *common.Common, items []string) *areaPicker { - listItems := make([]list.Item, len(items)) - for i, itm := range items { - listItems[i] = item(itm) - } - - list := list.New(listItems, list.DefaultDelegate{}, 20, 50) - list.SetFilteringEnabled(false) - list.SetShowStatusBar(false) - list.SetShowHelp(false) - list.SetShowPagination(false) - list.SetShowTitle(false) - - return &areaPicker{ - common: common, - list: list, - } -} - -func (a *areaPicker) Area() int { - // switch a.list.SelectedItem() { - // case item("Task"): - // return areaTask - // case item("Tags"): - // return areaTags - // case item("Dates"): - // return areaTime - // default: - // return areaTask - // } - return 0 -} - -func (a *areaPicker) Init() tea.Cmd { - return nil -} - -func (a *areaPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - cursor := a.list.Cursor() - // switch msg.(type) { - // case nextFieldMsg: - // a.list, cmd = a.list.Update(a.list.KeyMap.CursorDown) - // case prevFieldMsg: - // a.list, cmd = a.list.Update(a.list.KeyMap.CursorUp) - // } - a.list, cmd = a.list.Update(msg) - cmds = append(cmds, cmd) - if cursor != a.list.Cursor() { - cmds = append(cmds, changeArea(a.Area())) - } - - return a, tea.Batch(cmds...) -} - -func (a *areaPicker) View() string { - return a.list.View() -} - -type EditableField interface { - tea.Model - Focus() tea.Cmd - Blur() tea.Cmd -} - -type taskEdit struct { - common *common.Common - fields []EditableField - cursor int - - projectPicker *picker.Picker - // newProjectName *string - newAnnotation *string - udaValues map[string]*string -} - -func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEdit { - // newProject := "" - if task.Project == "" { - task.Project = "(none)" - } - - itemProvider := func() []list.Item { - projects := com.TW.GetProjects() - items := []list.Item{picker.NewItem("(none)")} - for _, proj := range projects { - items = append(items, picker.NewItem(proj)) - } - return items - } - onSelect := func(item list.Item) tea.Cmd { - return nil - } - onCreate := func(newProject string) tea.Cmd { - // The new project name will be used as the project value - // when the task is saved - return nil - } - - opts := []picker.PickerOption{picker.WithOnCreate(onCreate)} - - // Check if task has a pre-filled project (e.g., from ProjectTaskPickerPage) - hasPrefilledProject := task.Project != "" && task.Project != "(none)" - - if isNew && !hasPrefilledProject { - // New task with no project → start in filter mode for quick project search - opts = append(opts, picker.WithFilterByDefault(true)) - } else { - // Either existing task OR new task with pre-filled project → show list with project selected - opts = append(opts, picker.WithDefaultValue(task.Project)) - } - - projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...) - projPicker.SetSize(70, 8) - projPicker.Blur() - - defaultKeymap := huh.NewDefaultKeyMap() - - fields := []EditableField{ - huh.NewInput(). - Title("Task"). - Value(&task.Description). - Validate(func(desc string) error { - if desc == "" { - return fmt.Errorf("task description is required") - } - return nil - }). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form), - - projPicker, - - // huh.NewInput(). - // Title("New Project"). - // Value(&newProject). - // Inline(true). - // Prompt(": "). - // WithTheme(com.Styles.Form), - } - - udaValues := make(map[string]*string) - for _, uda := range com.Udas { - if uda.Name == "parenttask" { - continue - } - switch uda.Type { - case taskwarrior.UdaTypeNumeric: - val := "" - udaValues[uda.Name] = &val - fields = append(fields, huh.NewInput(). - Title(uda.Label). - Value(udaValues[uda.Name]). - Validate(taskwarrior.ValidateNumeric). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form)) - case taskwarrior.UdaTypeString: - if len(uda.Values) > 0 { - var val string - values := make([]string, len(uda.Values)) - for i, v := range uda.Values { - values[i] = v - if v == "" { - values[i] = "(none)" - } - if v == uda.Default { - val = values[i] - } - } - if val == "" { - val = values[0] - } - if v, ok := task.Udas[uda.Name]; ok { - //TODO: handle uda types correctly - val = v.(string) - } - udaValues[uda.Name] = &val - - fields = append(fields, huh.NewSelect[string](). - Options(huh.NewOptions(values...)...). - Title(uda.Label). - Value(udaValues[uda.Name]). - WithKeyMap(defaultKeymap). - WithTheme(com.Styles.Form)) - } else { - val := "" - udaValues[uda.Name] = &val - fields = append(fields, huh.NewInput(). - Title(uda.Label). - Value(udaValues[uda.Name]). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form)) - } - case taskwarrior.UdaTypeDate: - val := "" - udaValues[uda.Name] = &val - fields = append(fields, huh.NewInput(). - Title(uda.Label). - Value(udaValues[uda.Name]). - Validate(taskwarrior.ValidateDate). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form)) - case taskwarrior.UdaTypeDuration: - val := "" - udaValues[uda.Name] = &val - fields = append(fields, huh.NewInput(). - Title(uda.Label). - Value(udaValues[uda.Name]). - Validate(taskwarrior.ValidateDuration). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form)) - } - } - - newAnnotation := "" - fields = append(fields, huh.NewInput(). - Title("New Annotation"). - Value(&newAnnotation). - Inline(true). - Prompt(": "). - WithTheme(com.Styles.Form)) - - t := taskEdit{ - common: com, - fields: fields, - projectPicker: projPicker, - - udaValues: udaValues, - - // newProjectName: &newProject, - newAnnotation: &newAnnotation, - } - - t.fields[0].Focus() - - return &t -} - -func (t *taskEdit) GetName() string { - return "Task" -} - -func (t *taskEdit) IsFiltering() bool { - if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok { - return f.IsFiltering() - } - return false -} - -func (t *taskEdit) SetCursor(c int) { - t.fields[t.cursor].Blur() - if c < 0 { - t.cursor = len(t.fields) - 1 - } else { - t.cursor = c - } - t.fields[t.cursor].Focus() -} - -func (t *taskEdit) Init() tea.Cmd { - var cmds []tea.Cmd - // Ensure focus on the active field (especially for the first one) - if len(t.fields) > 0 { - cmds = append(cmds, func() tea.Msg { - return focusMsg{} - }) - } - for _, f := range t.fields { - cmds = append(cmds, f.Init()) - } - return tea.Batch(cmds...) -} - -func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { - case focusMsg: - if len(t.fields) > 0 { - return t, t.fields[t.cursor].Focus() - } - case nextFieldMsg: - if t.cursor == len(t.fields)-1 { - t.fields[t.cursor].Blur() - return t, nextArea() - } - t.fields[t.cursor].Blur() - t.cursor++ - t.fields[t.cursor].Focus() - case prevFieldMsg: - if t.cursor == 0 { - t.fields[t.cursor].Blur() - return t, prevArea() - } - t.fields[t.cursor].Blur() - t.cursor-- - t.fields[t.cursor].Focus() - default: - field, cmd := t.fields[t.cursor].Update(msg) - t.fields[t.cursor] = field.(EditableField) - return t, cmd - } - - return t, nil -} - -func (t *taskEdit) View() string { - views := make([]string, len(t.fields)) - for i, field := range t.fields { - views[i] = field.View() - if i < len(t.fields)-1 { - views[i] += "\n" - } - } - return lipgloss.JoinVertical( - lipgloss.Left, - views..., - ) -} - -type tagEdit struct { - common *common.Common - fields []huh.Field - - cursor int -} - -func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit { - defaultKeymap := huh.NewDefaultKeyMap() - - t := tagEdit{ - common: common, - fields: []huh.Field{ - input.NewMultiSelect(common). - Options(true, input.NewOptions(options...)...). - // Key("tags"). - Title("Tags"). - Value(selected). - Filterable(true). - WithKeyMap(defaultKeymap). - WithTheme(common.Styles.Form), - }, - } - - return &t -} - -func (t *tagEdit) GetName() string { - return "Tags" -} - -func (t *tagEdit) IsFiltering() bool { - if f, ok := t.fields[t.cursor].(interface{ IsFiltering() bool }); ok { - return f.IsFiltering() - } - return false -} - -func (t *tagEdit) SetCursor(c int) { - t.fields[t.cursor].Blur() - if c < 0 { - t.cursor = len(t.fields) - 1 - } else { - t.cursor = c - } - t.fields[t.cursor].Focus() -} - -func (t *tagEdit) Init() tea.Cmd { - return nil -} - -func (t *tagEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { - case nextFieldMsg: - if t.cursor == len(t.fields)-1 { - t.fields[t.cursor].Blur() - return t, nextArea() - } - t.fields[t.cursor].Blur() - t.cursor++ - t.fields[t.cursor].Focus() - case prevFieldMsg: - if t.cursor == 0 { - t.fields[t.cursor].Blur() - return t, prevArea() - } - t.fields[t.cursor].Blur() - t.cursor-- - t.fields[t.cursor].Focus() - default: - field, cmd := t.fields[t.cursor].Update(msg) - t.fields[t.cursor] = field.(huh.Field) - return t, cmd - } - return t, nil -} - -func (t tagEdit) View() string { - views := make([]string, len(t.fields)) - for i, field := range t.fields { - views[i] = field.View() - } - return lipgloss.JoinVertical( - lipgloss.Left, - views..., - ) -} - -type timeEdit struct { - common *common.Common - fields []*timestampeditor.TimestampEditor - - cursor int - - // Store task field pointers to update them - due *string - scheduled *string - wait *string - until *string -} - -func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit { - // Create timestamp editors for each date field - dueEditor := timestampeditor.New(common). - Title("Due"). - Description("When the task is due"). - ValueFromString(*due) - - scheduledEditor := timestampeditor.New(common). - Title("Scheduled"). - Description("When to start working on the task"). - ValueFromString(*scheduled) - - waitEditor := timestampeditor.New(common). - Title("Wait"). - Description("Hide task until this date"). - ValueFromString(*wait) - - untilEditor := timestampeditor.New(common). - Title("Until"). - Description("Task expires after this date"). - ValueFromString(*until) - - t := timeEdit{ - common: common, - fields: []*timestampeditor.TimestampEditor{ - dueEditor, - scheduledEditor, - waitEditor, - untilEditor, - }, - due: due, - scheduled: scheduled, - wait: wait, - until: until, - } - - // Focus the first field - if len(t.fields) > 0 { - t.fields[0].Focus() - } - - return &t -} - -func (t *timeEdit) GetName() string { - return "Dates" -} - -func (t *timeEdit) IsFiltering() bool { - return false -} - -func (t *timeEdit) SetCursor(c int) { - if len(t.fields) == 0 { - return - } - - // Blur the current field - t.fields[t.cursor].Blur() - - // Set new cursor position - if c < 0 { - t.cursor = len(t.fields) - 1 - } else { - t.cursor = c - } - - // Focus the new field - t.fields[t.cursor].Focus() -} - -func (t *timeEdit) Init() tea.Cmd { - return nil -} - -func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case nextFieldMsg: - if t.cursor == len(t.fields)-1 { - // Update task field before moving to next area - t.syncToTaskFields() - t.fields[t.cursor].Blur() - return t, nextArea() - } - t.fields[t.cursor].Blur() - t.cursor++ - t.fields[t.cursor].Focus() - return t, nil - case prevFieldMsg: - if t.cursor == 0 { - // Update task field before moving to previous area - t.syncToTaskFields() - t.fields[t.cursor].Blur() - return t, prevArea() - } - t.fields[t.cursor].Blur() - t.cursor-- - t.fields[t.cursor].Focus() - return t, nil - default: - // Update the current timestamp editor - model, cmd := t.fields[t.cursor].Update(msg) - t.fields[t.cursor] = model.(*timestampeditor.TimestampEditor) - return t, cmd - } -} - -func (t *timeEdit) View() string { - views := make([]string, len(t.fields)) - - for i, field := range t.fields { - views[i] = field.View() - if i < len(t.fields)-1 { - views[i] += "\n" - } - } - - return lipgloss.JoinVertical( - lipgloss.Left, - views..., - ) -} - -// syncToTaskFields converts the timestamp editor values back to task field strings -func (t *timeEdit) syncToTaskFields() { - // Update the task fields with values from the timestamp editors - // GetValueString() returns empty string for unset timestamps - if len(t.fields) > 0 { - *t.due = t.fields[0].GetValueString() - } - if len(t.fields) > 1 { - *t.scheduled = t.fields[1].GetValueString() - } - if len(t.fields) > 2 { - *t.wait = t.fields[2].GetValueString() - } - if len(t.fields) > 3 { - *t.until = t.fields[3].GetValueString() - } -} - -type detailsEdit struct { - com *common.Common - vp viewport.Model - ta textarea.Model - details string - // renderer *glamour.TermRenderer -} - -func NewDetailsEdit(com *common.Common, task *taskwarrior.Task) *detailsEdit { - // renderer, err := glamour.NewTermRenderer( - // // glamour.WithStandardStyle("light"), - // glamour.WithAutoStyle(), - // glamour.WithWordWrap(40), - // ) - // if err != nil { - // slog.Error(err.Error()) - // return nil - // } - - vp := viewport.New(com.Width(), 40-com.Styles.ColumnFocused.GetVerticalFrameSize()) - ta := textarea.New() - ta.SetWidth(70) - ta.SetHeight(40 - com.Styles.ColumnFocused.GetVerticalFrameSize() - 2) - ta.ShowLineNumbers = false - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.Focus() - if task.Udas["details"] != nil { - ta.SetValue(task.Udas["details"].(string)) - } - d := detailsEdit{ - com: com, - // renderer: renderer, - vp: vp, - ta: ta, - } - - return &d -} - -func (d *detailsEdit) GetName() string { - return "Details" -} - -func (d *detailsEdit) IsFiltering() bool { - return false -} - -func (d *detailsEdit) SetCursor(c int) { -} - -func (d *detailsEdit) Init() tea.Cmd { - return textarea.Blink -} - -func (d *detailsEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { - case nextFieldMsg: - return d, nextArea() - case prevFieldMsg: - return d, prevArea() - default: - var vpCmd, taCmd tea.Cmd - d.vp, vpCmd = d.vp.Update(msg) - d.ta, taCmd = d.ta.Update(msg) - return d, tea.Batch(vpCmd, taCmd) - } -} - -func (d *detailsEdit) View() string { - return d.ta.View() -} - -func (p *TaskEditorPage) updateTasksCmd() tea.Msg { - p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue() - - if p.task.Project == "(none)" { - p.task.Project = "" - } - - for _, uda := range p.common.Udas { - if val, ok := p.areas[0].(*taskEdit).udaValues[uda.Name]; ok { - if *val == "(none)" { - *val = "" - } - p.task.Udas[uda.Name] = *val - } - } - - // Sync timestamp fields from the timeEdit area (area 2) - p.areas[2].(*timeEdit).syncToTaskFields() - - if *(p.areas[0].(*taskEdit).newAnnotation) != "" { - p.task.Annotations = append(p.task.Annotations, taskwarrior.Annotation{ - Entry: time.Now().Format("20060102T150405Z"), - Description: *(p.areas[0].(*taskEdit).newAnnotation), - }) - } - - if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" { - p.task.Udas["details"] = p.areas[3].(*detailsEdit).ta.Value() - } - - p.common.TW.ImportTask(&p.task) - return UpdatedTasksMsg{} -} - -// type StatusLine struct { ... } -// ... diff --git a/pages/timeEditor.go b/pages/timeEditor.go deleted file mode 100644 index f87749e..0000000 --- a/pages/timeEditor.go +++ /dev/null @@ -1,754 +0,0 @@ -package pages - -import ( - "fmt" - "log/slog" - "strings" - "tasksquire/common" - "tasksquire/components/autocomplete" - "tasksquire/components/picker" - "tasksquire/components/timestampeditor" - "tasksquire/taskwarrior" - "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 - taskPicker *picker.Picker - startEditor *timestampeditor.TimestampEditor - endEditor *timestampeditor.TimestampEditor - tagsInput *autocomplete.Autocomplete - adjust bool - - // State - selectedProject string - selectedTask *taskwarrior.Task - currentField int - totalFields int - uuid string // Preserved UUID tag - track string // Preserved track tag (if present) -} - -type timeEditorProjectSelectedMsg struct { - project string -} - -type timeEditorTaskSelectedMsg struct { - task *taskwarrior.Task -} - -// createTaskPickerForProject creates a picker showing tasks with +track tag for the given project -func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker { - // Build filters for tasks with +track tag - filters := []string{"+track", "status:pending"} - if project != "" { - filters = append(filters, "project:"+project) - } - - taskItemProvider := func() []list.Item { - tasks := com.TW.GetTasks(nil, filters...) - // Add "(none)" as first option, then all tasks - items := make([]list.Item, 0, len(tasks)+1) - items = append(items, picker.NewItem("(none)")) - for i := range tasks { - items = append(items, picker.NewItem(tasks[i].Description)) - } - return items - } - - taskOnSelect := func(item list.Item) tea.Cmd { - return func() tea.Msg { - // Handle "(none)" selection - if item.FilterValue() == "(none)" { - return timeEditorTaskSelectedMsg{task: nil} - } - // Find the task by description - tasks := com.TW.GetTasks(nil, filters...) - for _, task := range tasks { - if task.Description == item.FilterValue() { - return timeEditorTaskSelectedMsg{task: task} - } - } - return nil - } - } - - title := "Task" - if project != "" { - title = fmt.Sprintf("Task (%s)", project) - } - - opts := []picker.PickerOption{ - picker.WithFilterByDefault(false), // Start in list mode, not filter mode - } - - // Pre-select task if provided, otherwise default to "(none)" - if defaultTask != "" { - opts = append(opts, picker.WithDefaultValue(defaultTask)) - } else { - opts = append(opts, picker.WithDefaultValue("(none)")) - } - - taskPicker := picker.New( - com, - title, - taskItemProvider, - taskOnSelect, - opts..., - ) - taskPicker.SetSize(50, 10) - - return taskPicker -} - -func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { - // Extract special tags (uuid, project, track) and display tags - uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags) - - // Track selected task for pre-selection - var selectedTask *taskwarrior.Task - var defaultTaskDescription string - - // If UUID exists, fetch the task and add its title to display tags - if uuid != "" { - tasks := com.TW.GetTasks(nil, "uuid:"+uuid) - if len(tasks) > 0 { - selectedTask = tasks[0] - defaultTaskDescription = selectedTask.Description - // Add to display tags if not already present - // Note: formatTags() will handle quoting for display, so we store the raw title - displayTags = ensureTagPresent(displayTags, defaultTaskDescription) - } - } - - // 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 task picker (only if project is selected) - var taskPicker *picker.Picker - if selectedProject != "" { - taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription) - } - - // 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(displayTags)) // Use display tags only (no uuid, project, track) - tagsInput.SetWidth(50) - - p := &TimeEditorPage{ - common: com, - interval: interval, - projectPicker: projectPicker, - taskPicker: taskPicker, - startEditor: startEditor, - endEditor: endEditor, - tagsInput: tagsInput, - adjust: true, // Enable :adjust by default - selectedProject: selectedProject, - selectedTask: selectedTask, - currentField: 0, - totalFields: 6, // 6 fields: project, task, tags, start, end, adjust - uuid: uuid, - track: track, - } - - 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: - // Project selection happens on Enter - advance to task picker - // (Auto-selection of project already happened in Update() switch) - p.blurCurrentField() - p.currentField = 1 - cmds = append(cmds, p.focusCurrentField()) - return p, tea.Batch(cmds...) - - case timeEditorTaskSelectedMsg: - // Task selection happens on Enter - advance to tags field - // (Auto-selection of task already happened in Update() switch) - p.blurCurrentField() - p.currentField = 2 - 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 { - // Task picker - let it handle Enter (will trigger taskSelectedMsg) - break - } - - if p.currentField == 2 { - // Tags field - if p.tagsInput.HasSuggestions() { - // Let autocomplete handle suggestion selection - break - } - // Tags confirmed without suggestions - advance to start timestamp - p.blurCurrentField() - p.currentField = 3 - cmds = append(cmds, p.focusCurrentField()) - return p, tea.Batch(cmds...) - } - - // For all other fields (3-5: 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 - // Track the previous project selection - previousProject := p.selectedProject - - var model tea.Model - model, cmd = p.projectPicker.Update(msg) - if pk, ok := model.(*picker.Picker); ok { - p.projectPicker = pk - } - - // Check if the highlighted project changed (auto-selection) - currentProject := p.projectPicker.GetValue() - if currentProject != previousProject && currentProject != "" { - // Update the selected project and refresh task picker - p.selectedProject = currentProject - // Clear task selection when project changes - p.selectedTask = nil - p.uuid = "" - // Create/update task picker for the new project - p.taskPicker = createTaskPickerForProject(p.common, currentProject, "") - // Refresh tag autocomplete with filtered combinations - cmds = append(cmds, p.updateTagSuggestions()) - } - case 1: // Task picker - if p.taskPicker != nil { - // Track the previous task selection - var previousTaskDesc string - if p.selectedTask != nil { - previousTaskDesc = p.selectedTask.Description - } - - var model tea.Model - model, cmd = p.taskPicker.Update(msg) - if pk, ok := model.(*picker.Picker); ok { - p.taskPicker = pk - } - - // Check if the highlighted task changed (auto-selection) - currentTaskDesc := p.taskPicker.GetValue() - if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" { - // Handle "(none)" selection - clear task state - if currentTaskDesc == "(none)" { - p.selectedTask = nil - p.uuid = "" - p.track = "" - // Don't clear tags - user might still want manual tags - // Refresh tag suggestions - cmds = append(cmds, p.updateTagSuggestions()) - } else { - // Find and update the selected task - filters := []string{"+track", "status:pending"} - if p.selectedProject != "" { - filters = append(filters, "project:"+p.selectedProject) - } - tasks := p.common.TW.GetTasks(nil, filters...) - for _, task := range tasks { - if task.Description == currentTaskDesc { - // Update selected task - p.selectedTask = task - p.uuid = task.Uuid - - // Build tags from task - tags := []string{} - - // Add task description - if task.Description != "" { - tags = append(tags, task.Description) - } - - // Add task tags (excluding "track" tag since it's preserved separately) - for _, tag := range task.Tags { - if tag != "track" { - tags = append(tags, tag) - } - } - - // Store track tag if present - if task.HasTag("track") { - p.track = "track" - } - - // Update tags input - p.tagsInput.SetValue(formatTags(tags)) - - // Refresh tag suggestions - cmds = append(cmds, p.updateTagSuggestions()) - break - } - } - } - } - } - case 2: // Tags - var model tea.Model - model, cmd = p.tagsInput.Update(msg) - if ac, ok := model.(*autocomplete.Autocomplete); ok { - p.tagsInput = ac - } - case 3: // Start - var model tea.Model - model, cmd = p.startEditor.Update(msg) - if editor, ok := model.(*timestampeditor.TimestampEditor); ok { - p.startEditor = editor - } - case 4: // End - var model tea.Model - model, cmd = p.endEditor.Update(msg) - if editor, ok := model.(*timestampeditor.TimestampEditor); ok { - p.endEditor = editor - } - case 5: // Adjust - // 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: - if p.taskPicker != nil { - return p.taskPicker.Init() - } - return nil - case 2: - p.tagsInput.Focus() - return p.tagsInput.Init() - case 3: - return p.startEditor.Focus() - case 4: - return p.endEditor.Focus() - case 5: - // Adjust checkbox doesn't need focus action - return nil - } - return nil -} - -func (p *TimeEditorPage) blurCurrentField() { - switch p.currentField { - case 0: - // Project picker doesn't have explicit Blur(), state handled by Update - case 1: - // Task picker doesn't have explicit Blur(), state handled by Update - case 2: - p.tagsInput.Blur() - case 3: - p.startEditor.Blur() - case 4: - p.endEditor.Blur() - case 5: - // 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, "") - - // Task picker (field 1) - if p.currentField == 1 { - if p.taskPicker != nil { - sections = append(sections, p.taskPicker.View()) - } else { - // No project selected yet - blurredLabelStyle := p.common.Styles.Form.Blurred.Title - sections = append(sections, blurredLabelStyle.Render("Task")) - sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)")) - } - } else { - blurredLabelStyle := p.common.Styles.Form.Blurred.Title - sections = append(sections, blurredLabelStyle.Render("Task")) - if p.selectedTask != nil { - sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description)) - } else { - sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)")) - } - } - - sections = append(sections, "") - sections = append(sections, "") - - // Tags input (field 2) - tagsLabelStyle := p.common.Styles.Form.Focused.Title - tagsLabel := tagsLabelStyle.Render("Tags") - if p.currentField == 2 { - 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 (field 3) - sections = append(sections, p.startEditor.View()) - sections = append(sections, "") - - // End editor (field 4) - sections = append(sections, p.endEditor.View()) - sections = append(sections, "") - - // Adjust checkbox (field 5) - 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 == 5 { - 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: select/save • esc: cancel")) - - content := lipgloss.JoinVertical(lipgloss.Left, sections...) - - return lipgloss.Place( - p.common.Width(), - p.common.Height(), - lipgloss.Center, - lipgloss.Center, - content, - ) -} - -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 display tags from input - displayTags := parseTags(p.tagsInput.GetValue()) - - // Reconstruct full tags array by combining special tags and display tags - var tags []string - - // Add preserved special tags first - if p.uuid != "" { - tags = append(tags, "uuid:"+p.uuid) - } - if p.track != "" { - tags = append(tags, p.track) - } - - // Add project tag - if p.selectedProject != "" { - tags = append(tags, "project:"+p.selectedProject) - } - - // Add display tags (user-entered tags from the input field) - tags = append(tags, displayTags...) - - 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, " ") -} - -// extractSpecialTags separates special tags (uuid, project, track) from display tags -// Returns uuid, project, track as separate strings, and displayTags for user editing -func extractSpecialTags(tags []string) (uuid, project, track string, displayTags []string) { - for _, tag := range tags { - if strings.HasPrefix(tag, "uuid:") { - uuid = strings.TrimPrefix(tag, "uuid:") - } else if strings.HasPrefix(tag, "project:") { - project = strings.TrimPrefix(tag, "project:") - } else if tag == "track" { - track = tag - } else { - displayTags = append(displayTags, tag) - } - } - return -} - -// extractProjectFromTags finds and removes the first tag that matches a known project -// Returns the found project (or empty string) and the remaining tags -// This is kept for backward compatibility but now uses extractSpecialTags internally -func extractProjectFromTags(tags []string, projects []string) (string, []string) { - _, project, _, remaining := extractSpecialTags(tags) - return project, remaining -} - -// ensureTagPresent adds a tag to the list if not already present -func ensureTagPresent(tags []string, tag string) []string { - for _, t := range tags { - if t == tag { - return tags // Already present - } - } - return append(tags, tag) -} - -// 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 == 2 { - p.tagsInput.Focus() - return p.tagsInput.Init() - } - - return nil -} diff --git a/pages/timePage.go b/pages/timePage.go deleted file mode 100644 index 584fd14..0000000 --- a/pages/timePage.go +++ /dev/null @@ -1,498 +0,0 @@ -package pages - -import ( - "fmt" - "log/slog" - "time" - - "tasksquire/common" - "tasksquire/components/timetable" - "tasksquire/timewarrior" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type TimePage struct { - common *common.Common - - intervals timetable.Model - data timewarrior.Intervals - - shouldSelectActive bool - pendingSyncAction string // "start", "stop", or "" (empty means no pending action) - - selectedTimespan string - subpage common.Component -} - -func NewTimePage(com *common.Common) *TimePage { - p := &TimePage{ - common: com, - selectedTimespan: ":day", - } - - p.populateTable(timewarrior.Intervals{}) - return p -} - -func (p *TimePage) isMultiDayTimespan() bool { - switch p.selectedTimespan { - case ":day", ":yesterday": - return false - case ":week", ":lastweek", ":month", ":lastmonth", ":year": - return true - default: - return true - } -} - -func (p *TimePage) getTimespanLabel() string { - switch p.selectedTimespan { - case ":day": - return "Today" - case ":yesterday": - return "Yesterday" - case ":week": - return "Week" - case ":lastweek": - return "Last Week" - case ":month": - return "Month" - case ":lastmonth": - return "Last Month" - case ":year": - return "Year" - default: - return p.selectedTimespan - } -} - -func (p *TimePage) getTimespanDateRange() (start, end time.Time) { - now := time.Now() - - switch p.selectedTimespan { - case ":day": - start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - end = start.AddDate(0, 0, 1) - case ":yesterday": - start = time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location()) - end = start.AddDate(0, 0, 1) - case ":week": - // Find the start of the week (Monday) - offset := int(time.Monday - now.Weekday()) - if offset > 0 { - offset = -6 - } - start = time.Date(now.Year(), now.Month(), now.Day()+offset, 0, 0, 0, 0, now.Location()) - end = start.AddDate(0, 0, 7) - case ":lastweek": - // Find the start of last week - offset := int(time.Monday - now.Weekday()) - if offset > 0 { - offset = -6 - } - start = time.Date(now.Year(), now.Month(), now.Day()+offset-7, 0, 0, 0, 0, now.Location()) - end = start.AddDate(0, 0, 7) - case ":month": - start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - end = start.AddDate(0, 1, 0) - case ":lastmonth": - start = time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location()) - end = start.AddDate(0, 1, 0) - case ":year": - start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) - end = start.AddDate(1, 0, 0) - default: - start = now - end = now - } - - return start, end -} - -func (p *TimePage) renderHeader() string { - label := p.getTimespanLabel() - start, end := p.getTimespanDateRange() - - var headerText string - if p.isMultiDayTimespan() { - // Multi-day format: "Week: Feb 02 - Feb 08, 2026" - if start.Year() == end.AddDate(0, 0, -1).Year() { - headerText = fmt.Sprintf("%s: %s - %s, %d", - label, - start.Format("Jan 02"), - end.AddDate(0, 0, -1).Format("Jan 02"), - start.Year()) - } else { - headerText = fmt.Sprintf("%s: %s, %d - %s, %d", - label, - start.Format("Jan 02"), - start.Year(), - end.AddDate(0, 0, -1).Format("Jan 02"), - end.AddDate(0, 0, -1).Year()) - } - } else { - // Single-day format: "Today (Mon, Feb 02, 2026)" - headerText = fmt.Sprintf("%s (%s, %s, %d)", - label, - start.Format("Mon"), - start.Format("Jan 02"), - start.Year()) - } - - slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan) - // Make header bold and prominent - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground()) - return headerStyle.Render(headerText) -} - -func (p *TimePage) Init() tea.Cmd { - return tea.Batch(p.getIntervals(), doTick()) -} - -func (p *TimePage) 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 UpdateTimespanMsg: - p.selectedTimespan = string(msg) - cmds = append(cmds, p.getIntervals()) - case intervalsMsg: - p.data = timewarrior.Intervals(msg) - p.populateTable(p.data) - - // If we have a pending sync action (from continuing an interval), - // execute it now that intervals are refreshed - if p.pendingSyncAction != "" { - action := p.pendingSyncAction - p.pendingSyncAction = "" - cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action)) - } - case TaskPickedMsg: - if msg.Task != nil && msg.Task.Status == "pending" { - p.common.TW.StopActiveTasks() - p.common.TW.StartTask(msg.Task) - cmds = append(cmds, p.getIntervals()) - cmds = append(cmds, doTick()) - } - case RefreshIntervalsMsg: - cmds = append(cmds, p.getIntervals()) - cmds = append(cmds, doTick()) - case BackMsg: - // Restart tick loop when returning from subpage - cmds = append(cmds, doTick()) - case tickMsg: - cmds = append(cmds, p.getIntervals()) - cmds = append(cmds, doTick()) - case tea.KeyMsg: - switch { - case key.Matches(msg, p.common.Keymap.Quit): - return p, tea.Quit - case key.Matches(msg, p.common.Keymap.SetReport): - // Use 'r' key to show timespan picker - p.subpage = NewTimespanPickerPage(p.common, p.selectedTimespan) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.PickProjectTask): - p.subpage = NewProjectTaskPickerPage(p.common) - cmd := p.subpage.Init() - p.common.PushPage(p) - return p.subpage, cmd - case key.Matches(msg, p.common.Keymap.StartStop): - row := p.intervals.SelectedRow() - if row != nil { - interval := (*timewarrior.Interval)(row) - - // Validate interval before proceeding - if interval.IsGap { - slog.Debug("Cannot start/stop gap interval") - return p, nil - } - - if interval.IsActive() { - // Stop tracking - p.common.TimeW.StopTracking() - // Sync: stop corresponding Taskwarrior task immediately (interval has UUID) - common.SyncIntervalToTask(interval, p.common.TW, "stop") - } else { - // Continue tracking - creates a NEW interval - slog.Info("Continuing interval for task sync", - "intervalID", interval.ID, - "hasUUID", timewarrior.ExtractUUID(interval.Tags) != "", - "uuid", timewarrior.ExtractUUID(interval.Tags)) - p.common.TimeW.ContinueInterval(interval.ID) - p.shouldSelectActive = true - // Set pending sync action instead of syncing immediately - // This ensures we sync AFTER intervals are refreshed - p.pendingSyncAction = "start" - } - cmds = append(cmds, p.getIntervals()) - cmds = append(cmds, doTick()) - return p, tea.Batch(cmds...) - } - case key.Matches(msg, p.common.Keymap.Delete): - row := p.intervals.SelectedRow() - if row != nil { - interval := (*timewarrior.Interval)(row) - p.common.TimeW.DeleteInterval(interval.ID) - return p, tea.Batch(p.getIntervals(), doTick()) - } - case key.Matches(msg, p.common.Keymap.Edit): - row := p.intervals.SelectedRow() - if row != nil { - interval := (*timewarrior.Interval)(row) - editor := NewTimeEditorPage(p.common, interval) - p.common.PushPage(p) - return editor, editor.Init() - } - case key.Matches(msg, p.common.Keymap.Add): - interval := timewarrior.NewInterval() - interval.Start = time.Now().UTC().Format("20060102T150405Z") - editor := NewTimeEditorPage(p.common, interval) - p.common.PushPage(p) - return editor, editor.Init() - case key.Matches(msg, p.common.Keymap.Fill): - row := p.intervals.SelectedRow() - if row != nil { - interval := (*timewarrior.Interval)(row) - p.common.TimeW.FillInterval(interval.ID) - return p, tea.Batch(p.getIntervals(), doTick()) - } - case key.Matches(msg, p.common.Keymap.Undo): - p.common.TimeW.Undo() - return p, tea.Batch(p.getIntervals(), doTick()) - case key.Matches(msg, p.common.Keymap.Join): - row := p.intervals.SelectedRow() - if row != nil { - interval := (*timewarrior.Interval)(row) - // Don't join if this is the last (oldest) interval - if interval.ID < len(p.data) { - p.common.TimeW.JoinInterval(interval.ID) - return p, tea.Batch(p.getIntervals(), doTick()) - } - } - } - } - - var cmd tea.Cmd - p.intervals, cmd = p.intervals.Update(msg) - cmds = append(cmds, cmd) - - return p, tea.Batch(cmds...) -} - -type RefreshIntervalsMsg struct{} - -func refreshIntervals() tea.Msg { - return RefreshIntervalsMsg{} -} - -func (p *TimePage) View() string { - header := p.renderHeader() - if len(p.data) == 0 { - noDataMsg := p.common.Styles.Base.Render("No intervals found") - content := header + "\n\n" + noDataMsg - return lipgloss.Place( - p.common.Width(), - p.common.Height(), - lipgloss.Left, - lipgloss.Top, - content, - ) - } - - tableView := p.intervals.View() - content := header + "\n\n" + tableView - - contentHeight := lipgloss.Height(content) - tableHeight := lipgloss.Height(tableView) - headerHeight := lipgloss.Height(header) - - slog.Info("TimePage View rendered", - "headerLen", len(header), - "dataCount", len(p.data), - "headerHeight", headerHeight, - "tableHeight", tableHeight, - "contentHeight", contentHeight, - "termHeight", p.common.Height()) - - return content -} - -func (p *TimePage) SetSize(width int, height int) { - p.common.SetSize(width, height) - frameSize := p.common.Styles.Base.GetVerticalFrameSize() - tableHeight := height - frameSize - 3 - slog.Info("TimePage SetSize", "totalHeight", height, "frameSize", frameSize, "tableHeight", tableHeight) - p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize()) - // Subtract 3: 1 for header line, 1 for empty line, 1 for safety margin - p.intervals.SetHeight(tableHeight) -} - -// insertGaps inserts gap intervals between actual intervals where there is untracked time. -// Gaps are not inserted before the first interval or after the last interval. -// Note: intervals are in reverse chronological order (newest first), so we need to account for that. -func insertGaps(intervals timewarrior.Intervals) timewarrior.Intervals { - if len(intervals) <= 1 { - return intervals - } - - result := make(timewarrior.Intervals, 0, len(intervals)*2) - - for i := 0; i < len(intervals); i++ { - result = append(result, intervals[i]) - - // Don't add gap after the last interval - if i < len(intervals)-1 { - // Since intervals are reversed (newest first), the gap is between - // the end of the NEXT interval and the start of the CURRENT interval - currentStart := intervals[i].GetStartTime() - nextEnd := intervals[i+1].GetEndTime() - - // Calculate gap duration - gap := currentStart.Sub(nextEnd) - - // Only insert gap if there is untracked time - if gap > 0 { - gapInterval := timewarrior.NewGapInterval(nextEnd, currentStart) - result = append(result, gapInterval) - } - } - } - - return result -} - -func (p *TimePage) populateTable(intervals timewarrior.Intervals) { - var selectedStart string - if row := p.intervals.SelectedRow(); row != nil { - selectedStart = row.Start - } - - // Insert gap intervals between actual intervals - intervalsWithGaps := insertGaps(intervals) - - // Determine column configuration based on timespan - var startEndWidth int - var startField, endField string - if p.isMultiDayTimespan() { - startEndWidth = 16 // "2006-01-02 15:04" - startField = "start" - endField = "end" - } else { - startEndWidth = 5 // "15:04" - startField = "start_time" - endField = "end_time" - } - - columns := []timetable.Column{ - {Title: "ID", Name: "id", Width: 4}, - {Title: "Weekday", Name: "weekday", Width: 9}, - {Title: "Start", Name: startField, Width: startEndWidth}, - {Title: "End", Name: endField, Width: startEndWidth}, - {Title: "Duration", Name: "duration", Width: 10}, - {Title: "Project", Name: "project", Width: 0}, // flexible width - {Title: "Tags", Name: "tags", Width: 0}, // flexible width - } - - // Calculate table height: total height - header (1 line) - blank line (1) - safety (1) - frameSize := p.common.Styles.Base.GetVerticalFrameSize() - tableHeight := p.common.Height() - frameSize - 3 - - p.intervals = timetable.New( - p.common, - timetable.WithColumns(columns), - timetable.WithIntervals(intervalsWithGaps), - timetable.WithFocused(true), - timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetHorizontalFrameSize()), - timetable.WithHeight(tableHeight), - timetable.WithStyles(p.common.Styles.TableStyle), - ) - - if len(intervalsWithGaps) > 0 { - newIdx := -1 - - if p.shouldSelectActive { - for i, interval := range intervalsWithGaps { - if !interval.IsGap && interval.IsActive() { - newIdx = i - break - } - } - p.shouldSelectActive = false - } - - if newIdx == -1 && selectedStart != "" { - for i, interval := range intervalsWithGaps { - if !interval.IsGap && interval.Start == selectedStart { - newIdx = i - break - } - } - } - - if newIdx == -1 { - // Default to first non-gap interval - for i, interval := range intervalsWithGaps { - if !interval.IsGap { - newIdx = i - break - } - } - } - - if newIdx >= len(intervalsWithGaps) { - newIdx = len(intervalsWithGaps) - 1 - } - if newIdx < 0 { - newIdx = 0 - } - - p.intervals.SetCursor(newIdx) - } -} - -type intervalsMsg timewarrior.Intervals - -func (p *TimePage) getIntervals() tea.Cmd { - return func() tea.Msg { - intervals := p.common.TimeW.GetIntervals(p.selectedTimespan) - return intervalsMsg(intervals) - } -} - -// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior -func (p *TimePage) syncActiveInterval(action string) tea.Cmd { - return func() tea.Msg { - // Get the currently active interval - activeInterval := p.common.TimeW.GetActive() - if activeInterval != nil { - common.SyncIntervalToTask(activeInterval, p.common.TW, action) - } - return nil - } -} - -// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed -// to ensure we're working with current data -func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd { - return func() tea.Msg { - // At this point, intervals have been refreshed, so GetActive() will work - activeInterval := p.common.TimeW.GetActive() - if activeInterval != nil { - slog.Info("Syncing active interval to task after refresh", - "action", action, - "intervalID", activeInterval.ID, - "hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "") - common.SyncIntervalToTask(activeInterval, p.common.TW, action) - } else { - slog.Warn("No active interval found after refresh, cannot sync to task") - } - return nil - } -} diff --git a/pages/timePage_test.go b/pages/timePage_test.go deleted file mode 100644 index 850368f..0000000 --- a/pages/timePage_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package pages - -import ( - "testing" - "time" - - "tasksquire/timewarrior" -) - -func TestInsertGaps(t *testing.T) { - tests := []struct { - name string - intervals timewarrior.Intervals - expectedCount int - expectedGaps int - description string - }{ - { - name: "empty intervals", - intervals: timewarrior.Intervals{}, - expectedCount: 0, - expectedGaps: 0, - description: "Should return empty list for empty input", - }, - { - name: "single interval", - intervals: timewarrior.Intervals{ - { - ID: 1, - Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().UTC().Format("20060102T150405Z"), - Tags: []string{"test"}, - }, - }, - expectedCount: 1, - expectedGaps: 0, - description: "Should return single interval without gaps", - }, - { - name: "two intervals with gap (reverse chronological)", - intervals: timewarrior.Intervals{ - { - ID: 1, - Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().UTC().Format("20060102T150405Z"), - Tags: []string{"test2"}, - }, - { - ID: 2, - Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"), - Tags: []string{"test1"}, - }, - }, - expectedCount: 3, - expectedGaps: 1, - description: "Should insert one gap between two intervals (newest first order)", - }, - { - name: "three intervals with two gaps (reverse chronological)", - intervals: timewarrior.Intervals{ - { - ID: 1, - Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().UTC().Format("20060102T150405Z"), - Tags: []string{"test3"}, - }, - { - ID: 2, - Start: time.Now().Add(-3 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"), - Tags: []string{"test2"}, - }, - { - ID: 3, - Start: time.Now().Add(-5 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().Add(-4 * time.Hour).UTC().Format("20060102T150405Z"), - Tags: []string{"test1"}, - }, - }, - expectedCount: 5, - expectedGaps: 2, - description: "Should insert two gaps between three intervals (newest first order)", - }, - { - name: "consecutive intervals with no gap (reverse chronological)", - intervals: timewarrior.Intervals{ - { - ID: 1, - Start: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().UTC().Format("20060102T150405Z"), - Tags: []string{"test2"}, - }, - { - ID: 2, - Start: time.Now().Add(-2 * time.Hour).UTC().Format("20060102T150405Z"), - End: time.Now().Add(-1 * time.Hour).UTC().Format("20060102T150405Z"), - Tags: []string{"test1"}, - }, - }, - expectedCount: 2, - expectedGaps: 0, - description: "Should not insert gap when intervals are consecutive (newest first order)", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := insertGaps(tt.intervals) - - if len(result) != tt.expectedCount { - t.Errorf("insertGaps() returned %d intervals, expected %d. %s", - len(result), tt.expectedCount, tt.description) - } - - gapCount := 0 - for _, interval := range result { - if interval.IsGap { - gapCount++ - } - } - - if gapCount != tt.expectedGaps { - t.Errorf("insertGaps() created %d gaps, expected %d. %s", - gapCount, tt.expectedGaps, tt.description) - } - - // Verify gaps are properly interleaved with intervals - for i := 0; i < len(result)-1; i++ { - if result[i].IsGap && result[i+1].IsGap { - t.Errorf("insertGaps() created consecutive gap rows at indices %d and %d", i, i+1) - } - } - - // Verify first and last items are never gaps - if len(result) > 0 { - if result[0].IsGap { - t.Errorf("insertGaps() created gap as first item") - } - if result[len(result)-1].IsGap { - t.Errorf("insertGaps() created gap as last item") - } - } - }) - } -} diff --git a/pages/timespanPicker.go b/pages/timespanPicker.go deleted file mode 100644 index 849a36a..0000000 --- a/pages/timespanPicker.go +++ /dev/null @@ -1,128 +0,0 @@ -package pages - -import ( - "log/slog" - "tasksquire/common" - "tasksquire/components/picker" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type TimespanPickerPage struct { - common *common.Common - picker *picker.Picker - selectedTimespan string -} - -func NewTimespanPickerPage(common *common.Common, currentTimespan string) *TimespanPickerPage { - p := &TimespanPickerPage{ - common: common, - selectedTimespan: currentTimespan, - } - - timespanOptions := []list.Item{ - picker.NewItem(":day"), - picker.NewItem(":yesterday"), - picker.NewItem(":week"), - picker.NewItem(":lastweek"), - picker.NewItem(":month"), - picker.NewItem(":lastmonth"), - picker.NewItem(":year"), - } - - itemProvider := func() []list.Item { - return timespanOptions - } - - onSelect := func(item list.Item) tea.Cmd { - return func() tea.Msg { return timespanSelectedMsg{item: item} } - } - - p.picker = picker.New(common, "Select Timespan", itemProvider, onSelect) - - // Select the current timespan in the picker - p.picker.SelectItemByFilterValue(currentTimespan) - - p.SetSize(common.Width(), common.Height()) - - return p -} - -func (p *TimespanPickerPage) SetSize(width, height int) { - p.common.SetSize(width, height) - - // Set list size with some padding/limits to look like a picker - listWidth := width - 4 - if listWidth > 40 { - listWidth = 40 - } - listHeight := height - 6 - if listHeight > 20 { - listHeight = 20 - } - p.picker.SetSize(listWidth, listHeight) -} - -func (p *TimespanPickerPage) Init() tea.Cmd { - return p.picker.Init() -} - -type timespanSelectedMsg struct { - item list.Item -} - -func (p *TimespanPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - p.SetSize(msg.Width, msg.Height) - case timespanSelectedMsg: - timespan := msg.item.FilterValue() - - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, func() tea.Msg { return UpdateTimespanMsg(timespan) } - case tea.KeyMsg: - if !p.picker.IsFiltering() { - 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 - } - } - } - - _, cmd = p.picker.Update(msg) - return p, cmd -} - -func (p *TimespanPickerPage) View() string { - width := p.common.Width() - 4 - if width > 40 { - width = 40 - } - - content := p.picker.View() - styledContent := lipgloss.NewStyle().Width(width).Render(content) - - return lipgloss.Place( - p.common.Width(), - p.common.Height(), - lipgloss.Center, - lipgloss.Center, - p.common.Styles.Base.Render(styledContent), - ) -} - -type UpdateTimespanMsg string diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 deleted file mode 100644 index 1d8609d..0000000 Binary files a/test/taskchampion.sqlite3 and /dev/null differ diff --git a/test/taskrc b/test/taskrc deleted file mode 100644 index db4baec..0000000 --- a/test/taskrc +++ /dev/null @@ -1,30 +0,0 @@ -include light-256.theme - -uda.priority.values=H,M,,L - -context.test.read=+test -context.test.write=+test -context.home.read=+home -context.home.write=+home - -uda.testuda.type=string -uda.testuda.label=Testuda -uda.testuda.values=eins,zwei,drei -uda.testuda.default=eins - -uda.testuda2.type=numeric -uda.testuda2.label=TESTUDA2 - -uda.testuda3.type=date -uda.testuda3.label=Ttttuda - -uda.testuda4.type=duration -uda.testuda4.label=TtttudaDURUD - -report.next.columns=id,testuda,start.age,entry.age,depends,priority,project,tags,recur,scheduled.countdown,due.relative,until.remaining,description,urgency -report.next.context=1 -report.next.description=Most urgent tasks -report.next.filter=status:pending -WAITING -report.next.labels=ID,UDA,Active,Age,Deps,P,Project,Tag,Recur,S,Due,Until,Description,Urg -report.next.sort=urgency- -uda.tasksquire.use_details=true