fix selection on empty panes

This commit is contained in:
booleanmaybe 2026-01-22 19:24:53 -05:00
parent c732bf751f
commit 2a6761d04c
8 changed files with 269 additions and 13 deletions

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
.gocache
.gomodcache
go-build-cache
# IntelliJ IDEA
.idea/
@ -12,7 +13,7 @@ out/
# AI
.cursor
.cursorindexingignore
.specstory/
.specstory
.claude
# Tiki binary

View file

@ -219,7 +219,7 @@ func (pc *PluginController) HandleSearch(query string) {
}
pc.pluginConfig.SetSearchResults(results, query)
if pc.selectFirstSearchPane() {
if pc.selectFirstNonEmptyPane() {
return
}
}
@ -297,18 +297,31 @@ func (pc *PluginController) selectTaskInPane(pane int, taskID string) {
pc.pluginConfig.SetSelectedIndexForPane(pane, targetIndex)
}
func (pc *PluginController) selectFirstSearchPane() bool {
func (pc *PluginController) selectFirstNonEmptyPane() bool {
for pane := range pc.pluginDef.Panes {
tasks := pc.GetFilteredTasksForPane(pane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedPane(pane)
pc.pluginConfig.SetSelectedIndexForPane(pane, 0)
pc.pluginConfig.SetSelectedPaneAndIndex(pane, 0)
return true
}
}
return false
}
func (pc *PluginController) EnsureFirstNonEmptyPaneSelection() bool {
if pc.pluginDef == nil {
return false
}
currentPane := pc.pluginConfig.GetSelectedPane()
if currentPane >= 0 && currentPane < len(pc.pluginDef.Panes) {
tasks := pc.GetFilteredTasksForPane(currentPane)
if len(tasks) > 0 {
return false
}
}
return pc.selectFirstNonEmptyPane()
}
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
if updated == nil {
return

View file

@ -0,0 +1,137 @@
package controller
import (
"testing"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/plugin/filter"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
func TestEnsureFirstNonEmptyPaneSelectionSelectsFirstTask(t *testing.T) {
taskStore := store.NewInMemoryStore()
if err := taskStore.CreateTask(&task.Task{
ID: "T-1",
Title: "Task 1",
Status: task.StatusTodo,
Type: task.TypeStory,
}); err != nil {
t.Fatalf("create task: %v", err)
}
if err := taskStore.CreateTask(&task.Task{
ID: "T-2",
Title: "Task 2",
Status: task.StatusTodo,
Type: task.TypeStory,
}); err != nil {
t.Fatalf("create task: %v", err)
}
emptyFilter, err := filter.ParseFilter("status = 'done'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
todoFilter, err := filter.ParseFilter("status = 'todo'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
{Name: "Empty", Columns: 1, Filter: emptyFilter},
{Name: "Todo", Columns: 1, Filter: todoFilter},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1, 1})
pluginConfig.SetSelectedPane(0)
pluginConfig.SetSelectedIndexForPane(0, 1)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc.EnsureFirstNonEmptyPaneSelection()
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
}
if pluginConfig.GetSelectedIndexForPane(1) != 0 {
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForPane(1))
}
}
func TestEnsureFirstNonEmptyPaneSelectionKeepsCurrentPane(t *testing.T) {
taskStore := store.NewInMemoryStore()
if err := taskStore.CreateTask(&task.Task{
ID: "T-1",
Title: "Task 1",
Status: task.StatusTodo,
Type: task.TypeStory,
}); err != nil {
t.Fatalf("create task: %v", err)
}
todoFilter, err := filter.ParseFilter("status = 'todo'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
{Name: "First", Columns: 1, Filter: todoFilter},
{Name: "Second", Columns: 1, Filter: todoFilter},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1, 1})
pluginConfig.SetSelectedPane(1)
pluginConfig.SetSelectedIndexForPane(1, 0)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc.EnsureFirstNonEmptyPaneSelection()
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
}
if pluginConfig.GetSelectedIndexForPane(1) != 0 {
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForPane(1))
}
}
func TestEnsureFirstNonEmptyPaneSelectionNoTasks(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter, err := filter.ParseFilter("status = 'done'")
if err != nil {
t.Fatalf("parse filter: %v", err)
}
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
{Name: "Empty", Columns: 1, Filter: emptyFilter},
{Name: "StillEmpty", Columns: 1, Filter: emptyFilter},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1, 1})
pluginConfig.SetSelectedPane(1)
pluginConfig.SetSelectedIndexForPane(1, 2)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc.EnsureFirstNonEmptyPaneSelection()
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
}
if pluginConfig.GetSelectedIndexForPane(1) != 2 {
t.Fatalf("expected selected index 2, got %d", pluginConfig.GetSelectedIndexForPane(1))
}
}

View file

@ -125,6 +125,26 @@ func (pc *PluginConfig) SetSelectedIndexForPane(pane int, idx int) {
pc.notifyListeners()
}
func (pc *PluginConfig) SetSelectedPaneAndIndex(pane int, idx int) {
pc.mu.Lock()
if pane < 0 || pane >= len(pc.selectedIndices) {
pc.mu.Unlock()
return
}
if len(pc.selectedIndices) == 0 {
pc.mu.Unlock()
return
}
changed := pc.selectedPane != pane || pc.selectedIndices[pane] != idx
pc.selectedPane = pane
pc.selectedIndices[pane] = idx
pc.mu.Unlock()
if changed {
pc.notifyListeners()
}
}
// GetColumnsForPane returns the number of grid columns for a pane.
func (pc *PluginConfig) GetColumnsForPane(pane int) int {
pc.mu.RLock()

View file

@ -3,8 +3,17 @@ foreground: "#e1bee7"
background: "#4a148c"
key: "F2"
panes:
- name: Roadmap
columns: 4
filter: type = 'epic'
- name: Now
columns: 2
filter: type = 'epic' AND status = 'todo'
action: status = 'todo'
- name: Next
columns: 1
filter: type = 'epic' AND status = 'backlog' AND priority = 1
action: status = 'backlog', priority = 1
- name: Later
columns: 1
filter: type = 'epic' AND status = 'backlog' AND priority > 1
action: status = 'backlog', priority = 2
sort: Priority, Points DESC
view: expanded

View file

@ -91,6 +91,7 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
pluginConfig,
tikiPlugin,
tikiController.GetFilteredTasksForPane,
tikiController.EnsureFirstNonEmptyPaneSelection,
)
} else {
// Fallback if controller type doesn't match

View file

@ -30,6 +30,7 @@ type PluginView struct {
storeListenerID int
selectionListenerID int
getPaneTasks func(pane int) []*task.Task // injected from controller
ensureSelection func() bool // injected from controller
}
// NewPluginView creates a plugin view
@ -38,6 +39,7 @@ func NewPluginView(
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
getPaneTasks func(pane int) []*task.Task,
ensureSelection func() bool,
) *PluginView {
pv := &PluginView{
taskStore: taskStore,
@ -45,6 +47,7 @@ func NewPluginView(
pluginDef: pluginDef,
registry: controller.PluginViewActions(),
getPaneTasks: getPaneTasks,
ensureSelection: ensureSelection,
}
pv.build()
@ -100,6 +103,9 @@ func (pv *PluginView) rebuildLayout() {
func (pv *PluginView) refresh() {
viewMode := pv.pluginConfig.GetViewMode()
if pv.ensureSelection != nil {
pv.ensureSelection()
}
// update item height based on view mode
itemHeight := config.TaskBoxHeight

View file

@ -0,0 +1,69 @@
package view
import (
"fmt"
"testing"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) {
taskStore := store.NewInMemoryStore()
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1})
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
{Name: "Pane", Columns: 1},
},
}
tasks := make([]*task.Task, 10)
for i := range tasks {
tasks[i] = &task.Task{
ID: fmt.Sprintf("T-%d", i),
Title: fmt.Sprintf("Task %d", i),
Status: task.StatusTodo,
Type: task.TypeStory,
}
}
pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(pane int) []*task.Task {
return tasks
}, nil)
if len(pv.paneBoxes) != 1 {
t.Fatalf("expected 1 pane box, got %d", len(pv.paneBoxes))
}
pane := pv.paneBoxes[0]
itemHeight := config.TaskBoxHeight
pane.SetRect(0, 0, 80, itemHeight*5)
pluginConfig.SetSelectedIndexForPane(0, len(tasks)-1)
pv.refresh()
expectedScrollOffset := len(tasks) - 5
if pane.scrollOffset != expectedScrollOffset {
t.Fatalf("expected scrollOffset %d, got %d", expectedScrollOffset, pane.scrollOffset)
}
paneBefore := pane
pluginConfig.SetSelectedIndexForPane(0, len(tasks)-2)
pv.refresh()
if pv.paneBoxes[0] != paneBefore {
t.Fatalf("expected pane list to be reused across refresh")
}
if pane.scrollOffset != expectedScrollOffset {
t.Fatalf("expected scrollOffset to remain %d, got %d", expectedScrollOffset, pane.scrollOffset)
}
}