summaryrefslogtreecommitdiff
path: root/internal/ui
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/ui
parent33064821d637aeef94fe6ee96edbac7a503c0692 (diff)
Migrate UI stack to Bubble Tea v2
Diffstat (limited to 'internal/ui')
-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
5 files changed, 290 insertions, 264 deletions
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.blinkO