diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 19:24:09 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 19:24:09 +0200 |
| commit | 30c955ef113e5e0c99c147ee4e7c8c20b0a7f273 (patch) | |
| tree | f872b7c44e62609a455dbed356fcc28ab3b53531 /internal | |
| parent | 33064821d637aeef94fe6ee96edbac7a503c0692 (diff) | |
Migrate UI stack to Bubble Tea v2
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/atable/table.go | 70 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 146 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 118 | ||||
| -rw-r--r-- | internal/ui/table.go | 172 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 104 | ||||
| -rw-r--r-- | internal/ui/taskdetail.go | 14 |
6 files changed, 328 insertions, 296 deletions
diff --git a/internal/atable/table.go b/internal/atable/table.go index 44f1c9f..d31088e 100644 --- a/internal/atable/table.go +++ b/internal/atable/table.go @@ -4,11 +4,11 @@ package table import ( "strings" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) @@ -150,7 +150,7 @@ func New(opts ...Option) Model { m := Model{ cursor: 0, colCursor: 0, - viewport: viewport.New(0, 20), //nolint:mnd + viewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(20)), //nolint:mnd KeyMap: DefaultKeyMap(), Help: help.New(), @@ -184,14 +184,14 @@ func WithRows(rows []Row) Option { // WithHeight sets the height of the table. func WithHeight(h int) Option { return func(m *Model) { - m.viewport.Height = h - lipgloss.Height(m.headersView()) + m.viewport.SetHeight(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 + m.viewport.SetWidth(w) } } @@ -230,20 +230,21 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } switch msg := msg.(type) { - case tea.KeyMsg: + case tea.KeyPressMsg: + height := m.viewport.Height() 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) + m.MoveUp(height) case key.Matches(msg, m.KeyMap.PageDown): - m.MoveDown(m.viewport.Height) + m.MoveDown(height) case key.Matches(msg, m.KeyMap.HalfPageUp): - m.MoveUp(m.viewport.Height / 2) //nolint:mnd + m.MoveUp(height / 2) //nolint:mnd case key.Matches(msg, m.KeyMap.HalfPageDown): - m.MoveDown(m.viewport.Height / 2) //nolint:mnd + m.MoveDown(height / 2) //nolint:mnd case key.Matches(msg, m.KeyMap.GotoTop): m.GotoTop() case key.Matches(msg, m.KeyMap.GotoBottom): @@ -296,16 +297,17 @@ func (m Model) HelpView() string { // columns and rows. func (m *Model) UpdateViewport() { renderedRows := make([]string, 0, len(m.rows)) + height := m.viewport.Height() // 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) + m.start = clamp(m.cursor-height, 0, m.cursor) } else { m.start = 0 } - m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows)) + m.end = clamp(m.cursor+height, m.cursor, len(m.rows)) for i := m.start; i < m.end; i++ { renderedRows = append(renderedRows, m.renderRow(i)) } @@ -349,24 +351,24 @@ func (m *Model) SetColumns(c []Column) { // SetWidth sets the width of the viewport of the table. func (m *Model) SetWidth(w int) { - m.viewport.Width = w + m.viewport.SetWidth(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.viewport.SetHeight(h - lipgloss.Height(m.headersView())) m.UpdateViewport() } // Height returns the viewport height of the table. func (m Model) Height() int { - return m.viewport.Height + return m.viewport.Height() } // Width returns the viewport width of the table. func (m Model) Width() int { - return m.viewport.Width + return m.viewport.Width() } // Cursor returns the index of the selected row. @@ -395,13 +397,15 @@ func (m *Model) SetColumnCursor(n int) { // 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) + yOffset := m.viewport.YOffset() + height := m.viewport.Height() 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.viewport.SetYOffset(clamp(yOffset, 0, m.cursor)) + case m.start < height: + m.viewport.SetYOffset(clamp(clamp(yOffset+n, 0, m.cursor), 0, height)) + case yOffset >= 1: + m.viewport.SetYOffset(clamp(yOffset+n, 1, height)) } m.UpdateViewport() } @@ -411,15 +415,17 @@ func (m *Model) MoveUp(n int) { func (m *Model) MoveDown(n int) { m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) m.UpdateViewport() + yOffset := m.viewport.YOffset() + height := m.viewport.Height() 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)) + case m.end == len(m.rows) && yOffset > 0: + m.viewport.SetYOffset(clamp(yOffset-n, 1, height)) + case m.cursor > (m.end-m.start)/2 && yOffset > 0: + m.viewport.SetYOffset(clamp(yOffset-n, 1, m.cursor)) + case yOffset > 1: + case m.cursor > yOffset+height-1: + m.viewport.SetYOffset(clamp(yOffset+1, 0, 1)) } } @@ -529,7 +535,7 @@ func addSpacingStyled(cells []string, style lipgloss.Style) []string { spaced := make([]string, 0, len(cells)*2-1) for i, cell := range cells { if i > 0 { - spaced = append(spaced, style.Copy().Padding(0, 0).Render(" ")) + spaced = append(spaced, style.Padding(0, 0).Render(" ")) } spaced = append(spaced, cell) } diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 11a78cd..3122a34 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -6,17 +6,17 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "codeberg.org/snonux/tasksamurai/internal/task" ) // handleTextInput provides generic text input handling for all input modes -func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter func(string) error, onExit func()) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: +func (m *Model) handleTextInput(msg tea.KeyPressMsg, input *textinput.Model, onEnter func(string) error, onExit func()) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": value := input.Value() if err := onEnter(value); err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) @@ -29,7 +29,7 @@ func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter onExit() m.updateTableHeight() return m, nil - case tea.KeyEsc: + case "esc": input.Blur() onExit() m.updateTableHeight() @@ -41,13 +41,13 @@ func (m *Model) handleTextInput(msg tea.KeyMsg, input *textinput.Model, onEnter } // handleAnnotationMode handles keyboard input when in annotation mode -func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleAnnotationMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { // Annotation can be empty when replacing (to remove all) if !m.replaceAnnotations && strings.TrimSpace(value) == "" { return fmt.Errorf("annotation cannot be empty") } - + if m.replaceAnnotations { if err := task.ReplaceAnnotations(m.annotateID, value); err != nil { return err @@ -61,14 +61,14 @@ func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { _ = m.reload() return nil } - + onExit := func() { m.annotating = false m.replaceAnnotations = false } - + model, cmd := m.handleTextInput(msg, &m.annotateInput, onEnter, onExit) - if msg.Type == tea.KeyEnter && m.annotateInput.Value() != "" { + if msg.String() == "enter" && m.annotateInput.Value() != "" { // Start blink after successful annotation return model, m.startBlink(m.annotateID, false) } @@ -76,7 +76,7 @@ func (m *Model) handleAnnotationMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleDescriptionMode handles keyboard input when editing description -func (m *Model) handleDescriptionMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleDescriptionMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { if err := validateDescription(value); err != nil { return err @@ -87,20 +87,20 @@ func (m *Model) handleDescriptionMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { _ = m.reload() return nil } - + onExit := func() { m.descEditing = false } - + model, cmd := m.handleTextInput(msg, &m.descInput, onEnter, onExit) - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { return model, m.startBlink(m.descID, false) } return model, cmd } // handleTagsMode handles keyboard input when editing tags -func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleTagsMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { words := strings.Fields(value) var adds, removes []string @@ -136,13 +136,13 @@ func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { _ = m.reload() return nil } - + onExit := func() { m.tagsEditing = false } - + model, cmd := m.handleTextInput(msg, &m.tagsInput, onEnter, onExit) - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { if m.showTaskDetail { // In detail view, blink the tags field return model, m.startDetailBlink(4) // Tags is field index 4 @@ -153,9 +153,9 @@ func (m *Model) handleTagsMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleDueEditMode handles due date editing -func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: +func (m *Model) handleDueEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { @@ -174,12 +174,12 @@ func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.updateTableHeight() return m, cmd - case tea.KeyEsc: + case "esc": m.dueEditing = false m.updateTableHeight() return m, nil } - + switch msg.String() { case "h", "left": m.dueDate = m.dueDate.AddDate(0, 0, -1) @@ -194,7 +194,7 @@ func (m *Model) handleDueEditMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleRecurrenceMode handles recurrence editing -func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { if err := validateRecurrence(value); err != nil { return err @@ -205,13 +205,13 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { _ = m.reload() return nil } - + onExit := func() { m.recurEditing = false } - + model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit) - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { if m.showTaskDetail { // In detail view, blink the recurrence field (dynamic index) // Need to calculate the index based on whether recurrence field exists @@ -226,18 +226,18 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleProjectMode handles project editing -func (m *Model) handleProjectMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleProjectMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { return task.SetProject(m.projID, value) } - + onExit := func() { m.projEditing = false m.reload() } - + model, cmd := m.handleTextInput(msg, &m.projInput, onEnter, onExit) - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { if m.showTaskDetail { // In detail view, blink the project field return model, m.startDetailBlink(fieldProject) // Project field index in detail view @@ -248,9 +248,9 @@ func (m *Model) handleProjectMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handlePriorityMode handles priority selection -func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: +func (m *Model) handlePriorityMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": priority := priorityOptions[m.priorityIndex] if err := validatePriority(priority); err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) @@ -277,12 +277,12 @@ func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.updateTableHeight() return m, cmd - case tea.KeyEsc: + case "esc": m.prioritySelecting = false m.updateTableHeight() return m, nil } - + switch msg.String() { case "h", "left": m.priorityIndex = (m.priorityIndex + len(priorityOptions) - 1) % len(priorityOptions) @@ -293,29 +293,29 @@ func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleFilterMode handles filter editing -func (m *Model) handleFilterMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleFilterMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { onEnter := func(value string) error { m.filters = strings.Fields(value) _ = m.reload() return nil } - + onExit := func() { m.filterEditing = false } - + return m.handleTextInput(msg, &m.filterInput, onEnter, onExit) } // handleAddTaskMode handles adding a new task -func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: +func (m *Model) handleAddTaskMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": oldIDs := make(map[int]struct{}, len(m.tasks)) for _, tsk := range m.tasks { oldIDs[tsk.ID] = struct{}{} } - + if err := task.AddLine(m.addInput.Value()); err != nil { m.statusMsg = fmt.Sprintf("Error: %v", err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { @@ -323,11 +323,11 @@ func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) return m, cmd } - + m.addingTask = false m.addInput.Blur() m.reload() - + // Find the newly added task var newID int row := -1 @@ -338,7 +338,7 @@ func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { break } } - + m.updateTableHeight() if row >= 0 { prevRow := m.tbl.Cursor() @@ -349,23 +349,23 @@ func (m *Model) handleAddTaskMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.startBlink(newID, false) } return m, nil - - case tea.KeyEsc: + + case "esc": m.addingTask = false m.addInput.Blur() m.updateTableHeight() return m, nil } - + var cmd tea.Cmd m.addInput, cmd = m.addInput.Update(msg) return m, cmd } // handleSearchMode handles search input -func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: +func (m *Model) handleSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": pattern := m.searchInput.Value() if pattern != "" { // Check cache first @@ -388,7 +388,7 @@ func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.searchInput.Blur() m.reload() m.updateTableHeight() - + if len(m.searchMatches) > 0 { match := m.searchMatches[m.searchIndex] prevRow := m.tbl.Cursor() @@ -398,23 +398,23 @@ func (m *Model) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) } return m, nil - - case tea.KeyEsc: + + case "esc": m.searching = false m.searchInput.Blur() m.updateTableHeight() return m, nil } - + var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) return m, cmd } // handleHelpSearchMode handles search input in help mode -func (m *Model) handleHelpSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: +func (m *Model) handleHelpSearchMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": pattern := m.helpSearchInput.Value() if pattern != "" { // Check cache first @@ -435,7 +435,7 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.helpSearching = false m.helpSearchInput.Blur() - + // Find matching help lines m.helpSearchMatches = nil if m.helpSearchRegex != nil { @@ -451,13 +451,13 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil - - case tea.KeyEsc: + + case "esc": m.helpSearching = false m.helpSearchInput.Blur() return m, nil } - + var cmd tea.Cmd m.helpSearchInput, cmd = m.helpSearchInput.Update(msg) return m, cmd @@ -465,7 +465,7 @@ func (m *Model) handleHelpSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleBlinkingState handles input when a task is blinking func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { - if _, ok := msg.(tea.KeyMsg); ok { + if _, ok := msg.(tea.KeyPressMsg); ok { // Only allow navigation while blinking prevRow := m.tbl.Cursor() prevCol := m.tbl.ColumnCursor() @@ -480,7 +480,7 @@ func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { } // handleEditingModes checks if we're in any editing mode and handles it -func (m *Model) handleEditingModes(msg tea.KeyMsg) (handled bool, model tea.Model, cmd tea.Cmd) { +func (m *Model) handleEditingModes(msg tea.KeyPressMsg) (handled bool, model tea.Model, cmd tea.Cmd) { switch { case m.annotating: model, cmd = m.handleAnnotationMode(msg) @@ -539,11 +539,11 @@ func (m *Model) getTaskAtCursor() *task.Task { } // handleTaskDetailMode handles keyboard input in task detail view -func (m *Model) handleTaskDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleTaskDetailMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.detailSearching { var cmd tea.Cmd - switch msg.Type { - case tea.KeyEnter: + switch msg.String() { + case "enter": pattern := m.detailSearchInput.Value() if pattern != "" { re, err := compileAndCacheRegex(pattern) @@ -559,7 +559,7 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.detailSearching = false m.detailSearchInput.Blur() return m, nil - case tea.KeyEsc, tea.KeyCtrlC: + case "esc", "ctrl+c": m.detailSearching = false m.detailSearchInput.Blur() return m, nil @@ -568,7 +568,7 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } } - + // Normal task detail view mode switch msg.String() { case "q", "esc": @@ -605,7 +605,7 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Check if current field is editable return m.handleDetailFieldEdit() } - + return m, nil } @@ -765,4 +765,4 @@ func (m *Model) activateDescriptionEdit(id int, tsk *task.Task) (tea.Model, tea. m.descInput.Focus() m.updateTableHeight() return m, nil -}
\ No newline at end of file +} diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 0a2bc62..9eedafa 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -7,15 +7,15 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" "codeberg.org/snonux/tasksamurai/internal/task" ) // handleNormalMode handles keyboard input in normal mode (not editing) -func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // If help is shown, handle special cases if m.showHelp { switch msg.String() { @@ -36,7 +36,7 @@ func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "pgup", "b": m.helpViewport.PageUp() return m, nil - case "pgdown", " ": + case "pgdown", "space": m.helpViewport.PageDown() return m, nil case "g", "home": @@ -50,7 +50,7 @@ func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } } - + switch msg.String() { case "H": return m.handleToggleHelp() @@ -98,7 +98,7 @@ func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleToggleDisco() case "B": return m.handleToggleBlink() - case " ": + case "space": return m.handleRefresh() case "/", "?": return m.handleSearch() @@ -123,7 +123,7 @@ func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) { m.showHelp = true // Initialize help viewport with proper dimensions - width := m.tbl.Width() - 4 // Account for padding + width := m.tbl.Width() - 4 // Account for padding height := m.windowHeight - 6 // Leave room for status bars and search input if width <= 0 { width = 80 // Default width @@ -131,7 +131,7 @@ func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) { if height <= 0 { height = 20 // Default height } - m.helpViewport = viewport.New(width, height) + m.helpViewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) // Set the content immediately content := m.buildHelpContent() m.helpViewport.SetContent(content) @@ -187,7 +187,7 @@ func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + // Check if task is started started := false for _, tsk := range m.tasks { @@ -196,7 +196,7 @@ func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { break } } - + if started { if err := task.Stop(id); err != nil { m.showError(err) @@ -208,7 +208,7 @@ func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) { return m, nil } } - + m.reload() return m, m.startBlink(id, false) } @@ -226,17 +226,17 @@ func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { if task == nil { return m, nil } - + url := urlRegex.FindString(task.Description) if url == "" { return m, nil } - + if err := exec.Command(m.browserCmd, url).Run(); err != nil { m.showError(fmt.Errorf("opening browser: %w", err)) return m, nil } - + return m, m.startBlink(task.ID, false) } @@ -244,21 +244,21 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { if len(m.undoStack) == 0 { return m, nil } - + uuid := m.undoStack[len(m.undoStack)-1] m.undoStack = m.undoStack[:len(m.undoStack)-1] - + if err := task.SetStatusUUID(uuid, "pending"); err != nil { m.showError(err) return m, nil } - + // Reload the task list to get the updated task with its new ID if err := m.reload(); err != nil { m.showError(err) return m, nil } - + // Find the task ID for blinking var id int var found bool @@ -269,7 +269,7 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { break } } - + // If task not found or has ID 0, try to get it directly from Taskwarrior if !found || id == 0 { // Use task export with UUID filter to get the specific task @@ -278,7 +278,7 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { filters = append(filters, m.filters...) } filters = append(filters, "status:pending") - + tasks, err := task.Export(filters...) if err == nil && len(tasks) > 0 { id = tasks[0].ID @@ -291,13 +291,13 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { } } } - + // If we still don't have a valid ID, don't try to blink if id == 0 { m.statusMsg = "Task restored" return m, nil } - + return m, m.startBlink(id, false) } @@ -306,7 +306,7 @@ func (m *Model) handleSetDueDate() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + m.clearEditingModes() m.dueID = id m.dueEditing = true @@ -320,13 +320,13 @@ func (m *Model) handleRemoveDueDate() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + // In Taskwarrior, passing an empty value to due: removes the due date if err := task.SetDueDate(id, ""); err != nil { m.showError(err) return m, nil } - + m.reload() return m, m.startBlink(id, false) } @@ -336,15 +336,15 @@ func (m *Model) handleRandomDueDate() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + days := rand.Intn(31) + 7 due := time.Now().AddDate(0, 0, days).Format("2006-01-02") - + if err := task.SetDueDate(id, due); err != nil { m.showError(err) return m, nil } - + m.reload() return m, m.startBlink(id, false) } @@ -354,12 +354,12 @@ func (m *Model) handleSetRecurrence() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + task := m.getTaskAtCursor() if task == nil { return m, nil } - + m.clearEditingModes() m.recurID = id m.recurEditing = true @@ -374,7 +374,7 @@ func (m *Model) handleSetPriority() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + m.clearEditingModes() m.priorityID = id m.prioritySelecting = true @@ -388,7 +388,7 @@ func (m *Model) handleAnnotate(replace bool) (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + m.clearEditingModes() m.annotateID = id m.annotating = true @@ -422,7 +422,7 @@ func (m *Model) handleEditTags() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + m.clearEditingModes() m.tagsID = id m.tagsEditing = true @@ -437,11 +437,11 @@ func (m *Model) handleEditProject() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + m.clearEditingModes() m.projID = id m.projEditing = true - + // Get current project value task := m.getTaskAtCursor() if task != nil { @@ -459,29 +459,29 @@ func (m *Model) handleTagToProject() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + // Get the task at cursor currentTask := m.getTaskAtCursor() if currentTask == nil || len(currentTask.Tags) == 0 { // No tags to convert return m, nil } - + // Get the first tag firstTag := currentTask.Tags[0] - + // Set the tag as project if err := task.SetProject(id, firstTag); err != nil { m.showError(err) return m, nil } - + // Remove the tag from the task if err := task.RemoveTags(id, []string{firstTag}); err != nil { m.showError(err) return m, nil } - + m.reload() return m, m.startBlink(id, false) } @@ -533,7 +533,7 @@ func (m *Model) handleNextSearchMatch() (tea.Model, tea.Cmd) { if len(m.searchMatches) == 0 { return m, nil } - + m.searchIndex = (m.searchIndex + 1) % len(m.searchMatches) match := m.searchMatches[m.searchIndex] prevRow := m.tbl.Cursor() @@ -548,7 +548,7 @@ func (m *Model) handlePrevSearchMatch() (tea.Model, tea.Cmd) { if len(m.searchMatches) == 0 { return m, nil } - + m.searchIndex = (m.searchIndex - 1 + len(m.searchMatches)) % len(m.searchMatches) match := m.searchMatches[m.searchIndex] prevRow := m.tbl.Cursor() @@ -572,7 +572,7 @@ func (m *Model) handleNextHelpSearchMatch() (tea.Model, tea.Cmd) { if len(m.helpSearchMatches) == 0 { return m, nil } - + m.helpSearchIndex = (m.helpSearchIndex + 1) % len(m.helpSearchMatches) // In the future, we could add visual indication of current match return m, nil @@ -582,7 +582,7 @@ func (m *Model) handlePrevHelpSearchMatch() (tea.Model, tea.Cmd) { if len(m.helpSearchMatches) == 0 { return m, nil } - + m.helpSearchIndex = (m.helpSearchIndex - 1 + len(m.helpSearchMatches)) % len(m.helpSearchMatches) // In the future, we could add visual indication of current match return m, nil @@ -593,7 +593,7 @@ func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) { if err != nil { return m, nil } - + // Find the task with this ID for i := range m.tasks { if m.tasks[i].ID == id { @@ -607,11 +607,11 @@ func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) { m.detailBlinkCount = 0 m.detailSearchInput = textinput.New() m.detailSearchInput.Placeholder = "Search..." - m.detailSearchInput.Width = 30 + m.detailSearchInput.SetWidth(30) break } } - + return m, nil } @@ -661,7 +661,7 @@ func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) handleTableNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *Model) handleTableNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { prevRow := m.tbl.Cursor() prevCol := m.tbl.ColumnCursor() var cmd tea.Cmd @@ -684,22 +684,22 @@ func (m *Model) handleJumpToRandomTask() (tea.Model, tea.Cmd) { m.statusMsg = "No tasks to jump to" return m, nil } - + // Pick a random index randomIndex := rand.Intn(len(m.tasks)) - + // Update cursor position prevRow := m.tbl.Cursor() prevCol := m.tbl.ColumnCursor() m.tbl.SetCursor(randomIndex) m.updateSelectionHighlight(prevRow, randomIndex, prevCol, m.tbl.ColumnCursor()) - + // Blink the task to indicate jump if randomIndex < len(m.tasks) { taskID := m.tasks[randomIndex].ID return m, m.startBlink(taskID, false) } - + return m, nil } @@ -712,27 +712,27 @@ func (m *Model) handleJumpToRandomTaskNoDue() (tea.Model, tea.Cmd) { noDueTasks = append(noDueTasks, i) } } - + if len(noDueTasks) == 0 { m.statusMsg = "No tasks without due date to jump to" return m, nil } - + // Pick a random task from the no-due list randomChoice := rand.Intn(len(noDueTasks)) randomIndex := noDueTasks[randomChoice] - + // Update cursor position prevRow := m.tbl.Cursor() prevCol := m.tbl.ColumnCursor() m.tbl.SetCursor(randomIndex) m.updateSelectionHighlight(prevRow, randomIndex, prevCol, m.tbl.ColumnCursor()) - + // Blink the task to indicate jump if randomIndex < len(m.tasks) { taskID := m.tasks[randomIndex].ID return m, m.startBlink(taskID, false) } - + return m, nil -}
\ No newline at end of file +} diff --git a/internal/ui/table.go b/internal/ui/table.go index 05c0548..1d593a0 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -11,10 +11,10 @@ import ( "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "codeberg.org/snonux/tasksamurai/internal" atable "codeberg.org/snonux/tasksamurai/internal/atable" @@ -64,18 +64,18 @@ type searchState struct { // Blink fields here are separate from blinkState because they drive a // per-field highlight inside the detail view rather than a table row. type detailViewState struct { - showTaskDetail bool - currentTaskDetail *task.Task - detailSearching bool - detailSearchInput textinput.Model - detailSearchRegex *regexp.Regexp - detailFieldIndex int // currently selected field (-1 = none) - detailBlinkField int // field currently blinking (-1 = none) - detailBlinkOn bool // whether the blink is currently on - detailBlinkCount int // number of blink cycles completed so far + showTaskDetail bool + currentTaskDetail *task.Task + detailSearching bool + detailSearchInput textinput.Model + detailSearchRegex *regexp.Regexp + detailFieldIndex int // currently selected field (-1 = none) + detailBlinkField int // field currently blinking (-1 = none) + detailBlinkOn bool // whether the blink is currently on + detailBlinkCount int // number of blink cycles completed so far // detailDescEditing lives here (not in editState) because it drives an // external-editor launch from the detail overlay, not inline text input. - detailDescEditing bool // whether the description editor is open + detailDescEditing bool // whether the description editor is open } // editState holds inline field-editing state for the task table. @@ -153,9 +153,9 @@ type Model struct { inProgress int due int - filters []string - tasks []task.Task - undoStack []string + filters []string + tasks []task.Task + undoStack []string browserCmd string theme Theme @@ -171,8 +171,8 @@ type Model struct { type editDoneMsg struct{ err error } // descEditDoneMsg is emitted when the external editor for description finishes. -type descEditDoneMsg struct{ - err error +type descEditDoneMsg struct { + err error tempFile string } @@ -202,7 +202,7 @@ func editDescriptionCmd(description string) tea.Cmd { return descEditDoneMsg{err: err, tempFile: ""} } tmpPath := tmpFile.Name() - + // Write current description to temp file _, err = tmpFile.WriteString(description) _ = tmpFile.Close() @@ -210,19 +210,19 @@ func editDescriptionCmd(description string) tea.Cmd { _ = os.Remove(tmpPath) return descEditDoneMsg{err: err, tempFile: ""} } - + // Get editor from environment editor := os.Getenv("EDITOR") if editor == "" { editor = "vi" // fallback to vi } - + // Create the command c := exec.Command(editor, tmpPath) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr - + // Use ExecProcess to properly handle the external TUI editor return tea.ExecProcess(c, func(err error) tea.Msg { return descEditDoneMsg{err: err, tempFile: tmpPath} @@ -384,7 +384,7 @@ func (m *Model) reload() error { m.total = task.TotalTasks(tasks) m.inProgress = task.InProgressTasks(tasks) m.due = task.DueTasks(tasks, time.Now()) - + // Refresh current task detail if in detail view if m.showTaskDetail { m.refreshCurrentTaskDetail() @@ -447,12 +447,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case struct{ clearStatus bool }: m.statusMsg = "" return m, nil - case tea.KeyMsg: + case tea.KeyPressMsg: // Handle blinking state first if m.blinkID != 0 { return m.handleBlinkingState(msg) } - + // Check if we're in detail view if m.showTaskDetail { // If we're editing in detail view, let editing modes handle it @@ -464,16 +464,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Otherwise handle detail view navigation return m.handleTaskDetailMode(msg) } - + // Check if we're in any editing mode if handled, model, cmd := m.handleEditingModes(msg); handled { return model, cmd } - + // Otherwise handle normal mode return m.handleNormalMode(msg) } - + // Default case - pass through to appropriate component if m.showHelp { // Update help viewport for mouse wheel and other events @@ -481,7 +481,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.helpViewport, cmd = m.helpViewport.Update(msg) return m, cmd } - + var cmd tea.Cmd m.tbl, cmd = m.tbl.Update(msg) return m, cmd @@ -493,17 +493,17 @@ func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.windowHeight = msg.Height m.computeColumnWidths() m.updateTableHeight() - + // Update help viewport if active - if m.showHelp && m.helpViewport.Width > 0 { + if m.showHelp && m.helpViewport.Width() > 0 { width := msg.Width - 4 height := msg.Height - 6 if width > 0 && height > 0 { - m.helpViewport.Width = width - m.helpViewport.Height = height + m.helpViewport.SetWidth(width) + m.helpViewport.SetHeight(height) } } - + return m, nil } @@ -522,7 +522,7 @@ func (m *Model) handleEditDone(msg editDoneMsg) (tea.Model, tea.Cmd) { func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { m.detailDescEditing = false _ = os.Remove(msg.tempFile) // Clean up temp file - + if msg.err != nil { m.statusMsg = fmt.Sprintf("Edit error: %v", msg.err) cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg { @@ -530,7 +530,7 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { }) return m, cmd } - + // Read the edited content content, err := os.ReadFile(msg.tempFile) if err != nil { @@ -540,7 +540,7 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { }) return m, cmd } - + // Update the description newDesc := strings.TrimSpace(string(content)) if m.currentTaskDetail != nil { @@ -552,12 +552,12 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { }) return m, cmd } - + // Reload and start blinking m.reload() return m, m.startDetailBlink(m.detailDescriptionFieldIndex()) } - + return m, nil } @@ -567,7 +567,7 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { if m.showTaskDetail && m.detailBlinkField != -1 { m.detailBlinkOn = !m.detailBlinkOn m.detailBlinkCount++ - + if m.detailBlinkCount >= blinkCycles { m.detailBlinkField = -1 m.detailBlinkOn = false @@ -577,15 +577,15 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { } return m, nil } - + if m.blinkID == 0 { return m, nil } - + m.blinkOn = !m.blinkOn m.blinkCount++ m.updateBlinkRow() - + if m.blinkCount >= blinkCycles { id := m.blinkID mark := m.blinkMarkDone @@ -593,7 +593,7 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { m.blinkOn = false m.blinkCount = 0 m.blinkMarkDone = false - + if mark { for _, tsk := range m.tasks { if tsk.ID == id { @@ -608,31 +608,36 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { m.reload() return m, nil } - + return m, blinkCmd() } // View renders the table UI. -func (m Model) View() string { +func (m Model) View() tea.View { + var content string if m.showHelp { m.updateHelpContent() - return m.renderHelpScreen() - } - if m.showTaskDetail { - return m.renderTaskDetail() - } - // expandedCellView is only appended when the user has toggled the - // expanded-cell panel open; including it unconditionally caused a - // double-render whenever cellExpanded was true. - view := lipgloss.JoinVertical(lipgloss.Left, - m.topStatusLine(), - m.tbl.View(), - m.statusLine(), - ) - if m.cellExpanded { - view = lipgloss.JoinVertical(lipgloss.Left, view, m.expandedCellView()) + content = m.renderHelpScreen() + } else if m.showTaskDetail { + content = m.renderTaskDetail() + } else { + // expandedCellView is only appended when the user has toggled the + // expanded-cell panel open; including it unconditionally caused a + // double-render whenever cellExpanded was true. + view := lipgloss.JoinVertical(lipgloss.Left, + m.topStatusLine(), + m.tbl.View(), + m.statusLine(), + ) + if m.cellExpanded { + view = lipgloss.JoinVertical(lipgloss.Left, view, m.expandedCellView()) + } + content = m.appendInlineInputOverlay(view) } - return m.appendInlineInputOverlay(view) + + v := tea.NewView(content) + v.AltScreen = true + return v } // appendInlineInputOverlay appends whichever active inline-editing widget @@ -683,17 +688,17 @@ func (m Model) buildHelpContent() string { Foreground(lipgloss.Color(m.theme.HeaderFG)). Background(lipgloss.Color(m.theme.SelectedBG)). Padding(0, 1) - + keyStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(m.theme.SelectedFG)) - + descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("250")) // Light gray for readability // Build help content with styled headers var sections []string - + // Navigation section sections = append(sections, headerStyle.Render("Navigation"), m.formatHelpLine("↑/k, ↓/j", "move up/down", keyStyle, descStyle), @@ -704,7 +709,7 @@ func (m Model) buildHelpContent() string { m.formatHelpLine("1", "jump to random task", keyStyle, descStyle), m.formatHelpLine("2", "jump to random task (no due date)", keyStyle, descStyle), "") - + // Task Management section sections = append(sections, headerStyle.Render("Task Management"), m.formatHelpLine("Enter", "view task details", keyStyle, descStyle), @@ -714,7 +719,7 @@ func (m Model) buildHelpContent() string { m.formatHelpLine("U", "undo last done", keyStyle, descStyle), m.formatHelpLine("s", "start/stop task", keyStyle, descStyle), "") - + // Task Fields section sections = append(sections, headerStyle.Render("Task Fields"), m.formatHelpLine("i", "edit current field", keyStyle, descStyle), @@ -728,7 +733,7 @@ func (m Model) buildHelpContent() string { m.formatHelpLine("a, A", "add/replace annotations", keyStyle, descStyle), m.formatHelpLine("o", "open URL from description", keyStyle, descStyle), "") - + // View & Search section sections = append(sections, headerStyle.Render("View & Search"), m.formatHelpLine("f", "change filter", keyStyle, descStyle), @@ -736,20 +741,20 @@ func (m Model) buildHelpContent() string { m.formatHelpLine("n, N", "next/previous match", keyStyle, descStyle), m.formatHelpLine("space", "refresh tasks", keyStyle, descStyle), "") - + // Appearance section sections = append(sections, headerStyle.Render("Appearance"), m.formatHelpLine("c, C", "random/reset theme", keyStyle, descStyle), m.formatHelpLine("x", "toggle disco mode", keyStyle, descStyle), m.formatHelpLine("B", "toggle blinking", keyStyle, descStyle), "") - + // General section sections = append(sections, headerStyle.Render("General"), m.formatHelpLine("H", "toggle help", keyStyle, descStyle), m.formatHelpLine("ESC", "close dialogs/cancel", keyStyle, descStyle), m.formatHelpLine("q", "quit", keyStyle, descStyle)) - + // Apply search highlighting if active if m.helpSearchRegex != nil { for i, line := range sections { @@ -758,7 +763,7 @@ func (m Model) buildHelpContent() string { } } } - + // Join all sections return strings.Join(sections, "\n") } @@ -767,10 +772,10 @@ func (m Model) buildHelpContent() string { func (m Model) renderHelpScreen() string { containerStyle := lipgloss.NewStyle(). Padding(1, 2) - + // Render viewport viewportView := m.helpViewport.View() - + result := containerStyle.Render(viewportView) // Add search input at the bottom if in help search mode @@ -798,25 +803,25 @@ func (m Model) highlightHelpLine(line string) string { if m.helpSearchRegex == nil { return line } - + matches := m.helpSearchRegex.FindAllStringIndex(line, -1) if len(matches) == 0 { return line } - + highlighted := line offset := 0 highlightStyle := lipgloss.NewStyle(). Background(lipgloss.Color(m.theme.SearchBG)). Foreground(lipgloss.Color(m.theme.SearchFG)) - + for _, match := range matches { start := match[0] + offset end := match[1] + offset highlighted = highlighted[:start] + highlightStyle.Render(highlighted[start:end]) + highlighted[end:] offset += len(highlightStyle.Render(highlighted[start:end])) - (end - start) } - + return highlighted } @@ -982,7 +987,7 @@ func (m Model) highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) if loc[0] > last { b.WriteString(base.Render(raw[last:loc[0]])) } - b.WriteString(highlight.Copy().Inherit(base).Render(raw[loc[0]:loc[1]])) + b.WriteString(highlight.Inherit(base).Render(raw[loc[0]:loc[1]])) last = loc[1] } if last < len(raw) { @@ -994,7 +999,7 @@ func (m Model) highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) func (m Model) highlightCellMatch(base lipgloss.Style, re *regexp.Regexp, raw, display string) string { if re != nil && re.MatchString(raw) { highlight := lipgloss.NewStyle().Background(lipgloss.Color(m.theme.SearchBG)).Foreground(lipgloss.Color(m.theme.SearchFG)) - return highlight.Copy().Inherit(base).Render(display) + return highlight.Inherit(base).Render(display) } return base.Render(display) } @@ -1023,8 +1028,8 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty anns = append(anns, a.Description) } - cellStyle := rowStyle.Copy().Inherit(styles.Cell) - selStyle := cellStyle.Copy().Inherit(styles.Selected) + cellStyle := rowStyle.Inherit(styles.Cell) + selStyle := cellStyle.Inherit(styles.Selected) getStyle := func(col int) lipgloss.Style { if col == selectedCol { @@ -1154,7 +1159,6 @@ func (m *Model) updateTableHeight() { m.tbl.SetHeight(h) } - func (m *Model) computeColumnWidths() { maxID := 1 maxAge := 0 diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 63e1cf7..25eea72 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) func TestAnnotateHotkey(t *testing.T) { @@ -46,14 +46,14 @@ func TestAnnotateHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mp := &m // Get pointer to model - mv, _ := mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + mp := &m // Get pointer to model + mv, _ := mp.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) mp = mv.(*Model) for _, r := range "note" { - mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mv, _ = mp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) mp = mv.(*Model) } - mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) mp = mv.(*Model) data, err := os.ReadFile(annoFile) @@ -103,13 +103,15 @@ func TestReplaceAnnotationHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'A', Text: "A"}) m = *mv.(*Model) for _, r := range "new" { - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = *mv.(*Model) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) data, err := os.ReadFile(annoFile) @@ -163,10 +165,11 @@ func TestDoneHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) m = *mv.(*Model) for i := 0; i < blinkCycles; i++ { - mp := &m; mv, _ = mp.Update(blinkMsg{}) + mp := &m + mv, _ = mp.Update(blinkMsg{}) m = *mv.(*Model) } @@ -212,13 +215,15 @@ func TestUndoHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) m = *mv.(*Model) for i := 0; i < blinkCycles; i++ { - mp := &m; mv, _ = mp.Update(blinkMsg{}) + mp := &m + mv, _ = mp.Update(blinkMsg{}) m = *mv.(*Model) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'U'}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: 'U', Text: "U"}) m = *mv.(*Model) data, err := os.ReadFile(logFile) @@ -275,7 +280,7 @@ func TestOpenURLHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'o', Text: "o"}) m = *mv.(*Model) data, err := os.ReadFile(openFile) @@ -319,13 +324,15 @@ func TestDueDateHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'w', Text: "w"}) m = *mv.(*Model) for i := 0; i < 3; i++ { - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRight}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyRight}) m = *mv.(*Model) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) data, err := os.ReadFile(dueFile) @@ -371,7 +378,7 @@ func TestRandomDueDateHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) m = *mv.(*Model) data, err := os.ReadFile(dueFile) @@ -426,13 +433,15 @@ func TestRecurrenceHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'R'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'R', Text: "R"}) m = *mv.(*Model) for _, r := range "daily" { - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = *mv.(*Model) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) data, err := os.ReadFile(recFile) @@ -477,9 +486,10 @@ func TestPriorityHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) m = *mv.(*Model) - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) data, err := os.ReadFile(priFile) @@ -524,13 +534,15 @@ func TestAddHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: '+', Text: "+"}) m = *mv.(*Model) for _, r := range "foo due:today" { - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = *mv.(*Model) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) data, err := os.ReadFile(addFile) @@ -574,19 +586,21 @@ func TestNavigationHotkeys(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) m = *mv.(*Model) if m.tbl.Cursor() != 1 { t.Fatalf("down: got cursor %d", m.tbl.Cursor()) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'0'}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: '0', Text: "0"}) m = *mv.(*Model) if m.tbl.Cursor() != 0 { t.Fatalf("0 hotkey: expected 0 got %d", m.tbl.Cursor()) } - mp = &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + mp = &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: 'G', Text: "G"}) m = *mv.(*Model) if m.tbl.Cursor() != 1 { t.Fatalf("G hotkey: expected 1 got %d", m.tbl.Cursor()) @@ -631,13 +645,14 @@ func TestEscClosesHelp(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'H', Text: "H"}) m = *mv.(*Model) if !m.showHelp { t.Fatalf("help not shown") } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEsc}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) m = *mv.(*Model) if m.showHelp { t.Fatalf("esc did not close help") @@ -675,14 +690,14 @@ func TestExpandedCellViewNoDoubleRender(t *testing.T) { // With cellExpanded false (the default), the expanded content must be absent. m.cellExpanded = false viewCollapsed := m.View() - if strings.Contains(viewCollapsed, expanded) { + if strings.Contains(viewCollapsed.Content, expanded) { t.Fatalf("cellExpanded=false: expandedCellView content unexpectedly present in View()") } // With cellExpanded true, the expanded content must appear exactly once. m.cellExpanded = true viewExpanded := m.View() - count := strings.Count(viewExpanded, expanded) + count := strings.Count(viewExpanded.Content, expanded) if count != 1 { t.Fatalf("cellExpanded=true: expandedCellView content appears %d times in View(), want exactly 1", count) } @@ -699,39 +714,46 @@ func TestSearchExitHotkeys(t *testing.T) { } // enter search mode - mv, _ := (&m).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + mv, _ := (&m).Update(tea.KeyPressMsg{Code: '/', Text: "/"}) m = *mv.(*Model) for _, r := range "alpha" { - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = *mv.(*Model) } - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) if m.searchRegex == nil { t.Fatalf("search regex not set") } // escape search results with ESC - mp = &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEsc}) + mp = &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) m = *mv.(*Model) if m.searchRegex != nil { t.Fatalf("esc did not clear search") } // search again and exit with q - mp = &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + mp = &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) m = *mv.(*Model) for _, r := range "beta" { - mp := &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + mp := &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = *mv.(*Model) } - mp = &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + mp = &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = *mv.(*Model) if m.searchRegex == nil { t.Fatalf("search regex not set for q") } - mp = &m; mv, _ = mp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + mp = &m + mv, _ = mp.Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) m = *mv.(*Model) if m.searchRegex != nil { t.Fatalf("q did not clear search") diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go index 1889129..94e2ea9 100644 --- a/internal/ui/taskdetail.go +++ b/internal/ui/taskdetail.go @@ -5,7 +5,7 @@ import ( "regexp" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // wordWrap wraps text to fit within the specified width, breaking at word boundaries @@ -13,13 +13,13 @@ func wordWrap(text string, width int) []string { if width <= 0 { return []string{text} } - + var lines []string words := strings.Fields(text) if len(words) == 0 { return []string{""} } - + currentLine := words[0] for i := 1; i < len(words); i++ { word := words[i] @@ -34,7 +34,7 @@ func wordWrap(text string, width int) []string { if currentLine != "" { lines = append(lines, currentLine) } - + return lines } @@ -138,7 +138,7 @@ func (m *Model) renderDetailPriorityField(labelStyle, valueStyle lipgloss.Style, if pv == "" { pv = "-" } - ps := valueStyle.Copy() + ps := valueStyle switch t.Priority { case "H": ps = ps.Background(lipgloss.Color(m.theme.PrioHighBG)) @@ -219,7 +219,7 @@ func (m *Model) renderDetailDescription(lines []string, cf int, labelStyle, desc t := m.currentTaskDetail lines = append(lines, "") - ls, vs := labelStyle.Copy(), descStyle.Copy() + ls, vs := labelStyle, descStyle if m.detailBlinkField == cf && m.detailBlinkOn { bg := lipgloss.Color("226") ls = ls.Background(bg).Foreground(lipgloss.Color("0")) @@ -260,7 +260,7 @@ func (m *Model) renderDetailAnnotations(lines []string, cf int, labelStyle, desc return lines } lines = append(lines, "") - ls, vs := labelStyle.Copy(), descStyle.Copy() + ls, vs := labelStyle, descStyle if m.detailFieldIndex == cf { ls = ls.Background(lipgloss.Color(m.theme.SelectedBG)) vs = vs.Background(lipgloss.Color(m.theme.SelectedBG)) |
