mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
action palette phase 1
This commit is contained in:
parent
80e7f1e510
commit
7b7136ce5e
21 changed files with 748 additions and 387 deletions
|
|
@ -92,6 +92,7 @@ func InitPluginActions(plugins []PluginInfo) {
|
|||
if p.Key == 0 && p.Rune == 0 {
|
||||
continue // skip plugins without key binding
|
||||
}
|
||||
pluginViewID := model.MakePluginViewID(p.Name)
|
||||
pluginActionRegistry.Register(Action{
|
||||
ID: ActionID("plugin:" + p.Name),
|
||||
Key: p.Key,
|
||||
|
|
@ -99,6 +100,12 @@ func InitPluginActions(plugins []PluginInfo) {
|
|||
Modifier: p.Modifier,
|
||||
Label: p.Name,
|
||||
ShowInHeader: true,
|
||||
IsEnabled: func(view *ViewEntry, _ View) bool {
|
||||
if view == nil {
|
||||
return true
|
||||
}
|
||||
return view.ViewID != pluginViewID
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -124,12 +131,14 @@ func GetPluginNameFromAction(id ActionID) string {
|
|||
|
||||
// Action represents a keyboard shortcut binding
|
||||
type Action struct {
|
||||
ID ActionID
|
||||
Key tcell.Key
|
||||
Rune rune // for letter keys (when Key == tcell.KeyRune)
|
||||
Label string
|
||||
Modifier tcell.ModMask
|
||||
ShowInHeader bool // whether to display in header bar
|
||||
ID ActionID
|
||||
Key tcell.Key
|
||||
Rune rune // for letter keys (when Key == tcell.KeyRune)
|
||||
Label string
|
||||
Modifier tcell.ModMask
|
||||
ShowInHeader bool // whether to display in header bar
|
||||
HideFromPalette bool // when true, action is excluded from the action palette (zero value = visible)
|
||||
IsEnabled func(view *ViewEntry, activeView View) bool
|
||||
}
|
||||
|
||||
// keyWithMod is a composite map key for special-key lookups, disambiguating
|
||||
|
|
@ -246,10 +255,19 @@ func (r *ActionRegistry) Match(event *tcell.EventKey) *Action {
|
|||
return nil
|
||||
}
|
||||
|
||||
// selectionRequired is an IsEnabled predicate that returns true only when
|
||||
// the active view has a non-empty selection (for use with plugin/deps actions).
|
||||
func selectionRequired(_ *ViewEntry, activeView View) bool {
|
||||
if sv, ok := activeView.(SelectableView); ok {
|
||||
return sv.GetSelectedID() != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultGlobalActions returns common actions available in all views
|
||||
func DefaultGlobalActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Hide Header", ShowInHeader: true})
|
||||
|
|
@ -267,6 +285,39 @@ func (r *ActionRegistry) GetHeaderActions() []Action {
|
|||
return result
|
||||
}
|
||||
|
||||
// GetPaletteActions returns palette-visible actions, deduped by ActionID (first registration wins).
|
||||
func (r *ActionRegistry) GetPaletteActions() []Action {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[ActionID]bool)
|
||||
var result []Action
|
||||
for _, a := range r.actions {
|
||||
if a.HideFromPalette {
|
||||
continue
|
||||
}
|
||||
if seen[a.ID] {
|
||||
continue
|
||||
}
|
||||
seen[a.ID] = true
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ContainsID returns true if the registry has an action with the given ID.
|
||||
func (r *ActionRegistry) ContainsID(id ActionID) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range r.actions {
|
||||
if a.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToHeaderActions converts the registry's header actions to model.HeaderAction slice.
|
||||
// This bridges the controller→model boundary without requiring callers to do the mapping.
|
||||
func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
|
||||
|
|
@ -293,15 +344,22 @@ func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction {
|
|||
func TaskDetailViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true})
|
||||
taskDetailEnabled := func(view *ViewEntry, _ View) bool {
|
||||
if view == nil || view.ViewID != model.TaskDetailViewID {
|
||||
return false
|
||||
}
|
||||
return model.DecodeTaskDetailParams(view.Params).TaskID != ""
|
||||
}
|
||||
|
||||
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
|
||||
if config.GetAIAgent() != "" {
|
||||
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true, IsEnabled: taskDetailEnabled})
|
||||
}
|
||||
|
||||
return r
|
||||
|
|
@ -321,8 +379,8 @@ func TaskEditViewActions() *ActionRegistry {
|
|||
r := NewActionRegistry()
|
||||
|
||||
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true, HideFromPalette: true})
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
@ -330,15 +388,15 @@ func TaskEditViewActions() *ActionRegistry {
|
|||
// CommonFieldNavigationActions returns actions available in all field editors (Tab/Shift-Tab navigation)
|
||||
func CommonFieldNavigationActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditTitleActions returns actions available when editing the title field
|
||||
func TaskEditTitleActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true})
|
||||
r.Merge(CommonFieldNavigationActions())
|
||||
return r
|
||||
|
|
@ -347,16 +405,16 @@ func TaskEditTitleActions() *ActionRegistry {
|
|||
// TaskEditStatusActions returns actions available when editing the status field
|
||||
func TaskEditStatusActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditTypeActions returns actions available when editing the type field
|
||||
func TaskEditTypeActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -370,8 +428,8 @@ func TaskEditPriorityActions() *ActionRegistry {
|
|||
// TaskEditAssigneeActions returns actions available when editing the assignee field
|
||||
func TaskEditAssigneeActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -385,19 +443,19 @@ func TaskEditPointsActions() *ActionRegistry {
|
|||
// TaskEditDueActions returns actions available when editing the due date field
|
||||
func TaskEditDueActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditRecurrenceActions returns actions available when editing the recurrence field
|
||||
func TaskEditRecurrenceActions() *ActionRegistry {
|
||||
r := CommonFieldNavigationActions()
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true, HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true, HideFromPalette: true})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -455,22 +513,22 @@ func GetActionsForField(field model.EditField) *ActionRegistry {
|
|||
func PluginViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
// navigation (not shown in header)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
|
||||
// navigation (not shown in header, hidden from palette)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
|
||||
|
||||
// plugin actions (shown in header)
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionToggleViewMode, Key: tcell.KeyRune, Rune: 'v', Label: "View mode", ShowInHeader: true})
|
||||
|
||||
|
|
@ -484,24 +542,24 @@ func PluginViewActions() *ActionRegistry {
|
|||
func DepsViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
// navigation (not shown in header)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
|
||||
// navigation (not shown in header, hidden from palette)
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true})
|
||||
|
||||
// move task between lanes (shown in header)
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
|
||||
// task actions
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
|
||||
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired})
|
||||
|
||||
// view mode and search
|
||||
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
|
||||
|
|
|
|||
|
|
@ -630,3 +630,163 @@ func TestMatchWithModifiers(t *testing.T) {
|
|||
t.Error("M (no modifier) should not match action with Alt-M binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_HidesMarkedActions(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", HideFromPalette: true})
|
||||
r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh"})
|
||||
|
||||
actions := r.GetPaletteActions()
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 palette actions, got %d", len(actions))
|
||||
}
|
||||
if actions[0].ID != ActionQuit {
|
||||
t.Errorf("expected first action to be Quit, got %v", actions[0].ID)
|
||||
}
|
||||
if actions[1].ID != ActionRefresh {
|
||||
t.Errorf("expected second action to be Refresh, got %v", actions[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_DedupsByActionID(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
|
||||
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑ (vim)"})
|
||||
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
|
||||
|
||||
actions := r.GetPaletteActions()
|
||||
if len(actions) != 2 {
|
||||
t.Fatalf("expected 2 deduped actions, got %d", len(actions))
|
||||
}
|
||||
if actions[0].ID != ActionNavUp {
|
||||
t.Errorf("expected first action to be NavUp, got %v", actions[0].ID)
|
||||
}
|
||||
if actions[0].Key != tcell.KeyUp {
|
||||
t.Error("dedup should keep first registered binding (arrow key), not vim key")
|
||||
}
|
||||
if actions[1].ID != ActionNavDown {
|
||||
t.Errorf("expected second action to be NavDown, got %v", actions[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPaletteActions_NilRegistry(t *testing.T) {
|
||||
var r *ActionRegistry
|
||||
if actions := r.GetPaletteActions(); actions != nil {
|
||||
t.Errorf("expected nil from nil registry, got %v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsID(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"})
|
||||
r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back"})
|
||||
|
||||
if !r.ContainsID(ActionQuit) {
|
||||
t.Error("expected ContainsID to find ActionQuit")
|
||||
}
|
||||
if !r.ContainsID(ActionBack) {
|
||||
t.Error("expected ContainsID to find ActionBack")
|
||||
}
|
||||
if r.ContainsID(ActionRefresh) {
|
||||
t.Error("expected ContainsID to not find ActionRefresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsID_NilRegistry(t *testing.T) {
|
||||
var r *ActionRegistry
|
||||
if r.ContainsID(ActionQuit) {
|
||||
t.Error("expected false from nil registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGlobalActions_BackHiddenFromPalette(t *testing.T) {
|
||||
registry := DefaultGlobalActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
for _, a := range paletteActions {
|
||||
if a.ID == ActionBack {
|
||||
t.Error("ActionBack should be hidden from palette")
|
||||
}
|
||||
}
|
||||
|
||||
if !registry.ContainsID(ActionBack) {
|
||||
t.Error("ActionBack should still be registered in global actions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginViewActions_NavHiddenFromPalette(t *testing.T) {
|
||||
registry := PluginViewActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
navIDs := map[ActionID]bool{
|
||||
ActionNavUp: true, ActionNavDown: true,
|
||||
ActionNavLeft: true, ActionNavRight: true,
|
||||
}
|
||||
for _, a := range paletteActions {
|
||||
if navIDs[a.ID] {
|
||||
t.Errorf("navigation action %v should be hidden from palette", a.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// semantic actions should remain visible
|
||||
found := map[ActionID]bool{}
|
||||
for _, a := range paletteActions {
|
||||
found[a.ID] = true
|
||||
}
|
||||
for _, want := range []ActionID{ActionOpenFromPlugin, ActionNewTask, ActionDeleteTask, ActionSearch} {
|
||||
if !found[want] {
|
||||
t.Errorf("expected palette-visible action %v", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEditActions_FieldLocalHidden_SaveVisible(t *testing.T) {
|
||||
registry := TaskEditTitleActions()
|
||||
paletteActions := registry.GetPaletteActions()
|
||||
|
||||
found := map[ActionID]bool{}
|
||||
for _, a := range paletteActions {
|
||||
found[a.ID] = true
|
||||
}
|
||||
|
||||
if !found[ActionSaveTask] {
|
||||
t.Error("Save should be palette-visible in task edit")
|
||||
}
|
||||
if found[ActionQuickSave] {
|
||||
t.Error("Quick Save should be hidden from palette")
|
||||
}
|
||||
if found[ActionNextField] {
|
||||
t.Error("Next field should be hidden from palette")
|
||||
}
|
||||
if found[ActionPrevField] {
|
||||
t.Error("Prev field should be hidden from palette")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitPluginActions_ActivePluginDisabled(t *testing.T) {
|
||||
InitPluginActions([]PluginInfo{
|
||||
{Name: "Kanban", Key: tcell.KeyRune, Rune: '1'},
|
||||
{Name: "Backlog", Key: tcell.KeyRune, Rune: '2'},
|
||||
})
|
||||
|
||||
registry := GetPluginActions()
|
||||
actions := registry.GetPaletteActions()
|
||||
|
||||
kanbanViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Kanban")}
|
||||
backlogViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Backlog")}
|
||||
|
||||
for _, a := range actions {
|
||||
if a.ID == "plugin:Kanban" {
|
||||
if a.IsEnabled == nil {
|
||||
t.Fatal("expected IsEnabled on plugin:Kanban")
|
||||
}
|
||||
if a.IsEnabled(kanbanViewEntry, nil) {
|
||||
t.Error("Kanban activation should be disabled when Kanban view is active")
|
||||
}
|
||||
if !a.IsEnabled(backlogViewEntry, nil) {
|
||||
t.Error("Kanban activation should be enabled when Backlog view is active")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,13 @@ type RecurrencePartNavigable interface {
|
|||
IsRecurrenceValueFocused() bool
|
||||
}
|
||||
|
||||
// ActionChangeNotifier is implemented by views that mutate their action registry
|
||||
// or live enablement/presentation state while the same view instance stays active.
|
||||
// RootLayout wires the handler and reruns syncViewContextFromView when fired.
|
||||
type ActionChangeNotifier interface {
|
||||
SetActionChangeHandler(handler func())
|
||||
}
|
||||
|
||||
// ViewInfoProvider is a view that provides its name and description for the header info section
|
||||
type ViewInfoProvider interface {
|
||||
GetViewName() string
|
||||
|
|
|
|||
|
|
@ -55,13 +55,17 @@ func NewPluginController(
|
|||
"plugin", pluginDef.Name, "key", string(a.Rune),
|
||||
"plugin_action", a.Label, "built_in_action", existing.Label)
|
||||
}
|
||||
pc.registry.Register(Action{
|
||||
action := Action{
|
||||
ID: pluginActionID(a.Rune),
|
||||
Key: tcell.KeyRune,
|
||||
Rune: a.Rune,
|
||||
Label: a.Label,
|
||||
ShowInHeader: true,
|
||||
})
|
||||
ShowInHeader: a.ShowInHeader,
|
||||
}
|
||||
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
|
||||
action.IsEnabled = selectionRequired
|
||||
}
|
||||
pc.registry.Register(action)
|
||||
}
|
||||
|
||||
return pc
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
return nil, err
|
||||
}
|
||||
InitPluginActionRegistry(plugins)
|
||||
syncHeaderPluginActions(headerConfig)
|
||||
viewContext := model.NewViewContext()
|
||||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
|
|
@ -163,11 +163,12 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
|
||||
})
|
||||
|
||||
headerWidget := header.NewHeaderWidget(headerConfig)
|
||||
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
|
||||
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
|
||||
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: headerConfig,
|
||||
ViewContext: viewContext,
|
||||
LayoutModel: layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: taskStore,
|
||||
|
|
@ -219,12 +220,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// syncHeaderPluginActions syncs plugin action shortcuts from the controller registry
|
||||
// into the header model.
|
||||
func syncHeaderPluginActions(headerConfig *model.HeaderConfig) {
|
||||
headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
|
||||
}
|
||||
|
||||
// wireOnViewActivated wires focus setters into views as they become active.
|
||||
func wireOnViewActivated(rootLayout *view.RootLayout, app *tview.Application) {
|
||||
rootLayout.SetOnViewActivated(func(v controller.View) {
|
||||
|
|
|
|||
|
|
@ -25,17 +25,13 @@ type StatValue struct {
|
|||
Priority int
|
||||
}
|
||||
|
||||
// HeaderConfig manages ALL header state - both content AND visibility.
|
||||
// HeaderConfig manages header visibility and burndown state.
|
||||
// View identity and actions are now in ViewContext.
|
||||
// Thread-safe model that notifies listeners when state changes.
|
||||
type HeaderConfig struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// Content state
|
||||
viewActions []HeaderAction
|
||||
pluginActions []HeaderAction
|
||||
viewName string // current view name for info section
|
||||
viewDescription string // current view description for info section
|
||||
burndown []store.BurndownPoint
|
||||
burndown []store.BurndownPoint
|
||||
|
||||
// Visibility state
|
||||
visible bool // current header visibility (may be overridden by fullscreen view)
|
||||
|
|
@ -56,59 +52,6 @@ func NewHeaderConfig() *HeaderConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// SetViewActions updates the view-specific header actions
|
||||
func (hc *HeaderConfig) SetViewActions(actions []HeaderAction) {
|
||||
hc.mu.Lock()
|
||||
hc.viewActions = actions
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetViewActions returns the current view's header actions
|
||||
func (hc *HeaderConfig) GetViewActions() []HeaderAction {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewActions
|
||||
}
|
||||
|
||||
// SetPluginActions updates the plugin navigation header actions
|
||||
func (hc *HeaderConfig) SetPluginActions(actions []HeaderAction) {
|
||||
hc.mu.Lock()
|
||||
hc.pluginActions = actions
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetPluginActions returns the plugin navigation header actions
|
||||
func (hc *HeaderConfig) GetPluginActions() []HeaderAction {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.pluginActions
|
||||
}
|
||||
|
||||
// SetViewInfo sets the current view name and description for the header info section
|
||||
func (hc *HeaderConfig) SetViewInfo(name, description string) {
|
||||
hc.mu.Lock()
|
||||
hc.viewName = name
|
||||
hc.viewDescription = description
|
||||
hc.mu.Unlock()
|
||||
hc.notifyListeners()
|
||||
}
|
||||
|
||||
// GetViewName returns the current view name
|
||||
func (hc *HeaderConfig) GetViewName() string {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewName
|
||||
}
|
||||
|
||||
// GetViewDescription returns the current view description
|
||||
func (hc *HeaderConfig) GetViewDescription() string {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
return hc.viewDescription
|
||||
}
|
||||
|
||||
// SetBurndown updates the burndown chart data
|
||||
func (hc *HeaderConfig) SetBurndown(points []store.BurndownPoint) {
|
||||
hc.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestNewHeaderConfig(t *testing.T) {
|
||||
|
|
@ -17,7 +15,6 @@ func TestNewHeaderConfig(t *testing.T) {
|
|||
t.Fatal("NewHeaderConfig() returned nil")
|
||||
}
|
||||
|
||||
// Initial visibility should be true
|
||||
if !hc.IsVisible() {
|
||||
t.Error("initial IsVisible() = false, want true")
|
||||
}
|
||||
|
|
@ -26,125 +23,11 @@ func TestNewHeaderConfig(t *testing.T) {
|
|||
t.Error("initial GetUserPreference() = false, want true")
|
||||
}
|
||||
|
||||
// Initial collections should be empty
|
||||
if len(hc.GetViewActions()) != 0 {
|
||||
t.Error("initial GetViewActions() should be empty")
|
||||
}
|
||||
|
||||
if len(hc.GetPluginActions()) != 0 {
|
||||
t.Error("initial GetPluginActions() should be empty")
|
||||
}
|
||||
|
||||
if hc.GetViewName() != "" {
|
||||
t.Error("initial GetViewName() should be empty")
|
||||
}
|
||||
|
||||
if hc.GetViewDescription() != "" {
|
||||
t.Error("initial GetViewDescription() should be empty")
|
||||
}
|
||||
|
||||
if len(hc.GetBurndown()) != 0 {
|
||||
t.Error("initial GetBurndown() should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewActions(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
actions := []HeaderAction{
|
||||
{
|
||||
ID: "action1",
|
||||
Key: tcell.KeyEnter,
|
||||
Label: "Enter",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
{
|
||||
ID: "action2",
|
||||
Key: tcell.KeyEscape,
|
||||
Label: "Esc",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
}
|
||||
|
||||
hc.SetViewActions(actions)
|
||||
|
||||
got := hc.GetViewActions()
|
||||
if len(got) != 2 {
|
||||
t.Errorf("len(GetViewActions()) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != "action1" {
|
||||
t.Errorf("ViewActions[0].ID = %q, want %q", got[0].ID, "action1")
|
||||
}
|
||||
|
||||
if got[1].ID != "action2" {
|
||||
t.Errorf("ViewActions[1].ID = %q, want %q", got[1].ID, "action2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_PluginActions(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
actions := []HeaderAction{
|
||||
{
|
||||
ID: "plugin1",
|
||||
Rune: '1',
|
||||
Label: "Plugin 1",
|
||||
ShowInHeader: true,
|
||||
},
|
||||
}
|
||||
|
||||
hc.SetPluginActions(actions)
|
||||
|
||||
got := hc.GetPluginActions()
|
||||
if len(got) != 1 {
|
||||
t.Errorf("len(GetPluginActions()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].ID != "plugin1" {
|
||||
t.Errorf("PluginActions[0].ID = %q, want %q", got[0].ID, "plugin1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewInfo(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
hc.SetViewInfo("Kanban", "Tasks moving through stages")
|
||||
|
||||
if got := hc.GetViewName(); got != "Kanban" {
|
||||
t.Errorf("GetViewName() = %q, want %q", got, "Kanban")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "Tasks moving through stages" {
|
||||
t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages")
|
||||
}
|
||||
|
||||
// update overwrites
|
||||
hc.SetViewInfo("Backlog", "Upcoming tasks")
|
||||
|
||||
if got := hc.GetViewName(); got != "Backlog" {
|
||||
t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "Upcoming tasks" {
|
||||
t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
hc.SetViewInfo("Task Detail", "")
|
||||
|
||||
if got := hc.GetViewName(); got != "Task Detail" {
|
||||
t.Errorf("GetViewName() = %q, want %q", got, "Task Detail")
|
||||
}
|
||||
|
||||
if got := hc.GetViewDescription(); got != "" {
|
||||
t.Errorf("GetViewDescription() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig_Burndown(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
|
|
@ -177,18 +60,15 @@ func TestHeaderConfig_Burndown(t *testing.T) {
|
|||
func TestHeaderConfig_Visibility(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Default should be visible
|
||||
if !hc.IsVisible() {
|
||||
t.Error("default IsVisible() = false, want true")
|
||||
}
|
||||
|
||||
// Set invisible
|
||||
hc.SetVisible(false)
|
||||
if hc.IsVisible() {
|
||||
t.Error("IsVisible() after SetVisible(false) = true, want false")
|
||||
}
|
||||
|
||||
// Set visible again
|
||||
hc.SetVisible(true)
|
||||
if !hc.IsVisible() {
|
||||
t.Error("IsVisible() after SetVisible(true) = false, want true")
|
||||
|
|
@ -198,12 +78,10 @@ func TestHeaderConfig_Visibility(t *testing.T) {
|
|||
func TestHeaderConfig_UserPreference(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Default preference should be true
|
||||
if !hc.GetUserPreference() {
|
||||
t.Error("default GetUserPreference() = false, want true")
|
||||
}
|
||||
|
||||
// Set preference
|
||||
hc.SetUserPreference(false)
|
||||
if hc.GetUserPreference() {
|
||||
t.Error("GetUserPreference() after SetUserPreference(false) = true, want false")
|
||||
|
|
@ -218,27 +96,21 @@ func TestHeaderConfig_UserPreference(t *testing.T) {
|
|||
func TestHeaderConfig_ToggleUserPreference(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Initial state
|
||||
initialPref := hc.GetUserPreference()
|
||||
initialVisible := hc.IsVisible()
|
||||
|
||||
// Toggle
|
||||
hc.ToggleUserPreference()
|
||||
|
||||
// Preference should be toggled
|
||||
if hc.GetUserPreference() == initialPref {
|
||||
t.Error("ToggleUserPreference() did not toggle preference")
|
||||
}
|
||||
|
||||
// Visible should match new preference
|
||||
if hc.IsVisible() != hc.GetUserPreference() {
|
||||
t.Error("visible state should match preference after toggle")
|
||||
}
|
||||
|
||||
// Toggle back
|
||||
hc.ToggleUserPreference()
|
||||
|
||||
// Should return to initial state
|
||||
if hc.GetUserPreference() != initialPref {
|
||||
t.Error("ToggleUserPreference() twice did not return to initial state")
|
||||
}
|
||||
|
|
@ -258,14 +130,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
|
|||
|
||||
listenerID := hc.AddListener(listener)
|
||||
|
||||
// Test various operations trigger notification
|
||||
tests := []struct {
|
||||
name string
|
||||
action func()
|
||||
}{
|
||||
{"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }},
|
||||
{"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }},
|
||||
{"SetViewInfo", func() { hc.SetViewInfo("Test", "desc") }},
|
||||
{"SetBurndown", func() { hc.SetBurndown([]store.BurndownPoint{{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}}) }},
|
||||
{"SetVisible", func() { hc.SetVisible(false); hc.SetVisible(true) }},
|
||||
{"ToggleUserPreference", func() { hc.ToggleUserPreference() }},
|
||||
|
|
@ -284,11 +152,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// Remove listener
|
||||
hc.RemoveListener(listenerID)
|
||||
|
||||
called = false
|
||||
hc.SetViewActions([]HeaderAction{{ID: "test2"}})
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -305,8 +172,7 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
|
|||
callCount++
|
||||
})
|
||||
|
||||
// Set to current value (no change)
|
||||
hc.SetVisible(true) // Already true by default
|
||||
hc.SetVisible(true) // already true by default
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -314,7 +180,6 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) {
|
|||
t.Error("listener called when visibility didn't change")
|
||||
}
|
||||
|
||||
// Now change it
|
||||
hc.SetVisible(false)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
|
@ -344,8 +209,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
id1 := hc.AddListener(listener1)
|
||||
id2 := hc.AddListener(listener2)
|
||||
|
||||
// Both should be notified
|
||||
hc.SetViewInfo("Test", "desc")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -355,10 +219,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Remove one
|
||||
hc.RemoveListener(id1)
|
||||
|
||||
hc.SetViewInfo("Test2", "desc2")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -368,10 +231,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) {
|
|||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Remove second
|
||||
hc.RemoveListener(id2)
|
||||
|
||||
hc.SetViewInfo("Test3", "desc3")
|
||||
hc.SetBurndown(nil)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
|
|
@ -387,24 +249,6 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
|
||||
done := make(chan bool)
|
||||
|
||||
// Writer goroutine - actions
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetViewActions([]HeaderAction{{ID: string(rune('a' + i%26))}})
|
||||
hc.SetPluginActions([]HeaderAction{{ID: string(rune('A' + i%26))}})
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Writer goroutine - view info
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc")
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Writer goroutine - visibility
|
||||
go func() {
|
||||
for i := range 50 {
|
||||
hc.SetVisible(i%2 == 0)
|
||||
|
|
@ -415,13 +259,15 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
done <- true
|
||||
}()
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
for range 50 {
|
||||
hc.SetBurndown(nil)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for range 100 {
|
||||
_ = hc.GetViewActions()
|
||||
_ = hc.GetPluginActions()
|
||||
_ = hc.GetViewName()
|
||||
_ = hc.GetViewDescription()
|
||||
_ = hc.GetBurndown()
|
||||
_ = hc.IsVisible()
|
||||
_ = hc.GetUserPreference()
|
||||
|
|
@ -429,30 +275,14 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) {
|
|||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for all
|
||||
for range 4 {
|
||||
for range 3 {
|
||||
<-done
|
||||
}
|
||||
|
||||
// If we get here without panic, test passes
|
||||
}
|
||||
|
||||
func TestHeaderConfig_EmptyCollections(t *testing.T) {
|
||||
func TestHeaderConfig_EmptyBurndown(t *testing.T) {
|
||||
hc := NewHeaderConfig()
|
||||
|
||||
// Set empty actions
|
||||
hc.SetViewActions([]HeaderAction{})
|
||||
if len(hc.GetViewActions()) != 0 {
|
||||
t.Error("GetViewActions() should return empty slice")
|
||||
}
|
||||
|
||||
// Set nil actions
|
||||
hc.SetPluginActions(nil)
|
||||
if len(hc.GetPluginActions()) != 0 {
|
||||
t.Error("GetPluginActions() with nil input should return empty slice")
|
||||
}
|
||||
|
||||
// Set empty burndown
|
||||
hc.SetBurndown([]store.BurndownPoint{})
|
||||
if len(hc.GetBurndown()) != 0 {
|
||||
t.Error("GetBurndown() should return empty slice")
|
||||
|
|
|
|||
96
model/view_context.go
Normal file
96
model/view_context.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package model
|
||||
|
||||
import "sync"
|
||||
|
||||
// ViewContext holds the active view's identity and action DTOs.
|
||||
// Subscribed to by HeaderWidget and ActionPalette. Written by RootLayout
|
||||
// via syncViewContextFromView when the active view or its actions change.
|
||||
type ViewContext struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
viewID ViewID
|
||||
viewName string
|
||||
viewDescription string
|
||||
viewActions []HeaderAction
|
||||
pluginActions []HeaderAction
|
||||
|
||||
listeners map[int]func()
|
||||
nextListener int
|
||||
}
|
||||
|
||||
func NewViewContext() *ViewContext {
|
||||
return &ViewContext{
|
||||
listeners: make(map[int]func()),
|
||||
nextListener: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFromView atomically updates all view-context fields and fires exactly one notification.
|
||||
func (vc *ViewContext) SetFromView(id ViewID, name, description string, viewActions, pluginActions []HeaderAction) {
|
||||
vc.mu.Lock()
|
||||
vc.viewID = id
|
||||
vc.viewName = name
|
||||
vc.viewDescription = description
|
||||
vc.viewActions = viewActions
|
||||
vc.pluginActions = pluginActions
|
||||
vc.mu.Unlock()
|
||||
vc.notifyListeners()
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewID() ViewID {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewID
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewName() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewName
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewDescription() string {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewDescription
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetViewActions() []HeaderAction {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.viewActions
|
||||
}
|
||||
|
||||
func (vc *ViewContext) GetPluginActions() []HeaderAction {
|
||||
vc.mu.RLock()
|
||||
defer vc.mu.RUnlock()
|
||||
return vc.pluginActions
|
||||
}
|
||||
|
||||
func (vc *ViewContext) AddListener(listener func()) int {
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
id := vc.nextListener
|
||||
vc.nextListener++
|
||||
vc.listeners[id] = listener
|
||||
return id
|
||||
}
|
||||
|
||||
func (vc *ViewContext) RemoveListener(id int) {
|
||||
vc.mu.Lock()
|
||||
defer vc.mu.Unlock()
|
||||
delete(vc.listeners, id)
|
||||
}
|
||||
|
||||
func (vc *ViewContext) notifyListeners() {
|
||||
vc.mu.RLock()
|
||||
listeners := make([]func(), 0, len(vc.listeners))
|
||||
for _, listener := range vc.listeners {
|
||||
listeners = append(listeners, listener)
|
||||
}
|
||||
vc.mu.RUnlock()
|
||||
|
||||
for _, listener := range listeners {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
93
model/view_context_test.go
Normal file
93
model/view_context_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestViewContext_SetFromView_SingleNotification(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var count int32
|
||||
vc.AddListener(func() { atomic.AddInt32(&count, 1) })
|
||||
|
||||
vc.SetFromView("plugin:Kanban", "Kanban", "desc", nil, nil)
|
||||
|
||||
if got := atomic.LoadInt32(&count); got != 1 {
|
||||
t.Errorf("expected exactly 1 notification, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_Getters(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
|
||||
viewActions := []HeaderAction{{ID: "edit", Label: "Edit"}}
|
||||
pluginActions := []HeaderAction{{ID: "plugin:Kanban", Label: "Kanban"}}
|
||||
|
||||
vc.SetFromView(TaskDetailViewID, "Tiki Detail", "desc", viewActions, pluginActions)
|
||||
|
||||
if vc.GetViewID() != TaskDetailViewID {
|
||||
t.Errorf("expected %v, got %v", TaskDetailViewID, vc.GetViewID())
|
||||
}
|
||||
if vc.GetViewName() != "Tiki Detail" {
|
||||
t.Errorf("expected 'Tiki Detail', got %q", vc.GetViewName())
|
||||
}
|
||||
if vc.GetViewDescription() != "desc" {
|
||||
t.Errorf("expected 'desc', got %q", vc.GetViewDescription())
|
||||
}
|
||||
if len(vc.GetViewActions()) != 1 || vc.GetViewActions()[0].ID != "edit" {
|
||||
t.Errorf("unexpected view actions: %v", vc.GetViewActions())
|
||||
}
|
||||
if len(vc.GetPluginActions()) != 1 || vc.GetPluginActions()[0].ID != "plugin:Kanban" {
|
||||
t.Errorf("unexpected plugin actions: %v", vc.GetPluginActions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_RemoveListener(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var count int32
|
||||
id := vc.AddListener(func() { atomic.AddInt32(&count, 1) })
|
||||
|
||||
vc.SetFromView("v1", "n", "d", nil, nil)
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Fatal("listener should have fired once")
|
||||
}
|
||||
|
||||
vc.RemoveListener(id)
|
||||
vc.SetFromView("v2", "n", "d", nil, nil)
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Error("listener should not fire after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_MultipleListeners(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
var a, b int32
|
||||
vc.AddListener(func() { atomic.AddInt32(&a, 1) })
|
||||
vc.AddListener(func() { atomic.AddInt32(&b, 1) })
|
||||
|
||||
vc.SetFromView("v1", "n", "d", nil, nil)
|
||||
|
||||
if atomic.LoadInt32(&a) != 1 {
|
||||
t.Errorf("listener A: expected 1, got %d", atomic.LoadInt32(&a))
|
||||
}
|
||||
if atomic.LoadInt32(&b) != 1 {
|
||||
t.Errorf("listener B: expected 1, got %d", atomic.LoadInt32(&b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewContext_ZeroValueIsEmpty(t *testing.T) {
|
||||
vc := NewViewContext()
|
||||
|
||||
if vc.GetViewID() != "" {
|
||||
t.Errorf("expected empty view ID, got %v", vc.GetViewID())
|
||||
}
|
||||
if vc.GetViewName() != "" {
|
||||
t.Errorf("expected empty view name, got %q", vc.GetViewName())
|
||||
}
|
||||
if vc.GetViewActions() != nil {
|
||||
t.Errorf("expected nil view actions, got %v", vc.GetViewActions())
|
||||
}
|
||||
if vc.GetPluginActions() != nil {
|
||||
t.Errorf("expected nil plugin actions, got %v", vc.GetPluginActions())
|
||||
}
|
||||
}
|
||||
|
|
@ -83,13 +83,15 @@ type PluginActionConfig struct {
|
|||
Key string `yaml:"key" mapstructure:"key"`
|
||||
Label string `yaml:"label" mapstructure:"label"`
|
||||
Action string `yaml:"action" mapstructure:"action"`
|
||||
Hot *bool `yaml:"hot,omitempty" mapstructure:"hot"`
|
||||
}
|
||||
|
||||
// PluginAction represents a parsed shortcut action bound to a key.
|
||||
type PluginAction struct {
|
||||
Rune rune
|
||||
Label string
|
||||
Action *ruki.ValidatedStatement
|
||||
Rune rune
|
||||
Label string
|
||||
Action *ruki.ValidatedStatement
|
||||
ShowInHeader bool
|
||||
}
|
||||
|
||||
// PluginLaneConfig represents a lane in YAML or config definitions.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
|
|||
return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)}
|
||||
}
|
||||
|
||||
if len(wf.Views.Plugins) == 0 {
|
||||
if len(wf.Views.Plugins) == 0 && len(wf.Views.Actions) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +55,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct
|
|||
for i := range wf.Views.Plugins {
|
||||
totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i])
|
||||
}
|
||||
totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions)
|
||||
if totalConverted > 0 {
|
||||
slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path)
|
||||
}
|
||||
|
|
@ -257,6 +258,27 @@ func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginActio
|
|||
}
|
||||
}
|
||||
|
||||
// convertLegacyGlobalActions converts legacy action expressions in global views.actions
|
||||
// to ruki format, matching the same conversion applied to per-plugin actions.
|
||||
func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int {
|
||||
count := 0
|
||||
for i := range actions {
|
||||
action := &actions[i]
|
||||
if action.Action != "" && !isRukiAction(action.Action) {
|
||||
newAction, err := transformer.ConvertAction(action.Action)
|
||||
if err != nil {
|
||||
slog.Warn("failed to convert legacy global action, passing through",
|
||||
"error", err, "action", action.Action, "key", action.Key)
|
||||
continue
|
||||
}
|
||||
slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key)
|
||||
action.Action = newAction
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// DefaultPlugin returns the first plugin marked as default, or the first plugin
|
||||
// in the list if none are marked. The caller must ensure plugins is non-empty.
|
||||
func DefaultPlugin(plugins []Plugin) Plugin {
|
||||
|
|
|
|||
|
|
@ -210,10 +210,15 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
|
||||
}
|
||||
showInHeader := true
|
||||
if cfg.Hot != nil {
|
||||
showInHeader = *cfg.Hot
|
||||
}
|
||||
actions = append(actions, PluginAction{
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: actionStmt,
|
||||
Rune: r,
|
||||
Label: cfg.Label,
|
||||
Action: actionStmt,
|
||||
ShowInHeader: showInHeader,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -761,3 +761,81 @@ foreground: "#00ff00"
|
|||
t.Errorf("Expected URL, got %q", dokiPlugin.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotDefault(t *testing.T) {
|
||||
parser := testParser()
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].ShowInHeader {
|
||||
t.Error("absent hot should default to ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotExplicitFalse(t *testing.T) {
|
||||
parser := testParser()
|
||||
hotFalse := false
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if actions[0].ShowInHeader {
|
||||
t.Error("hot: false should set ShowInHeader=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginActions_HotExplicitTrue(t *testing.T) {
|
||||
parser := testParser()
|
||||
hotTrue := true
|
||||
configs := []PluginActionConfig{
|
||||
{Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue},
|
||||
}
|
||||
actions, err := parsePluginActions(configs, parser)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !actions[0].ShowInHeader {
|
||||
t.Error("hot: true should set ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) {
|
||||
yamlData := []byte(`
|
||||
name: Test
|
||||
key: T
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: select where status = "backlog"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Board"
|
||||
action: update where id = id() set status = "ready"
|
||||
hot: false
|
||||
- key: "a"
|
||||
label: "Assign"
|
||||
action: update where id = id() set assignee = user()
|
||||
`)
|
||||
|
||||
p, err := parsePluginYAML(yamlData, "test.yaml", testSchema())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
tiki, ok := p.(*TikiPlugin)
|
||||
if !ok {
|
||||
t.Fatalf("expected TikiPlugin, got %T", p)
|
||||
}
|
||||
|
||||
if tiki.Actions[0].ShowInHeader {
|
||||
t.Error("action with hot: false should have ShowInHeader=false")
|
||||
}
|
||||
if !tiki.Actions[1].ShowInHeader {
|
||||
t.Error("action without hot should default to ShowInHeader=true")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type TestApp struct {
|
|||
taskController *controller.TaskController
|
||||
statuslineConfig *model.StatuslineConfig
|
||||
headerConfig *model.HeaderConfig
|
||||
viewContext *model.ViewContext
|
||||
layoutModel *model.LayoutModel
|
||||
}
|
||||
|
||||
|
|
@ -113,11 +114,13 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
viewFactory := view.NewViewFactory(taskStore)
|
||||
|
||||
// 7. Create header widget, statusline, and RootLayout
|
||||
headerWidget := header.NewHeaderWidget(headerConfig)
|
||||
viewContext := model.NewViewContext()
|
||||
headerWidget := header.NewHeaderWidget(headerConfig, viewContext)
|
||||
statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig)
|
||||
rootLayout := view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: headerConfig,
|
||||
ViewContext: viewContext,
|
||||
LayoutModel: layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: taskStore,
|
||||
|
|
@ -181,6 +184,7 @@ func NewTestApp(t *testing.T) *TestApp {
|
|||
taskController: taskController,
|
||||
statuslineConfig: statuslineConfig,
|
||||
headerConfig: headerConfig,
|
||||
viewContext: viewContext,
|
||||
layoutModel: layoutModel,
|
||||
}
|
||||
|
||||
|
|
@ -441,13 +445,14 @@ func (ta *TestApp) LoadPlugins() error {
|
|||
})
|
||||
|
||||
// Recreate RootLayout with new view factory
|
||||
headerWidget := header.NewHeaderWidget(ta.headerConfig)
|
||||
headerWidget := header.NewHeaderWidget(ta.headerConfig, ta.viewContext)
|
||||
ta.RootLayout.Cleanup()
|
||||
slConfig := model.NewStatuslineConfig()
|
||||
slWidget := statusline.NewStatuslineWidget(slConfig)
|
||||
ta.RootLayout = view.NewRootLayout(view.RootLayoutOpts{
|
||||
Header: headerWidget,
|
||||
HeaderConfig: ta.headerConfig,
|
||||
ViewContext: ta.viewContext,
|
||||
LayoutModel: ta.layoutModel,
|
||||
ViewFactory: viewFactory,
|
||||
TaskStore: ta.TaskStore,
|
||||
|
|
|
|||
|
|
@ -29,13 +29,14 @@ var customMd string
|
|||
|
||||
// DokiView renders a documentation plugin (navigable markdown)
|
||||
type DokiView struct {
|
||||
root *tview.Flex
|
||||
titleBar tview.Primitive
|
||||
md *markdown.NavigableMarkdown
|
||||
pluginDef *plugin.DokiPlugin
|
||||
registry *controller.ActionRegistry
|
||||
imageManager *navtview.ImageManager
|
||||
mermaidOpts *nav.MermaidOptions
|
||||
root *tview.Flex
|
||||
titleBar tview.Primitive
|
||||
md *markdown.NavigableMarkdown
|
||||
pluginDef *plugin.DokiPlugin
|
||||
registry *controller.ActionRegistry
|
||||
imageManager *navtview.ImageManager
|
||||
mermaidOpts *nav.MermaidOptions
|
||||
actionChangeHandler func()
|
||||
}
|
||||
|
||||
// NewDokiView creates a doki view
|
||||
|
|
@ -172,6 +173,10 @@ func (dv *DokiView) OnBlur() {
|
|||
}
|
||||
}
|
||||
|
||||
func (dv *DokiView) SetActionChangeHandler(handler func()) {
|
||||
dv.actionChangeHandler = handler
|
||||
}
|
||||
|
||||
// UpdateNavigationActions updates the registry to reflect current navigation state
|
||||
func (dv *DokiView) UpdateNavigationActions() {
|
||||
// Clear and rebuild the registry
|
||||
|
|
@ -212,6 +217,10 @@ func (dv *DokiView) UpdateNavigationActions() {
|
|||
ShowInHeader: true,
|
||||
})
|
||||
}
|
||||
|
||||
if dv.actionChangeHandler != nil {
|
||||
dv.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
|
||||
// internalDokiProvider implements navidown.ContentProvider for embedded/internal docs.
|
||||
|
|
|
|||
|
|
@ -55,20 +55,23 @@ type HeaderWidget struct {
|
|||
rightSpacer *tview.Box
|
||||
logo *tview.TextView
|
||||
|
||||
// Model reference
|
||||
headerConfig *model.HeaderConfig
|
||||
listenerID int
|
||||
// Model references
|
||||
headerConfig *model.HeaderConfig
|
||||
viewContext *model.ViewContext
|
||||
headerListenerID int
|
||||
viewContextListenerID int
|
||||
|
||||
// Layout state
|
||||
lastWidth int
|
||||
chartVisible bool
|
||||
}
|
||||
|
||||
// NewHeaderWidget creates a header widget that observes HeaderConfig for all state
|
||||
func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
|
||||
// NewHeaderWidget creates a header widget that observes HeaderConfig (burndown/visibility)
|
||||
// and ViewContext (view info + actions) for state.
|
||||
func NewHeaderWidget(headerConfig *model.HeaderConfig, viewContext *model.ViewContext) *HeaderWidget {
|
||||
info := NewInfoWidget()
|
||||
contextHelp := NewContextHelpWidget()
|
||||
chart := NewChartWidgetSimple() // No store dependency, data comes from HeaderConfig
|
||||
chart := NewChartWidgetSimple()
|
||||
|
||||
logo := tview.NewTextView()
|
||||
logo.SetDynamicColors(true)
|
||||
|
|
@ -87,29 +90,27 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget {
|
|||
chart: chart,
|
||||
rightSpacer: tview.NewBox(),
|
||||
headerConfig: headerConfig,
|
||||
viewContext: viewContext,
|
||||
}
|
||||
|
||||
// Subscribe to header config changes
|
||||
hw.listenerID = headerConfig.AddListener(hw.rebuild)
|
||||
hw.headerListenerID = headerConfig.AddListener(hw.rebuild)
|
||||
if viewContext != nil {
|
||||
hw.viewContextListenerID = viewContext.AddListener(hw.rebuild)
|
||||
}
|
||||
|
||||
hw.rebuild()
|
||||
hw.rebuildLayout(0)
|
||||
return hw
|
||||
}
|
||||
|
||||
// rebuild reads all data from HeaderConfig and updates display
|
||||
// rebuild reads data from ViewContext (view info + actions) and HeaderConfig (burndown)
|
||||
func (h *HeaderWidget) rebuild() {
|
||||
// Update view info from HeaderConfig
|
||||
h.info.SetViewInfo(h.headerConfig.GetViewName(), h.headerConfig.GetViewDescription())
|
||||
if h.viewContext != nil {
|
||||
h.info.SetViewInfo(h.viewContext.GetViewName(), h.viewContext.GetViewDescription())
|
||||
h.contextHelp.SetActionsFromModel(h.viewContext.GetViewActions(), h.viewContext.GetPluginActions())
|
||||
}
|
||||
|
||||
// Update burndown chart from HeaderConfig
|
||||
burndown := h.headerConfig.GetBurndown()
|
||||
h.chart.UpdateBurndown(burndown)
|
||||
|
||||
// Update context help from HeaderConfig
|
||||
viewActions := h.headerConfig.GetViewActions()
|
||||
pluginActions := h.headerConfig.GetPluginActions()
|
||||
h.contextHelp.SetActionsFromModel(viewActions, pluginActions)
|
||||
h.chart.UpdateBurndown(h.headerConfig.GetBurndown())
|
||||
|
||||
if h.lastWidth > 0 {
|
||||
h.rebuildLayout(h.lastWidth)
|
||||
|
|
@ -125,9 +126,12 @@ func (h *HeaderWidget) Draw(screen tcell.Screen) {
|
|||
h.Flex.Draw(screen)
|
||||
}
|
||||
|
||||
// Cleanup removes the listener from HeaderConfig
|
||||
// Cleanup removes all listeners
|
||||
func (h *HeaderWidget) Cleanup() {
|
||||
h.headerConfig.RemoveListener(h.listenerID)
|
||||
h.headerConfig.RemoveListener(h.headerListenerID)
|
||||
if h.viewContext != nil {
|
||||
h.viewContext.RemoveListener(h.viewContextListenerID)
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildLayout recalculates and rebuilds the flex layout based on terminal width.
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) {
|
|||
|
||||
func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
|
||||
headerConfig := model.NewHeaderConfig()
|
||||
h := NewHeaderWidget(headerConfig)
|
||||
h := NewHeaderWidget(headerConfig, model.NewViewContext())
|
||||
defer h.Cleanup()
|
||||
|
||||
h.contextHelp.width = 10
|
||||
|
|
@ -154,7 +154,7 @@ func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) {
|
|||
|
||||
func TestHeaderWidget_chartVisibilityThreshold_growsWithContextHelp(t *testing.T) {
|
||||
headerConfig := model.NewHeaderConfig()
|
||||
h := NewHeaderWidget(headerConfig)
|
||||
h := NewHeaderWidget(headerConfig, model.NewViewContext())
|
||||
defer h.Cleanup()
|
||||
|
||||
h.contextHelp.width = 60
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ type RootLayout struct {
|
|||
contentView controller.View
|
||||
lastParamsKey string
|
||||
|
||||
viewContext *model.ViewContext
|
||||
|
||||
headerListenerID int
|
||||
layoutListenerID int
|
||||
storeListenerID int
|
||||
|
|
@ -50,6 +52,7 @@ type RootLayout struct {
|
|||
type RootLayoutOpts struct {
|
||||
Header *header.HeaderWidget
|
||||
HeaderConfig *model.HeaderConfig
|
||||
ViewContext *model.ViewContext
|
||||
LayoutModel *model.LayoutModel
|
||||
ViewFactory controller.ViewFactory
|
||||
TaskStore store.Store
|
||||
|
|
@ -65,6 +68,7 @@ func NewRootLayout(opts RootLayoutOpts) *RootLayout {
|
|||
header: opts.Header,
|
||||
contentArea: tview.NewFlex().SetDirection(tview.FlexRow),
|
||||
headerConfig: opts.HeaderConfig,
|
||||
viewContext: opts.ViewContext,
|
||||
layoutModel: opts.LayoutModel,
|
||||
viewFactory: opts.ViewFactory,
|
||||
taskStore: opts.TaskStore,
|
||||
|
|
@ -139,20 +143,8 @@ func (rl *RootLayout) onLayoutChange() {
|
|||
rl.contentArea.AddItem(newView.GetPrimitive(), 0, 1, true)
|
||||
rl.contentView = newView
|
||||
|
||||
// Update header with new view's actions
|
||||
rl.headerConfig.SetViewActions(newView.GetActionRegistry().ToHeaderActions())
|
||||
|
||||
// Show or hide plugin navigation keys based on the view's declaration
|
||||
if np, ok := newView.(controller.NavigationProvider); ok && np.ShowNavigation() {
|
||||
rl.headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions())
|
||||
} else {
|
||||
rl.headerConfig.SetPluginActions(nil)
|
||||
}
|
||||
|
||||
// Update header info section with view name and description
|
||||
if vip, ok := newView.(controller.ViewInfoProvider); ok {
|
||||
rl.headerConfig.SetViewInfo(vip.GetViewName(), vip.GetViewDescription())
|
||||
}
|
||||
// Sync view context (writes to both ViewContext and HeaderConfig for header actions)
|
||||
rl.syncViewContextFromView(newView)
|
||||
|
||||
// Update statusline stats from the view
|
||||
rl.updateStatuslineViewStats(newView)
|
||||
|
|
@ -169,6 +161,13 @@ func (rl *RootLayout) onLayoutChange() {
|
|||
})
|
||||
}
|
||||
|
||||
// Wire up action change notifications (registry or enablement changes on the same view)
|
||||
if notifier, ok := newView.(controller.ActionChangeNotifier); ok {
|
||||
notifier.SetActionChangeHandler(func() {
|
||||
rl.syncViewContextFromView(newView)
|
||||
})
|
||||
}
|
||||
|
||||
// Focus the view
|
||||
newView.OnFocus()
|
||||
if newView.GetViewID() == model.TaskEditViewID {
|
||||
|
|
@ -199,6 +198,32 @@ func (rl *RootLayout) onLayoutChange() {
|
|||
rl.app.SetFocus(newView.GetPrimitive())
|
||||
}
|
||||
|
||||
// syncViewContextFromView computes view name/description, view actions, and plugin actions
|
||||
// from the active view, then writes to both ViewContext (single atomic update) and HeaderConfig
|
||||
// (for backward-compatible header display during the transition period).
|
||||
func (rl *RootLayout) syncViewContextFromView(v controller.View) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
|
||||
viewActions := v.GetActionRegistry().ToHeaderActions()
|
||||
|
||||
var pluginActions []model.HeaderAction
|
||||
if np, ok := v.(controller.NavigationProvider); ok && np.ShowNavigation() {
|
||||
pluginActions = controller.GetPluginActions().ToHeaderActions()
|
||||
}
|
||||
|
||||
var viewName, viewDesc string
|
||||
if vip, ok := v.(controller.ViewInfoProvider); ok {
|
||||
viewName = vip.GetViewName()
|
||||
viewDesc = vip.GetViewDescription()
|
||||
}
|
||||
|
||||
if rl.viewContext != nil {
|
||||
rl.viewContext.SetFromView(v.GetViewID(), viewName, viewDesc, viewActions, pluginActions)
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeHeaderVisibility computes header visibility based on view requirements and user preference
|
||||
func (rl *RootLayout) recomputeHeaderVisibility(v controller.View) {
|
||||
// Start from user preference
|
||||
|
|
|
|||
|
|
@ -256,11 +256,13 @@ func (ev *TaskEditView) IsRecurrenceValueFocused() bool {
|
|||
func (ev *TaskEditView) UpdateHeaderForField(field model.EditField) {
|
||||
if ev.descOnly {
|
||||
ev.registry = controller.DescOnlyEditActions()
|
||||
return
|
||||
}
|
||||
if ev.tagsOnly {
|
||||
} else if ev.tagsOnly {
|
||||
ev.registry = controller.TagsOnlyEditActions()
|
||||
return
|
||||
} else {
|
||||
ev.registry = controller.GetActionsForField(field)
|
||||
}
|
||||
|
||||
if ev.actionChangeHandler != nil {
|
||||
ev.actionChangeHandler()
|
||||
}
|
||||
ev.registry = controller.GetActionsForField(field)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,28 +45,34 @@ type TaskEditView struct {
|
|||
tagsEditing bool
|
||||
|
||||
// All callbacks
|
||||
onTitleSave func(string)
|
||||
onTitleChange func(string)
|
||||
onTitleCancel func()
|
||||
onDescSave func(string)
|
||||
onDescCancel func()
|
||||
onStatusSave func(string)
|
||||
onTypeSave func(string)
|
||||
onPrioritySave func(int)
|
||||
onAssigneeSave func(string)
|
||||
onPointsSave func(int)
|
||||
onDueSave func(string)
|
||||
onRecurrenceSave func(string)
|
||||
onTagsSave func(string)
|
||||
onTagsCancel func()
|
||||
onTitleSave func(string)
|
||||
onTitleChange func(string)
|
||||
onTitleCancel func()
|
||||
onDescSave func(string)
|
||||
onDescCancel func()
|
||||
onStatusSave func(string)
|
||||
onTypeSave func(string)
|
||||
onPrioritySave func(int)
|
||||
onAssigneeSave func(string)
|
||||
onPointsSave func(int)
|
||||
onDueSave func(string)
|
||||
onRecurrenceSave func(string)
|
||||
onTagsSave func(string)
|
||||
onTagsCancel func()
|
||||
actionChangeHandler func()
|
||||
}
|
||||
|
||||
// Compile-time interface checks
|
||||
var (
|
||||
_ controller.View = (*TaskEditView)(nil)
|
||||
_ controller.FocusSettable = (*TaskEditView)(nil)
|
||||
_ controller.View = (*TaskEditView)(nil)
|
||||
_ controller.FocusSettable = (*TaskEditView)(nil)
|
||||
_ controller.ActionChangeNotifier = (*TaskEditView)(nil)
|
||||
)
|
||||
|
||||
func (ev *TaskEditView) SetActionChangeHandler(handler func()) {
|
||||
ev.actionChangeHandler = handler
|
||||
}
|
||||
|
||||
// NewTaskEditView creates a task edit view
|
||||
func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager) *TaskEditView {
|
||||
ev := &TaskEditView{
|
||||
|
|
@ -75,7 +81,7 @@ func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtvie
|
|||
taskID: taskID,
|
||||
imageManager: imageManager,
|
||||
},
|
||||
registry: controller.TaskEditViewActions(),
|
||||
registry: controller.GetActionsForField(model.EditFieldTitle),
|
||||
viewID: model.TaskEditViewID,
|
||||
focusedField: model.EditFieldTitle,
|
||||
titleEditing: true,
|
||||
|
|
@ -142,7 +148,11 @@ func (ev *TaskEditView) SetDescOnly(descOnly bool) {
|
|||
ev.descOnly = descOnly
|
||||
if descOnly {
|
||||
ev.focusedField = model.EditFieldDescription
|
||||
ev.registry = controller.DescOnlyEditActions()
|
||||
ev.refresh()
|
||||
if ev.actionChangeHandler != nil {
|
||||
ev.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +166,11 @@ func (ev *TaskEditView) IsDescOnly() bool {
|
|||
func (ev *TaskEditView) SetTagsOnly(tagsOnly bool) {
|
||||
ev.tagsOnly = tagsOnly
|
||||
if tagsOnly {
|
||||
ev.registry = controller.TagsOnlyEditActions()
|
||||
ev.refresh()
|
||||
if ev.actionChangeHandler != nil {
|
||||
ev.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type PluginView struct {
|
|||
selectionListenerID int
|
||||
getLaneTasks func(lane int) []*task.Task // injected from controller
|
||||
ensureSelection func() bool // injected from controller
|
||||
actionChangeHandler func()
|
||||
}
|
||||
|
||||
// NewPluginView creates a plugin view
|
||||
|
|
@ -186,6 +187,14 @@ func (pv *PluginView) refresh() {
|
|||
// Sync scroll offset from view to model for later lane navigation
|
||||
pv.pluginConfig.SetScrollOffsetForLane(laneIdx, laneContainer.GetScrollOffset())
|
||||
}
|
||||
|
||||
if pv.actionChangeHandler != nil {
|
||||
pv.actionChangeHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func (pv *PluginView) SetActionChangeHandler(handler func()) {
|
||||
pv.actionChangeHandler = handler
|
||||
}
|
||||
|
||||
// GetPrimitive returns the root tview primitive
|
||||
|
|
|
|||
Loading…
Reference in a new issue