diff --git a/.gitignore b/.gitignore index 9320e1e..c65b854 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store app.log test/taskchampion.sqlite3 +tasksquire diff --git a/common/common.go b/common/common.go index 29aab3d..545a25d 100644 --- a/common/common.go +++ b/common/common.go @@ -6,6 +6,7 @@ import ( "os" "tasksquire/taskwarrior" + "tasksquire/timewarrior" "golang.org/x/term" ) @@ -13,6 +14,7 @@ import ( type Common struct { Ctx context.Context TW taskwarrior.TaskWarrior + TimeW timewarrior.TimeWarrior Keymap *Keymap Styles *Styles Udas []taskwarrior.Uda @@ -22,10 +24,11 @@ type Common struct { height int } -func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common { +func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common { return &Common{ Ctx: ctx, TW: tw, + TimeW: timeW, Keymap: NewKeymap(), Styles: NewStyles(tw.GetConfig()), Udas: tw.GetUdas(), diff --git a/components/picker/picker.go b/components/picker/picker.go index 0b75f40..9877cf6 100644 --- a/components/picker/picker.go +++ b/components/picker/picker.go @@ -18,12 +18,25 @@ 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 + baseItems []list.Item } type PickerOption func(*Picker) @@ -34,10 +47,16 @@ func WithFilterByDefault(enabled bool) PickerOption { } } +func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption { + return func(p *Picker) { + p.onCreate = onCreate + } +} + func New( c *common.Common, title string, - items []list.Item, + itemProvider func() []list.Item, onSelect func(list.Item) tea.Cmd, opts ...PickerOption, ) *Picker { @@ -45,7 +64,7 @@ func New( delegate.ShowDescription = false delegate.SetSpacing(0) - l := list.New(items, delegate, 0, 0) + l := list.New([]list.Item{}, delegate, 0, 0) l.SetShowTitle(false) l.SetShowHelp(false) l.SetShowStatusBar(false) @@ -58,10 +77,11 @@ func New( ) p := &Picker{ - common: c, - list: l, - onSelect: onSelect, - title: title, + common: c, + list: l, + itemProvider: itemProvider, + onSelect: onSelect, + title: title, } if c.TW.GetConfig().Get("uda.tasksquire.picker.filter_by_default") == "yes" { @@ -72,18 +92,43 @@ func New( opt(p) } + p.Refresh() + return p } +func (p *Picker) Refresh() tea.Cmd { + p.baseItems = p.itemProvider() + return p.updateListItems() +} + +func (p *Picker) updateListItems() tea.Cmd { + items := p.baseItems + filterVal := p.list.FilterValue() + + if p.onCreate != nil && filterVal != "" { + newItem := creationItem{ + text: "(new) " + filterVal, + filter: filterVal, + } + newItems := make([]list.Item, len(items)+1) + copy(newItems, items) + newItems[len(items)] = newItem + items = newItems + } + + 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) } @@ -107,7 +152,7 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key.Matches(msg, p.common.Keymap.Ok) { items := p.list.VisibleItems() if len(items) == 1 { - return p, p.onSelect(items[0]) + return p, p.handleSelect(items[0]) } } break // Pass to list.Update @@ -119,19 +164,39 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if selectedItem == nil { return p, nil } - return p, p.onSelect(selectedItem) + return p, p.handleSelect(selectedItem) } } + prevFilter := p.list.FilterValue() p.list, cmd = p.list.Update(msg) + + if p.list.FilterValue() != prevFilter { + updateCmd := p.updateListItems() + return p, tea.Batch(cmd, updateCmd) + } + return p, cmd } +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 { title := p.common.Styles.Form.Focused.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() @@ -141,4 +206,4 @@ func (p *Picker) SelectItemByFilterValue(filterValue string) { break } } -} +} \ No newline at end of file diff --git a/components/timetable/table.go b/components/timetable/table.go new file mode 100644 index 0000000..b4d0a61 --- /dev/null +++ b/components/timetable/table.go @@ -0,0 +1,498 @@ +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. +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() 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. +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)) +} + +// 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)) + 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/main.go b/main.go index 17228ca..a456cb5 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "tasksquire/common" "tasksquire/pages" "tasksquire/taskwarrior" + "tasksquire/timewarrior" tea "github.com/charmbracelet/bubbletea" ) @@ -26,9 +27,23 @@ func main() { 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 = "" + } + ts := taskwarrior.NewTaskSquire(taskrcPath) + tws := timewarrior.NewTimeSquire(timewConfigPath) ctx := context.Background() - common := common.NewCommon(ctx, ts) + common := common.NewCommon(ctx, ts, tws) file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { diff --git a/pages/contextPicker.go b/pages/contextPicker.go index 4d21c5e..43cd470 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -26,25 +26,30 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { } selected := common.TW.GetActiveContext().Name - options := make([]string, 0) - for _, c := range p.contexts { - if c.Name != "none" { - options = append(options, c.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...) + slices.Sort(options) + options = append([]string{"(none)"}, options...) - items := []list.Item{} - for _, opt := range options { - items = append(items, picker.NewItem(opt)) + 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", items, onSelect) + p.picker = picker.New(common, "Contexts", itemProvider, onSelect) // Set active context if selected == "" { @@ -87,7 +92,7 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) case contextSelectedMsg: - name := msg.item.(picker.Item).Title() + name := msg.item.FilterValue() // Use FilterValue (which is the name/text) if name == "(none)" { name = "" } @@ -101,14 +106,16 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return model, func() tea.Msg { return UpdateContextMsg(ctx) } 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 + 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 } - return model, BackCmd } } @@ -134,4 +141,4 @@ func (p *ContextPickerPage) View() string { ) } -type UpdateContextMsg *taskwarrior.Context +type UpdateContextMsg *taskwarrior.Context \ No newline at end of file diff --git a/pages/main.go b/pages/main.go index 4b048fc..a396aed 100644 --- a/pages/main.go +++ b/pages/main.go @@ -1,14 +1,18 @@ package pages import ( - tea "github.com/charmbracelet/bubbletea" - "tasksquire/common" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" ) type MainPage struct { common *common.Common activePage common.Component + + taskPage common.Component + timePage common.Component } func NewMainPage(common *common.Common) *MainPage { @@ -16,15 +20,16 @@ func NewMainPage(common *common.Common) *MainPage { common: common, } - m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) - // m.activePage = NewTaskEditorPage(common, taskwarrior.Task{}) + m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) + m.timePage = NewTimePage(common) + + m.activePage = m.taskPage return m - } func (m *MainPage) Init() tea.Cmd { - return m.activePage.Init() + return tea.Batch(m.taskPage.Init(), m.timePage.Init()) } func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -33,6 +38,19 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.common.SetSize(msg.Width, msg.Height) + case tea.KeyMsg: + if key.Matches(msg, m.common.Keymap.Next) { + if m.activePage == m.taskPage { + m.activePage = m.timePage + } else { + m.activePage = m.taskPage + } + // Re-size the new active page just in case + m.activePage.SetSize(m.common.Width(), m.common.Height()) + // 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) diff --git a/pages/projectPicker.go b/pages/projectPicker.go index 0723f5e..1508611 100644 --- a/pages/projectPicker.go +++ b/pages/projectPicker.go @@ -21,17 +21,25 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP common: common, } - projects := common.TW.GetProjects() - items := []list.Item{picker.NewItem("(none)")} - for _, proj := range projects { - items = append(items, picker.NewItem(proj)) + 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} } } - p.picker = picker.New(common, "Projects", items, onSelect) + // 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 == "" { @@ -74,7 +82,7 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) case projectSelectedMsg: - proj := msg.item.(picker.Item).Title() + proj := msg.item.FilterValue() // Use FilterValue (text) if proj == "(none)" { proj = "" } @@ -86,14 +94,16 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return model, func() tea.Msg { return UpdateProjectMsg(proj) } 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 + 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 } - return model, BackCmd } } @@ -123,4 +133,4 @@ func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { return nil } -type UpdateProjectMsg string +type UpdateProjectMsg string \ No newline at end of file diff --git a/pages/reportPicker.go b/pages/reportPicker.go index 388e09f..0b179ce 100644 --- a/pages/reportPicker.go +++ b/pages/reportPicker.go @@ -4,18 +4,19 @@ 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/huh" "github.com/charmbracelet/lipgloss" ) type ReportPickerPage struct { common *common.Common reports taskwarrior.Reports - form *huh.Form + picker *picker.Picker } func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage { @@ -24,27 +25,29 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report reports: common.TW.GetReports(), } - selected := activeReport.Name + itemProvider := func() []list.Item { + options := make([]string, 0) + for _, r := range p.reports { + options = append(options, r.Name) + } + slices.Sort(options) - options := make([]string, 0) - for _, r := range p.reports { - options = append(options, r.Name) + items := []list.Item{} + for _, opt := range options { + items = append(items, picker.NewItem(opt)) + } + return items } - slices.Sort(options) - p.form = huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Key("report"). - Options(huh.NewOptions(options...)...). - Title("Reports"). - Description("Choose a report"). - Value(&selected). - WithTheme(common.Styles.Form), - ), - ). - WithShowHelp(false). - WithShowErrors(false) + 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()) @@ -54,72 +57,76 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report func (p *ReportPickerPage) SetSize(width, height int) { p.common.SetSize(width, height) - if width >= 20 { - p.form = p.form.WithWidth(20) - } else { - p.form = p.form.WithWidth(width) + // Set list size with some padding/limits to look like a picker + listWidth := width - 4 + if listWidth > 40 { + listWidth = 40 } - - if height >= 30 { - p.form = p.form.WithHeight(30) - } else { - p.form = p.form.WithHeight(height) + listHeight := height - 6 + if listHeight > 20 { + listHeight = 20 } + p.picker.SetSize(listWidth, listHeight) } func (p *ReportPickerPage) Init() tea.Cmd { - return p.form.Init() + return p.picker.Init() +} + +type reportSelectedMsg struct { + item list.Item } func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd + var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: p.SetSize(msg.Width, msg.Height) - 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 reportSelectedMsg: + reportName := msg.item.FilterValue() + report := p.common.TW.GetReport(reportName) - f, cmd := p.form.Update(msg) - if f, ok := f.(*huh.Form); ok { - p.form = f - cmds = append(cmds, cmd) - } - - if p.form.State == huh.StateCompleted { - cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...) model, err := p.common.PopPage() if err != nil { slog.Error("page stack empty") return nil, tea.Quit } - return model, tea.Batch(cmds...) + 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 + } + } } - return p, tea.Batch(cmds...) + _, cmd = p.picker.Update(msg) + return p, cmd } func (p *ReportPickerPage) 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(p.form.View()), + p.common.Styles.Base.Render(styledContent), ) } -func (p *ReportPickerPage) updateReportCmd() tea.Msg { - return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report"))) -} - -type UpdateReportMsg *taskwarrior.Report +type UpdateReportMsg *taskwarrior.Report \ No newline at end of file diff --git a/pages/timePage.go b/pages/timePage.go new file mode 100644 index 0000000..599e55d --- /dev/null +++ b/pages/timePage.go @@ -0,0 +1,161 @@ +package pages + +import ( + "tasksquire/common" + "tasksquire/components/timetable" + "tasksquire/timewarrior" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +type TimePage struct { + common *common.Common + + intervals timetable.Model + data timewarrior.Intervals + + shouldSelectActive bool +} + +func NewTimePage(com *common.Common) *TimePage { + p := &TimePage{ + common: com, + } + + p.populateTable(timewarrior.Intervals{}) + return p +} + +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 intervalsMsg: + p.data = timewarrior.Intervals(msg) + p.populateTable(p.data) + 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.StartStop): + row := p.intervals.SelectedRow() + if row != nil { + interval := (*timewarrior.Interval)(row) + if interval.IsActive() { + p.common.TimeW.StopTracking() + } else { + p.common.TimeW.ContinueInterval(interval.ID) + p.shouldSelectActive = true + } + return p, tea.Batch(p.getIntervals(), doTick()) + } + 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()) + } + } + } + + var cmd tea.Cmd + p.intervals, cmd = p.intervals.Update(msg) + cmds = append(cmds, cmd) + + return p, tea.Batch(cmds...) +} + +func (p *TimePage) View() string { + if len(p.data) == 0 { + return p.common.Styles.Base.Render("No intervals found for today") + } + return p.intervals.View() +} + +func (p *TimePage) SetSize(width int, height int) { + p.common.SetSize(width, height) + p.intervals.SetWidth(width - p.common.Styles.Base.GetHorizontalFrameSize()) + p.intervals.SetHeight(height - p.common.Styles.Base.GetVerticalFrameSize()) +} + +func (p *TimePage) populateTable(intervals timewarrior.Intervals) { + var selectedStart string + currentIdx := p.intervals.Cursor() + if row := p.intervals.SelectedRow(); row != nil { + selectedStart = row.Start + } + + columns := []timetable.Column{ + {Title: "ID", Name: "id", Width: 4}, + {Title: "Start", Name: "start", Width: 16}, + {Title: "End", Name: "end", Width: 16}, + {Title: "Duration", Name: "duration", Width: 10}, + {Title: "Tags", Name: "tags", Width: 0}, // flexible width + } + + p.intervals = timetable.New( + p.common, + timetable.WithColumns(columns), + timetable.WithIntervals(intervals), + timetable.WithFocused(true), + timetable.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()), + timetable.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()), + timetable.WithStyles(p.common.Styles.TableStyle), + ) + + if len(intervals) > 0 { + newIdx := -1 + + if p.shouldSelectActive { + for i, interval := range intervals { + if interval.IsActive() { + newIdx = i + break + } + } + p.shouldSelectActive = false + } + + if newIdx == -1 && selectedStart != "" { + for i, interval := range intervals { + if interval.Start == selectedStart { + newIdx = i + break + } + } + } + + if newIdx == -1 { + newIdx = currentIdx + } + + if newIdx >= len(intervals) { + newIdx = len(intervals) - 1 + } + if newIdx < 0 { + newIdx = 0 + } + + p.intervals.SetCursor(newIdx) + } +} + +type intervalsMsg timewarrior.Intervals + +func (p *TimePage) getIntervals() tea.Cmd { + return func() tea.Msg { + // ":day" is a timewarrior hint for "today" + intervals := p.common.TimeW.GetIntervals(":day") + return intervalsMsg(intervals) + } +} diff --git a/timewarrior/models.go b/timewarrior/models.go index c7fa638..8816677 100644 --- a/timewarrior/models.go +++ b/timewarrior/models.go @@ -1,7 +1,6 @@ package timewarrior import ( - "encoding/json" "fmt" "log/slog" "math" diff --git a/timewarrior/timewarrior.go b/timewarrior/timewarrior.go index 12f54d8..daf52db 100644 --- a/timewarrior/timewarrior.go +++ b/timewarrior/timewarrior.go @@ -12,7 +12,6 @@ import ( "slices" "strings" "sync" - "time" ) const ( @@ -28,6 +27,7 @@ type TimeWarrior interface { StartTracking(tags []string) error StopTracking() error ContinueTracking() error + ContinueInterval(id int) error CancelTracking() error DeleteInterval(id int) error ModifyInterval(interval *Interval) error @@ -122,9 +122,12 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { return nil } - // Assign IDs based on reverse chronological order + // Reverse the intervals to show newest first + slices.Reverse(intervals) + + // Assign IDs based on new order (newest is @1) for i := range intervals { - intervals[i].ID = len(intervals) - i + intervals[i].ID = i + 1 } return intervals @@ -176,6 +179,19 @@ func (ts *TimeSquire) ContinueTracking() error { return nil } +func (ts *TimeSquire) ContinueInterval(id int) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...) + if err := cmd.Run(); err != nil { + slog.Error("Failed continuing interval:", err) + return err + } + + return nil +} + func (ts *TimeSquire) CancelTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock()