add statusline hints

This commit is contained in:
booleanmaybe 2026-03-24 23:27:02 -04:00
parent 8a9477d3fb
commit 0fa71de797
23 changed files with 375 additions and 108 deletions

View file

@ -139,6 +139,11 @@ func (re *RecurrenceEdit) CycleNext() {
re.cycleNext()
}
// IsValueFocused returns true when the value part (weekday/day) is active.
func (re *RecurrenceEdit) IsValueFocused() bool {
return re.activePart == 1
}
// MovePartLeft moves the active part to frequency (part 0).
func (re *RecurrenceEdit) MovePartLeft() {
re.activePart = 0

View file

@ -88,13 +88,15 @@ type ColorConfig struct {
HeaderActionViewLabelColor string // tview color string for view action labels
// Statusline colors (bottom bar, powerline style)
StatuslineBg string // hex color for stat segment background, e.g. "#3a3a5c"
StatuslineFg string // hex color for stat segment text, e.g. "#cccccc"
StatuslineAccentBg string // hex color for accent segment background (first segment), e.g. "#5f87af"
StatuslineAccentFg string // hex color for accent segment text, e.g. "#1c1c2e"
StatuslineMessageFg string // hex color for right-section message text, e.g. "#ff8787"
StatuslineMessageBg string // hex color for right-section message background, e.g. "#3a3a3a"
StatuslineFillBg string // hex color for empty statusline area between segments
StatuslineBg string // hex color for stat segment background, e.g. "#3a3a5c"
StatuslineFg string // hex color for stat segment text, e.g. "#cccccc"
StatuslineAccentBg string // hex color for accent segment background (first segment), e.g. "#5f87af"
StatuslineAccentFg string // hex color for accent segment text, e.g. "#1c1c2e"
StatuslineInfoFg string // hex color for info message text
StatuslineInfoBg string // hex color for info message background
StatuslineErrorFg string // hex color for error message text
StatuslineErrorBg string // hex color for error message background
StatuslineFillBg string // hex color for empty statusline area between segments
}
// DefaultColors returns the default color configuration
@ -195,13 +197,15 @@ func DefaultColors() *ColorConfig {
HeaderActionViewLabelColor: "#808080", // gray for view-specific labels
// Statusline (Nord theme)
StatuslineBg: "#434c5e", // Nord polar night 3
StatuslineFg: "#d8dee9", // Nord snow storm 1
StatuslineAccentBg: "#5e81ac", // Nord frost blue
StatuslineAccentFg: "#2e3440", // Nord polar night 1
StatuslineMessageFg: "#bf616a", // Nord aurora red
StatuslineMessageBg: "#3b4252", // Nord polar night 2
StatuslineFillBg: "#3b4252", // Nord polar night 2
StatuslineBg: "#434c5e", // Nord polar night 3
StatuslineFg: "#d8dee9", // Nord snow storm 1
StatuslineAccentBg: "#5e81ac", // Nord frost blue
StatuslineAccentFg: "#2e3440", // Nord polar night 1
StatuslineInfoFg: "#a3be8c", // Nord aurora green
StatuslineInfoBg: "#3b4252", // Nord polar night 2
StatuslineErrorFg: "#bf616a", // Nord aurora red
StatuslineErrorBg: "#3b4252", // Nord polar night 2
StatuslineFillBg: "#3b4252", // Nord polar night 2
}
}

View file

@ -30,6 +30,7 @@ func NewDepsController(
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
) *DepsController {
return &DepsController{
pluginBase: pluginBase{
@ -37,6 +38,7 @@ func NewDepsController(
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
statusline: statusline,
registry: DepsViewActions(),
},
}

View file

@ -47,7 +47,7 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
pluginConfig.SetLaneLayout([]int{1, 2, 1}, nil)
nav := newMockNavigationController()
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nav)
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nav, nil)
return dc, taskStore
}

View file

@ -1,6 +1,7 @@
package controller
import (
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
)
@ -9,6 +10,7 @@ import (
type DokiController struct {
pluginDef *plugin.DokiPlugin
navController *NavigationController
statusline *model.StatuslineConfig
registry *ActionRegistry
}
@ -16,10 +18,12 @@ type DokiController struct {
func NewDokiController(
pluginDef *plugin.DokiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
) *DokiController {
return &DokiController{
pluginDef: pluginDef,
navController: navController,
statusline: statusline,
registry: DokiViewActions(),
}
}

View file

@ -46,6 +46,7 @@ type InputRouter struct {
pluginControllers map[string]PluginControllerInterface // keyed by plugin name
globalActions *ActionRegistry
taskStore store.Store
statusline *model.StatuslineConfig
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
}
@ -55,6 +56,7 @@ func NewInputRouter(
taskController *TaskController,
pluginControllers map[string]PluginControllerInterface,
taskStore store.Store,
statusline *model.StatuslineConfig,
) *InputRouter {
return &InputRouter{
navController: navController,
@ -63,6 +65,7 @@ func NewInputRouter(
pluginControllers: pluginControllers,
globalActions: DefaultGlobalActions(),
taskStore: taskStore,
statusline: statusline,
}
}
@ -235,7 +238,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
pluginConfig.SetViewMode(vm)
}
ctrl := NewDepsController(ir.taskStore, pluginConfig, pluginDef, ir.navController)
ctrl := NewDepsController(ir.taskStore, pluginConfig, pluginDef, ir.navController, ir.statusline)
if ir.registerPlugin != nil {
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)

View file

@ -269,6 +269,7 @@ type RecurrenceEditableView interface {
type RecurrencePartNavigable interface {
MoveRecurrencePartLeft() bool
MoveRecurrencePartRight() bool
IsRecurrenceValueFocused() bool
}
// ViewInfoProvider is a view that provides its name and description for the header info section

View file

@ -24,6 +24,7 @@ func NewPluginController(
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
) *PluginController {
pc := &PluginController{
pluginBase: pluginBase{
@ -31,6 +32,7 @@ func NewPluginController(
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
statusline: statusline,
registry: PluginViewActions(),
},
}

View file

@ -17,6 +17,7 @@ type pluginBase struct {
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
navController *NavigationController
statusline *model.StatuslineConfig
registry *ActionRegistry
}

View file

@ -53,7 +53,7 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 1)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedLane() != 1 {
@ -94,7 +94,7 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 0)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedLane() != 1 {
@ -126,7 +126,7 @@ func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 2)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedLane() != 1 {
@ -183,7 +183,7 @@ func TestLaneSwitchSelectsTopOfViewport(t *testing.T) {
// Simulate that lane 1 has been scrolled to offset 3
pluginConfig.SetScrollOffsetForLane(1, 3)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
// Navigate right to lane 1
pc.HandleAction(ActionNavRight)
@ -242,7 +242,7 @@ func TestLaneSwitchClampsScrollOffsetToTaskCount(t *testing.T) {
// Set a stale scroll offset that exceeds the task count
pluginConfig.SetScrollOffsetForLane(1, 10)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
// Navigate left (to empty lane, will skip to... well, nowhere)
// Then try to go right from a fresh setup

View file

@ -20,6 +20,7 @@ import (
type TaskController struct {
taskStore store.Store
navController *NavigationController
statusline *model.StatuslineConfig
currentTaskID string
draftTask *taskpkg.Task // For new task creation only
editingTask *taskpkg.Task // In-memory copy being edited (existing tasks)
@ -34,10 +35,12 @@ type TaskController struct {
func NewTaskController(
taskStore store.Store,
navController *NavigationController,
statusline *model.StatuslineConfig,
) *TaskController {
return &TaskController{
taskStore: taskStore,
navController: navController,
statusline: statusline,
registry: TaskDetailViewActions(),
editRegistry: TaskEditViewActions(),
}

View file

@ -27,7 +27,7 @@ func init() {
func TestTaskController_SetDraft(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
draft := newTestTask()
tc.SetDraft(draft)
@ -44,7 +44,7 @@ func TestTaskController_SetDraft(t *testing.T) {
func TestTaskController_ClearDraft(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tc.SetDraft(newTestTask())
tc.ClearDraft()
@ -57,7 +57,7 @@ func TestTaskController_ClearDraft(t *testing.T) {
func TestTaskController_StartEditSession(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
// Create a task in the store
original := newTestTask()
@ -87,7 +87,7 @@ func TestTaskController_StartEditSession(t *testing.T) {
func TestTaskController_StartEditSession_NonExistent(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
editingTask := tc.StartEditSession("NONEXISTENT")
@ -99,7 +99,7 @@ func TestTaskController_StartEditSession_NonExistent(t *testing.T) {
func TestTaskController_CancelEditSession(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
// Start an edit session
original := newTestTask()
@ -183,7 +183,7 @@ func TestTaskController_SaveStatus(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -259,7 +259,7 @@ func TestTaskController_SaveType(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -342,7 +342,7 @@ func TestTaskController_SavePriority(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -418,7 +418,7 @@ func TestTaskController_SaveAssignee(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -493,7 +493,7 @@ func TestTaskController_SavePoints(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -572,7 +572,7 @@ func TestTaskController_SaveTitle(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -639,7 +639,7 @@ func TestTaskController_SaveDescription(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -669,7 +669,7 @@ func TestTaskController_SaveDescription(t *testing.T) {
func TestTaskController_CommitEditSession_Draft(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
draft := newTestTaskWithID()
draft.Title = "Draft Title"
@ -699,7 +699,7 @@ func TestTaskController_CommitEditSession_Draft(t *testing.T) {
func TestTaskController_CommitEditSession_DraftValidationFailure(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
draft := newTestTaskWithID()
draft.Title = "" // Invalid - empty title
@ -721,7 +721,7 @@ func TestTaskController_CommitEditSession_DraftValidationFailure(t *testing.T) {
func TestTaskController_CommitEditSession_Existing(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
// Create original task
original := newTestTask()
@ -755,7 +755,7 @@ func TestTaskController_CommitEditSession_Existing(t *testing.T) {
func TestTaskController_CommitEditSession_NoActiveSession(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
err := tc.CommitEditSession()
if err != nil {
@ -768,7 +768,7 @@ func TestTaskController_CommitEditSession_NoActiveSession(t *testing.T) {
func TestTaskController_GetCurrentTask(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
// Create task
original := newTestTask()
@ -790,7 +790,7 @@ func TestTaskController_GetCurrentTask(t *testing.T) {
func TestTaskController_GetCurrentTask_Empty(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
current := tc.GetCurrentTask()
if current != nil {
@ -801,7 +801,7 @@ func TestTaskController_GetCurrentTask_Empty(t *testing.T) {
func TestTaskController_GetCurrentTask_NonExistent(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tc.SetCurrentTask("NONEXISTENT")
@ -816,7 +816,7 @@ func TestTaskController_GetCurrentTask_NonExistent(t *testing.T) {
func TestTaskController_GetActionRegistry(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
registry := tc.GetActionRegistry()
if registry == nil {
@ -833,7 +833,7 @@ func TestTaskController_GetActionRegistry(t *testing.T) {
func TestTaskController_GetEditActionRegistry(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
registry := tc.GetEditActionRegistry()
if registry == nil {
@ -852,7 +852,7 @@ func TestTaskController_GetEditActionRegistry(t *testing.T) {
func TestTaskController_FocusedField(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
// Initially should be empty
if tc.GetFocusedField() != "" {
@ -927,7 +927,7 @@ func TestTaskController_SaveDue(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)
@ -1029,7 +1029,7 @@ func TestTaskController_SaveTags(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
taskStore := store.NewInMemoryStore()
navController := newMockNavigationController()
tc := NewTaskController(taskStore, navController)
tc := NewTaskController(taskStore, navController, nil)
tt.setupTask(tc, taskStore)

View file

@ -65,12 +65,18 @@ func (c *TaskEditCoordinator) HandleKey(activeView View, event *tcell.EventKey)
return c.FocusPrevField(activeView)
case tcell.KeyLeft:
if nav, ok := activeView.(RecurrencePartNavigable); ok {
return nav.MoveRecurrencePartLeft()
if nav.MoveRecurrencePartLeft() {
c.updateFieldHint(activeView)
return true
}
}
return false
case tcell.KeyRight:
if nav, ok := activeView.(RecurrencePartNavigable); ok {
return nav.MoveRecurrencePartRight()
if nav.MoveRecurrencePartRight() {
c.updateFieldHint(activeView)
return true
}
}
return false
case tcell.KeyEscape:
@ -89,7 +95,9 @@ func (c *TaskEditCoordinator) FocusNextField(activeView View) bool {
if !ok {
return false
}
return fieldFocusable.FocusNextField()
result := fieldFocusable.FocusNextField()
c.updateFieldHint(activeView)
return result
}
func (c *TaskEditCoordinator) FocusPrevField(activeView View) bool {
@ -97,7 +105,9 @@ func (c *TaskEditCoordinator) FocusPrevField(activeView View) bool {
if !ok {
return false
}
return fieldFocusable.FocusPrevField()
result := fieldFocusable.FocusPrevField()
c.updateFieldHint(activeView)
return result
}
func (c *TaskEditCoordinator) CycleFieldValueUp(activeView View) bool {
@ -118,6 +128,7 @@ func (c *TaskEditCoordinator) CommitAndClose(activeView View) bool {
if !c.commit(activeView) {
return false
}
c.clearFieldHint()
c.navController.HandleBack()
return true
}
@ -142,6 +153,7 @@ func (c *TaskEditCoordinator) CancelAndClose() bool {
// Cancel edit session (discards changes) and clear any draft.
c.taskController.CancelEditSession()
c.taskController.ClearDraft()
c.clearFieldHint()
c.navController.HandleBack()
return true
}
@ -171,6 +183,38 @@ func (c *TaskEditCoordinator) commit(activeView View) bool {
return true
}
// updateFieldHint shows or clears a statusline hint based on the focused field.
func (c *TaskEditCoordinator) updateFieldHint(activeView View) {
sl := c.taskController.statusline
if sl == nil {
return
}
fieldFocusable, ok := activeView.(FieldFocusableView)
if !ok {
return
}
switch fieldFocusable.GetFocusedField() {
case model.EditFieldStatus, model.EditFieldType, model.EditFieldPriority,
model.EditFieldAssignee, model.EditFieldPoints, model.EditFieldDue:
sl.SetMessage("↑↓ change value", model.MessageLevelInfo, false)
case model.EditFieldRecurrence:
if nav, ok := activeView.(RecurrencePartNavigable); ok && nav.IsRecurrenceValueFocused() {
sl.SetMessage("← edit pattern ↑↓ change value", model.MessageLevelInfo, false)
} else {
sl.SetMessage("↑↓ change pattern → edit value", model.MessageLevelInfo, false)
}
default:
sl.ClearMessage()
}
}
// clearFieldHint removes any statusline hint set by updateFieldHint.
func (c *TaskEditCoordinator) clearFieldHint() {
if sl := c.taskController.statusline; sl != nil {
sl.ClearMessage()
}
}
func (c *TaskEditCoordinator) prepareView(activeView View, focus model.EditField) {
app := c.navController.GetApp()

View file

@ -26,6 +26,28 @@ func (m *mockTaskEditView) GetEditedTitle() string { return m.title
func (m *mockTaskEditView) GetEditedDescription() string { return m.description }
func (m *mockTaskEditView) GetEditedTags() []string { return m.tags }
// mockFieldFocusableView implements FieldFocusableView + RecurrencePartNavigable for hint tests.
type mockFieldFocusableView struct {
mockTaskEditView
focusedField model.EditField
valueFocused bool
}
func (m *mockFieldFocusableView) SetFocusedField(field model.EditField) { m.focusedField = field }
func (m *mockFieldFocusableView) GetFocusedField() model.EditField { return m.focusedField }
func (m *mockFieldFocusableView) FocusNextField() bool {
m.focusedField = model.NextField(m.focusedField)
return true
}
func (m *mockFieldFocusableView) FocusPrevField() bool {
m.focusedField = model.PrevField(m.focusedField)
return true
}
func (m *mockFieldFocusableView) IsEditFieldFocused() bool { return true }
func (m *mockFieldFocusableView) MoveRecurrencePartLeft() bool { m.valueFocused = false; return true }
func (m *mockFieldFocusableView) MoveRecurrencePartRight() bool { m.valueFocused = true; return true }
func (m *mockFieldFocusableView) IsRecurrenceValueFocused() bool { return m.valueFocused }
// mockNonEditView implements only View (not TaskEditView).
type mockNonEditView struct{}
@ -68,7 +90,7 @@ func TestTaskEditCoordinator_HandleKey_TagsOnly_Backtab(t *testing.T) {
func TestTaskEditCoordinator_HandleKey_Escape(t *testing.T) {
taskStore := store.NewInMemoryStore()
nav := newMockNavigationController()
tc := NewTaskController(taskStore, nav)
tc := NewTaskController(taskStore, nav, nil)
tc.SetDraft(newTestTask())
coord := NewTaskEditCoordinator(nav, tc)
@ -89,7 +111,7 @@ func TestTaskEditCoordinator_HandleKey_Escape(t *testing.T) {
func TestTaskEditCoordinator_Commit_SavesTags(t *testing.T) {
taskStore := store.NewInMemoryStore()
nav := newMockNavigationController()
tc := NewTaskController(taskStore, nav)
tc := NewTaskController(taskStore, nav, nil)
draft := newTestTask()
draft.Title = "Tagged Task"
@ -119,7 +141,7 @@ func TestTaskEditCoordinator_Commit_SavesTags(t *testing.T) {
func TestTaskEditCoordinator_Commit_NonEditView(t *testing.T) {
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav)
tc := NewTaskController(store.NewInMemoryStore(), nav, nil)
coord := NewTaskEditCoordinator(nav, tc)
got := coord.commit(&mockNonEditView{})
@ -131,7 +153,7 @@ func TestTaskEditCoordinator_Commit_NonEditView(t *testing.T) {
func TestTaskEditCoordinator_Commit_ValidationFails(t *testing.T) {
taskStore := store.NewInMemoryStore()
nav := newMockNavigationController()
tc := NewTaskController(taskStore, nav)
tc := NewTaskController(taskStore, nav, nil)
tc.SetDraft(newTestTask())
coord := NewTaskEditCoordinator(nav, tc)
@ -149,3 +171,110 @@ func TestTaskEditCoordinator_Commit_ValidationFails(t *testing.T) {
t.Error("commit() should return false when IsValid() returns false")
}
}
// --- field hint tests ---
func TestTaskEditCoordinator_FieldHint_RecurrencePatternFocused(t *testing.T) {
sl := model.NewStatuslineConfig()
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
coord := NewTaskEditCoordinator(nav, tc)
view := &mockFieldFocusableView{focusedField: model.EditFieldRecurrence, valueFocused: false}
coord.updateFieldHint(view)
msg, level, _ := sl.GetMessage()
if msg != "\u2191\u2193 change pattern \u2192 edit value" {
t.Errorf("message = %q, want pattern hint", msg)
}
if level != model.MessageLevelInfo {
t.Errorf("level = %q, want %q", level, model.MessageLevelInfo)
}
}
func TestTaskEditCoordinator_FieldHint_RecurrenceValueFocused(t *testing.T) {
sl := model.NewStatuslineConfig()
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
coord := NewTaskEditCoordinator(nav, tc)
view := &mockFieldFocusableView{focusedField: model.EditFieldRecurrence, valueFocused: true}
coord.updateFieldHint(view)
msg, level, _ := sl.GetMessage()
if msg != "\u2190 edit pattern \u2191\u2193 change value" {
t.Errorf("message = %q, want value hint", msg)
}
if level != model.MessageLevelInfo {
t.Errorf("level = %q, want %q", level, model.MessageLevelInfo)
}
}
func TestTaskEditCoordinator_FieldHint_NonRecurrenceClearsHint(t *testing.T) {
sl := model.NewStatuslineConfig()
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
coord := NewTaskEditCoordinator(nav, tc)
// set a hint first
sl.SetMessage("some hint", model.MessageLevelInfo, false)
view := &mockFieldFocusableView{focusedField: model.EditFieldTitle}
coord.updateFieldHint(view)
msg, _, _ := sl.GetMessage()
if msg != "" {
t.Errorf("message = %q, want empty after focusing non-recurrence field", msg)
}
}
func TestTaskEditCoordinator_FieldHint_FocusNextSetsHint(t *testing.T) {
sl := model.NewStatuslineConfig()
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
coord := NewTaskEditCoordinator(nav, tc)
// Due is right before Recurrence in navigation order
view := &mockFieldFocusableView{focusedField: model.EditFieldDue}
coord.FocusNextField(view)
if view.focusedField != model.EditFieldRecurrence {
t.Fatalf("expected recurrence, got %q", view.focusedField)
}
msg, _, _ := sl.GetMessage()
if msg != "\u2191\u2193 change pattern \u2192 edit value" {
t.Errorf("message = %q, want hint after FocusNextField to recurrence", msg)
}
}
func TestTaskEditCoordinator_FieldHint_CancelClearsHint(t *testing.T) {
sl := model.NewStatuslineConfig()
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
coord := NewTaskEditCoordinator(nav, tc)
sl.SetMessage("some hint", model.MessageLevelInfo, false)
coord.CancelAndClose()
msg, _, _ := sl.GetMessage()
if msg != "" {
t.Errorf("message = %q, want empty after CancelAndClose", msg)
}
}
func TestTaskEditCoordinator_FieldHint_NilStatuslineNoOp(t *testing.T) {
nav := newMockNavigationController()
tc := NewTaskController(store.NewInMemoryStore(), nav, nil)
coord := NewTaskEditCoordinator(nav, tc)
view := &mockFieldFocusableView{focusedField: model.EditFieldRecurrence}
// should not panic
coord.updateFieldHint(view)
coord.clearFieldHint()
}

View file

@ -22,9 +22,10 @@ func BuildControllers(
taskStore store.Store,
plugins []plugin.Plugin,
pluginConfigs map[string]*model.PluginConfig,
statuslineConfig *model.StatuslineConfig,
) *Controllers {
navController := controller.NewNavigationController(app)
taskController := controller.NewTaskController(taskStore, navController)
taskController := controller.NewTaskController(taskStore, navController, statuslineConfig)
pluginControllers := make(map[string]controller.PluginControllerInterface)
for _, p := range plugins {
@ -34,11 +35,12 @@ func BuildControllers(
pluginConfigs[p.GetName()],
tp,
navController,
statuslineConfig,
)
continue
}
if dp, ok := p.(*plugin.DokiPlugin); ok {
pluginControllers[p.GetName()] = controller.NewDokiController(dp, navController)
pluginControllers[p.GetName()] = controller.NewDokiController(dp, navController, statuslineConfig)
}
}

View file

@ -118,6 +118,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
taskStore,
plugins,
pluginConfigs,
statuslineConfig,
)
// Phase 8: Input routing
@ -126,6 +127,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
controllers.Task,
controllers.Plugins,
taskStore,
statuslineConfig,
)
// Phase 9: View factory and layout

10
model/message_level.go Normal file
View file

@ -0,0 +1,10 @@
package model
// MessageLevel identifies the severity of a statusline message.
// Determines which color pair is used when rendering the message.
type MessageLevel string
const (
MessageLevelInfo MessageLevel = "info"
MessageLevelError MessageLevel = "error"
)

View file

@ -19,6 +19,7 @@ type StatuslineConfig struct {
// right section: transient message
message string
level MessageLevel
autoHide bool // if true, entire bar hides on next keypress
// visibility
@ -35,6 +36,7 @@ func NewStatuslineConfig() *StatuslineConfig {
leftStats: make(map[string]StatValue),
viewStats: make(map[string]StatValue),
rightViewStats: make(map[string]StatValue),
level: MessageLevelInfo,
visible: true,
listeners: make(map[int]func()),
nextListener: 1,
@ -120,23 +122,24 @@ func (sc *StatuslineConfig) SetViewStats(stats map[string]StatValue) {
sc.notifyListeners()
}
// SetMessage sets the right-section message. If autoHide is true, the entire
// statusline will hide on the next keypress (via DismissAutoHide).
// SetMessage sets the right-section message with a severity level. If autoHide
// is true, the entire statusline will hide on the next keypress (via DismissAutoHide).
// Setting a message makes the statusline visible.
func (sc *StatuslineConfig) SetMessage(text string, autoHide bool) {
func (sc *StatuslineConfig) SetMessage(text string, level MessageLevel, autoHide bool) {
sc.mu.Lock()
sc.message = text
sc.level = level
sc.autoHide = autoHide
sc.visible = true
sc.mu.Unlock()
sc.notifyListeners()
}
// GetMessage returns the current message and whether auto-hide is active
func (sc *StatuslineConfig) GetMessage() (string, bool) {
// GetMessage returns the current message, its level, and whether auto-hide is active
func (sc *StatuslineConfig) GetMessage() (string, MessageLevel, bool) {
sc.mu.RLock()
defer sc.mu.RUnlock()
return sc.message, sc.autoHide
return sc.message, sc.level, sc.autoHide
}
// ClearMessage clears the right-section message
@ -144,6 +147,7 @@ func (sc *StatuslineConfig) ClearMessage() {
sc.mu.Lock()
changed := sc.message != ""
sc.message = ""
sc.level = MessageLevelInfo
sc.autoHide = false
sc.mu.Unlock()
if changed {
@ -162,6 +166,7 @@ func (sc *StatuslineConfig) DismissAutoHide() bool {
}
sc.autoHide = false
sc.message = ""
sc.level = MessageLevelInfo
sc.visible = false
sc.mu.Unlock()
sc.notifyListeners()

View file

@ -20,10 +20,13 @@ func TestNewStatuslineConfig(t *testing.T) {
t.Error("initial GetLeftStats() should be empty")
}
msg, autoHide := sc.GetMessage()
msg, level, autoHide := sc.GetMessage()
if msg != "" {
t.Errorf("initial message = %q, want empty", msg)
}
if level != MessageLevelInfo {
t.Errorf("initial level = %q, want %q", level, MessageLevelInfo)
}
if autoHide {
t.Error("initial autoHide = true, want false")
}
@ -101,12 +104,15 @@ func TestStatuslineConfig_ClearViewStats(t *testing.T) {
func TestStatuslineConfig_Message(t *testing.T) {
sc := NewStatuslineConfig()
sc.SetMessage("error occurred", false)
sc.SetMessage("error occurred", MessageLevelError, false)
msg, autoHide := sc.GetMessage()
msg, level, autoHide := sc.GetMessage()
if msg != "error occurred" {
t.Errorf("message = %q, want %q", msg, "error occurred")
}
if level != MessageLevelError {
t.Errorf("level = %q, want %q", level, MessageLevelError)
}
if autoHide {
t.Error("autoHide = true, want false")
}
@ -115,9 +121,9 @@ func TestStatuslineConfig_Message(t *testing.T) {
func TestStatuslineConfig_MessageAutoHide(t *testing.T) {
sc := NewStatuslineConfig()
sc.SetMessage("transient error", true)
sc.SetMessage("transient error", MessageLevelInfo, true)
msg, autoHide := sc.GetMessage()
msg, _, autoHide := sc.GetMessage()
if msg != "transient error" {
t.Errorf("message = %q, want %q", msg, "transient error")
}
@ -134,7 +140,7 @@ func TestStatuslineConfig_MessageMakesVisible(t *testing.T) {
t.Fatal("precondition: should be invisible")
}
sc.SetMessage("hello", false)
sc.SetMessage("hello", MessageLevelInfo, false)
if !sc.IsVisible() {
t.Error("SetMessage should make statusline visible")
}
@ -143,13 +149,16 @@ func TestStatuslineConfig_MessageMakesVisible(t *testing.T) {
func TestStatuslineConfig_ClearMessage(t *testing.T) {
sc := NewStatuslineConfig()
sc.SetMessage("error", true)
sc.SetMessage("error", MessageLevelError, true)
sc.ClearMessage()
msg, autoHide := sc.GetMessage()
msg, level, autoHide := sc.GetMessage()
if msg != "" {
t.Errorf("message after clear = %q, want empty", msg)
}
if level != MessageLevelInfo {
t.Errorf("level after clear = %q, want %q", level, MessageLevelInfo)
}
if autoHide {
t.Error("autoHide should be false after ClearMessage")
}
@ -178,7 +187,7 @@ func TestStatuslineConfig_DismissAutoHide(t *testing.T) {
}
// set auto-hide message
sc.SetMessage("will hide", true)
sc.SetMessage("will hide", MessageLevelInfo, true)
if !sc.IsVisible() {
t.Fatal("precondition: should be visible after SetMessage")
}
@ -194,10 +203,13 @@ func TestStatuslineConfig_DismissAutoHide(t *testing.T) {
}
// message should be cleared
msg, autoHide := sc.GetMessage()
msg, level, autoHide := sc.GetMessage()
if msg != "" {
t.Errorf("message after dismiss = %q, want empty", msg)
}
if level != MessageLevelInfo {
t.Errorf("level after dismiss = %q, want %q", level, MessageLevelInfo)
}
if autoHide {
t.Error("autoHide should be false after dismiss")
}
@ -214,7 +226,7 @@ func TestStatuslineConfig_DismissAutoHideNotifiesListeners(t *testing.T) {
callCount := 0
sc.AddListener(func() { callCount++ })
sc.SetMessage("hide me", true)
sc.SetMessage("hide me", MessageLevelInfo, true)
callCount = 0 // reset after SetMessage notification
sc.DismissAutoHide()
@ -274,7 +286,7 @@ func TestStatuslineConfig_ListenerNotification(t *testing.T) {
{"SetLeftStat", func() { sc.SetLeftStat("k", "v", 1) }},
{"SetViewStat", func() { sc.SetViewStat("k", "v", 1) }},
{"ClearViewStats", func() { sc.ClearViewStats() }},
{"SetMessage", func() { sc.SetMessage("msg", false) }},
{"SetMessage", func() { sc.SetMessage("msg", MessageLevelInfo, false) }},
{"SetVisible", func() { sc.SetVisible(false); sc.SetVisible(true) }},
}
@ -368,7 +380,7 @@ func TestStatuslineConfig_ConcurrentAccess(t *testing.T) {
go func() {
for i := range 50 {
sc.SetMessage("msg", i%2 == 0)
sc.SetMessage("msg", MessageLevelInfo, i%2 == 0)
if i%5 == 0 {
sc.ClearMessage()
}
@ -398,7 +410,7 @@ func TestStatuslineConfig_ConcurrentAccess(t *testing.T) {
for range 100 {
_ = sc.GetLeftStats()
_ = sc.GetRightViewStats()
_, _ = sc.GetMessage()
_, _, _ = sc.GetMessage()
_ = sc.IsVisible()
}
done <- true

View file

@ -34,6 +34,7 @@ type TestApp struct {
PluginControllers map[string]controller.PluginControllerInterface
PluginDefs []plugin.Plugin
taskController *controller.TaskController
statuslineConfig *model.StatuslineConfig
headerConfig *model.HeaderConfig
layoutModel *model.LayoutModel
}
@ -82,8 +83,9 @@ func NewTestApp(t *testing.T) *TestApp {
app.SetScreen(screen)
// 5. Initialize Controller Layer
statuslineConfig := model.NewStatuslineConfig()
navController := controller.NewNavigationController(app)
taskController := controller.NewTaskController(taskStore, navController)
taskController := controller.NewTaskController(taskStore, navController, statuslineConfig)
// Empty plugin controllers map for tests (no plugins configured by default)
pluginControllers := make(map[string]controller.PluginControllerInterface)
inputRouter := controller.NewInputRouter(
@ -91,6 +93,7 @@ func NewTestApp(t *testing.T) *TestApp {
taskController,
pluginControllers,
taskStore,
statuslineConfig,
)
// 6. Initialize View Layer
@ -98,7 +101,6 @@ func NewTestApp(t *testing.T) *TestApp {
// 7. Create header widget, statusline, and RootLayout
headerWidget := header.NewHeaderWidget(headerConfig)
statuslineConfig := model.NewStatuslineConfig()
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
Header: headerWidget,
@ -153,17 +155,18 @@ func NewTestApp(t *testing.T) *TestApp {
// Note: Do NOT call app.Run() - we use app.Draw() + screen.Show() for synchronous testing
ta := &TestApp{
App: app,
Screen: screen,
RootLayout: rootLayout,
TaskStore: taskStore,
NavController: navController,
InputRouter: inputRouter,
TaskDir: taskDir,
t: t,
taskController: taskController,
headerConfig: headerConfig,
layoutModel: layoutModel,
App: app,
Screen: screen,
RootLayout: rootLayout,
TaskStore: taskStore,
NavController: navController,
InputRouter: inputRouter,
TaskDir: taskDir,
t: t,
taskController: taskController,
statuslineConfig: statuslineConfig,
headerConfig: headerConfig,
layoutModel: layoutModel,
}
// 11. Auto-load plugins since all views are now plugins
@ -328,11 +331,11 @@ func (ta *TestApp) LoadPlugins() error {
}
pc.SetLaneLayout(columns, widths)
pluginControllers[p.GetName()] = controller.NewPluginController(
ta.TaskStore, pc, tp, ta.NavController,
ta.TaskStore, pc, tp, ta.NavController, ta.statuslineConfig,
)
} else if dp, ok := p.(*plugin.DokiPlugin); ok {
pluginControllers[p.GetName()] = controller.NewDokiController(
dp, ta.NavController,
dp, ta.NavController, ta.statuslineConfig,
)
}
}
@ -361,6 +364,7 @@ func (ta *TestApp) LoadPlugins() error {
ta.taskController,
pluginControllers,
ta.TaskStore,
ta.statuslineConfig,
)
// Update global input capture to handle plugin switching keys

View file

@ -93,8 +93,8 @@ func (sw *StatuslineWidget) render(width int) {
rightStatsLen := segmentsVisibleLen(rightSegments)
// message (between left and right)
msg, _ := sw.config.GetMessage()
msgRendered := sw.renderMessage(msg, colors)
msg, level, _ := sw.config.GetMessage()
msgRendered := sw.renderMessage(msg, level, colors)
msgLen := visibleLen(msg)
// pad to fill the width with the fill background color
@ -104,7 +104,7 @@ func (sw *StatuslineWidget) render(width int) {
}
padding := fmt.Sprintf("[-:%s]%s[-:-]", colors.StatuslineFillBg, strings.Repeat(" ", padLen))
sw.SetText(left + padding + msgRendered + rightStats)
sw.SetText(left + msgRendered + padding + rightStats)
}
// renderLeftSegments builds the powerline left section.
@ -173,12 +173,23 @@ func segmentColors(index int, colors *config.ColorConfig) (string, string) {
return colors.StatuslineBg, colors.StatuslineFg
}
// renderMessage builds the message section
func (sw *StatuslineWidget) renderMessage(msg string, colors *config.ColorConfig) string {
// renderMessage builds the message section with level-specific colors
func (sw *StatuslineWidget) renderMessage(msg string, level model.MessageLevel, colors *config.ColorConfig) string {
if msg == "" {
return ""
}
return fmt.Sprintf("[%s:%s] %s [-:-]", colors.StatuslineMessageFg, colors.StatuslineMessageBg, msg)
fg, bg := messageColors(level, colors)
return fmt.Sprintf("[%s:%s] %s [-:-]", fg, bg, msg)
}
// messageColors returns (fg, bg) for the given message level
func messageColors(level model.MessageLevel, colors *config.ColorConfig) (string, string) {
switch level {
case model.MessageLevelError:
return colors.StatuslineErrorFg, colors.StatuslineErrorBg
default:
return colors.StatuslineInfoFg, colors.StatuslineInfoBg
}
}
// sortedSegments converts a stat map to a sorted slice of segments

View file

@ -11,13 +11,15 @@ import (
func testColors() *config.ColorConfig {
return &config.ColorConfig{
StatuslineBg: "#normal_bg",
StatuslineFg: "#normal_fg",
StatuslineAccentBg: "#accent_bg",
StatuslineAccentFg: "#accent_fg",
StatuslineMessageFg: "#msg_fg",
StatuslineMessageBg: "#msg_bg",
StatuslineFillBg: "#fill_bg",
StatuslineBg: "#normal_bg",
StatuslineFg: "#normal_fg",
StatuslineAccentBg: "#accent_bg",
StatuslineAccentFg: "#accent_fg",
StatuslineInfoFg: "#info_fg",
StatuslineInfoBg: "#info_bg",
StatuslineErrorFg: "#error_fg",
StatuslineErrorBg: "#error_bg",
StatuslineFillBg: "#fill_bg",
}
}
@ -217,19 +219,32 @@ func TestRenderRightSegments_threeSegments(t *testing.T) {
func TestRenderMessage_empty(t *testing.T) {
sw := newTestWidget()
result := sw.renderMessage("", testColors())
result := sw.renderMessage("", model.MessageLevelInfo, testColors())
if result != "" {
t.Errorf("got %q, want empty", result)
}
}
func TestRenderMessage_nonEmpty(t *testing.T) {
func TestRenderMessage_info(t *testing.T) {
sw := newTestWidget()
colors := testColors()
result := sw.renderMessage("error occurred", colors)
result := sw.renderMessage("task saved", model.MessageLevelInfo, colors)
if !strings.Contains(result, "[#msg_fg:#msg_bg] error occurred ") {
t.Errorf("message should use message colors, got %q", result)
if !strings.Contains(result, "[#info_fg:#info_bg] task saved ") {
t.Errorf("info message should use info colors, got %q", result)
}
if !strings.HasSuffix(result, "[-:-]") {
t.Errorf("should end with color reset, got %q", result)
}
}
func TestRenderMessage_error(t *testing.T) {
sw := newTestWidget()
colors := testColors()
result := sw.renderMessage("validation failed", model.MessageLevelError, colors)
if !strings.Contains(result, "[#error_fg:#error_bg] validation failed ") {
t.Errorf("error message should use error colors, got %q", result)
}
if !strings.HasSuffix(result, "[-:-]") {
t.Errorf("should end with color reset, got %q", result)

View file

@ -239,6 +239,14 @@ func (ev *TaskEditView) MoveRecurrencePartRight() bool {
return true
}
// IsRecurrenceValueFocused returns true when the recurrence field's value part is active.
func (ev *TaskEditView) IsRecurrenceValueFocused() bool {
if ev.focusedField != model.EditFieldRecurrence || ev.recurrenceInput == nil {
return false
}
return ev.recurrenceInput.IsValueFocused()
}
// UpdateHeaderForField updates the registry with field-specific actions
func (ev *TaskEditView) UpdateHeaderForField(field model.EditField) {
if ev.descOnly {