summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 19:24:09 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 19:24:09 +0200
commit30c955ef113e5e0c99c147ee4e7c8c20b0a7f273 (patch)
treef872b7c44e62609a455dbed356fcc28ab3b53531 /internal
parent33064821d637aeef94fe6ee96edbac7a503c0692 (diff)
Migrate UI stack to Bubble Tea v2
Diffstat (limited to 'internal')
-rw-r--r--internal/atable/table.go70
-rw-r--r--internal/ui/handlers.go146
-rw-r--r--internal/ui/keyhandlers.go118
-rw-r--r--internal/ui/table.go172
-rw-r--r--internal/ui/table_test.go104
-rw-r--r--internal/ui/taskdetail.go14
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))