From 418bcd96a8462f213d027a3131d35b58ad1b1cb6 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Thu, 26 Feb 2026 20:00:56 +0100 Subject: [PATCH] Tear down everything Fix config --- components/autocomplete/autocomplete.go | 293 ----- components/detailsviewer/detailsviewer.go | 174 --- components/input/multiselect.go | 672 ----------- components/input/option.go | 38 - components/input/select.go | 618 ---------- components/picker/picker.go | 300 ----- components/table/table.go | 696 ----------- components/timestampeditor/timestampeditor.go | 409 ------- components/timetable/table.go | 582 --------- flake.lock | 6 +- flake.nix | 2 +- go.mod | 61 +- go.sum | 138 +-- {common => internal/common}/common.go | 4 +- {common => internal/common}/component.go | 2 +- {common => internal/common}/keymap.go | 2 +- {common => internal/common}/stack.go | 0 {common => internal/common}/styles.go | 40 +- {common => internal/common}/sync.go | 4 +- {common => internal/common}/task.go | 0 internal/pages/main.go | 112 ++ .../taskwarrior}/config.go | 0 .../taskwarrior}/models.go | 0 .../taskwarrior}/models_test.go | 0 .../taskwarrior}/taskwarrior.go | 62 +- .../taskwarrior}/taskwarrior_test.go | 0 {taskwarrior => internal/taskwarrior}/tree.go | 0 .../taskwarrior}/tree_test.go | 0 .../timewarrior}/config.go | 0 .../timewarrior}/models.go | 0 {timewarrior => internal/timewarrior}/tags.go | 0 .../timewarrior}/timewarrior.go | 42 +- main.go | 88 -- on-modify.timewarrior | 118 -- pages/contextPicker.go | 140 --- pages/datePicker.go | 122 -- pages/main.go | 108 -- pages/messaging.go | 88 -- pages/page.go | 11 - pages/projectPicker.go | 132 --- pages/projectTaskPicker.go | 468 -------- pages/report.go | 374 ------ pages/reportPicker.go | 128 -- pages/taskEditor.go | 1043 ----------------- pages/timeEditor.go | 754 ------------ pages/timePage.go | 498 -------- pages/timePage_test.go | 146 --- pages/timespanPicker.go | 128 -- test/taskchampion.sqlite3 | Bin 389120 -> 0 bytes test/taskrc | 30 - 50 files changed, 256 insertions(+), 8377 deletions(-) delete mode 100644 components/autocomplete/autocomplete.go delete mode 100644 components/detailsviewer/detailsviewer.go delete mode 100644 components/input/multiselect.go delete mode 100644 components/input/option.go delete mode 100644 components/input/select.go delete mode 100644 components/picker/picker.go delete mode 100644 components/table/table.go delete mode 100644 components/timestampeditor/timestampeditor.go delete mode 100644 components/timetable/table.go rename {common => internal/common}/common.go (94%) rename {common => internal/common}/component.go (68%) rename {common => internal/common}/keymap.go (98%) rename {common => internal/common}/stack.go (100%) rename {common => internal/common}/styles.go (78%) rename {common => internal/common}/sync.go (96%) rename {common => internal/common}/task.go (100%) create mode 100644 internal/pages/main.go rename {taskwarrior => internal/taskwarrior}/config.go (100%) rename {taskwarrior => internal/taskwarrior}/models.go (100%) rename {taskwarrior => internal/taskwarrior}/models_test.go (100%) rename {taskwarrior => internal/taskwarrior}/taskwarrior.go (88%) rename {taskwarrior => internal/taskwarrior}/taskwarrior_test.go (100%) rename {taskwarrior => internal/taskwarrior}/tree.go (100%) rename {taskwarrior => internal/taskwarrior}/tree_test.go (100%) rename {timewarrior => internal/timewarrior}/config.go (100%) rename {timewarrior => internal/timewarrior}/models.go (100%) rename {timewarrior => internal/timewarrior}/tags.go (100%) rename {timewarrior => internal/timewarrior}/timewarrior.go (87%) delete mode 100644 main.go delete mode 100644 on-modify.timewarrior delete mode 100644 pages/contextPicker.go delete mode 100644 pages/datePicker.go delete mode 100644 pages/main.go delete mode 100644 pages/messaging.go delete mode 100644 pages/page.go delete mode 100644 pages/projectPicker.go delete mode 100644 pages/projectTaskPicker.go delete mode 100644 pages/report.go delete mode 100644 pages/reportPicker.go delete mode 100644 pages/taskEditor.go delete mode 100644 pages/timeEditor.go delete mode 100644 pages/timePage.go delete mode 100644 pages/timePage_test.go delete mode 100644 pages/timespanPicker.go delete mode 100644 test/taskchampion.sqlite3 delete mode 100644 test/taskrc 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 1d8609d9a6e8888dfe62a36c564f9264c4d09187..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 389120 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 z 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 X(LeokAGj^|x7~dx`y7fS_(uL8TvVo4 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